Skip to main content

pebble/wgpu/
skeleton.rs

1//! Plain CPU data for a joint hierarchy — no [`Asset`](crate::assets::upload::Asset)/
2//! `Handle`/GPU upload involved. A skeleton is pure computation (walking a
3//! joint hierarchy, multiplying matrices) right up until you write the
4//! result into a buffer of your own — see [`Skeleton::skinning_matrices`].
5
6/// A local (parent-relative) rigid pose — translation, rotation, scale.
7///
8/// Kept as separate T/R/S rather than a single `glam::Mat4`: interpolating
9/// a matrix directly (e.g. lerping its columns) is mathematically wrong —
10/// rotation has to slerp/nlerp, not lerp component-wise — so
11/// [`AnimationClip::sample`](super::animation::AnimationClip::sample) needs
12/// T/R/S kept apart to interpolate each correctly, and only composes them
13/// into a matrix at the very end via [`to_matrix`](Self::to_matrix).
14#[derive(Copy, Clone, Debug, PartialEq)]
15pub struct Transform {
16    pub translation: glam::Vec3,
17    pub rotation: glam::Quat,
18    pub scale: glam::Vec3,
19}
20
21impl Transform {
22    pub const IDENTITY: Self = Self {
23        translation: glam::Vec3::ZERO,
24        rotation: glam::Quat::IDENTITY,
25        scale: glam::Vec3::ONE,
26    };
27
28    pub fn to_matrix(&self) -> glam::Mat4 {
29        glam::Mat4::from_scale_rotation_translation(self.scale, self.rotation, self.translation)
30    }
31
32    /// Blends toward `other` by `t` (`0.0` = `self`, `1.0` = `other`) —
33    /// translation/scale lerp, rotation slerp. The building block for
34    /// crossfading between two animations: sample both clips into a
35    /// `Vec<Transform>` each, then `poses_a.iter().zip(&poses_b).map(|(a,
36    /// b)| a.lerp(b, t)).collect()`. Blending more than two poses, per-bone
37    /// blend masks, and additive blending are all just repeated/weighted
38    /// applications of this same building block — left to you, since the
39    /// right blending strategy depends entirely on what you're building.
40    pub fn lerp(&self, other: &Transform, t: f32) -> Transform {
41        Transform {
42            translation: self.translation.lerp(other.translation, t),
43            rotation: self.rotation.slerp(other.rotation, t),
44            scale: self.scale.lerp(other.scale, t),
45        }
46    }
47}
48
49impl Default for Transform {
50    fn default() -> Self {
51        Self::IDENTITY
52    }
53}
54
55/// One joint's static rig data — everything that never changes once the
56/// skeleton is built, as opposed to [`Transform`], which is per-pose (a new
57/// one every frame, from [`AnimationClip::sample`](super::animation::AnimationClip::sample)).
58#[derive(Clone, Debug)]
59pub struct Joint {
60    /// For lookup via [`Skeleton::joint_index_by_name`] and diagnostics —
61    /// has no effect on the math.
62    pub name: String,
63    /// Index into the same [`Skeleton`]'s joint list. `None` for a root
64    /// joint (no parent within this skeleton).
65    pub parent: Option<usize>,
66    /// Transforms a vertex from mesh-bind space into this joint's local
67    /// space — the fixed per-joint matrix `Skeleton::skinning_matrices`
68    /// multiplies each joint's current world matrix by.
69    pub inverse_bind_matrix: glam::Mat4,
70    /// This joint's own local transform in the bind pose — the fallback
71    /// [`AnimationClip::sample`](super::animation::AnimationClip::sample)
72    /// uses for any joint (or T/R/S component) a clip doesn't animate.
73    pub local_bind_transform: Transform,
74}
75
76/// A joint hierarchy — parent/child relationships plus each joint's fixed
77/// bind-pose data. Immutable once built; combine it with a per-frame
78/// `Vec<Transform>` (one local pose per joint, e.g. from
79/// [`AnimationClip::sample`](super::animation::AnimationClip::sample)) via
80/// [`world_matrices`](Self::world_matrices)/[`skinning_matrices`](Self::skinning_matrices)
81/// to get the matrices your own shader/buffer actually needs.
82pub struct Skeleton {
83    joints: Vec<Joint>,
84    /// Precomputed once in [`new`](Self::new): indices into `joints`, with
85    /// every joint appearing after its parent. glTF's own node array isn't
86    /// guaranteed to already be in this order, so `world_matrices` doesn't
87    /// assume `joints` itself is — it walks `topo_order` instead, which is.
88    topo_order: Vec<usize>,
89}
90
91impl Skeleton {
92    /// Panics if any `parent` index is out of range, or the joint graph
93    /// contains a cycle — both are a malformed skeleton (a genuine bug in
94    /// whatever built `joints`), not a "not ready yet" condition worth
95    /// tolerating. Does not require `joints` to already be in
96    /// parent-before-child order.
97    pub fn new(joints: Vec<Joint>) -> Self {
98        let len = joints.len();
99        for (i, joint) in joints.iter().enumerate() {
100            if let Some(parent) = joint.parent {
101                assert!(
102                    parent < len,
103                    "Skeleton::new: joint {i} ('{}') has parent index {parent}, out of range for {len} joints",
104                    joint.name,
105                );
106            }
107        }
108
109        let mut children: Vec<Vec<usize>> = vec![Vec::new(); len];
110        let mut roots: Vec<usize> = Vec::new();
111        for (i, joint) in joints.iter().enumerate() {
112            match joint.parent {
113                Some(parent) => children[parent].push(i),
114                None => roots.push(i),
115            }
116        }
117
118        // BFS from every root — a node's parent is always visited (and thus
119        // pushed) before its children are, so this order already satisfies
120        // "parent before child" with no extra sorting step.
121        let mut topo_order = Vec::with_capacity(len);
122        let mut queue = roots;
123        while let Some(i) = queue.pop() {
124            topo_order.push(i);
125            queue.extend(children[i].iter().copied());
126        }
127
128        assert!(
129            topo_order.len() == len,
130            "Skeleton::new: joint graph has a cycle — {} of {len} joints are unreachable from any \
131             root (a joint with no parent within this skeleton)",
132            len - topo_order.len(),
133        );
134
135        Self { joints, topo_order }
136    }
137
138    pub fn joint_count(&self) -> usize {
139        self.joints.len()
140    }
141
142    pub fn joint(&self, index: usize) -> &Joint {
143        &self.joints[index]
144    }
145
146    pub fn joint_index_by_name(&self, name: &str) -> Option<usize> {
147        self.joints.iter().position(|j| j.name == name)
148    }
149
150    /// Each joint's local bind transform — the rest pose, one entry per joint.
151    /// Use as the starting point for IK: sample this, modify specific joints,
152    /// then feed into [`skinning_matrices`](Self::skinning_matrices).
153    pub fn bind_pose(&self) -> Vec<Transform> {
154        self.joints.iter().map(|j| j.local_bind_transform).collect()
155    }
156
157    /// Computes each joint's world-space matrix from a set of local
158    /// (parent-relative) poses — one linear pass over the precomputed
159    /// topological order, no recursion needed. Panics if
160    /// `local_poses.len() != self.joint_count()`.
161    pub fn world_matrices(&self, local_poses: &[Transform]) -> Vec<glam::Mat4> {
162        assert!(
163            local_poses.len() == self.joints.len(),
164            "Skeleton::world_matrices: {} local poses given for {} joints",
165            local_poses.len(),
166            self.joints.len(),
167        );
168
169        let mut world = vec![glam::Mat4::IDENTITY; self.joints.len()];
170        for &i in &self.topo_order {
171            let local = local_poses[i].to_matrix();
172            world[i] = match self.joints[i].parent {
173                Some(parent) => world[parent] * local,
174                None => local,
175            };
176        }
177        world
178    }
179
180    /// The matrix palette your shader/buffer actually needs: each joint's
181    /// world matrix (from [`world_matrices`](Self::world_matrices)) times
182    /// its own [`inverse_bind_matrix`](Joint::inverse_bind_matrix), so the
183    /// result transforms a vertex straight from mesh-bind space into the
184    /// current pose.
185    pub fn skinning_matrices(&self, local_poses: &[Transform]) -> Vec<glam::Mat4> {
186        let world = self.world_matrices(local_poses);
187        world
188            .iter()
189            .zip(&self.joints)
190            .map(|(w, joint)| *w * joint.inverse_bind_matrix)
191            .collect()
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    fn joint(name: &str, parent: Option<usize>) -> Joint {
200        Joint {
201            name: name.to_string(),
202            parent,
203            inverse_bind_matrix: glam::Mat4::IDENTITY,
204            local_bind_transform: Transform::IDENTITY,
205        }
206    }
207
208    #[test]
209    fn transform_lerp_blends_translation_scale_and_rotation() {
210        let a = Transform {
211            translation: glam::Vec3::new(0.0, 0.0, 0.0),
212            rotation: glam::Quat::IDENTITY,
213            scale: glam::Vec3::new(1.0, 1.0, 1.0),
214        };
215        let b = Transform {
216            translation: glam::Vec3::new(10.0, 0.0, 0.0),
217            rotation: glam::Quat::from_rotation_y(std::f32::consts::PI),
218            scale: glam::Vec3::new(3.0, 3.0, 3.0),
219        };
220
221        let mid = a.lerp(&b, 0.5);
222        assert_eq!(mid.translation, glam::Vec3::new(5.0, 0.0, 0.0));
223        assert_eq!(mid.scale, glam::Vec3::new(2.0, 2.0, 2.0));
224        // Halfway through a 180-degree turn is a 90-degree turn.
225        let angle = mid.rotation.to_axis_angle().1;
226        assert!((angle - std::f32::consts::FRAC_PI_2).abs() < 1e-5, "expected a ~90 degree rotation, got {angle}");
227    }
228
229    #[test]
230    fn transform_lerp_at_the_endpoints_returns_each_transform_unchanged() {
231        let a = Transform { translation: glam::Vec3::new(1.0, 2.0, 3.0), ..Transform::IDENTITY };
232        let b = Transform { translation: glam::Vec3::new(4.0, 5.0, 6.0), ..Transform::IDENTITY };
233        assert_eq!(a.lerp(&b, 0.0), a);
234        assert_eq!(a.lerp(&b, 1.0), b);
235    }
236
237    #[test]
238    fn out_of_range_parent_panics() {
239        let result = std::panic::catch_unwind(|| Skeleton::new(vec![joint("root", Some(1))]));
240        assert!(result.is_err(), "expected a panic for an out-of-range parent index");
241    }
242
243    #[test]
244    fn a_two_joint_cycle_panics() {
245        let result = std::panic::catch_unwind(|| {
246            Skeleton::new(vec![joint("a", Some(1)), joint("b", Some(0))])
247        });
248        assert!(result.is_err(), "expected a panic for a joint cycle");
249    }
250
251    #[test]
252    fn world_matrices_is_correct_even_when_input_order_is_not_topological() {
253        // Deliberately listing the child (index 0) before its parent (index
254        // 1) before the grandparent (index 2) — exactly the "glTF's node
255        // array isn't guaranteed sorted" scenario Skeleton::new must handle.
256        let joints = vec![
257            joint("child", Some(1)),
258            joint("mid", Some(2)),
259            joint("root", None),
260        ];
261        let skeleton = Skeleton::new(joints);
262
263        let poses = vec![
264            Transform { translation: glam::Vec3::new(1.0, 0.0, 0.0), ..Transform::IDENTITY },
265            Transform { translation: glam::Vec3::new(0.0, 1.0, 0.0), ..Transform::IDENTITY },
266            Transform { translation: glam::Vec3::new(0.0, 0.0, 1.0), ..Transform::IDENTITY },
267        ];
268        let world = skeleton.world_matrices(&poses);
269
270        // root: (0,0,1). mid: root * (0,1,0) = (0,1,1). child: mid * (1,0,0) = (1,1,1).
271        assert_eq!(world[2].transform_point3(glam::Vec3::ZERO), glam::Vec3::new(0.0, 0.0, 1.0));
272        assert_eq!(world[1].transform_point3(glam::Vec3::ZERO), glam::Vec3::new(0.0, 1.0, 1.0));
273        assert_eq!(world[0].transform_point3(glam::Vec3::ZERO), glam::Vec3::new(1.0, 1.0, 1.0));
274    }
275
276    #[test]
277    fn skinning_matrices_applies_inverse_bind_matrix() {
278        let mut root = joint("root", None);
279        root.inverse_bind_matrix = glam::Mat4::from_translation(glam::Vec3::new(-2.0, 0.0, 0.0));
280        let skeleton = Skeleton::new(vec![root]);
281
282        let poses = vec![Transform {
283            translation: glam::Vec3::new(5.0, 0.0, 0.0),
284            ..Transform::IDENTITY
285        }];
286        let skinning = skeleton.skinning_matrices(&poses);
287
288        // world = translate(5,0,0); skinning = world * inverse_bind = translate(3,0,0).
289        assert_eq!(skinning[0].transform_point3(glam::Vec3::ZERO), glam::Vec3::new(3.0, 0.0, 0.0));
290    }
291
292    #[test]
293    fn world_matrices_panics_on_mismatched_pose_count() {
294        let skeleton = Skeleton::new(vec![joint("root", None)]);
295        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
296            skeleton.world_matrices(&[])
297        }));
298        assert!(result.is_err(), "expected a panic for a local_poses length mismatch");
299    }
300
301    #[test]
302    fn joint_index_by_name_finds_and_misses_correctly() {
303        let skeleton = Skeleton::new(vec![joint("root", None), joint("child", Some(0))]);
304        assert_eq!(skeleton.joint_index_by_name("child"), Some(1));
305        assert_eq!(skeleton.joint_index_by_name("missing"), None);
306    }
307}