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.
The convention: meta.animation
Section titled “The convention: meta.animation”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.
Binding the machine to a mixer
Section titled “Binding the machine to a mixer”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 createdbindXStateAnimation 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).
Where the mixer ticks
Section titled “Where the mixer ticks”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.
Deliberately out of scope
Section titled “Deliberately out of scope”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.
See also
Section titled “See also”- Blend trees — the 1D/2D/direct math
meta.animation’sblendTreefield reuses. - Inverse kinematics — procedural bone posing layered on top of the pose this binding produces.
- The game loop & phases — the
animationphase.