Node Interface Framework
Nowadays, many different node tree types exist for Blender. Some of them are integrated in Blender directly, while others are developed as addons. In both cases, the implementation of every node has to define which inputs and outputs it has.
First, the existing approaches of how nodes define their sockets are summarized. Then, the properties a good solution should have are explained. Afterwards, it is shown that the existing solutions do not solve everything they should solve. In the end, a better solution is presented.
I'm aware of three different ways, developers approached this problem.
Fixed Node Templates
This is used for the node systems that are implemented in Blenders core.
Every node defines two static arrays; one for its inputs and one for its outputs.
Every element is an instance of the
Among others, it contains the type, default value and identifier of a socket.
Most addons that implement nodes use this approach, because it is how the Python API for nodes suggests you to do it.
Every node defines an
init function, which is called once when the node is created.
Within this function, a node creates its own sockets programmatically.
Sverchok and ProcGenMod work this way.
I'm calling it "create function" in lack of a better name. It works very similarly to the init function. The big difference is, that it is not only called once, when the node is created. Instead it might be called many times over the lifetime of a node. It does the same as the init function in the sense, that a node creates its own sockets in it. However, with this semantic change, a node can be rebuild by first removing all sockets, then running the function again. Animation Nodes uses this approach.
Before getting into the problems of these approaches, this section explains what functionality I expect from a good solution. Creating a node system, that helps users achieve their goal, requires individual nodes to be dynamic. This is in contrast to the static nodes, that we see in Blender today. There are multiple levels, that should be considered.
- Optional Sockets: Some nodes don't have to show all their inputs and outputs by default, because they are rarely used in the common case. Users should be able to turn these on and off (e.g. some sockets in the Principled BSDF node are much less used than others).
- Different Functions: Nodes can be containers for multiple functions that logically belong together. Depending on other properties of the node, the set of required inputs and outputs can be different (e.g. the second input in a math node is only required by some operations).
- Dynamic Socket Types: Some nodes can be generic in the sense that they can operate on different types of data. Depending on its links to other nodes, its socket types can change (e.g. the Get List Length node in Animation Nodes).
- User Defined Interface: Some nodes don't define the exact set of inputs and outputs they have themselves. Instead the user adds and removes sockets dynamically (e.g. the in the Group Input node or the Create List node in Animation Nodes). These nodes typically have a placeholder socket.
One might think that the last two targets are special cases, but they are really not. Supporting them in more nodes, can improve the user experience a lot.
None of the existing approaches supports the desired functionality in a good way.
Fixed Node Templates can support Optional Sockets rather easily, and partially does so already (unused sockets can be hidden). Different Functions can be supported, but only in a hacky way (create all sockets in the beginning and then dynamically enable and disable them). User Defined Interfaces and Dynamic Socket Types are special cases that cannot be handled with fixed node templates.
An Init Function that is run once in the beginning has basically the same problems. One benefit is, that a node can more easily be changed at run-time, because it does not rely on a fixed template. Nevertheless it is difficult and error prone to implement more dynamic sockets.
Using a Create Function the targets Optional Sockets, Different Functions and Dynamic Socket Types can be implemented without any hacks. What makes it less error prone compared to the Init Function approach, is that the entire node is rebuild every time it changes. That avoids all special cases when changing from one state to the next. The User Defined Interface still remains a special case in this approach.
A better Solution
The solution I present here is the result of multiple iterations in the
functions branch and based on my experience with Animation Nodes.
It might seem simple.
That is, because it is simple to use.
It requires more work on the framework/backend side, but that allows the code of nodes to be very clean (which is where most development happens once the basic system is put in place).
The most important idea is take away all control from the code of nodes about when sockets are created. As a result, the entire node tree is a fully managed system. Nodes only declare what interface they would like to have, but they do not build it themselves. Furthermore, with this approach, a fixed set of supported "dynamicness" can be allowed, which is known to work well together.
This is similar to how layouts are drawn in Blender.
Panel.draw function does not actually draw anything, it just tells Blender what it would like to draw.
It is totally up to Blender to decide when and how to draw it.
A entire framework, as the code of a node sees it, has two main parts (the names here just happen to be what I use currently and will likely change at some point).
SocketBuilder type and a
In the simplest case, a completely static node, only the declaration function has to be implemented.
It calls the
fixed_output methods with at least a socket identifier, name and type.
def declaration(self, builder: SocketBuilder): builder.fixed_input("x", "X", "Float") builder.fixed_input("y", "Y", "Float") builder.fixed_input("z", "Z", "Float") builder.fixed_output("vector", "Vector", "Vector")
Having a different set of sockets in the same node, depending on other properties is very straight forward as well.
def declaration(self, builder: SocketBuilder): builder.fixed_input("a", "A", "Float") if self.current_operation_requires_two_inputs(): builder.fixed_input("b", "B", "Float") builder.fixed_output("result", "Result", "Float")
Dynamic socket types are complicated in the sense, that we have to be very careful to only make them dynamic in ways we can reliably support. Too much flexibility makes deterministic type deduction very hard or even impossible. Even more so, when combined with implicit conversions. For that reason, the framework only allows predefined types of dynamicness. Dynamic sockets always have a certain state, that has to be stored in the node.
class ListLengthNode(bpy.types.Node, FunctionNode): bl_idname = "fn_ListLengthNode" bl_label = "List Length" active_type: SocketBuilder.DynamicListProperty() def declaration(self, builder: SocketBuilder): builder.dynamic_list_input("list", "List", "active_type") builder.fixed_output("length", "Length", "Integer")
The framework also supports a fixed set of user defined interfaces. They are similar to variadic inputs in C/C++, but a single node can have multiple variadic inputs and outputs if it wants to.
class FunctionInputNode(BaseNode, bpy.types.Node): bl_idname = "fn_FunctionInputNode" bl_label = "Function Input" variadic: SocketBuilder.VariadicProperty() def declaration(self, builder: SocketBuilder): builder.variadic_output("outputs", "variadic", "New Input")
As you can see, it is very simple to implement simple and complex node interfaces. All complexity is handled by the framework in a centralized place. Another great aspect of this is that all nodes can easily benefit from improvements to the framework. Furthermore, they can be developed separately.
So far, I have a working implementation in Python in the
functions branch (currently in
However, the general idea can easily be ported to C or C++ if necessary.
Currently, the framework implements a
sync operation which runs after changes on the node tree and brings it back to valid state.
Alternatively, we could also try to keep the node tree in a valid state after each operation, but I did not do it (although I tried) for three reasons:
- The performance is actually worse. Running the type inferencer after every change can be slow and highly unnecessary.
- It makes modifying the node tree with scripts harder, because there could be unintended side effects after each operation. This is similar to how bmesh supports invalid states during more complex mesh operations.
- The Python API lacks some callbacks that would be necessary, to make it work.
sync operation, multiple things are done:
- Decide in which order node trees are synced. Every tree is synced on its own, but the order could be important when one tree calls another.
- Rebuild already outdated nodes.
- Run socket operators. These are functions that have to be run, when the user connects a link to specific placeholder sockets (e.g. to create a new input/output).
- Do inferencing and update nodes accordingly (the details of how the inferencing works can be a topic of another document).
- Remove invalid links.
Since the sockets of nodes are rebuild quite often, it is necessary to store and restore the state of the sockets as good as possible. The state for each socket can contain its current value as well as the other sockets it linked to. To improve the user experience, even the state of sockets that cannot be restored immediately, should be saved until either the file is closed or the node is deleted.