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    /// Computes each joint's world-space matrix from a set of local
151    /// (parent-relative) poses — one linear pass over the precomputed
152    /// topological order, no recursion needed. Panics if
153    /// `local_poses.len() != self.joint_count()`.
154    pub fn world_matrices(&self, local_poses: &[Transform]) -> Vec<glam::Mat4> {
155        assert!(
156            local_poses.len() == self.joints.len(),
157            "Skeleton::world_matrices: {} local poses given for {} joints",
158            local_poses.len(),
159            self.joints.len(),
160        );
161
162        let mut world = vec![glam::Mat4::IDENTITY; self.joints.len()];
163        for &i in &self.topo_order {
164            let local = local_poses[i].to_matrix();
165            world[i] = match self.joints[i].parent {
166                Some(parent) => world[parent] * local,
167                None => local,
168            };
169        }
170        world
171    }
172
173    /// The matrix palette your shader/buffer actually needs: each joint's
174    /// world matrix (from [`world_matrices`](Self::world_matrices)) times
175    /// its own [`inverse_bind_matrix`](Joint::inverse_bind_matrix), so the
176    /// result transforms a vertex straight from mesh-bind space into the
177    /// current pose.
178    pub fn skinning_matrices(&self, local_poses: &[Transform]) -> Vec<glam::Mat4> {
179        let world = self.world_matrices(local_poses);
180        world
181            .iter()
182            .zip(&self.joints)
183            .map(|(w, joint)| *w * joint.inverse_bind_matrix)
184            .collect()
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    fn joint(name: &str, parent: Option<usize>) -> Joint {
193        Joint {
194            name: name.to_string(),
195            parent,
196            inverse_bind_matrix: glam::Mat4::IDENTITY,
197            local_bind_transform: Transform::IDENTITY,
198        }
199    }
200
201    #[test]
202    fn transform_lerp_blends_translation_scale_and_rotation() {
203        let a = Transform {
204            translation: glam::Vec3::new(0.0, 0.0, 0.0),
205            rotation: glam::Quat::IDENTITY,
206            scale: glam::Vec3::new(1.0, 1.0, 1.0),
207        };
208        let b = Transform {
209            translation: glam::Vec3::new(10.0, 0.0, 0.0),
210            rotation: glam::Quat::from_rotation_y(std::f32::consts::PI),
211            scale: glam::Vec3::new(3.0, 3.0, 3.0),
212        };
213
214        let mid = a.lerp(&b, 0.5);
215        assert_eq!(mid.translation, glam::Vec3::new(5.0, 0.0, 0.0));
216        assert_eq!(mid.scale, glam::Vec3::new(2.0, 2.0, 2.0));
217        // Halfway through a 180-degree turn is a 90-degree turn.
218        let angle = mid.rotation.to_axis_angle().1;
219        assert!((angle - std::f32::consts::FRAC_PI_2).abs() < 1e-5, "expected a ~90 degree rotation, got {angle}");
220    }
221
222    #[test]
223    fn transform_lerp_at_the_endpoints_returns_each_transform_unchanged() {
224        let a = Transform { translation: glam::Vec3::new(1.0, 2.0, 3.0), ..Transform::IDENTITY };
225        let b = Transform { translation: glam::Vec3::new(4.0, 5.0, 6.0), ..Transform::IDENTITY };
226        assert_eq!(a.lerp(&b, 0.0), a);
227        assert_eq!(a.lerp(&b, 1.0), b);
228    }
229
230    #[test]
231    fn out_of_range_parent_panics() {
232        let result = std::panic::catch_unwind(|| Skeleton::new(vec![joint("root", Some(1))]));
233        assert!(result.is_err(), "expected a panic for an out-of-range parent index");
234    }
235
236    #[test]
237    fn a_two_joint_cycle_panics() {
238        let result = std::panic::catch_unwind(|| {
239            Skeleton::new(vec![joint("a", Some(1)), joint("b", Some(0))])
240        });
241        assert!(result.is_err(), "expected a panic for a joint cycle");
242    }
243
244    #[test]
245    fn world_matrices_is_correct_even_when_input_order_is_not_topological() {
246        // Deliberately listing the child (index 0) before its parent (index
247        // 1) before the grandparent (index 2) — exactly the "glTF's node
248        // array isn't guaranteed sorted" scenario Skeleton::new must handle.
249        let joints = vec![
250            joint("child", Some(1)),
251            joint("mid", Some(2)),
252            joint("root", None),
253        ];
254        let skeleton = Skeleton::new(joints);
255
256        let poses = vec![
257            Transform { translation: glam::Vec3::new(1.0, 0.0, 0.0), ..Transform::IDENTITY },
258            Transform { translation: glam::Vec3::new(0.0, 1.0, 0.0), ..Transform::IDENTITY },
259            Transform { translation: glam::Vec3::new(0.0, 0.0, 1.0), ..Transform::IDENTITY },
260        ];
261        let world = skeleton.world_matrices(&poses);
262
263        // root: (0,0,1). mid: root * (0,1,0) = (0,1,1). child: mid * (1,0,0) = (1,1,1).
264        assert_eq!(world[2].transform_point3(glam::Vec3::ZERO), glam::Vec3::new(0.0, 0.0, 1.0));
265        assert_eq!(world[1].transform_point3(glam::Vec3::ZERO), glam::Vec3::new(0.0, 1.0, 1.0));
266        assert_eq!(world[0].transform_point3(glam::Vec3::ZERO), glam::Vec3::new(1.0, 1.0, 1.0));
267    }
268
269    #[test]
270    fn skinning_matrices_applies_inverse_bind_matrix() {
271        let mut root = joint("root", None);
272        root.inverse_bind_matrix = glam::Mat4::from_translation(glam::Vec3::new(-2.0, 0.0, 0.0));
273        let skeleton = Skeleton::new(vec![root]);
274
275        let poses = vec![Transform {
276            translation: glam::Vec3::new(5.0, 0.0, 0.0),
277            ..Transform::IDENTITY
278        }];
279        let skinning = skeleton.skinning_matrices(&poses);
280
281        // world = translate(5,0,0); skinning = world * inverse_bind = translate(3,0,0).
282        assert_eq!(skinning[0].transform_point3(glam::Vec3::ZERO), glam::Vec3::new(3.0, 0.0, 0.0));
283    }
284
285    #[test]
286    fn world_matrices_panics_on_mismatched_pose_count() {
287        let skeleton = Skeleton::new(vec![joint("root", None)]);
288        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
289            skeleton.world_matrices(&[])
290        }));
291        assert!(result.is_err(), "expected a panic for a local_poses length mismatch");
292    }
293
294    #[test]
295    fn joint_index_by_name_finds_and_misses_correctly() {
296        let skeleton = Skeleton::new(vec![joint("root", None), joint("child", Some(0))]);
297        assert_eq!(skeleton.joint_index_by_name("child"), Some(1));
298        assert_eq!(skeleton.joint_index_by_name("missing"), None);
299    }
300}