BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / tests / skinned_equipment.rs

skinned_equipment.rs

skinned_equipment.rs 6.3 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}