Skeletons and Animation
Data types: Skeleton, AnimationClip, Transform
Skeleton and AnimationClip are plain CPU data — no Asset/Handle/GPU upload involved, unlike everything else in the rendering chapters. A skeleton is a joint hierarchy; sampling a clip and turning the result into a matrix palette is ordinary interpolation and matrix math.
Transform: why not just glam::Mat4?
A joint’s pose is Transform — separate translation/rotation/scale, not a single matrix:
pub struct Transform {
pub translation: glam::Vec3,
pub rotation: glam::Quat,
pub scale: glam::Vec3,
}
Interpolating a Mat4 directly (lerping its columns) is mathematically wrong — rotation has to slerp, not lerp component-wise. Keeping T/R/S apart lets AnimationClip::sample interpolate each correctly, composing them into a matrix only at the very end. Transform::lerp(&self, other, t) blends two poses the same way (translation/scale lerp, rotation slerp).
Skeleton
A joint hierarchy: each Joint has a name, an optional parent (an index into the same skeleton’s joint list — None for a root), an inverse bind matrix, and its own bind-pose Transform:
use pebble::wgpu::skeleton::{Joint, Skeleton, Transform};
let skeleton = Skeleton::new(vec![
Joint { name: "root".into(), parent: None, inverse_bind_matrix: glam::Mat4::IDENTITY, local_bind_transform: Transform::IDENTITY },
Joint { name: "arm".into(), parent: Some(0), inverse_bind_matrix: /* ... */, local_bind_transform: /* ... */ },
]);
Given a Vec<Transform> (one local pose per joint):
skeleton.world_matrices(&poses)— each joint’s world-space matrix.skeleton.skinning_matrices(&poses)— world matrix times inverse bind matrix, the actual palette a shader needs.
AnimationClip
A name, a duration, and per-joint keyframe tracks. sample clamps time to [0, duration] — looping is handled by AnimationPlayer (see below). Only Linear and Step interpolation are supported — CubicSpline returns an error.
AnimationPlayer: built-in playback
AnimationPlayer is an ECS component that manages animation playback for a skinned entity. Attach it alongside a Handle<SkinnedMesh> and a Handle<Material>:
commands.spawn((mesh_handle, material_handle, loaded.player));
Controlling playback
player.play("walk"); // hard cut to clip, loop
player.play_once("attack"); // play once, hold last frame
player.crossfade("run", 0.3); // blend from current → "run" over 0.3 s
player.set_speed(2.0); // double speed
player.pause(); player.resume();
player.set_time(1.5); // jump to specific time
player.clip_names() returns an iterator over all loaded clip names — useful for playing the first available clip without knowing its name in advance:
if let Some(name) = loaded.player.clip_names().next().map(|s| s.to_string()) {
loaded.player.play(&name);
}
Advancing time
SkinnedBatchingPlugin reads matrices but does not advance time — add a system that calls advance each tick:
fn advance_animation(time: Res<Time>, mut query: Query<&mut AnimationPlayer>) {
for player in query.iter() {
player.advance(time.delta_seconds());
}
}
IK and procedural animation via Pose
compute_pose() samples the current clip into a Pose you can modify before it becomes matrices — useful for IK or procedural overrides:
let mut pose = player.compute_pose();
// two-bone IK: you supply the math, Pose handles local/world conversion
pose.set_world_rotation(upper_leg, rotation_upper);
pose.set_world_rotation(lower_leg, rotation_lower);
player.set_matrices(pose.skinning_matrices()); // persists until clear_matrices()
player.clear_matrices() removes the override and returns to sampling the current clip.
SkinnedBatchingPlugin: GPU-batched rendering
SkinnedBatchingPlugin collects all entities with (Handle<Material>, Handle<SkinnedMesh>, AnimationPlayer) components each PreRender tick, writes their joint matrices into a per-(material, mesh) GPU storage buffer, and stores the draw batch metadata in SkinnedBatchStorage:
App::new()
// ...
.add_plugin(SkinnedBatchingPlugin)
// ...
The WGSL skinning bind group ("pebble_skinning") is registered automatically in GlobalLayoutPool. In a material that uses it, declare GroupEntry::Global("pebble_skinning") at whichever group index the shader expects:
MaterialBuilder::new(SHADER)
.with_vertex_layouts(vec![SkinnedVertex::layout()])
.with_entries(vec![GroupEntry::Global("pebble_skinning")])
// ...
.build_asset("skinned", &mut materials);
The WGSL layout the plugin fills:
struct SkinningInfo { joint_count: u32 }
@group(N) @binding(0) var<storage, read> joint_matrices: array<mat4x4<f32>>;
@group(N) @binding(1) var<uniform> skin_info: SkinningInfo;
// in vs_main — use instance_index to offset into the flat matrix array:
let base = instance_index * skin_info.joint_count;
let skin =
joint_matrices[base + joint_indices.x] * joint_weights.x +
joint_matrices[base + joint_indices.y] * joint_weights.y +
joint_matrices[base + joint_indices.z] * joint_weights.z +
joint_matrices[base + joint_indices.w] * joint_weights.w;
instance_index * joint_count gives the per-entity base offset into the flat buffer — instanced draw_indexed(0..index_count, 0, 0..instance_count) calls let one draw cover all entities sharing the same (material, mesh) pair.
The render loop
fn render(
mut frame: ResMut<CurrentFrame<WGPUBackend>>,
materials: Res<Assets<Material>>,
meshes: Res<Assets<SkinnedMesh>>,
renderer: Option<Res<SkinnedBatchRenderer>>,
storage: Option<Res<SkinnedBatchStorage>>,
) {
let Some(renderer) = renderer else { return };
let Some(storage) = storage else { return };
let Some(mut active) = frame.active() else { return };
let mut pass = active.render_context([0.05, 0.05, 0.08, 1.0]);
for batch in storage.batches.iter() {
let Some(mesh) = meshes.get(Handle::<SkinnedMesh>::new(batch.mesh)) else { continue };
let Some(material) = materials.get(Handle::<Material>::new(batch.material)) else { continue };
let Some(bind_group) = renderer.bind_group(batch.material, batch.mesh) else { continue };
pass.set_pipeline(&material.pipeline);
pass.set_bind_group(0, bind_group, &[]);
pass.set_vertex_buffer(0, &mesh.vertex_buffer);
pass.set_index_buffer(&mesh.index_buffer, IndexFormat::Uint32);
pass.draw_indexed(0..mesh.index_count, 0, 0..batch.instance_count);
}
}
SkinnedBatchRenderer and SkinnedBatchStorage are Option<Res<...>> above because they’re inserted by SkinnedBatchingPlugin’s startup system — they don’t exist on the very first frame before that runs.
Loading from glTF with SkinnedModelBuilder
The recommended way to load a glTF file with a skinned mesh and animation clips into a ready-to-use AnimationPlayer:
fn setup(
mut commands: Commands,
mut skinned_meshes: ResMut<Assets<SkinnedMesh>>,
mut materials: ResMut<Assets<Material>>,
backend: Res<WGPUBackend>,
) -> Option<()> {
let mut loaded = SkinnedMeshBuilder::from_file("assets/character.gltf")
.with_animation("run", "assets/character_run.gltf") // extra clips, optional
.build(&mut skinned_meshes)
.ok()?;
let mesh_handle = loaded.mesh()?;
// Play first available clip (name comes from the glTF file)
if let Some(name) = loaded.player.clip_names().next().map(|s| s.to_string()) {
loaded.player.play(&name);
}
let material = MaterialBuilder::new(SHADER)
.with_vertex_layouts(vec![SkinnedVertex::layout()])
.with_entries(vec![GroupEntry::Global("pebble_skinning")])
// ...
.build_asset("skinned", &mut materials);
commands.spawn((mesh_handle, material, loaded.player));
Some(())
}
SkinnedModelBuilder::build is synchronous — it calls load_gltf internally and returns Result<LoadedSkinnedMesh, ModelLoadError>. Using .ok()? inside a -> Option<()> once-system means a load failure silently retries, which is usually wrong for a path error; handle the Result explicitly if you need a clear error:
let loaded = SkinnedMeshBuilder::from_file(path)
.build(&mut skinned_meshes)
.expect("failed to load character");
Blending between animations
Transform::lerp is the one primitive provided for manual blending — everything past it (custom blend trees, additive blending, per-bone masks) is left to you via compute_pose() + set_matrices():
let mut pose_a = player_a.compute_pose();
let mut pose_b = player_b.compute_pose();
// blend per-joint and override — player.set_matrices() sends the result to the GPU
For straightforward crossfades between two clips on the same entity, prefer player.crossfade(name, duration) — it handles the interpolation internally.