Engineering Journal

← all posts

Part of Building the 3D Club Scene

One beat clock, one room

3D & motionSoftware & tooling

Eye-level in the crowd: the pole, spotlights, lasers, video wall, mirror ball, and a crowd member's phone glow all lit at once — the room this post is about teaching to take turns.

Most of the lights in our virtual club (wall, lasers, strobes, neon, mirror ball) were already on a shared bar clock and still had no idea the others existed. Hanging a set of spatial sequence modes off that clock turned nine independent effects into one instrument. The interesting parts are geographic: zones defined by where things hang, a brightness wave that sweeps the room by azimuth, and a floor rule meant to keep the hero prop lit that we only half-shipped.


The problem with everything being on

Each element of the scene was individually good: the video wall pulses, the lasers fan, the strobes hit on schedule, the pole runs its pattern engine. Together they had the energy of a hotel lobby with every TV on. Real light shows don't run everything at once — an operator pushes attention around the room. The wall takes a phrase, then the rig takes it back. What our scene was missing wasn't another effect; it was the taking turns.

Most of the elements already shared a clock. A module-level BEAT tracks bpm, bar, beat-in-bar, and phase (the dance take is cut at 114 bpm), and everything that hits quantizes to it: video wall, lasers, strobes, neon, shafts, mirror ball, crowd, pyro. The pole and the fixtures that follow its color (spotlights, uplights, booth) were the exception, running off the pattern engine's own cadence instead. So shared time was most of the way there. Shared space was the gap: no element knew where any other element was, so there was no way to say "the bright spot moves around the room."

Zones by geography

The sequencer's zones are just our existing per-element level channels, split by where things hang:

And the perimeter zones each get a bearing: the wall arc at 222°, the laser fans at 120°, the strobe truss at 320°, the neon signs at 40°, the volumetric shafts at 60°. Only the wall's number is literally the scene's geometry (222° is the arc's own theta constant). The other four zones are symmetric pairs or truss runs straight through the room center, so they have no single bearing to measure; those numbers are hand-placed, near enough to where the fixture reads from and spaced so the sweep visits them in an order the eye accepts. That table is the whole spatial model. No scene graph traversal, no bounding boxes, five numbers.

A per-frame director computes a gain of 0..1 per zone, and every element multiplies gain(zone) at exactly the line where it already multiplies its user-facing level slider. Integration cost per element: one line. Elements without a zone (the booth, the crowd, the haze) always read 1, so no sequence can black the room out on its own. That invariant covers one multiplier, not the whole pipe: the level sliders bottom out at 0 and the grade panel's brightness goes to -0.7, and either will still get you a dark room.

The modes

Four modes, all phase-locked to the bar count rather than wall time, with a period of 1, 2, or 4 bars and a depth knob for how far the off-phase dips:

Unified — every gain is 1. The status quo, kept as a mode because looks that want wall-to-wall energy are legitimate.

Alternate — stage and perimeter trade. The naive version is a square wave off sin(2π·u) > 0, and it clicks: half the room changes brightness between two frames, with the frame boundary picking which millisecond it happens on. Smoothstepping the crossing over about a tenth of the cycle was the difference between "flicker bug" and "someone cut to the wall." At one bar and full depth this mode is the pole/screen strobe trade we originally wanted.

Wave — a bright window sweeps the perimeter by azimuth, one lap per period. Each zone's gain falls off with angular distance from the sweep center, full brightness inside a 110° window. This is where the bearing table pays off: the wave reads as travelling around you because it visits the zones one at a time in a fixed ring order, instead of changing brightness everywhere at once. The stage doesn't sweep (a fixture at the center has no bearing) so it breathes gently in sync instead, the whole stage group floored at 0.55 so the pole reads as the source of the wave rather than a victim of it.

Three moments in one wave cycle: the bright window reaches the lasers at 120 degrees, the video wall at 222 degrees, then the beatshow at 320 degrees.

One camera, one cycle. The highlight visits each zone in the order the bearing table gives it, rather than merely changing brightness everywhere at once.

Breath — everything swells together on a slow sine across two periods. The one wrinkle: the pole's dip is halved. Full-room breathing at depth 0.6 with the hero swinging 40–100% looked like a brownout.

A floor rule is the closest thing the sequencer has to a design principle: the pole is the product, so it should be the last thing the choreography takes away. We only half-implemented it. Breath gives the pole its own halved dip. Wave floors the whole stage group at 0.55, which protects the pole but also the uplights, mirror ball and spotlights standing next to it. Alternate gives it nothing at all: at depth 1 the stage half goes to zero like everything else in it, so the pole does go dark there. Attention can leave the pole; outside alternate, light mostly doesn't.

The same room under four sequence states: unified, alternate with the stage lit, alternate with the perimeter lit, and the trough of breath mode.

The modes are changes in attention, not four new scenes. The camera, palette, and bar clock are held constant.

Sequences belong to looks

Mode, period, and depth are stored per look — the scene's preset system — so "Slow Blues" carries a 4-bar breath and "Rainbow Party" carries a 1-bar alternate, and switching looks re-choreographs the room along with re-coloring it. Depth turned out to be the knob that needed the most restraint: our first auto-generated looks shipped waves at depth 0.85–0.9, which parks most of the room at 10–15% brightness most of the time. On a screenshot that's "moody"; over thirty seconds it's "is something broken." We measured mean frame luminance per look and pulled the factory wave depths to 0.5–0.55 (the alternates and breaths came down too, to 0.8–0.85 and 0.35–0.4). Mean luma is a crude stand-in for "does this read as broken", but it decides an argument that screenshots could not. If you build a depth knob, assume whoever authors presets — us, in this case — will over-crank it, because the deep settings demo well in the first five seconds and die over a full song.

The part we didn't build

There's no timeline. Every mode is a pure function of the bar count, which means no cues to author, no two cue lists to drift apart, and any element can be added to the choreography by giving it a zone name and a bearing. (The bar count itself is an accumulated per-frame delta, so it can lag wall time on a stalled tab. Nothing in the scene plays audio, so there is nothing for it to drift against yet; the day there's a track, that changes.) The obvious next step — an actual cue list, "wave for 8 bars then trade for 4" — is sitting right there, and we keep not needing it: four procedural behaviors that respect the geometry of the room cover a surprising amount of what a human operator does. The room takes turns now. That was the feature.