BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / camera.rs

camera.rs

camera.rs 13.0 KiB Download Raw
1//! Fixed render orbit pivot is independent of the orthographic image center.
2use crate::{
3 preview::{self, Mat4, PreviewCharacter},
4 project::CameraFraming,
5};
6use serde::Serialize;
7#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
8#[serde(tag = "kind", rename_all = "snake_case")]
9pub enum PivotSource {
10 Manual,
11 Neck {
12 joint_index: usize,
13 joint_name: String,
14 },
15 Fallback {
16 reason: String,
17 },
18}
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Resolved {
21 pub framing: CameraFraming,
22 pub source: PivotSource,
23}
24impl Resolved {
25 pub fn description(&self) -> String {
26 let y = self
27 .framing
28 .pivot_y_milli
29 .unwrap_or(self.framing.center_y_milli) as f32
30 / 1000.;
31 match &self.source {
32 PivotSource::Manual => format!("Vaste pivot Y {y:.3} m"),
33 PivotSource::Neck { joint_name, .. } => {
34 format!("Nekpivot {joint_name}: Y {y:.3} m (rustpose)")
35 }
36 PivotSource::Fallback { reason } => {
37 format!("{reason}; centrum Y {y:.3} m als fallback — stel de pivot handmatig in")
38 }
39 }
40 }
41}
42fn neck_name(name: &str) -> bool {
43 let lower = name.to_ascii_lowercase();
44 let is_neck = |token: &str| {
45 token
46 .strip_prefix("neck")
47 .is_some_and(|suffix| suffix.chars().all(|c| c.is_ascii_digit()))
48 };
49 lower
50 .split(|c: char| !c.is_ascii_alphanumeric())
51 .any(is_neck)
52 || ["mixamorig", "bip001", "bip01", "ccbase"]
53 .iter()
54 .any(|prefix| lower.strip_prefix(prefix).is_some_and(is_neck))
55}
56fn ancestor(body: &PreviewCharacter, parent: usize, child: usize) -> bool {
57 let mut current = Some(child);
58 for _ in 0..body.nodes.len() {
59 let Some(node) = current else { return false };
60 if node == parent {
61 return true;
62 }
63 current = body.nodes.get(node).and_then(|n| n.parent);
64 }
65 false
66}
67/// Select the base of one neck chain, not a similarly named unrelated skeleton.
68pub fn neck_height(body: &PreviewCharacter) -> Result<(usize, i32), String> {
69 if body.is_static() {
70 // Documented fallback: a static body has no bones, so no neck joint
71 // is guessed from geometry; `resolve` uses center_y (or a manual pivot).
72 return Err("Statisch lichaam zonder skeleton: geen nekbot".into());
73 }
74 let candidates = body
75 .joint_names
76 .iter()
77 .enumerate()
78 .filter(|(_, n)| neck_name(n))
79 .map(|(j, _)| j)
80 .collect::<Vec<_>>();
81 if candidates.is_empty() {
82 return Err("Geen nekbot gevonden".into());
83 }
84 let roots = candidates
85 .iter()
86 .copied()
87 .filter(|j| {
88 candidates.iter().all(|other| {
89 match (body.joint_nodes.get(*j), body.joint_nodes.get(*other)) {
90 (Some(a), Some(b)) => ancestor(body, *a, *b),
91 _ => false,
92 }
93 })
94 })
95 .collect::<Vec<_>>();
96 if roots.len() != 1 {
97 return Err("Meerdere losstaande/ongeldige nekketens".into());
98 }
99 let joint = roots[0];
100 let world = preview::rest_joint_world(body, joint).ok_or("Ongeldige rustpose van nekbot")?;
101 let height = (world[13] * 1000.).round();
102 if !(-10000.0..=10000.0).contains(&height) {
103 return Err("Nekhoogte buiten -10…10 m".into());
104 }
105 Ok((joint, height as i32))
106}
107pub fn resolve(body: &PreviewCharacter, mut framing: CameraFraming) -> Resolved {
108 if framing.pivot_y_milli.is_some() {
109 return Resolved {
110 framing,
111 source: PivotSource::Manual,
112 };
113 }
114 let source = match neck_height(body) {
115 Ok((joint, y)) => {
116 framing.pivot_y_milli = Some(y);
117 PivotSource::Neck {
118 joint_index: joint,
119 joint_name: body.joint_names[joint].clone(),
120 }
121 }
122 Err(reason) => {
123 framing.pivot_y_milli = Some(framing.center_y_milli);
124 PivotSource::Fallback { reason }
125 }
126 };
127 Resolved { framing, source }
128}
129/// Translation around the neck is separate from the image's vertical framing.
130/// Keeping the orthographic offset constant makes the orbit pivot pixel stable
131/// for every yaw/pitch without cropping the feet merely by raising the pivot.
132pub fn matrices(framing: CameraFraming, yaw: f32, pitch: f32, aspect: f32) -> (Mat4, Mat4) {
133 let pivot = framing.pivot_y_milli.unwrap_or(framing.center_y_milli) as f32 / 1000.;
134 let center = framing.center_y_milli as f32 / 1000.;
135 let target = [0., pivot, 0.];
136 let d = preview::eye_offset(yaw, pitch, 10.);
137 let eye = [d[0], pivot + d[1], d[2]];
138 let half = framing.height_milli as f32 / 2000.;
139 let mut p = preview::identity();
140 p[0] = 1. / (half * aspect);
141 p[5] = 1. / half;
142 p[13] = (pivot - center) / half;
143 p[10] = -2. / (crate::production::FAR - crate::production::NEAR);
144 p[14] = -(crate::production::FAR + crate::production::NEAR)
145 / (crate::production::FAR - crate::production::NEAR);
146 (preview::view_matrix(eye, target), p)
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use crate::preview::{self, PreviewCharacter, SceneNode};
153
154 fn node(name: &str, parent: Option<usize>, y: f32) -> SceneNode {
155 SceneNode {
156 name: name.into(),
157 parent,
158 translation: [0., y, 0.],
159 rotation: [0., 0., 0., 1.],
160 scale: [1., 1., 1.],
161 }
162 }
163
164 fn rig(names: &[&str], parents: &[Option<usize>], ys: &[f32]) -> PreviewCharacter {
165 assert_eq!(names.len(), parents.len());
166 assert_eq!(names.len(), ys.len());
167 let nodes = names
168 .iter()
169 .zip(parents)
170 .zip(ys)
171 .map(|((name, parent), y)| node(name, *parent, *y))
172 .collect();
173 PreviewCharacter {
174 nodes,
175 joint_nodes: (0..names.len()).collect(),
176 joint_names: names.iter().map(|n| (*n).into()).collect(),
177 inverse_binds: vec![preview::identity(); names.len()],
178 positions: Vec::new(),
179 normals: Vec::new(),
180 source_normals_complete: true,
181 joints: Vec::new(),
182 weights: Vec::new(),
183 indices: Vec::new(),
184 base_color: [1., 1., 1., 1.],
185 colors: Vec::new(),
186 uvs: Vec::new(),
187 surfaces: Vec::new(),
188 clips: Vec::new(),
189 rig: preview::BodyRig::Skinned,
190 }
191 }
192
193 fn project(view: Mat4, projection: Mat4, world: [f32; 3]) -> [f32; 3] {
194 let eye = [
195 view[0] * world[0] + view[4] * world[1] + view[8] * world[2] + view[12],
196 view[1] * world[0] + view[5] * world[1] + view[9] * world[2] + view[13],
197 view[2] * world[0] + view[6] * world[1] + view[10] * world[2] + view[14],
198 ];
199 [
200 projection[0] * eye[0] + projection[12],
201 projection[5] * eye[1] + projection[13],
202 projection[10] * eye[2] + projection[14],
203 ]
204 }
205
206 #[test]
207 fn neck_name_variants_and_missing_ambiguous() {
208 let cases = [
209 ("Neck", 1.5),
210 ("mixamorigNeck", 1.6),
211 ("Bip001 Neck", 1.7),
212 ("Bip01 Neck", 1.55),
213 ("CC_Base_Neck", 1.45),
214 ("neck2", 1.42),
215 ];
216 for (name, y) in cases {
217 let body = rig(&["Hips", name], &[None, Some(0)], &[1.0, y - 1.0]);
218 let (joint, milli) = neck_height(&body).unwrap();
219 assert_eq!(body.joint_names[joint], name);
220 assert_eq!(milli, (y * 1000.).round() as i32);
221 }
222 assert!(neck_height(&rig(&["Hips", "Head"], &[None, Some(0)], &[1., 0.5])).is_err());
223 let ambiguous = rig(
224 &["Hips", "Neck", "Prop Neck"],
225 &[None, Some(0), None],
226 &[1., 0.5, 2.],
227 );
228 assert!(neck_height(&ambiguous).is_err());
229 let chain = rig(
230 &["Hips", "Neck", "Neck1"],
231 &[None, Some(0), Some(1)],
232 &[1., 0.5, 0.1],
233 );
234 let (joint, _) = neck_height(&chain).unwrap();
235 assert_eq!(chain.joint_names[joint], "Neck");
236 }
237
238 #[test]
239 fn resolve_manual_override_wins_and_fallback_uses_center() {
240 let body = rig(&["Hips", "Neck"], &[None, Some(0)], &[1.0, 0.5]);
241 let auto = resolve(
242 &body,
243 CameraFraming {
244 height_milli: 2600,
245 center_y_milli: 1000,
246 pivot_y_milli: None,
247 },
248 );
249 assert!(matches!(auto.source, PivotSource::Neck { .. }));
250 assert_eq!(auto.framing.pivot_y_milli, Some(1500));
251 let manual = resolve(
252 &body,
253 CameraFraming {
254 height_milli: 2600,
255 center_y_milli: 1000,
256 pivot_y_milli: Some(1800),
257 },
258 );
259 assert_eq!(manual.source, PivotSource::Manual);
260 assert_eq!(manual.framing.pivot_y_milli, Some(1800));
261 let missing = resolve(
262 &rig(&["Hips"], &[None], &[1.0]),
263 CameraFraming {
264 height_milli: 2600,
265 center_y_milli: 900,
266 pivot_y_milli: None,
267 },
268 );
269 assert!(matches!(missing.source, PivotSource::Fallback { .. }));
270 assert_eq!(missing.framing.pivot_y_milli, Some(900));
271 }
272
273 #[test]
274 fn zero_pitch_keeps_image_center_when_pivot_differs() {
275 let framing = CameraFraming {
276 height_milli: 2600,
277 center_y_milli: 1000,
278 pivot_y_milli: Some(1600),
279 };
280 for yaw in [0., 90., 180., 270.] {
281 let (view, projection) = matrices(framing, yaw, 0., 1.);
282 let center = project(view, projection, [0., 1., 0.]);
283 assert!(
284 center[0].abs() < 1e-4 && center[1].abs() < 1e-4,
285 "yaw {yaw}: center NDC {center:?}"
286 );
287 let feet = project(view, projection, [0., 0., 0.]);
288 assert!(
289 (feet[1] + 1000. / 1300.).abs() < 1e-4,
290 "yaw {yaw}: feet NDC y {}",
291 feet[1]
292 );
293 }
294 }
295
296 #[test]
297 fn all_60_angles_share_pivot_and_neck_projects_stably() {
298 let framing = CameraFraming {
299 height_milli: 3000,
300 center_y_milli: 1100,
301 pivot_y_milli: Some(1500),
302 };
303 let mut pivot_ndcs = Vec::new();
304 for pitch in crate::preview::PAPERDOLL_PITCHES_DEG {
305 for yaw in (0..12).map(|i| i as f32 * 30.) {
306 let (view, projection) = matrices(framing, yaw, pitch, 64. / 96.);
307 let pivot = project(view, projection, [0., 1.5, 0.]);
308 assert!(
309 pivot[0].abs() < 1e-4,
310 "pivot NDC x drifted at yaw {yaw} pitch {pitch}: {pivot:?}"
311 );
312 pivot_ndcs.push(pivot[1]);
313 let old = CameraFraming {
314 pivot_y_milli: Some(framing.center_y_milli),
315 ..framing
316 };
317 let (old_view, old_projection) = matrices(old, yaw, pitch, 64. / 96.);
318 assert_ne!(
319 view, old_view,
320 "eye must move with pivot at yaw {yaw} pitch {pitch}"
321 );
322 let neck_new = project(view, projection, [0., 1.5, 0.]);
323 let neck_old = project(old_view, old_projection, [0., 1.5, 0.]);
324 if pitch.abs() > 1. {
325 // Ortho Y-compensation cancels at pitch 0; pitched residual is δ/half*(1-cos θ).
326 assert!(
327 (neck_new[1] - neck_old[1]).abs() > 0.02,
328 "pitched neck residual too small at pitch {pitch}: {} vs {}",
329 neck_new[1],
330 neck_old[1]
331 );
332 assert!(
333 (neck_new[2] - neck_old[2]).abs() > 1e-4
334 || (view[13] - old_view[13]).abs() > 1e-4,
335 "view translation/depth must change with pivot"
336 );
337 } else {
338 assert!((neck_new[1] - neck_old[1]).abs() < 1e-4);
339 }
340 }
341 }
342 assert_eq!(pivot_ndcs.len(), 60);
343 let first = pivot_ndcs[0];
344 assert!(pivot_ndcs.iter().all(|y| (y - first).abs() < 1e-4));
345 }
346
347 #[test]
348 fn male_fixture_neck_is_detected() {
349 let body = preview::load_gltf_file(
350 &std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
351 .join("tests/fixtures/bodies/test-body-male.glb"),
352 )
353 .unwrap();
354 let resolved = resolve(&body, CameraFraming::default());
355 assert!(matches!(
356 resolved.source,
357 PivotSource::Neck {
358 joint_name: ref name,
359 ..
360 } if name == "Neck"
361 ));
362 assert_eq!(resolved.framing.pivot_y_milli, Some(1460));
363 assert_ne!(
364 resolved.framing.pivot_y_milli.unwrap(),
365 resolved.framing.center_y_milli
366 );
367 }
368}