concinnity_core/components/skeleton_pose.rs
1// src/components/skeleton_pose.rs
2
3use alloc::vec::Vec;
4
5use crate::ecs::SkinnedMeshHandle;
6use crate::gfx::pose_scratch::PoseScratch;
7use crate::gfx::proportions::ProportionLayer;
8use crate::gfx::skeleton::Skeleton;
9use crate::gfx::transform::Mat4;
10
11/// Runtime-only link between a skinned mesh and its animation state.
12///
13/// `GraphicsSystem` publishes one `SkeletonPose` per `SkinnedMesh` during
14/// init: it carries the resolved bind-pose `Skeleton` and the index of the
15/// mesh's skinned draw object in the backend. `AnimationSystem` then ticks the
16/// matching `Animation` clip each frame and writes the resulting skinning
17/// matrices into `joint_matrices`; `GraphicsSystem` reads them back and
18/// uploads them to the GPU. The one-frame producer/consumer hand-off is
19/// invisible at animation rates.
20///
21/// A `CharacterShape` targeting the mesh seeds the static layers: `morph_base`
22/// sits under every clip's morph track and `proportions` re-shapes every
23/// sampled pose before skinning.
24///
25/// Not authored in world files: it has no `args`.
26#[derive(Debug)]
27pub struct SkeletonPose {
28 /// The `SkinnedMesh` resource this pose belongs to. Used by
29 /// `AnimationSystem` to match an `Animation` clip to its target.
30 pub mesh_id: SkinnedMeshHandle,
31 /// Index of this mesh's skinned draw object in the render backend.
32 pub skinned_index: usize,
33 /// Bind-pose joint hierarchy, used to compose skinning matrices.
34 pub skeleton: Skeleton,
35 /// Current skinning matrices, one per joint. Seeded to the bind pose
36 /// (identity skinning) and overwritten by `AnimationSystem` each frame.
37 pub joint_matrices: Vec<Mat4>,
38 /// Current morph-target weights, one per target of the mesh: the base
39 /// layer plus whatever a clip's morph track adds. Empty for a mesh with
40 /// neither a base layer nor a morph clip.
41 pub morph_weights: Vec<f32>,
42 /// Static morph weights from the mesh's `CharacterShape`, one per target;
43 /// empty without one. Clip morph tracks are added onto these.
44 pub morph_base: Vec<f32>,
45 /// Per-joint proportion changes from the mesh's `CharacterShape`, applied
46 /// to every sampled pose before the skinning matrices are built.
47 pub proportions: ProportionLayer,
48 /// True while `joint_matrices` / `morph_weights` hold data the render
49 /// backend has not consumed yet. Set by whoever writes the pose, cleared
50 /// after upload, so an unanimated pose is uploaded exactly once.
51 pub updated: bool,
52 /// Reusable sampling buffers owned by this pose, so the per-frame
53 /// sample/blend/skinning chain allocates nothing in steady state.
54 pub scratch: PoseScratch,
55}
56
57impl SkeletonPose {
58 /// Build a pose for `mesh_id`'s skinned draw object, seeded to the bind
59 /// pose so the mesh renders undeformed until an animation drives it.
60 pub fn new(mesh_id: SkinnedMeshHandle, skinned_index: usize, skeleton: Skeleton) -> Self {
61 let joint_matrices = skeleton.bind_skinning_matrices();
62 Self {
63 mesh_id,
64 skinned_index,
65 skeleton,
66 joint_matrices,
67 morph_weights: Vec::new(),
68 morph_base: Vec::new(),
69 proportions: ProportionLayer::default(),
70 updated: true,
71 scratch: PoseScratch::default(),
72 }
73 }
74
75 /// Install the static shape layers and re-seed the rest pose through them,
76 /// so a mesh with no clip renders shaped.
77 pub fn with_shape(mut self, morph_base: Vec<f32>, proportions: ProportionLayer) -> Self {
78 self.set_shape(morph_base, proportions);
79 self
80 }
81
82 /// Replace the static shape layers in place and re-seed the rest pose
83 /// through them; an animated pose picks the new layers up on its next
84 /// sample. For editing a shape on a live pose without rebuilding it.
85 pub fn set_shape(&mut self, morph_base: Vec<f32>, proportions: ProportionLayer) {
86 self.morph_weights = morph_base.clone();
87 self.morph_base = morph_base;
88 self.proportions = proportions;
89 self.scratch.locals.clear();
90 self.scratch
91 .locals
92 .extend_from_slice(self.skeleton.bind_locals());
93 self.proportions.apply(&mut self.scratch.locals);
94 self.skeleton
95 .skinning_matrices_into(&self.scratch.locals, &mut self.joint_matrices);
96 self.updated = true;
97 }
98
99 /// A fresh pose sharing this one's skeleton and shape layers, for a
100 /// runtime-spawned copy of the mesh at draw slot `skinned_index`.
101 pub fn clone_for_slot(&self, skinned_index: usize) -> Self {
102 Self::new(self.mesh_id, skinned_index, self.skeleton.clone())
103 .with_shape(self.morph_base.clone(), self.proportions.clone())
104 }
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110 use crate::gfx::skeleton::{Joint, JointPose};
111 use alloc::string::String;
112 use alloc::vec;
113
114 fn two_joint_chain() -> Skeleton {
115 Skeleton::new(vec![
116 Joint {
117 name: String::from("root"),
118 parent: None,
119 bind: JointPose::default(),
120 },
121 Joint {
122 name: String::from("tip"),
123 parent: Some(0),
124 bind: JointPose {
125 translation: [0.0, 1.0, 0.0],
126 ..Default::default()
127 },
128 },
129 ])
130 }
131
132 #[test]
133 fn a_shaped_rest_pose_is_seeded_through_the_layers() {
134 let skeleton = two_joint_chain();
135 let layer = ProportionLayer::resolve(
136 &skeleton,
137 &[crate::components::JointProportion {
138 joint: String::from("root"),
139 scale: 2.0,
140 length: 0.0,
141 }],
142 );
143 let pose =
144 SkeletonPose::new(SkinnedMeshHandle(1), 3, skeleton).with_shape(vec![0.25, 0.5], layer);
145 assert_eq!(pose.morph_weights, [0.25, 0.5]);
146 // Root doubled: its skinning matrix is a pure scale of 2 (bind is
147 // identity there), and the tip's bind position (0, 1, 0) skins to
148 // (0, 2, 0).
149 assert_eq!(pose.joint_matrices[0][0][0], 2.0);
150 let tip = pose.joint_matrices[1];
151 assert!((tip[1][1] + tip[3][1] - 2.0).abs() < 1e-5, "{tip:?}");
152 assert!(pose.updated);
153 let copy = pose.clone_for_slot(7);
154 assert_eq!(copy.skinned_index, 7);
155 assert_eq!(copy.joint_matrices, pose.joint_matrices);
156 assert_eq!(copy.morph_base, pose.morph_base);
157 }
158}