BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / camera.rs

camera.rs

camera.rs 15.4 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.
132/// Orthographic camera distance from the orbit pivot (metres).
133pub const CAMERA_DISTANCE: f32 = 10.;
134/// Minimum half-depth of the per-sex depth interval (metres).
135pub const MIN_DEPTH_HALF: f32 = 0.5;
136/// How 16-bit depth samples map back to camera distance (t963u/t964u).
137pub const DEPTH_ENCODING: &str = "linear: distance = depth_near + (sample/65535)*(depth_far-depth_near); 16-bit gray+alpha; clamped to [near,far]";
138/// Fixed, narrow depth interval around the orbit pivot derived only from the
139/// per-sex framing: `CAMERA_DISTANCE ∓ R`, R = max(height × 0.5 + |pivot −
140/// center|, 0.5 m) — the radius around the pivot that reaches the frame's
141/// farthest edge, so geometry inside the pitch-0 frame stays in range at
142/// pitch ±60 too. Shared by every clip, frame, view and equipment of that sex.
143pub fn depth_range(framing: CameraFraming) -> (f32, f32) {
144 let pivot = framing.pivot_y_milli.unwrap_or(framing.center_y_milli);
145 let offset = (i64::from(pivot) - i64::from(framing.center_y_milli)).unsigned_abs();
146 let r = ((u64::from(framing.height_milli) / 2 + offset) as f32 / 1000.).max(MIN_DEPTH_HALF);
147 (CAMERA_DISTANCE - r, CAMERA_DISTANCE + r)
148}
149pub fn matrices(framing: CameraFraming, yaw: f32, pitch: f32, aspect: f32) -> (Mat4, Mat4) {
150 let pivot = framing.pivot_y_milli.unwrap_or(framing.center_y_milli) as f32 / 1000.;
151 let center = framing.center_y_milli as f32 / 1000.;
152 let target = [0., pivot, 0.];
153 let d = preview::eye_offset(yaw, pitch, CAMERA_DISTANCE);
154 let (near, far) = depth_range(framing);
155 let eye = [d[0], pivot + d[1], d[2]];
156 let half = framing.height_milli as f32 / 2000.;
157 let mut p = preview::identity();
158 p[0] = 1. / (half * aspect);
159 p[5] = 1. / half;
160 p[13] = (pivot - center) / half;
161 p[10] = -2. / (far - near);
162 p[14] = -(far + near) / (far - near);
163 (preview::view_matrix(eye, target), p)
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use crate::preview::{self, PreviewCharacter, SceneNode};
170
171 fn node(name: &str, parent: Option<usize>, y: f32) -> SceneNode {
172 SceneNode {
173 name: name.into(),
174 parent,
175 translation: [0., y, 0.],
176 rotation: [0., 0., 0., 1.],
177 scale: [1., 1., 1.],
178 }
179 }
180
181 fn rig(names: &[&str], parents: &[Option<usize>], ys: &[f32]) -> PreviewCharacter {
182 assert_eq!(names.len(), parents.len());
183 assert_eq!(names.len(), ys.len());
184 let nodes = names
185 .iter()
186 .zip(parents)
187 .zip(ys)
188 .map(|((name, parent), y)| node(name, *parent, *y))
189 .collect();
190 PreviewCharacter {
191 nodes,
192 joint_nodes: (0..names.len()).collect(),
193 joint_names: names.iter().map(|n| (*n).into()).collect(),
194 inverse_binds: vec![preview::identity(); names.len()],
195 positions: Vec::new(),
196 normals: Vec::new(),
197 source_normals_complete: true,
198 joints: Vec::new(),
199 weights: Vec::new(),
200 indices: Vec::new(),
201 base_color: [1., 1., 1., 1.],
202 colors: Vec::new(),
203 uvs: Vec::new(),
204 surfaces: Vec::new(),
205 clips: Vec::new(),
206 rig: preview::BodyRig::Skinned,
207 }
208 }
209
210 fn project(view: Mat4, projection: Mat4, world: [f32; 3]) -> [f32; 3] {
211 let eye = [
212 view[0] * world[0] + view[4] * world[1] + view[8] * world[2] + view[12],
213 view[1] * world[0] + view[5] * world[1] + view[9] * world[2] + view[13],
214 view[2] * world[0] + view[6] * world[1] + view[10] * world[2] + view[14],
215 ];
216 [
217 projection[0] * eye[0] + projection[12],
218 projection[5] * eye[1] + projection[13],
219 projection[10] * eye[2] + projection[14],
220 ]
221 }
222
223 #[test]
224 fn depth_range_is_narrow_fixed_and_maps_pivot_to_middle() {
225 let f = CameraFraming::default();
226 let (near, far) = depth_range(f);
227 assert!((near - 8.7).abs() < 1e-5 && (far - 11.3).abs() < 1e-5);
228 let small = CameraFraming {
229 height_milli: 400,
230 ..f
231 };
232 assert_eq!(depth_range(small), (9.5, 10.5));
233 let offset = CameraFraming {
234 pivot_y_milli: Some(1460),
235 ..f
236 };
237 let (near, far) = depth_range(offset);
238 assert!((near - 8.24).abs() < 1e-5 && (far - 11.76).abs() < 1e-5);
239 // Pivot projects to NDC z = 0 (gray 32768) for every view.
240 let f = CameraFraming {
241 pivot_y_milli: Some(1460),
242 ..f
243 };
244 for (yaw, pitch) in [(0., 0.), (90., 30.), (210., -60.), (330., 60.)] {
245 let (view, projection) = matrices(f, yaw, pitch, 1.);
246 let p = project(view, projection, [0., 1.46, 0.]);
247 assert!(p[2].abs() < 1e-4, "{p:?}");
248 // 0.5 m in front of the pivot uses a large part of the range.
249 let d = preview::eye_offset(yaw, pitch, 0.5);
250 let q = project(view, projection, [d[0], 1.46 + d[1], d[2]]);
251 assert!((q[2] + 0.5 / 1.76).abs() < 1e-3, "{q:?}");
252 }
253 }
254
255 #[test]
256 fn neck_name_variants_and_missing_ambiguous() {
257 let cases = [
258 ("Neck", 1.5),
259 ("mixamorigNeck", 1.6),
260 ("Bip001 Neck", 1.7),
261 ("Bip01 Neck", 1.55),
262 ("CC_Base_Neck", 1.45),
263 ("neck2", 1.42),
264 ];
265 for (name, y) in cases {
266 let body = rig(&["Hips", name], &[None, Some(0)], &[1.0, y - 1.0]);
267 let (joint, milli) = neck_height(&body).unwrap();
268 assert_eq!(body.joint_names[joint], name);
269 assert_eq!(milli, (y * 1000.).round() as i32);
270 }
271 assert!(neck_height(&rig(&["Hips", "Head"], &[None, Some(0)], &[1., 0.5])).is_err());
272 let ambiguous = rig(
273 &["Hips", "Neck", "Prop Neck"],
274 &[None, Some(0), None],
275 &[1., 0.5, 2.],
276 );
277 assert!(neck_height(&ambiguous).is_err());
278 let chain = rig(
279 &["Hips", "Neck", "Neck1"],
280 &[None, Some(0), Some(1)],
281 &[1., 0.5, 0.1],
282 );
283 let (joint, _) = neck_height(&chain).unwrap();
284 assert_eq!(chain.joint_names[joint], "Neck");
285 }
286
287 #[test]
288 fn resolve_manual_override_wins_and_fallback_uses_center() {
289 let body = rig(&["Hips", "Neck"], &[None, Some(0)], &[1.0, 0.5]);
290 let auto = resolve(
291 &body,
292 CameraFraming {
293 height_milli: 2600,
294 center_y_milli: 1000,
295 pivot_y_milli: None,
296 },
297 );
298 assert!(matches!(auto.source, PivotSource::Neck { .. }));
299 assert_eq!(auto.framing.pivot_y_milli, Some(1500));
300 let manual = resolve(
301 &body,
302 CameraFraming {
303 height_milli: 2600,
304 center_y_milli: 1000,
305 pivot_y_milli: Some(1800),
306 },
307 );
308 assert_eq!(manual.source, PivotSource::Manual);
309 assert_eq!(manual.framing.pivot_y_milli, Some(1800));
310 let missing = resolve(
311 &rig(&["Hips"], &[None], &[1.0]),
312 CameraFraming {
313 height_milli: 2600,
314 center_y_milli: 900,
315 pivot_y_milli: None,
316 },
317 );
318 assert!(matches!(missing.source, PivotSource::Fallback { .. }));
319 assert_eq!(missing.framing.pivot_y_milli, Some(900));
320 }
321
322 #[test]
323 fn zero_pitch_keeps_image_center_when_pivot_differs() {
324 let framing = CameraFraming {
325 height_milli: 2600,
326 center_y_milli: 1000,
327 pivot_y_milli: Some(1600),
328 };
329 for yaw in [0., 90., 180., 270.] {
330 let (view, projection) = matrices(framing, yaw, 0., 1.);
331 let center = project(view, projection, [0., 1., 0.]);
332 assert!(
333 center[0].abs() < 1e-4 && center[1].abs() < 1e-4,
334 "yaw {yaw}: center NDC {center:?}"
335 );
336 let feet = project(view, projection, [0., 0., 0.]);
337 assert!(
338 (feet[1] + 1000. / 1300.).abs() < 1e-4,
339 "yaw {yaw}: feet NDC y {}",
340 feet[1]
341 );
342 }
343 }
344
345 #[test]
346 fn all_60_angles_share_pivot_and_neck_projects_stably() {
347 let framing = CameraFraming {
348 height_milli: 3000,
349 center_y_milli: 1100,
350 pivot_y_milli: Some(1500),
351 };
352 let mut pivot_ndcs = Vec::new();
353 for pitch in crate::preview::PAPERDOLL_PITCHES_DEG {
354 for yaw in (0..12).map(|i| i as f32 * 30.) {
355 let (view, projection) = matrices(framing, yaw, pitch, 64. / 96.);
356 let pivot = project(view, projection, [0., 1.5, 0.]);
357 assert!(
358 pivot[0].abs() < 1e-4,
359 "pivot NDC x drifted at yaw {yaw} pitch {pitch}: {pivot:?}"
360 );
361 pivot_ndcs.push(pivot[1]);
362 let old = CameraFraming {
363 pivot_y_milli: Some(framing.center_y_milli),
364 ..framing
365 };
366 let (old_view, old_projection) = matrices(old, yaw, pitch, 64. / 96.);
367 assert_ne!(
368 view, old_view,
369 "eye must move with pivot at yaw {yaw} pitch {pitch}"
370 );
371 let neck_new = project(view, projection, [0., 1.5, 0.]);
372 let neck_old = project(old_view, old_projection, [0., 1.5, 0.]);
373 if pitch.abs() > 1. {
374 // Ortho Y-compensation cancels at pitch 0; pitched residual is δ/half*(1-cos θ).
375 assert!(
376 (neck_new[1] - neck_old[1]).abs() > 0.02,
377 "pitched neck residual too small at pitch {pitch}: {} vs {}",
378 neck_new[1],
379 neck_old[1]
380 );
381 assert!(
382 (neck_new[2] - neck_old[2]).abs() > 1e-4
383 || (view[13] - old_view[13]).abs() > 1e-4,
384 "view translation/depth must change with pivot"
385 );
386 } else {
387 assert!((neck_new[1] - neck_old[1]).abs() < 1e-4);
388 }
389 }
390 }
391 assert_eq!(pivot_ndcs.len(), 60);
392 let first = pivot_ndcs[0];
393 assert!(pivot_ndcs.iter().all(|y| (y - first).abs() < 1e-4));
394 }
395
396 #[test]
397 fn male_fixture_neck_is_detected() {
398 let body = preview::load_gltf_file(
399 &std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
400 .join("tests/fixtures/bodies/test-body-male.glb"),
401 )
402 .unwrap();
403 let resolved = resolve(&body, CameraFraming::default());
404 assert!(matches!(
405 resolved.source,
406 PivotSource::Neck {
407 joint_name: ref name,
408 ..
409 } if name == "Neck"
410 ));
411 assert_eq!(resolved.framing.pivot_y_milli, Some(1460));
412 assert_ne!(
413 resolved.framing.pivot_y_milli.unwrap(),
414 resolved.framing.center_y_milli
415 );
416 }
417}