Animation graphs
How meep drives skeletal animation through state machines, cross-faded transitions, clip notifications and per-clip blend weights - with the clock on the CPU and the deformation on the GPU.
An entity that needs state-driven animation carries an AnimationGraphController
component: a state machine that decides which clips of its model are playing,
what each one weighs, and where in its timeline each one is. AnimationGraphSystem
ticks every controller once a frame and hands the result to Shade’s
GPUAnimationManager, which does the deformation on the GPU.
The state machine - AnimationState, AnimationTransition, BlendStateMatrix
and the graph-definition/JSON layer - is plain CPU data. There is no mixer, no
action object, and no graph runtime object separate from the component.
Setting up the system
import { AnimationGraphSystem } from
"@woosh/meep-engine/src/engine/graphics3/AnimationGraphSystem.js";
import { MeshSystem } from
"@woosh/meep-engine/src/engine/graphics3/MeshSystem.js";
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import { load_model_scene_bundle } from
"@woosh/meep-engine/src/engine/asset/load_model_scene_bundle.js";
const meshes = new MeshSystem(
engine.graphics,
EngineHarness.shadeScene(engine),
url => load_model_scene_bundle(engine.assetManager, url)
);
await em.addSystem(meshes);
await em.addSystem(new AnimationGraphSystem(engine.graphics, meshes));
The system’s dependencies are [AnimationGraphController, SGMesh] - an entity
needs both components before the system touches it. It takes the
MeshSystem instance rather than looking it up, because that system owns the
per-entity model copy and the ids the animation manager issued for it.
AnimationGraphSystem3 is a deprecated alias of the same class; import the
unsuffixed name.
The CPU owns the clock
Shade can advance clip time itself, in its tick shader, and neither animation
system ever asks it to. AnimationClipFlags.Playing is left clear and each
playing clip’s time is written down every frame with
GPUAnimationManager.set_time.
The reason is that the pose gameplay reads - a socket, an effect anchor, the
muzzle a projectile leaves from - has to be the pose that is on screen, and two
clocks drift. One clock, on the CPU, is what makes
query_entity_node_world_pose answer with the pose the
frame was drawn from.
Two consequences worth knowing:
- Nothing is culled.
AnimationGraphFlag.MeshSizeCullingandAnimationFlags.MeshSizeCullingexist as constants and deserialize, but nothing reads them: a clock that camera framing can stop is a simulation that camera framing can change. Drawing is culled, and that is Shade’s job. - A finished one-shot holds its last frame. Shade’s pose accumulator has no rest-pose term to fall back to, so there is nothing else it could do.
Defining a graph
A graph definition is authored as JSON and loaded with
readAnimationGraphDefinitionFromJSON:
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 }
],
startingSate: 0
});
Note the key: the reader destructures startingSate (the spelling in the
source), an index into states. Anything else falls back to 0, so the first
state becomes the starting state.
The duration written in the JSON is not what plays. Durations are the model’s,
not the graph’s, and AnimationGraphSystem overwrites them from the loaded
.glb before the first tick - see below.
The loader calls AnimationGraphDefinition.build() for you, which populates
clipIndex: the flat list of distinct AnimationClipDefinitions the graph
names. Every per-clip array in the runtime - weights, time scales, times, GPU
ids - is in clipIndex order.
AnimationGraphDefinitionAssetLoader, beside the reader, is the AssetLoader
that turns a JSON asset into a built definition - register it with the asset
manager to load graphs by path.
Core objects
| Class | Role |
|---|---|
AnimationGraphDefinition | Immutable blueprint - states, transitions, clipIndex |
AnimationGraphController | The ECS component. Current state, active transitions, per-clip blend state, per-clip clock |
AnimationState | Runtime node - owns a BlendStateMatrix, tracks playback time |
AnimationTransition | Directed edge - fires on an entity event, cross-fades over duration seconds |
AnimationClip | Motion on a state - an AnimationClipDefinition plus weight, timeScale and flags |
AnimationClipDefinition | Clip metadata - name, duration, sorted notifications[], tags[] |
AnimationGraphController lives at
@woosh/meep-engine/src/engine/graphics3/animation/AnimationGraphController.js
(named export, typeName = "AnimationGraphController"); everything else is
under engine/graphics/ecs/animation/animator/.
The controller does not round-trip. It has no serialization adapter and is
not in populateEngineSerializationRegistry - it is built transiently from the
graph definition each time an entity is decorated. The typeName exists so the
registry can name the class, not because anything writes one to disk.
Attaching a controller
import { AnimationGraphController } from
"@woosh/meep-engine/src/engine/graphics3/animation/AnimationGraphController.js";
import { SGMesh } from
"@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/aggregate/SGMesh.js";
import { Transform64 } from
"@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
const controller = new AnimationGraphController();
controller.initialize(def);
controller.start_phase = Math.random(); // fraction of the starting clip
ecd.addEntity([
SGMesh.fromURL("data/models/knight.glb"),
new Transform64(),
controller
]);
initialize(def) builds one AnimationState per state definition and one
AnimationTransition per transition, and wires them to each other. Do not call
it on a linked controller.
start_phase is where the graph begins inside its starting clip, as a
fraction of that clip’s duration - a fraction rather than a time because the
duration comes from the model, which loads long after the entity does. It is
applied once, at link, without dispatching the notifications it skips over, so a
unit that spawns mid-stride does not also spawn the footstep it missed. Its
purpose is to keep identical units from moving in lockstep.
You do not call link yourself. AnimationGraphSystem does it once the entity’s
model has arrived:
- It resolves each entry of
def.clipIndexto a clip of the model by name. A name the model does not carry throwsAnimation clip '<name>' is not in the model. - It calls
controller.link(entity, ecd, durations)with the durations those clips actually have.linkwrites them onto the shared clip definitions, which is where notification timing reads them. - It registers only the clips the graph names with the GPU animation manager, and re-registers when the model changes.
That last point is a rule, not an optimisation - see Skeletons & skinning for why a clip nobody plays must not be bound.
States and state types
AnimationStateType has three values:
| Value | Constant | Meaning |
|---|---|---|
1 | Unknown | placeholder / uninitialised |
2 | Clip | single clip - the only type anything evaluates |
4 | Blend | blend space (see below) |
Each AnimationStateDefinition carries a tags string array. Tags do not drive
transitions; AnimationGraphDefinition.matchStateWithMostTags(tags) returns the
definition matching the most of them - preferring the state with the fewest tags
on a tie, and undefined when no state shares a tag. That is how you pick an
idle or a movement state at runtime without hard-coding event names.
AnimationGraphController also offers state_by_name(name),
state_by_clip_name(name) and state_by_definition(def) for finding the runtime
state that corresponds to one.
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. The class default is 0.2, but the JSON reader writes 0 for any transition that omits it - so always author it |
source / target | AnimationStateDefinition | The directed edge |
When a state is entered, each of its outgoing transitions registers an entity
event listener. When the event fires, AnimationTransition.transition() starts
the cross-fade and pushes itself onto the controller’s activeTransitions; the
target becomes state immediately, while the source keeps ticking in
simulatedStates until the transition finishes.
Cross-fades interpolate with BlendStateMatrix.lerp over the transition’s
normalised time - weights and time scales both. The transition’s own clock
advances at a rate lerped between the source’s and the target’s timeScale.
Multiple transitions can be active at once: the controller sums their blend states and divides by the count.
// 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, clamps at its end, and dispatches
AnimationEventTypes.ClipEnded ("animation-event-clip-ended") with the
AnimationClip as the event payload.
AnimationClip.timeScale multiplies the playback rate; AnimationState.timeScale
is a second multiplier at the state level. Both are composited into the
BlendStateMatrix row, and both are consumed on the CPU - the time they
scale is already integrated into the state’s clock, so they have no GPU
counterpart.
AnimationGraphController.clip_time(clip_index) gives the time a clip is at, in
seconds from its own start, wrapped for a looping clip and clamped for a
one-shot. That is exactly the value written to the renderer and exactly what a
pose query has to be asked at. It is only meaningful for a clip the blend gives
weight to; a clip nothing is playing keeps whatever time it last stopped at.
Clip notifications
Notifications let a clip fire game events at authored times - foot-plant contacts, attack windows, sound cues.
Each AnimationClipDefinition holds a notifications array of
AnimationNotification objects sorted by time, each referencing an
AnimationNotificationDefinition that stores an event string and a data
payload.
AnimationClip.dispatchNotifications(entity, ecd, time0, time1) runs during
AnimationState.tick and dispatches everything in the half-open interval
[time0, time1), calling ecd.sendEvent(entity, event, data) for each. The
interval is half-open so consecutive steps tile the timeline without overlap: a
step’s end time is carried forward verbatim as the next step’s start time, and
accumulated frame times do land exactly on authored notification times.
By the same rule a clip spans [0, duration), so a notification authored at
exactly duration belongs to the next cycle - which a non-repeating clip never
plays. Use ClipEnded for the end of a one-shot. A looping clip re-enters the
list at time = 0 on each cycle boundary, and a single step that spans several
cycles fires each cycle’s notifications in order.
// listen for a foot-plant notification on the entity:
ecd.addEntityEventListener(entityId, "foot-plant-left", onFootPlant);
Blend spaces
BlendSpace, BlendSpaceDefinition and BlendSpacePoint ship under
engine/graphics/ecs/animation/animator/blending/, and
AnimationStateType.Blend is still a constant - but nothing evaluates them.
AnimationState.updateBlendState handles AnimationStateType.Clip and nothing
else, readAnimationGraphDefinitionFromJSON always builds an AnimationClip for
a state’s motion, and no module in the engine imports BlendSpace. Treat the
types as unwired scaffolding, not as a feature.
For blending today, use overlapping transitions, or drive several clips directly
through the Animation component and its
AnimationSystem, where per-clip weights are shares of one pose.
Related
- Skeletons & skinning -
Skin,SkinnedMesh,GPUAnimationManager, per-entity model instancing - Inverse kinematics - post-processing the pose against terrain
- Hierarchy - how a bone is addressed, and querying a socket’s world pose
- Meshes & materials -
SGMeshandMeshSystem - Source:
engine/graphics3/animation/,engine/graphics/ecs/animation/animator/