Animation graphs
How Meep drives skeletal animation through state machines, cross-faded transitions, clip-level notifications, and per-clip blend weights.
The animation graph system lives in
engine/graphics/ecs/animation/animator/. An entity that needs driven
animation carries an AnimationGraph component; AnimationGraphSystem
ticks every visible graph each frame.
Core objects
| Class | Role |
|---|---|
AnimationGraphDefinition | Immutable blueprint — states, transitions, clip index |
AnimationGraph | Runtime instance — current state, active transitions, THREE.AnimationMixer |
AnimationState | Runtime node — owns a BlendStateMatrix, tracks playback time |
AnimationTransition | Directed edge — fires when an entity event arrives, cross-fades over duration seconds |
AnimationClip | Motion payload on a state — wraps an AnimationClipDefinition plus weight and timeScale |
AnimationClipDefinition | Clip metadata — name, duration, sorted notifications[], tags[] |
Defining a graph
A graph definition is most easily authored as JSON and loaded with
readAnimationGraphDefinitionFromJSON. The schema mirrors the class structure:
import { readAnimationGraphDefinitionFromJSON } from
"@woosh/meep-engine/src/engine/graphics/ecs/animation/animator/graph/definition/serialization/readAnimationGraphDefinitionFromJSON.js";
const def = readAnimationGraphDefinitionFromJSON({
clips: [
{ name: "idle", duration: 2.0 },
{ name: "run", duration: 0.8 }
],
notifications: [
{ event: "foot-plant-left" },
{ event: "foot-plant-right" }
],
states: [
{ name: "Idle", type: 2 /* AnimationStateType.Clip */,
motion: { def: 0, weight: 1, timeScale: 1, flags: 1 /* Repeat */ },
tags: ["idle"] },
{ name: "Run", type: 2,
motion: { def: 1, weight: 1, timeScale: 1, flags: 1 },
tags: ["run"] }
],
transitions: [
{ event: "start-run", source: 0, target: 1, duration: 0.2 },
{ event: "stop-run", source: 1, target: 0, duration: 0.3 }
],
startingState: 0
});
startingState is an index into the states array. The first state in the
list also becomes the default startingSate on the definition object.
After loading, call AnimationGraphDefinition.build() (the loader calls this
automatically) to populate clipIndex, the flat list of unique clip
definitions the mixer needs.
States and state types
AnimationStateType has three values:
| Value | Constant | Meaning |
|---|---|---|
1 | Unknown | placeholder / uninitialized |
2 | Clip | single clip — the common case |
4 | Blend | blend space (see below) |
Each AnimationStateDefinition carries a tags string array. Tags do not
drive transitions automatically; AnimationGraphDefinition.matchStateWithMostTags
lets you find the best-matching state by tag set at runtime (useful for
dynamically choosing the next idle or movement state without hard-coding event
names).
Transitions
An AnimationTransitionDefinition has three fields that matter at runtime:
| Field | Type | Purpose |
|---|---|---|
event | string | Entity event name that triggers this transition |
duration | number (seconds) | Cross-fade length; default 0.2 |
source / target | AnimationStateDefinition | State graph directed edge |
When the graph links to an entity (AnimationGraph.link(entity, ecd)), it
registers an entity-event listener for each outgoing transition of the current
state. When the event fires, AnimationTransition.transition() starts the
cross-fade. Cross-fades interpolate via BlendStateMatrix.lerp — weights and
time-scales are linearly mixed over the transition’s normalised time.
Multiple transitions can be active simultaneously. The graph accumulates their blend states and normalises the combined weight.
// fire a transition from gameplay code:
ecd.sendEvent(entityId, "start-run");
Clip flags and playback
AnimationClipFlag.Repeat = 1 — set this bit in motion.flags to loop the
clip. Without it, the clip plays once to the end, clamps, and dispatches
AnimationEventTypes.ClipEnded ("animation-event-clip-ended").
AnimationClip.timeScale multiplies the playback rate. AnimationState.timeScale
is a second multiplier at the state level — both are composited when the mixer
weight row is written.
Clip notifications
Notifications let a clip fire game events at specific times — foot-plant contacts, attack windows, sound cues.
Each AnimationClipDefinition holds a notifications array of
AnimationNotification objects, sorted by time. Each notification references
an AnimationNotificationDefinition, which stores an event string and a
data payload.
AnimationClip.dispatchNotifications(entity, ecd, time0, time1) is called
during AnimationState.tick for the elapsed time interval. It scans the sorted
list once per interval and calls ecd.sendEvent(entity, event, data) for each
notification whose time falls in [time0, time1). Looping clips re-enter
the list from time = 0 when a cycle boundary is crossed — notifications fire
every loop.
// listen for a foot-plant notification on the entity:
ecd.addEntityEventListener(entityId, "foot-plant-left", onFootPlant);
Blend spaces
AnimationStateType.Blend states use a BlendSpace as their motion. A
BlendSpaceDefinition describes the axes of the space (an array of axis
descriptors). Each BlendSpacePoint maps a position vector in that space to a
BlendState (a list of AnimationClip instances with per-clip weights).
A one-axis blend space produces 1-D locomotion blending (idle → walk → run by speed); a two-axis space supports directional locomotion or aim blending (speed × direction, or pitch × yaw). At runtime the graph evaluates which blend-space points surround the current parameter values and interpolates the weights between them.
The blend state is ultimately written into a BlendStateMatrix — parallel
Float32Array vectors of weights and time-scales, one entry per clip in the
graph’s clipIndex. The AnimationGraphSystem writes these values directly to
the Three.js AnimationAction objects on every tick.
Visibility culling
AnimationGraphFlag.MeshSizeCulling = 1 (set by default) causes
AnimationGraphSystem to skip ticking the graph when the mesh’s projected
bounding sphere occupies fewer than 32 pixels on screen. Accumulated
debtTime ensures the animation stays frame-consistent when the entity
re-enters view. Disable by clearing the flag:
graph.clearFlag(AnimationGraphFlag.MeshSizeCulling);
Setting up the system
import { AnimationGraphSystem } from
"@woosh/meep-engine/src/engine/graphics/ecs/animation/animator/AnimationGraphSystem.js";
em.addSystem(new AnimationGraphSystem(viewportSize)); // viewportSize: Vector2
The system depends on AnimationGraph and Mesh components together on the
same entity. It listens for MeshEvents.DataSet to re-attach the mixer when the
mesh asset loads or swaps.
Related
- Skeletons & skinning
- Inverse kinematics
- Source:
engine/graphics/ecs/animation/animator/graph/