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