Part of Building the 3D Club Scene
Pause is not a fixed point: giving a debug tool the same physics as the thing it debugs


We handed our motion review page to a human for the first time and he broke it in three sentences: frame-stepping made the elbow flap like a chicken, pausing near the start seemed to restart the dance, and the space bar did nothing. Every one was real. Fixing them turned into a small essay on what "pause", "step back", and "jump to 12 s" even mean when the thing you are inspecting has seconds of internal state.
The setup
Our stage runs in three.js, so the dancer you see is produced by a live solver: inverse kinematics for the arms, pinned grips on the pole, a foot-plant lifecycle, a root corrector that pulls the whole body toward a grip it cannot quite reach, several rate-limited motors that keep all of it from snapping. The headless QA harness had been scanning that solver for days — seven cameras, 1,773 frames of telemetry per loop, twelve checks.
Then we built a review page a human could drive: scrub, pause, frame-step, orbit, drop a flag where it looks wrong. Headless capture had never exercised any of that, because a batch scan only ever plays forward at a fixed rate.
The first session with a human in the chair produced three complaints in about two minutes, and all three were the same bug wearing different clothes: the review tool did not share the solver's physics.
1. A paused frame must be a fixed point
Frame-stepping made the elbow "flap like a chicken."
The corrective states integrate time: the root chase, the pin slip, the azimuth relief motor, the plant blends. Pausing stopped the animation mixer and left all of those running against wall-clock. A pinned frame kept consuming time, so the pose kept converging — the elbow wandered about 180 mm over two paused seconds — and then snapped back the moment you stepped forward and the clip's own pose reasserted itself.
Which is a genuinely uncomfortable property to state out loud: the frame you are staring at while paused was not a frame that ever rendered during playback. You are inspecting an artifact of your own pause.
Pause now freezes solver time outright. dt = 0 holds every rate limit, blend
and integrator that scales with it. A paused frame is a fixed point or it is not
evidence.
2. Freezing exposed the next layer
At dt = 0 the body slid across the floor at constant speed.
The root acceleration clamp — built to stop the whole-body corrector from lurching — estimates current velocity from a short history buffer and extrapolates. With no time passing, the buffer never updates, the estimate never decays, and the clamp confidently keeps applying the last velocity it saw forever.
Nothing about that code is wrong for the case it was written for. It had simply never been told that time can stop. Guards built for motion need explicit frozen-time behavior, and if you have a rate limiter, an accumulator, or a predictor anywhere in a render loop, it has this bug right now.
Worth saying plainly: the fixed-point property is built corrector by corrector,
not verified. There is one hand-written frozen-time branch in the root solver and
everything else is trusted to scale with dt. Nothing re-renders a paused frame
twice and compares the two poses, which is exactly how the clamp above survived
the first fix.
3. The deep one: state is path-dependent over seconds
The third complaint was that pausing near the start of the take seemed to restart the whole dance. That one took the longest and produced the actual design.
The naive fix for scrubbing is to jump the clock and re-converge on the target frame. We tried that, and it does not work, because several of the solver's decisions are path-dependent over seconds:
- The elbow-swivel motor is a stateful search: it climbs a clearance gradient when violated and relaxes toward the natural pose when clear, both under rate caps. Let it re-converge freely on two adjacent frames and it settles about 140 mm apart — same clip time, same body, different history.
- A short rewind is not enough either. Replaying 0.5 s before a target frame landed the swivel at −0.05 rad where real playback sits at −1.05 rad. Half a second is not long enough to re-earn a second of accumulated state.
So the scrub engine is direction-aware, and every operation is defined in terms of what it does to solver state rather than what it does to the display:
- Pause pins the live playhead instantly — no re-solve, because the state on screen is already playback's own.
- +1 frame advances the real solver by exactly one frame. Not a jump: a step.
- Backward steps and slider jumps rewind 1.5 s and visibly replay real take history through the solver before pinning the requested frame.
1.5 s is where we stopped, not a horizon we proved. It is the rewind that stopped adjacent seeks landing on different poses; 0.5 s did not, and nothing between the two was tried. The replay also does not reset the correctors first: the pinned state is whatever the solver was already holding, plus 1.5 s of real history run over the top. Anything that accumulates over a longer span than that is not re-earned by it, and this solver has such state. A hand engaged for fourteen unbroken seconds carries motors that have been integrating the whole time.
A backward seek first chooses the requested frame, rewinds 1.5 seconds, replays every intervening frame through the real solver, and only then pins the requested frame. Pause preserves the live state with solver dt equal to zero; a forward step advances the real solver by exactly one frame.
That last one has a visible consequence we decided to keep rather than hide: a little fast-forward beat after every backward jump. It is not a glitch. It is the price of a truthful pose, and once you know what it means it is reassuring — you can see the tool re-earning the state it is about to show you.
4. And one entirely ordinary web bug, for garnish
The space bar did nothing.
Clicked buttons keep keyboard focus, so pressing space fired our pause handler and the focused button's native activation. Two toggles, net zero.
We include this because it is the honest texture of the session: two of the four findings were deep properties of stateful solvers and one was a DOM default from 1996. Review tools have physics, and they also have focus rings.
Ghost mode: making the solver's diff visible
Everything above is about trusting a single frame. The other half of the problem is attribution: when something looks wrong, was it the recording or was it us?
Every number in the review measures the gap between what the take asks and what the solver renders, but numbers needed a human-visible form. So the page gained a ghost: a second copy of the same character, driven by its own animation mixer playing the same retargeted clip, with every runtime corrector switched off — no grip IK, no leg pass, no root correction, no pole avoidance — offset 1.5 m to the side and slaved to the same action clock, including pause, frame-step and the rewind-replay.
Identical bodies on one clock. Any difference in the pose is the solver. Dimmed and cooled materials keep it legible as the reference copy rather than as a second dancer.
Two design notes worth keeping:
- The ghost is a
SkeletonUtils.clonedriven inside the dancer's own frame loop. A siblinguseFramewould register first and trail by a frame, because child effects run before parents in React — a one-frame offset between the two bodies would have made every honest comparison look like a solver lag. - Checking that a display-only feature does not perturb the thing it displays took two full scans, with and without the flag: same 6-of-12 checks, per-check counts inside scan-to-scan variance, and the ghost scan was the calmer of the two. Weaker evidence than it looked at the time. That same capture path was later measured nondeterministic on byte-identical input (four runs of one bake gave three, four, five and five detected windows), so two draws cannot separate an observer effect from the harness's own noise. If you add an observer to a stateful system, measure the observer, and calibrate the instrument you measure it with first.
The page now carries three references at once: the raw source skeleton in orange (was the input sane?), the ghost (what did we do to it?), and the shipped dancer (what does the viewer get?). Those are three different questions and we used to answer all of them with one video.
The flag loop is what the tool is for
The endgame of all this is not that a human watches more carefully. It is that a human's impression becomes a machine-readable address.
The reviewer scrubs, orbits, and drops timestamped flags exactly where the motion looks wrong. Flags export as JSON. An agent reads them and answers each one from telemetry — which corrector fired, what the source was doing at that instant, whether any check was near its limit.
The suite catches what we thought to measure. The flags catch what we did not. And the flag turns "around the 4 second mark, kind of" into 2.83 s, note attached, reproducible forever.
Here is that loop paying for itself twice in one session. The complaint was "the leg violently shakes." Telemetry resolved it to the right knee's bend plane vibrating ±12–26° per frame through five consecutive direction flips between 2.81 and 2.90 s. The mechanism: a clip-relative plane budget, added earlier so fast kicks could track their own motion, was being spent chasing the flip-flop between two remedies while the foot was planted and the other leg swept past.
The fix is one line with a principle inside it. Sustained same-direction rotation keeps the full clip-relative budget; a direction reversal gets only the flat floor. Oscillation collapses to about 5.7° per frame, kicks still track. Budgets should pay for progress, not for dithering. That is one mechanism with one before and after, not a swept result. A later census did find three other decisions in the solver flipping between near-tied choices and fixed all three with hysteresis, which is the same instinct rather than proof of the same rule.
The generalisable version
If you are building an inspector for anything with internal state — a physics sim, a solver, a simulation, a game loop, a streaming pipeline — the three questions worth asking on day one:
- Is a paused frame a fixed point? If any integrator, accumulator or predictor still runs while paused, the frame your user is judging is not a frame your system produces.
- What does "step back" mean? If state is path-dependent, going backward is not seeking, it is replaying. Decide how far back you must rewind to re-earn the state and measure that horizon rather than guessing (0.5 s measurably failed, 1.5 s is where we stopped, and that is a floor we tested rather than a ceiling we established), and let the user see it happen.
- Can you show the same input with the machinery off? A reference copy on the same clock converts "this looks weird" into "this specific pass did that," which is the difference between a complaint and a bug report.
None of it is glamorous work, and all of it is why the next round of fixes could be diagnosed in ninety seconds instead of an afternoon.