A render engine in Python Chromatik was powerful. We wanted total control.
Chromatik already worked. This is the case for writing a second engine anyway, and the mirror that made it possible to write one without the sculpture in the room.
Chromatik already drove Naga, and drives it still. It ran the serpent at Black Rock City, it ran the Grand Lighting Ceremony in Golden Gate Park off a laptop at the edge of the pond, and it is installed on the Raspberry Pi today. Nothing about it was broken. So the first thing to say about writing a second engine is that it was not a rescue.
What changed is what we wanted to point at the sculpture. A visitor
with a phone, scanning a code on a plaque and painting light onto a
hundred feet of steel. That is a different shape of problem. It
wants a renderer a web API can command over a wire format rather
than a mouse, scenes that live in files a pull request can diff, and
a frame loop you can hold to a latency budget and then actually
measure. So the engine is a Python service that does one thing:
load the topology, run a scene, push pixels onto the wire, thirty
times a second. The controller decides which source is live and can
hand the LEDs back to Chromatik with a
systemctl call.
What a frame is
540 LEDs, 25 fixtures, five ESP32 nodes, thirty frames a second. That is a budget of thirty three milliseconds per frame on a Raspberry Pi, and a dropped frame is not an abstraction: it is a visible stutter across a hundred feet of animal.
For a long time the loop was a sequence of stages that had grown one at a time, and nobody could answer "what runs in what order" without reading it. Naming it was most of the fix. There are five layers, and every frame goes through all of them in the same order:
2 · deck top, blend mode chosen by the operator
3 · brush layer, the visitor's own touches
4 · hype overlay, strobe and sparkle
5 · calibration, per group and per fixture
→ the wire
The vocabulary above those layers is borrowed on purpose. A scene is an ordered list of channels. A channel has a target, a coordinate frame, a blend mode, a stack of patterns and a chain of effects. Those are Chromatik's words, used to mean Chromatik's things. It would have been easy to invent something more orthogonal and slightly better on paper. Keeping the borrowed vocabulary means anyone moving between the two runtimes is translating, not relearning, and porting a pattern is a transcription rather than a redesign.
One tick, top to bottom, with the call that does each part of it. The numbered rows inside step two are the same five layers; what the drawing adds is where the work happens, and what becomes of a frame after the last layer is done with it.
Scenes are files
There are sixty seven of them, and not one is a Python class. A
scene is a YAML file: channels, each naming a group of fixtures, a
frame to work in, and a kernel with its parameters.
rainbow-vortex is three channels. The body runs a
rainbow head to tail. The fins run the same kernel with
reverse: true, so the bands sweep the other way. The
spikes twinkle. On every kick both rainbows negate for 220
milliseconds and the whole thing visibly flips direction.
That split matters more than it looks. The kernel library grows slowly and carefully, because a kernel is numpy that has to finish inside the frame budget. The catalog grows fast, because a new look is a file. It also means the interesting knobs are declared next to the scene that uses them, which is what the hardware control surface binds to, and that is a story for the next entry.
The day nothing lit up
The engine emitted correct Art-Net for weeks without lighting a single LED.
Every packet was well formed. The network was right. The nodes were
up. Nothing errored, anywhere, which is the worst way for something
to be wrong. The fix came from giving up on reading our own code and
running tcpdump on the Pi to watch what Chromatik
actually put on the wire, and it corrected two things at once.
The first: the firmware listens on port 6454, the Art-Net standard. We were sending to 7890. A node that receives a packet on the wrong port does not complain, it simply never hears it.
The second was the more interesting one. Chromatik does not broadcast. It sends each universe to the one node that owns it. We had been broadcasting, which is simpler and looks equivalent, and is not: under broadcast every node parses every packet, and the firmware reports about 19 frames a second. Under unicast the same firmware on the same hardware reports about 139. Seven times, for a change in who you address rather than what you send.
The wire has one more opinion. LED strips do not agree on channel order. WS2812B is green first, WS2811 is blue first, some are plain RGB, and the sculpture has more than one kind on it. The engine's buffer stays RGB the whole way down, and the permutation happens in the last step before the bytes are packed, skipped entirely when the order is already RGB. No pattern, scene, effect or brush ever learns that the wire has a preference.
The network came out of the model
Here is the part I like best, and it is the least visible.
Nobody typed the network map into the engine. Chromatik's model
file, the .lxm, is JSON, and it already carries
everything the wire needs, because Chromatik needed it too. A
script reads that file and writes the topology the engine loads. Per
fixture it lifts the hostname of the node that drives it, the
Art-Net universe it owns, the channel order it is wired in, and
whether its pixels run backwards.
So the model Dan maintains by dragging fixtures around in a graphical application is the deployment configuration. There is no second copy to keep in step, because there is no second copy. The generated files are never edited by hand and the check runs in CI, so a model change that would break the wire fails before it ships. Where a fact genuinely does not live in the model, a strip wired in an order the file cannot express, it goes in a small sidecar the same script merges in, which keeps one generated artifact and one place to edit it.
The cost is real and worth saying out loud. That file is now load-bearing for runtime correctness, not just for designing patterns. A typo in a hostname is no longer a cosmetic mistake in a drawing. It is one dark segment.
A mirror for the wire
None of the above could have been written with the sculpture in the room, because the sculpture is a hundred feet of steel in a public park and I am not.
So the second piece of software is a simulator. It binds the same UDP port the nodes listen on and catches the engine's own packets. What makes it a test rather than a preview is that it refuses to take the engine's word for anything. It reconstructs the picture the way a node would: it undoes the per fixture reverse, and it applies the inverse of the channel permutation, so what the browser draws is what a correctly wired strip would show. A wiring assumption that is wrong in the engine is wrong on screen, in the same direction, which is the only property that makes it worth trusting.
The parser is the other half of that bargain. Its tests round trip against the engine's own packet builder, so the two ends of the wire cannot drift apart without a test going red. From there it is a Three.js page fed by a binary WebSocket about thirty times a second: four bytes of magic, a frame counter, then 540 pixels of raw RGB. No JSON anywhere near the hot path.
Three frames of the same 540 pixels
A pixel has three addresses, and every one of them is the right answer to a different question.
There is the wire frame, which is the order the pixels are actually addressed in, one flat strip per fixture. There is the body frame, the serpent as the model draws it: straight strips and clean helices, laid out as designed. And there is the world frame, the serpent as it actually hangs, curved over the pond through three humps with its head out of the water. The geometry story is told properly in the previous entry; what matters here is that a channel picks one, per channel, so a single scene can mix them.
A rainbow along the body sweeps head to tail down the animal, and stays correct however the body is arranged. The same rainbow in the world sweeps through the park and lights whichever part of the serpent is standing in its way. Neither is more true than the other.
The calls that mattered
The render loop must not own the event loop. Rendering and sending ran synchronously inside the same asyncio loop as the HTTP control plane, so every incoming command sat in the kernel until the frame finished. It showed up as a stubborn ten to fifteen milliseconds on a hop that had no business costing anything. Moving the work to a thread fixed it, and the decision record spells out the thread safety argument rather than asserting it: state is only written at the top of the tick, before the thread starts, so there is nothing to race with.
One dead node must not black out the sculpture. The tail node's weatherproof box went quiet, its hostname stopped resolving, the lookup raised inside a dictionary comprehension, startup aborted, and systemd dutifully restart-looped it. The whole serpent went dark with four healthy segments on it. An unresolvable host is now skipped and retried instead of fatal. The general shape of that lesson: failing loudly is the right default right up until the blast radius is the entire piece.
Resolve once, not per send. Hostnames are the stable identifier on a network handing out addresses by DHCP, but a name lookup inside a thirty three millisecond budget is not survivable. Resolution happens when the socket opens, and again on a slow refresh loop, and never in the hot path.
Measure the latency, do not assert it. On the local development stack a full round trip is 31.7 milliseconds on average, and about eighty percent of that is the engine waiting for the next tick boundary. That is the honest shape of a thirty frame a second system. The network is nearly free and the frame clock is the whole budget, which is worth knowing before optimizing the wrong end of it.
What it buys
A pattern can now be written, watched, argued with and thrown away without anyone driving to the park. The simulator has run for millions of packets with nothing unmapped and no gaps in sequence, which is a claim worth being able to make out loud, because it is the difference between believing the wire is right and knowing it.
Both engines are installed on the Pi and the controller picks between them. Chromatik is not a fallback we are embarrassed about; it is a known good renderer with years on it, and there is no date in any plan for removing it. The Python engine earns its place by being the one a visitor's phone can talk to.
The next entry is about what happens when you put the knobs from the third section onto real hardware and play the sculpture live, through a DJ set, on the beat.