Spreads: The Core of Lux

What if you never wrote a loop again?

I just added my favourite thing: Spreads.

One wire carries [10, 20, 30, 40, 50]. Every node downstream evaluates per-element automatically. Shorter spreads wrap. It just works.

What’s a Spread?

A Spread is an ordered collection of values that flows through a single wire. Instead of one number, you get many. Instead of one position, you get a thousand.

LinearSpread(center=0, width=100, count=5)
→ [-50, -25, 0, 25, 50]

Feed that into a Circle node’s X position, and you get five circles. The Circle node doesn’t know about spreads, it just sees “here’s an X position” and draws a circle. The evaluator calls it five times, once per element, and collects the outputs into a new spread.

This is called auto-spreading. I talked about why this matters so much to me in the first post, it’s the thing that made me fall in love with visual programming.

How auto-spreading works

The evaluator checks each node’s inputs before calling process(). If it sees a Spread value on a pin that expects a scalar (like Number), it kicks into auto-spread mode:

  1. Find the longest spread across all inputs → that’s the broadcast length
  2. For each element 0..N:
    • Extract element i from each spread input (with wrapping: i % len)
    • Call process() with those scalar values
    • Collect the outputs
  3. Merge all per-element outputs into new spreads

The wrapping is the key insight. A 5-element position spread combined with a 3-element color spread? The colors wrap: element 3 gets color 0, element 4 gets color 1. No errors, no padding, no special cases.

positions: [0, 1, 2, 3, 4]
colors:    [red, green, blue]
result:    [red@0, green@1, blue@2, red@3, green@4]

Spread creator nodes

I built four generators:

LinearSpread, Evenly spaced values. Give it a center, width, and count. The bread and butter.

RandomSpread, Random values in a range, driven by a seed. Same seed = same output. Deterministic randomness matters when you’re performing live.

CircularSpread, Points distributed on a circle. Center, radius, count, phase offset. Feed this into position pins for instant radial layouts.

GridSpread, Points on a 2D grid. Rows, columns, size. The quick way to fill a screen.

All four are marked spread_native, they handle spreads internally and don’t get auto-spread treatment. They’re the spread creators, not consumers.

Spread operations

Once you have spreads, you need to process them:

  • Map nodes: apply a function per-element (this is just auto-spreading)
  • Reduce nodes: collapse a spread to a single value (Sum, Average, Min, Max, Count)
  • Filter nodes: keep elements matching a condition
  • Sort nodes: reorder by value
  • Take/Skip: slice into sub-spreads
  • Reverse: flip the order
  • Zip: combine element-wise

The reduction nodes are spread_native too, they explicitly consume spreads and output scalars. You don’t want Sum to auto-spread (that would just return each element unchanged).

Seeing it on the wires

Here’s a nice touch: when a spread flows through a wire, a badge appears at the midpoint showing the count. x5 means five elements. x1000 means a thousand.

The badge sits on a pill-shaped background positioned at the bezier curve’s midpoint (t=0.5). It’s a small thing, but it makes the data flow visible. You can see at a glance which wires carry spreads and how large they are.

Spread wires also render slightly thicker than scalar wires. Another visual cue.

The panic problem

What happens if a node panics during auto-spread iteration 47 of 100?

I considered keeping the first 47 results and outputting a partial spread. But partial data is worse than no data, downstream nodes would get a spread of unexpected length, and debugging would be a nightmare.

So the rule is: if any iteration panics, the entire spread output is discarded. The node logs an error, outputs nothing, and the rest of the graph continues. Clean failure.

What it feels like

Spreads change how you think about visual programming. You stop thinking “I need a circle” and start thinking “I need a spread of circles.” Every node becomes a parallel operation. A color adjustment node becomes a batch color adjustment. A transform becomes a batch transform.

Connect a LinearSpread to a Circle’s radius, and you get concentric rings. Connect a RandomSpread to colors, and every circle is different. Connect a CircularSpread to positions, and they’re arranged in a ring. Layer these and you’re making generative art without a single loop.

That’s the point.

← Back to blog