concinnity_core/components/character_shape.rs
1// Character-shape schema: slider-driven morph weights and per-joint proportions
2// that deform a SkinnedMesh at runtime.
3
4use crate::ecs::SkinnedMeshHandle;
5use crate::ecs::asset_id::AssetId;
6use crate::ecs::de_opt_skinned_mesh_handle;
7use alloc::string::String;
8use alloc::vec::Vec;
9
10/// One named shape value in `[-1, 1]`.
11#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
12#[serde(default)]
13pub struct ShapeSlider {
14 /// Slider name; matched against the target mesh's morph-target names.
15 pub name: String,
16 /// Slider value, clamped to `[-1, 1]`.
17 pub value: f32,
18}
19
20/// One joint's proportion change.
21#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
22#[serde(default)]
23pub struct JointProportion {
24 /// Name of the joint in the target mesh's `skeleton`.
25 pub joint: String,
26 /// Uniform scale applied to the joint (and, through the hierarchy,
27 /// everything below it). `1` leaves it alone.
28 pub scale: f32,
29 /// Extra length along the bone, in model units: every child joint is
30 /// pushed that far along its bind direction from this joint. `0` leaves
31 /// it alone.
32 pub length: f32,
33}
34
35impl Default for JointProportion {
36 fn default() -> Self {
37 Self {
38 joint: String::new(),
39 scale: 1.0,
40 length: 0.0,
41 }
42 }
43}
44
45/// Shape sliders and joint proportions applied to one [SkinnedMesh](#skinnedmesh).
46///
47/// Every characteristic of the shape is data on the mesh, not code: a slider
48/// drives one or two of the mesh's morph targets, and a proportion scales or
49/// lengthens one joint of its skeleton. The deformation is static and sits
50/// under any [Animation](#animation) playing on the same mesh: clip morph
51/// tracks are added on top of the slider weights, and clip poses are
52/// re-proportioned every frame.
53///
54/// **Sliders** resolve to morph targets by name. A target named exactly
55/// `name` is unipolar and receives the slider value clamped to `[0, 1]`. A
56/// pair named `name+` / `name-` is bipolar: a positive value drives `name+`,
57/// a negative value drives `name-` by its magnitude. A slider with no matching
58/// target is reported as a build warning and ignored.
59///
60/// **Proportions** resolve to joints by name. `scale` is uniform (the
61/// skinning shaders transform normals with the plain joint matrix, so a
62/// non-uniform scale would shade incorrectly) and propagates to the joint's
63/// descendants; `length` moves only the joint's children along the bone, so a
64/// longer thigh does not also stretch the shin. Proportions change the posed
65/// skeleton, not the bind pose, so clips with translation tracks on the
66/// affected joints fight them; keep such rigs rotation-only. When the mesh
67/// declares a `capsule`, the capsule's half-height follows the skeleton's
68/// height change and its radius follows the root joint's scale.
69///
70/// `target` may name a [CharacterModel](#charactermodel) as well as a
71/// `SkinnedMesh`; the model's emitted mesh is what the shape deforms.
72///
73/// **Baking.** With `bake` set, the build flattens the shape into its target:
74/// the sliders' deformation is applied to the vertices and the morph targets
75/// dropped, the bind pose is rewritten through the proportions, the capsule
76/// is resized, and this asset is consumed. The result is a plain `SkinnedMesh`
77/// with no per-frame shape work, for characters that never change shape.
78///
79/// ```rust
80/// # use concinnity_core::components::{CharacterShape, JointProportion, ShapeSlider};
81/// CharacterShape {
82/// sliders: vec![ShapeSlider { name: "weight".into(), value: 0.5 }],
83/// proportions: vec![JointProportion { joint: "spine".into(), scale: 1.1, length: 0.0 }],
84/// ..Default::default()
85/// };
86/// ```
87#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
88#[serde(default)]
89pub struct CharacterShape {
90 /// Asset identity; injected via `inject_name`. Not part of `args`.
91 #[serde(skip)]
92 pub asset_id: AssetId,
93 /// The [SkinnedMesh](#skinnedmesh) this shape deforms.
94 #[serde(deserialize_with = "de_opt_skinned_mesh_handle")]
95 pub target: Option<SkinnedMeshHandle>,
96 /// Named shape values, each resolved to the mesh's morph targets.
97 pub sliders: Vec<ShapeSlider>,
98 /// Per-joint scale and length changes.
99 pub proportions: Vec<JointProportion>,
100 /// Flatten the shape into the target mesh at build time and drop this
101 /// asset, instead of deforming at runtime.
102 pub bake: bool,
103}
104
105/// Morph weights resolved from a shape's sliders against a mesh's morph-target
106/// names, plus the slider names that matched nothing.
107#[derive(Debug, Clone, Default, PartialEq)]
108pub struct ResolvedSliders {
109 /// One weight per morph target, in target order.
110 pub weights: Vec<f32>,
111 /// Slider names with neither a unipolar nor a bipolar target.
112 pub unresolved: Vec<String>,
113}
114
115impl CharacterShape {
116 /// Resolve `sliders` against `target_names` (the mesh's morph targets in
117 /// target order). Several sliders naming the same target accumulate; the
118 /// result is clamped to `[0, 1]` per target.
119 pub fn resolve_sliders(&self, target_names: &[String]) -> ResolvedSliders {
120 let mut out = ResolvedSliders {
121 weights: alloc::vec![0.0; target_names.len()],
122 unresolved: Vec::new(),
123 };
124 let find = |name: &str, suffix: &str| {
125 target_names
126 .iter()
127 .position(|t| t.strip_suffix(suffix).is_some_and(|base| base == name))
128 };
129 for slider in &self.sliders {
130 let value = slider.value.clamp(-1.0, 1.0);
131 let plus = find(&slider.name, "+");
132 let minus = find(&slider.name, "-");
133 if plus.is_some() || minus.is_some() {
134 if let Some(i) = plus {
135 out.weights[i] += value.max(0.0);
136 }
137 if let Some(i) = minus {
138 out.weights[i] += (-value).max(0.0);
139 }
140 } else if let Some(i) = find(&slider.name, "") {
141 out.weights[i] += value.max(0.0);
142 } else {
143 out.unresolved.push(slider.name.clone());
144 }
145 }
146 for w in &mut out.weights {
147 *w = w.clamp(0.0, 1.0);
148 }
149 out
150 }
151
152 /// The proportion joint names that `has_joint` does not know.
153 pub fn unresolved_joints(&self, has_joint: impl Fn(&str) -> bool) -> Vec<String> {
154 self.proportions
155 .iter()
156 .filter(|p| !has_joint(&p.joint))
157 .map(|p| p.joint.clone())
158 .collect()
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use alloc::vec;
166
167 fn names(list: &[&str]) -> Vec<String> {
168 list.iter().map(|s| String::from(*s)).collect()
169 }
170
171 fn shape(sliders: &[(&str, f32)]) -> CharacterShape {
172 CharacterShape {
173 sliders: sliders
174 .iter()
175 .map(|(n, v)| ShapeSlider {
176 name: String::from(*n),
177 value: *v,
178 })
179 .collect(),
180 ..Default::default()
181 }
182 }
183
184 #[test]
185 fn unipolar_slider_drives_the_target_of_the_same_name() {
186 let r = shape(&[("weight", 0.6)]).resolve_sliders(&names(&["height", "weight"]));
187 assert_eq!(r.weights, [0.0, 0.6]);
188 assert!(r.unresolved.is_empty());
189 // A negative value on a unipolar target contributes nothing.
190 let r = shape(&[("weight", -0.6)]).resolve_sliders(&names(&["weight"]));
191 assert_eq!(r.weights, [0.0]);
192 }
193
194 #[test]
195 fn bipolar_slider_splits_by_sign() {
196 let targets = names(&["jaw-", "jaw+"]);
197 let r = shape(&[("jaw", 0.25)]).resolve_sliders(&targets);
198 assert_eq!(r.weights, [0.0, 0.25]);
199 let r = shape(&[("jaw", -0.75)]).resolve_sliders(&targets);
200 assert_eq!(r.weights, [0.75, 0.0]);
201 // The pair takes precedence over a same-named unipolar target.
202 let r = shape(&[("jaw", 0.5)]).resolve_sliders(&names(&["jaw", "jaw+"]));
203 assert_eq!(r.weights, [0.0, 0.5]);
204 }
205
206 #[test]
207 fn unresolved_sliders_are_reported_not_fatal() {
208 let r = shape(&[("nose", 1.0), ("weight", 2.0)]).resolve_sliders(&names(&["weight"]));
209 assert_eq!(r.unresolved, names(&["nose"]));
210 // Values clamp to the slider range before resolving.
211 assert_eq!(r.weights, [1.0]);
212 }
213
214 #[test]
215 fn repeated_sliders_accumulate_and_clamp() {
216 let r = shape(&[("w", 0.7), ("w", 0.7)]).resolve_sliders(&names(&["w"]));
217 assert_eq!(r.weights, [1.0]);
218 }
219
220 #[test]
221 fn unresolved_joints_are_listed() {
222 let s = CharacterShape {
223 proportions: vec![
224 JointProportion {
225 joint: "spine".into(),
226 ..Default::default()
227 },
228 JointProportion {
229 joint: "tail".into(),
230 ..Default::default()
231 },
232 ],
233 ..Default::default()
234 };
235 assert_eq!(s.unresolved_joints(|j| j == "spine"), names(&["tail"]));
236 }
237
238 #[test]
239 fn a_shape_round_trips_through_postcard() {
240 crate::test_support::install_resolvers();
241 let s: CharacterShape = serde_json::from_str(
242 r#"{"target":"hero","sliders":[{"name":"jaw","value":-0.5}],
243 "proportions":[{"joint":"thigh.L","scale":1.05,"length":0.1}]}"#,
244 )
245 .unwrap();
246 assert_eq!(s.target, Some(SkinnedMeshHandle(4)));
247 let bytes = postcard::to_allocvec(&s).unwrap();
248 let back: CharacterShape = postcard::from_bytes(&bytes).unwrap();
249 assert_eq!(back.target, Some(SkinnedMeshHandle(4)));
250 assert_eq!(back.sliders, s.sliders);
251 assert_eq!(back.proportions, s.proportions);
252 assert_eq!(back.proportions[0].scale, 1.05);
253 assert!(!back.bake, "runtime deformation is the default");
254 assert_eq!(back.asset_id, AssetId::default());
255 // A blank proportion is the identity.
256 let p = JointProportion::default();
257 assert_eq!((p.scale, p.length), (1.0, 0.0));
258 }
259}