Skip to content

XState animation binding

Three.js gives you an AnimationMixer that plays and crossfades clips. vgai adds the layer it doesn’t have: which clip plays when, driven by an ordinary XState state machine you already own. There is no VGAI-specific graph runtime or JSON format — bindXStateAnimation (packages/engine/src/animation/xstate-animation-binding.ts) reads plain meta.animation data off your machine’s states and drives native AnimationActions as XState transitions states.

idle, walk, run states with parameter-driven transitions
An XState machine whose states carry meta.animation; transitions are ordinary XState guards/events.

You write a normal xstate machine. Any state that should drive animation gets a meta.animation value — either a single clip or a blend tree:

import { assign, setup } from 'xstate';
const characterAnimationMachine = setup({
types: {
context: {} as { speed: number; grounded: boolean },
events: {} as { type: 'UPDATE'; speed: number; grounded: boolean } | { type: 'JUMP' },
},
}).createMachine({
id: 'character-animation',
initial: 'idle',
context: { speed: 0, grounded: true },
on: {
UPDATE: { actions: assign({ speed: ({ event }) => event.speed, grounded: ({ event }) => event.grounded }) },
},
states: {
idle: {
meta: { animation: { clip: 'idle', loop: true, crossfade: { duration: 0.2 } } },
on: { JUMP: 'airborne' },
always: { guard: ({ context }) => context.speed > 0.1, target: 'locomotion' },
},
locomotion: {
meta: {
animation: {
blendTree: {
type: '1D',
parameter: 'speed',
children: [
{ clip: 'walk', threshold: 1 },
{ clip: 'run', threshold: 5 },
],
},
crossfade: { duration: 0.2 },
},
},
on: { JUMP: 'airborne' },
always: { guard: ({ context }) => context.speed <= 0.1, target: 'idle' },
},
airborne: {
meta: { animation: { clip: 'jump', loop: false, crossfade: { duration: 0.1 } } },
always: [
{ guard: ({ context }) => context.grounded && context.speed > 0.1, target: 'locomotion' },
{ guard: ({ context }) => context.grounded, target: 'idle' },
],
},
},
});

This is the shipped third-person example’s machine (examples/third-person/src/components.ts, trimmed). Note what’s absent: no graph.setParameter/trigger vocabulary. Gameplay code drives the machine the normal XState way — actor.send({ type: 'UPDATE', speed, grounded }) and actor.send({ type: 'JUMP' }) — and always-guarded eventless transitions react to context, exactly like the old AnimGraph’s parameter-conditioned transitions, but expressed in XState itself rather than a bespoke condition language.

meta.animation is deliberately read-only data with no transition vocabulary — Zod schemas (xstate-animation-meta.ts) are .strict() so an on/target/guard key smuggled into meta.animation is a loud validation error. XState alone owns states, events, and guards.

import { bindXStateAnimation } from '@engine/animation/xstate-animation-binding';
import { createActor } from 'xstate';
const actor = createActor(characterAnimationMachine).start();
const binding = bindXStateAnimation(actor, mixer, clips); // clips: Map<string, THREE.AnimationClip>
// each frame:
binding.tick(dt); // advances the mixer AND any live blend-tree weights
// later:
binding.dispose(); // unsubscribes + uncaches every action this binding created

bindXStateAnimation validates every state’s meta.animation and resolves every referenced clip name against the clips map up front, at bind time — a typo’d clip name throws immediately, naming the state, rather than only failing once gameplay happens to reach that state.

The scene loader auto-builds mixer/clips for an entity’s GLTF-loaded animations and exposes them via userData (getUserData(object3D, '_animMixer') / '_animClips') — see the real usage in examples/third-person/src/components.ts’s CharacterController.init().

Crossfades use native Three primitives, not a hand-rolled weight scheduler

Section titled “Crossfades use native Three primitives, not a hand-rolled weight scheduler”

Entering or leaving a state fires one real AnimationAction.crossFadeTo / fadeIn / fadeOut call — driven by the same mixer.update(dt) that advances clip time. There’s no second, parallel clock deciding “is the fade done”; Three’s own scheduler is authoritative end to end. (The removed AnimGraph runtime instead pre-played every action at weight 0 and rewrote every action’s weight explicitly every frame, bypassing Three’s fade methods — a workaround for multi-layer weight-summing bugs it used to hit. E2 deliberately does the opposite.)

A blend-tree state is, at the instant of entry, represented by one clip — its heaviest-weighted child — which receives the native crossfade. Sibling children stay silent until the crossfade window elapses, at which point the full live blend-tree distribution takes over via setEffectiveWeight every tick, recomputed from the actor’s live context. This is a documented simplification (Three’s crossFadeTo is strictly 1-to-1; there’s no native N-ary blend-tree crossfade).

binding.tick(dt) has exactly the engine’s SystemFn shape. Register it on the engine’s 'animation' phase:

ctx.systems.add('animation', binding.tick);

or, the pattern the examples use, call it from a GameComponent’s own update(dt) when that component owns the actor (see CharacterController.update() in examples/third-person/src/components.ts). Either way, mixer advancement rides the engine’s one real fixed-step scheduler — the same animation-phase slot the removed AnimGraph runtime’s global animationSystem used to occupy.

Layers, additive blending, and bone masks are not supported by meta.animation — the removed AnimGraph layers never had a working runtime for them either (blendMode/weight/boneMask were rejected at parse because nothing read them). A single XState machine drives one mixer’s weight budget; multi-machine/multi-layer composition would be a distinct, future binding with its own reader, not a dead field on this one.