> Cinder 0.1.0 documentation: Animation guide (cinder/anim)
> Canonical page: https://cinder.ash10.dev/docs/guides/anim/ · Source: `docs/guides/anim.md` in the engine repository (engine commit 47c9352, imported 2026-11-09)
> Index of every page for agents: https://cinder.ash10.dev/llms.txt

# Animation guide (`cinder/anim`)

`cinder/anim` is skeletal animation written in Mojo: skeletons built from glTF skins or read
from cooked `.cskel` files, clip sampling into local poses, blend trees evaluated on a shared
phase, animators with states, crossfades, events and root motion, and an `AnimationSystem` that
advances every animator as its own job and publishes `JointPalette` components for the render
bridge. There is no native code in the path; the only C involved is the glTF importer that
produces the clips.

In the module map (`docs/ARCHITECTURE.md`) the package sits beside `physics` and `render`. It
imports `cinder/core` (math, jobs), `cinder/assets` (`Clip`, `ClipChannel`, `GltfAsset`, the
cooked-file `Cursor`), `cinder/ecs` and `cinder/scene/render_sync` (`JointPalette`), and is
owned by `cinder/app`, which holds one `AnimationSystem`.

| Module | Contents |
|---|---|
| `anim/skeleton.mojo` | `Skeleton`, `skeleton_from_gltf`, `write_skeleton`, `read_skeleton`, `MAX_BONES` |
| `anim/sampling.mojo` | `Pose`, `sample_clip`, `wrap_time` (plus `sample_component`, `sample_rotation`) |
| `anim/blend.mojo` | `BlendNode`, `BlendTree` |
| `anim/animator.mojo` | `AnimationEvent`, `AnimationState`, `Animator` |
| `anim/system.mojo` | `AnimatorRef`, `AnimationSystem` |

Signatures are in `docs/api/anim.md`; `tests/anim/test_animation.mojo` covers every feature
below and is where the snippets come from.

## Units and conventions

Clip times are seconds. A blend tree and an animator run on a normalised **phase** in `[0, 1)`;
leaf clips sample at `phase * duration`. Clips keep glTF **node** indices in their channels and
`Skeleton.node_to_bone` maps them to bones, so clips from the same glTF file work with the
skeleton of any of its skins. Bones are stored parents-first; `joints` lists the skin's joints in
palette order. Matrices are column-major `Mat4`; rest transforms are `Transform` (translation,
rotation, scale) in parent space. The palette is expressed in the mesh node's rest space:
`inverse(mesh node world) * bone world * inverse bind`, the same convention the importer uses at
the bind pose, so the render instance's transform is the mesh node's world transform and the
palette moves bones relative to it.

## Skeletons

```mojo
from cinder.anim import Skeleton, read_skeleton, skeleton_from_gltf, write_skeleton
from cinder.assets.gltf import load_gltf

var asset = load_gltf(lib.ptr(), String("assets/samples/Fox/Fox.glb"))
var skeleton = skeleton_from_gltf(asset, 0)          # skin 0
print(skeleton.bone_count(), skeleton.joint_count())  # >= 24 bones, 24 joints for the Fox
var spine = skeleton.find_bone(String("b_Spine01_02"))

var bytes = write_skeleton(skeleton)                  # CSKL v1
var back = read_skeleton(bytes.copy())                # raises on bad magic, version or trailing bytes
```

`skeleton_from_gltf(asset, skin)` includes every joint of the skin plus the ancestors needed to
reach the outermost ancestor shared by all joints, so an animated parent that is not itself a
joint still moves its children. Bones are emitted depth-first from the roots so parents precede
children (`validate()` enforces this, together with `1 <= bone_count <= MAX_BONES`, which is
256, and consistent `joints` / `inverse_bind` lengths). The mesh node is the first node that
uses the skin and carries a mesh; `mesh_node`, `mesh_node_inverse` and `morph_count` (morph
target weights animated on that node, 0 when none) come from it.

`Skeleton` fields are public and a skeleton can be built by hand (the tests' `_two_bone_skeleton`
does so). `world_transforms(local)` / `world_transforms_into(local, out)` give bone matrices in
model space; `palette(local)` / `palette_into(local, worlds, out)` give skinning matrices in
palette order. The `_into` forms resize their output lists and allocate nothing once sized; the
plain forms allocate and are for tests and tools (ADR-0014).

The cooker (`cinder/assets/cook.mojo`) writes one `.cskel` per skin under the logical name
`<prefix>#skeleton<i>` and one `.cclip` per animation. `write_skeleton` stores every bone/node
index plus one so that -1 round-trips as 0; `read_skeleton` consumes its byte list and
revalidates the result, so a cooked skeleton from an older version is rejected rather than
misread.

## Poses and clip sampling

A `Pose` is the skeleton's local transforms plus morph weights. `Pose(skeleton)` starts at the
rest pose with zero weights; `reset(skeleton)` returns to it, `copy_from(other)` copies without
allocating and `blend_with(b, t)` lerps in place (translation and scale linearly, rotation with
`Quat.nlerp` along the shorter arc, weights linearly).

```mojo
from cinder.anim import Pose, sample_clip, wrap_time
from cinder.assets.cooked import Clip, ClipChannel

var clip = Clip(String("synthetic"))
clip.duration = 2.0
# node 0, translation (path 0), linear (interpolation 0), 3 components: (0,0,0) -> (2,0,0) over 2 s
clip.channels.append(ClipChannel(0, 0, 0, 3, _floats(0.0, 2.0), _floats(0.0, 0.0, 0.0, 2.0, 0.0, 0.0)))

var pose = Pose(skeleton)
sample_clip(clip, skeleton, 0.5, pose)       # root translation is (0.5, 0, 0)
sample_clip(clip, skeleton, 5.0, pose)       # past the end: the last key holds
var looped = wrap_time(2.5, clip.duration)   # 0.5
```

`sample_clip(clip, skeleton, time, pose)` writes every channel of the clip at `time` into
`pose`. Sampling follows glTF: `interpolation` 0 is linear (slerp for rotations), 1 is step, 2 is
cubic spline (`values` holds in-tangent, value, out-tangent per key; tangents are per second and
scaled by the key span; spline rotations are normalised). Before the first key or after the last
the end value holds, so looping is the caller's job through `wrap_time(time, duration)`, which
maps any time into `[0, duration)`. Channels whose node is not in the skeleton are skipped, and
because the pose starts from the rest pose, channels a clip does not animate keep the skeleton's
rest values. Weight channels (`path` 3) are applied only when they target the mesh node.

Clips come from the cooked `.cclip` format or straight from a `GltfAsset`:

```mojo
var clips = List[Clip]()
for a in range(len(asset.animations)):
    var clip = Clip(asset.animations[a].name)
    clip.duration = asset.animations[a].duration
    for ch in asset.animations[a].channels:
        clip.channels.append(
            ClipChannel(ch.node, ch.path, ch.interpolation, ch.components, ch.times.copy(), ch.values.copy())
        )
    clips.append(clip^)
```

## Blend trees

A `BlendTree` is a flat list of `BlendNode`s with a `root`, plus named parameters. Leaves are
clips (`add_clip(clip_index, speed=1.0)`); inner nodes are `add_blend_1d(parameter)` or
`add_blend_2d(parameter_x, parameter_y)`. `add_child(node, child, at: Vec2)` attaches a child
with full weight at parameter value `at` (1D nodes use `at.x()`; their children must be added
in increasing order). A node takes at most `MAX_CHILDREN = 8` children. A tree must be acyclic:
`add_child` raises when the edge would close a cycle (`add_child(n, n)`, or attaching an
ancestor of `n` under `n`); a node may still be shared by several parents. Finish with
`set_root`; `validate(clip_count)` runs when the tree becomes an animator state and also
rejects child indices out of range and cycles made by editing `nodes` directly.

```mojo
from cinder.anim import AnimationState, Animator, BlendTree
from cinder.core.math.vec import Vec2

var tree = BlendTree()
var speed = tree.add_parameter(String("speed"), 0.0)
var blend = tree.add_blend_1d(speed)
var walk_leaf = tree.add_clip(walk_index)
var run_leaf = tree.add_clip(run_index)
tree.add_child(blend, walk_leaf, Vec2(1.0, 0.0))    # pure Walk at speed 1
tree.add_child(blend, run_leaf, Vec2(4.0, 0.0))     # pure Run at speed 4
tree.set_root(blend)

var tree2 = BlendTree()
var px = tree2.add_parameter(String("x"))
var py = tree2.add_parameter(String("y"))
var node2 = tree2.add_blend_2d(px, py)
tree2.add_child(node2, tree2.add_clip(survey_index), Vec2(0.0, 0.0))
tree2.add_child(node2, tree2.add_clip(walk_index), Vec2(0.0, 1.0))
tree2.add_child(node2, tree2.add_clip(run_index), Vec2(1.0, 0.0))
tree2.set_root(node2)
```

Weights: a 1D node interpolates linearly between the two thresholds around the parameter and
clamps outside them. A 2D node uses gradient-band interpolation (Johansen 2009): smooth, sums to
one, and reproduces each child exactly at its own point. `leaf_weights()` /
`leaf_weights_into(weights)` return one weight per node (leaves carry their blended weight,
inner nodes their subtree's).

Phase synchronisation: every leaf samples at `phase * clip.duration`, so a walk and a run
blended 50/50 stay in step (same foot down at the same phase). The phase advances at
`cycle_rate(clips)` per second, the weighted average of each leaf's `speed / duration`;
`cycle_rate_with(clips, weights)` is the allocation-free form the animator uses. With the Fox
at `speed = 2.5` the rate is `0.5 / walk.duration + 0.5 / run.duration`, which the test asserts.
`evaluate(clips, skeleton, phase, pose, scratch)` and
`evaluate_with(..., pose, scratch, weights)` blend every weighted leaf into `pose`;
`scratch` is a second pose of the same skeleton. Blending is sequential normalised lerp:
leaf *i* is mixed in with weight `w_i / (accumulated + w_i)`, so the result is the weighted
average regardless of node order.

## The animator

`Animator(skeleton^, clips^)` owns a skeleton, its clips, the current `pose` and the scratch it
needs. States are `AnimationState(name, tree^, looping=True)`; `add_state` validates the tree
against the clip count and returns the state index, and `add_clip_state(name, clip, looping,
speed)` is the one-leaf shortcut. `find_state(name)` looks an index up; `set_parameter(name,
value)` sets the parameter on every state that declares it and raises if none does.

```mojo
var animator = Animator(skeleton.copy(), clips.copy())
var survey = animator.add_clip_state(String("survey"), survey_index)
var locomotion = animator.add_state(AnimationState(String("locomotion"), tree^))
animator.add_event(locomotion, 0.5, String("footstep"))

animator.play(locomotion)
animator.set_parameter(String("speed"), 2.5)
animator.update(dt)                                 # phase advances at the blended cycle rate
for ev in animator.take_events():
    print(ev.state, ev.name)

animator.play(survey, 0.5)                          # crossfade over 0.5 s
animator.update(0.25)
print(animator.fade_weight())                       # 0.5: halfway between the two states
var palette = animator.palette()                    # List[Mat4] in palette order
```

- **Playback.** `play(state, fade_seconds=0, start_phase=0, restart=False)` switches states
  and clears `finished`; playing the current state again is a no-op (so calling it every frame
  is safe), even when that state is non-looping and has finished. Pass `restart=True` to start
  the current state again from `start_phase`, for example to replay a finished jump; with a
  fade the old playthrough fades out as the new one starts. `update(dt)` raises if nothing has
  been played. `speed`
  is a global rate multiplier (the test sets it to 0 to freeze phases and watch the fade alone).
  `current_state()`, `phase()` and `finished` (a non-looping state reached phase 1 and holds
  its last pose) report where playback stands.
- **Crossfades.** With `fade_seconds > 0` the outgoing state keeps advancing at its own rate;
  both trees are evaluated and the poses are blended with a linear weight,
  `fade_weight() = 1 - remaining / duration`, so the fade starts exactly at the outgoing pose
  and ends exactly in the new one.
- **Events.** `add_event(state, phase, name)` with `phase` in `[0, 1)`. During `update` every
  event whose phase lies in `(phase_before, phase_after]` on the unwrapped timeline fires once,
  including across loop wraps and several whole cycles in one long `dt` (a 2 s update over a
  1 s looping clip with events at 0.0 and 0.5 fires four). `take_events()` returns and clears
  the list; events are reported for the current state only.
- **Root motion.** `enable_root_motion(bone=0)` removes the horizontal (X, Z) translation of
  `bone` from the pose, pinning it to the rest translation while keeping its Y so authored
  crouches and jumps still show, and accumulates the displacement in model space;
  `take_root_motion()` returns and clears it. Loop wraps are unwrapped exactly: the remainder of
  the old cycle, any full cycles in between, and the start of the new one. The flag can be
  turned off again by setting `root_motion = False`. Root motion evaluates the tree at extra
  phases each update with freshly allocated scratch poses, so it is not allocation-free; the
  sample game keeps it off and drives the character from the controller instead (ADR-0012).
- **Outputs.** `pose` (local transforms plus `morph_weights`), `palette()` /
  `palette_into(out)` (skinning matrices for the render), `bone_matrices()` /
  `bone_matrices_into(out)` (every bone in model space, for attachments, IK or debug draw).

## The animation system and the render bridge

Animators live in `AnimationSystem.animators`; `add(animator^)` returns the index and an entity
refers to it with `AnimatorRef(index)` (component id 8). `publish(world)` writes a
`JointPalette` (component id 9, `cinder/scene/render_sync.mojo`) for every entity with an
`AnimatorRef`: joints through `palette_into` and the pose's morph weights, in place when the
component already exists, otherwise added. `build_render_scene` then copies `JointPalette.joints`
into `RenderScene.joints` and gives the instance its `joint_offset`, so the skinned draw sees the
matrices. `__len__` is the animator count and `clear()` drops them all (a level restart), after
which every `AnimatorRef` is stale and `publish` raises on it.

```mojo
from cinder.anim import AnimationSystem, AnimatorRef
from cinder.core.jobs import JobSystem

var system = AnimationSystem()
var index = system.add(animator^)
var man = world.spawn()
world.add[LocalTransform](man, LocalTransform(asset.nodes[skeleton.mesh_node].world))
world.add[MeshRenderer](man, MeshRenderer(mesh, material, True))   # skinned
world.add[AnimatorRef](man, AnimatorRef(index))

var jobs = JobSystem(lib.ptr(), 3)
system.update(dt, Optional[Pointer[JobSystem, MutUntrackedOrigin]](_ptr(jobs)))
_ = system.publish(world)
_ = build_render_scene(world, scene)              # scene.joints now holds the palette
```

Inside `App.run_frame` the order is: fixed steps (physics, `Game.fixed_update`),
`write_transforms`, `Game.update`, `anim.update(dt, jobs)`, `anim.publish(world)`,
`propagate_transforms`, audio, render bridge, render. Animation therefore runs once per rendered
frame on the frame's variable `dt`, not inside the fixed step; set blend parameters and call
`play` from `Game.update` (or `fixed_update`) and they take effect in the same frame's `update`.
The sample sets its locomotion `speed` parameter from the controller's horizontal speed every
frame.

## Threading: one job per animator

`AnimationSystem.update(dt, jobs)` builds one `_AnimatorJob` (animator pointer, `dt`, failure
flag) per animator, keeps them in a persistent list that outlives the wait, and
submits each to the engine job system with a shared `Counter`; the caller then `wait`s on the
counter, executing jobs itself while it waits. Each job touches only its own animator, so no
synchronisation is needed beyond the counter, and the results are bit-identical to serial
evaluation (the test runs 16 animators both ways and compares every palette matrix). With no job
system, or with a single animator, the same jobs run inline one after the other. Both paths
behave the same: an animator whose `update` raised (one that was never played) sets its flag
and the others still run; after every animator has been updated the call raises
`animator <i> failed to update` for the first flagged one (with `: it was never played` when
that is the cause), and `updates` is not incremented. `publish` runs on the calling thread.

This is the one place Mojo code runs on a non-main thread: job workers are threads Cinder itself
created, which `docs/THREADING.md` rule 1 allows. Do not touch `system.animators` from another
job while `update` is in flight, and keep the `AnimationSystem` where it is (it hands raw
pointers to its own list elements to the jobs and waits for them before returning, so the list
must not grow during the call; `add` between frames is fine).

## Pitfalls

- A `Pose` is tied to the skeleton it was built for; `sample_clip`, `evaluate` and
  `blend_with` index by bone and do no resizing.
- Children of a 1D node must be added with increasing thresholds or `add_child` raises; a clip
  node cannot take children.
- `Animator.set_parameter` raises when no state declares the parameter, so declare it on every
  tree that should react, or set `states[i].tree.parameters[p]` directly.
- `update` before `play` raises; `AnimationSystem.update` turns that into a failure after all
  other animators have run, with or without jobs, so `play` an animator before (or right
  after) adding it to the system.
- `play` of the current state does not restart it, even once a non-looping state has
  finished; use `play(state, restart=True)`.
- Phases are truncated with `Int(phase)` when counting loop wraps; the unwrapped phase, not a
  comparison of wrapped values, is what the code uses (`docs/MOJO_NOTES.md`, Phase 6).
- `palette()`, `bone_matrices()`, `leaf_weights()`, `evaluate()` and `cycle_rate()` allocate;
  the `_into` / `_with` forms do not. The engine's frame path uses only the latter.
- Morph weights are published from `pose.morph_weights`; a skeleton whose `mesh_node` is unknown
  (-1) accepts weight channels from any node.
- The render instance's transform must be the mesh node's world transform (as in the snippet
  above), or the palette's `mesh_node_inverse` term puts the skin in the wrong place.

## See also

- `docs/adr/0012-physics-animation-audio-input.md`, animation decisions 1 to 6.
- `docs/adr/0014-sample-game-and-performance.md`, the zero-allocation frame and the `_into`
  convention.
- `docs/THREADING.md`, job data ownership rules; `docs/ARCHITECTURE.md`, the frame loop.
- `docs/api/anim.md`, the generated reference; `docs/api/assets.md` for `Clip` and the cooker.
- `tests/anim/test_animation.mojo`: sampling modes and loops; the Fox skeleton, `.cskel` round
  trip, 1D and 2D blend trees, phase sync and crossfades; events and root motion; the system on
  jobs versus serial, and a skinned render through the ECS bridge.
- `docs/guides/physics.md` for the character controller the sample drives its animator from.
