BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / compositor.rs

compositor.rs

compositor.rs 31.9 KiB Download Raw
1//! Deterministic 2D depth compositor and linear relighting.
2//!
3//! Consumes already-exported maps through [`crate::export_set`] (loose PNGs) or
4//! [`crate::atlas`] (`.ppda` + atlases, the consumer format).
5//! Saved diffuse remains unlit albedo elsewhere; this module only applies
6//! consumer-side lighting to composed layers.
7//!
8//! # Depth selection
9//!
10//! For each pixel, every layer with a **valid** sample (depth alpha ≠ 0 and
11//! diffuse alpha ≠ 0) competes. The nearest surface wins: smaller depth gray
12//! is closer (OpenGL-style 0 = near, 1 = far, stored as 16-bit gray).
13//!
14//! ## Epsilon and tie-break
15//!
16//! Depths that differ by at most [`DEPTH_EPSILON_U16`] codes are treated as a
17//! tie. On a tie, the **lower layer index** wins. Callers must pass layers in a
18//! stable order (body first, then equipment sorted by preset name) so ties are
19//! reproducible across machines.
20//!
21//! # Same-layer attribute binding
22//!
23//! The winning layer supplies diffuse, normal and specular/shiny from that
24//! **same** layer index. Maps are never mixed across layers for one pixel.
25//!
26//! # Alpha-edge policy
27//!
28//! Single-depth compositing cannot reconstruct arbitrary order-dependent
29//! translucency. Supported approximation:
30//!
31//! - Eligible samples require non-zero depth coverage **and** non-zero diffuse
32//! alpha (cutout / coverage). Purely transparent texels never win.
33//! - Exactly one winning layer is chosen by depth+tie-break; losing layers
34//! contribute nothing, even if they have soft alpha.
35//! - The winner’s straight diffuse alpha is copied to the composed output.
36//! Soft edges therefore keep the winner’s own coverage; they are not blended
37//! with occluded layers behind them.
38//!
39//! # Lighting
40//!
41//! Linear ambient + Lambertian diffuse + Blinn-Phong specular. The light
42//! direction is already in **view space**, matching the exported normal maps
43//! (`normal_space: view (+Y up)`). View direction is `(0, 0, 1)` (toward the
44//! camera). Output RGB is encoded back to sRGB; alpha is unchanged coverage.
45
46use crate::{
47 export_set::{ExportError, ExportRun, MapImage, MapKind},
48 render_job::ImageBuf,
49};
50use std::fmt;
51
52/// Depth codes within this inclusive distance count as a tie.
53pub const DEPTH_EPSILON_U16: u16 = 1;
54
55/// Default Blinn-Phong exponent for the simple preview light.
56pub const DEFAULT_SHININESS: f32 = 32.0;
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum ComposeError {
60 Empty,
61 Dimension(String),
62 Map(String),
63 Incompatible(String),
64 Export(String),
65}
66
67impl fmt::Display for ComposeError {
68 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69 match self {
70 Self::Empty => write!(f, "geen lagen om te composeren"),
71 Self::Dimension(m) | Self::Map(m) | Self::Incompatible(m) | Self::Export(m) => {
72 write!(f, "{m}")
73 }
74 }
75 }
76}
77
78impl std::error::Error for ComposeError {}
79
80impl From<ExportError> for ComposeError {
81 fn from(value: ExportError) -> Self {
82 Self::Export(value.to_string())
83 }
84}
85
86/// One composable layer already decoded to the export pixel contract.
87#[derive(Debug, Clone)]
88pub struct LayerMaps {
89 pub label: String,
90 pub width: u32,
91 pub height: u32,
92 pub diffuse: Vec<u8>,
93 pub normal: Vec<u8>,
94 pub specular: Vec<u8>,
95 /// Interleaved gray, alpha (`width*height*2`), same layout as `MapImage::Depth16`.
96 pub depth: Vec<u16>,
97}
98
99impl LayerMaps {
100 pub fn from_maps(
101 label: impl Into<String>,
102 diffuse: &MapImage,
103 normal: &MapImage,
104 specular: &MapImage,
105 depth: &MapImage,
106 ) -> Result<Self, ComposeError> {
107 let (dw, dh, drgba) = expect_rgba(diffuse, "diffuse")?;
108 let (nw, nh, nrgba) = expect_rgba(normal, "normal")?;
109 let (sw, sh, srgba) = expect_rgba(specular, "specular")?;
110 let (zw, zh, samples) = expect_depth(depth)?;
111 if !(dw == nw && nw == sw && sw == zw && dh == nh && nh == sh && sh == zh) {
112 return Err(ComposeError::Dimension(
113 "laagmaps hebben niet dezelfde afmetingen".into(),
114 ));
115 }
116 let pixels = (dw as usize)
117 .checked_mul(dh as usize)
118 .ok_or_else(|| ComposeError::Dimension("afmetingen overlopen".into()))?;
119 if drgba.len() != pixels * 4
120 || nrgba.len() != pixels * 4
121 || srgba.len() != pixels * 4
122 || samples.len() != pixels * 2
123 {
124 return Err(ComposeError::Map(
125 "buffergrootte past niet bij breedte×hoogte".into(),
126 ));
127 }
128 Ok(Self {
129 label: label.into(),
130 width: dw,
131 height: dh,
132 diffuse: drgba,
133 normal: nrgba,
134 specular: srgba,
135 depth: samples,
136 })
137 }
138
139 /// Load the four target maps for one pose from a completed export run.
140 pub fn from_export_run(
141 run: &ExportRun,
142 label: impl Into<String>,
143 clip_token: &str,
144 frame: u32,
145 pitch: i32,
146 yaw: u32,
147 ) -> Result<Self, ComposeError> {
148 let diffuse = run.load_map(clip_token, frame, pitch, yaw, MapKind::Diffuse)?;
149 let normal = run.load_map(clip_token, frame, pitch, yaw, MapKind::Normal)?;
150 let specular = run.load_map(clip_token, frame, pitch, yaw, MapKind::Specular)?;
151 let depth = run.load_map(clip_token, frame, pitch, yaw, MapKind::Depth)?;
152 Self::from_maps(label, &diffuse, &normal, &specular, &depth)
153 }
154
155 /// Crop the four map cells for one pose from decoded `.ppda` atlases.
156 pub fn from_atlas(
157 atlas: &crate::atlas::LoadedAtlas,
158 label: impl Into<String>,
159 clip_token: &str,
160 frame: u32,
161 pitch: i32,
162 yaw: u32,
163 ) -> Result<Self, ComposeError> {
164 let diffuse = atlas.cell(clip_token, frame, pitch, yaw, MapKind::Diffuse)?;
165 let normal = atlas.cell(clip_token, frame, pitch, yaw, MapKind::Normal)?;
166 let specular = atlas.cell(clip_token, frame, pitch, yaw, MapKind::Specular)?;
167 let depth = atlas.cell(clip_token, frame, pitch, yaw, MapKind::Depth)?;
168 Self::from_maps(label, &diffuse, &normal, &specular, &depth)
169 }
170}
171
172fn expect_rgba(image: &MapImage, kind: &str) -> Result<(u32, u32, Vec<u8>), ComposeError> {
173 match image {
174 MapImage::Rgba8 {
175 width,
176 height,
177 rgba,
178 } => Ok((*width, *height, rgba.clone())),
179 MapImage::Depth16 { .. } => Err(ComposeError::Map(format!(
180 "{kind} verwacht RGBA8, kreeg depth16"
181 ))),
182 }
183}
184
185fn expect_depth(image: &MapImage) -> Result<(u32, u32, Vec<u16>), ComposeError> {
186 match image {
187 MapImage::Depth16 {
188 width,
189 height,
190 samples,
191 } => Ok((*width, *height, samples.clone())),
192 MapImage::Rgba8 { .. } => Err(ComposeError::Map(
193 "depth verwacht gray-alpha16, kreeg RGBA8".into(),
194 )),
195 }
196}
197
198/// Shared export contract that body + equipment layers must agree on.
199#[derive(Debug, Clone, PartialEq)]
200pub struct CompatKey {
201 pub project_id: String,
202 pub sex: String,
203 pub body_model_sha256: String,
204 pub target: [u32; 2],
205 pub fps: u32,
206 pub framing: crate::project::CameraFraming,
207 pub near: f32,
208 pub far: f32,
209 pub clip_tokens: Vec<(String, u32)>,
210}
211
212impl CompatKey {
213 pub fn from_run(run: &ExportRun) -> Self {
214 Self {
215 project_id: run.render.project.id.clone(),
216 sex: run.render.sex.clone(),
217 body_model_sha256: run.render.body_model.sha256.clone(),
218 target: run.render.target,
219 fps: run.render.fps,
220 framing: run.render.framing,
221 near: run.render.near,
222 far: run.render.far,
223 clip_tokens: run
224 .render
225 .clips
226 .iter()
227 .map(|c| (c.token.clone(), c.frames))
228 .collect(),
229 }
230 }
231}
232
233impl CompatKey {
234 /// Same contract read from a validated `.ppda` (consumer format).
235 pub fn from_ppda(p: &crate::atlas::Ppda) -> Self {
236 Self {
237 project_id: p.project.id.clone(),
238 sex: p.sex.clone(),
239 body_model_sha256: p.identity.body_model_sha256.clone(),
240 target: [p.cell_width, p.cell_height],
241 fps: p.fps,
242 framing: p.identity.framing,
243 near: p.identity.near,
244 far: p.identity.far,
245 clip_tokens: p
246 .clips
247 .iter()
248 .map(|c| (c.token.clone(), c.frame_count))
249 .collect(),
250 }
251 }
252}
253
254/// Reject runs that cannot be depth-composited together.
255pub fn assert_compatible(runs: &[&ExportRun]) -> Result<(), ComposeError> {
256 let keys: Vec<CompatKey> = runs.iter().map(|r| CompatKey::from_run(r)).collect();
257 assert_compatible_keys(&keys)
258}
259
260/// Reject layer contracts that cannot be depth-composited together.
261pub fn assert_compatible_keys(keys: &[CompatKey]) -> Result<(), ComposeError> {
262 if keys.is_empty() {
263 return Err(ComposeError::Empty);
264 }
265 let first = &keys[0];
266 for key in keys.iter().skip(1) {
267 if key.project_id != first.project_id {
268 return Err(ComposeError::Incompatible(
269 "project.id verschilt tussen exportsets".into(),
270 ));
271 }
272 if key.sex != first.sex {
273 return Err(ComposeError::Incompatible(
274 "geslacht verschilt tussen exportsets".into(),
275 ));
276 }
277 if key.body_model_sha256 != first.body_model_sha256 {
278 return Err(ComposeError::Incompatible(
279 "body_model fingerprint verschilt".into(),
280 ));
281 }
282 if key.target != first.target {
283 return Err(ComposeError::Incompatible(
284 "target-resolutie verschilt".into(),
285 ));
286 }
287 if key.fps != first.fps {
288 return Err(ComposeError::Incompatible("fps verschilt".into()));
289 }
290 if key.framing != first.framing {
291 return Err(ComposeError::Incompatible("framing verschilt".into()));
292 }
293 if (key.near - first.near).abs() > f32::EPSILON
294 || (key.far - first.far).abs() > f32::EPSILON
295 {
296 return Err(ComposeError::Incompatible("near/far verschilt".into()));
297 }
298 if key.clip_tokens != first.clip_tokens {
299 return Err(ComposeError::Incompatible(
300 "clip-inventaris verschilt".into(),
301 ));
302 }
303 }
304 Ok(())
305}
306
307/// Composed target maps plus the winning layer index per pixel (`u8::MAX` = empty).
308#[derive(Debug, Clone)]
309pub struct ComposedMaps {
310 pub width: u32,
311 pub height: u32,
312 pub diffuse: Vec<u8>,
313 pub normal: Vec<u8>,
314 pub specular: Vec<u8>,
315 pub depth: Vec<u16>,
316 pub winner: Vec<u8>,
317}
318
319/// Select nearest valid surface per pixel; bind all maps from that layer.
320pub fn compose(layers: &[LayerMaps]) -> Result<ComposedMaps, ComposeError> {
321 if layers.is_empty() {
322 return Err(ComposeError::Empty);
323 }
324 let width = layers[0].width;
325 let height = layers[0].height;
326 if width == 0 || height == 0 {
327 return Err(ComposeError::Dimension("lege afmetingen".into()));
328 }
329 let pixels = (width as usize)
330 .checked_mul(height as usize)
331 .ok_or_else(|| ComposeError::Dimension("afmetingen overlopen".into()))?;
332 for (index, layer) in layers.iter().enumerate() {
333 if layer.width != width || layer.height != height {
334 return Err(ComposeError::Dimension(format!(
335 "laag {index} ('{}') heeft andere afmetingen",
336 layer.label
337 )));
338 }
339 if layer.diffuse.len() != pixels * 4
340 || layer.normal.len() != pixels * 4
341 || layer.specular.len() != pixels * 4
342 || layer.depth.len() != pixels * 2
343 {
344 return Err(ComposeError::Map(format!(
345 "laag {index} ('{}') heeft inconsistente buffers",
346 layer.label
347 )));
348 }
349 if layers.len() > u8::MAX as usize {
350 return Err(ComposeError::Map(
351 "maximaal 255 lagen voor winner-index".into(),
352 ));
353 }
354 }
355
356 let mut diffuse = vec![0u8; pixels * 4];
357 let mut normal = vec![0u8; pixels * 4];
358 let mut specular = vec![0u8; pixels * 4];
359 let mut depth = vec![0u16; pixels * 2];
360 let mut winner = vec![u8::MAX; pixels];
361
362 for i in 0..pixels {
363 let mut best_depth: Option<u16> = None;
364 let mut best_layer: Option<usize> = None;
365 for (layer_idx, layer) in layers.iter().enumerate() {
366 let depth_alpha = layer.depth[i * 2 + 1];
367 let diffuse_alpha = layer.diffuse[i * 4 + 3];
368 if depth_alpha == 0 || diffuse_alpha == 0 {
369 continue;
370 }
371 let gray = layer.depth[i * 2];
372 match (best_depth, best_layer) {
373 (None, _) => {
374 best_depth = Some(gray);
375 best_layer = Some(layer_idx);
376 }
377 (Some(bd), Some(_)) => {
378 if gray.saturating_add(DEPTH_EPSILON_U16) < bd {
379 // Strictly nearer beyond epsilon.
380 best_depth = Some(gray);
381 best_layer = Some(layer_idx);
382 } else if gray <= bd.saturating_add(DEPTH_EPSILON_U16) {
383 // Tie (within epsilon): keep lower layer index already chosen.
384 }
385 // else: farther — ignore
386 }
387 _ => unreachable!(),
388 }
389 }
390 if let (Some(_), Some(layer_idx)) = (best_depth, best_layer) {
391 let layer = &layers[layer_idx];
392 let o = i * 4;
393 diffuse[o..o + 4].copy_from_slice(&layer.diffuse[o..o + 4]);
394 normal[o..o + 4].copy_from_slice(&layer.normal[o..o + 4]);
395 specular[o..o + 4].copy_from_slice(&layer.specular[o..o + 4]);
396 depth[i * 2] = layer.depth[i * 2];
397 depth[i * 2 + 1] = layer.depth[i * 2 + 1];
398 winner[i] = layer_idx as u8;
399 }
400 }
401
402 Ok(ComposedMaps {
403 width,
404 height,
405 diffuse,
406 normal,
407 specular,
408 depth,
409 winner,
410 })
411}
412
413/// View-space directional light for consumer-side relighting.
414#[derive(Debug, Clone, Copy, PartialEq)]
415pub struct Light {
416 /// Direction **toward** the light, view space (will be normalized).
417 pub direction_view: [f32; 3],
418 pub ambient: f32,
419 pub diffuse: f32,
420 pub specular: f32,
421 pub shininess: f32,
422}
423
424impl Default for Light {
425 fn default() -> Self {
426 Self {
427 // Slightly above and toward +Z so a front-facing normal responds.
428 direction_view: [0.35, 0.55, 0.75],
429 ambient: 0.15,
430 diffuse: 0.75,
431 specular: 0.4,
432 shininess: DEFAULT_SHININESS,
433 }
434 }
435}
436
437/// Relight composed albedo/normal/specular into an sRGB RGBA image.
438pub fn relight(composed: &ComposedMaps, light: &Light) -> Result<ImageBuf, ComposeError> {
439 let pixels = (composed.width as usize)
440 .checked_mul(composed.height as usize)
441 .ok_or_else(|| ComposeError::Dimension("afmetingen overlopen".into()))?;
442 if composed.diffuse.len() != pixels * 4
443 || composed.normal.len() != pixels * 4
444 || composed.specular.len() != pixels * 4
445 {
446 return Err(ComposeError::Map(
447 "composed buffers passen niet bij afmetingen".into(),
448 ));
449 }
450 let l = normalize(light.direction_view);
451 let view = [0.0_f32, 0.0, 1.0];
452 let half = normalize([l[0] + view[0], l[1] + view[1], l[2] + view[2]]);
453 let mut rgba = vec![0u8; pixels * 4];
454 for i in 0..pixels {
455 let o = i * 4;
456 let a = composed.diffuse[o + 3];
457 if a == 0 || composed.winner[i] == u8::MAX {
458 continue;
459 }
460 let albedo = [
461 linear_srgb(composed.diffuse[o]),
462 linear_srgb(composed.diffuse[o + 1]),
463 linear_srgb(composed.diffuse[o + 2]),
464 ];
465 let n = decode_normal([
466 composed.normal[o],
467 composed.normal[o + 1],
468 composed.normal[o + 2],
469 ]);
470 let shiny = composed.specular[o] as f32 / 255.0;
471 let ndotl = dot(n, l).max(0.0);
472 let ndoth = dot(n, half).max(0.0);
473 let spec = shiny * ndoth.powf(light.shininess.max(1.0));
474 let mut lit = [0.0_f32; 3];
475 for c in 0..3 {
476 lit[c] = light.ambient * albedo[c]
477 + light.diffuse * ndotl * albedo[c]
478 + light.specular * spec;
479 lit[c] = lit[c].clamp(0.0, 1.0);
480 }
481 rgba[o] = encode_srgb(lit[0]);
482 rgba[o + 1] = encode_srgb(lit[1]);
483 rgba[o + 2] = encode_srgb(lit[2]);
484 rgba[o + 3] = a;
485 }
486 Ok(ImageBuf {
487 width: composed.width,
488 height: composed.height,
489 rgba,
490 })
491}
492
493/// Compose then relight in one step.
494pub fn compose_and_relight(
495 layers: &[LayerMaps],
496 light: &Light,
497) -> Result<(ComposedMaps, ImageBuf), ComposeError> {
498 let composed = compose(layers)?;
499 let lit = relight(&composed, light)?;
500 Ok((composed, lit))
501}
502
503fn decode_normal(rgb: [u8; 3]) -> [f32; 3] {
504 normalize([
505 rgb[0] as f32 / 255.0 * 2.0 - 1.0,
506 rgb[1] as f32 / 255.0 * 2.0 - 1.0,
507 rgb[2] as f32 / 255.0 * 2.0 - 1.0,
508 ])
509}
510
511fn normalize(v: [f32; 3]) -> [f32; 3] {
512 let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
513 if len > 1e-10 {
514 [v[0] / len, v[1] / len, v[2] / len]
515 } else {
516 [0.0, 0.0, 1.0]
517 }
518}
519
520fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
521 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
522}
523
524fn linear_srgb(x: u8) -> f32 {
525 static LUT: std::sync::OnceLock<[f32; 256]> = std::sync::OnceLock::new();
526 LUT.get_or_init(|| {
527 std::array::from_fn(|i| {
528 let v = i as f32 / 255.;
529 if v <= 0.04045 {
530 v / 12.92
531 } else {
532 ((v + 0.055) / 1.055).powf(2.4)
533 }
534 })
535 })[x as usize]
536}
537
538fn encode_srgb(x: f32) -> u8 {
539 let v = if x <= 0.0031308 {
540 x * 12.92
541 } else {
542 1.055 * x.powf(1. / 2.4) - 0.055
543 };
544 (v * 255.).round().clamp(0., 255.) as u8
545}
546
547#[cfg(test)]
548mod tests {
549 use super::*;
550 use crate::digest;
551 use crate::export_fixture::{Pixel, SyntheticRun};
552 use crate::export_set::ExportRun;
553 use crate::project::CameraFraming;
554 use std::fs;
555 use std::path::{Path, PathBuf};
556 use std::sync::atomic::{AtomicU64, Ordering};
557
558 struct Temp(PathBuf);
559 impl Temp {
560 fn new() -> Self {
561 static N: AtomicU64 = AtomicU64::new(0);
562 let id = N.fetch_add(1, Ordering::Relaxed);
563 let path = std::env::temp_dir().join(format!(
564 "paperdoll-compositor-{}-{}",
565 std::process::id(),
566 id
567 ));
568 let _ = fs::remove_dir_all(&path);
569 fs::create_dir_all(&path).unwrap();
570 Self(path)
571 }
572 }
573 impl Drop for Temp {
574 fn drop(&mut self) {
575 let _ = fs::remove_dir_all(&self.0);
576 }
577 }
578
579 fn framing() -> CameraFraming {
580 CameraFraming {
581 height_milli: 2600,
582 center_y_milli: 1000,
583 pivot_y_milli: Some(1460),
584 }
585 }
586
587 #[allow(clippy::too_many_arguments)]
588 fn solid_layer(
589 label: &str,
590 w: u32,
591 h: u32,
592 diffuse: [u8; 4],
593 normal: [u8; 4],
594 specular: [u8; 4],
595 depth_gray: u16,
596 depth_alpha: u16,
597 ) -> LayerMaps {
598 let pixels = (w * h) as usize;
599 LayerMaps {
600 label: label.into(),
601 width: w,
602 height: h,
603 diffuse: diffuse.repeat(pixels),
604 normal: normal.repeat(pixels),
605 specular: specular.repeat(pixels),
606 depth: [depth_gray, depth_alpha]
607 .into_iter()
608 .cycle()
609 .take(pixels * 2)
610 .collect(),
611 }
612 }
613
614 fn pixel_layer(
615 label: &str,
616 w: u32,
617 h: u32,
618 fill: impl Fn(usize) -> ([u8; 4], [u8; 4], [u8; 4], u16, u16),
619 ) -> LayerMaps {
620 let pixels = (w * h) as usize;
621 let mut diffuse = vec![0; pixels * 4];
622 let mut normal = vec![0; pixels * 4];
623 let mut specular = vec![0; pixels * 4];
624 let mut depth = vec![0u16; pixels * 2];
625 for i in 0..pixels {
626 let (d, n, s, g, a) = fill(i);
627 diffuse[i * 4..i * 4 + 4].copy_from_slice(&d);
628 normal[i * 4..i * 4 + 4].copy_from_slice(&n);
629 specular[i * 4..i * 4 + 4].copy_from_slice(&s);
630 depth[i * 2] = g;
631 depth[i * 2 + 1] = a;
632 }
633 LayerMaps {
634 label: label.into(),
635 width: w,
636 height: h,
637 diffuse,
638 normal,
639 specular,
640 depth,
641 }
642 }
643
644 #[test]
645 fn front_occludes_back_and_binds_same_layer_maps() {
646 // 2×1: left pixel has front red equipment over blue body; right is body only.
647 let body = pixel_layer("body", 2, 1, |i| {
648 let depth = if i == 0 { 30000 } else { 20000 };
649 (
650 [0, 0, 200, 255],
651 [128, 128, 255, 255],
652 [0, 0, 0, 255],
653 depth,
654 65535,
655 )
656 });
657 let gear = pixel_layer("sword", 2, 1, |i| {
658 if i == 0 {
659 (
660 [200, 0, 0, 255],
661 [255, 128, 128, 255], // +X-ish normal from gear
662 [180, 180, 180, 255],
663 10000, // nearer
664 65535,
665 )
666 } else {
667 ([0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], 0, 0)
668 }
669 });
670 let composed = compose(&[body, gear]).unwrap();
671 assert_eq!(composed.winner[0], 1, "front equipment wins left");
672 assert_eq!(&composed.diffuse[0..4], &[200, 0, 0, 255]);
673 assert_eq!(&composed.normal[0..4], &[255, 128, 128, 255]);
674 assert_eq!(&composed.specular[0..4], &[180, 180, 180, 255]);
675 assert_eq!(composed.depth[0], 10000);
676 assert_eq!(composed.winner[1], 0, "body remains on right");
677 assert_eq!(&composed.diffuse[4..8], &[0, 0, 200, 255]);
678 assert_eq!(&composed.specular[4..8], &[0, 0, 0, 255]);
679 }
680
681 #[test]
682 fn epsilon_ties_prefer_lower_layer_index() {
683 let body = solid_layer(
684 "body",
685 1,
686 1,
687 [10, 20, 30, 255],
688 [128, 128, 255, 255],
689 [0, 0, 0, 255],
690 20000,
691 65535,
692 );
693 // Within DEPTH_EPSILON_U16 of body depth.
694 let gear = solid_layer(
695 "hat",
696 1,
697 1,
698 [90, 80, 70, 255],
699 [128, 200, 128, 255],
700 [50, 50, 50, 255],
701 20000 + DEPTH_EPSILON_U16,
702 65535,
703 );
704 let composed = compose(&[body.clone(), gear.clone()]).unwrap();
705 assert_eq!(composed.winner[0], 0);
706 assert_eq!(&composed.diffuse[..4], &[10, 20, 30, 255]);
707
708 // Reverse order: now gear is index 0 and wins the tie.
709 let reversed = compose(&[gear, body]).unwrap();
710 assert_eq!(reversed.winner[0], 0);
711 assert_eq!(&reversed.diffuse[..4], &[90, 80, 70, 255]);
712 }
713
714 #[test]
715 fn strictly_nearer_beyond_epsilon_beats_earlier_layer() {
716 let body = solid_layer(
717 "body",
718 1,
719 1,
720 [1, 2, 3, 255],
721 [128, 128, 255, 255],
722 [0, 0, 0, 255],
723 20000,
724 65535,
725 );
726 let gear = solid_layer(
727 "cloak",
728 1,
729 1,
730 [9, 8, 7, 255],
731 [128, 128, 255, 255],
732 [0, 0, 0, 255],
733 20000 - DEPTH_EPSILON_U16 - 1,
734 65535,
735 );
736 let composed = compose(&[body, gear]).unwrap();
737 assert_eq!(composed.winner[0], 1);
738 assert_eq!(&composed.diffuse[..4], &[9, 8, 7, 255]);
739 }
740
741 #[test]
742 fn transparency_and_alpha_edges_follow_documented_policy() {
743 // Center soft-edge body; equipment only on left with full coverage;
744 // right pixel has transparent equipment over body — body keeps soft alpha.
745 let body = pixel_layer("body", 3, 1, |i| {
746 let alpha = match i {
747 0 => 255,
748 1 => 128, // soft edge
749 _ => 255,
750 };
751 (
752 [40, 40, 40, alpha],
753 [128, 128, 255, alpha],
754 [0, 0, 0, alpha],
755 25000,
756 u16::from(alpha) * 257,
757 )
758 });
759 let gear = pixel_layer("pad", 3, 1, |i| match i {
760 0 => (
761 [255, 0, 0, 255],
762 [128, 128, 255, 255],
763 [0, 0, 0, 255],
764 10000,
765 65535,
766 ),
767 // Transparent equipment must not steal the soft body edge.
768 1 => ([255, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], 5000, 0),
769 // Semi-transparent losing layer behind would also be ignored if farther;
770 // here nearer but alpha 0 — skipped.
771 _ => ([0, 255, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], 5000, 0),
772 });
773 let composed = compose(&[body, gear]).unwrap();
774 assert_eq!(composed.winner[0], 1);
775 assert_eq!(composed.diffuse[3], 255);
776 assert_eq!(composed.winner[1], 0);
777 assert_eq!(
778 composed.diffuse[7], 128,
779 "winner soft alpha preserved; no cross-layer blend"
780 );
781 assert_eq!(composed.winner[2], 0);
782 assert_eq!(&composed.diffuse[8..12], &[40, 40, 40, 255]);
783 }
784
785 #[test]
786 fn lighting_responds_to_normal_and_light_direction() {
787 let facing = solid_layer(
788 "front",
789 1,
790 1,
791 [200, 200, 200, 255],
792 [128, 128, 255, 255], // ~+Z
793 [0, 0, 0, 255],
794 10000,
795 65535,
796 );
797 let away = solid_layer(
798 "back",
799 1,
800 1,
801 [200, 200, 200, 255],
802 [128, 128, 0, 255], // ~-Z
803 [0, 0, 0, 255],
804 10000,
805 65535,
806 );
807 let light = Light {
808 direction_view: [0.0, 0.0, 1.0],
809 ambient: 0.05,
810 diffuse: 0.9,
811 specular: 0.0,
812 shininess: 16.0,
813 };
814 let lit_front = relight(&compose(&[facing]).unwrap(), &light).unwrap();
815 let lit_back = relight(&compose(&[away]).unwrap(), &light).unwrap();
816 let front_luma =
817 lit_front.rgba[0] as u32 + lit_front.rgba[1] as u32 + lit_front.rgba[2] as u32;
818 let back_luma = lit_back.rgba[0] as u32 + lit_back.rgba[1] as u32 + lit_back.rgba[2] as u32;
819 assert!(
820 front_luma > back_luma + 40,
821 "front-facing should be brighter: {front_luma} vs {back_luma}"
822 );
823
824 let shiny = solid_layer(
825 "metal",
826 1,
827 1,
828 [180, 180, 180, 255],
829 [128, 128, 255, 255],
830 [255, 255, 255, 255],
831 10000,
832 65535,
833 );
834 let matte = solid_layer(
835 "matte",
836 1,
837 1,
838 [180, 180, 180, 255],
839 [128, 128, 255, 255],
840 [0, 0, 0, 255],
841 10000,
842 65535,
843 );
844 let spec_light = Light {
845 direction_view: [0.0, 0.0, 1.0],
846 ambient: 0.0,
847 diffuse: 0.2,
848 specular: 1.0,
849 shininess: 64.0,
850 };
851 let lit_shiny = relight(&compose(&[shiny]).unwrap(), &spec_light).unwrap();
852 let lit_matte = relight(&compose(&[matte]).unwrap(), &spec_light).unwrap();
853 let shiny_luma =
854 lit_shiny.rgba[0] as u32 + lit_shiny.rgba[1] as u32 + lit_shiny.rgba[2] as u32;
855 let matte_luma =
856 lit_matte.rgba[0] as u32 + lit_matte.rgba[1] as u32 + lit_matte.rgba[2] as u32;
857 assert!(
858 shiny_luma > matte_luma + 20,
859 "specular map must brighten highlight: {shiny_luma} vs {matte_luma}"
860 );
861 assert_eq!(lit_shiny.rgba[3], 255);
862 }
863
864 #[test]
865 fn empty_and_mismatched_layers_error() {
866 assert!(matches!(compose(&[]), Err(ComposeError::Empty)));
867 let a = solid_layer(
868 "a",
869 1,
870 1,
871 [1, 1, 1, 255],
872 [128, 128, 255, 255],
873 [0, 0, 0, 255],
874 1,
875 65535,
876 );
877 let b = solid_layer(
878 "b",
879 2,
880 1,
881 [1, 1, 1, 255],
882 [128, 128, 255, 255],
883 [0, 0, 0, 255],
884 1,
885 65535,
886 );
887 assert!(matches!(compose(&[a, b]), Err(ComposeError::Dimension(_))));
888 }
889
890 fn write_completed_run(
891 root: &Path,
892 set: &str,
893 equipment_sha: Option<&str>,
894 px: Pixel,
895 body_bytes: &[u8],
896 ) {
897 let mut spec = SyntheticRun::body("demo", "male", body_bytes);
898 spec.framing = framing();
899 spec.set = set.into();
900 spec.equipment_sha256 = equipment_sha.map(str::to_string);
901 spec.write(root, |_, _, _, _| px).unwrap();
902 }
903
904 fn px(diffuse: [u8; 4], normal: [u8; 4], specular: [u8; 4], depth: u16) -> Pixel {
905 Pixel {
906 diffuse,
907 normal,
908 specular,
909 depth,
910 }
911 }
912
913 #[test]
914 fn export_set_layers_compose_through_safe_reader() {
915 let body_tmp = Temp::new();
916 let gear_tmp = Temp::new();
917 let body_bytes = b"shared-body";
918 let body_px = px(
919 [20, 40, 60, 255],
920 [128, 128, 255, 255],
921 [0, 0, 0, 255],
922 30000,
923 );
924 write_completed_run(&body_tmp.0, "body", None, body_px, body_bytes);
925 let gear_hash = digest::sha256_hex(b"sword-ppe");
926 let gear_px = px(
927 [220, 10, 10, 255],
928 [255, 128, 128, 255],
929 [200, 200, 200, 255],
930 12000,
931 );
932 write_completed_run(&gear_tmp.0, "sword", Some(&gear_hash), gear_px, body_bytes);
933
934 let body_run = ExportRun::open(&body_tmp.0).unwrap();
935 let gear_run = ExportRun::open(&gear_tmp.0).unwrap();
936 assert_compatible(&[&body_run, &gear_run]).unwrap();
937
938 let body_layer =
939 LayerMaps::from_export_run(&body_run, "body", "clip000-bind", 0, 0, 0).unwrap();
940 let gear_layer =
941 LayerMaps::from_export_run(&gear_run, "sword", "clip000-bind", 0, 0, 0).unwrap();
942 let (composed, lit) =
943 compose_and_relight(&[body_layer, gear_layer], &Light::default()).unwrap();
944 assert_eq!(composed.winner[0], 1);
945 assert_eq!(&composed.diffuse[..4], &[220, 10, 10, 255]);
946 assert_eq!(&composed.specular[..4], &[200, 200, 200, 255]);
947 assert_eq!(lit.rgba[3], 255);
948 assert!(lit.rgba[0] > 0);
949
950 // The .ppda/atlas path yields the identical layer maps.
951 let body_atlas = crate::atlas::AtlasSet::open_run(&body_tmp.0)
952 .unwrap()
953 .load()
954 .unwrap();
955 let from_atlas =
956 LayerMaps::from_atlas(&body_atlas, "body", "clip000-bind", 0, 60, 300).unwrap();
957 let loose =
958 LayerMaps::from_export_run(&body_run, "body", "clip000-bind", 0, 60, 300).unwrap();
959 assert_eq!(from_atlas.diffuse, loose.diffuse);
960 assert_eq!(from_atlas.normal, loose.normal);
961 assert_eq!(from_atlas.specular, loose.specular);
962 assert_eq!(from_atlas.depth, loose.depth);
963 assert_eq!(
964 CompatKey::from_ppda(body_atlas.ppda()),
965 CompatKey::from_run(&body_run)
966 );
967 }
968
969 #[test]
970 fn incompatible_export_sets_are_rejected() {
971 let a = Temp::new();
972 let b = Temp::new();
973 let same = px([1, 2, 3, 255], [128, 128, 255, 255], [0, 0, 0, 255], 1000);
974 write_completed_run(&a.0, "body", None, same, b"body-a");
975 write_completed_run(&b.0, "body", None, same, b"body-b");
976 let run_a = ExportRun::open(&a.0).unwrap();
977 let run_b = ExportRun::open(&b.0).unwrap();
978 let err = assert_compatible(&[&run_a, &run_b]).unwrap_err();
979 assert!(matches!(err, ComposeError::Incompatible(_)));
980 }
981}