exppad* / blog / Writing a custom geometry node for Blender May 29th, 2022

Geometry nodes represent non-destructive geometry processing operations in Blender. Like modifiers, they are a key building block for authoring parametric shapes. This is a programming guide for adding new geometry nodes to Blender 3.1.

Geometry nodes are used for visual programming of 3D shapes in Blender.

This guide shows what parts of the C/C++ code of Blender must be modified, similarly to what I have presented a few years ago for modifiers.

Building Blender

Since we are going to modify the core of Blender, we first need to build Blender from source. This process is well documented in Blender’s Wiki: Building Blender. I will assume that you followed these instruction regarding directory names.

Before actually starting our changes, it is recommended to create a branch to isolate our changes from upstream development. Let’s call it for example pizza-geonode:

/.../blender-git/blender $ git checkout -b pizza-geonode

Throughout this post, we will add a “Pizza” geometry node. I don’t think it’s really useful, but at least we are sure not to get confused by other occurrences of the word “pizza” in the source code.

This Pizza node will be a generator of meshes representing pizzas, with a variable radius and variable number of olives, that the user can tune. For the sake of illustration, the radius is a pluggable input while the number of olives is not – it is a property. This is about the simplest example we can imagine, so that we can focus on the Blender specific boilerplate rather than the behavior of the node itself.

Getting started

An overview of geometry nodes is given in Blender’s dev wiki. One thing that is important to note is that an input/output socket of type “geometry” contains a Geometry Set, which may be either a mesh, a volume, a curve, etc.

The behavior of all geometry nodes is located in the directory source/blender/nodes/geometry/nodes. We create there a file node_geo_pizza.cc and add it to the list of source files in source/blender/nodes/geometry/CMakeLists.txt

We can look at, e.g., node_geo_extrude_mesh.cc for inspiration, since this is a simple operation taking a mesh as input, returning a mesh as output. If you intend to make a node that operates on a different type of content, you may look at a different base example to take inspiration.

Registering the node

All what matters to the exterior of this file is that register_node_type_geo_pizza() is defined (the very last definition of the file). This function must also be declared in source/blender/nodes/NOD_geometry.h, and called in registerGeometryNodes() in source/blender/blenkernel/intern/node.cc.

void register_node_type_geo_pizza()
{
  namespace file_ns = blender::nodes::node_geo_pizza_cc;

  static bNodeType ntype;
  geo_node_type_base(&ntype, GEO_NODE_PIZZA, "Pizza", NODE_CLASS_GEOMETRY);
  ntype.declare = file_ns::node_declare;
  node_type_init(&ntype, file_ns::node_init);
  node_type_update(&ntype, file_ns::node_update);
  ntype.geometry_node_execute = file_ns::node_geo_exec;
  node_type_storage(
      &ntype, "NodeGeometryPizza", node_free_standard_storage, node_copy_standard_storage);
  ntype.draw_buttons = file_ns::node_layout;
  nodeRegisterType(&ntype);
}

This function creates a new node type (bNodeType ntype), sets its various fields, then calls nodeRegisterType() to globally register it. The fields of ntype are mostly callbacks, i.e., pointers to function, and are either set directly (ntype.declare = file_ns::node_declare) or through a setter function node_type_init(&ntype, file_ns::node_init), which performs additional checks.

The remaining of this C++ file consists in defining all these callbacks. By convention, we wrap all the code related to our node in a dedicated namespace, namely blender::nodes::node_geo_pizza_cc.

RNA and DNA

Our node needs to store some data. Not all nodes do, because only properties must be stored in a node, input values are managed at the level of the node graph instance. Specifically, we store olive_count but not radius. Data storage in Blender is defined by describing a DNA and an RNA.

NB: The DNA defines what gets stored in the .blend file, the RNA specifies how to interpret this data at runtime, including what to check when it gets updated, how to display it in the UI, etc. It is also used to automatically generate Python bindings in the bpy module.

In register_node_type_geo_pizza() we informed through a call to node_type_storage that the storage associated to the node is described by the DNA structNodeGeometryPizza. This must be define in DNA_node_types.h:

typedef struct NodeGeometryPizza {
  uint8_t olive_count;
} NodeGeometryPizza;

We then describe the associated RNA in source/blender/makesrna/intern/rna_nodetree.c:

static void def_geo_pizza(StructRNA *srna)
{
  PropertyRNA *prop;

  RNA_def_struct_sdna_from(srna, "NodeGeometryPizza", "storage");

  // For each property, i.e., each user-exposed parameter of the node
  // that is not a pluggable input:
  prop = RNA_def_property(srna, "olive_count", PROP_INT, PROP_NONE);
  RNA_def_property_int_sdna(prop, NULL, "olive_count");
  // Call various setters to fill in the property's settings:
  RNA_def_property_range(prop, 0, 1000);
  RNA_def_property_ui_text(prop, "Olive Count", "Number of olives topping the pizza");
  RNA_def_property_update(prop, NC_NODE | NA_EDITED, "rna_Node_update");
}

This is registered in NOD_static_types.h, at the end of the file, just before #undef DefNode:

/*      Tree type      Node ID          RNA def function   Enum name   Struct name   UI Name   UI Description */
DefNode(GeometryNode,  GEO_NODE_PIZZA,  def_geo_pizza,     "PIZZA",    Pizza,        "Pizza",  "")

For this line, we must also define GEO_NODE_PIZZA in source/blender/blenkernel/BKE_node.h. Be aware that this number must not change once set otherwise previously saved .blend files that use this node will not load correctly. The Struct name is by convention the same as the DNA struct name suffix (it is used to define a RNA struct called Tree type + Struct name).

Node callbacks

Let’s get back to the details of node_geo_pizza.cc, we can see in register_node_type_geo_pizza() that we must define the following callbacks:

  • node_declare
  • node_init
  • node_update
  • node_layout
  • node_geo_exec

NB: For node storage, we use like in the extrude node the default node_free_standard_storage and node_copy_standard_storage which are defined in node_util.h, but if you want to do custom storage management you will need to define custom callbacks.

Node declaration

The node_declare() callback describes the list of sockets of the geometry node. Simply call b.add_input<InputType> and b.add_output<OutputType> to declare the input and output sockets of the node. These follow the builder pattern, i.e., the object returned by add_input is a builder which supports chaining calls to attribute setters. A bit like keyword arguments in Python.

There are many possible attributes to set, the best is to look at the source code of a node that has an input/output which looks like what you are looking for and copy it.

Our example is particularly simple because our node is a generator. It has a single input, namely the radius, and a single geometry output. We also output two fields, telling which part of the geometry is the base and which part is the olives:

static void node_declare(NodeDeclarationBuilder &b)
{
  b.add_input<decl::Float>(N_("Radius"))
    .default_value(1.0f)
    .min(0.0f)
    .subtype(PROP_DISTANCE)
    .description(N_("Size of the pizza"));
  b.add_output<decl::Geometry>("Mesh");
  b.add_output<decl::Bool>(N_("Base")).field_source();
  b.add_output<decl::Bool>(N_("Olives")).field_source();
}

Node initialization

The node_init() callback is called each time a new node of the type Pizza is created. It allocates the storage memory (an instance of the DNA struct NodeGeometryPizza) required to store the node’s properties. It sets the default values for each field and saves this in node->storage. This data is automatically freed upon destruction of the node instance.

static void node_init(bNodeTree *UNUSED(tree), bNode *node)
{
  NodeGeometryPizza *data = MEM_cnew<NodeGeometryPizza>(__func__);
  data->olive_count = 5;
  node->storage = data;
}

Node update

The node_update() callback can be used to dynamically modify the availability of some input or output sockets, depending on the value of properties. This is only called upon update of properties that are not pluggable themselves. Here we show a mock example, although for our pizza node we could just not set any update callback:

NODE_STORAGE_FUNCS(NodeGeometryPizza) // To define node_storage()

static void node_update(bNodeTree *ntree, bNode *node)
{
  const NodeGeometryPizza &storage = node_storage(*node);

  bNodeSocket *out_socket_geometry = (bNodeSocket *)node->outputs.first;
  bNodeSocket *out_socket_base = out_socket_geometry->next;
  bNodeSocket *out_socket_olives = out_socket_base->next;

  // Stupid feature for the sake of the example: When there are too many
  // olives, we no longer output the fields!
  nodeSetSocketAvailability(ntree, out_socket_base, storage.olive_count < 25);
  nodeSetSocketAvailability(ntree, out_socket_olives, storage.olive_count < 25);
}

Node layout

The node_layout() callback tells Blender’s UI how to draw the node. This typically calls uiItemR() for each property of the node. Note that the part of UI related to sockets is automatically laid out from the node’s input/output declaration.

static void node_layout(uiLayout *layout, bContext *UNUSED(C), PointerRNA *ptr)
{
  uiLayoutSetPropSep(layout, true);
  uiLayoutSetPropDecorate(layout, false);
  uiItemR(layout, ptr, "olive_count", 0, "", ICON_NONE);
}

To draw extra UI in the side panel, one can also set a ntype.draw_buttons_ex callback.

Node execution

Finally, the main entry point is the node_geo_exec() callback. All of the input and output and context information are passed through the unique GeoNodeExecParams params argument. For instance, in the extrusion node, the input mesh is retrieved by calling:

GeometrySet geometry_set = params.extract_input<GeometrySet>("Mesh");
// [...]
if (geometry_set.has_mesh()) {
  MeshComponent &component = geometry_set.get_component_for_write<MeshComponent>();
}

NB: As described in Blender’s wiki, a geometry set can contain components of various types (mesh, curve, volume, etc.). Components may reference geometry owned by other entities, and follow the Copy on Write (CoW) idiom, which means that if they are references, they are copied to on the fly the moment some code requires to write in them. This is what happens here with get_component_for_write.

Anyways, for our simple example, we only generate a new mesh.

static void node_geo_exec(GeoNodeExecParams params)
{
  // We first retrieve the property (olive count) and the input socket (radius)
  const NodeGeometryPizza &storage = node_storage(params.node());
  const int olive_count = storage.olive_count;
  const float radius = params.extract_input<float>("Radius");

  // Then we create the mesh (let's put it in a separate function)
  Mesh *mesh = create_pizza_mesh(olive_count, radius);

  // We build a geometry set to wrap the mesh and set it as the output value
  params.set_output("Mesh", GeometrySet::create_with_mesh(mesh));
}

The structure of create_pizza_mesh() follows the same outline as any geometry cooking function, like in OpenMfx: (i) compute element counts, (ii) allocate memory, (iii) fill in element buffers.

static Mesh *create_pizza_mesh(const int olive_count, const float radius)
{
  // Very simple pizza: the base is a disc and olives are little quads

  // (i) compute element counts
  int vert_count = 32 + olive_count * 4;
  int edge_count = 32 + olive_count * 4;
  int corner_count = 32 + olive_count * 4;
  int face_count = 32 + olive_count;

  // (ii) allocate memory
  Mesh *mesh = BKE_mesh_new_nomain(vert_count, edge_count, 0, corner_count, face_count);

  // (iii) fill in element buffers
  MutableSpan<MVert> verts{mesh->mvert, mesh->totvert};
  MutableSpan<MLoop> loops{mesh->mloop, mesh->totloop};
  MutableSpan<MEdge> edges{mesh->medge, mesh->totedge};
  MutableSpan<MPoly> polys{mesh->mpoly, mesh->totpoly};

  // [...]

  return mesh;
}

The full cooking function can be found in this gist, which also adds two arguments IndexRange &base_polys and IndexRange &olives_polys indicating how to fill the Base and Olives output fields. We use them if output_is_required is true:

static void node_geo_exec(GeoNodeExecParams params)
{
  // [...]

  IndexRange base_polys, olives_polys;
  Mesh *mesh = create_pizza_mesh(olive_count, radius, base_polys, olives_polys);

  // Fill in output fields if required (i.e., if some link is plugged)
  if (params.output_is_required("Base")) {
    // Create a mesh component (a simple wrapper)
    MeshComponent component;
    component.replace(mesh, GeometryOwnershipType::Editable);

    // Create the field from a range and a mesh component:
    StrongAnonymousAttributeID id("Base");
    OutputAttribute_Typed<bool> attribute = component.attribute_try_get_for_output_only<bool>(
        id.get(), ATTR_DOMAIN_FACE);
    attribute.as_span().slice(base_polys).fill(true);
    attribute.save();
    
    // Output this field in the Base output
    params.set_output("Base",
                      AnonymousAttributeFieldInput::Create<bool>(
                          std::move(id), params.attribute_producer_name()));
  }

  if (params.output_is_required("Olives")) {
    // [...] Idem for olives
  }

  // We build a geometry set to wrap the mesh and set it as the output value
  params.set_output("Mesh", GeometrySet::create_with_mesh(mesh));
}

NB: I am a little less confident about the way fields and anonymous attributes work so this part may not be as idiomatic as I would like.

Add Node Menu

One last thing before we can use our new node: we must add the menu entry that enables one to create the node in the Geometry Nodes space. In release/scripts/startup/nodeitems_builtins.py, look for the definition of geometry_node_categories and add in the relevant section NodeItem("GeometryNodePizza"),. Do not forget to rebuild CMake’s INSTALL target, or copy nodeitems_builtins.py to 3.1/scripts/startup (within your build directory) for this change to take effect.

The menu entry for adding a new Pizza node.

Final Result

At this stage you should be able to compile and see your new pizza node in the geometry node editor!

Our Pizza geometry node up and running in Blender!