BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / compositor.rs

compositor.rs

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