Animation
Skeletal animation from glTF, node animation, and sprite clips from an image atlas, all deterministic by tick, drawn live with GPU skinning and traced in the same pose (the engine plan’s block D4). Everything is generic: clips, atlases and settings come from a scene’s data or a game’s code, and nothing of any product is in the engine.
| Piece | Where | What |
|---|---|---|
| sampling, blending, crossfades, events, sprite clips, CPU skinning | pfx_core::anim |
pure Rust, no dependencies, bit-exact across platforms |
glTF skins, joints, weights, clips; [object.animation] |
pfx-load |
Mesh::rig, Mesh::node_rig, Scene::animations |
| GPU skinning | pfx-live (skin.rs, frame.rs) |
linear blend skinning in the vertex stage of every mesh pass |
| sprite fills | pfx-live (flat) |
Fill::Image and FlatSprites |
| playing it in a game | pfx-play |
world.animate(...), world.play_clip(...), Game::animated |
| the traced pose | pfx-trace |
stage::scene_posed with a Posed snapshot |
The data
Section titled “The data”A scene object plays an animation through pfx-scene’s [object.animation] (format 1, pfx-scene at 696888c; its Formats has every key):
[[object]]name = "walker"mesh = "walker"
[object.animation]blend = [{ clip = "Walk", weight = 0.7 }, { clip = "Run", weight = 0.3 }]events = [{ name = "step", clip = "Walk", time = 0.4 }]
[[object]]name = "hero"mesh = "card"material = "sprite"face_camera = true
[object.animation]atlas = "art/hero.png"grid = [8, 4]fps = 12.0clip = "idle"events = [{ name = "step", clip = "run", frame = 2 }]
[object.animation.clips.idle]from = 0to = 3
[object.animation.clips.run]from = 8to = 15fps = 16.0pfx-scene checks the keys, names and kinds. pfx-load checks what only the assets can say and refuses at the key, with the file and line:
- a glTF
clip, ablendclip and an event’s clip must be clips of the object’s mesh (mesh walker has no clip Jog); - an event of a glTF clip takes a
time; a time past the clip’s end never comes and is dropped; - a sprite’s atlas must read as a PNG, its
framesmust lie inside it, and each clip’stomust be one of its frames; - an object takes a sprite or
content, not both.
Scene::animations maps each animated object’s name to an Animation: clip, blend (clip and weight pairs), speed, playback (Loop, Once, PingPong), start, and either rig (the mesh’s clips with this object’s events on them) or sprite (its SpriteSheet and the content key of its atlas). Animation::still_frame() is the frame a sprite shows before play.
Mesh::parse reads skins (joints, inverse bind matrices, identity when the file has none), animations (translation, rotation and scale channels with their keys; morph weights are skipped), each node’s skin and decomposed trs, and each primitive’s JOINTS_0 and WEIGHTS_0.
- Skinned meshes.
Mesh::rig(skin)builds aRig: the skin’s joints ordered parents first (a vertex’s joints are remapped withSkinRig::joints), each joint’s parent the nearest joint above it, the root joints’ common parent node as the skeleton’s root matrix, and every animation that moves its joints as a clip named after the animation. A scene mesh holds one skin; its skinned parts keep their bind-space vertices with an identity part transform, weights normalised to sum to one, andGeometry::skincarries the joints and weights.SceneMesh::rigis the rig. - Node animation. A mesh with animations and no skin gets a rig of its nodes (
Mesh::node_rig): each walked node is a joint with an identity inverse bind, so its palette entry is the node’s world matrix, and each part carries its node asPart::joint(andDraw::joint). A part then moves with its node:Draws::models_posedplaces it at the object’s matrix times its node’s pose.
Clips, poses and blending (pfx_core::anim)
Section titled “Clips, poses and blending (pfx_core::anim)”Skeletonholds joints (a name, a parent before it, a restTrs), inverse bind matrices and a root matrix.Rigis a skeleton with named clips.ClipholdsChannels (a joint, a property, an interpolation, key times and values) andClipEvents. Sampling follows glTF 2.0:- step holds a key until the next;
- linear lerps translation and scale and slerps rotation (the shorter way, nlerp within 0.9995);
- cubic spline uses the Hermite basis with the keys’ in and out tangents scaled by the key interval; rotations come back normalised;
- before the first key the first value holds, after the last the last value.
Poseis a localTrsper joint.globalschains them through the parents from the root;palettemultiplies each by its inverse bind, which is what skinning takes.Animatorplays clips on a rig by tick, at the game’s tick rate:play(clip, tick)starts a clip;crossfade(clip, seconds, tick)starts it as a new mix that fades in over that many ticks, rounded, while the mixes before it keep playing underneath (at mostMAX_MIXES, 4).blend(clip, weight, tick)adds a layer to the newest mix: each layer mixes over the layers before it by its weight, only on the joints and properties its clip animates.world.animate(id, "walk").blend("run", 0.3)is 70% walk and 30% run wherever both move a joint.play_blend(&[(clip, weight)], tick)shares the weights out by their sum, as[object.animation] blendsays:[(Walk, 0.7), (Run, 0.3)]and[(Walk, 7), (Run, 3)]pose the same.set_speed(0 or more; rebased so the clip time never jumps),set_start(seconds into the clip),set_playbackandset_weightchange a playing layer;stopreturns the rest pose.- Clip time is
offset + ticks × speed / rate. A loop wraps at the clip’s duration, once holds the last pose, ping-pong runs to the end and back.
- Events.
advance(tick)returns every event whose mark play passed since the last call, in tick order: by mix, by layer, then by time. A loop fires on every pass; ping-pong on every pass in either direction; once only once; a clip started atstartskips the marks before it. A stretch of skipped ticks still delivers each crossing, up toMAX_REPEATS(64) passes per mark. - Determinism. Only
+ - * /,floor,roundandpfx_core::sim’ssqrtf,sinf,cosfandatan2ftouch floats: no std transcendentals and no fused multiply-add, which a source guard test checks. Ticks are integers and seconds are formed in f64 from them. A scripted run (plays, blends, crossfades, a speed change, once) hashes the palette bits of 400 ticks to one golden value, the same in debug, release and under Wine.
Sprite clips
Section titled “Sprite clips”Atlas::grid(size, cell, count)cuts an image into cells, left to right then top to bottom;Atlas::rects(size, rects)takes pixel rectangles.uv(frame)is[u0, v0, u1, v1].SpriteClipis a list of frames with an fps, a playback andFrameEvents on its frames.SpriteSheetis an atlas with named clips.SpritePlayerplays one clip by tick: the step isfloor(offset × fps + ticks × speed × fps / rate), the frame the clip’s frame at that step (looping, held at the end, or to and fro).advance(tick)fires each frame’s events once per step that enters it.
CPU skinning
Section titled “CPU skinning”skin(positions, normals, tangents, joints, weights, palette) blends each vertex’s four palette matrices by its weights, in joint order from zero, as the GPU does, and transforms the position, normal and tangent (renormalised). It is the reference the GPU test holds the vertex stage to, and what the tracer’s snapshot uses.
Playing it (pfx-play)
Section titled “Playing it (pfx-play)”The world gives every object with a rigged mesh an Animator, and every sprite object a SpritePlayer, at the physics tick rate. What [object.animation] plays starts at tick 0, with its speed, loop and start; an animator with nothing to play holds the rest pose and a sprite its first frame.
fn tick(&mut self, world: &mut World, _tick: &Tick, input: &Input) { let hero = world.object("hero").unwrap(); if input.pressed("run") { world.animate(hero, "run").fade(0.2); } else if input.pressed("walk") { world.animate(hero, "walk").blend("run", 0.3); }}
fn animated(&mut self, world: &mut World, events: &[AnimationEvent]) { for event in events { if event.event == "step" { world.play_sound("step"); } }}| Call | What it does |
|---|---|
play_clip(id, clip) |
starts a clip now, a skeletal one with the object’s speed and loop as defaults, or a sprite clip |
animate(id, clip) |
the same, as an Animate builder: .blend(clip, weight), .fade(seconds) (crossfade from what played), .speed(s), .start(seconds), .once(), .looped(), .ping_pong(), .playback(p), .done() for the first error |
stop_animation(id) |
the rest pose, or no sprite clip |
animator(id), animator_mut(id), sprite(id), sprite_mut(id) |
the players themselves |
palette(id), sprite_frame(id) |
the joint palette and the sprite frame at the current tick |
animation_events() |
the latest tick’s AnimationEvent { object, clip, event }s, by object |
animation_errors() |
this tick’s refused calls (an unknown clip, a sprite asked to blend) |
posed() |
a Posed snapshot (object matrices with camera-facing ones turned, palettes, sprite frames) for the tracer |
An unknown clip is never silent: .done() and play_clip return the AnimError, and it is kept in animation_errors() until the next tick begins. A sprite takes speed and start but neither blends nor crossfades.
The tick runs game.tick, the physics step, game.events, then the animators and sprite players advance and Game::animated receives their events (only on ticks that have any), then the camera rigs. A clip a game starts in tick or events therefore plays from that tick, and its marks at 0 fire on it. The same scene, seed and inputs give the same poses and events, bit for bit, natively and under Wine (the_same_ticks_give_the_same_poses_and_events_on_every_platform, a golden hash over 240 frames of uneven length).
present poses the frame from the world: each skinned draw gets its object’s palette and the previous frame’s as an InstancePose, node-animated parts their node’s pose, and each sprite object’s surface its frame’s UV offset, scale and crop. Stop clears the poses and puts the staged surfaces back. Poses are taken at the latest tick, not interpolated between ticks.
GPU skinning (pfx-live)
Section titled “GPU skinning (pfx-live)”let mesh = renderer.upload_skinned_mesh(data, SkinData { joints: &joints, weights: &weights })?;renderer.set_poses(&[InstancePose { instance: 0, joints: &palette, previous: &last_palette }])?;- A skinned mesh’s vertex records (four 16-bit joints and four f32 weights, 32 bytes) live in one storage buffer with every posed instance’s palettes after them (
skin::SkinStore, group 0 binding 4). Records are allocated with a free list, as sway ranges are; palettes are rewritten each frame. The instance carries a skin word: the record base, the current and previous palette offsets, and the joint count. - The vertex stage blends the four joint matrices by their weights and skins the position, normal and tangent before the instance’s model, in the opaque, prepass (velocity and ids), sun and local-light shadow, cut shadow and transmissive passes. Picking and shadows follow the pose.
- The prepass takes the previous palette for the previous position, so a moving joint writes its own motion vector and TAA holds; a still joint writes none.
set_posesholds until the next call and counts as a change for frames on demand. An instance whose mesh is skinned and has no pose draws its bind pose. A pose with fewer joints than its mesh names is an error fromrender.- Skinned instances are moving batches: they never enter the static shadow cache.
Stageduploads skinned scene geometry withupload_skinned_meshand shows a sprite object’s still frame.- Not covered: a skinned mesh does not also take a deformer or tree sway, cannot be replaced in place (
replace_meshrefuses it; the staged path uploads a new mesh), and a skinned part with a glass material draws its bind pose in the glass pass. LOD sets, impostors and baked plates have not been tried with skinned meshes.
The binding floor holds: the shadow casters’ vertex stage now binds 6 storage buffers of the default 8 (The live renderer’s GPU floor), and tests/binding_floor.rs checks the layouts.
Sprites on 3D cards and on the flat pass
Section titled “Sprites on 3D cards and on the flat pass”- Cards. A sprite object’s atlas is loaded as the object’s content (
content_key(object),sprite:<object>), so it shows through its material’s content layer as any content does: give it a material with acontent_layer. Its surface’s UV offset, scale and crop pick one frame (pfx_load::scene::surface(frame)). - Flat. A game draws a frame with
Draw::sprite(centre, size, player.uv(world.ticks()).unwrap())(orFill::Imageon any shape) over aFlatSpritesatlas inFlatScene::sprites, and steps aSpritePlayerof its own by tick (The flat pass). A pfx-game game hands its atlas over once withConfig::sprites(image), and itsUidraws use it (The game runtime).
The traced pose (pfx-trace)
Section titled “The traced pose (pfx-trace)”stage::scene_posed(scene, aspect, &posed) stages a scene for Trace in a given pose: posed.models (or, when empty, the movers at posed.time), each object’s palette (skinned parts are CPU-skinned into their own static meshes; node-animated parts are placed by their node’s pose) and each sprite’s frame. scene_at(scene, aspect, time) is scene_posed with Posed::at(time). A game traces what it plays with scene_posed(world.scene(), aspect, &world.posed()).
pfx-core(anim::tests,anim::tests_sprite): linear, step, cubic-spline and slerp samples against hand-computed values; refusals; the rest palette; parent chains; blends, shared weights, crossfades, events in loops, once, ping-pong, skipped ticks and after a start offset; speed changes; CPU skinning; atlases, sprite steps, events and ping-pong; the golden hash; the source guard.pfx-load(rig_tests): a generated skinned glb round-trips to the engine fixture’s rig; joints listed children first are reordered and remapped; a scene mesh carries its rig and skin.pfx-play(tests::animation): scene objects play their clips and fire events at fixed ticks; a sprite card steps its frames and a game switches its clip; a game’s blends and crossfades match a reference animator; refused calls; the loader’s refusals with their lines; the golden run natively and under Wine; the posed snapshot.pfx-live, GPU:tests/skinning.rs(the skinned fixture against its CPU-skinned twin within 1/255 with its shadow and ids, its bind pose without a pose, and motion vectors on a moving joint and none on a still one);tests/posed_trace.rs(the live silhouette against the traced snapshot in the same pose);flat::sprite_tests(the GPU against the CPU twin and its picks, and a sprite clip stepping a flat draw by tick).
The fixtures are generated: pfx_core::anim::fixture (a chain of bones, a skinned square column and four clips, one per interpolation), pfx_load::fixture::skinned_column (that rig and column written as a glb with its skin and animations) and pfx_load::fixture::atlas_png (a grid of flat cells).
cargo test -p pfx-core --lib animcargo test -p pfx-load --lib rig_testscargo test -p pfx-play --lib animationcargo test -p pfx-live --test skinning --test posed_trace --lib --no-runpgpu run --class clip --as pfx -- cargo test -p pfx-live --test skinning --test posed_trace -- --ignored --test-threads=1pgpu run --class clip --as pfx -- cargo test -p pfx-live --lib flat::sprite_tests -- --ignored --test-threads=1Not covered
Section titled “Not covered”Morph targets, root motion, inverse kinematics, additive layers and joint masks, more than four influences per vertex, more than one skin per scene mesh, and interpolating poses between ticks.