BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / tests / skinned_equipment.rs

skinned_equipment.rs

skinned_equipment.rs 10.7 KiB Download Raw
1//! Tim t1027u: skinned equipment parts with per-bone links to the character.
2use paperdoll_renderer::equipment::{self, Preset};
3use paperdoll_renderer::equipment_rig;
4use paperdoll_renderer::preview::{self, PreviewControls};
5use std::path::PathBuf;
6
7fn body_path() -> PathBuf {
8 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb")
9}
10
11fn apply(m: &[f32; 16], p: [f32; 3]) -> [f32; 3] {
12 [0, 1, 2].map(|i| m[i] * p[0] + m[4 + i] * p[1] + m[8 + i] * p[2] + m[12 + i])
13}
14
15/// The body skinned on the CPU (linear blend) at a clip time.
16fn skinned_body(body: &preview::PreviewCharacter, clip: &str, t: f32) -> Vec<[f32; 3]> {
17 let skins: Vec<[f32; 16]> = (0..body.joint_nodes.len())
18 .map(|j| {
19 preview::mul(
20 preview::animated_joint_world(body, clip, t, j).unwrap(),
21 body.inverse_binds[j],
22 )
23 })
24 .collect();
25 (0..body.positions.len())
26 .map(|v| {
27 let mut out = [0f32; 3];
28 for (j, w) in body.joints[v].iter().zip(body.weights[v]) {
29 let p = apply(&skins[*j as usize], body.positions[v]);
30 for k in 0..3 {
31 out[k] += p[k] * w;
32 }
33 }
34 out
35 })
36 .collect()
37}
38
39fn equipment_points(
40 preset: &Preset,
41 body: &preview::PreviewCharacter,
42 clip: &str,
43 t: f32,
44) -> Vec<[f32; 3]> {
45 let controls = PreviewControls {
46 clip_name: clip.into(),
47 time: t,
48 ..Default::default()
49 };
50 equipment::layers(preset, 0, body, &controls)
51 .unwrap()
52 .iter()
53 .flat_map(|l| {
54 l.mesh
55 .positions
56 .iter()
57 .map(|p| apply(&l.model, *p))
58 .collect::<Vec<_>>()
59 })
60 .collect()
61}
62
63fn max_nearest(points: &[[f32; 3]], cloud: &[[f32; 3]]) -> f32 {
64 points
65 .iter()
66 .map(|p| {
67 cloud
68 .iter()
69 .map(|q| (0..3).map(|k| (p[k] - q[k]).powi(2)).sum::<f32>())
70 .fold(f32::MAX, f32::min)
71 .sqrt()
72 })
73 .fold(0., f32::max)
74}
75
76fn mapped_preset(body: &preview::PreviewCharacter) -> Preset {
77 let mut preset = Preset::new("suit".into());
78 preset.applies = [true, false];
79 let part = equipment::import_model(&mut preset, &body_path()).unwrap();
80 assert!(preset.attachments[part].skeleton.is_some());
81 let added = equipment_rig::auto_link(&mut preset, part, 0, body).unwrap();
82 assert_eq!(added, body.joint_nodes.len(), "every bone name matches");
83 preset
84}
85
86#[test]
87fn skinned_part_follows_the_character_skeleton_in_rest_and_animation() {
88 let body = preview::load_gltf_file(&body_path()).unwrap();
89 let preset = mapped_preset(&body);
90 // Same rig: every auto link keeps the rest placement with a zero offset.
91 for link in &preset.attachments[0].bone_links[0] {
92 assert!(link.translation.iter().all(|x| x.abs() < 1e-3), "{link:?}");
93 assert!(link.rotation_deg.iter().all(|x| x.abs() < 0.05), "{link:?}");
94 }
95 let rest = equipment_points(&preset, &body, "", 0.0);
96 assert!(max_nearest(&rest, &skinned_body(&body, "", 0.0)) < 1e-3);
97 let clip = &body.clips[0];
98 let t = clip.duration * 0.5;
99 let moving = equipment_points(&preset, &body, &clip.name, t);
100 let reference = skinned_body(&body, &clip.name, t);
101 // Rigid per dominant bone: close to linear blend skinning, never far off.
102 assert!(
103 max_nearest(&moving, &reference) < 0.03,
104 "{}",
105 max_nearest(&moving, &reference)
106 );
107 // Moving differs from rest, so the links really animate.
108 assert!(max_nearest(&moving, &rest) > 0.01);
109}
110
111#[test]
112fn per_link_offset_moves_only_that_bone_and_its_unlinked_children() {
113 let body = preview::load_gltf_file(&body_path()).unwrap();
114 let mut preset = mapped_preset(&body);
115 let skeleton = preset.attachments[0].skeleton.clone().unwrap();
116 let head = skeleton
117 .bones
118 .iter()
119 .position(|b| b.name == "Head")
120 .unwrap();
121 // Lift only the Head link by 10 cm.
122 let links = &mut preset.attachments[0].bone_links[0];
123 let link = links.iter_mut().find(|l| l.bone == head).unwrap();
124 link.translation[1] += 0.1;
125 let controls = PreviewControls::default();
126 let layers = equipment::layers(&preset, 0, &body, &controls).unwrap();
127 let rest = equipment_rig::bone_models(&skeleton, &[], &[], |_| None);
128 assert!(rest.is_err(), "no links is an error");
129 for layer in &layers {
130 let bone = layer.mesh.bone.unwrap();
131 let expected = skeleton.bones[bone].rest_world;
132 let lifted = bone == head;
133 let dy = layer.model[13] - expected[13];
134 if lifted {
135 assert!(dy > 0.05, "head lifted");
136 } else {
137 assert!(dy.abs() < 1e-3, "bone {} moved", skeleton.bones[bone].name);
138 }
139 }
140}
141
142#[test]
143fn version_1_presets_migrate_and_mapping_round_trips_in_version_2() {
144 let old = std::fs::read(
145 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/equipment/sword-v1.ppe"),
146 )
147 .unwrap();
148 assert_eq!(u32::from_le_bytes(old[8..12].try_into().unwrap()), 1);
149 let migrated = Preset::decode(&old).unwrap();
150 assert!(migrated.attachments.iter().all(|a| a.skeleton.is_none()));
151 assert!(!migrated.semi_transparency);
152 // A version-2 file (0.1.3) loads with semi-transparency off (t1031u).
153 let v2 = std::fs::read(
154 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/equipment/sword-v2.ppe"),
155 )
156 .unwrap();
157 assert_eq!(u32::from_le_bytes(v2[8..12].try_into().unwrap()), 2);
158 assert!(!Preset::decode(&v2).unwrap().semi_transparency);
159 let bytes = migrated.encode().unwrap();
160 assert_eq!(
161 u32::from_le_bytes(bytes[8..12].try_into().unwrap()),
162 equipment::VERSION
163 );
164 assert_eq!(Preset::decode(&bytes).unwrap(), migrated);
165
166 let body = preview::load_gltf_file(&body_path()).unwrap();
167 let mut preset = mapped_preset(&body);
168 preset.attachments[0].bone_links[0][0].rotation_deg = [5., 10., 15.];
169 let again = Preset::decode(&preset.encode().unwrap()).unwrap();
170 assert_eq!(again, preset);
171 // Links to an unknown bone are rejected.
172 let mut bad = preset.clone();
173 bad.attachments[0].bone_links[0][0].target = "nope".into();
174 assert!(bad.encode().is_err());
175}
176
177fn female_body() -> preview::PreviewCharacter {
178 preview::load_gltf_file(
179 &PathBuf::from(env!("CARGO_MANIFEST_DIR"))
180 .join("tests/fixtures/bodies/test-body-female.glb"),
181 )
182 .unwrap()
183}
184
185fn rename_joint(
186 mut body: preview::PreviewCharacter,
187 from: &str,
188 to: &str,
189) -> preview::PreviewCharacter {
190 for name in &mut body.joint_names {
191 if name == from {
192 *name = to.to_string();
193 }
194 }
195 for node in &mut body.nodes {
196 if node.name == from {
197 node.name = to.to_string();
198 }
199 }
200 body
201}
202
203#[test]
204fn copy_skinned_links_matching_partial_and_no_overlap() {
205 let male = preview::load_gltf_file(&body_path()).unwrap();
206 let female = female_body();
207 let mut preset = mapped_preset(&male);
208 // Distinct authored offsets on two links so we can see the copy.
209 let head = preset.attachments[0]
210 .skeleton
211 .as_ref()
212 .unwrap()
213 .bones
214 .iter()
215 .position(|b| b.name == "Head")
216 .unwrap();
217 let hand = preset.attachments[0]
218 .skeleton
219 .as_ref()
220 .unwrap()
221 .bones
222 .iter()
223 .position(|b| b.name == "RightHand")
224 .unwrap();
225 for link in &mut preset.attachments[0].bone_links[0] {
226 if link.bone == head {
227 link.translation = [0.0, 0.1, 0.0];
228 }
229 if link.bone == hand {
230 link.rotation_deg = [0.0, 15.0, 0.0];
231 }
232 }
233 assert!(preset.attachments[0].bone_links[1].is_empty());
234
235 // Full name overlap.
236 let copied = preset.copy_gender_mapping(0, 1, &female).unwrap();
237 assert_eq!(copied.unmatched, 0);
238 assert_eq!(copied.mapped, preset.attachments[0].bone_links[0].len());
239 assert_eq!(
240 preset.attachments[0].bone_links[0].len(),
241 preset.attachments[0].bone_links[1].len()
242 );
243 let female_paths = preview::joint_paths(&female);
244 for src in &preset.attachments[0].bone_links[0] {
245 let dest = preset.attachments[0].bone_links[1]
246 .iter()
247 .find(|l| l.bone == src.bone)
248 .expect("copied link");
249 assert_eq!(dest.translation, src.translation);
250 assert_eq!(dest.rotation_deg, src.rotation_deg);
251 assert!(female_paths.contains(&dest.target));
252 assert_eq!(
253 equipment::joint_leaf_name(&dest.target),
254 equipment::joint_leaf_name(&src.target)
255 );
256 }
257 // Independent after the copy: changing male does not change female.
258 preset.attachments[0].bone_links[0]
259 .iter_mut()
260 .find(|l| l.bone == head)
261 .unwrap()
262 .translation = [3.0, 0.0, 0.0];
263 let female_head = preset.attachments[0].bone_links[1]
264 .iter()
265 .find(|l| l.bone == head)
266 .unwrap();
267 assert_eq!(female_head.translation, [0.0, 0.1, 0.0]);
268 let restored = Preset::decode(&preset.encode().unwrap()).unwrap();
269 assert_eq!(restored, preset);
270
271 // Partial overlap: female is missing Head; other links still copy.
272 let mut partial = mapped_preset(&male);
273 partial.attachments[0].bone_links[0]
274 .iter_mut()
275 .find(|l| l.bone == head)
276 .unwrap()
277 .translation = [0.0, 0.1, 0.0];
278 let no_head = rename_joint(female.clone(), "Head", "Skull");
279 let outcome = partial.copy_gender_mapping(0, 1, &no_head).unwrap();
280 assert_eq!(outcome.unmatched, 1);
281 assert_eq!(
282 outcome.mapped,
283 partial.attachments[0].bone_links[0].len() - 1
284 );
285 assert!(partial.attachments[0].bone_links[1]
286 .iter()
287 .all(|l| equipment::joint_leaf_name(&l.target) != "Head"));
288 assert!(partial.attachments[0].bone_links[1]
289 .iter()
290 .any(|l| equipment::joint_leaf_name(&l.target) == "RightHand"));
291
292 // No overlap at all: rename every female joint.
293 let mut unique = female.clone();
294 for (i, name) in unique.joint_names.iter_mut().enumerate() {
295 *name = format!("OnlyFemale{i}");
296 }
297 for (i, node) in unique.nodes.iter_mut().enumerate() {
298 if male.joint_names.iter().any(|n| n == &node.name) {
299 node.name = format!("OnlyFemaleNode{i}");
300 }
301 }
302 let mut none = mapped_preset(&male);
303 let outcome = none.copy_gender_mapping(0, 1, &unique).unwrap();
304 assert_eq!(outcome.mapped, 0);
305 assert_eq!(outcome.unmatched, none.attachments[0].bone_links[0].len());
306 assert!(none.attachments[0].bone_links[1].is_empty());
307 assert!(none.rigs[1].is_none());
308}