DrawIop: Generating Images from Scratch

The DrawIop class provides a further specialisation of PixelIop for classes which create a shape to use as a mask for drawing some pixels on top of the output buffer. DrawIop inherits the majority of its functionality from the base image processing class Iop, so if you haven’t already, please read 2d-architecture, before coming back to this section.

It is intended for use by generators of monochrome image data (but not by readers of data off disk, for that please see Writing Image Readers & Writers). For example, the plugins “Grid”, “Noise”, “Radial”, “Ramp”, “Rectangle” and “Text” that are provided with NUKE are DrawIops. Note that “Roto” and “RotoPaint” are not based off DrawIop, and thus do not inherit functionality such as ramp drawing.

  • Intended for use by monochrome image data generating Ops.
  • Adds colour and ramp functionality to the underlying alogorithm.

The DrawIop Class Specifics & Required Virtual Calls

DrawIops must implement the following function:

bool DrawIop::draw_engine(int y, int x, int r, float* buffer)

This should fill in buffer[x] to buffer[r] with a mask, which will be combined with the colour, the ramp, the mask input, in the DrawIop’s implementation of pixel_engine. Pixels should be set to 1 for full drawing, 0, for no drawing, and values in between for partial. Unlike other engine functions in the iop family, DrawIop returns a bool. If the buffer would be filled entirely with zeroes, you can return false, and then it can quickly copy the background input. Otherwise the function should return true.

DrawIop derived Ops can optionally choose to overide the default _validate() provided by DrawIop.

void DrawIop::_validate(bool for_real)

A DrawIop’s implementation of _validate() should call DrawIop::_validate() passing the same boolean for_real. If your Op’s draw size is limited to a smaller size than that of the format it should pass the new bounding box, ie DrawIop::_validate(for_real, x, y, r, t).

As with all Ops, DrawIops must additionally implement Description, and may optionally implement knobs() to provide user interface elements. There are a number of common controls offered by the DrawIop class, which need to be specifically called in your knobs function via input_knobs(Knob_Callback) and output_knobs(Knob_Callback, bool ramp=true). input_knobs provides a channel selector, replace incoming image checkbox, and an invert checkbox. output_knobs provides colourisation, and optionally ramp/gradient tools. These replace the NukeWrapper functionality which can be used by other Iop types.

Building the Rectangle Node

We’ll now take a look a couple of examples of DrawIops, building on what we saw in the PixelIop examples. We’ll only cover the salient functions, as opposed to break down the full listing as done in the previous examples, so do read the PixelIop section if you haven’t already. The full source code for both these operators is included in the NDK, so feel free to refer to the supporting code listed there.

First up, the rectangle node. As you might expect from the name, this draws a rectangle into the image, on top of the pre-existing image data. The rectangle has a softness control, plus colourisation and gradient/ramp options.

Interesting sections of the code include the header:

class RectangleIop : public DrawIop
{
  // bounding box of the final rectangle
  double x, y, r, t;
  // softness of the rectangle in horizontal and vertical direction
  double soft_x, soft_y;
public:
  void _validate(bool);
  bool draw_engine(int y, int x, int r, float* buffer);
  // make sure that all members are initialised
  RectangleIop(Node* node) : DrawIop(node)
  {
    x = y = r = t = 0;
    soft_x = soft_y = 0;
  }
  virtual void knobs(Knob_Callback);

  const char* Class() const { return CLASS; }
  const char* node_help() const { return HELP; }
  static const Iop::Description d;
};

The _validate function:

void RectangleIop::_validate(bool for_real)
{
  // don't bother calling the engine in degenerate cases
  if (x >= r || y >= t) {
    set_out_channels(Mask_None);
    copy_info();
    return;
  }
  set_out_channels(Mask_All);
  // make sure that we get enough pixels to build our rectangle
  DrawIop::_validate(for_real,
                     int(floor(x)),
                     int(floor(y)),
                     int(ceil(r)),
                     int(ceil(t)));
}

The knobs interface call:

void RectangleIop::knobs(Knob_Callback f)
{

  // allow DrawIop to add its own knobs to handle input
  input_knobs(f);

  // this knob provides controls for the position and size of our rectangle.
  // It also manages the rectangular handle box in all connected viewers.
  BBox_knob(f, &x, "area");

  // This knob manages user input for the rectangles edge softness
  WH_knob(f, &soft_x, "softness");

  // allow DrawIop to add its own knobs to handle output
  output_knobs(f);
}

And finally the draw_engine():

bool RectangleIop::draw_engine(int Y, int X, int R, float* buffer)
{
  // lets see if there is anything to draw at all
  if (Y < (int)floor(y))
    return false;
  if (Y >= (int)ceil(t))
    return false;
  // calculate the vertical multiplier:
    float m = 1;
  if ( soft_y >= 0.0 ) {
    // if this line is within the softned falloff, change the multiplier
    if (Y < y + soft_y) {
      float T = (Y + 1 - y) / (soft_y + 1);
      if (T < 1)
        m *= (3 - 2 * T) * T * T;
    }
    // same for the 'upper' lines in the image (bottom left is 0,0)
    if (Y > t - soft_y - 1) {
      float T = (t - Y) / (soft_y + 1);
      if (T < 1)
        m *= (3 - 2 * T) * T * T;
    }
  }
  // now fill the line with data
  for (; X < R; X++) {
    float m1 = m;
    // first, calculate the multiplier for the left side falloff
    if (X + 1 <= x || X >= r)
      m1 = 0;
    else if (X < x + soft_x && soft_x >= 0) {
      float T = (X + 1 - x) / (soft_x + 1);
      if (T < 1)
        m1 *= (3 - 2 * T) * T * T;
    }
    // now do the same for the right side falloff
    if (X > r - soft_x - 1 && soft_x >= 0) {
      float T = (r - X) / (soft_x + 1);
      if (T < 1)
        m1 *= (3 - 2 * T) * T * T;
    }
    // finally, we can fill the buffer with the calculated value
    buffer[X] = m1;
  }
  return true;
}

So the constructor and the knobs call initialises a set of storage variables and creates a pair of rectangle specific knobs referencing them respectively. Both the BBox_knob and the WH_knob types are interesting because they are both spatial and aware of proxy scaling. This means that they will automatically update their values dependant on both the current proxy and active downres settings - to see this ensure you have a visible rectangle with some softness set and switch your viewer into proxy mode. Note how the viewer overlay handles and the rectangle itself are updated to be drawn at the correct place to take this scaling into account. The knobs call additionally uses the input_knobs and output_knobs calls mentioned previously to construct a full DrawIop derived interface.

In _validate the Op sets the out_channels to Mask_None (ie telling NUKE to ignore the op) in the situations where the bottom of the rectangle is above the top, or where the left of the rectangle is further across than the right. It also calls the base DrawIop::_validate passing the smaller region which it generates - this way NUKE knows not to both going to the extent of passing the data external to this region through the Rectangle op. Note the use of the floor and ceil to truncate the float values to the nearest int value, ensuring the passed integer area is always slightly bigger in the circumstances where the rectangle doesn’t sit on integer values. Given that the softness value isn’t taken into account here, this also means that the softness implementation must always blur the edges inside the rectangular area, since we’re never going to be passed anything outside it. Also important is the initial include of DDImage/DDMath.h, rather than <math.h>, to give cross platform, portable math functions.

Finally in the draw_engine we do the real work. Again, most of the engine call is focussed around the intricacies of its particular task, so rather than discuss it in extensive and rather boring detail, we’ll just take a look at some of the new functions utilised. If you want to check out the minutae of the drawing function, fire it up in a debugger and step through. As before, we initially handle degenerate cases where we can simply return, however in this case we return false to allow rapid copying through of input data, as opposed to initialising the buffer to zeros. Subsequent drawing code attempts to handle common fast circumstances, before handling the more esoteric and slow configurations. Vertical softness is calculated on a per row basis, whilst horizontal softness is calculated inside the row loop as required.

Building the Noise Node

Next up, the Noise node. For this example, you’ll want to open up the full source code listing from the NDK in your IDE of choice, set up header locations and follow along, as it gets rather interrelated.

Points of interest here include the use of the NDK provided noise.h functions, as well as the utility Matrix and Vector classes for handling, in this case 4x4 and 3x1 maths for us.

The engine relies on fBm and turbulence as encompassed by the noise.h include. These expect x y and z values to define size, and plane, which are in turn constructed by generating a 4x4 transform matrix based on user interface controls. Slightly different to the previous example, the row loops are included down the end of the if tree, since in this case we can avoid evaluating the decisions on every pixel, and optimise by doing the majority on a row by row basis.

As for the knobs call, there are a couple of items we like haven’t come across before - enumeration knobs and obsolete knobs. Both of these are discussed in more detail in the relevant parts in the Knob Types, and Versioning sections. Most of the other knobs are functionally and visually similar to other knobs we’re already come across, however the Enumeration_knob provides a drop down list. The current setting is stored as an int, so it’s generally wise to setup the static char* array alongside an enumeration defining the entries themselves up at the top of your class, as in the following lines of this example.

enum { FBM, TURBULENCE };
const char* const types[] = {
  "fBm", "turbulence", 0
};

The Obsolete_knob provides a method for allowing you to change the interface of your operator whilst maintaining backwards compatibility. As you can see in this example, the initial definition of the ‘lacunarity’ knob had a capital ‘L,’ which is generally regarded as bad practice in the world of NUKE. This was corrected, but the new knob had a spelling error - ‘lucanarity.’ To correct this yet another knob was defined, spelt and capitalised correctly. At each ste, instead of simply removing the previous incorrect knob, which would have meant that scripts saved with the old version would have not loaded correctly, an Obsolete_knob entry was created with the incorrect name. NUKE would then, when encountering a script saved which such a named knob, call the script defined in this obsolete entry. In both these cases the script simply updates the correctly named node with $value - the script variable holding the setting found in the script. Note that both entries point to the correct knob, as opposed to chaining from the first obsolete, to the second and then to the correct.

Exercise: Build a Fractal Node

For the second exercise we’re going to look at building your first Op from the ground up, and without a prebuilt NUKE node as a point of reference.

  • Create a new DrawIop called Fractal, fill in the required virtuals and get it building.
  • Research fractal drawing algorithms (noting that a DrawIop can only fill in a single luminance buffer and not a colour one), and implement draw_engine, _validate and knobs so as to draw the selected algorithm and provide controls dependant on its underlying variables and expected artist requirements. The wikipedia pages on fractals, the mandelbrot set and fractal software offer a selection of useful links and algorithm discussions which might serve as a good starting point.
  • Try a few optimisations to get the drawing running as fast as possible.
  • If you have clearance to, compile and upload the binaries and/or source code to Nukepedia.