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].
Quick start
Section titled “Quick start”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_pathfor 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_floatsRunnable 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).
Data model
Section titled “Data model”| 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).
glTF import
Section titled “glTF import”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.
Blending
Section titled “Blending”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), translationbase + w (additive - reference), scalebase * lerp(1, additive / reference, w). Withbase == referenceandw = 1the result is exactlyadditive. - Masks (
BoneMask, from aMaskSpecin JSON:default, entries{bone, weight, children, falloff_per_level}; later entries override earlier ones).E_ANIM_MASKnames an unknown bone. - Blend spaces:
BlendSpace1D::weights(x)(two neighbours, ends held) andBlendSpace2D::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.
The animation graph (JSON)
Section titled “The animation graph (JSON)”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 byweighttimes the mask) oradditive(additive_reference:restorfirst_frameof the same motion). - Motions:
clip,blend1d {param, samples[{clip, position}]},blend2d {params[2], samples[{clip, position[2]}]}. States havespeed,speed_param(a float that multiplies the speed) andloop. 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). Conditionsgt ge lt le eq ne is_true is_false triggerare all required (and).has_exit_timealso waits for the source state’s normalised time to reachexit_time(looping states: within each cycle;exit_time >= 1fires at the cycle end). A transition needs conditions or an exit time.durationis the cross-fade in seconds (0 switches at once),curvelinearorsmooth,offsetthe normalised start time of the destination,can_interruptlets 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), andStateExit/StateEnterwhen a transition starts. AnimatorAPI: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.
Root motion
Section titled “Root motion”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.
Inverse kinematics
Section titled “Inverse kinematics”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.polepicks the bend side,mid_angle_limitsbounds the interior angle at the middle joint (a knee),weightblends with the animation,end_rotationkeeps 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.
Ragdolls (physics-fx)
Section titled “Ragdolls (physics-fx)”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 aSkeletonDescfrom the rest pose of a bigger rig (skipped bones are folded into the hierarchy,axis/lengthpoint 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 forRagdoll::drive(Kinematic and Blended modes).root_worldis therootgiven toRagdoll::build.blend_from_ragdoll(&skeleton, &binding, &anim_pose, &ragdoll.pose_world(&world), root_world, &weights): the pose to skin, withweights[r]the physics share of ragdoll boner(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.
Skinning on the GPU
Section titled “Skinning on the GPU”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::skinningfn skin_joint_matrix(joint: u32) -> mat4x4<f32> { return palette[joint]; } // storage buffer, uniform array or texture fetchlet 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 fromspace3d.component_schema):Skeleton(glTF rig: skeleton, skins, clips),Animator(graph path, initial parameters, speed),RootMotionTarget(to the transform, or as desired velocity for aCharacterController),RagdollLink(ragdoll, physics weight, per-bone weights) andIkTarget(two_boneorfabrik, chain of bone names, target object or position, pole, weight). ARagdollcan be built from the rig (source: "rig", optional bone list), usingragdoll_skeletonandPhysicsBinding. - Frame order in
Space3D::step: animator update, IK on the animator’s pose, root motion,export_poseandRagdoll::drive, the physics step,blend_from_ragdoll(physics share per bone: the link’s weight, scaled by1 - strengthwhen blended, 0 when kinematic). The final pose isspace3d.skeleton.pose. - Commands:
space3d.anim.set_param,anim.trigger,anim.play,anim.validate_graph(theE_ANIM_*errors with JSON pointers, optionally compiled against an object’s rig),space3d.ik.set_target,space3d.skeleton.pose(local,modelorworldspace, with an exact pose hash),space3d.state(animation state, parameters, root motion, IK report) andanim_evententries inspace3d.events. - Determinism:
AnimatorisClone, sospace3d.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
SKINNEDvariant (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 builtSkeletonwhose glTF has skinned meshes is drawn with the palette of its final pose (animation, IK, ragdoll blend). ARagdollwithout 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 throughsparklight-skeletal; animation LOD (lower update rate and fewer layers for distant characters),*.animgraph.jsonand clip sets as asset kinds with a schema in the asset registry.
Error codes
Section titled “Error codes”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. |
Measured results
Section titled “Measured results”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).
Cost (release build, one thread)
Section titled “Cost (release build, one thread)”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.
Known limits
Section titled “Known limits”- 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_nset is imported. - Clip events come from glTF files only through
AnimationClip::events(glTF has no standard event channel): add them in code or JSON.