Lattice Visual Scripting for ECS

Note: This first post has been updated to match the latest version. The rest of the thread follows the history of development.

Hey everyone! I wanted to start sharing updates on a project I’ve been working on full time for a while now. This system has been a long time coming, and is actually the 3rd iteration of several designs I’ve had for visual scripting engines.

We’re using ECS for one of our new games, so we’ve been working on a node-based system to help with writing gameplay and iterating on gameplay code, called Lattice. Lattice is a visual scripting environment, tightly tied to Unity’s ECS. It takes inspiration from Houdini, UE Animation Blueprints, Overwatch’s StateScript, and Bazel.

Unlike most visual scripting languages, Lattice compiles ahead-of-time to .NET IL, the assembly language that C# compiles down to. This means it is efficient, and has minimal allocation at runtime. Of course we’re still a ways off from competing with carefully tuned Jobs/Burst code, but for gameplay logic it’s been fantastic for us.

Lattice Visual Scripting - 0.4.0
Package: GitHub - Pontoco/Lattice: A visual scripting system for Unity ECS. Quickly create gameplay logic.
Discord: Pontoco 🌱

Status: Lattice is in preview. The compiler is stable and in use for our own projects. There is not yet a large library of nodes shipping with the package, so you will need to write many of your own.

Goals:
There is a tendency for visual scripting languages to be built only with designers in mind. Non-engineers are an important use case, of course. However, it often results in languages that are cumbersome to use, impossible to refactor, and are very slow.

Another way of putting this, visual scripting usually has a cliff: it really flies for certain tasks, but becomes like molasses when stepping out of bounds. As a result, big projects often avoid putting important logic in visual scripting, rewriting large swaths of it into C#, where designers can’t modify it.

Lattice aims to solve this problem. The goal of Lattice code is to be shippable. It is performant and integrates sensibly with C#. It should have good tooling for refactoring and analysis. You should never feel bad about writing logic in visual scripting, because it’s just another tool in your belt.

And at the same time, it should be fun to use for designers. Values are debuggable inline. You can create high level ‘behaviors’ that compose together, rather than fiddling with lots of small nodes and wires. You can kind of think of it like VFX Graph or the RenderGraph, but for gameplay logic.

There are a few ways that Lattice tackles this:

Nodes are just C# functions. Defining a node is as simple as writing a static C# function. Lattice handles all of the ‘glue’ code: pulling values from ECS components, getting prefab references baked, etc. This frees you up to focus only on the logic itself.

/// <summary> Animate a number based on a user-selected curve. </summary>
public static float SimpleAnimate([Prop] AnimationCurve curve, float time)
{
    return curve.Evaluate(time);
}

Lattice is a Baker. Lattice nodes can execute logic at bake time. This means Lattice scripts are their own bakers, there’s no separate ‘authoring’ workflow. Lattice graphs can even add components to your entities if you like.

Inline Debugging. Debugging is the slowest part of writing gameplay code. We can do a lot better than code-based debuggers. In Lattice you can inspect the output of any node in the graph, across the entire execution of a frame. If a value seems incorrect, you can walk up the chain of input to figure out which node went wrong.

“What” not “How”. Similar to ShaderGraph and VFXGraph, Lattice is “declarative”. This means that you define “what” you want the logic to be, not the “how”. For example, it is natural in Lattice to write “The color of this entity is blue when active, and red when disabled”. You don’t write code that executes line-by-line.

The Lattice Compiler. Lattice is built on an ahead of time compiler, using the same architecture as traditional programming languages. It compiles all graphs ahead-of-time to .NET IL, just like C#. All nodes are written as static C# functions operating on unmanaged data types, resulting in minimal allocations. The compiler features an IR representation that has several optimization passes, including dead code elimination.

From our internal testing, Lattice is 10-100x faster that Bolt, and competitive (or faster) than main-thread C#. As of 0.6, Lattice now scripts run with automatic parallelism, per-node, leading to a massive performance boost. See the blog post for more info.

Blog Posts:

This will likely be a long on-going thread. Feel free to ask any questions as it develops.

17 Likes

Very cool! I’m glad other programmers like you acknowledge that visual scripting is more than “for designers that don’t know C#”.

My feedback on your current ergonomics: I have difficulty understanding what are the inputs and outputs of nodes. I don’t know their purpose, type and direction.

Looking forward to updates!

Thanks!

Yeah, the interface is much easier to understand with a video walkthrough. A few things though:

  • Values flow downwards, like reading a book, or code.
  • Types input/output names are visible on hover. I’m considering a color-edges-by-type mode, but it turned out to be quite noisy.

Cool, I’m excited to see how this develops!

Personally, I’m a fan of how shader graph shows the names for all inputs and outputs. Makes it easier for me to follow. Hover tooltips are good, but I don’t think they should be the only way to see this information
9426557--1321376--upload_2023-10-23_14-44-38.png

But I also like how you draw the default value inline, right next to the parameter name for optional parameters.

I know adding names to all inputs and outputs would probably require moving the connection points to the left and right of the nodes, which changes your downward flow design, but I still would prefer it working this way.

Yeah, I can see that argument. My counter-argument is that once you get to a big enough graph, those names take up a huge amount of space. I’m aiming for a level of detail closer to text code, so nodes need to stay compact.

2 Likes

I think it is a good idea to make it toggle-able then, the empty circles will severely kick back the readability of any teaching material which isn’t inside the editor. So any screenshots of graphs will be essentially useless to teach anything other than the “I did this” and not “I chose this among these options because…”.

1 Like

Yeah, I’ll keep that in mind when I get to the stage of working on learnability. Right now, I’m squarely focused on making the tool extremely efficient for experts (ie. me who designed it, haha). Once I’m at a happy level of productivity with the tool, I can circle back to making it easier to teach.

Sorry for the straightforward feedback, but I don’t think the tool is usable currently for anyone else besides you, who designed it.

The idea is very cool, but as a visual scripting tool it is expected to help visualize things better, so if you are hiding such essential information it ends up going against its own goals.

If you want the tool to have a wider audience, including teammates that didn’t work in the tool directly, I think it would be valuable to listen to the feedback here, as so far 4 out of 4 (including me) commenters pointed out the same issue on the tool.

In any case, amazing project, looking forward to see it on GitHub on Asset Store to better follow its updates!

Don’t worry, I’m very interested in UX and visualization, haha! I have a long history of experience there, and we’ll get there.

My philosophy about Visual Scripting is that too much time is spent too early on UX, and not nearly enough time on the ‘fundamentals’. Ie. “How does the language operate? How does the type system function? What are the perf characteristics?” The ux-first approach makes for very pretty, very learnable tools that, at the end of the day, can’t do much that’s useful for me.

The project is still very early, and I’m not too worried about syntax right now. That’s all stuff to be designed somewhere down the road once I know the language is useful. But the feedback is always useful! :slight_smile:

4 Likes

It’s been a while, but development has not stopped. In fact, nearly all of the underlying runtime code has been rewritten. Here’s some of the latest work:

Lattice IR
The previous version of Lattice executed the authored node graph assets directly. This was clumsy, and limited the ability for the executor to analyze the broader task graph. It also linked the implementation of the runtime to the editing experience, which was a major pain to work with.

The latest version of Lattice compiles edit-time Lattice Graphs down into an IR, or Intermediate Representation. If you’re not familiar with compilers, and IR is a different representation of the same input program, but usually with simpler primitives and some amount of information erased. For example, in Rust, for loops are compiled down to simple loop/break statements in their MIR representation.

One interesting simplification in the IR in lattice is that every node has only a single output, where the edit-time nodes may have several outputs. Pragmatically, that means a node like:

9655139--1373936--upload_2024-2-20_13-6-2.png
9655139--1373939--upload_2024-2-20_13-6-17.png

Is represented in the IR as:

9655139--1373945--upload_2024-2-20_13-8-23.png

Multiple output ports for a node are represented as several single-value nodes that ‘project’ the field from the tuple object. Because of the dependency structure, field nodes you don’t use will never be executed.

The IR means the Lattice Runtime is now much simpler, operating on a very trivial number of custom nodes. It also paves the way for group nodes, advanced analysis, and compilation to C# long term. Additionally, this IR forms a full global graph for the entire program:

Global Graph
One of the key features of Lattice is that all scripts are unified into a single global compilation unit. Pragmatically, that means that your scripts can depend on values in other scripts. For example, a “UI Display” node in the UI Lattice Graph can depend on the “Player Inventory” node in the player graph. Or an “Enemy Behavior” node could depend on “Player Velocity”.

Broadly speaking, this means that Lattice forms a global ordering of all tasks within the frame. Importantly, this is all done at compile time. There is no runtime scheduling of tasks in Lattice, like in the Job system, or the Render Graph. Lattice Graphs are compiled as you edit them, and the unified global IR is executed at runtime (long term, compiled to C#).

Type Checking
With the IR comes some basic forms of type checking and type inference. This is nice because it also allows type coercion. Ie. You can plug an int? into a bool and it will automatically cast it using the nullable status as the boolean.

Types flow through the global IR, allowing you to also define nodes that pull their type information from their inputs. For example, a generic “PreviousFrameValue” node, which takes an input and returns the last frame’s value. This can now properly type-check, instead of having to use the ‘object’ type.

On the subject of types, Lattice uses the C# type system, with a few extra constraints:

  • A single nullable type. C# has several: Nullable, Entity.Null, and managed ‘null’. These are all merged under the hood into a singular Maybe type in Lattice type checking. This does not change runtime behavior, Lattice just automatically converts between them.
  • Entity is implied to be non-null. Lattice will generate an error if you return Entity.Null for type Entity. You must specifically call this out using Entity?, instead.
  • Propagating exceptions. Every node can throw exceptions, and these automatically propagate downwards. If an input node throws, all dependent nodes will also return an error, allowing you to follow the chain upwards to find the error.
  • No generics. I’m not opposed, and it’s perfectly possible, but implementing them is out of scope at the moment. I’m not in the busines of writing a Hindley–Milner type inference engine (yet. :p)

But largely, the goal is to stick close to C# because at the end of the day, all nodes are implemented in C# anyway, and need to use those types.


That’s all for now. Currently I’m implementing features as needed for our new project at Pontoco, for which we’re using it to author gameplay abilities (dashes, attacks) and some simple enemy AI (enemy types).

A loose roadmap for now:

  • Null propagation. If you pass “int?” to a function “bool greaterThanZero(int input)”, the function will automatically be lifted to “bool? greaterThanZero(int? input)” and pass the null value through. This is very helpful for stitching gameplay together where many entities may not exist.
  • Group nodes. Think “Sub-Graphs” in shader graph. But with different and hopefully much simpler semantics / editing workflows. Group nodes act sort of like function calls, except they are fully inlined in all instances.

Right now Lattice is still changing way too much to release something to play with, but heading in that direction! For now you’ll have to enjoy the updates. :slight_smile:

9655139--1373942--upload_2024-2-20_13-8-10.png

7 Likes

Will be curious to get to play with it

1 Like

Lattice has met a major milestone this week: programs now fully compile to flat, plain, .NET IL!

I’m very proud of this work. One of the goals of the Lattice project was to design a visual language that could compile down into fast linear code — effectively just the procedural code that you would write if you were coding it by hand. This is very much inspired by Rust’s tradition of “zero-cost abstractions”. With the new compilation pipeline, the node-graph representation is stripped away entirely. Outputs of nodes become local variables and bodies of nodes become plain static methods.

Originally I planned to generate C#, but IL was a much better option for a few reasons:

  • Generating C# requires a Domain Reload to compile which is a non-starter if you’re editing scripts while the game runs.
  • Generating C# would require shipping a C# compiler at runtime if you want to modify scripts during standalone play.
  • C# is a sloppy thing to generate. There are a lot of syntactical concerns you get bogged down in just trying to get something valid.

IL, as it turns out, is trivially easy to emit in-process with Reflection.Emit, and is actually really simple to work with. I use the Sigil library which is a validating wrapper which catches a number of type errors, etc, during generation. Plus, IL can do several things that C# can’t like calling private methods, etc, which just makes the whole process smoother.

This is all possible because of the IR representation I added to the compiler a month or two back. For example, my integration test graph blow is represented under the hood as a larger graph of simple primitives…



This IR is critical because it reduces the complexity of the next step of the compilation: code generation. While there may be hundreds of nodes available for user scripts, in the IR there are only 7 distinct types of operators:

  • Function (a handle to a static C# method)
  • Previous (allows referencing a node value from the previous frame)
  • Entity (returns a handle to the current Entity)
  • QualifierTransform (allows referencing other entities dynamically)
  • Barrier (execution barrier, waits for all inputs to finish)
  • Collect (collects several values into an array)
  • Malformed (a stub node that returns an error. Used for syntax errors)

The IL Generation step only needs to implement generation for these 7 operators, dramatically reducing the complexity. In fact, the IL Generator is only ~500 lines of code. The final code looks something like:


With the new backend, Lattice is now quite fast — as fast as C#! I still need to do comparative profiling, but I’m fairly confident Lattice is by far the fastest visual scripting system in Unity by a long shot. Bolt, NodeCanvas, and Playmaker all interpret their node graphs. Lattice emits a single static method that executes all lattice scripts in the game in a single pass. The .NET and Mono JITs eat pure static methods for breakfast.

This work also enables some interesting next steps for the compiler:

  • Automatic parallelization & jobification (Unity Job system)
  • Burst compilation of Lattice Graphs

However, Lattice is now plenty fast for my needs. So for the time being, I’m pivoting back to working on gameplay workflows and UX. We have some needs on the OST project at Pontoco that need some feature work in those areas.

I know ya’ll are hankering for something to play with. Getting there as quickly as we can. :slight_smile:

11 Likes

Oh, and another benefit of the IR: several different tools can compile down into it. For example, I’d like to have a separate interface for defining statemachine-like graphs. Both this and the current value-flow style graphs can compile down into the same representation for the compiler.

You could even imagine letting folks make their own custom tools that compile down into Lattice IR, just for performance.

1 Like

I’m curious! And I have a myriad of questions that pop into my head; I’ll limit myself to a few hopefully useful ones:

  • How does it integrate with the ECS?
  • How/where are the graphs stored?
  • Can we invoke/schedule the graphs when/where we want (e.g. jobs)
  • How do we invoke the graphs?
  • How are its dependencies managed?
  • How do you imagine parallelizing the graphs (e.g. what are it’s sync points)?

As for visual scripting systems that can compile to C#, uNode is a system that can do this.

As a programmer, the only graph system that I found useful to work with was the shader graph. For everything else I’ve never really been able to get along with visual scripting. I’d be very curious to see examples of gameplay systems where it shines, so that perhaps one day I will get along with it!

Hey thanks for the good questions.

Yeah, I completely agree! It’s one of the reasons I started this project – I truly believe there is a visual gameplay system that would make me more productive, but I haven’t found it yet! I’m hoping to show more gameplay examples. It’s tough without getting a whole video setup. For now, here’s an example weapon behavior I wrote recently for our project. It’s a special weapon that boosts the player and can jump off of walls.

A big part of my goal is to have few nodes in a graph. Most of the ‘mungy’ stuff like math and conditionals is contained within the node bodies in C#.

- How does it integrate with the ECS?
There are nodes for every ECS component. They can be driven (written to) by passing a value in from the top, and read from by reading the value out the bottom. This is from my spring example that drives a box’s LocalTransform position with a spring and keyboard input.

One thing that’s unique about Lattice – it has no execution wires! The system is declarative, more like Shadergraph, than Blueprints. You would read this as “The position of the LocalTransform is equal to SpringPosition”. Although this seems simplistic, with a couple of extensions this is actually quite nice. (More on this in the future)

- How/where are the graphs stored?
Graphs are just assets right now (ScriptableObject). Similar to Bolt, or C# scripts. You attach them with an authoring component.

- Can we invoke/schedule the graphs when/where we want (e.g. jobs)
Potentially. The system compiles a global graph for the entire process. And you can execute that whenever you like by calling the generated delegate.

That said, I’m currently working on adding ‘phases’. Ie. Some nodes that run at earlier parts of the frame, and some that run later. This is useful if you want to write into an ECS component, let a different System run, and then read again from the component later. So in the future, each node in the graph will be associated with a specific time in the frame that it executes.

- How do we invoke the graphs?
Currently it’s automatically invoked in the LatticeExecutionSystem. But that really just calls into the execution graph. The graph is just a big static function, so you could theoretically execute it whenever.

- How are its dependencies managed?
Dependencies on the asset side are handled like normal assets. You can also make node dependencies between graphs. For example “Enemy A charges at the player, if they’re holding item X”.

- How do you imagine parallelizing the graphs (e.g. what are it’s sync points)?
Parallelization can go two ways:

  • Per Entity: Each node is tagged by which entities it executes for. These can be split pretty naturally like IJobParallelFor, when there are many entities executing the same graphs / nodes.
  • Splitting the graph: Because the graph is composed of pure functions, nodes that depend on different values can be executed in parallel. The challenge here is determining how best to split this up, keeping in mind the overhead of scheduling (you wouldn’t want to run every node in parallel as a job, that’s too many jobs).

Keeping everything as static functions makes this really nice because we can introspect the precise data dependencies of each node.

3 Likes

Oh, and thanks for the tip off about uNode. I’d skimmed it, but hadn’t noticed the C# generation.

1 Like

Awesome! I like the declarative approach. It feels like it is adding a constraint, and while that means it perhaps won’t be good at certain things, it also means other things may flow very naturally from it. The declarative approach to evaluating nodes exists in more graph systems (like uNode, too), but there’s always an execution wire needed somewhere.

Looking forward to seeing more! :slight_smile:

Yeah, that’s exactly it. It’s a bit of a constraint, but there’s two escape hatches:

  • You can reference any value from the previous frame (ie. carry state forward)
  • Node bodies are C# functions, so they can do any looping/recursion/etc you might need.

In practice, it’s also fine for Node bodies to do mutable operations like writing to ECS state, static variables, or acting on passed in references. So while the graph is composed of pure functions, they only need to be ‘effectively pure’.

These combined, it’s not as limiting as it first seems, and you still get the benefits of the declarative structure. (That’s what enables the compilation pipeline / analysis)

where can i get a test package now?

And When…