Animation

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

ClassRole
AnimationGraphDefinitionImmutable blueprint — states, transitions, clip index
AnimationGraphRuntime instance — current state, active transitions, THREE.AnimationMixer
AnimationStateRuntime node — owns a BlendStateMatrix, tracks playback time
AnimationTransitionDirected edge — fires when an entity event arrives, cross-fades over duration seconds
AnimationClipMotion payload on a state — wraps an AnimationClipDefinition plus weight and timeScale
AnimationClipDefinitionClip 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:

ValueConstantMeaning
1Unknownplaceholder / uninitialized
2Clipsingle clip — the common case
4Blendblend 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:

FieldTypePurpose
eventstringEntity event name that triggers this transition
durationnumber (seconds)Cross-fade length; default 0.2
source / targetAnimationStateDefinitionState 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.