I think the part that is most difficult for a lot of these scripting languages for me is that what I'm primarily interested in is the GUI. I want the graph portion of it to be as flexible as possible
For example, take two common visual scripting tasks:
- Shader Editor
- State Machine
In a Shader Editor, you are interested in using the nodes to describe a calculation into several discrete steps, but you want to execute this graph once, and capture the output each time the graph is run. In this mode, the final result of the graph is the important result.
In a state machine, each node represents a particular state of the system. There is no final result, but rather the update of each node is what matters. (Spoiler alert: this is what I built.)
Some things are good at one or the other, but most don't do both. The ones that do, rarely handle it the way I would like, (for example: nesting graphs, or handling custom logic on/for transitions).
The most exciting thing about xNode is the open source permissive licensing. This means I can put it in my core libraries and use it even when doing open source game jamming (horray!) It also means I can take the pieces I like or need (like the Editor GUI) and rewrite the pieces I don't. Unlike some of the other libraries (even the source included ones), xNode is extremely easy to read, and as simple as I would expect a system like this.
xNode Evaluation
Their code revolves around two basic classes (A node and a graph, as expected),
and the editors for them. Each node contains a port list which can be explicitly
declared using attributes ([Input]
and [Output]
respectively) with the additional
caveat that they need to be default serializable.
Alternatively you can use a fairly basic API for adding DynamicPorts and handling
the conversion to and from a generic object
class as required. Both modes
require only a handful of lines of code.
Below is a complete source listing for a SeekPath class, which takes a target
as a Vector3
, starts calculating a path to that location from the current
position, waits until that completes, then publishes the resulting path as
an output of this node.
In order to get the code to be this lean, I had to make a few adjustments to the existing xNode implementation. The changes I made revolve around generally building a framework within my game for handling behavior graphs (how to set them up, associate them with my game objects, pass information into and out of these graphs, and run them, etc).
There were two sort of obnoxious functions they ask you to implement, however, that I really wanted to simplify.
GetValue improvements
I'm actually glad they implemented it the way they did even though it is obnoxious to implement and maintain for every funciton.
Basically, each node contains a port list which contains all the possible connections. The ports themselves are not bound in any way to the actual values, they just describe what possible outputs exist and give them a name (which, by convention, matches the name of the field on your node).
The expectation is you write a function which takes a port and returns the
associated value for it as a generic object
type.
Here's a toy example:
This isn't so bad, but the problem is every time we change our ports we have to remember to maintain this function and all the hardcoded mappings.
If we adhere to the convention where the port fieldName
, matches the field
name in our class, however, we can use the power of reflection
to handle this for us.
We can actually implement it with just around 5 lines of code:
This code simplly looks on our type to see if we have a fieldname that matches the fieldname we set on our port. If they match, return the value.
Input Value improvements
This one is a little weirder, but again because there are no bindings between the values on your class and the ports that connect nodes, they didn't properly set those values either. Additionally, there are no real lifecycle functions on the node (good for them for not over architecting this). Since I already added a little of that to my behavior tree, it was simple for me to automatically populate my values when my node starts, just the inverse of the process I did to return the proper values for the ports.
This is broken up into two sections.
The first simply takes a fieldname, find the appropriate port, if it's connected get the value from the port itself (as an object). This is where the output from our connected node shows up.
The second section is the part that checks each of our fields of our node. If any have a fieldName that matches a connected port, set that value.
Path Finder
To test my integration I rebuilt my existing pathfinder driver to use a graph.
There were a few hiccups, firstly because I forgot to instantiate the graph, and was using the shared instance of it initially.
Next I had to shake out a bunch of edge cases involved with my graph system.
Overall though, it went quite well, I'm really interested in continuing to learn and grow with this library!
Conclusion
Really enjoying xNode as a project, and would be willing to submit patches to improve it, but I'm not sure how exactly yet. There are performance implications to using reflection here, where graphs could be executed in the inner loop. There might be some caching we can do.
There's also some things that were only possible because I had built some infrastruture to handle things like how nodes are created, initialized, etc. I am actually grateful the library doesn't have those things, so I would want to be able to provide these helpers in a way that works well with what is already there.