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