Skip to main content

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