DocsGuides

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 BlendNodes 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 waits 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 #

View as MarkdownSource: docs/guides/anim.md