Custom Relighting with Blink Script

This is more of an exercise in learning the limitations of blink script than a functional tool, although it does have some useful alterations and allows for a lot of customisation. The end result is a way of using Point Position and Normals renders to relight an image with a choice of point or spotlight. Directional is easily achieved using these same methods but is not currently part of the script. It will likely be added in future updates.

RelightImage

Download from here. An example exr containing a point position and a normals pass is also included for easy practice.

Installing

This one isn’t a plugin like most, instead just place a Blink Script node and navigate it’s file knob to the script, then hit load. For those unfamiliar with blink, the scripts need to be compiled on the machine that is running it, meaning it cannot be moved from PC to PC like a regular node. You can see the code currently being loaded by clicking on the small arrow beneath the file knob. If you make any changes and need to apply them, simply hit Recompile.

 

Code Breakdown

Beginning with blink can be a bit confusing. It is effectively C++, but needs some specific declarations to work.

kernel CustomRelight : ImageComputationKernel<ePixelWise>
{
  Image<eRead, eAccessPoint, eEdgeNone> pos;
  Image<eRead, eAccessPoint, eEdgeNone> normal;
  Image<eWrite> dst;

Most importantly, you need to decide between from ePixelWise or eComponentWise. This is referred to as Granularity. Pixelwise will run once for each pixel, with access to all components at once (eg, R, G and B). Componentwise will run on each component and can only access the current one. In our case, we need to know the 3d position and normal vectors, meaning we’ll need access to all components at once, so we’ll go with pixelwise.

Next up, we need to know what inputs and outputs we are using. In our case, we need two inputs, one for position and one for normals, and a generic output. There are a few options for the inputs, the read access, how to access pixels, and how to treat out of bounds pixels. The read access can be eRead or eWrite, which is pretty self explanatory. If you plan on overwriting any information use the eWrite. It’s possible to access pixels outside of the current process by using a difference access pattern, however in our case we don’t need to assess any neighbours so can just use eAccessPoint. Finally, as we’re not accessing neighbours, we won’t be looking at any pixels outside of the frame, so we can use eEdgeNone. If you want to look at any of the other types, the Foundry’s blink reference is pretty comprehensive in it’s explanations.

Next up, we need to declare any public and private variables we intend to use. Public variables that are adjustable by the user are declared in “param:”, and private are declared in “local:”. These are then initialised seperately, using the void functions define() for the public, and init() for the private. Note, that define is called first, meaning init can be used to overwrite variables, or use the user defined ones in their calculations. The format for public variables is as follows:

 defineParam( spotlight, "Spotlight", false);

The first argument is the variable to initialise, the second is the label to be available to the user, and the third is the starting  value.

float3 flip(float3 f) {
  return float3( f.x, f.z, -f.y );
}

Custom functions can also be used, in this script I use a flip() function to switch up-axis. This is important depending on what program has rendered out the passes. Maya for example uses Y as it’s up-axis, whereas 3ds Max uses Z. As you probably know, Nuke uses Y for it’s up-axis, so if we’re using differing software then we need to swap the y and z components, then invert the new z-axis to be compatible (This is the equivalent of swapping the two directions). For those using my sample exr, it was rendered in Maya, so we don’t need to flip the axis.

 // Set position
 float3 position = float3( pos(0), pos(1), pos(2) );
 if ( swizzle ) { position = flip( position ); }
 
 // Get the spread of light from range/intensity settings
 float3 vecToLight = light_pos - position;
 float distance = length( vecToLight );
 float spread = ( ( range - distance ) / range ) * intensity;

 if ( spread <= 0.0f ) {
   for ( int component = 0; component < 3; component++ )
     dst( component ) = 0.0f;
   return;
 }

All of the actual work in Blink is done by the process() function, which is run once for every pixel/component depending on the type of Granularity we chose. A quick breakdown of what we need to achieve a relight is:

  1. Calculate how far the light would spread based on user settings.
  2. Calculate what light would hit the current pixel based on normals.
  3. Calculate any Specular highlights.
  4. Calculate any restrictions to lighting direction.
  5. Multiply the above 4 results together.

To begin with, we need to get the position of our point in 3d space. We do this by accessing the different components for the position input of our current pixel, using their index ( R=0, G=1, B=2, A=3 ). As an optimisation, we can simply return a blank pixel and exit out here if the alpha is blank, but for the current use I’m ignoring the alpha.

Once we’ve gotten the position, we can flip it if necessary. The word “swizzle” was coined for this use by our ever inventive Team Lead, and I’ve decided to keep it for this script. Next up, we get the vector pointing from the light to the current position using simple vector subtraction. The good thing about Blink is that it does also include some basic vector functions, such as length() and normalise() which we will take advantage of. We can calculate and fit the light spread between 0 and 1 by simply deducting the distance from the light (the length of our vector) from our user set range, then dividing by the range. The intensity can then be used to bring this value up or down. Now, you may be thinking this is an unusual way to approach lighting, but I find that for post effects, we’re generally not too worried about the accuracy of the light falloff, and more concerned with getting the nice result. For this reason, the relight will simply allow users to say how far they want the light to spread, and how strong they want it to be. Simple, right?

And that’s step 1! Of course, if this value is equal to or less than 0, then our output is just going to be black, so we can exit out of the function and save ourselves a lot of processing time. So, to set a pixels value in ePixelWise Granularity, we loop over each component and set the value. If you want to ensure you set every components value, you can use dst.kComps to get the total number of components. However, I only care about the RGB, so simply set the first 3 to 0.

 // Normalise and clamp the vector to the light
 vecToLight = vecToLight / distance;
 
 // Get Normals
 float3 normals = float3( normal(0), normal(1), normal(2) );
 if ( swizzle ) { normals = flip( normals ); }

 ...

 // Resulting light value
 float light = dot( vecToLight, normals );

If you’re following along the code, you’ll see I’m skipping a section which we’ll get back to in a second. What we’re currently interested in getting is how the light would actually hit the object based on it’s normals, which is actually surprisingly simple. We normalise the vector towards the light (we could have used normalise(), but as we already calculated the length this saves us an expensive square root calculation), and we get the normals and flip as necessary in the same way as we got the position.

So to calculate the light result, we simply get the dot product between the vector from the point to the light, and the normals vector. For those unfamiliar, or those who need a refresher (Which is me everytime I come back to vector math after any amount of time), the dot product is a simple multiplication of both vectors that gives a single float result, with 1 meaning both vectors point in the same direction, 0 means they’re at 90°, and -1 means they’re pointing in directly opposite directions. This immediately gives us a nice falloff to how much light is hitting the surface. We can of course play with this as desired, but for the basics this is all we need. We could now create a functioning point light by multiplying the light by the spread, and setting that as our output. But why stop there?

 // Specular highlights
 float specLight = 0.0f;
 if ( spec ) {
 float3 vecToCam = normalize(cam_pos - position);

 // Get the result of the light bouncing off the normal (angle of incidence)
 float3 bounce = 2.0f * dot( vecToLight, normals ) * normals - vecToLight;
 specLight = pow( max( dot( vecToCam, bounce ), 0.0f ), specFall ) * specInt;

 ...

 light += max( specLight, 0.0f ) * light;

Next up, we want to get any specular highlights. For those who are unsure, specular highlights are a lighting cheat to give bright spots to make an object look glossy or shiny. These occur wherever the light would bounce off the object and directly into the camera, meaning we will need to get the camera position (For those who are using my sample exr, the camera position for it was (9.068, 3.238, 2.775) ). Once we have that, we simply get the normalised vector pointing towards the camera from our point. We then calculate the direction the light would bounce off the surface of the object using the above formula and compare it to the camera vector. I won’t go into detail on the formula, but if you are curious, look up the angle of incidence. The simplified version, is we are mirroring our incoming light direction through the normal to determine it’s outgoing direction. Once we have this, we can adjust the falloff by giving it an exponent, and multiply to increase or decrease the strength.

This method will however cause specular highlights for surfaces that are not facing the light, so we multiply by the light result before adding it in. It’s also very important to clamp any values below 0 to prevent negative light.

Now we have a fully functioning point light with some handy control. However, wouldn’t it be nice to have other light types?

void init() {
  if (spotlight) {
    // Degrees to Radians converter
    float degToRad = atan2( 0, -1 ) / 180;

    // Convert to Radians (atan2(0, -1) is equal to pi)
    float3 light_rad;
    for ( int i = 0; i < 3; i++ )
      light_rad[ i ] = light_rot[ i ] * degToRad;

    light_dir = float3(
      sin(light_rad.y) * cos(light_rad.x),
      sin(light_rad.x),
      cos(light_rad.x) * cos(light_rad.y)
    );

  cone_limit = cos( cone_angle * degToRad );
  pen_limit = cos( ( cone_angle + penumbra ) * degToRad );
  }
}

To incorporate a spotlight effect, we need a few values, but some of these are set in stone, regardless of the current position. Rather than waste processing time calculating every pixel, we can set these up in the init() function, and even skip this function if the user hasn’t selected a spotlight. So what are these values we need? Most obviously, the spotlight needs to face in a specified direction. Next, a spotlight will have full brightness within a certain radius, which is called the cone angle. Sometimes, the light will be given a softer edge or falloff called the penumbra, which is also an angle.

First up, the light direction. This may seem simple at first thought, but can be a little tricky. Users are going to want to set the rotation of their light using Euler angles (0°, 45°, 60° etc…), but we need a directional vector. How do we convert between the two? Again, this is vector math I won’t go into here, but a quick explanation is that when looking at a 2d plane, the cos() of an angle is the x-axis, and the sin() is the y-axis (more info). When calculating rotations, programs will also have a particular rotation order, that is, the order in which they will do the angle for each axis. In Nuke’s case, the default is ZXY. This means we can work out the “X” and “Y” directions for each axis in turn using cos and sin, then multiply them to get our direction vector. You can read up on this if you’re intrigued, but otherwise you’ll just have to trust me that for a ZXY rotation order, we use the formula in the code above. There’s one last hitch though, the user is using Euler angles, whereas these functions are calculated in radians. A simple conversion, but it appears you can’t access math to get Pi within blink. However, atan2( 0, -1 ) gives almost the exact same result, so we can use that instead.

Phew, with that awkward math done, we can simply get the cos() of the cone angle and cone angle with the added penumbra. These are directly comparable to dot products of vectors, and can be used as the limiters before we begin to fade or cut off the light.

 if ( spotlight ) {
   float3 vecFromLight = normalize( position - light_pos );
   target_area = dot( vecFromLight, light_dir );
   target_area = ( target_area - pen_limit ) / ( cone_limit - pen_limit );
   target_area = clamp( target_area, 0.0f, 1.0f );

   if ( target_area == 0.0f ) {
     for ( int component = 0; component < 3; component++ )
       dst( component ) = 0.0f;
     return;
   }
 }

Last but not least, we have to compare the direction of the light to the current pixel against these limits. We then fit the values within the penumbra range between 0 and 1, and clamp the result. If we wanted to allow the spotlight to have brighter values in the center, we could just clamp the lower values to prevent negative results and leave values to extend beyond 1. Once again, if this results in 0, we can exit out of the process to save processing time.

Finally, we simply multiply all our results together to get the final output. You’ll notice that the target_area variable for the spotlight is given a default value of 1 so that it will not have any effect if the user chooses a different light type.

If you actually read all that, I’m very impressed at your dedication. Please do play around and see what else you can do with your custom relighting, and let me know of any fun results you might get. Enjoy!

Advertisement
Custom Relighting with Blink Script

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s