Skip to content

3D skeletal animation

Crate sparklight-skeletal (crates/sparklight-skeletal): glTF skins and clips, pose evaluation, blending, a data-driven state machine, root motion, IK, pose exchange with physics-fx ragdolls, and GPU skinning. Everything is data plus plain Rust APIs and runs headless: the tests build a reference glTF in code (sample::sample_glb()), animate it and compare the result with values worked out by hand. Design background: docs/AAA_ROADMAP.md (animation rows), docs/AGENT_FIRST.md.

Units follow the rest of the engine: metres, seconds, radians (mask and graph JSON use seconds and unit-less weights), right handed, Y up. Quaternions in JSON are [x, y, z, w], vectors [x, y, z].

use std::sync::Arc;
use sparklight_skeletal::*;
// 1. Import: skeleton (with all joint ancestors), skins, skinned meshes, clips.
let rig = import_gltf_slice(&std::fs::read("hero.glb")?)?; // or import_gltf_path
for w in &rig.warnings { eprintln!("{}: {}", w.code, w.message); }
// 2. A graph (versioned JSON, schema: crates/sparklight-skeletal/schemas/animgraph.schema.json).
let def = AnimGraphDef::from_json(&std::fs::read_to_string("hero.animgraph.json")?)?;
let mut animator = Animator::new(&def, Arc::new(rig.skeleton.clone()), &ClipSet::new(rig.clips.clone()))?;
// 3. Every frame: set parameters, update, read the pose and the root motion.
animator.set_float("speed", 2.0)?;
let out = animator.update(1.0 / 60.0);
character_transform = character_transform * out.root_motion.to_mat4(); // see "Root motion"
let mut model = Vec::new();
rig.skeleton.model_matrices(&out.pose, Mat4::IDENTITY, &mut model);
let mut palette = Vec::new();
rig.skeleton.skin_palette(&rig.skins[0], &model, &mut palette); // upload with skinning::palette_floats

Runnable pieces: cargo run --release -p sparklight-skeletal --example skeletal_capture (scripted run, writes trace.json, frame.svg and sample.glb to .gwdev/out/skeletal/), --example bench (cost on this machine, bench.json).

Type What it is
Skeleton Bones parents first, unique names, a rest Transform (translation, rotation, scale) per bone, several roots allowed. index_of(name), model_matrices(&pose, root, &mut out), skin_palette(&skin, &model, &mut out).
Skin Joint bone indices in the order of the mesh’s JOINTS_0 values plus inverse bind matrices. Skin::from_rest builds the usual bind pose skin.
LocalPose A Transform per bone, parent-relative. skeleton.rest_pose() is the neutral start. hash() gives an exact fingerprint.
AnimationClip Tracks by bone name (BoneTracks: optional translation, rotation, scale Track), events, duration. Serde-serialisable. bind(&skeleton) matches names (unmatched ones are listed in unbound), sample(&binding, t, looping, &mut pose) writes only the animated components.
Track<T> Interpolation::{Step, Linear, CubicSpline}. Linear rotation is a shortest-arc slerp; cubic splines follow glTF 2.0 appendix C (tangents scale with the key interval; [in, value, out] per key). Ends are held.

Clips bind by name, so one clip plays on every skeleton that has bones with those names (a simple form of retargeting).

import_gltf_slice(bytes) (GLB or self-contained glTF) and import_gltf_path. The result, ImportedRig:

  • skeleton: every joint of every skin and all their ancestors up to the scene roots, so model space equals scene space (an “Armature” node with a transform is a bone). Non-joint nodes between joints are bones too, without a skin entry. Names come from the nodes (node_<i> when unnamed, name#<i> on duplicates).
  • skins, meshes (SkinnedMesh: positions, normals, uvs, indices, joints: [u16; 4], weights: [f32; 4]), clips (named after the animations), node_bone (glTF node to bone index), warnings.
  • Mesh node transforms are ignored for skinned meshes, as the glTF specification says.

Repairs and drops are never silent. Warning codes: anim.channel_target_not_in_skeleton, anim.morph_channel_without_mesh, anim.morph_channel_without_targets, anim.duplicate_channel, anim.weights_renormalized, anim.influences_truncated (only JOINTS_0/WEIGHTS_0), anim.mesh_without_weights, anim.primitive_mode, anim.skin_no_inverse_bind, anim.morph_weights_ignored (default weights of the wrong count), anim.morph_unskinned_mesh (morph targets on a mesh without a skin). Hostile files (truncated data, a node with two parents, a cycle, joint indices past the skin, non-increasing key times, wrong value counts) return coded errors, never a panic.

blend(a, b, w, &mut out), blend_masked(&mut base, &over, mask, weight), apply_additive(&mut base, &additive, &reference, mask, weight), PoseAccumulator (weighted sum of any number of poses).

  • Additive: rotation base * slerp(I, reference^-1 * additive, w), translation base + w (additive - reference), scale base * lerp(1, additive / reference, w). With base == reference and w = 1 the result is exactly additive.
  • Masks (BoneMask, from a MaskSpec in JSON: default, entries {bone, weight, children, falloff_per_level}; later entries override earlier ones). E_ANIM_MASK names an unknown bone.
  • Blend spaces: BlendSpace1D::weights(x) (two neighbours, ends held) and BlendSpace2D::weights(p) (Delaunay triangulation of the sample points, barycentric weights; outside the hull the point is projected onto the nearest triangle; collinear samples fall back to 1D). A weighted blend space keeps its cycles in phase: the cycle length is the weighted mean of the clip lengths.

Version 1, schema schemas/animgraph.schema.json (regenerate with UPDATE_SCHEMAS=1 cargo test -p sparklight-skeletal --test graph; a test fails when the file is stale). Unknown fields are rejected. A complete example is crates/sparklight-skeletal/graphs/locomotion.animgraph.json.

{ "version": 1, "name": "locomotion",
"parameters": [ {"name": "speed", "type": "float"}, {"name": "attack", "type": "trigger"} ],
"root_motion": { "bone": "root", "mode": "planar_yaw" },
"layers": [
{ "name": "base", "machine": { "entry": "idle",
"states": [
{ "name": "idle", "motion": {"type": "clip", "clip": "idle"} },
{ "name": "locomotion", "motion": {"type": "blend1d", "param": "speed",
"samples": [ {"clip": "idle", "position": 0}, {"clip": "walk", "position": 1}, {"clip": "run", "position": 3} ]} },
{ "name": "attack", "motion": {"type": "clip", "clip": "turn"}, "loop": false } ],
"transitions": [
{ "from": "idle", "to": "locomotion", "conditions": [ {"param": "speed", "op": "gt", "value": 0.1} ], "duration": 0.2 },
{ "from": "*", "to": "attack", "conditions": [ {"param": "attack", "op": "trigger"} ], "duration": 0.1 },
{ "from": "attack", "to": "idle", "has_exit_time": true, "exit_time": 1.0, "duration": 0.1 } ] } },
{ "name": "upper", "blend": "additive", "weight": 1.0,
"mask": { "default": 0.0, "entries": [ {"bone": "tip", "weight": 1.0, "children": true} ] },
"machine": { "entry": "wave", "states": [ {"name": "wave", "motion": {"type": "clip", "clip": "wave"}} ] } } ] }
  • Parameters: float, int, bool, trigger (a one-shot flag, cleared when a transition that tests it fires).
  • Layers: layer 0 is the base (override, no mask). Later layers are override (replaces, weighted by weight times the mask) or additive (additive_reference: rest or first_frame of the same motion).
  • Motions: clip, blend1d {param, samples[{clip, position}]}, blend2d {params[2], samples[{clip, position[2]}]}. States have speed, speed_param (a float that multiplies the speed) and loop. A non-looping state holds its last pose.
  • Transitions are checked in order, the first match fires; from: "*" matches any state (and never targets the current state). Conditions gt ge lt le eq ne is_true is_false trigger are all required (and). has_exit_time also waits for the source state’s normalised time to reach exit_time (looping states: within each cycle; exit_time >= 1 fires at the cycle end). A transition needs conditions or an exit time. duration is the cross-fade in seconds (0 switches at once), curve linear or smooth, offset the normalised start time of the destination, can_interrupt lets another transition start from the state being entered while the fade runs: the blended pose at that moment is frozen and faded out, so there is no pop.
  • Events (AnimOutput::events): clip events (clips with blend weight >= 0.5 only, so a fading-out clip stays quiet), and StateExit / StateEnter when a transition starts.
  • Animator API: new(&def, skeleton, &clips) (missing clips, unbound clips and an unknown root bone are errors), set_float, set_int, set_bool, set_trigger, reset_trigger, get, update(dt), pose(), state_name(layer), is_fading(layer), normalized_time(layer), force_state(layer, state, normalized), reset(&def).

AnimGraphDef::validate reports errors with a JSON pointer: E_ANIM_GRAPH_JSON (syntax, unknown fields), _VERSION, _PARAM, _STATE, _MOTION, _TRANSITION, _LAYER, E_ANIM_BLEND_SPACE.

Set root_motion {bone, mode} in the graph. planar_yaw extracts horizontal translation and the heading (rotation about +Y); full extracts everything. AnimOutput::root_motion is {translation, rotation} in the frame of the root bone’s parent at the start of the interval: apply it as world = world * delta (translation rotated by the character’s rotation). It is blended with the same weights as the poses (blend spaces, cross-fades). The pose keeps the root bone in place (planar: x and z at the rest value and the heading removed, the height still animates). Looping clips continue across the loop point without a jump, and any partition of the time gives the same total (tested with random splits). Root motion comes from layer 0 only. AnimationClip::root_motion(track, rest, mode, t0, t1, looping) is the building block.

Targets and poles are in the skeleton’s model space. Both solvers only change the local rotations of the chain, so bone lengths and all other bones stay as they are. Every solve returns IkReport {reached, error, limit_hit}.

  • solve_two_bone(&skeleton, &mut pose, &TwoBoneIk): analytic (law of cosines), for legs and arms. pole picks the bend side, mid_angle_limits bounds the interior angle at the middle joint (a knee), weight blends with the animation, end_rotation keeps the foot’s animated world orientation (AlignUp(+Y)), tilts it to a slope (AlignUp(normal)) or sets it (Model(q)). An unreachable target gives a straight limb towards it.
  • solve_fabrik(&skeleton, &mut pose, &FabrikChain): chains of any length, JointLimit::Cone {max_angle} per joint (the bone stays within the cone around the previous bone), iterations and tolerance. Out of reach gives a straight line.

Foot placement recipe: raycast down from the animated ankle with PhysicsWorld::raycast, TwoBoneIk::new(hip, knee, ankle, hit.point + hit.normal * ankle_height) with pole = a point in front of the knee and end_rotation = AlignUp(hit.normal); drop the pelvis by the larger of the two feet offsets first.

physics::PhysicsBinding::new(&skeleton, &ragdoll_skeleton_desc) matches bones by name (the ragdoll usually has 15 bones, the render rig many more).

  • ragdoll_skeleton(&skeleton, Some(&names), radius_scale) builds a SkeletonDesc from the rest pose of a bigger rig (skipped bones are folded into the hierarchy, axis/length point at the first included child; tune radii in the ragdoll profile).
  • export_pose(&skeleton, &binding, &local_pose, root_world) -> SkeletonPose: the animation as world poses for Ragdoll::drive (Kinematic and Blended modes). root_world is the root given to Ragdoll::build.
  • blend_from_ragdoll(&skeleton, &binding, &anim_pose, &ragdoll.pose_world(&world), root_world, &weights): the pose to skin, with weights[r] the physics share of ragdoll bone r (1 = physics, 0 = animation, exactly the animation at 0). Rotations are blended along the shortest arc; translations stay animated so bones keep their lengths, except ragdoll roots (the pelvis follows the simulation). Bones with no ragdoll bone keep their animated local transform and move with their parent.

Per frame: animator update -> export_pose -> ragdoll.drive -> world.step() -> blend_from_ragdoll -> palette. A hit reaction: ragdoll.hit(...) lowers the strengths; feed the same strengths (as 1 - strength) to the weights.

wgsl/skinning.wgsl is the module sparklight::skinning (register it with skinning::register_modules(&mut ShaderLibrary); #import sparklight::skinning). It declares no bindings, because the bind group contract of sparklight-shader keeps groups 0 to 2 for the library. The including shader defines the palette access:

#import sparklight::skinning
fn skin_joint_matrix(joint: u32) -> mat4x4<f32> { return palette[joint]; } // storage buffer, uniform array or texture fetch
let v = skin_vertex(position, normal, joints, weights); // v.position, v.normal (unit)

Also skin_matrix, skin_position, skin_direction. The palette is model_matrix[joint] * inverse_bind per skin joint (Skeleton::skin_palette, skinning::palette_floats for upload). Weights must sum to 1 (the importer repairs them). Normals use the blended matrix: exact for rotation plus uniform scale. wgsl/skin_compute.wgsl (SKIN_COMPUTE_SHADER, kind: custom) is a ready compute pass that skins a vertex buffer into another (shadow passes, readback, cloth); skinning::skin_mesh is the CPU reference, and a test runs the compute shader on the GPU and compares it with the CPU.

Scene integration and what the renderer still needs

Section titled “Scene integration and what the renderer still needs”

Done in task space3d-commands (reference: docs/agent/space3d.md):

  • Components (crates/sparklight-scene3d/src/specs.rs, schemas from space3d.component_schema): Skeleton (glTF rig: skeleton, skins, clips), Animator (graph path, initial parameters, speed), RootMotionTarget (to the transform, or as desired velocity for a CharacterController), RagdollLink (ragdoll, physics weight, per-bone weights) and IkTarget (two_bone or fabrik, chain of bone names, target object or position, pole, weight). A Ragdoll can be built from the rig (source: "rig", optional bone list), using ragdoll_skeleton and PhysicsBinding.
  • Frame order in Space3D::step: animator update, IK on the animator’s pose, root motion, export_pose and Ragdoll::drive, the physics step, blend_from_ragdoll (physics share per bone: the link’s weight, scaled by 1 - strength when blended, 0 when kinematic). The final pose is space3d.skeleton.pose.
  • Commands: space3d.anim.set_param, anim.trigger, anim.play, anim.validate_graph (the E_ANIM_* errors with JSON pointers, optionally compiled against an object’s rig), space3d.ik.set_target, space3d.skeleton.pose (local, model or world space, with an exact pose hash), space3d.state (animation state, parameters, root motion, IK report) and anim_event entries in space3d.events.
  • Determinism: Animator is Clone, so space3d.snapshot.* includes the animators; the state hash includes every final pose.

Done in task skinned-render-3d (reference: crates/sparklight-3d/docs/render-3d.md, “Skinned meshes”):

  • Skinned draw path. Every surface shader has a SKINNED variant (joint indices at @location(3), weights at (4), the joint palette at group 1 binding 1, object.data.y/z = first matrix of the current and the previous pose). Mesh::with_skin, Scene3D::push_skinned; the shadow and depth passes and the motion vectors use the same palette (the previous one for velocity). Skinned objects of one mesh batch into one draw call.
  • Scene integration (Space3D::skinned_draws, ModelCache): an object with a built Skeleton whose glTF has skinned meshes is drawn with the palette of its final pose (animation, IK, ragdoll blend). A Ragdoll without rig meshes is drawn as a generated skin: a capsule per bone, blended with the neighbouring bone at the joints (Ragdoll.draw = "skin", the default; "bones" shows the bare capsules).
  • Still open: the 3D crate’s own glTF loader still drops the skin attributes (gltf.skin_ignored) and rigs come through sparklight-skeletal; animation LOD (lower update rate and fewer layers for distant characters), *.animgraph.json and clip sets as asset kinds with a schema in the asset registry.

Every error is AnimError {code, message, path?, hint?}; to_value() gives the JSON form used everywhere in the engine. The list is sparklight_skeletal::ERROR_CODES (a test checks that code, list and this table agree).

Code Meaning
E_ANIM_SKELETON Bad skeleton: empty or duplicate name, parent not before the child, non-finite rest transform.
E_ANIM_TRACK A keyframe track is empty, has non-increasing or non-finite times, or the wrong number of values.
E_ANIM_CLIP Invalid clip (duration, event outside the clip) or a clip none of whose tracks matches a bone.
E_ANIM_MASK A mask entry names a bone the skeleton does not have.
E_ANIM_BLEND_SPACE Blend space samples empty, unsorted/duplicate (1D), coincident or non-finite (2D).
E_ANIM_GRAPH_JSON Graph JSON syntax error or unknown/missing field (with line and column or the serde message).
E_ANIM_GRAPH_VERSION The version is missing or not 1.
E_ANIM_GRAPH_PARAM Duplicate or unknown parameter, or a parameter of the wrong type for its use.
E_ANIM_GRAPH_STATE No states, duplicate state name, unknown entry state.
E_ANIM_GRAPH_MOTION A motion without a clip name, or a clip that is not in the clip set.
E_ANIM_GRAPH_TRANSITION Unknown from/to state, bad duration/exit time/offset, no condition and no exit time, operator not valid for the parameter type.
E_ANIM_GRAPH_LAYER No layers, duplicate layer name, weight outside 0 to 1, base layer not an unmasked override, unknown root motion bone.
E_ANIM_RUNTIME_PARAM set_* / get with an unknown parameter or the wrong setter for its type.
E_ANIM_RUNTIME_STATE force_state with an unknown layer or state.
E_ANIM_IK IK chain is not parent-child, a bone has zero length, or the target is not finite.
E_ANIM_GLTF glTF cannot be read, a node has two parents, a cycle, too many bones, invalid indices.
E_ANIM_GLTF_SKIN Skin and skeleton disagree, a mesh joint index is past the skin, vertex attribute counts differ.
E_ANIM_PHYSICS_MAP A ragdoll bone is missing in the skeleton, or the selected ragdoll bones do not form one tree.
E_ANIM_MORPH A morph target set is invalid: empty or repeated name, a delta past the vertex count, unsorted or repeated vertices, a non-finite value, wrong number of default weights. See docs/agent/characters.md.
E_ANIM_MORPH_LIMIT A mesh has more than 256 morph targets; the message names every target over the limit.
E_ANIM_FACE A face definition is invalid (JSON, version, a non-finite weight, bad timing, an eye bone missing from the skeleton).
E_ANIM_RETARGET A retarget map is invalid: bad JSON or version, a bone either skeleton lacks, a target bone driven twice, a bad scale or frame rotation, or (require_complete) unmapped bones.
E_ANIM_FOOT_IK A foot placement definition is invalid (JSON, version, non-positive limits) or names bones the skeleton lacks or legs that are not hip > knee > ankle chains.
E_ANIM_CLOTH A character cloth definition is invalid (JSON, version, negative or inconsistent distances, bad material or collider), a collider bone is missing from the skeleton, or no vertex is within pin_height of the top.
E_ANIM_FACE_TARGET The face uses morph targets no mesh has; the message names them all.
E_ANIM_FACE_PHONEME Phoneme timings are invalid or name phonemes the map does not know (all listed).
E_ANIM_FACE_EXPRESSION An expression name the face does not define, or a non-finite weight.

Produced by cargo test -p sparklight-skeletal (debug is fine; nothing here is slow) and cargo run --release -p sparklight-skeletal --example skeletal_bench on the development Mac (Apple M5 Max, one thread; other builds ran at the same time, so treat the cost table as an order of magnitude).

Claim Measured
Reference glTF import 5 bones (armature and a non-joint helper included), 1 skin, 10 vertices, 8 clips; rest-pose skinning reproduces the bind pose within 1e-5
Linear rotation vs analytic skinning (bend clip at 7 times) within 1e-5 m (CPU), within 1e-5 m (GPU, Metal)
Step / cubic spline sampling step holds the previous key exactly; cubic spline equals the Hermite formula within 1e-5 and hits its keys exactly
Cross-fade pose equals weight * 60 deg * phase at every frame of a 4-frame fade within 1e-5 rad
1D blend space root motion 0 / 0.5 / 1 / 2 / 3 m of root travel for speeds 0 / 0.5 / 1 / 2 / 3 within 1e-4 m
Root motion path independence 100 random splits of a turning clip compose to the same delta within 1e-4
Two-bone IK 300 random reachable targets: worst end error 3.2e-6 m, bone lengths within 1e-5
FABRIK (4 segments, 40 iterations) 200 random targets: mean tip error 4.4e-5 m, lengths within 1e-5; cone limits hold within 0.002 rad
Ragdoll exchange exported rest pose equals physics-fx rest_pose within 1e-5; kinematic ragdoll read back within 2e-3 rad; full weight returns the exported rotations within 1e-4
GPU skinning vs CPU (Metal, 1 to 5000 vertices, random palettes) worst relative position error 3.1e-7, normal 6.9e-7

Test counts: see the final report of the task; the suites are import, clips, blend, graph, animator, ik, physics, skinning, golden, errors under crates/sparklight-skeletal/tests. The golden file tests/golden/locomotion.json records a scripted 180-frame run (UPDATE_GOLDEN=1 rewrites it).

From the bench example (.gwdev/out/skeletal/bench.json): “pipeline” is the whole per-character CPU path: sample 3 clips, 2 blends, model matrices and the skin palette.

Bones Sample 1 clip (bones/ms) Blend 2 poses (bones/ms) Model matrices (bones/ms) Whole pipeline, 3 clips (bones/ms) Characters per 16.6 ms frame
24 24051 14207 40375 1788 1236
64 18317 19719 44536 2389 619
128 10731 16968 48106 2478 321
256 8701 22831 62215 2437 158

Animator update of the locomotion graph (blend space of 3 clips plus an additive layer, 5 bones) 1.9 us; two-bone IK 1.05 us; FABRIK on 12 bones 9.7 us; CPU skinning 48200 vertices/ms (four influences). The pipeline numbers include two pose clones per blend that a renderer integration can avoid with scratch buffers.

  • Linear blend skinning only (no dual quaternion, no corrective shapes); four influences per vertex; morph targets are covered in docs/agent/characters.md.
  • IK assumes uniform scale on the chain; FABRIK limits are cones (no hinge/twist limits yet); no look-at/aim constraint node.
  • No retargeting between different bone naming; clips bind by name. Humanoid mapping profiles are a follow-up.
  • Blend trees are flat (one motion per state); there is no graph-in-graph, no sync groups beyond the cycle-length rule of blend spaces, and no inertialisation (cross-fades and the frozen-snapshot interrupt are what exist).
  • One transition per layer and update; zero-duration chains of states need one update each.
  • Only the first JOINTS_n/WEIGHTS_n set is imported.
  • Clip events come from glTF files only through AnimationClip::events (glTF has no standard event channel): add them in code or JSON.