BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / compositor.rs

compositor.rs

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