BlinkScript - Tutorial - Guillermo Algora - Visual Effects Compositor

Guillermo Algora
Go to content
BLINKSCRIPT - BULLET GUIDE
INTRODUCTION

Functionality:

BlinkScript runs a Blink kernel over every pixel in the output. This Blink kernel derives from ImageComputationKernel, which describes a kernel that is used to produce an output image.

When using an ImageComputationKernel, you have no control over the order in which pixels (for pixelwise kernels) or components (for componentwise kernels) in the output image will be filled in. One kernel call will be launched for each point in the output space. The idea is that all these kernel calls are independent of one another and can potentially be executed in parallel. On a GPU at least, thousands of these kernel calls might be run at the same time.

A Blink kernel is similar to a C++ class but with some special parameter types and functions. Through translation (the node's Recompile button) the code in the BlinkScript node can be turned into normal C++ or SIMD code for the CPU, or OpenCL for the GPU.

A BlinkScript node consists of two tabs (sections):

The BlinkScrip Tab: the kernel section, where you can write / insert the code, save it and load an external kernel. BlinkScript kernels use the .rpp file extension. We have to recompile the kernel everytime we wish to see the result of any changes.

The Kernel Parameters Tab: where the public parameters of your kernel are being exposed as knobs, as well as some of the intrinsic parameters of the BlinkScript node.

A 'whole' BlinkScript code is made of up to 6 sections:

kernel KernelName : ImageComputationKernel<ePixelWise> {  // Kernel name and Granularity.

Image<eRead, eAccessPoint, eEdgeClamped> input-name;  // Inputs and their 'access' specifications (Image Specifications).
Image<eWrite> output-name; // Output and its 'access' specifications (Image Specifications).

param: // The kernel public parameters, which are exposed as knobs.
local: // The kernel hidden parameters.

void define() { // Labels and default values for the public parameters (those declared in the 'param' section above).
 }

void init() {  //The init() function is run once before any calls to process(). Pre-calculations and local variables can be initialized here.
   }                   // Used as well for setRange() and setAxis() declarations.

void process() { // The process function is run at every pixel to produce the output.
 }
}; // The last curly bracket and semicolon closes the kernel statement.

Some basic C++ guidelines:

Variables are containers for storing data values (or in other words, reserved memory locations to store values). In C++, when declaring a new variable we have to specify its data type.

Data type, in programming, is a classification that specifies which type of value a variable has and what type of mathematical, relational or logical operations can be applied to it without causing an error. A string, for example, is a data type that is used to classify text and an integer is a data type used to classify whole numbers.
Some examples of general data types (we will see relevant data types for BlinkScript in the vector data types and 'param' section):

int: stores integers (whole numbers), without decimals, such as 123 or -123
float: stores floating point numbers with decimals such as 19.99 or -19.99, with 7 decimal digits precision.
! The suffix f should be added at the end of a float value (e.g. 19.99f ) or otherwise the compiler will intepret it as a double data type which has a decimal precision of 15 digits but uses more memory.
char: stores single characters, such as 'a' or 'B'. Char values are surrounded by single quotes
string: stores text, such as "Hello World". String values are surrounded by double quotes
bool: stores values with two states: true or false

Comparing different data types in C++ is prone to error.

The semicolon  ;   is used to tell the compiler where a statement ends. There are different types of statements:

int n = 1;  // Declaration statement. Declarations introduce a name into a program.
n = n + 1;  // Expression statement. These statements evaluate an expression for its side effects or for its return value.

The curly brackets { } are used to signify the start and end of a series of statements (for example, conditions and loops). Indentation is irrelevant in C++ as that is the function of the brackets, but it's kept for code readability.

for (int n = 1; n <= 3; n++) {
n = n + 1;
}

Comments are excluded from the code execution and declared with two consecutive slashes // .

OpenCL vector data types:

Vector data type variables can store up to four components:

int2: stores two integer values. ! Useful for a w, h size or 2D coordinates.
int3: stores three integer values. ! Useful for 3D coordinates, for example.
float3: stores three float values. ! Useful for storing RGB color information.
float4: stores four float values. ! Useful for storing RGBA color information.

The components in a vector can be addressed through the letters x, y, z and w, in the form of:

vector.x  accesses the first component.
vector.y  accesses the second component.
vector.z  accesses the third component.
vector.w  accesses the fourth component.

* substitute "vector" with the vector variable name.

Tips:

Print: unfortunately, debugging in Blinkscript is limited and tedious. However, there is at least a similar function to print() with which we can output some information in the Nuke's Terminal:

debugPrint(&variable-name, int x, int y)

variable-name -> variable to print out, preceded by &.
int x ->  x coordinate in the iteration space.
int y ->  y coordinate in the iteration space.

As the kernel is executed on each pixel of the output iteration space, we need to specify an x and y-coordinate to retrieve the result from.

Evaluating multiple components: we can use the following OpenCL functions to evaluate multiple components at once, for example, to compare the components in a vector type variable against a float value without having to address and compare each component independently.

any(): returns true if any component in the variable meet the condition.
all(): returns true if all component in the variable meet the condition.

Example:
float4 srcPixel = src(); // Save the src pixel values (red, green, blue, alpha) under a vector float4 variable named srcPixel.
if (any(srcPixel > 0) { // If any of the values in the srcPixel variable is greater than 0:
dst() = srcPixel; // Write the srcPixel values to the output.
}

Foundry's BlinkScript resources:

Guide to writing Blink Kernels:

Blink API Reference:

Let's inspect each section of a BlinkScript kernel code.


I. KERNEL NAME AND TYPE

kernel KernelName ImageComputationKernel<ePixelWise> {
//kernel<KernelName> ImageComputationKernel<Granularity>

1. Kernel Name:

The name we want to give to our kernel, which will also be exposed in the BlinkScript node label.

2. Kernel Granularity:

A kernel can be iterated in either a componentwise or pixelwise manner.

2.1 ePixelWise: the kernel processes the image one pixel at a time, and all component values (channels) can be read from and written to. Use it when operations to the channel must be simultaneous or interdependent.

  • The pixel (channel) values are suited to be stored under a float4 vector variable. // See more on how to address vector variables in the "Introduction" section on this page.

Example:
float4 srcPixel = src(); // Store the input values under a float4 vector variable named "srcPixel".
dst() = src() / srcPixel.w; // Divides the input RGBA values by the alpha component ("srcPixel.w") -> an Unpremult operation.

2.2 eComponentWise: the kernel processes the image one component (channel) at a time. Only the current component’s value can be accessed in any of the input images, or written to in the output image.

  • The current component is addressed in the void process().
  • Runs all the pixels (for the current component) before moving on to the next component. The order is red, green, blue, and alpha.
  • We can "know" the current component via the third component (pos.z) of the int3 vector variable "pos". // More on this subject in the void process() section."
II. INPUTS AND OUTPUT AND THEIR ACCESS PARAMETERS

We can add as many inputs as we want, but only have one output.

Image<eRead, eAccessPoint, eEdgeClamped> input_name; // Input image.
Image<eWrite, eAccessPoint, eEdgeClamped> output_name; // Output image.
// Image<Read Spec, Access Patern, Edge Method>  input/output_name;

Inputs and outputs can be named as you wish. Each will be addressed in the code by its name, and therefore we cannot have duplicates. Inputs will expose their names in the nodegraph.
// From now on in this guide, we will refer to the input as "src" and the output as "dst".

3. Read Spec:

3.1 eRead: read-only access. ! For inputs it can only be eRead as it is not possible to write information to an input.
3.2 eWrite: write-only access. ! For the output it cannot be eRead since it is not possible to read from the output without first writing to it.
3.3 eReadWrite: both read and write functionality. ! Only available for the output. Use when we want to write an intermediate result and then read from it.

4. Access Patern:

Defines how the kernel interacts with the iteration space. ! Current x, y position in the kernel iteration space is accessed in the process() section, in which we is possible to request a int2 "pos" vector.

4.1 eAccessPoint: only the information of the current pixel can be accessed.

  • The compiler does not require us to specify a pixel position to write to or read from, as it can only be the current one.

Example:
void process() {
dst() = src(); // Output is equal to input.
}

4.2 AccessRanged1D: only information of the current pixel and neighboring pixels within a specified range on a single axis (x or y) can be accessed.

  • The range and axis must be defined in the void init() section with:

src.setRange() defines the extent of the range around the current output position.
src.setAxis(eX or eY) defines the axis, which includes both the positive and negative direction (e.g. -x and +x).

Example:
void init() {
int radius = 2; // We create an integer variable named radius, with a current value of 2.
src.setRange(radius) // The range is set to the radius value.
src.setAxis(eX) // Set to the x axis.
}
void process() {
for (int i = -radius; i <= radius; i++) { // For loop (-2 to 2) uses the radius value. The loop value is stored under a variable named "i".
sum += src(i); // Variable "sum" sums the input value at position "i" (offset to the current position in the x axis  as defined in setAxis).
dst() = sum; // Output writting at current ouput position (no offset being called in the parenthesis) set to the "sum" value.
}
}

4.3 AccessRanged2D: same as eAccessRanged1D but in two axis simultaneously.

  • To specify an axis is no longer needed, only the range via setRange().
  • Use two parameters to set the minimum and maximum for both axes (x and y). First value defines the "x" axis and the second one the "y".

Example:
src.setRange(2, 3); // Set a range of x-2, x+2 and y-3, y+3.

  • Alternatively, four parameters can be used to specify the range for each "side" individually.

Example:
src.setRange(1, 2, 3, 4); // Set a range x-1, y-2, x+3, y+4.

4.4 eAccessRandom: possibility to access any pixel within the iteration space.

  • The compiler requires us to specify the x, y coordinates.

Example:
src(2, 2); // Access the input's value at the coordinates x=2 and y=2.   
dst(3, 3); // Write to the output at coordinates x=3 and y=3.

5. Edge Method:

Defines the behaviour while accessing information outside the image bounds.

5.1 eEdgeNone: is the default edge method and means that the kernel will not check whether your access position is outside the image.
! This can make image access faster if you know you are not going to access data outside the image bounds. However, it is not recommended if you intend to do so, as it means that the behaviour there will be undefined and it might well lead to a crash.

5.2 eEdgeClamped: edge pixels should be repeated for pixel accesses outsite the bounds of the input image.

5.3 eEdgeConstant: will return zero for all components outside the image bounds.
! If you end up accessing values outside the image bounds, keep in mind that those values will return 0, which can lead to unexpected results, for example, a blur function could output at the edges of the frame an averaged result with zeros outside the image.
III. KERNEL PARAMETERS:

6. Visibility:

Kernel variables can have two different levels of visibility. The two types serve as variables within the code but the ones under the param section are exposed as knobs for the user.

param:
// Accessible from outside the kernel. These parameters are exposed as knobs in the "Kernel Parameters Tab" of the BlinkScript node.

local:
// Accessible only from within the kernel. These local parameters are never exposed to the user.

7. Structure:

Both variable types are declared inside variable blocks with the same visibility as they would be in C++:
data-type variable-name;

Example:
param:
int radius; // An exposed integer parameter named radius, which will show up for the user as an integer input type knob.

local:
float multiplier; // A float parameter named multiplier, which will not be exposed to the user.

7.1 Data Type:

For the exposed variable, Nuke will generate the "right" knob in the "Kernel Parameters Tab" in accordance with the data type.

Example:
int -> integer input knob.
float -> float input knob.
int2 -> integer 2D size type knob.
float3 -> float 3D position type knob.
float4 -> float colour picker knob.

7.2 Name:

For the exposed variable, the variable name will also result as the knob's name.
! Do not confuse the knob's name with the label.
IV. DEFINE()

This section, which will be called just once when the kernel is first created, is used to define label and default value for the public parameters (the param), by calling the function:

defineParam(parameter-name, "label", default-value).

* parameter-name: as declared in the param section.
* "label": a string which will become the knob's label.
* default-value: the knob's default value.

Example:
void define() {
defineParam(integerKnob, "Integer", 5);
defineParam(floatingKnob, "Floating", 5.5f);
defineParam(sizeKnob, "Size", int2(2,1);
defineParam(position3DKnob, "Position3D", float3(2.2f, 1.1f, 3.3f));
defineParam(colorpickerKnob, "ColorPicker", float4(4.1f, 2.3f, 1.3f, 1.0f));
}
V. INIT()

  • The init() function is run once before any calls to process().
  • Local variables can be initialized here.
  • Used for pre-calculations and for setRange() and setAxis().
  • The init() function can also be used to set the value of local variables in the kernel to avoid repeating expensive computation at every point in the iteration space.

Example:
param:
int radius;
local:
int filterWidth;

void init() {
filterWidth = filterWidth * radius; // Local variable "filterWidth" is multiplied by the public variable "radius", before calls to the void process(),
}
VI. PROCESS()

It's where the action happens. Until this point, pre-calculations have just ran once, but now the process function is ran at every pixel of the iteration space to produce the output.

8. Position and component vector:

At void process() we can request a int2 position vector (current output position x, y coordinates) or a int3 vector for position and current component, the former only available in eComponentWise kernels.

8.1 The "pos" variable:
8.1.1 pos.x : x coordinate.
8.1.2 pos.y : y coordinate .
8.1.3 pos.z : component; 0 = red, 1 = green, 2 = blue, 3 = alpha.
// How to address components in the vector types was also seen in the Introduction chapter.

Example:
void process(int2 pos) { // int2 pos variable for x, y coordinates.
if (pos.x == 2 && pos.y == 2) {
dst() = src() / 2; // Divide input value by 2 and write the result to the output image.
}

Example:
void process(int3 pos) { // int3 pos variable for x, y coordinates and component.
if (pos.z == 1) { // If current component is the green channel (i.e. pos.z = 1)
dst() = src() / 2; // Divide input value by 2 and write the result to the output image.
}
Back to content