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