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.local_transforms.get(i).copied().unwrap_or(glam::Affine3A::IDENTITY);
139            world[i] = match joint.parent {
140                Some(p) => world[p as usize] * local,
141                None => local,
142            };
143        }
144
145        let matrices = world.iter().zip(skeleton.joints().iter())
146            .map(|(w, j)| *w * j.inverse_bind)
147            .collect();
148
149        Self { matrices }
150    }
151
152    /// The skinning matrix palette as a slice.
153    pub fn as_slice(&self) -> &[glam::Affine3A] {
154        &self.matrices
155    }
156}
157
158/// Apply CPU linear blend skinning to a mesh.
159///
160/// Returns `(skinned_positions, skinned_normals)`. Each vertex is transformed
161/// by the weighted sum of up to four joint matrices. Zero-weight influences
162/// are skipped. Output normals are re-normalized.
163///
164/// `positions`, `normals`, and the per-vertex arrays in `weights` must all
165/// have the same length.
166pub fn apply_skin(
167    positions: &[[f32; 3]],
168    normals: &[[f32; 3]],
169    weights: &SkinWeights,
170    joint_matrices: &JointMatrices,
171) -> (Vec<[f32; 3]>, Vec<[f32; 3]>) {
172    let n = positions.len();
173    let mut out_pos = vec![[0.0f32; 3]; n];
174    let mut out_nrm = vec![[0.0f32; 3]; n];
175
176    for i in 0..n {
177        let p = glam::Vec3::from(positions[i]);
178        let nm = glam::Vec3::from(normals[i]);
179        let indices = weights.joint_indices[i];
180        let ws = weights.joint_weights[i];
181
182        let mut blended_p = glam::Vec3::ZERO;
183        let mut blended_n = glam::Vec3::ZERO;
184
185        for k in 0..4 {
186            let w = ws[k];
187            if w < 1e-6 {
188                continue;
189            }
190            let m = joint_matrices.matrices[indices[k] as usize];
191            blended_p += w * m.transform_point3(p);
192            blended_n += w * m.transform_vector3(nm);
193        }
194
195        out_pos[i] = blended_p.to_array();
196        out_nrm[i] = blended_n.normalize_or_zero().to_array();
197    }
198
199    (out_pos, out_nrm)
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use crate::resources::SkinWeights;
206    use glam::{Affine3A, Vec3};
207
208    fn two_joint_skeleton(joint_z: f32) -> Skeleton {
209        Skeleton::new(vec![
210            Joint {
211                name: "root".into(),
212                parent: None,
213                inverse_bind: Affine3A::IDENTITY,
214            },
215            Joint {
216                name: "child".into(),
217                parent: Some(0),
218                inverse_bind: Affine3A::from_translation(-Vec3::new(0.0, 0.0, joint_z)),
219            },
220        ])
221    }
222
223    /// Returns the pose whose forward kinematics reproduces the bind pose for
224    /// `two_joint_skeleton(joint_z)`. Joint 0 stays at the origin; joint 1
225    /// sits at z=joint_z, which is the inverse of its `inverse_bind`.
226    fn bind_pose(joint_z: f32) -> Pose {
227        let mut p = Pose::identity(2);
228        p.local_transforms[1] = Affine3A::from_translation(Vec3::new(0.0, 0.0, joint_z));
229        p
230    }
231
232    fn approx_eq(a: [f32; 3], b: [f32; 3], eps: f32) -> bool {
233        (a[0] - b[0]).abs() < eps && (a[1] - b[1]).abs() < eps && (a[2] - b[2]).abs() < eps
234    }
235
236    #[test]
237    fn bind_pose_produces_identity_skinning_matrices() {
238        let joint_z = 2.0;
239        let sk = two_joint_skeleton(joint_z);
240        let jm = JointMatrices::compute(&sk, &bind_pose(joint_z));
241        for m in jm.as_slice() {
242            let p = m.transform_point3(Vec3::new(1.0, 2.0, 3.0));
243            assert!(approx_eq(p.to_array(), [1.0, 2.0, 3.0], 1e-5), "got {:?}", p);
244        }
245    }
246
247    #[test]
248    fn apply_skin_at_bind_pose_returns_input() {
249        let joint_z = 2.0;
250        let sk = two_joint_skeleton(joint_z);
251        let jm = JointMatrices::compute(&sk, &bind_pose(joint_z));
252        let positions = vec![[0.0, 0.0, 0.0], [0.5, 0.0, 1.0], [0.0, 0.0, 4.0]];
253        let normals = vec![[1.0, 0.0, 0.0]; 3];
254        let weights = SkinWeights {
255            joint_indices: vec![[0, 1, 0, 0]; 3],
256            joint_weights: vec![[1.0, 0.0, 0.0, 0.0], [0.5, 0.5, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0]],
257        };
258        let (out_p, out_n) = apply_skin(&positions, &normals, &weights, &jm);
259        for i in 0..3 {
260            assert!(approx_eq(out_p[i], positions[i], 1e-5), "pos {i}: {:?}", out_p[i]);
261            assert!(approx_eq(out_n[i], normals[i], 1e-5), "nrm {i}: {:?}", out_n[i]);
262        }
263    }
264
265    #[test]
266    fn child_rotation_bends_around_joint() {
267        // Rotating joint 1 by 90 deg around X with the bind transform applied
268        // should swing a child-weighted vertex at (0,0,3) down to (0,-1,2).
269        let joint_z = 2.0;
270        let sk = two_joint_skeleton(joint_z);
271        let mut pose = bind_pose(joint_z);
272        pose.local_transforms[1] = Affine3A::from_translation(Vec3::new(0.0, 0.0, joint_z))
273            * Affine3A::from_rotation_x(std::f32::consts::FRAC_PI_2);
274        let jm = JointMatrices::compute(&sk, &pose);
275
276        let positions = vec![[0.0, 0.0, joint_z + 1.0]];
277        let normals = vec![[0.0, 0.0, 1.0]];
278        let weights = SkinWeights {
279            joint_indices: vec![[0, 1, 0, 0]],
280            joint_weights: vec![[0.0, 1.0, 0.0, 0.0]],
281        };
282        let (out_p, out_n) = apply_skin(&positions, &normals, &weights, &jm);
283        assert!(approx_eq(out_p[0], [0.0, -1.0, joint_z], 1e-4), "got {:?}", out_p[0]);
284        assert!(approx_eq(out_n[0], [0.0, -1.0, 0.0], 1e-4), "got {:?}", out_n[0]);
285    }
286
287    #[test]
288    fn zero_weight_slots_are_skipped() {
289        let joint_z = 2.0;
290        let sk = two_joint_skeleton(joint_z);
291        let mut pose = bind_pose(joint_z);
292        // Add a huge translation to joint 1, but weight 0 for our vertex.
293        pose.local_transforms[1] = pose.local_transforms[1]
294            * Affine3A::from_translation(Vec3::new(100.0, 0.0, 0.0));
295        let jm = JointMatrices::compute(&sk, &pose);
296
297        let positions = vec![[0.0, 0.0, 0.0]];
298        let normals = vec![[1.0, 0.0, 0.0]];
299        let weights = SkinWeights {
300            joint_indices: vec![[0, 1, 1, 1]],
301            joint_weights: vec![[1.0, 0.0, 0.0, 0.0]],
302        };
303        let (out_p, _) = apply_skin(&positions, &normals, &weights, &jm);
304        assert!(approx_eq(out_p[0], [0.0, 0.0, 0.0], 1e-5));
305    }
306}