BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / camera.rs

camera.rs

camera.rs 12.7 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 let candidates = body
70 .joint_names
71 .iter()
72 .enumerate()
73 .filter(|(_, n)| neck_name(n))
74 .map(|(j, _)| j)
75 .collect::<Vec<_>>();
76 if candidates.is_empty() {
77 return Err("Geen nekbot gevonden".into());
78 }
79 let roots = candidates
80 .iter()
81 .copied()
82 .filter(|j| {
83 candidates.iter().all(|other| {
84 match (body.joint_nodes.get(*j), body.joint_nodes.get(*other)) {
85 (Some(a), Some(b)) => ancestor(body, *a, *b),
86 _ => false,
87 }
88 })
89 })
90 .collect::<Vec<_>>();
91 if roots.len() != 1 {
92 return Err("Meerdere losstaande/ongeldige nekketens".into());
93 }
94 let joint = roots[0];
95 let world = preview::rest_joint_world(body, joint).ok_or("Ongeldige rustpose van nekbot")?;
96 let height = (world[13] * 1000.).round();
97 if !(-10000.0..=10000.0).contains(&height) {
98 return Err("Nekhoogte buiten -10…10 m".into());
99 }
100 Ok((joint, height as i32))
101}
102pub fn resolve(body: &PreviewCharacter, mut framing: CameraFraming) -> Resolved {
103 if framing.pivot_y_milli.is_some() {
104 return Resolved {
105 framing,
106 source: PivotSource::Manual,
107 };
108 }
109 let source = match neck_height(body) {
110 Ok((joint, y)) => {
111 framing.pivot_y_milli = Some(y);
112 PivotSource::Neck {
113 joint_index: joint,
114 joint_name: body.joint_names[joint].clone(),
115 }
116 }
117 Err(reason) => {
118 framing.pivot_y_milli = Some(framing.center_y_milli);
119 PivotSource::Fallback { reason }
120 }
121 };
122 Resolved { framing, source }
123}
124/// Translation around the neck is separate from the image's vertical framing.
125/// Keeping the orthographic offset constant makes the orbit pivot pixel stable
126/// for every yaw/pitch without cropping the feet merely by raising the pivot.
127pub fn matrices(framing: CameraFraming, yaw: f32, pitch: f32, aspect: f32) -> (Mat4, Mat4) {
128 let pivot = framing.pivot_y_milli.unwrap_or(framing.center_y_milli) as f32 / 1000.;
129 let center = framing.center_y_milli as f32 / 1000.;
130 let target = [0., pivot, 0.];
131 let d = preview::eye_offset(yaw, pitch, 10.);
132 let eye = [d[0], pivot + d[1], d[2]];
133 let half = framing.height_milli as f32 / 2000.;
134 let mut p = preview::identity();
135 p[0] = 1. / (half * aspect);
136 p[5] = 1. / half;
137 p[13] = (pivot - center) / half;
138 p[10] = -2. / (crate::production::FAR - crate::production::NEAR);
139 p[14] = -(crate::production::FAR + crate::production::NEAR)
140 / (crate::production::FAR - crate::production::NEAR);
141 (preview::view_matrix(eye, target), p)
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147 use crate::preview::{self, PreviewCharacter, SceneNode};
148
149 fn node(name: &str, parent: Option<usize>, y: f32) -> SceneNode {
150 SceneNode {
151 name: name.into(),
152 parent,
153 translation: [0., y, 0.],
154 rotation: [0., 0., 0., 1.],
155 scale: [1., 1., 1.],
156 }
157 }
158
159 fn rig(names: &[&str], parents: &[Option<usize>], ys: &[f32]) -> PreviewCharacter {
160 assert_eq!(names.len(), parents.len());
161 assert_eq!(names.len(), ys.len());
162 let nodes = names
163 .iter()
164 .zip(parents)
165 .zip(ys)
166 .map(|((name, parent), y)| node(name, *parent, *y))
167 .collect();
168 PreviewCharacter {
169 nodes,
170 joint_nodes: (0..names.len()).collect(),
171 joint_names: names.iter().map(|n| (*n).into()).collect(),
172 inverse_binds: vec![preview::identity(); names.len()],
173 positions: Vec::new(),
174 normals: Vec::new(),
175 source_normals_complete: true,
176 joints: Vec::new(),
177 weights: Vec::new(),
178 indices: Vec::new(),
179 base_color: [1., 1., 1., 1.],
180 colors: Vec::new(),
181 uvs: Vec::new(),
182 surfaces: Vec::new(),
183 clips: Vec::new(),
184 }
185 }
186
187 fn project(view: Mat4, projection: Mat4, world: [f32; 3]) -> [f32; 3] {
188 let eye = [
189 view[0] * world[0] + view[4] * world[1] + view[8] * world[2] + view[12],
190 view[1] * world[0] + view[5] * world[1] + view[9] * world[2] + view[13],
191 view[2] * world[0] + view[6] * world[1] + view[10] * world[2] + view[14],
192 ];
193 [
194 projection[0] * eye[0] + projection[12],
195 projection[5] * eye[1] + projection[13],
196 projection[10] * eye[2] + projection[14],
197 ]
198 }
199
200 #[test]
201 fn neck_name_variants_and_missing_ambiguous() {
202 let cases = [
203 ("Neck", 1.5),
204 ("mixamorigNeck", 1.6),
205 ("Bip001 Neck", 1.7),
206 ("Bip01 Neck", 1.55),
207 ("CC_Base_Neck", 1.45),
208 ("neck2", 1.42),
209 ];
210 for (name, y) in cases {
211 let body = rig(&["Hips", name], &[None, Some(0)], &[1.0, y - 1.0]);
212 let (joint, milli) = neck_height(&body).unwrap();
213 assert_eq!(body.joint_names[joint], name);
214 assert_eq!(milli, (y * 1000.).round() as i32);
215 }
216 assert!(neck_height(&rig(&["Hips", "Head"], &[None, Some(0)], &[1., 0.5])).is_err());
217 let ambiguous = rig(
218 &["Hips", "Neck", "Prop Neck"],
219 &[None, Some(0), None],
220 &[1., 0.5, 2.],
221 );
222 assert!(neck_height(&ambiguous).is_err());
223 let chain = rig(
224 &["Hips", "Neck", "Neck1"],
225 &[None, Some(0), Some(1)],
226 &[1., 0.5, 0.1],
227 );
228 let (joint, _) = neck_height(&chain).unwrap();
229 assert_eq!(chain.joint_names[joint], "Neck");
230 }
231
232 #[test]
233 fn resolve_manual_override_wins_and_fallback_uses_center() {
234 let body = rig(&["Hips", "Neck"], &[None, Some(0)], &[1.0, 0.5]);
235 let auto = resolve(
236 &body,
237 CameraFraming {
238 height_milli: 2600,
239 center_y_milli: 1000,
240 pivot_y_milli: None,
241 },
242 );
243 assert!(matches!(auto.source, PivotSource::Neck { .. }));
244 assert_eq!(auto.framing.pivot_y_milli, Some(1500));
245 let manual = resolve(
246 &body,
247 CameraFraming {
248 height_milli: 2600,
249 center_y_milli: 1000,
250 pivot_y_milli: Some(1800),
251 },
252 );
253 assert_eq!(manual.source, PivotSource::Manual);
254 assert_eq!(manual.framing.pivot_y_milli, Some(1800));
255 let missing = resolve(
256 &rig(&["Hips"], &[None], &[1.0]),
257 CameraFraming {
258 height_milli: 2600,
259 center_y_milli: 900,
260 pivot_y_milli: None,
261 },
262 );
263 assert!(matches!(missing.source, PivotSource::Fallback { .. }));
264 assert_eq!(missing.framing.pivot_y_milli, Some(900));
265 }
266
267 #[test]
268 fn zero_pitch_keeps_image_center_when_pivot_differs() {
269 let framing = CameraFraming {
270 height_milli: 2600,
271 center_y_milli: 1000,
272 pivot_y_milli: Some(1600),
273 };
274 for yaw in [0., 90., 180., 270.] {
275 let (view, projection) = matrices(framing, yaw, 0., 1.);
276 let center = project(view, projection, [0., 1., 0.]);
277 assert!(
278 center[0].abs() < 1e-4 && center[1].abs() < 1e-4,
279 "yaw {yaw}: center NDC {center:?}"
280 );
281 let feet = project(view, projection, [0., 0., 0.]);
282 assert!(
283 (feet[1] + 1000. / 1300.).abs() < 1e-4,
284 "yaw {yaw}: feet NDC y {}",
285 feet[1]
286 );
287 }
288 }
289
290 #[test]
291 fn all_36_angles_share_pivot_and_neck_projects_stably() {
292 let framing = CameraFraming {
293 height_milli: 3000,
294 center_y_milli: 1100,
295 pivot_y_milli: Some(1500),
296 };
297 let mut pivot_ndcs = Vec::new();
298 for pitch in [-30., 0., 30.] {
299 for yaw in (0..12).map(|i| i as f32 * 30.) {
300 let (view, projection) = matrices(framing, yaw, pitch, 64. / 96.);
301 let pivot = project(view, projection, [0., 1.5, 0.]);
302 assert!(
303 pivot[0].abs() < 1e-4,
304 "pivot NDC x drifted at yaw {yaw} pitch {pitch}: {pivot:?}"
305 );
306 pivot_ndcs.push(pivot[1]);
307 let old = CameraFraming {
308 pivot_y_milli: Some(framing.center_y_milli),
309 ..framing
310 };
311 let (old_view, old_projection) = matrices(old, yaw, pitch, 64. / 96.);
312 assert_ne!(
313 view, old_view,
314 "eye must move with pivot at yaw {yaw} pitch {pitch}"
315 );
316 let neck_new = project(view, projection, [0., 1.5, 0.]);
317 let neck_old = project(old_view, old_projection, [0., 1.5, 0.]);
318 if pitch.abs() > 1. {
319 // Ortho Y-compensation cancels at pitch 0; pitched residual is δ/half*(1-cos θ).
320 assert!(
321 (neck_new[1] - neck_old[1]).abs() > 0.02,
322 "pitched neck residual too small at pitch {pitch}: {} vs {}",
323 neck_new[1],
324 neck_old[1]
325 );
326 assert!(
327 (neck_new[2] - neck_old[2]).abs() > 1e-4
328 || (view[13] - old_view[13]).abs() > 1e-4,
329 "view translation/depth must change with pivot"
330 );
331 } else {
332 assert!((neck_new[1] - neck_old[1]).abs() < 1e-4);
333 }
334 }
335 }
336 assert_eq!(pivot_ndcs.len(), 36);
337 let first = pivot_ndcs[0];
338 assert!(pivot_ndcs.iter().all(|y| (y - first).abs() < 1e-4));
339 }
340
341 #[test]
342 fn male_fixture_neck_is_detected() {
343 let body = preview::load_gltf_file(
344 &std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
345 .join("tests/fixtures/bodies/test-body-male.glb"),
346 )
347 .unwrap();
348 let resolved = resolve(&body, CameraFraming::default());
349 assert!(matches!(
350 resolved.source,
351 PivotSource::Neck {
352 joint_name: ref name,
353 ..
354 } if name == "Neck"
355 ));
356 assert_eq!(resolved.framing.pivot_y_milli, Some(1460));
357 assert_ne!(
358 resolved.framing.pivot_y_milli.unwrap(),
359 resolved.framing.center_y_milli
360 );
361 }
362}