Skip to main content

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