TraverseWork with us
Menu
Back to the blog

From the engineering desk

Motion matching on the GPU, checked against the reference

Traverse Research15 min read

Motion matching on the GPU, checked against the reference / From the implementation
In this article

Motion matching is a way of animating a character without an animation graph: instead of an animator wiring idle, walk, run and turn together with hand-tuned transitions, the game keeps a database of motion capture and, several times a second, searches it for the frame whose pose and near future path best match what the character is doing and where the player wants it to go, then plays on from that frame. Simon Clavet presented it at GDC 2016 in Motion Matching and The Road to Next-Gen Animation, and most people now learn it from Daniel Holden's Code vs Data Driven Displacement and his open reference implementation, which also shows the usual partner technique, inertialization, the transition method David Bollo described at GDC 2018 that switches to the new clip at once and decays the difference away afterwards.

Engine teams adopt it because motion quality scales with data rather than with graph complexity, so adding a move means adding a clip rather than a node and six transitions, and the costs are just as concrete: the feature database has to be resident, every character searches several times a second, tuning happens through feature weights, and nobody authors exactly which clip plays when.

Breda, our research renderer, already animated characters entirely in GPU compute (see Ray tracing animated crowds) and has no CPU pose pipeline at all, so the change that added motion matching, merged on 2026-09-11, put the search, the decision and the inertialized transition on the GPU too and kept only the character controller on the CPU, and what crosses over each frame is the controller's output: a 12-float predicted trajectory for the search, and the root transform that places the character in the world. We then checked the result against Holden's implementation one pipeline stage at a time rather than by eye, and in a 250-character debug capture mm_match measured 0.18 to 0.36 ms per command-buffer context.

This post covers why the search runs on the GPU, how the search and the transition work, how the conformance checking was set up and what 250 characters cost, and it leaves out the ray-traced foot IK and the acceleration structure ordering that came in the same change, which are a subject of their own.

What has to happen between the stick and the pose

Every frame, a character needs a pose that continues smoothly from the previous one, heads where the player is pushing, keeps its feet still while they are on the ground, and moves its root in agreement with wherever gameplay thinks the character is, and those requirements pull against each other: responsiveness wants to change motion now, continuity wants to keep playing what is already playing, and a planted foot wants neither.

The classic answer is a state machine, where each state plays a clip or a blend tree and each edge is a crossfade someone tuned, which is predictable and gives the animator direct control, and which gets expensive in a specific way: transitions multiply with moves, every crossfade averages two poses whose feet are in different places, and a turn at a speed nobody authored falls between the states that exist.

Three ways to choose the next pose

There are three serious options today, and they differ mostly in what a person has to author and what the runtime has to hold.

State machine and crossfadesMotion matching and inertializationLearned motion matching
Authoredstates, transitions, blend treesclips, feature weights, tagsclips, then trained networks
Runtime workgraph evaluation, two clips sampled per crossfadea nearest-neighbour search per character several times a second, one clip samplednetwork inference per character per frame
Resident memoryclipsclips plus the feature databasenetworks in place of most pose data
Controldirectindirect, through weights, tags and filtersindirect, and changing it means retraining
Typical failuregraph complexitya poor match, or thrashing between near tiesnetwork error, and a training pipeline to own

Learned motion matching (Holden and colleagues, 2020) replaces the database with small networks, which buys back the memory and costs a training pipeline, and it only pays off once the database stops fitting, so for a first version the choice is between the first two columns. We chose motion matching with inertialization, on the grounds that a research renderer is better served by a system that animates whatever clips it is given than by one that needs an animator to wire a graph for every test scene.

The more interesting decision was where to run it, because the implementations most engine teams will have read, the reference included, run the search on the CPU, where gameplay code can read the pose and a debugger can step through the decision.

Running the search where the pose already lives

The constraint is that a CPU matcher needs the current pose on the CPU, and in Breda the pose is only ever produced on the GPU, so a CPU matcher would need either a readback of the pose every search, with the latency and synchronization that implies, or a second pose pipeline on the CPU that duplicates the sampling and hierarchy work purely to feed the search, which would be throwaway infrastructure in an engine that otherwise has none.

What genuinely belongs on the CPU is small: reading input, predicting where the character should be in a third of a second, two thirds and one second, and owning its position, which gameplay and physics need to agree on. So the controller, a port of Holden's spring controller with his 0.27 s default halflife, stays on the CPU and owns the root: it hands the matcher its predicted trajectory, 12 floats per character in the query constants, and writes the root transform onto the character's node, while the cooked clips have their root motion stripped out, so the animation never moves the character by itself.

The plan in the change was then to read the winning frame back to the CPU, 8 bytes per character with one frame of latency, and to move the playback cursor to the GPU later as an optimization. By the time it merged the cursor had moved already: mm_resolve_decision writes the winner straight into a per-character playback state buffer that clip sampling reads later in the same command buffer, and the result buffer is only read back when a conformance dump asks for it, so a decision is applied in the frame it is made.

The cost is that the logic deciding whether a character jumps is now a one-thread-per-character shader, invisible to a CPU debugger and to gameplay code, and most of the tooling in the change (dumps, a strict reference mode, per-candidate cost capture) exists to pay that back. We would make the same choice again, because the alternative is keeping two copies of the pose in sync.

Twenty-seven numbers per frame

The search compares feature vectors, and the layout is Holden's: both foot positions and velocities, the hip velocity, and three future trajectory positions and facing directions projected onto the ground, 27 floats in all.

The 27 floats of the feature vector in seven blocks: left and right foot position, left and right foot velocity, hip velocity, trajectory positions and trajectory directions, weighted 0.75, 0.75, 1, 1, 1, 1 and 1.5.

The 27-float feature vector, with the matching weight each block is given at cook time. The first 15 floats come from the database row being played, the last 12 from the controller.

For a normalized query qq and database frame ii with cooked features fif_i, the cost is

C(i)=∑k=127(qk−fi,k)2+ct[i≠icur]C(i) = \sum_{k=1}^{27} \left(q_k - f_{i,k}\right)^2 + c_t\,[\,i \ne i_{\mathrm{cur}}\,]

where icuri_{\mathrm{cur}} is the frame currently playing and ctc_t is a flat transition cost that switching has to beat, zero by default. There are no weights in that sum because the cook folds them into the normalization: every value is stored as (x−µk)/sb(x - \mu_k)/s_b, where µk\mu_k is the mean of dimension kk and sbs_b is the average standard deviation of its block divided by the block's weight. Scaling per block rather than per dimension matters more than it looks, since z-scoring each dimension separately would take a foot's height, which barely varies, and blow its small differences up into a dominant cost term.

The pose half of the query is where the comparison against the reference found a real discrepancy. Building the pose features from the live skeleton seems like the honest thing to do, but our cooked bones have the root motion stripped out, so a character running forward has hips and feet that barely move in model space, and the query came out with close to zero forward velocity against a database whose velocities include the root motion, so mm_build_query now does what Holden's controller does and copies the 15 pose floats from the database row currently playing, keeping the live-pose path behind a toggle for poses that do not come from the database.

One workgroup per character, and no acceleration structure

Holden's reference prunes the search with axis-aligned bounds over blocks of frames, and the obvious GPU instinct is to go further and build a proper spatial structure, so the question is whether the search is expensive enough to need either. The estimate we started from was that a few hundred thousand frames of 27 floats, searched ten times a second, does not, with an instruction to measure before building anything, and the measurements further down bear that out for the database we have, while an exhaustive search has one more advantage: it returns the same winner as a correctly pruned one by construction, which is one less thing to prove against the reference.

mm_match gives each character one workgroup of 256 threads, which stride through the list of matchable frames, keep a running best, and reduce with wave intrinsics and then group-shared memory. Three details carry the behaviour:

  • Seeding with the current frame. The search starts from the cost of the frame already playing, and a candidate only wins by being strictly cheaper, which is Holden's "beat the current frame or keep playing" rule. Without it the search returns the global best every time and the character wanders between near-identical frames in different clips.
  • Ignoring the neighbourhood. Candidates within 0.333 s of the current frame (20 frames at 60 Hz) are skipped, so the matcher cannot keep hopping between adjacent frames of the same clip.
  • Breaking ties by index. Equal costs are resolved in favour of the earliest candidate, which makes the parallel result identical to a sequential scan whatever the wave size, and that is what lets the winner be compared against the reference exactly.

Query building, normalization, search and decision are one dispatch each across every character, and each character searches at 10 Hz by default, earlier if the controller sees the stick settle after a sharp change or playback reaches the last frame of its clip. Running all of them in the same frame would put the whole search load on a single frame of every cycle, so each character's timer starts at a different phase, ((13nmod⁡16)+1)/16\left((13\,n \bmod 16) + 1\right)/16 of the interval for character nn.

The stagger only holds while frames are short: in heavily instrumented captures with frames over 100 ms every timer expires at once.

On top of the reference we added policies that a playable character needed: the matcher only sees locomotion clips by default, it swaps to a jump and obstacle set while the controller is airborne, a winner in the same clip at most 0.2 s ahead of the playing frame counts as continuing rather than jumping, and a jump cannot be followed by another for 0.3 s unless playback has run off the end of the clip's matchable frames.

Inertialization instead of crossfades

A crossfade samples the old clip and the new one and averages them for a while, which needs two samples per character and produces poses that neither clip contains, including the familiar foot slide. Inertialization switches to the new clip immediately and adds the difference between where the pose was and where the new clip starts, then decays that offset to zero with a critically damped spring:

x(t)=e−yt(x0+(v0+yx0)t),y=2ln⁡2hx(t) = e^{-y t}\left(x_0 + (v_0 + y\,x_0)\,t\right), \qquad y = \frac{2 \ln 2}{h}

where x0x_0 and v0v_0 are the position and velocity offsets captured at the transition, hh is the halflife in seconds and tt is the time since the transition. Rotations use the same spring on offsets stored as scaled angle-axis vectors, and a transition that interrupts one still decaying folds the remaining offset into the new one.

Two plots. Above, a joint angle over 1.2 seconds: a hard cut drops about 27 degrees at the transition, while the inertialized curve continues smoothly from the old clip and converges onto the new one. Below, the offset decaying to zero, faster for the 0.1 second reference halflife than for the 0.15 second default.
Inertialization halflife. A joint channel switching clips at 0.3 s. The hard cut jumps, while the inertialized output leaves from where the old clip was, with its velocity, and settles onto the new clip. The two clips are illustrative, the spring is the one Breda runs, and dragging across the figure changes the halflife.

pose_inertialize runs between clip sampling and matrix composition, evaluates the spring analytically from the time since the transition rather than stepping it every frame, and writes the result back into the sampled channel buffer, so every later pass is unchanged and each character samples exactly one clip, with a default halflife of 0.15 s against the reference's 0.1 s, and characters whose offsets have decayed are skipped outside strict mode. That one-clip property is most of why we chose it, since a crossfade would have needed a clip mixer on the GPU and a second sampling pass for every transitioning character, and it would still slide feet.

Checking it against the reference, stage by stage

Motion matching fails quietly, since a wrong feature scale or a velocity in the wrong space still produces a character that walks, just worse, and looking at it will not tell you. So the change carries a conformance checklist of 26 stages in execution order, from parsing the source BVH files down to final skinned vertices, and the rules are strict: a stage is only checked from a repeatable numeric comparison, quaternions are compared sign-invariantly, most tolerances are 10−510^{-5} in metres or radians, and a screenshot, a silhouette or a plausible skeleton is explicitly not proof. The first failing stage owns the bug, and nobody debugs a later stage until every earlier one passes.

The value of that ordering shows in the kind of bug it is built to catch. The checklist's frozen test cases name the error they exist to prevent, global database frame 28386 passed as a clip-local time in clip 37, which silently samples the clip's last key: nothing crashes, the pose is plausible, and it is wrong, so every tool now has to label its frame numbers as global or clip-local, and the chain has to be repeated on the frame that was originally reported as bad.

Exact comparison also needs a mode where our behaviour and the reference's can be identical, so motion-matching-holden-conformance turns off the policies described above, uses every clip category and the reference's candidate set, advances playback by exactly one database frame per step and uses the 0.1 s halflife. As merged, the individual checks for stages 1 to 23 are ticked, apart from four byte-for-byte comparisons in the serialized database and the normalization that are still marked partial, and the last three stages (contact locking and foot IK, final forward kinematics, and skinned vertices against a CPU oracle) are open, as is the end-to-end run on the original bad frame.

What 250 characters cost

The performance mode in the example application spawns one player-controlled character and others steered by simple boids, each with its own timeline, matcher state, controller and skinning, all sharing one cooked database, and the numbers are debug-build measurements recorded as validation evidence rather than targets.

Bar charts of profiler markers for 250 characters: World::update 3.86 ms, controllers 1.49 ms, AnimationComposer::update 0.83 ms, matcher dispatch 0.29 ms, apply_vertex_bones about 0.70 ms, mm_channel_animation 0.51 ms before and 0.024 ms after, mm_match 0.18 to 0.36 ms and pose_inertialize 0.17 to 0.25 ms.

Markers from the 250-character debug captures recorded in the change, frame-level on the left and per pass on the right, including the channel sampling pass before and after it was specialized.

Two changes behind those numbers are worth copying: skinning had been running twice, once before IK and once after, and removing the first leaves apply_vertex_bones running once at about 0.70 ms, and the motion-matched sampler was specialized for the database's uniform frame layout, which took mm_channel_animation from about 0.51 ms to 0.024 ms per command-buffer context because every clip in the database shares one frame rate, so the key index is simply the playback time multiplied by that rate.

At small counts memory is mostly the rest of the renderer: on an Apple M4 at 2560x1440 the allocator reported 790.96 MB for an empty world, 882.99 MB with one character and 908.60 MB with four, and a 250-character debug run on the same machine, with animated acceleration structures for ray-traced shadows, used 1.50 GB in total, once refits were split into batches that fit the fixed 256 MiB scratch buffer.

One measurement trap is worth repeating: stepping a fixed 1/60 s per rendered frame without a catch-up loop makes simulation and animation run in slow motion under load, so performance mode uses elapsed time and only conformance and step-debug runs keep the fixed step.

Where it falls down

  • The data cannot ship. The test corpus is LAFAN1 on the re-solved skeleton from Holden's lafan1-resolved, which is licensed for non-commercial use only, so no demo or benchmark built on it can be released until we have a corpus we own and the retargeting step that a second skeleton would need.
  • Brute force scales with both axes. Search cost is database size times searching characters, and a bigger database or a crowd searching more often will eventually want either pruning or learned motion matching, which the plan describes and nothing implements.
  • Conformance stops before the feet. Everything after root adjustment is unproven against the reference, and our own policies, which are what a player actually sees, are by construction outside what conformance can prove.
  • The CPU cannot see the decision. Gameplay that wants to react to which clip is playing has to read it back or trust its own controller, which is the price of never reading the pose back.

Where to go next

If you are weighing this for your own engine, read Holden's Spring-It-On for the controller and the springs, then build his reference implementation and run it on LAFAN1, because that pairing is the closest thing the field has to a shared test, with Clavet's talk for the argument and Bollo's for the transition.

The first thing to build is the comparison harness, before the fast path: dump the query, the per-candidate costs and the winner from both sides for the same global frame, then measure the plain exhaustive search at your real database size and search rate before designing an acceleration structure for it.

What we have not settled is how to hand the decision back to gameplay without a readback, whether 10 Hz with forced searches is responsive enough under fast direction changes or should rise, and when the database grows large enough that learned motion matching earns its training pipeline.

Our view, for a team starting on this, is that where the search runs matters less than where the pose lives, since the expensive mistake is splitting the pose across two processors and then spending the project keeping them in agreement, and that the conformance work is not optional polish: a matcher that is subtly wrong still produces a character that walks, so the only way to know it is right is to compare it number by number with an implementation you trust, in execution order, and to start doing that before the first optimization rather than after it.