Part of Building the 3D Club Scene
Shooting a stage that isn't there: an on-rails camera for a real-time scene


The dancer from the last post needed a music video. We built her a camera department instead: dolly, jib, orbit and long-lens rails, a cut list synced to her choreography, and a lighting operator. No pose timeline: we author geometry, cut points, and a few spline control points, and the engine generates every frame between them. This is the part of the project where the physics stopped and the taste started.
Once the retargeting pipeline could put any body on the pole, the obvious next question was embarrassing in its simplicity: how do you look at it? A free-orbit camera is fine for debugging and terrible for wanting things. Product pages, hero banners, trade-show loops — those need the camera to have opinions.
We decided early that the camera should move like equipment that exists. Not because realism is sacred — because a century of film grammar has trained everyone's eye. A dolly reads as intent. A jib reads as reveal. A camera that swoops through arcs no crane could hold reads as a video game, and we are trying very hard not to look like one. The constraint is the aesthetic.
Shots that never end
The first design decision that mattered: a shot never finishes. Each one is a plain data object — { type: 'orbit', radius, height, periodSec, … } — evaluated by a pure function of shot-local time. An orbit circles forever. A dolly runs its spline to the far end and eases back. A "static" shot drifts on six sine frequencies chosen so no two share a short common period: 0.011 to 0.041 Hz, which puts the composite's true repeat at about sixteen minutes, and the slowest component alone at 91 seconds. A static shot holds for eight to twelve, so it never gets anywhere near its own loop.
That sounds like a small implementation detail; it's actually the whole architecture. Because no shot has a duration, no shot has a "finished" state, and the decision of when to cut lives in exactly one place — the rig that owns the timeline. Shots compose like loops in music production: the cut list arranges them, the material itself is endless.
Dollies and jibs can't loop the way orbits do — a straight track has no seam to hide — so they ping-pong, and the ping-pong runs through an ease so velocity reaches zero at both turnarounds. A camera that reverses direction with nonzero velocity is instantly, viscerally CGI. Zero-velocity turnarounds read as an operator changing their mind.
Cutting onto a moving rail
Transitions were the fussiest part. The naive version — lerp from shot A's camera to shot B's starting pose, then start shot B — has a giveaway flaw: shot B's rail is already moving, so the blend lands on a pose the rail occupied 2.2 seconds ago, and the camera visibly hiccups as it hands off.
The fix: blend from the camera's actual on-screen transform toward shot B's live, currently-evaluating pose. The destination is a moving target; the ease chases it and lands in stride, like stepping onto a moving walkway. Cuts (zero-duration transitions) exist too — the show uses both, and the difference between "cut" and "catch" turns out to be most of what makes the reel feel edited rather than generated.
The show is a timeline of three languages
The autoplay reel is one array. Each entry names a shot, a hold duration, what the choreography should do, and what the lights should do:
{ shot: 'close spin', holdSec: 8, dance: { seek: 0.28, rate: 0.75 }, lights: 'silhouette' },
{ shot: "god's eye", holdSec: 8, dance: { seek: 0.56, rate: 1 }, lights: 'pole' },
{ shot: 'tele creep', holdSec: 12, dance: { seek: 0.62, rate: 0.85 }, lights: 'silhouette' },
dance.seek jumps the mocap to a normalized phase of the take the instant the entry begins — so each cut lands on the beat of the choreography it was chosen for, no matter how long the previous shot held. dance.rate is a transport control: 0.75 is film-style slow motion for the sculptural holds, 0 freezes her mid-pose. The dancer component exposes a two-field transport (rate, seek) and the show drives it; nothing else about the animation stack knows the cinema page exists.
lights names a cue for the moving-head rig — including a request-only silhouette cue that swings the upstage fixtures low and hot behind her so she reads as a black cutout in a cone of haze. The lighting rig runs its own autonomous plot when no one's asking; a cue is a temporary possession, released at the next entry.
The phases in the seek values aren't aesthetic guesses, by the way — they're read off the same source-contact curves the grip system built in the last post. The capture knows where the climbs and holds are; the cut list just points cameras at them.

Lenses are half the cinematography
For most of development the camera had one focal length, and everything looked like coverage. The fix cost two numbers per shot: a field of view, and a depth-of-field bokeh scale, both blended through the same eases as the camera moves so a lens change reads as a rack, not a swap.
The shot that sold it is tele creep: a 21° field of view on a dolly that barely moves, seven meters out, bokeh cranked. (three.js measures fov vertically, so on a full-frame gate that's about a 65mm lens. Read 21° as a diagonal angle of view, the way a lens spec sheet quotes it, and you'd call it a 115mm. It is the shorter one.) Telephoto compression stacks the pole, the dancer and the back-wall booth into one flattened plane; the shallow focus dissolves everything but her. It was the most "expensive-looking" shot in the first reel and it's four lines of data. It did not survive the recut described below: tele creep is gone from the shot library entirely.

One camera-department detail we stole outright: the look-at point is damped separately from the camera position, and every "aim at the dancer" shot tracks a smoothed version of her chest — found by scene traversal, re-resolved every 1.5 seconds so swapping the character mid-show doesn't strand the camera staring at a skeleton that no longer exists. When a seek teleports the choreography, the camera pans to reacquire her, because that's what an operator would do. Position is instant; attention has inertia.
There's also a handheld layer — six-frequency rotational noise, a fraction of a degree, applied per shot by taste. On the floor-level track it adds documentary nerves; on the god's eye it would be seasickness, so it's zero there.
A whole reel from two data files
The first reel was ~73 seconds and looped, eight shots. That is the version this post was written against, and it had already been replaced when the post went up: on the evening of 23 July it was recut into a 30-second loop of six 5-second shots, five of them seeking the take to a measured highlight; the sixth, a blended handoff, deliberately carries no seek of its own and plays inside the window the previous shot opened. The structure below is what did not change. Every number in the reel (shot geometry, cut points, dance phases, light cues) lives in two declarative files a designer could edit without touching the engine. Not "no keyframes", exactly: a dolly's three spline control points are authored positions sampled by time, which is a keyframed path wearing a hat. What's absent is a timeline of poses. Nothing is keyed per frame, no channel is scrubbed, and the timing between the authored points is generated. The hero-banner variant is the same page with ?hero: UI hidden, letterboxed, autoplaying.
The other thing that had to happen for phones is less glamorous: the LED pole's pattern engine (real firmware patterns, vendored into the page as six <script> tags) wouldn't load on mobile WebKit. Some builds of it, at least; we never pinned down which, or why. Rather than debug a browser we can't attach a console to, we pre-render each pattern to a binary sheet of frames (160 frames of 12×240 RGB, 1.4MB apiece, three of them) at build time and replay those on every platform, crossfading between patterns on a timer. The engine becomes a dev-only luxury behind a query flag.
That trade is not free and we never measured the half of it we lost. The sheets are baked at 10fps against the engine's own 20fps repaint, so every device now gets half the pattern cadence to fix a problem some of them didn't have, plus 4MB of assets to fetch. We chose it because an undebuggable blank pole on a phone is worse than a slower one everywhere, not because we knew what it cost.
What we'd tell the next person
Make shots endless and cuts owned by exactly one timeline — the moment a shot knows its own duration, every change to the edit is a change to the shots. Blend onto live rails, never onto stored poses. Give attention inertia even when position teleports. And spend your polish budget on lenses before you spend it on paths: the difference between "screen recording" and "cinematography" turned out to be mostly field of view and focus, not camera trajectories.
The reel is running in the same private build as the last post; if you're reading this, you probably have the password.