A Houdini-like node evaluation framework with (hopefully) common tech art functions.
The project is useable but still needs quality of life improvements.
At the moment, the code base contains a minor amount of behaviour tree concepts, because it is derived from an earlier behaviour tree plugin MochiBTS
The project is named after the famous French magician, and the reason being, well, Harry Houdini is also a famous and remarkable figure in the history of prestidigitation. Plus, Houdin is so similar to Houdini so I couldn't help it.
- Houdini has a 4-digit price tag.
- You can't do everything with HDA, since HDA is not the complete Houdini.
- Houdini is CPU-bound.
- Visual Scripting isn't really tooling friendly.
- Working in the industry, you would sometimes find yourself needing to do similar things again and again.
In comparison:
- This project is free.
- You can do anything as long as you can program an appropriate node. (You can write a fluid sim if you have the time budget!)
- You can run compute shaders with this project, and feel the power of GPU.
- Your artist don't need to go back and forth between Unity and DCC applications, and they always prefer so.
- With a good node design, you could achieve the level of decoupling just as behaviour trees, write once, connect the dots later.
Of course, you would be using houdini because it has those nice convenient features, not soley because it is procedural. This project does not aim to replace Houdini, but rather a fast Houdini-like editing experience all inside the Unity itself, for smaller, less complex, but highly Unity-bound features.
Create -> RobertHoudin -> RH Tree
An RhTree represents a collection of inter-connected nodes, equivalent to a network in Houdini. Double click on an RhTree asset to open the RobertHoudin Editor.
In the Editor, press space to bring up the node search window, click on an entry to add the node to the RhTree.
Each node will have an "OUTPUT" flag, which determines when the RhTree stops evaluating nodes. This is equivalent to the render flag in Houdini. Click on the output flag will turn the flag to green, and any previously activated output flag will become grey. There can only be one active output flag per tree.
For tree that returns a result as output, or requires Property Blocks, a driver script is usually needed.
To create a custom node, simply inherit RhNode
.
public class CustomNode : RhNode
{
protected override bool OnEvaluate(RhExecutionContext context)
{
throw new System.NotImplementedException();
}
}
The OnEvaluate
method must be implemented to tell what the node does. But for now, we should add ports first.
A port is any class inherit from RhPort
, and there are plenty of ports to choose from. In general, ports are categorized as follows:
- Simple Ports and Data Source Ports: A simple port receives its value from connected port, while a data source port can receive a value from connected port, directly configure a fixed value in the inspector, or from a Property Block via reflection. Builtin data source ports can be identified by their suffix
Ds
in type name. - Single Ports and Multi Ports: A single port can only accept one inbound connection, while a multi port can accept multiple. Note that this only limits input, an output single port can still connect to multiple ports.
Declaring a port is simply adding a member field, plus adding a port attribute in front of that field.
For example, the Sum sample node:
public class SumNode : RhNode
{
[RhInputPort] public MultiIntPort inputs;
[RhOutputPort] public IntPort output;
protected override bool OnEvaluate(RhExecutionContext context)
{
var sum = 0;
inputs.ForEachConnected(i => sum += i);
output.SetValueNoBoxing(sum);
return true;
}
}
Here, [RhInputPort]
signifies inputs
is an input port, and [RhOutputPort]
means output
is an output port.
There are other attributes that are specifically used in Loop nodes, but the API is still very unstable.
To implement the OnEvaluate
method, typically you call GetValueNoBoxing()
on an input port, and SetValueNoBoxing()
on an output port. For Multi Ports, however, you will need to call ForEachConnected()
instead as a Multi Port doesn't store a value itself.
Once the implementation is finished, the node should show up in the node search window (RobertHoudin Editor, press space).
For non-port fields that you wish to display in a node directly, add [RhNodeData]
attribute to that field:
public class PrintNode: RhNode
{
[RhNodeData] public string someString;
[RhNodeData] public Bounds bounds;
[RhInputPort] public StringPort str;
//...
}
Note that this is only used to control whether the field should appear in the node, without the attribute, the field is always visible in the node's inspector.
Caveats:
- Does not support Odin or anything that isn't IMGUI
- Has layout issues as seen in the previous image.
The framework provides a proxy type Number
that can be used as both int
and float
. This is added to eliminate the need to implement a float and int version of the same node, as well as using a conversion node if you need int or float but only have the other.
You can use NumberPort
, NumberPortDs
or MultiNumberPort
to replace int or float ports in most cases.
The caveat, is that Number
secretly uses float
and can result in overflow if you are manipulating a large enough integer. If you find yourself needing to do so, use int or float ports explicitly.
When using DataSource
, there are 3 different types where the data can come from:
None
: the value is embedded into the data source. In the editor, an extra field will appear after the type selection.
Port
: the value comes from evaluating the connected port.
PropertyBlock
: the value is retrieved from the property block inRhExecutionContext
, using cached reflection.
Assigning a value to PropertyBlockType
in RhTree
asset will provide a dropdown of eligible fields when editing datasources.
IRhPropertyBlock
is the primary way to pass external data or volatile data to the RhTree
for evaluation. Typically, you would use a datasource port set to PropertyBlock to access values in the property block.
For example, this class defines a simple context for scattering:
[Serializable]
public class SimpleScatterPropertyBlock: IRhPropertyBlock
{
public Transform rootTransform;
public int maxActivePoints;
public int k;
public Bounds bounds;
public Vector2 distance;
public SimpleObjectProvider objectProvider;
}
You could either expose them in inspector or manually set them with other code.
You could directly access the property block inside OnEvaluate()
method of a node, since the property block is passed as part of the RhExecutionContext
, but doing so would create a strong dependency between the node and the property block class, and is best to avoid.
As describe in the previous section, to access a field via datasource port, you simply need to fill the text box with the name of the field, and reflection will take care of the rest.
For example, this will bind rootTransform
and objectProvider
in the node as in the SimpleScatterPropertyBlock
class:
There are a few validation mechanisms that catch graph errors early on.
- Node Culling : When a node cannot be traced to the result node, it becomes dimmed, meaning the node will not be executed.
- Missing Connection : If an input port or a data source input port set to
Port
is not connected, the port becomes magenta:
- Missing Reflection Binding : If a data source port is set to
PropertyBlock
while its binding is null, the port becomes red. Though it can only happen if you do not specify the property block type in theRhTree
asset.
ForEachNode
is a very special generic class that not only has input and output ports, but also item port and item result port. If you are familiar with LINQ, ForEachNode
is very similar to Select
:
var inputCollection = new List<Item>();
var outputCollection = new List<ItemResult>();
//selector takes an Item, and outputs an ItemResult
Func<Item, ItemResult> selector;
outputCollection = inputCollection.Select(selector).ToList();
The generic class takes care of most of the hard working but you need to specify 6 type arguments:
- Input Item type
T
- Output Item type
U
- Input collection port type (port that takes
List<T>
or other indexable collection) - Output collection port type (port that takes
List<U>
or other indexable collection) - Item port type (port that takes
T
) - Item result port type (port that takes
U
)
You will also need to provide a way to extract an item from the input collection, as well as a way to put a result into the output collection:
//for example, in ForEachNumber
protected override Number Extract(NumberCollectionPort input, int i)
{
return input.value[i];
}
protected override void Put(NumberCollectionPort outputPort, int i, Number value)
{
outputPort.value.Add(value);
}
And that's it. You should be able to see 2 ports on the right of the node that represent item port and item result port.
It is legal to connect a node that isn't part of the loop with a node that is in the loop, but note that this can produce confusing results at times. The loop itself resets every node that is part of the loop with each iteration, and this can propagate the reset behaviour unexpectedly if you try to mix nodes that are outside the loop. Though I am thinking about implementing scopes and capturing so that it somewhat aligns with common programming schemes.
Here are some implementation details if you wish to modify the framework.
The framework uses string GUIDs to reference ports and nodes, using Unity's GUID generator. The GUID is used in several ways:
- Each
RhNode
andRhPort
keeps a serialized string as GUID (hidden from inspector). If the GUID is empty, we generate a new one. - Unity's Graph View system finds an
RhPortView
by usingGetPortByGuid
, and findingRhNodeView
byGetNodeByGuid
. These two methods expects setting the.viewDataKey
field of the node or port. - In
RhTree
, we keep separate dictionaries that map string GUIDs to respectiveRhNode
andRhPort
. - Each
RhPort
serializes the GUIDs of the connected ports.
We always start evaluating an RhTree
from the result node, and check each of its inputs. If the input has not been evaluated, we do the same for the input node. This is similar to a depth-first search.
OnBeginEvaluate
will be called before checking the inputs. This is a virtual method but usually no override is needed. Currently, the only thing it does is to setup bindings in DataSource ports.
During a node's evaluation, which is calling the node's OnEvaluate
method, the node reads from the input ports and sets values of the output ports. After that, the consumer of the output value will perform a Port Forwarding process that copies the output value to the connected input port (see ForwardValue
). Though this is only applicable for single ports, as multi ports cannot have a single value.
Ports also have IsActive
state. This doesn't mean whether they contain values or not, but rather if we should propagate the evaluation process through that port.