Skip to main content

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}