Writing a GeoOp

A GeoOp node creates or modifies 3D data. The output is visible in NUKE’s built-in 3D viewer, can be saved out to an OBJ or FBX file via the WriteGeo node, or rendered into a 2D image using (for example) the ScanlineRender node.

Writing a 3D op is similar to writing a 2D op. You will need to override many of the same functions to describe the op to nuke: Class(), knobs, test_input and so on. Refer to the section on implementing 2D ops for details about these. The main differences are:

  • Your op will inherit from a different base class.
  • You will override a different method to do the work of the op (e.g. create_geometry instead of pixel_engine). Obviously the implementation of this method will be quite different to what you would have in a 2D op.
  • As well as the overall op hash, 3D ops have separate hashes for each data group. You’ll need to implement the get_geometry_hash() method to calculate each of these.

To write a GeoOp you should, broadly, follow these steps:

  1. Pick a base class to inherit from.
  2. Override the appropriate method, depending on the base class you chose, to do the work of the op.
  3. Override the get_geometry_hash() method, to calculate a hash for each of the data groups.
  4. Override whichever of the standard op functions you need. The Class() method must always be overridden; all the others are optional. If you override the knobs() method, remember to call the method from the base class as well; otherwise, you’re likely to get some crashes.

The GeoOp class has a method, geometry_engine, which does the bulk of the geometry processing work. Both SourceGeo and ModifyGeo override this method to provide simpler interfaces for creating or modifying geometry respectively.

Base classes for GeoOps

All GeoOps inherit ultimately from the DD::Image::GeoOp class. There are two additional subclasses which you can inherit from instead, DD::Image::SourceGeo and DD::Image::ModifyGeo; these take care of some extra details for you.

Deciding which op to inherit from depends on what you want your op to do. Some examples to help guide you:

Purpose Base class to inherit from
Create new geometry from scratch. DD::Image::SourceGeo
Create new geometry based on 2D inputs.
Transform or distort existing geometry. DD::Image::ModifyGeo
Produce a more finely tesselated version of some existing geometry.
Calculations based on analysis of the whole scene, rather than just one object at a time. DD::Image::GeoOp
Filter existing objects.

A common reason for inheriting from GeoOp rather than ModifyGeo is that the latter provides an interface which operates on one 3D object at a time. If you are doing calculations which depend on having access to all the objects at once, it may be easier to inherit from GeoOp. You can still access information about other 3D objects in ModifyGeo; it just comes down to convenience.

Extending SourceGeo

SourceGeo provides a simplified interface for creating geometry. It provides some useful default behaviour:

  • It takes a single input, which must be a subclass of DD::Image::Iop. This gets used as the default material on all 3D objects created by this op.
  • It overrides get_geometry_hash, appending the hash of the input op to Group_Object. There’s special handling for when the input op is a subclass of DD::Image::Material.
  • It overrides geometry_engine to ensure that the 3D object cache is properly synchronized and that any created objects get filled in with some sensible default values.

The actual creation of 3D geometry is delegated to the create_geometry function, which you must override in your subclass:

void DD::Image::SourceGeo::create_geometry(Scene& scene, GeometryList& out)

Fills the out parameter with newly created geometry. This will be called to create geometry for the first time, or to rebuild it after something has changed.

If you want to change the default values which are set up for each 3D object, you can override the init_geoinfo_parms method:

void DD::Image::SourceGeo::init_geoinfo_parms(Scene& scene, GeometryList& out)

Sets up the default values for created objects, including the transform matrix and the material.

There are further things which are common to all GeoOps that you can do. See the Common parts of all GeoOps section for details.

The NDK includes Sphere.cpp as an example of how to implement a SourceGeo subclass.

Extending ModifyGeo

The ModifyGeo class provides a single method that you must override:

void DD::Image::ModifyGeo::modify_geometry(int obj, DD::Image::Scene& scene, DD::Image::GeometryList& out)

This function is called once per GeoInfo from the input node. The obj parameter is the index of the input object to be processed.

The data for the object being processed will already be populated in the out parameter. Your implementation of this function should modify the data in place by calling (e.g.) out.writable_points(obj) and overwriting the entries in the list it returns.

There are further things which are common to all GeoOps that you can do. See the Common parts of all GeoOps section for details.

The NDK includes LogGeo.cpp as an example of how to implement a ModifyGeo subclass.

Extending GeoOp directly

The GeoOp base class does most of it’s work in a single method which you can override:

void DD::Image::GeoOp::geometry_engine(DD::Image::Scene& scene, DD::Image::GeometryList& out)

Creates or modifies geometry and ensures the op’s geometry cache is up to date.

Your implementation of this function should ensure that it calls out.synchronize_objects() to get the op’s geometry cache up to date. Calling this more than once is relatively cheap, because it exits quickly if the cache is up to date. It must be called at least once after all the geometry creation or modification.

There are further things which are common to all GeoOps that you can/must do. See the Common parts of all GeoOps section for details.

The NDK includes UVProject.cpp as an example of how to implement a GeoOp subclass.

Common parts of all GeoOps

Generally speaking, all GeoOps should calculate suitable hashes for each of the groups by overriding this method:

void DD::Image::GeoOp::get_geometry_hash()

Recalculates and updates the stored hashes for each group.

This function is called by NUKE to get the hashes up to date, so it can use them to check which groups need to be rebuilt and set the appropriate rebuild flags.

All GeoOps have a few features in common:

  • They store a separate hash for each group.

  • They perform caching

  • Operations to implement (what, when and why).

  • Geometry hashes

    Updates the stored hashes for each of the groups.

GeoOp call order for rendering

The call order for a GeoOp is different depending on whether it’s being rendered through a ScanlineRender (or similar) node, or being viewed in the 3D viewer.

When rendering a GeoOp, pretty much all of the work happens inside the renderers own _validate(bool) method (the renderer itself will be a subclass of Iop, so will be following the usual Iop call sequence):

  1. GeoOp::_validate(bool)
  2. GeoOp::get_geometry_hash() (called by the _validate method).
  3. GeoOp::build_scene()
  4. GeoOp::get_geometry() (called by build_scene).
  5. GeoOp::geometry_engine() (called by get_geometry).
  6. Scene::validate()
  7. GeoInfo::validate() for each GeoInfo in the scene.
  8. Iop::validate() for each material used by a GeoInfo in the scene.
  9. Scene::evaluate_lights()

In terms of the functions you’ll be overriding:

  • get_geometry_hash() is called first to ensure the hashes are up to date;
  • then geometry_engine() is called to produce the geometry.

In the case of a SourceGeo op, that will instead look like:

  • get_geometry_hash() is called first, as above;
  • then create_geometry(Scene& scene, GeometryList& out) is called, to create the geometry.
  • finally init_geoinfo_parms(Scene& scene, GeometryList& out) is called to setup some default values for various GeoInfo parameters. This includes providing a default material, when none has specified.

And for a ModifyGeo op it will be:

  • get_geometry_hash() is called first, as above;
  • then modify_geometry(int obj, Scene& scene, GeometryList& out) gets called once for each GeoInfo in the scene.

GeoOp call order for viewing

When you’re viewing the output of a GeoOp node in NUKE’s 3D viewer, the call order is different to the above. Geometry is generated first, without any materials; then textures are generated in a background thread for each of the materials and the viewer is updated as they complete.

Another difference between rendering 3D and viewing 3D is the amount that gets drawn. For rendering it’s the connected inputs only; for the viewer, it’s the connected inputs and also anything which has a panel open in the properties bin.

The call order in the 3D viewer looks like this, for every op connected to the viewers primary input and for every op with an open panel in the properties bin:

  1. _validate(false)
  2. get_geometry_hash() (called by the _validate method).
  3. geometry_engine(Scene& scene, GeometryList& out)
  4. GeoOp::build_handles(ViewerContext* ctx) is called first. If this returns true a draw_handles callback will be added for the op.
  5. Knob::build_handles(ViewerContext* ctx) is called for every knob on the op. If it returns true, a draw_handles callback will be added for the knob.

This gathers the information necessary for NUKE to do its drawing. Next we do a series of render passes; for each pass we set the ViewerContext’s event to a different value then, after drawing all the geometry, invoke the draw_handles callbacks:

  1. draw_handle(ViewerContext* ctx) with ctx->event() == DRAW_SOLID.
  2. draw_handle(ViewerContext* ctx) with ctx->event() == DRAW_LINES.

If your op doesn’t draw any custom handles, then build_handles should return false and you can ignore the draw_handle function. Likewise, if you’re not using any custom knobs you can ignore the knob build_handles and draw_handle functions; the built-in knobs already have suitable implementations of these.

Creating geometry

To create a new 3D object, you’ll need to follow these steps:

  1. Add an object
  2. Add points for the object
  3. Add primitives
  4. Set attributes

It may not always be necessary to rebuild all of the geometry data; only some of the data groups may have been affected. The implementation should check the state of the rebuild flags to determine which groups need to be rebuilt. You can do this using (for example):

if (rebuild(Mask_Points)) {
  ...
}

Note that it’s possible to set the rebuild flags explicitly by calling set_rebuild(); but you should avoid doing this while inside your geometry_engine, create_geometry or modify_geometry method as it could lead to a memory leak.

Call out.add_object(obj) to make space for the new object in the geometry list.