Animation

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.

SystemControllerEntityGPUAnimationManagertick(dt)notification eventsweights + clip timesset_playback_weight / set_timeSystemControllerEntityGPUAnimationManager

Two consequences worth knowing:

  • Nothing is culled. AnimationGraphFlag.MeshSizeCulling and AnimationFlags.MeshSizeCulling exist 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

ClassRole
AnimationGraphDefinitionImmutable blueprint - states, transitions, clipIndex
AnimationGraphControllerThe ECS component. Current state, active transitions, per-clip blend state, per-clip clock
AnimationStateRuntime node - owns a BlendStateMatrix, tracks playback time
AnimationTransitionDirected edge - fires on an entity event, cross-fades over duration seconds
AnimationClipMotion on a state - an AnimationClipDefinition plus weight, timeScale and flags
AnimationClipDefinitionClip 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:

  1. It resolves each entry of def.clipIndex to a clip of the model by name. A name the model does not carry throws Animation clip '<name>' is not in the model.
  2. It calls controller.link(entity, ecd, durations) with the durations those clips actually have. link writes them onto the shared clip definitions, which is where notification timing reads them.
  3. 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:

ValueConstantMeaning
1Unknownplaceholder / uninitialised
2Clipsingle clip - the only type anything evaluates
4Blendblend 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:

FieldTypePurpose
eventstringEntity event name that triggers this transition
durationnumber (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 / targetAnimationStateDefinitionThe 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.

  • 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 - SGMesh and MeshSystem
  • Source: engine/graphics3/animation/, engine/graphics/ecs/animation/animator/