Skip to main content

viewport_lib/runtime/plugins/skeleton_plugin/
skeleton.rs

1//! Skeleton, pose, and CPU linear blend skinning.
2//!
3//! These types form the substrate for skeletal animation. A [`Skeleton`] defines
4//! the bone hierarchy and bind-pose inverses. A [`Pose`] holds local-space
5//! transforms for each joint. [`JointMatrices::compute`] runs forward kinematics
6//! and returns the per-joint skinning matrices ready for [`apply_skin`].
7//!
8//! # Workflow
9//!
10//! ```rust,ignore
11//! // Once at startup:
12//! let skeleton = Skeleton::new(joints);
13//! let base_pose = Pose::identity(skeleton.joint_count());
14//!
15//! // Each frame (in a plugin at phase::ANIMATE or later):
16//! ctx.resources.insert(my_pose); // write current pose
17//!
18//! // SkeletonPlugin at phase::POST_SIM reads the pose and pushes a
19//! // SkinnedMeshUpdate to ctx.output.skinned_mesh_updates.
20//!
21//! // After runtime.step(), in the app:
22//! for u in &output.skinned_mesh_updates {
23//!     renderer.resources_mut()
24//!         .write_mesh_positions_normals(queue, u.mesh_id, &u.positions, &u.normals)
25//!         .ok();
26//! }
27//! ```
28
29use crate::resources::SkinWeights;
30
31/// Maximum number of joints in a skeleton.
32pub const MAX_JOINTS: usize = 128;
33
34/// A single joint in a skeleton hierarchy.
35#[derive(Clone)]
36pub struct Joint {
37    /// Display name for the joint.
38    pub name: String,
39    /// Index of the parent joint. `None` for root joints.
40    ///
41    /// Parent indices must be less than the joint's own index (topological
42    /// order), so forward kinematics can be computed in a single pass.
43    pub parent: Option<u8>,
44    /// Inverse of the joint's world-space transform in the bind pose.
45    ///
46    /// `inverse_bind = bind_world_transform.inverse()`. The skinning matrix
47    /// for joint `i` is `world_transform[i] * inverse_bind[i]`.
48    pub inverse_bind: glam::Affine3A,
49}
50
51/// A joint hierarchy with bind-pose inverse matrices.
52///
53/// Joints must be stored in topological order: each joint's parent index is
54/// less than its own. This is the standard glTF/FBX convention and allows
55/// forward kinematics in a single forward pass.
56#[derive(Clone)]
57pub struct Skeleton {
58    joints: Vec<Joint>,
59}
60
61impl Skeleton {
62    /// Create a skeleton from a list of joints in topological order.
63    ///
64    /// Panics in debug builds if any parent index is >= the joint's own index
65    /// or if `joints.len() > MAX_JOINTS`.
66    pub fn new(joints: Vec<Joint>) -> Self {
67        debug_assert!(joints.len() <= MAX_JOINTS, "skeleton exceeds MAX_JOINTS");
68        for (i, j) in joints.iter().enumerate() {
69            if let Some(p) = j.parent {
70                debug_assert!((p as usize) < i, "joint {i} has parent {p} >= own index");
71            }
72        }
73        Self { joints }
74    }
75
76    /// All joints in topological order.
77    pub fn joints(&self) -> &[Joint] {
78        &self.joints
79    }
80
81    /// Number of joints.
82    pub fn joint_count(&self) -> usize {
83        self.joints.len()
84    }
85
86    /// Find a joint by name. Returns the first match or `None`.
87    pub fn find_joint(&self, name: &str) -> Option<usize> {
88        self.joints.iter().position(|j| j.name == name)
89    }
90}
91
92/// Per-frame local-space transforms for each joint.
93///
94/// One `Affine3A` per joint, indexed in the same order as the parent
95/// [`Skeleton`]. Store this in [`super::resources::RuntimeResources`] so
96/// animation plugins can write it and [`super::plugins::SkeletonPlugin`] can
97/// read it in the same frame.
98#[derive(Clone)]
99pub struct Pose {
100    /// Local-space transform for each joint. Must have the same length as the
101    /// skeleton it is paired with.
102    pub local_transforms: Vec<glam::Affine3A>,
103}
104
105impl Pose {
106    /// Create a pose with all joints at identity.
107    pub fn identity(joint_count: usize) -> Self {
108        Self {
109            local_transforms: vec![glam::Affine3A::IDENTITY; joint_count],
110        }
111    }
112
113    /// Number of joint transforms in the pose.
114    pub fn joint_count(&self) -> usize {
115        self.local_transforms.len()
116    }
117}
118
119/// Per-joint skinning matrices computed from a [`Skeleton`] and [`Pose`].
120///
121/// Each matrix is `world_transform[i] * inverse_bind[i]`. Multiply a
122/// bind-pose vertex position by this matrix (with LBS blending) to get the
123/// deformed position.
124pub struct JointMatrices {
125    matrices: Vec<glam::Affine3A>,
126}
127
128impl JointMatrices {
129    /// Run forward kinematics and compute the skinning matrix palette.
130    ///
131    /// Joints are processed in topological order so each parent world
132    /// transform is available when the child is processed.
133    pub fn compute(skeleton: &Skeleton, pose: &Pose) -> Self {
134        let n = skeleton.joint_count();
135        let mut world = vec![glam::Affine3A::IDENTITY; n];
136
137        for (i, joint) in skeleton.joints().iter().enumerate() {
138            let local = pose
139                .local_transforms
140                .get(i)
141                .copied()
142                .unwrap_or(glam::Affine3A::IDENTITY);
143            world[i] = match joint.parent {
144                Some(p) => world[p as usize] * local,
145                None => local,
146            };
147        }
148
149        let matrices = world
150            .iter()
151            .zip(skeleton.joints().iter())
152            .map(|(w, j)| *w * j.inverse_bind)
153            .collect();
154
155        Self { matrices }
156    }
157
158    /// The skinning matrix palette as a slice.
159    pub fn as_slice(&self) -> &[glam::Affine3A] {
160        &self.matrices
161    }
162}
163
164/// Apply CPU linear blend skinning to a mesh.
165///
166/// Returns `(skinned_positions, skinned_normals)`. Each vertex is transformed
167/// by the weighted sum of up to four joint matrices. Zero-weight influences
168/// are skipped. Output normals are re-normalized.
169///
170/// `positions`, `normals`, and the per-vertex arrays in `weights` must all
171/// have the same length.
172pub fn apply_skin(
173    positions: &[[f32; 3]],
174    normals: &[[f32; 3]],
175    weights: &SkinWeights,
176    joint_matrices: &JointMatrices,
177) -> (Vec<[f32; 3]>, Vec<[f32; 3]>) {
178    let n = positions.len();
179    let mut out_pos = vec![[0.0f32; 3]; n];
180    let mut out_nrm = vec![[0.0f32; 3]; n];
181
182    for i in 0..n {
183        let p = glam::Vec3::from(positions[i]);
184        let nm = glam::Vec3::from(normals[i]);
185        let indices = weights.joint_indices[i];
186        let ws = weights.joint_weights[i];
187
188        let mut blended_p = glam::Vec3::ZERO;
189        let mut blended_n = glam::Vec3::ZERO;
190
191        for k in 0..4 {
192            let w = ws[k];
193            if w < 1e-6 {
194                continue;
195            }
196            let m = joint_matrices.matrices[indices[k] as usize];
197            blended_p += w * m.transform_point3(p);
198            blended_n += w * m.transform_vector3(nm);
199        }
200
201        out_pos[i] = blended_p.to_array();
202        out_nrm[i] = blended_n.normalize_or_zero().to_array();
203    }
204
205    (out_pos, out_nrm)
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::resources::SkinWeights;
212    use glam::{Affine3A, Vec3};
213
214    fn two_joint_skeleton(joint_z: f32) -> Skeleton {
215        Skeleton::new(vec![
216            Joint {
217                name: "root".into(),
218                parent: None,
219                inverse_bind: Affine3A::IDENTITY,
220            },
221            Joint {
222                name: "child".into(),
223                parent: Some(0),
224                inverse_bind: Affine3A::from_translation(-Vec3::new(0.0, 0.0, joint_z)),
225            },
226        ])
227    }
228
229    /// Returns the pose whose forward kinematics reproduces the bind pose for
230    /// `two_joint_skeleton(joint_z)`. Joint 0 stays at the origin; joint 1
231    /// sits at z=joint_z, which is the inverse of its `inverse_bind`.
232    fn bind_pose(joint_z: f32) -> Pose {
233        let mut p = Pose::identity(2);
234        p.local_transforms[1] = Affine3A::from_translation(Vec3::new(0.0, 0.0, joint_z));
235        p
236    }
237
238    fn approx_eq(a: [f32; 3], b: [f32; 3], eps: f32) -> bool {
239        (a[0] - b[0]).abs() < eps && (a[1] - b[1]).abs() < eps && (a[2] - b[2]).abs() < eps
240    }
241
242    #[test]
243    fn bind_pose_produces_identity_skinning_matrices() {
244        let joint_z = 2.0;
245        let sk = two_joint_skeleton(joint_z);
246        let jm = JointMatrices::compute(&sk, &bind_pose(joint_z));
247        for m in jm.as_slice() {
248            let p = m.transform_point3(Vec3::new(1.0, 2.0, 3.0));
249            assert!(
250                approx_eq(p.to_array(), [1.0, 2.0, 3.0], 1e-5),
251                "got {:?}",
252                p
253            );
254        }
255    }
256
257    #[test]
258    fn apply_skin_at_bind_pose_returns_input() {
259        let joint_z = 2.0;
260        let sk = two_joint_skeleton(joint_z);
261        let jm = JointMatrices::compute(&sk, &bind_pose(joint_z));
262        let positions = vec![[0.0, 0.0, 0.0], [0.5, 0.0, 1.0], [0.0, 0.0, 4.0]];
263        let normals = vec![[1.0, 0.0, 0.0]; 3];
264        let weights = SkinWeights {
265            joint_indices: vec![[0, 1, 0, 0]; 3],
266            joint_weights: vec![
267                [1.0, 0.0, 0.0, 0.0],
268                [0.5, 0.5, 0.0, 0.0],
269                [0.0, 1.0, 0.0, 0.0],
270            ],
271        };
272        let (out_p, out_n) = apply_skin(&positions, &normals, &weights, &jm);
273        for i in 0..3 {
274            assert!(
275                approx_eq(out_p[i], positions[i], 1e-5),
276                "pos {i}: {:?}",
277                out_p[i]
278            );
279            assert!(
280                approx_eq(out_n[i], normals[i], 1e-5),
281                "nrm {i}: {:?}",
282                out_n[i]
283            );
284        }
285    }
286
287    #[test]
288    fn child_rotation_bends_around_joint() {
289        // Rotating joint 1 by 90 deg around X with the bind transform applied
290        // should swing a child-weighted vertex at (0,0,3) down to (0,-1,2).
291        let joint_z = 2.0;
292        let sk = two_joint_skeleton(joint_z);
293        let mut pose = bind_pose(joint_z);
294        pose.local_transforms[1] = Affine3A::from_translation(Vec3::new(0.0, 0.0, joint_z))
295            * Affine3A::from_rotation_x(std::f32::consts::FRAC_PI_2);
296        let jm = JointMatrices::compute(&sk, &pose);
297
298        let positions = vec![[0.0, 0.0, joint_z + 1.0]];
299        let normals = vec![[0.0, 0.0, 1.0]];
300        let weights = SkinWeights {
301            joint_indices: vec![[0, 1, 0, 0]],
302            joint_weights: vec![[0.0, 1.0, 0.0, 0.0]],
303        };
304        let (out_p, out_n) = apply_skin(&positions, &normals, &weights, &jm);
305        assert!(
306            approx_eq(out_p[0], [0.0, -1.0, joint_z], 1e-4),
307            "got {:?}",
308            out_p[0]
309        );
310        assert!(
311            approx_eq(out_n[0], [0.0, -1.0, 0.0], 1e-4),
312            "got {:?}",
313            out_n[0]
314        );
315    }
316
317    #[test]
318    fn zero_weight_slots_are_skipped() {
319        let joint_z = 2.0;
320        let sk = two_joint_skeleton(joint_z);
321        let mut pose = bind_pose(joint_z);
322        // Add a huge translation to joint 1, but weight 0 for our vertex.
323        pose.local_transforms[1] =
324            pose.local_transforms[1] * Affine3A::from_translation(Vec3::new(100.0, 0.0, 0.0));
325        let jm = JointMatrices::compute(&sk, &pose);
326
327        let positions = vec![[0.0, 0.0, 0.0]];
328        let normals = vec![[1.0, 0.0, 0.0]];
329        let weights = SkinWeights {
330            joint_indices: vec![[0, 1, 1, 1]],
331            joint_weights: vec![[1.0, 0.0, 0.0, 0.0]],
332        };
333        let (out_p, _) = apply_skin(&positions, &normals, &weights, &jm);
334        assert!(approx_eq(out_p[0], [0.0, 0.0, 0.0], 1e-5));
335    }
336}