module retarget
Skeletal animation retargeting — map a clip authored on one rig onto
another humanoid rig, preserving the target's shape. Pure, readable Luau:
the behavior an agent follows and tweaks. `bake` is the cold, cached
transform (clip + source rig + target rig -> a clip in the target's bone
space); the hot path just samples the baked clip. `plan` reports which
canonical roles map across the two rigs and which don't.
require modules/retarget
parseRig(rig: ?) → void
Normalize a `.rig` (table or JSON string) into the enriched form the math
below reads: bones, name->index, role->bone, bone->role, FK globals, lengths.
Idempotent: an already-parsed rig is returned unchanged.
fkOrder(bones: ?) → void
Parent-before-child traversal order over a bone array (0-based parents).
symmetrizeBind(R: ?) → void
Absolute bind correction: force a perfectly symmetric canonical bind by
mirroring each left/right bone's world position across x = 0 and rebuilding
the local offsets from the symmetric positions. Orientation is already
canonical (the caller ran the relative pass first), so only offsets change —
this overrides the rig's authored left/right asymmetry and proportions, which
is the whole point of absolute mode. `R` is a parsed, already-normalized rig.
computeBodyFrame(R: ?) → void
The rigid world rotation that aligns a rig's FACING to canonical: the horizontal
hip-to-hip side onto +X, so the body faces +Z. Mesh-aware up: a humanoid mesh is
modeled upright in model space, so +Y IS the body's up — a rig's bind SKELETON
can sit at a tilt WITHIN that upright mesh (hips→neck leaning forward, say), and
verticalizing to the skeleton's spine would rotate the upright mesh off vertical.
So +Y up is left untouched and only the facing is aligned, a pure rotation about
+Y that never tips the mesh. Read from joint POSITIONS, so it captures a baked
facing an importer left in the bind regardless of bone rotations. `normalizeToTPose`
applies it before aiming the limbs; the body's verticality stays as authored.
rolePos(roleName: ?) → void
| arg | type | description |
|---|
| roleName | ? | |
computeUpAlign(R: ?) → void
Just the up-alignment of a rig's body frame: the rotation that stands the bind
upright (hips→neck onto +Y), WITHOUT the yaw that would re-aim its facing. The
retarget bake applies this to the source pose so a bind authored with a forward
lean doesn't tilt the target, while leaving the source's heading intact — the
character's world facing is the controller's job, and it drives source and
target through the same convention, so re-aiming the clip's yaw would fight it
(the character would face away from its travel — moonwalk).
rolePos(roleName: ?) → void
| arg | type | description |
|---|
| roleName | ? | |
normalizeToTPose(rig: ?, opts: ?) →
Re-pose a rig's bind to the EXACT canonical T-pose, so a clip's source
rig and the avatar it drives share one reference pose. Retarget transfers
RELATIVE motion, so any residual bind mismatch (hands rolled the wrong way,
feet pointing askew, an A-pose vs a T-pose) shows up as broken hands/feet in
the result. Each bone is posed to a canonical world frame for its role —
direction AND roll: arms horizontal palms-down, legs straight down, spine up.
Only the limb bones whose bind direction differs between an A-pose and a
T-pose are aimed; limb ENDPOINTS (hands, feet, toes) and bones with no role
keep their authored orientation relative to the re-posed parent (their pose is
mesh-defined, so aiming them twists the hand / tips the foot). The whole body
is also rigidly de-rotated into an upright, forward-facing frame (handling a
baked root rotation) and centered on its sagittal plane.
Bind correction is RELATIVE by default — bone offsets/lengths/inverse-bind are
untouched, so the rig keeps its own proportions and any authored left/right
asymmetry. Pass `opts.symmetrize = true` for ABSOLUTE correction: left/right
bones are mirrored across the sagittal plane for a perfectly symmetric bind,
overriding the rig's authored asymmetry/proportions.
correction (force symmetry); default/false = relative (preserve proportions).
| arg | type | description |
|---|
| rig | ? | The rig to normalize (parsed rig / table / JSON). |
| opts | ? | Optional `{ symmetrize: boolean }`. `symmetrize=true` = absolute |
qnlerp(a: ?, b: ?, s: ?) → void
Linear-blend two quaternions along the shortest arc, normalized.
| arg | type | description |
|---|
| a | ? | |
| b | ? | |
| s | ? | |
sampleRotAt(ch: ?, t: ?) → void
The value quaternion of a Rotation channel at time `t`, interpolated by the
channel's own mode (Step holds; Linear / CubicSpline blend the value samples —
the CubicSpline tangents are skipped, so the value is exact at keyframes and a
stable nlerp between them, which is all the cold rebake needs).
bindLocalRot(R: ?, i: ?) → void
A bone's local bind rotation (relative to its parent), from the parsed rig's
FK globals.
validateScale(out: ?) → void
Scale is rig-independent; keep it, replacing non-finite / non-positive with 1.
plan(srcRig: ?, tgtRig: ?) →
Report how a source rig's clips map onto a target rig: which canonical
roles both rigs fill (`mapped`), which the source has but the target lacks
(`unmappedSource` — those channels are dropped), and which the target has
spare (`unmappedTarget`). Use it to see why a retarget is partial and which
bone to hand-map in a rig's profile.
| arg | type | description |
|---|
| srcRig | ? | Source rig — a parsed rig, a `.rig` table, or its JSON string. |
| tgtRig | ? | Target rig — same forms. |
bake(clip: ?, srcRig: ?, tgtRig: ?, opts: ?) →
Retarget a decoded clip from its source rig onto a target rig, producing
a clip in the TARGET's bone space (channels named for target bones). Per
frame it forward-kinematics the source pose on the rig AS AUTHORED (the bind
the clip's channel rotations are local to), measures each mapped role's world
rotation as a deviation from the source's CANONICAL bind, re-applies that
deviation to the target's CANONICAL bind, and converts back to a target-local
rotation through the target's ANIMATED parent. The target chain is rebuilt
top-down, so error never accumulates down a limb. Both rigs are re-posed to
the geometry-derived canonical T-pose (`normalizeToTPose`) for that deviation,
so the result depends only on the T-pose the two rigs share — never on
whatever arbitrary pose either was authored in (an A-pose source idle lands
the target's arms down, not splayed out at the A-pose offset). The apply path
seeds that same canonical rest. Translation routes through the role and is
size-scaled by the hip-height ratio; the apply path makes it relative to the
target's bind. This is the cold step — bake once per (clip, target rig) and
cache (see `bakeBytes`).
(e.g. `json.decode(skeleton.clipDecode(bytes))`).
| arg | type | description |
|---|
| clip | ? | A decoded clip table `{ name, duration, channels, bone_names }` |
| srcRig | ? | Source rig the clip was authored on (parsed rig / table / JSON). |
| tgtRig | ? | Target rig to retarget onto (parsed rig / table / JSON). |
| opts | ? | Optional `{ symmetrize: boolean }` forwarded to `normalizeToTPose`. |
loadRig(ref: ?) →
Resolve a `.rig` asset and return its parsed, FK-enriched rig (ready for
`plan` / `bake`). The rig payload is the asset's `rig.json`.
| arg | type | description |
|---|
| ref | ? | A `.rig` asset ref (identity / guid / path / handle). |
bakeBytes(clipBytes: ?, srcRig: ?, tgtRig: ?, cacheKey: ?, opts: ?) →
Bake from clip BYTES to retargeted clip BYTES — `clipDecode` -> `bake`
-> `clipEncode` — with an in-memory cache. Retarget is cold: pass a stable
`cacheKey` (e.g. clip identity + target rig identity) and the second call
for the same pair returns the cached bytes. The hot path then just samples
the result like any native clip.
(absolute vs relative bind correction). Each mode caches separately.
| arg | type | description |
|---|
| clipBytes | ? | The source clip's `zanim` payload bytes. |
| srcRig | ? | Source rig (parsed rig / table / JSON). |
| tgtRig | ? | Target rig (parsed rig / table / JSON). |
| cacheKey | ? | Optional stable key; when given, the result is cached and reused. |
| opts | ? | Optional `{ symmetrize: boolean }` forwarded to `normalizeToTPose` |
bakeKey(cacheKey: string, opts: ?) → string
The key a bake is stored under, so a caller that derives something FROM
a bake can hold it under the same key and have it dropped at the same time.
Each correction mode bakes separately, which is what the suffix carries.
| arg | type | description |
|---|
| cacheKey | string | The stable key passed to `bakeBytes`. |
| opts | ? | The same `{ symmetrize }` passed to `bakeBytes`. |
aux(bakeKey: string) →
Read what was cached beside the bake at `bakeKey`.
| arg | type | description |
|---|
| bakeKey | string | A key from `bakeKey()`. |
setAux(bakeKey: string, value: ?) → void
Cache a value beside the bake at `bakeKey`. It is dropped whenever that
bake is, so it cannot outlive the bytes it was derived from.
| arg | type | description |
|---|
| bakeKey | string | A key from `bakeKey()`. |
| value | ? | The value to hold. |
clearCache(cacheKey: string?) → void
Drop every cached bake and everything derived from it (or just
`cacheKey` when given). Call after editing a rig's profile so clips re-bake
against the corrected mapping.
the key passed to `bakeBytes` or an already-composed `bakeKey()`.
| arg | type | description |
|---|
| cacheKey | string? | Optional single key to evict; omit to clear all. Accepts either |