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:
- Marks “always dirty” nodes (Time, Mouse, things that change every frame)
- Propagates dirty flags forward through connections
- Walks the topo order, skipping clean nodes
- 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.