Week One: From Zero to a Running Node Graph

First week of Lux. Coffee, Rust docs, and one question: can I build a node graph engine that feels right? Not “works in theory” right, actually right. Types that catch mistakes at compile time, evaluation that’s predictable, and a Spread type that makes you forget loops exist.

Spoiler: yes.

The Node trait

Everything in Lux is a node. Every node implements one trait:

pub trait Node: Send + Sync + 'static {
    fn info(&self) -> NodeInfo;
    fn process(&mut self, ctx: &mut ProcessContext);
}

That’s it. info() tells the graph what pins you have. process() reads inputs and writes outputs. A Circle node, a Math node, an FFT analyzer, they all look the same from the graph’s perspective.

The ProcessContext gives you typed access to your pins:

fn process(&mut self, ctx: &mut ProcessContext) {
    let a: f64 = ctx.input("a");
    let b: f64 = ctx.input("b");
    ctx.output("result", a + b);
}

No casting, no unsafe, no “did I get the right pin name?” at runtime. The FromPinValue trait handles coercion, feed an Int to a Number pin and it just converts. Feed a Color to a Vec4 pin? Same thing.

22 pin types

I went broad on the type system from day one:

  • Primitives: Number, Int, Bool, String
  • Spatial: Vec2, Vec3, Vec4, Color, Matrix4, Rect
  • GPU handles: Texture, Mesh, Shader, Layer
  • Media: Audio, Midi, Video
  • Meta: Bang, Any, Enum
  • Collections: Spread(Box<PinType>), Map

Some of these are placeholders, I’m not doing audio yet. But defining them now means the type system won’t need breaking changes later. A Texture pin knows it’s a texture even if the only thing flowing through it today is a u64 handle.

The graph evaluator

The graph is a DAG. When you connect nodes, I run Kahn’s algorithm to get a topological order. Every frame, the evaluator:

  1. Marks “always dirty” nodes (Time, Mouse, things that change every frame)
  2. Propagates dirty flags forward through connections
  3. Walks the topo order, skipping clean nodes
  4. For each dirty node: transfers upstream outputs to inputs, calls process()

Every process() call is wrapped in catch_unwind. A panicking node doesn’t crash your show, it logs an error and the graph keeps running. This matters a LOT when you’re performing live.

Spread, the good stuff

This is the part I was most excited about. Spread<T> is Lux’s core collection type:

pub struct Spread<T> {
    data: Vec<T>,
}

Simple wrapper, but the magic is in the semantics: wrapping index access. spread.get(7) on a 3-element spread gives you element 7 % 3 = 1. This means shorter spreads automatically cycle when combined with longer ones. No bounds checking, no padding, no errors. It just wraps.

I also built in a parallel threshold, spreads with 1000+ elements automatically use Rayon for map/filter/reduce operations. Below that, sequential is faster (no thread pool overhead).

462 tests on week one

Yeah, I went a bit overboard. But the core engine is the foundation everything else sits on, if PinValue::coerce() is wrong, every node behaves wrong. If the topo sort has a bug, evaluation order is wrong. If spread wrapping is off by one, every spread operation is wrong.

So I tested everything. Pin type coercion (every valid pair), graph operations (add, remove, connect, disconnect, cycle detection), evaluation order, dirty propagation, auto-spread behavior, the Spread<T> methods, the plugin registry.

95% line coverage on the core crate. Not because I’m obsessive, because this is the stuff that would be really painful to debug later.

What’s next

I have a node graph that evaluates. I have types, spreads, and a plugin system. But I have zero pixels on screen.

Time to fix that.

← Back to blog