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.
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.
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.
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.
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;
(&ntype, GEO_NODE_PIZZA, "Pizza", NODE_CLASS_GEOMETRY);
geo_node_type_base.declare = file_ns::node_declare;
ntype(&ntype, file_ns::node_init);
node_type_init(&ntype, file_ns::node_update);
node_type_update.geometry_node_execute = file_ns::node_geo_exec;
ntype(
node_type_storage&ntype, "NodeGeometryPizza", node_free_standard_storage, node_copy_standard_storage);
.draw_buttons = file_ns::node_layout;
ntype(&ntype);
nodeRegisterType}
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
.
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)
{
*prop;
PropertyRNA
(srna, "NodeGeometryPizza", "storage");
RNA_def_struct_sdna_from
// For each property, i.e., each user-exposed parameter of the node
// that is not a pluggable input:
= RNA_def_property(srna, "olive_count", PROP_INT, PROP_NONE);
prop (prop, NULL, "olive_count");
RNA_def_property_int_sdna// Call various setters to fill in the property's settings:
(prop, 0, 1000);
RNA_def_property_range(prop, "Olive Count", "Number of olives topping the pizza");
RNA_def_property_ui_text(prop, NC_NODE | NA_EDITED, "rna_Node_update");
RNA_def_property_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 */
(GeometryNode, GEO_NODE_PIZZA, def_geo_pizza, "PIZZA", Pizza, "Pizza", "") DefNode
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).
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.
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)
{
.add_input<decl::Float>(N_("Radius"))
b.default_value(1.0f)
.min(0.0f)
.subtype(PROP_DISTANCE)
.description(N_("Size of the pizza"));
.add_output<decl::Geometry>("Mesh");
b.add_output<decl::Bool>(N_("Base")).field_source();
b.add_output<decl::Bool>(N_("Olives")).field_source();
b}
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)
{
*data = MEM_cnew<NodeGeometryPizza>(__func__);
NodeGeometryPizza ->olive_count = 5;
data->storage = data;
node}
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:
(NodeGeometryPizza) // To define node_storage()
NODE_STORAGE_FUNCS
static void node_update(bNodeTree *ntree, bNode *node)
{
const NodeGeometryPizza &storage = node_storage(*node);
*out_socket_geometry = (bNodeSocket *)node->outputs.first;
bNodeSocket *out_socket_base = out_socket_geometry->next;
bNodeSocket *out_socket_olives = out_socket_base->next;
bNodeSocket
// Stupid feature for the sake of the example: When there are too many
// olives, we no longer output the fields!
(ntree, out_socket_base, storage.olive_count < 25);
nodeSetSocketAvailability(ntree, out_socket_olives, storage.olive_count < 25);
nodeSetSocketAvailability}
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)
{
(layout, true);
uiLayoutSetPropSep(layout, false);
uiLayoutSetPropDecorate(layout, ptr, "olive_count", 0, "", ICON_NONE);
uiItemR}
To draw extra UI in the side panel, one can also set a
ntype.draw_buttons_ex
callback.
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:
= params.extract_input<GeometrySet>("Mesh");
GeometrySet geometry_set // [...]
if (geometry_set.has_mesh()) {
&component = geometry_set.get_component_for_write<MeshComponent>();
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 = create_pizza_mesh(olive_count, radius);
Mesh
// We build a geometry set to wrap the mesh and set it as the output value
.set_output("Mesh", GeometrySet::create_with_mesh(mesh));
params}
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 = BKE_mesh_new_nomain(vert_count, edge_count, 0, corner_count, face_count);
Mesh
// (iii) fill in element buffers
<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};
MutableSpan
// [...]
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)
{
// [...]
, olives_polys;
IndexRange base_polys*mesh = create_pizza_mesh(olive_count, radius, base_polys, olives_polys);
Mesh
// 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.replace(mesh, GeometryOwnershipType::Editable);
component
// Create the field from a range and a mesh component:
("Base");
StrongAnonymousAttributeID id<bool> attribute = component.attribute_try_get_for_output_only<bool>(
OutputAttribute_Typed.get(), ATTR_DOMAIN_FACE);
id.as_span().slice(base_polys).fill(true);
attribute.save();
attribute
// Output this field in the Base output
.set_output("Base",
params::Create<bool>(
AnonymousAttributeFieldInputstd::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
.set_output("Mesh", GeometrySet::create_with_mesh(mesh));
params}
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.
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.
At this stage you should be able to compile and see your new pizza node in the geometry node editor!