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 #
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 bytesskeleton_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).
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.5sample_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:
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.
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.
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 clearsfinished; 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. Passrestart=Trueto start the current state again fromstart_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.speedis a global rate multiplier (the test sets it to 0 to freeze phases and watch the fade alone).current_state(),phase()andfinished(a non-looping state reached phase 1 and holds its last pose) report where playback stands. - Crossfades. With
fade_seconds > 0the 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)withphasein[0, 1). Duringupdateevery 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 longdt(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 ofbonefrom 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 settingroot_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 plusmorph_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.
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 paletteInside 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
Poseis tied to the skeleton it was built for;sample_clip,evaluateandblend_withindex by bone and do no resizing. - Children of a 1D node must be added with increasing thresholds or
add_childraises; a clip node cannot take children. Animator.set_parameterraises when no state declares the parameter, so declare it on every tree that should react, or setstates[i].tree.parameters[p]directly.updatebeforeplayraises;AnimationSystem.updateturns that into a failure after all other animators have run, with or without jobs, soplayan animator before (or right after) adding it to the system.playof the current state does not restart it, even once a non-looping state has finished; useplay(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()andcycle_rate()allocate; the_into/_withforms do not. The engine's frame path uses only the latter.- Morph weights are published from
pose.morph_weights; a skeleton whosemesh_nodeis 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_inverseterm 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_intoconvention.docs/THREADING.md, job data ownership rules;docs/ARCHITECTURE.md, the frame loop.docs/api/anim.md, the generated reference;docs/api/assets.mdforClipand the cooker.tests/anim/test_animation.mojo: sampling modes and loops; the Fox skeleton,.cskelround 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.mdfor the character controller the sample drives its animator from.