BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / preview.rs

preview.rs

preview.rs 29.9 KiB Download Raw
1//! CPU skeleton sampling and orbit camera for the live glTF preview.
2//!
3//! This is not the paperdoll export. The export camera is orthographic and
4//! still out of scope. The interactive camera can snap to the same three
5//! pitches and twelve yaws. The character forward axis of the test bodies is -Z,
6//! so yaw 0 places the camera on -Z, looking at the body.
7
8use std::path::Path;
9
10pub const PAPERDOLL_PITCHES_DEG: [f32; 3] = [-30.0, 0.0, 30.0];
11pub const PAPERDOLL_YAW_COUNT: usize = 12;
12pub const MAX_JOINTS: usize = 128;
13
14pub type Mat4 = [f32; 16];
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum PreviewSex {
18 Male,
19 Female,
20}
21
22#[derive(Debug, Clone)]
23pub struct PreviewControls {
24 pub sex: PreviewSex,
25 pub clip_name: String,
26 pub time: f32,
27 pub playing: bool,
28 pub yaw_deg: f32,
29 pub pitch_deg: f32,
30 pub distance: f32,
31}
32
33impl Default for PreviewControls {
34 fn default() -> Self {
35 Self {
36 sex: PreviewSex::Male,
37 clip_name: "walk".to_string(),
38 time: 0.0,
39 playing: true,
40 yaw_deg: 0.0,
41 pitch_deg: 0.0,
42 distance: 3.4,
43 }
44 }
45}
46
47impl PreviewControls {
48 pub fn drag(&mut self, dx_px: f32, dy_px: f32) {
49 self.yaw_deg = wrap_deg(self.yaw_deg - dx_px * 0.45);
50 self.pitch_deg = (self.pitch_deg + dy_px * 0.35).clamp(-80.0, 80.0);
51 }
52
53 pub fn zoom(&mut self, scroll_y: f32) {
54 let factor = (-scroll_y * 0.0015).exp();
55 self.distance = (self.distance * factor).clamp(0.8, 12.0);
56 }
57
58 pub fn snap_yaw_index(&mut self, index: i32) {
59 let wrapped = index.rem_euclid(PAPERDOLL_YAW_COUNT as i32) as f32;
60 self.yaw_deg = wrapped * 30.0;
61 }
62
63 pub fn snap_pitch_row(&mut self, row: i32) {
64 let row = row.clamp(0, 2) as usize;
65 self.pitch_deg = PAPERDOLL_PITCHES_DEG[row];
66 }
67
68 pub fn advance(&mut self, dt: f32, duration: f32) {
69 if self.playing && duration > 0.0 {
70 self.time = wrap_time(self.time + dt, duration);
71 }
72 }
73
74 pub fn select_clip(&mut self, name: &str) {
75 if self.clip_name != name {
76 self.clip_name = name.to_string();
77 self.time = 0.0;
78 }
79 }
80}
81
82#[derive(Debug, Clone)]
83pub struct PreviewCharacter {
84 pub nodes: Vec<SceneNode>,
85 pub joint_nodes: Vec<usize>,
86 pub joint_names: Vec<String>,
87 pub inverse_binds: Vec<Mat4>,
88 pub positions: Vec<[f32; 3]>,
89 pub normals: Vec<[f32; 3]>,
90 /// A placement-only fallback must never masquerade as a production normal map.
91 pub source_normals_complete: bool,
92 pub joints: Vec<[u16; 4]>,
93 pub weights: Vec<[f32; 4]>,
94 pub indices: Vec<u32>,
95 pub base_color: [f32; 4],
96 pub colors: Vec<[f32; 4]>,
97 pub uvs: Vec<[f32; 2]>,
98 pub surfaces: Vec<BodySurface>,
99 pub clips: Vec<PreviewClip>,
100}
101
102#[derive(Debug, Clone)]
103pub struct BodySurface {
104 pub first: usize,
105 pub count: usize,
106 pub color: [f32; 4],
107 pub albedo: Option<std::sync::Arc<crate::equipment::Texture>>,
108 pub cutoff: f32,
109 pub flat_normals: bool,
110}
111
112#[derive(Debug, Clone)]
113pub struct SceneNode {
114 pub name: String,
115 pub parent: Option<usize>,
116 pub translation: [f32; 3],
117 pub rotation: [f32; 4],
118 pub scale: [f32; 3],
119}
120
121#[derive(Debug, Clone)]
122pub struct PreviewClip {
123 pub name: String,
124 pub duration: f32,
125 pub channels: Vec<AnimChannel>,
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum ChannelProperty {
130 Translation,
131 Rotation,
132 Scale,
133}
134
135#[derive(Debug, Clone)]
136pub struct AnimChannel {
137 pub node: usize,
138 pub property: ChannelProperty,
139 pub times: Vec<f32>,
140 pub values: Vec<f32>,
141}
142
143/// Whether a body model path is drawable by the live viewport and production batch.
144pub fn is_drawable_body(path: &Path) -> bool {
145 matches!(
146 path.extension()
147 .and_then(|e| e.to_str())
148 .map(|e| e.to_ascii_lowercase())
149 .as_deref(),
150 Some("gltf" | "glb" | "fbx")
151 )
152}
153
154/// Load a skinned body for the live viewport and the production batch.
155/// glTF/GLB use the glTF reader; FBX goes through the normalized
156/// `fbx_scene` loader (axis/unit conversion is applied there exactly once).
157pub fn load_body_file(path: &Path) -> Result<PreviewCharacter, String> {
158 match path
159 .extension()
160 .and_then(|e| e.to_str())
161 .map(|e| e.to_ascii_lowercase())
162 .as_deref()
163 {
164 Some("gltf" | "glb") => load_gltf_file(path),
165 Some("fbx") => crate::fbx_scene::load_fbx_drawable(path).map(|scene| scene.character),
166 _ => Err(format!(
167 "{} is geen tekenbaar lichaam; gebruik .glb, .gltf of .fbx",
168 path.display()
169 )),
170 }
171}
172
173pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {
174 let (document, buffers, images) = gltf::import(path).map_err(|err| err.to_string())?;
175 let mut nodes = Vec::new();
176 let mut parents = vec![None; document.nodes().len()];
177 for node in document.nodes() {
178 for child in node.children() {
179 parents[child.index()] = Some(node.index());
180 }
181 }
182 for node in document.nodes() {
183 let (translation, rotation, scale) = node.transform().decomposed();
184 nodes.push(SceneNode {
185 name: node.name().unwrap_or("").to_string(),
186 parent: parents[node.index()],
187 translation,
188 rotation,
189 scale,
190 });
191 }
192 let skin = document
193 .skins()
194 .next()
195 .ok_or_else(|| "het glTF-bestand heeft geen skeleton".to_string())?;
196 if skin.joints().len() > MAX_JOINTS {
197 return Err(format!(
198 "het skeleton heeft {} joints; de preview stopt bij {MAX_JOINTS}",
199 skin.joints().len()
200 ));
201 }
202 let joint_nodes: Vec<usize> = skin.joints().map(|joint| joint.index()).collect();
203 let joint_names: Vec<String> = joint_nodes
204 .iter()
205 .map(|index| nodes[*index].name.clone())
206 .collect();
207 let reader = skin.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
208 let inverse_binds = reader
209 .read_inverse_bind_matrices()
210 .map(|iter| iter.map(columns_to_mat).collect())
211 .unwrap_or_else(|| vec![identity(); joint_nodes.len()]);
212 if inverse_binds.len() != joint_nodes.len() {
213 return Err("inverse-bindmatrices en joints verschillen".to_string());
214 }
215 let mut positions = Vec::new();
216 let mut normals = Vec::new();
217 let mut source_normals_complete = true;
218 let mut joints = Vec::new();
219 let mut weights = Vec::new();
220 let mut indices = Vec::new();
221 let mut base_color = [0.7, 0.7, 0.75, 1.0];
222 let mut colors = Vec::new();
223 let mut uvs = Vec::new();
224 let mut surfaces = Vec::new();
225 if let Some(mesh) = document.meshes().next() {
226 for primitive in mesh.primitives() {
227 if primitive.mode() != gltf::mesh::Mode::Triangles {
228 return Err("live/productie ondersteunt alleen driehoekprimitieven".into());
229 }
230 let reader = primitive.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
231 let prim_positions: Vec<[f32; 3]> = reader
232 .read_positions()
233 .map(|iter| iter.collect())
234 .unwrap_or_default();
235 source_normals_complete &= reader.read_normals().is_some();
236 let prim_normals: Vec<[f32; 3]> = reader
237 .read_normals()
238 .map(|iter| iter.collect())
239 .unwrap_or_else(|| vec![[0.0, 1.0, 0.0]; prim_positions.len()]);
240 let prim_joints: Vec<[u16; 4]> = reader
241 .read_joints(0)
242 .map(|iter| iter.into_u16().collect())
243 .unwrap_or_else(|| vec![[0, 0, 0, 0]; prim_positions.len()]);
244 let prim_weights: Vec<[f32; 4]> = reader
245 .read_weights(0)
246 .map(|iter| iter.into_f32().collect())
247 .unwrap_or_else(|| vec![[1.0, 0.0, 0.0, 0.0]; prim_positions.len()]);
248 let material = primitive.material();
249 let pbr = material.pbr_metallic_roughness();
250 let texture = pbr.base_color_texture();
251 let uvset = texture.as_ref().map(|t| t.tex_coord()).unwrap_or(0);
252 colors.extend(
253 reader
254 .read_colors(0)
255 .map(|v| v.into_rgba_f32().collect::<Vec<_>>())
256 .unwrap_or_else(|| vec![[1.; 4]; prim_positions.len()]),
257 );
258 uvs.extend(
259 reader
260 .read_tex_coords(uvset)
261 .map(|v| v.into_f32().collect::<Vec<_>>())
262 .unwrap_or_else(|| vec![[0.; 2]; prim_positions.len()]),
263 );
264 let first = indices.len();
265 let base = positions.len() as u32;
266 if let Some(read_indices) = reader.read_indices() {
267 indices.extend(read_indices.into_u32().map(|index| index + base));
268 } else {
269 indices.extend(base..base + prim_positions.len() as u32);
270 }
271 positions.extend(prim_positions);
272 normals.extend(prim_normals);
273 joints.extend(prim_joints);
274 weights.extend(prim_weights);
275 base_color = primitive
276 .material()
277 .pbr_metallic_roughness()
278 .base_color_factor();
279 let albedo = texture
280 .map(|t| {
281 let image = images
282 .get(t.texture().source().index())
283 .ok_or("ontbrekende bodytexture")?;
284 use gltf::image::Format;
285 let channels = match image.format {
286 Format::R8 => 1,
287 Format::R8G8 => 2,
288 Format::R8G8B8 => 3,
289 Format::R8G8B8A8 => 4,
290 _ => return Err("bodytexture moet 8-bit zijn".to_string()),
291 };
292 if image.width > 2048 || image.height > 2048 {
293 return Err("bodytexture groter dan 2048×2048".into());
294 }
295 let mut rgba = Vec::new();
296 for p in image.pixels.chunks_exact(channels) {
297 rgba.extend_from_slice(&match channels {
298 1 => [p[0], p[0], p[0], 255],
299 2 => [p[0], p[0], p[0], p[1]],
300 3 => [p[0], p[1], p[2], 255],
301 _ => [p[0], p[1], p[2], p[3]],
302 });
303 }
304 use std::hash::{Hash, Hasher};
305 let mut hash = std::collections::hash_map::DefaultHasher::new();
306 rgba.hash(&mut hash);
307 Ok(std::sync::Arc::new(crate::equipment::Texture {
308 content_hash: hash.finish(),
309 width: image.width,
310 height: image.height,
311 rgba,
312 }))
313 })
314 .transpose()?;
315 surfaces.push(BodySurface {
316 flat_normals: reader.read_normals().is_none(),
317 first,
318 count: indices.len() - first,
319 color: base_color,
320 albedo,
321 cutoff: if material.alpha_mode() == gltf::material::AlphaMode::Mask {
322 material.alpha_cutoff().unwrap_or(0.5)
323 } else {
324 0.001
325 },
326 });
327 }
328 }
329 if positions.is_empty() || indices.len() < 3 {
330 return Err("het glTF-bestand heeft geen tekenbare driehoeken".to_string());
331 }
332 let mut clips = Vec::new();
333 for animation in document.animations() {
334 let mut channels = Vec::new();
335 let mut duration = 0.0_f32;
336 for channel in animation.channels() {
337 let reader = channel.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
338 let times: Vec<f32> = reader
339 .read_inputs()
340 .map(|iter| iter.collect())
341 .unwrap_or_default();
342 if let Some(last) = times.last() {
343 duration = duration.max(*last);
344 }
345 let Some(outputs) = reader.read_outputs() else {
346 continue;
347 };
348 let (property, values) = match outputs {
349 gltf::animation::util::ReadOutputs::Translations(values) => {
350 (ChannelProperty::Translation, values.flatten().collect())
351 }
352 gltf::animation::util::ReadOutputs::Scales(values) => {
353 (ChannelProperty::Scale, values.flatten().collect())
354 }
355 gltf::animation::util::ReadOutputs::Rotations(values) => (
356 ChannelProperty::Rotation,
357 values.into_f32().flatten().collect(),
358 ),
359 gltf::animation::util::ReadOutputs::MorphTargetWeights(_) => continue,
360 };
361 channels.push(AnimChannel {
362 node: channel.target().node().index(),
363 property,
364 times,
365 values,
366 });
367 }
368 clips.push(PreviewClip {
369 name: animation.name().unwrap_or("naamloos").to_string(),
370 duration,
371 channels,
372 });
373 }
374 Ok(PreviewCharacter {
375 nodes,
376 joint_nodes,
377 joint_names,
378 inverse_binds,
379 positions,
380 normals,
381 source_normals_complete,
382 joints,
383 weights,
384 indices,
385 base_color,
386 colors,
387 uvs,
388 surfaces,
389 clips,
390 })
391}
392
393pub fn sample_skin_matrices(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec<Mat4> {
394 let locals = sample_local_matrices(character, clip_name, time);
395 let worlds = world_matrices(&character.nodes, &locals);
396 character
397 .joint_nodes
398 .iter()
399 .enumerate()
400 .map(|(joint, node)| mul(worlds[*node], character.inverse_binds[joint]))
401 .collect()
402}
403
404/// Stable hierarchy path includes node indices to disambiguate duplicate names.
405pub fn joint_paths(character: &PreviewCharacter) -> Vec<String> {
406 character
407 .joint_nodes
408 .iter()
409 .map(|&node| {
410 let mut names = Vec::new();
411 let mut cursor = Some(node);
412 while let Some(index) = cursor {
413 let item = &character.nodes[index];
414 names.push(format!("{}[{}]", item.name, index));
415 cursor = item.parent;
416 if names.len() > character.nodes.len() {
417 break;
418 }
419 }
420 names.reverse();
421 names.join("/")
422 })
423 .collect()
424}
425
426/// Rest-world joint transform, never selected by a possibly empty clip name.
427/// Bounded parent walk rejects malformed cycles/indices instead of recursing.
428pub fn rest_joint_world(character: &PreviewCharacter, joint: usize) -> Option<Mat4> {
429 let mut node = Some(*character.joint_nodes.get(joint)?);
430 let mut chain = Vec::new();
431 while let Some(i) = node {
432 if chain.len() >= character.nodes.len() {
433 return None;
434 }
435 let n = character.nodes.get(i)?;
436 chain.push(i);
437 node = n.parent;
438 }
439 let mut world = identity();
440 for i in chain.into_iter().rev() {
441 let n = &character.nodes[i];
442 world = mul(world, trs(n.translation, n.rotation, n.scale));
443 }
444 world.iter().all(|n| n.is_finite()).then_some(world)
445}
446
447pub fn animated_joint_world(
448 character: &PreviewCharacter,
449 clip: &str,
450 time: f32,
451 joint: usize,
452) -> Option<Mat4> {
453 let locals = sample_local_matrices(character, clip, time);
454 let worlds = world_matrices(&character.nodes, &locals);
455 character
456 .joint_nodes
457 .get(joint)
458 .and_then(|node| worlds.get(*node))
459 .copied()
460}
461
462pub fn joint_world_translation(
463 character: &PreviewCharacter,
464 clip_name: &str,
465 time: f32,
466 joint_name: &str,
467) -> Option<[f32; 3]> {
468 let joint = character
469 .joint_names
470 .iter()
471 .position(|name| name == joint_name)?;
472 let node = character.joint_nodes[joint];
473 let locals = sample_local_matrices(character, clip_name, time);
474 let worlds = world_matrices(&character.nodes, &locals);
475 Some([worlds[node][12], worlds[node][13], worlds[node][14]])
476}
477
478pub fn clip_duration(character: &PreviewCharacter, clip_name: &str) -> f32 {
479 character
480 .clips
481 .iter()
482 .find(|clip| {
483 clip.name == clip_name || clip.name.to_lowercase().contains(&clip_name.to_lowercase())
484 })
485 .map(|clip| clip.duration)
486 .unwrap_or(0.0)
487}
488
489pub fn eye_offset(yaw_deg: f32, pitch_deg: f32, distance: f32) -> [f32; 3] {
490 let yaw = yaw_deg.to_radians();
491 let pitch = pitch_deg.to_radians();
492 [
493 -distance * pitch.cos() * yaw.sin(),
494 distance * pitch.sin(),
495 -distance * pitch.cos() * yaw.cos(),
496 ]
497}
498
499pub fn paperdoll_yaws_deg() -> [f32; PAPERDOLL_YAW_COUNT] {
500 let mut yaws = [0.0; PAPERDOLL_YAW_COUNT];
501 for (index, yaw) in yaws.iter_mut().enumerate() {
502 *yaw = index as f32 * 30.0;
503 }
504 yaws
505}
506
507fn sample_local_matrices(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec<Mat4> {
508 let mut translation: Vec<[f32; 3]> = character
509 .nodes
510 .iter()
511 .map(|node| node.translation)
512 .collect();
513 let mut rotation: Vec<[f32; 4]> = character.nodes.iter().map(|node| node.rotation).collect();
514 let mut scale: Vec<[f32; 3]> = character.nodes.iter().map(|node| node.scale).collect();
515 if let Some(clip) = character.clips.iter().find(|clip| {
516 clip.name == clip_name || clip.name.to_lowercase().contains(&clip_name.to_lowercase())
517 }) {
518 let time = wrap_time(time, clip.duration);
519 for channel in &clip.channels {
520 if channel.node >= character.nodes.len() || channel.times.is_empty() {
521 continue;
522 }
523 let (left, right, blend) = key_blend(&channel.times, time);
524 match channel.property {
525 ChannelProperty::Translation => {
526 translation[channel.node] = lerp3(
527 key3(&channel.values, left),
528 key3(&channel.values, right),
529 blend,
530 );
531 }
532 ChannelProperty::Scale => {
533 scale[channel.node] = lerp3(
534 key3(&channel.values, left),
535 key3(&channel.values, right),
536 blend,
537 );
538 }
539 ChannelProperty::Rotation => {
540 rotation[channel.node] = slerp(
541 key4(&channel.values, left),
542 key4(&channel.values, right),
543 blend,
544 );
545 }
546 }
547 }
548 }
549 translation
550 .into_iter()
551 .zip(rotation)
552 .zip(scale)
553 .map(|((translation, rotation), scale)| trs(translation, rotation, scale))
554 .collect()
555}
556
557fn world_matrices(nodes: &[SceneNode], locals: &[Mat4]) -> Vec<Mat4> {
558 fn world(
559 index: usize,
560 nodes: &[SceneNode],
561 locals: &[Mat4],
562 worlds: &mut [Option<Mat4>],
563 depth: usize,
564 ) -> Mat4 {
565 if let Some(matrix) = worlds[index] {
566 return matrix;
567 }
568 if depth > nodes.len() {
569 return locals[index];
570 }
571 let matrix = match nodes[index].parent {
572 Some(parent) => mul(
573 world(parent, nodes, locals, worlds, depth + 1),
574 locals[index],
575 ),
576 None => locals[index],
577 };
578 worlds[index] = Some(matrix);
579 matrix
580 }
581 let mut cached = vec![None; nodes.len()];
582 (0..nodes.len())
583 .map(|index| world(index, nodes, locals, &mut cached, 0))
584 .collect()
585}
586
587fn key_blend(times: &[f32], time: f32) -> (usize, usize, f32) {
588 if times.len() == 1 || time <= times[0] {
589 return (0, 0, 0.0);
590 }
591 if time >= times[times.len() - 1] {
592 let last = times.len() - 1;
593 return (last, last, 0.0);
594 }
595 let mut index = 0;
596 while index + 1 < times.len() && times[index + 1] < time {
597 index += 1;
598 }
599 let span = times[index + 1] - times[index];
600 let blend = if span.abs() < 1e-6 {
601 0.0
602 } else {
603 (time - times[index]) / span
604 };
605 (index, index + 1, blend)
606}
607
608fn key3(values: &[f32], index: usize) -> [f32; 3] {
609 let start = index * 3;
610 [
611 values.get(start).copied().unwrap_or(0.0),
612 values.get(start + 1).copied().unwrap_or(0.0),
613 values.get(start + 2).copied().unwrap_or(0.0),
614 ]
615}
616
617fn key4(values: &[f32], index: usize) -> [f32; 4] {
618 let start = index * 4;
619 [
620 values.get(start).copied().unwrap_or(0.0),
621 values.get(start + 1).copied().unwrap_or(0.0),
622 values.get(start + 2).copied().unwrap_or(0.0),
623 values.get(start + 3).copied().unwrap_or(1.0),
624 ]
625}
626
627fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
628 [
629 a[0] + (b[0] - a[0]) * t,
630 a[1] + (b[1] - a[1]) * t,
631 a[2] + (b[2] - a[2]) * t,
632 ]
633}
634
635fn slerp(a: [f32; 4], mut b: [f32; 4], t: f32) -> [f32; 4] {
636 let mut dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
637 if dot < 0.0 {
638 dot = -dot;
639 b = [-b[0], -b[1], -b[2], -b[3]];
640 }
641 if dot > 0.9995 {
642 return normalize4([
643 a[0] + (b[0] - a[0]) * t,
644 a[1] + (b[1] - a[1]) * t,
645 a[2] + (b[2] - a[2]) * t,
646 a[3] + (b[3] - a[3]) * t,
647 ]);
648 }
649 let theta = dot.clamp(-1.0, 1.0).acos();
650 let sin_theta = theta.sin();
651 let w1 = ((1.0 - t) * theta).sin() / sin_theta;
652 let w2 = (t * theta).sin() / sin_theta;
653 [
654 a[0] * w1 + b[0] * w2,
655 a[1] * w1 + b[1] * w2,
656 a[2] * w1 + b[2] * w2,
657 a[3] * w1 + b[3] * w2,
658 ]
659}
660
661fn normalize4(value: [f32; 4]) -> [f32; 4] {
662 let length =
663 (value[0] * value[0] + value[1] * value[1] + value[2] * value[2] + value[3] * value[3])
664 .sqrt();
665 if length < 1e-8 {
666 [0.0, 0.0, 0.0, 1.0]
667 } else {
668 [
669 value[0] / length,
670 value[1] / length,
671 value[2] / length,
672 value[3] / length,
673 ]
674 }
675}
676
677fn trs(translation: [f32; 3], rotation: [f32; 4], scale: [f32; 3]) -> Mat4 {
678 let mut matrix = quat_to_mat(normalize4(rotation));
679 matrix[0] *= scale[0];
680 matrix[1] *= scale[0];
681 matrix[2] *= scale[0];
682 matrix[4] *= scale[1];
683 matrix[5] *= scale[1];
684 matrix[6] *= scale[1];
685 matrix[8] *= scale[2];
686 matrix[9] *= scale[2];
687 matrix[10] *= scale[2];
688 matrix[12] = translation[0];
689 matrix[13] = translation[1];
690 matrix[14] = translation[2];
691 matrix
692}
693
694fn quat_to_mat(q: [f32; 4]) -> Mat4 {
695 let (x, y, z, w) = (q[0], q[1], q[2], q[3]);
696 let xx = x * x;
697 let yy = y * y;
698 let zz = z * z;
699 let xy = x * y;
700 let xz = x * z;
701 let yz = y * z;
702 let wx = w * x;
703 let wy = w * y;
704 let wz = w * z;
705 [
706 1.0 - 2.0 * (yy + zz),
707 2.0 * (xy + wz),
708 2.0 * (xz - wy),
709 0.0,
710 2.0 * (xy - wz),
711 1.0 - 2.0 * (xx + zz),
712 2.0 * (yz + wx),
713 0.0,
714 2.0 * (xz + wy),
715 2.0 * (yz - wx),
716 1.0 - 2.0 * (xx + yy),
717 0.0,
718 0.0,
719 0.0,
720 0.0,
721 1.0,
722 ]
723}
724
725pub fn identity() -> Mat4 {
726 [
727 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
728 ]
729}
730
731pub fn mul(left: Mat4, right: Mat4) -> Mat4 {
732 let mut out = [0.0; 16];
733 for column in 0..4 {
734 for row in 0..4 {
735 out[column * 4 + row] = left[row] * right[column * 4]
736 + left[4 + row] * right[column * 4 + 1]
737 + left[8 + row] * right[column * 4 + 2]
738 + left[12 + row] * right[column * 4 + 3];
739 }
740 }
741 out
742}
743
744fn columns_to_mat(columns: [[f32; 4]; 4]) -> Mat4 {
745 let mut matrix = [0.0; 16];
746 for column in 0..4 {
747 for row in 0..4 {
748 matrix[column * 4 + row] = columns[column][row];
749 }
750 }
751 matrix
752}
753
754pub fn wrap_time(time: f32, duration: f32) -> f32 {
755 if duration <= 1e-5 {
756 return 0.0;
757 }
758 let mut wrapped = time % duration;
759 if wrapped < 0.0 {
760 wrapped += duration;
761 }
762 wrapped
763}
764
765fn wrap_deg(mut degrees: f32) -> f32 {
766 degrees %= 360.0;
767 if degrees < 0.0 {
768 degrees += 360.0;
769 }
770 degrees
771}
772
773pub fn view_matrix(eye: [f32; 3], target: [f32; 3]) -> Mat4 {
774 let forward = normalize3([target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]]);
775 let up = [0.0, 1.0, 0.0];
776 let mut side = normalize3(cross(forward, up));
777 if side[0].abs() + side[1].abs() + side[2].abs() < 1e-4 {
778 side = [1.0, 0.0, 0.0];
779 }
780 let up = cross(side, forward);
781 [
782 side[0],
783 up[0],
784 -forward[0],
785 0.0,
786 side[1],
787 up[1],
788 -forward[1],
789 0.0,
790 side[2],
791 up[2],
792 -forward[2],
793 0.0,
794 -dot(side, eye),
795 -dot(up, eye),
796 dot(forward, eye),
797 1.0,
798 ]
799}
800
801pub fn perspective(fov_y_deg: f32, aspect: f32, near: f32, far: f32) -> Mat4 {
802 let f = 1.0 / (fov_y_deg.to_radians() * 0.5).tan();
803 let aspect = if aspect.abs() < 1e-4 { 1.0 } else { aspect };
804 let nf = 1.0 / (near - far);
805 [
806 f / aspect,
807 0.0,
808 0.0,
809 0.0,
810 0.0,
811 f,
812 0.0,
813 0.0,
814 0.0,
815 0.0,
816 (far + near) * nf,
817 -1.0,
818 0.0,
819 0.0,
820 2.0 * far * near * nf,
821 0.0,
822 ]
823}
824
825fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
826 [
827 a[1] * b[2] - a[2] * b[1],
828 a[2] * b[0] - a[0] * b[2],
829 a[0] * b[1] - a[1] * b[0],
830 ]
831}
832
833fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
834 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
835}
836
837fn normalize3(value: [f32; 3]) -> [f32; 3] {
838 let length = dot(value, value).sqrt();
839 if length < 1e-8 {
840 [0.0, 0.0, 1.0]
841 } else {
842 [value[0] / length, value[1] / length, value[2] / length]
843 }
844}
845
846#[cfg(test)]
847mod tests {
848 use super::*;
849
850 fn male() -> PreviewCharacter {
851 load_gltf_file(
852 &Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"),
853 )
854 .unwrap()
855 }
856
857 fn distance(a: [f32; 3], b: [f32; 3]) -> f32 {
858 let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
859 dot(d, d).sqrt()
860 }
861
862 #[test]
863 fn walk_and_attack_move_the_matching_bones() {
864 let character = male();
865 assert!(character.clips.iter().any(|clip| clip.name == "walk"));
866 assert!(character.clips.iter().any(|clip| clip.name == "attack"));
867 assert_eq!(character.joint_names.len(), 19);
868 assert!(character.indices.len() >= 3);
869 assert!(character.positions.len() >= 19 * 8);
870 let leg_a = joint_world_translation(&character, "walk", 0.0, "LeftLowerLeg").unwrap();
871 let leg_b = joint_world_translation(&character, "walk", 0.25, "LeftLowerLeg").unwrap();
872 assert!(
873 distance(leg_a, leg_b) > 0.05,
874 "walk should move the lower leg, delta {}",
875 distance(leg_a, leg_b)
876 );
877 let arm_a = joint_world_translation(&character, "attack", 0.0, "RightHand").unwrap();
878 let arm_b = joint_world_translation(&character, "attack", 0.35, "RightHand").unwrap();
879 assert!(
880 distance(arm_a, arm_b) > 0.05,
881 "attack should move the right hand, delta {}",
882 distance(arm_a, arm_b)
883 );
884 let matrices = sample_skin_matrices(&character, "walk", 0.2);
885 assert_eq!(matrices.len(), 19);
886 assert!(matrices.iter().flatten().all(|value| value.is_finite()));
887 }
888
889 #[test]
890 fn female_fixture_has_the_same_clip_names() {
891 let character = load_gltf_file(
892 &Path::new(env!("CARGO_MANIFEST_DIR"))
893 .join("tests/fixtures/bodies/test-body-female.glb"),
894 )
895 .unwrap();
896 assert!(character.clips.iter().any(|clip| clip.name == "walk"));
897 assert!(character.clips.iter().any(|clip| clip.name == "attack"));
898 let a = joint_world_translation(&character, "walk", 0.0, "RightLowerLeg").unwrap();
899 let b = joint_world_translation(&character, "walk", 0.5, "RightLowerLeg").unwrap();
900 assert!(distance(a, b) > 0.05);
901 }
902
903 #[test]
904 fn yaw_zero_is_in_front_and_the_twelve_yaws_differ() {
905 let front = eye_offset(0.0, 0.0, 4.0);
906 assert!(front[2] < -3.5, "yaw 0 camera belongs on -Z, got {front:?}");
907 assert!(front[0].abs() < 0.05);
908 let mut seen = Vec::new();
909 for yaw in paperdoll_yaws_deg() {
910 let eye = eye_offset(yaw, 0.0, 4.0);
911 assert!(seen
912 .iter()
913 .all(|previous: &[f32; 3]| distance(*previous, eye) > 0.5));
914 seen.push(eye);
915 }
916 assert_eq!(seen.len(), 12);
917 let low = eye_offset(0.0, -30.0, 4.0);
918 let high = eye_offset(0.0, 30.0, 4.0);
919 assert!(low[1] < 0.0 && high[1] > 0.0);
920 assert_eq!(PAPERDOLL_PITCHES_DEG.len(), 3);
921 }
922
923 #[test]
924 fn controls_snap_wrap_and_keep_a_clip_change() {
925 let mut controls = PreviewControls::default();
926 controls.drag(-100.0, 40.0);
927 assert!(controls.yaw_deg > 40.0);
928 assert!(controls.pitch_deg > 10.0);
929 controls.zoom(400.0);
930 assert!(controls.distance < 3.4);
931 controls.snap_yaw_index(-1);
932 assert!((controls.yaw_deg - 330.0).abs() < 0.01);
933 controls.snap_pitch_row(99);
934 assert!((controls.pitch_deg - 30.0).abs() < 0.01);
935 controls.snap_pitch_row(-4);
936 assert!((controls.pitch_deg + 30.0).abs() < 0.01);
937 controls.time = 0.4;
938 controls.select_clip("attack");
939 assert_eq!(controls.clip_name, "attack");
940 assert_eq!(controls.time, 0.0);
941 controls.time = 1.2;
942 controls.advance(0.0, 1.0);
943 controls.playing = true;
944 controls.advance(0.3, 1.0);
945 assert!((controls.time - 0.5).abs() < 0.02 || (wrap_time(1.5, 1.0) - 0.5).abs() < 0.02);
946 }
947}