BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / compositor.rs

compositor.rs

compositor.rs 32.0 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 depth_range_milli: None,
585 }
586 }
587
588 #[allow(clippy::too_many_arguments)]
589 fn solid_layer(
590 label: &str,
591 w: u32,
592 h: u32,
593 diffuse: [u8; 4],
594 normal: [u8; 4],
595 specular: [u8; 4],
596 depth_gray: u16,
597 depth_alpha: u16,
598 ) -> LayerMaps {
599 let pixels = (w * h) as usize;
600 LayerMaps {
601 label: label.into(),
602 width: w,
603 height: h,
604 diffuse: diffuse.repeat(pixels),
605 normal: normal.repeat(pixels),
606 specular: specular.repeat(pixels),
607 depth: [depth_gray, depth_alpha]
608 .into_iter()
609 .cycle()
610 .take(pixels * 2)
611 .collect(),
612 }
613 }
614
615 fn pixel_layer(
616 label: &str,
617 w: u32,
618 h: u32,
619 fill: impl Fn(usize) -> ([u8; 4], [u8; 4], [u8; 4], u16, u16),
620 ) -> LayerMaps {
621 let pixels = (w * h) as usize;
622 let mut diffuse = vec![0; pixels * 4];
623 let mut normal = vec![0; pixels * 4];
624 let mut specular = vec![0; pixels * 4];
625 let mut depth = vec![0u16; pixels * 2];
626 for i in 0..pixels {
627 let (d, n, s, g, a) = fill(i);
628 diffuse[i * 4..i * 4 + 4].copy_from_slice(&d);
629 normal[i * 4..i * 4 + 4].copy_from_slice(&n);
630 specular[i * 4..i * 4 + 4].copy_from_slice(&s);
631 depth[i * 2] = g;
632 depth[i * 2 + 1] = a;
633 }
634 LayerMaps {
635 label: label.into(),
636 width: w,
637 height: h,
638 diffuse,
639 normal,
640 specular,
641 depth,
642 }
643 }
644
645 #[test]
646 fn front_occludes_back_and_binds_same_layer_maps() {
647 // 2×1: left pixel has front red equipment over blue body; right is body only.
648 let body = pixel_layer("body", 2, 1, |i| {
649 let depth = if i == 0 { 30000 } else { 20000 };
650 (
651 [0, 0, 200, 255],
652 [128, 128, 255, 255],
653 [0, 0, 0, 255],
654 depth,
655 65535,
656 )
657 });
658 let gear = pixel_layer("sword", 2, 1, |i| {
659 if i == 0 {
660 (
661 [200, 0, 0, 255],
662 [255, 128, 128, 255], // +X-ish normal from gear
663 [180, 180, 180, 255],
664 10000, // nearer
665 65535,
666 )
667 } else {
668 ([0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], 0, 0)
669 }
670 });
671 let composed = compose(&[body, gear]).unwrap();
672 assert_eq!(composed.winner[0], 1, "front equipment wins left");
673 assert_eq!(&composed.diffuse[0..4], &[200, 0, 0, 255]);
674 assert_eq!(&composed.normal[0..4], &[255, 128, 128, 255]);
675 assert_eq!(&composed.specular[0..4], &[180, 180, 180, 255]);
676 assert_eq!(composed.depth[0], 10000);
677 assert_eq!(composed.winner[1], 0, "body remains on right");
678 assert_eq!(&composed.diffuse[4..8], &[0, 0, 200, 255]);
679 assert_eq!(&composed.specular[4..8], &[0, 0, 0, 255]);
680 }
681
682 #[test]
683 fn epsilon_ties_prefer_lower_layer_index() {
684 let body = solid_layer(
685 "body",
686 1,
687 1,
688 [10, 20, 30, 255],
689 [128, 128, 255, 255],
690 [0, 0, 0, 255],
691 20000,
692 65535,
693 );
694 // Within DEPTH_EPSILON_U16 of body depth.
695 let gear = solid_layer(
696 "hat",
697 1,
698 1,
699 [90, 80, 70, 255],
700 [128, 200, 128, 255],
701 [50, 50, 50, 255],
702 20000 + DEPTH_EPSILON_U16,
703 65535,
704 );
705 let composed = compose(&[body.clone(), gear.clone()]).unwrap();
706 assert_eq!(composed.winner[0], 0);
707 assert_eq!(&composed.diffuse[..4], &[10, 20, 30, 255]);
708
709 // Reverse order: now gear is index 0 and wins the tie.
710 let reversed = compose(&[gear, body]).unwrap();
711 assert_eq!(reversed.winner[0], 0);
712 assert_eq!(&reversed.diffuse[..4], &[90, 80, 70, 255]);
713 }
714
715 #[test]
716 fn strictly_nearer_beyond_epsilon_beats_earlier_layer() {
717 let body = solid_layer(
718 "body",
719 1,
720 1,
721 [1, 2, 3, 255],
722 [128, 128, 255, 255],
723 [0, 0, 0, 255],
724 20000,
725 65535,
726 );
727 let gear = solid_layer(
728 "cloak",
729 1,
730 1,
731 [9, 8, 7, 255],
732 [128, 128, 255, 255],
733 [0, 0, 0, 255],
734 20000 - DEPTH_EPSILON_U16 - 1,
735 65535,
736 );
737 let composed = compose(&[body, gear]).unwrap();
738 assert_eq!(composed.winner[0], 1);
739 assert_eq!(&composed.diffuse[..4], &[9, 8, 7, 255]);
740 }
741
742 #[test]
743 fn transparency_and_alpha_edges_follow_documented_policy() {
744 // Center soft-edge body; equipment only on left with full coverage;
745 // right pixel has transparent equipment over body — body keeps soft alpha.
746 let body = pixel_layer("body", 3, 1, |i| {
747 let alpha = match i {
748 0 => 255,
749 1 => 128, // soft edge
750 _ => 255,
751 };
752 (
753 [40, 40, 40, alpha],
754 [128, 128, 255, alpha],
755 [0, 0, 0, alpha],
756 25000,
757 u16::from(alpha) * 257,
758 )
759 });
760 let gear = pixel_layer("pad", 3, 1, |i| match i {
761 0 => (
762 [255, 0, 0, 255],
763 [128, 128, 255, 255],
764 [0, 0, 0, 255],
765 10000,
766 65535,
767 ),
768 // Transparent equipment must not steal the soft body edge.
769 1 => ([255, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], 5000, 0),
770 // Semi-transparent losing layer behind would also be ignored if farther;
771 // here nearer but alpha 0 — skipped.
772 _ => ([0, 255, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], 5000, 0),
773 });
774 let composed = compose(&[body, gear]).unwrap();
775 assert_eq!(composed.winner[0], 1);
776 assert_eq!(composed.diffuse[3], 255);
777 assert_eq!(composed.winner[1], 0);
778 assert_eq!(
779 composed.diffuse[7], 128,
780 "winner soft alpha preserved; no cross-layer blend"
781 );
782 assert_eq!(composed.winner[2], 0);
783 assert_eq!(&composed.diffuse[8..12], &[40, 40, 40, 255]);
784 }
785
786 #[test]
787 fn lighting_responds_to_normal_and_light_direction() {
788 let facing = solid_layer(
789 "front",
790 1,
791 1,
792 [200, 200, 200, 255],
793 [128, 128, 255, 255], // ~+Z
794 [0, 0, 0, 255],
795 10000,
796 65535,
797 );
798 let away = solid_layer(
799 "back",
800 1,
801 1,
802 [200, 200, 200, 255],
803 [128, 128, 0, 255], // ~-Z
804 [0, 0, 0, 255],
805 10000,
806 65535,
807 );
808 let light = Light {
809 direction_view: [0.0, 0.0, 1.0],
810 ambient: 0.05,
811 diffuse: 0.9,
812 specular: 0.0,
813 shininess: 16.0,
814 };
815 let lit_front = relight(&compose(&[facing]).unwrap(), &light).unwrap();
816 let lit_back = relight(&compose(&[away]).unwrap(), &light).unwrap();
817 let front_luma =
818 lit_front.rgba[0] as u32 + lit_front.rgba[1] as u32 + lit_front.rgba[2] as u32;
819 let back_luma = lit_back.rgba[0] as u32 + lit_back.rgba[1] as u32 + lit_back.rgba[2] as u32;
820 assert!(
821 front_luma > back_luma + 40,
822 "front-facing should be brighter: {front_luma} vs {back_luma}"
823 );
824
825 let shiny = solid_layer(
826 "metal",
827 1,
828 1,
829 [180, 180, 180, 255],
830 [128, 128, 255, 255],
831 [255, 255, 255, 255],
832 10000,
833 65535,
834 );
835 let matte = solid_layer(
836 "matte",
837 1,
838 1,
839 [180, 180, 180, 255],
840 [128, 128, 255, 255],
841 [0, 0, 0, 255],
842 10000,
843 65535,
844 );
845 let spec_light = Light {
846 direction_view: [0.0, 0.0, 1.0],
847 ambient: 0.0,
848 diffuse: 0.2,
849 specular: 1.0,
850 shininess: 64.0,
851 };
852 let lit_shiny = relight(&compose(&[shiny]).unwrap(), &spec_light).unwrap();
853 let lit_matte = relight(&compose(&[matte]).unwrap(), &spec_light).unwrap();
854 let shiny_luma =
855 lit_shiny.rgba[0] as u32 + lit_shiny.rgba[1] as u32 + lit_shiny.rgba[2] as u32;
856 let matte_luma =
857 lit_matte.rgba[0] as u32 + lit_matte.rgba[1] as u32 + lit_matte.rgba[2] as u32;
858 assert!(
859 shiny_luma > matte_luma + 20,
860 "specular map must brighten highlight: {shiny_luma} vs {matte_luma}"
861 );
862 assert_eq!(lit_shiny.rgba[3], 255);
863 }
864
865 #[test]
866 fn empty_and_mismatched_layers_error() {
867 assert!(matches!(compose(&[]), Err(ComposeError::Empty)));
868 let a = solid_layer(
869 "a",
870 1,
871 1,
872 [1, 1, 1, 255],
873 [128, 128, 255, 255],
874 [0, 0, 0, 255],
875 1,
876 65535,
877 );
878 let b = solid_layer(
879 "b",
880 2,
881 1,
882 [1, 1, 1, 255],
883 [128, 128, 255, 255],
884 [0, 0, 0, 255],
885 1,
886 65535,
887 );
888 assert!(matches!(compose(&[a, b]), Err(ComposeError::Dimension(_))));
889 }
890
891 fn write_completed_run(
892 root: &Path,
893 set: &str,
894 equipment_sha: Option<&str>,
895 px: Pixel,
896 body_bytes: &[u8],
897 ) {
898 let mut spec = SyntheticRun::body("demo", "male", body_bytes);
899 spec.framing = framing();
900 spec.set = set.into();
901 spec.equipment_sha256 = equipment_sha.map(str::to_string);
902 spec.write(root, |_, _, _, _| px).unwrap();
903 }
904
905 fn px(diffuse: [u8; 4], normal: [u8; 4], specular: [u8; 4], depth: u16) -> Pixel {
906 Pixel {
907 diffuse,
908 normal,
909 specular,
910 depth,
911 }
912 }
913
914 #[test]
915 fn export_set_layers_compose_through_safe_reader() {
916 let body_tmp = Temp::new();
917 let gear_tmp = Temp::new();
918 let body_bytes = b"shared-body";
919 let body_px = px(
920 [20, 40, 60, 255],
921 [128, 128, 255, 255],
922 [0, 0, 0, 255],
923 30000,
924 );
925 write_completed_run(&body_tmp.0, "body", None, body_px, body_bytes);
926 let gear_hash = digest::sha256_hex(b"sword-ppe");
927 let gear_px = px(
928 [220, 10, 10, 255],
929 [255, 128, 128, 255],
930 [200, 200, 200, 255],
931 12000,
932 );
933 write_completed_run(&gear_tmp.0, "sword", Some(&gear_hash), gear_px, body_bytes);
934
935 let body_run = ExportRun::open(&body_tmp.0).unwrap();
936 let gear_run = ExportRun::open(&gear_tmp.0).unwrap();
937 assert_compatible(&[&body_run, &gear_run]).unwrap();
938
939 let body_layer =
940 LayerMaps::from_export_run(&body_run, "body", "clip000-bind", 0, 0, 0).unwrap();
941 let gear_layer =
942 LayerMaps::from_export_run(&gear_run, "sword", "clip000-bind", 0, 0, 0).unwrap();
943 let (composed, lit) =
944 compose_and_relight(&[body_layer, gear_layer], &Light::default()).unwrap();
945 assert_eq!(composed.winner[0], 1);
946 assert_eq!(&composed.diffuse[..4], &[220, 10, 10, 255]);
947 assert_eq!(&composed.specular[..4], &[200, 200, 200, 255]);
948 assert_eq!(lit.rgba[3], 255);
949 assert!(lit.rgba[0] > 0);
950
951 // The .ppda/atlas path yields the identical layer maps.
952 let body_atlas = crate::atlas::AtlasSet::open_run(&body_tmp.0)
953 .unwrap()
954 .load()
955 .unwrap();
956 let from_atlas =
957 LayerMaps::from_atlas(&body_atlas, "body", "clip000-bind", 0, 60, 300).unwrap();
958 let loose =
959 LayerMaps::from_export_run(&body_run, "body", "clip000-bind", 0, 60, 300).unwrap();
960 assert_eq!(from_atlas.diffuse, loose.diffuse);
961 assert_eq!(from_atlas.normal, loose.normal);
962 assert_eq!(from_atlas.specular, loose.specular);
963 assert_eq!(from_atlas.depth, loose.depth);
964 assert_eq!(
965 CompatKey::from_ppda(body_atlas.ppda()),
966 CompatKey::from_run(&body_run)
967 );
968 }
969
970 #[test]
971 fn incompatible_export_sets_are_rejected() {
972 let a = Temp::new();
973 let b = Temp::new();
974 let same = px([1, 2, 3, 255], [128, 128, 255, 255], [0, 0, 0, 255], 1000);
975 write_completed_run(&a.0, "body", None, same, b"body-a");
976 write_completed_run(&b.0, "body", None, same, b"body-b");
977 let run_a = ExportRun::open(&a.0).unwrap();
978 let run_b = ExportRun::open(&b.0).unwrap();
979 let err = assert_compatible(&[&run_a, &run_b]).unwrap_err();
980 assert!(matches!(err, ComposeError::Incompatible(_)));
981 }
982}