Engineering Journal

← all posts

Part of Building the 3D Club Scene

Hands on the pole: retargeting mocap onto bodies that don't exist yet

3D & motion

The stage in retarget-debug mode: the LED pole under spotlights, the dancer mid-pose, and the glowing orange source-skeleton overlay striking the pose the capture recorded — the two skeletons this whole post is about reconciling.

We bought one 30-second mocap take of a pole routine. Making it play on characters the performer has never met — feet on the floor, hands on the pole, nothing clipping through anything — took five rewrites of the arm alone.


We have a real-time club stage in three.js: LED pole, moving heads, haze, a dancer. We bought a professionally captured pole routine and want any rigged humanoid to perform it — store-bought characters today, 3D scans of real people eventually. On paper that's "load animation, play animation."

The animation data had other plans. Here they are, in the order they ambushed us.

The rest pose that wasn't

Cross-skeleton retargeting works on world-space deltas. For each bone you ask "how far has this bone rotated from its rest pose, in world axes," then replay that delta on the target rig: R = P⁻¹·D·P·B, where D is the world delta and P, B come from each rig's rest pose. Both skeletons need to agree on what "rest" means. Every rig family orients bone axes differently (Mixamo runs them down the limb, our procedural rig keeps them world-aligned), so copying local rotations across rigs puts legs on backwards. The world-delta form is what makes the pose transferable at all.

It has one load-bearing assumption: the rest pose stored in the file is the pose the animation is authored against. Our vendor's FBX exports the skeleton posed at frame zero of the dance. The actual T-pose ships as a separate file, politely labeled "for retargeting purposes," which we ignored because the pipeline worked fine on Mixamo test data.

The result is spectacular. Each frame in isolation looks nearly human. In motion, every limb is consistently rotated by its own private constant — we measured 75–133° of rest-pose disagreement per major bone — so the body does confident, coordinated nonsense. If you ever see retargeted mocap that looks locally plausible and globally deranged, check the bind pose first and thank us later.

Here it is, preserved for posterity — we regenerated the clip through the pre-fix converter specifically so you could see it. Same take, same character, same everything except which pose the retarget believes is "rest":

Vendor rest pose taken at face value: every limb off by its own constant.
Same take, converter re-bound to the real T-pose.

Two traps inside the fix. The T-pose file's node tree parses to NaN in three.js's FBX loader (pelvis scale of exactly zero — thanks), but it carries a clean one-frame animation take, so the converter reads the pose from the take's first keys instead. And the dance file's own skin bind matrices are no substitute: that bind is an A-pose, 40° of arm error against a T-posed target.

Our regression test for all of this is blunt and effective: play the raw clip on its own skeleton and the retargeted clip on the target, and compare world limb directions over the whole take. After the fix, the per-bone error is constant in time (mean equals worst), which is what "the pose transfers cleanly, the rigs just have different anatomy" looks like as a number. Scope it honestly, though, because we spent a while over-reading it: that check samples the major bones every 0.2 s and looks only at limb directions. It is blind to twist about a limb axis, to translation, and to timing. It is one invariant that a broken bind pose cannot survive, not evidence that the motion transferred.

The capture is the closest thing to ground truth

Next problem: when is she actually holding the pole? Our first answer thresholded the distance from the retargeted wrist to our pole. Bad idea. Retargeting preserves pose, not hand position — the same arm angles hang off different torso and arm proportions, so one character's wrist sits 12cm off the pole surface through a grip and another's sits 75. The IK half-engaged in the fuzzy zone and hands hovered eerily beside the pole.

The answer was always sitting in the clip. The source skeleton is the performer's body; play the raw take on it once at load, cluster the wrist positions, and the cluster centroid gives you the capture pole, because she spent a third of the take holding it. That axis is an estimate and deserves to be labeled as one: it is the centroid of the densest cluster of her own wrist samples, taken at 15 Hz, assuming a vertical pole of an assumed 2.3 cm radius, and fitted to the very samples the engagement thresholds are then read off. The vendor shipped no prop transform, so there is nothing independent to check it against, and there is only one take. It is a source-derived proxy, and a good one, but it is not a measurement, and treating it as ground truth is how you stop testing it.

With that caveat attached, it works: distance from her own wrists to that axis turns out cleanly bimodal, gripping hands riding 2–8cm off the surface, free hands 30cm and beyond, almost nothing between. Threshold in the empty zone and engagement becomes a fact you read off the capture rather than a guess you tune per character.

Histogram of the actual capture data: wrist-to-pole-surface distance in centimeters across every sampled frame of the take. A tall violet cluster from 2 to 8 labeled "holding," a near-empty gap through the teens, and a broad low mound past 30 labeled "not holding."

The same trick answers "when are her feet on the floor" — sample the source toes, normalize to the take's own standing baseline (toe bones float centimeters above the sole, and how far differs per skeleton), and you get a grounded/airborne signal that no target-body proportion can corrupt. While she was grounded we hold the character's lowest toe to the deck, in both directions: short-legged rigs get set down onto it instead of hovering. While she's mid-climb, feet belong to the clip.

Then the final step of trusting the capture, which in hindsight should have been step one: her wrist path is valid pole-holding by definition. So the grip targets aren't derived from the target rig at all anymore. We map the source wrist trajectory through the exact same transform as the root motion (recenter on the capture pole, calibrated horizontal scale, stature vertical) and pin hands to her path. Body proportions can no longer move a grip anywhere. What they still decide is whether the arm can get to it, which is the wall this whole approach eventually hits, and the last section of this post is about that.

Grips have friction, motors have speed limits

A pinned hand raises two mechanical questions we got wrong before getting right.

First: a grip is not a rigid weld. Pin the hand to one fixed point and let the body orbit, and the wrist winds up like a rubber band until it looks one frame away from snapping. Watch real pole dancers: the palm pivots around the tube once the wrist runs out of travel, and hands slide along the pole constantly. So the pin's azimuth has friction — a ~26° dead zone, then a rate-limited pivot — while its height just tracks the source wrist. Grip friction is azimuthal. Height was never supposed to be pinned at all.

Second: the elbow. The arm has one free degree of freedom once the wrist is pinned — the swivel angle about the shoulder-wrist axis — and we use it to keep the elbow out of the chest and both arm segments out of the pole. Our first implementation searched the whole swivel circle every frame for the smallest clearing angle. Correct per frame; disastrous over time. Near symmetric poses the two directions around the circle clear with nearly equal effort, and a millimeter of pose change flips the winner. On stage: an elbow teleporting between two poses several times a second.

The fix is to stop treating it as a solver and treat it as a motor. The swivel angle is state that persists across frames and moves at bounded speed, climbing the local clearance gradient while something penetrates, relaxing toward the natural pose behind a hysteresis band once clear. The flicker stops, and it is worth being exact about why, because we were sloppy about it at first and it cost us. The 4 rad/s cap (229°/s) bounds the swivel angle, an internal variable. It does not bound the bone that gets rendered. The first review scan of this build caught the free right arm jumping 4,746°/s in a single frame at 8.90 s, hand not even gripping, with this clearance motor isolated as the cause. A rate limit on a state variable bounds that variable and nothing downstream of it, so the rule to write down is: cap the final rendered angular delta, not only the internal step.

Technical diagram, dark background with violet accents: a 3D arm skeleton gripping a vertical pole, a dashed circle drawn around the shoulder-to-wrist axis showing candidate elbow positions. Two zones on the circle shaded red labeled "chest" and "pole," a green arc between them, and a small gauge icon labeled "4 rad/s max."

Even the solver feeding that motor eventually got demoted. Classic two-bone IK invents an elbow direction from a heuristic; the performer already chose one. The solve this post describes keeps the clip's arm pose as the basis and applies two minimal corrections: flex the elbow in her bend plane until the shoulder-wrist distance matches the pin, then rigidly rotate the whole arm about the shoulder onto it. Her elbow style survives by construction, and the motor only intervenes when geometry actually demands it.

You can smooth motion. You can't smooth an invariant.

Everything above is smoothed — pulls, pivots, motors, damped everything, because smoothness is what reads as alive. Which is precisely why none of it can promise "no body part ever crosses the pole." A smoothed corrector chasing a fast pass leaves the crotch inside the pole for the three frames it needs to converge, and one frame is all a viewer needs.

So the last stage of the body solve is not smooth on purpose: measure a set of guard points and chords (hips, spine, head, the line between the hip joints), find the deepest penetration into the pole cylinder, translate the whole rig out along the radial that same frame. The recovery afterward is smoothed; the invariant is not negotiable.

Three things about that guarantee, all of which took a scan to see. It covers the torso and nothing else. Limbs have their own clearance solves, and those are smoothed correctors with no hard clamp behind them, which is why the right forearm sits inside the pole for 80.8% of the take, and why the leg campaign that followed opened at 21 detected leg crossings and shipped at 15, with 13 of those in a configuration held back by one unfixed defect. Anyone telling you the torso clamp generalizes to limbs is describing an intention as an achievement.

The clamp that does hold is enforcing a 0.09 m torso half-width, which the check that was lying later measured at roughly 40% under the rendered mesh. So the invariant is real, and it is protecting a torso narrower than the one on screen.

And "translate the whole rig out along the radial that same frame" carries the planted feet with it. Nothing in the invariant accounts for that. The next day's scan caught the reach correction and the ground hold pulling against each other: 158 mm of root correction against 79 mm of vertical, with a 121 m/s² spike at the hips.

That split, motion shaped by soft controllers with correctness enforced by hard clamps, every "does nothing when nothing's wrong" path kept bit-exact so frozen regression baselines stay meaningful, is the architecture of the pipeline this post describes. It is also, stated plainly, classical retargeting with correctors stacked on top, and on 2026-07-25 we replaced it with a contact-script architecture: extract a semantic record of what touches what and when, then let each body solve its own motion against that contract. The two tied on grip accuracy, which is what makes the reason they diverged worth a post of its own.

Where it stops working

Whether an arbitrary 3D scan of a stranger could do this was the point all along. The answer is "yes, until it isn't."

The mechanism holds. Proportions genuinely cannot move a grip target any more, and that is the fix this post is about. What proportions can still do is make a grip unreachable. The performer's arms are 0.516 m; one of our three characters has 0.466 m, and the take asks for both hands on the pole 25 cm apart at the same instant. There is no root position from which that body can do what she did. She is about 30 to 40 mm short per hand, and somebody then has to decide what she grabs instead. On that body, the recorded wrist path, the one thing in this pipeline we trusted as physically true, is a description of something the body cannot do.

Absolute retargeting cannot express that decision at all, and that is the deeper reason for the architecture swap above. A wrist trajectory can only say "be here." A contact script whose recorded ranges are the license a different body inherits can say "hold the pole somewhere in this band, at about this height," which is a sentence a shorter arm can answer.

Worth flagging for anyone planning the same product: the three characters here are curated, partly because their proportions land where this stack is graceful. The ?fit=grip escape hatch our own roadmap names as the fallback for extreme proportions is not in the running code, so an arbitrary upload has no fallback today.

What we'd tell the next person

Distrust the file's rest pose, always. Beyond that, one habit did most of the work here: measure against the capture, because the capture is the closest thing this pipeline has to physical truth. The performer really stood on that floor and really held that pole. Everything derived from a target rig is an opinion. Carry the caveat with the habit, though, because it is the sharpest one in this post: measuring against the capture is right up to the reach limit and wrong past it.

And if a per-frame correction ever flickers on you, the cure isn't a better search. It's state and a speed limit, applied to the thing you actually render.

The demo this post describes is running in a private build; the sizzle reel it feeds is another post's story.