Motion Matching
Introduction
Motion matching animates a character with no animation state machine. Instead of authoring transitions between clips, gameplay states a goal — a desired velocity and facing, typically straight from the steering system — and every tenth of a second the engine searches all loaded animation frames for the one that best matches what the character is currently doing and where it should be heading, then eases playback to that frame with an inertialized transition. The clips' own root motion moves the character.
Why Use It
- No state machine to author or maintain. Adding a clip to the entity adds it to the vocabulary; the search decides when it's used.
- Natural movement. The character is always playing real captured motion; transitions happen at the frames where poses genuinely match.
- AI-friendly. "Walk toward the player through the environment" is one
setMotionMatchingGoalcall per frame with the steering output.
Step-by-Step Implementation
- Load the entity's clips as usual and enable root motion — motion matching relies on the clips' travel to move the entity:
setEntityAnimations(entityId: zombie, filename: "locomotion", withExtension: "untold", name: "locomotion")
setRootMotionEnabled(entityId: zombie, enabled: true)
- Describe the character and build the database (built lazily on the first enabled frame, from the loaded clips — a few minutes of animation resamples in milliseconds):
setMotionMatching(entityId: zombie, descriptor: MotionMatchingDescriptor(
leftFootPath: "root/hips/thigh_l/calf_l/foot_l",
rightFootPath: "root/hips/thigh_r/calf_r/foot_r"
))
setMotionMatchingEnabled(entityId: zombie, enabled: true)
- Feed it a goal every frame from your AI:
// e.g. inside the game update, from the steering system:
let toPlayer = getPosition(entityId: player) - getPosition(entityId: zombie)
let desired = simd_normalize(simd_float3(toPlayer.x, 0, toPlayer.z)) * zombieSpeed
setMotionMatchingGoal(entityId: zombie, desiredVelocity: desired)
That's the whole integration — no changeAnimation calls, no states.
What Happens Behind the Scenes
- Database build: every clip is resampled at
sampleRate(default 30 Hz). Each frame stores a 27-dimensional feature vector in character space: foot positions and velocities, hip velocity, and the root's future positions and facings at 0.33/0.66/1.0 s (loop-wrapped with the clip's per-loop displacement, or extrapolated at the terminal velocity for a one-shot clip). Features are normalized per dimension and weighted per group (MotionMatchingWeights). - Query: on each search (default every 0.1 s) the current pose's foot
features are measured, velocities as world-space finite differences
rotated into the character frame, and the future trajectory is
predicted by easing the current simulated velocity toward the goal
with
predictionHalflife. - Search: brute force over every frame — a few thousand frames is a few hundred microseconds. The currently playing frame's cost is discounted 10% as hysteresis, so only meaningfully better frames cause a jump, and a winner near the natural playback position is skipped entirely.
- Jump: playback switches clip and time through the same
inertialized transition
changeAnimationuses, and root motion re-baselines — no pops, no teleports.
Tips and Best Practices
- Coverage beats quantity: the search can only pick frames that exist. For locomotion you want starts, stops, turns, and speed variations; a single straight walk loop will turn by sliding.
- Weights are the tuning surface. Raise
trajectoryPosition/trajectoryDirectionfor responsiveness to the goal; raise the foot weights for pose fidelity (less foot sliding at transitions). minPlayTime(default 0.3 s) is the floor on how long a chosen frame plays before the next jump may fire. Without it, a frame that systematically beats the incumbent — the velocity peak of a cycle whenever the goal speed exceeds the clip's mean — wins every search and playback treadmills on one spot: a frozen-looking pose drifting across the floor. Raise it for calmer motion, lower it for snappier reactions.searchIntervaltrades responsiveness for cost; 0.1 s is a good default. Databases of a few thousand frames need no acceleration structure.- Name the hand joints (
leftHandPath/rightHandPath) to add their positions to the features, weighted byweights.handPosition: a jump then prefers a clip whose arms are near where they already are, instead of flapping the upper body between clips the feet alone call equal. Or take the arms away from the clips altogether with a pose layer (UsingPoseLayers.md). headingCorrectionRatebends a moving character's path toward the goal when no clip turns sharply enough;headingCorrectionSpeed(default 0.5 m/s) is the travel speed at which that reaches its full rate. Raise it (1-2 m/s) when slow steps must turn through pivot and arc clips — a shamble bent at the full rate reads as a rigid turn with no footwork.- Hierarchical assets (loaded via
setEntityMeshAsync) keep theirAnimationComponenton a skinned child, but heading, world position, and root-motion deltas all anchor to the entity you called the APIs on — the same handle your game steers. - Combine with foot IK for terrain and animation policy
(
.forceOff) as a distance LOD lever — freeze far characters and stop callingsetMotionMatchingGoalfor them. - Clips are assumed to loop with keys spanning their full duration
(standard for locomotion loops). Name the ones that do not in
oneShotClipNames— starts, stops, pivots, long captures, or any generated take whose last frame does not meet its first. A looping database predicts a one-shot clip's future into its own beginning (a stop clip looks like "stand, then run again") and playback runs through the wrap as a hard cut. A one-shot clip instead extrapolates its trajectory past the end, forces a search as playback comes within a search interval of the end, and keeps the frames that could not play forminPlayTimebefore that out of the candidates, so it always leaves through an inertialized jump — often back to its own beginning, which is a seamless loop for a walk whose ends do not quite match.oneShotRunwayPenalty(default 1) taxes a one-shot frame as its playable time runs out, so a standing goal settles on a looping idle rather than bouncing off the still tail of a stop clip every half second.
Running the Feature
- Load a character with at least an idle and a walk whose root travels.
- Enable root motion + motion matching with both foot paths.
- In game mode, drive the goal from input or AI and watch the character pick clips by itself — zero the goal and it settles into idle.