DrawIop: Generating Images from Scratch

The DrawIop class provides a further specialization of PixelIop for classes that 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.

DrawIop 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 plug-ins “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.

DrawIop:

  • is intended for use by Ops that generate monochrome image data.

  • adds color 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 color, the ramp, and 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 drawing. Unlike other engine functions in the Iop family, DrawIop returns a bool. If the buffer is filled entirely with zeroes, the function can return false and then 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: that is, 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 you need to specifically call in your knobs function via input_knobs(Knob_Callback) and output_knobs(Knob_Callback, bool ramp=true). input_knobs provides a channel selector, a replace incoming image checkbox, and an invert checkbox. output_knobs provides colorization and optionally ramp/gradient tools. These replace the NukeWrapper functionality that can be used by other Iop types.

Building the Rectangle Node

We’ll now take a look at 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 breaking 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 colorization 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 initialized
  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 rectangle's 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)
{
  // let's 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 softened 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 initialize a set of storage variables and create 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 automatically update their values depending 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 (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 that it generates - this way NUKE knows not to bother 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 optimize by doing the majority on a row by row basis.

As for the knobs call, there are a couple of items we 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 have already come across, however, the Enumeration_knob provides a dropdown list. The current setting is stored as an int, so it’s generally wise to set up 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 changing 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, spelled and capitalized correctly. At each step, the previous incorrect knob wasn’t simply removed, as that would have meant that scripts saved with the old version would have not loaded correctly. Instead, an Obsolete_knob entry was created with the incorrect name. When encountering a script saved which such a named knob, NUKE would then 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 color one) and implement draw_engine, _validate, and knobs so as to draw the selected algorithm and provide controls dependent 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 optimizations 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.