Part of Building the 3D Club Scene
Watching a pole dancer with numbers: a motion-QA lab, and the mistakes it couldn't catch


The last post shipped a retargeting pipeline with hard invariants and rate-limited motors: nothing should be able to teleport. So we built a browser-based motion-QA lab to check: seven cameras, 1,773 frames of telemetry a loop, a regression suite that plays the raw performance back for comparison. It found four families of failure the demo reel never showed, including one escape hatch that was quietly manufacturing exactly the teleports we'd sworn off. It also missed things, confidently, for days — which turned out to be the more useful half of the story.
The pipeline from the last post looked done. It played, hands found the pole, feet found the floor. Then we stopped watching the render and started measuring it, and the story got a lot less flattering.
Our stage runs in three.js, which means the thing we're usually squinting at through video compression is, in this case, just... available. The real solver, the final rendered skeleton, the pole geometry that causes every failure, all live in one page. So instead of eyeballing footage, we turned the review page into a small motion-QA laboratory: instrument the dancer without touching the pose solver, park purpose-built cameras on the feet and both grips and the joints, record the actual rendered result headlessly, turn every frame into structured measurements, flag suspicious intervals automatically, then go back and package video evidence for each one. Scan, diagnose, fix, rescan: the same test, every round.

The comparison that convicts
The cheapest way to dodge blame for bad motion is "the mocap was just like that." So the seventh camera plays the raw source skeleton next to our rendered dancer, same clock, side by side. That comparison is what turned a vague uneasy feeling into numbers we couldn't argue with. Measured in world space, the same way as our own output and at the same instant, the performer's right shin turns at about 650 degrees per second around 2.97 seconds. Ours hit 7,736 there: about twelvefold amplification. The performer's foot plants; ours slid at 4.57 meters per second while the solver's own state insisted it was "planted." We weren't reproducing her choreography. We were amplifying our own bugs and blaming her.
That "measured the same way" is doing real work in the sentence, and getting it wrong is easy. A source bone's local per-track peak and our rendered bone's world speed are not the same quantity: world speed composes every parent joint, so a source bone's world motion legitimately exceeds any local track number, and pairing the two across different moments in the take produces a comparison that flatters or damns whichever side you like. The analyzer measures both sides in world space, at the same timestamp, or it does not get to convict anyone.
One full loop, sampled at 60Hz, is 1,773 frames. That's the unit everything below is measured in.
Four ways to fail at pole dancing
Sorted by what actually broke, not by where in the code it lived:
Ground contact. A planted foot slides, hovers, or sinks into the floor as a leg correction releases too abruptly.
Pole collision. The forearm passes through the pole even when the hand's target position is satisfied exactly: wrist-perfect, forearm-impossible. The right forearm was inside the pole's collision volume for 80.8% of the whole take, up to 67.5mm deep. That number, and every collision number in this post, comes with a scope attached: they are measured against collision radii hardcoded roughly 40% thinner than the rendered mesh, a torso at 0.09 m against a true half-depth nearer 0.13 to 0.16 m. Read every collision percentage here as an understatement. How a wrong constant got to issue false assurance for a week is its own post.
Grip continuity. Hand position stays glued to the pole while hand orientation snaps 6,000+ degrees per second during a grip transition. The wrist doesn't move, the palm flips.
Whole-body correction. An unreachable grip gets "solved" by yanking the entire dancer's root position, which reads on screen as a full-body lurch.
The forearm number is the one worth sitting with, so here's the same moment — 18.82 seconds into the take — before and after the repair pass:

The fix that wasn't clever
Ten scan-diagnose-fix rounds in one session, every diagnosis made from frame-level telemetry, never from watching video first. And the single most consequential fix was almost embarrassing: the elbow-swivel motor that's supposed to keep the forearm off the pole demanded 45mm of clearance at a wrist that is, by design, pinned 32mm off the pole surface. The constraint was mathematically impossible to satisfy on every single gripping frame, so the motor sat frozen at a flat gradient (paralyzed) inside the pole for 80% of the take. Tapering the clearance model, 45mm at the elbow down to 25mm at the wrist, un-paralyzed it in one change.
That's the pattern that recurs through this whole project: a constraint the legitimate motion cannot satisfy doesn't restrain the solver. It detonates somewhere else.
Two more repeat offenders:
Three.js picks the shortest interpolation path every frame by default, so any blend between two moving poses flips to the opposite branch the instant their angle crosses 180 degrees. The same bug wore three different costumes across the codebase (a 7,647°/s grip-blend flip, a 10,783°/s foot-orientation flip, a 750mm/frame shin-aim flip) before we recognized it as one problem and fixed it once, by pinning blend endpoints to a chosen hemisphere instead of re-picking each frame.
And every "skip the work when the pose already looks fine" fast path turned out to be a teleport waiting for the one frame where the internal state hadn't actually converged. Fixing a snap kept creating a new snap exactly at the hand-off frame (a genuine whack-a-mole round) until every stateful corrector got a rule: converge before you exit, every time, no early outs.
By the end of that pass: forearm-in-pole down from 80.8% of the take to about 7% (transient grazes, not sustained impalement); planted feet down from 4.57 m/s skating to sub-3cm/s stillness with exact sole contact; the 6,300°/s hand snaps down to 174°/s and 72°/s at the same timestamps in the clip.
Both forearm percentages carry the thin-radius caveat from earlier, so read "80.8% → about 7%" as understating both ends. The improvement is real and reproduces under calibrated geometry; the absolute figures don't. Re-run against honest radii and this class is still wide open, hundreds of arm-versus-pole violations per body, and it took another week and an architecture change before anyone could call it fixed. The plant and hand-orientation numbers are unaffected: those checks were never radius-dependent.
The teleport that shouldn't exist
One bug survived that whole pass: a free leg would occasionally jump half a meter in a single frame, no warning, no ramp. We'd built rate caps everywhere. Something was still bypassing them.
The instrumentation staged for exactly this paid off in one scan. Every teleport frame showed the solver's own internal state calm and continuous, except for one number: the solver's speed-capped ankle target, which jumped 870 to 1,490mm in a single frame. The "capped" state was itself teleporting. Only one code path could do that, as far as reading the solver could establish: an escape hatch built for emergencies, meant to hand the frame to the uncapped ideal position whenever the capped pose was impaling the pole too deeply. The arithmetic gave it away: for six frames running before each jump, the ankle's steps sat pinned at exactly the rate-cap budget, lag quietly building, and then one frame paid off the entire debt at once.
Any bounded corrector whose emergency path is allowed to jump straight to the uncapped ideal is the discontinuity. It just fires on the worst frames, which is exactly when you notice it.
Fixing it took four rounds, each one teaching us something we hadn't budgeted for. A flat speed cap punished fast choreography: this take's kicks sustain 5.4 m/s and the cap was tuned to 3, so the cap itself manufactured the lag that armed the escape hatch; the fix floors the budget at 1.3x the clip's own local speed instead of a flat number. A flat "emergency multiplier" produced worse oscillation than no emergency handling at all; closing a fixed fraction of the remaining gap every frame is smooth by construction. A trust-region budget accidentally measured its own output instead of the original ask, so it could be fooled by a corrector that had already flung the target away: budgets have to reference the ask, never the corrector's own work. And two separate code paths were each granting themselves a full speed budget in the same frame, doubling the effective cap.
End state: no single-frame leg step over 250mm anywhere in the 29.5-second take on the reference body, down from eight events as large as 1,487mm. The honest cost: the single fastest kick in the choreography now grazes the pole's flesh margin for about three frames where it used to teleport cleanly past. Continuity and hard non-penetration can't both be absolute in a per-frame solver replaying the clip at its own timing, when the source motion sweeps a limb through the obstacle in two frames. We chose continuity. That was a choice, not a theorem: retiming, routing the limb over the whole interval, and substituting the move are all ways out, and the rewrite we eventually specified takes the middle one.
The bug the suite couldn't see
After the automated checks were solid, Spencer watched the corrected render and flagged a knee doing something wrong around the 4-second mark. Nothing in the telemetry had caught it.
It turned out the performer deliberately drags her foot along the floor there: an authored brush step, toe down, moving slowly. Our plant system saw a low, slow foot and anchored it as if it were a stance, and the knee spent the next second churning around a false anchor at two to three times the source's own speed. Every individual number was inside some allowance. The sum was obviously wrong to a human eye and invisible to twelve numeric checks, because the solver wasn't glitching: it was confidently, smoothly solving the wrong problem.
The fix came from measuring every plant onset in the whole take: genuine stances all arrive slower than 0.85 m/s, and the false "catches" (1.2 to 4.5 m/s) lined up exactly with the trouble spots we already knew about. Plants now require a slow arrival before they lock, and a plant that never settles into stillness gets reclassified as a drag and released.
We're keeping this one as the header lesson for the whole project: numeric gates catch what you thought to measure in advance. A human watching caught a policy error: the solver solving the wrong problem, gracefully. The counter-lesson is just as important: every one of the failed fix attempts before the real one was cheap, because the scan-then-regress loop turned "does this help" into a 90-second answer instead of a re-render-and-squint afternoon.
Ghost mode, and what "pause" means for a stateful solver
Numbers needed a human-legible form, so the review page grew a ghost: a second copy of the same character playing the same clip, every runtime corrector switched off, standing beside the solved dancer on the same clock. Identical bodies, one clock. Any visible difference between them is the solver, full stop.
Handing that page to a person immediately broke it in ways our headless capture scripts never had. Pausing needs to freeze solver time, not just playback, because the correctors integrate real elapsed time: a "paused" elbow wandered 180mm over two seconds and snapped back the instant playback resumed. And because the solver's state is path-dependent over seconds, jumping the scrub bar can't just converge on the target frame; it has to rewind about 1.5 seconds and visibly replay real history through the solver before landing. The little fast-forward beat you see after a scrub jump isn't a bug. It's the price of showing you a pose the solver actually earned.
That review page is where the human half of the loop lives: a person scrubs, watches, drops a timestamped flag exactly where something looks wrong, and the flag gets answered from telemetry. The regression suite catches what we thought to measure in advance. The flags catch what we didn't, and that split is not a temporary state of the tooling. Every genuine surprise in the following week arrived through the flags, never through the suite, and twice the flags were catching the suite rather than the solver.
Three bodies, one performance
We ran the identical take on our two other stock characters, same solver, same thresholds. The report card split cleanly in two. Continuity generalizes completely: zero teleports, exact ground contact, every angular spike bounded, on every body. Geometric fit does not: engaged-grip error climbed from 17 violations on the performer-matched character to 125, then 275, on rigs whose proportions depart further from hers. Both alternate builds bury an upper-arm segment in the pole at the same moment our reference character clears it clean, a chord the elbow physically cannot rescue when the shoulder end is simply in the wrong place.
Those are counts, which is a weaker thing than it looks. A count is a detector, not a measurement, and re-scored on magnitude and duration these same numbers turn out to rank implementations on noise (the whole story). Take the split as direction, not as a score.
The take was performed by one specific body. The further an avatar's proportions sit from that body, the harder absolute-position pole and floor constraints fight the skeleton wearing them. The stability half generalizes; the fit half is an open account.
The obvious fix, and the one we bet on here, was one constrained solve for root, grip, and ground together instead of three correctors taking turns. That consolidation was eventually attempted and priced (a staged multi-session rewrite), and the price tag is what settled the architecture question against it rather than for it. The fit half was not a solver-quality problem at all. It was a representation problem, and the answer was to stop preserving her coordinates.
What we'd tell the next person
Measure the final rendered skeleton, never the solver's opinion of itself: our "planted" feet were sliding at 4.57 m/s while the state variable said otherwise. Play the raw source next to your output on one clock; amplification is the crime and the comparison view is the only witness that convicts it. Make every rate budget relative to the source's own local speed, not a flat number, or your safety cap becomes the thing that manufactures the emergency. Watch for escape hatches: any correction path that's allowed to exceed the normal cap "just this once" will eventually be the only path that runs. And ship the tooling that lets a person hand you a timestamped flag, because the best bug we found all week wasn't caught by anything we thought to measure.
Two more outrank all of that, and both cost us more than they should have. Calibrate your checks against the geometry you actually render, and re-derive them whenever that geometry moves. A check with a wrong constant issues false assurance, which is strictly worse than having no check at all: with radii 40% too thin and a report sorted by key order, a whole performance once rendered 0.4 m beside the pole with all twelve checks green. And sort every QA output by severity, in every code path that prints one. We truncated to "the first six violations by key order" twice, in two different tools, and made a day of decisions off each list.
Where this went next
The week after this loop was built, it got tested against itself. Four follow-ups came out of it, and they read as one arc:
- The check wasn't missing. It was lying. — the collision radii behind the numbers above were 40% too thin, the regression report was sorted by key order, and Spencer found all of it by watching five clips. Includes the salience ordering that explains why: viewers are acutely sensitive to interpenetration and contact errors, and remarkably insensitive to the trajectory error we had instrumented most carefully.
- Two philosophies, one take — two implementations of this problem, an A/B that the metric ranked backwards, and the wall both of them hit from opposite directions: the hardest body's arms are 50 mm shorter than the performer's, and no amount of solver quality invents reach.
- Writing a dance down: what replaced her coordinates. A machine-readable contact script in pole coordinates, in which the recorded ranges are the license a different body inherits.
- Change the solver or change the gate, never both — what we do now so a green light means something, including the fixed-point test that has never once passed.
Every one of them describes the same build, running the same evidence loop on every change since. If you're reading this, you probably have the password.