BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / compositor.rs

compositor.rs

compositor.rs 48.7 KiB Download Raw
1//! Deterministic 2D depth compositor, linear relighting and subtle rim/edge lighting.
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//!
45//! # Rim / edge highlight (consumer only)
46//!
47//! [`apply_rim`] adds a bounded Fresnel-like `(1 − N·V)^power` highlight plus an
48//! optional depth-discontinuity term. Intensity is clamped to
49//! [`MAX_RIM_INTENSITY`]. Straight alpha/coverage is preserved byte-for-byte.
50//! Zero intensity returns the relit image unchanged. Production diffuse PNGs
51//! are never modified.
52use crate::{
53 export_set::{ExportError, ExportRun, MapImage, MapKind},
54 render_job::ImageBuf,
55};
56use std::fmt;
57
58/// Depth codes within this inclusive distance count as a tie.
59pub const DEPTH_EPSILON_U16: u16 = 1;
60
61/// Default Blinn-Phong exponent for the simple preview light.
62pub const DEFAULT_SHININESS: f32 = 32.0;
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum ComposeError {
66 Empty,
67 Dimension(String),
68 Map(String),
69 Incompatible(String),
70 Export(String),
71}
72
73impl fmt::Display for ComposeError {
74 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75 match self {
76 Self::Empty => write!(f, "geen lagen om te composeren"),
77 Self::Dimension(m) | Self::Map(m) | Self::Incompatible(m) | Self::Export(m) => {
78 write!(f, "{m}")
79 }
80 }
81 }
82}
83
84impl std::error::Error for ComposeError {}
85
86impl From<ExportError> for ComposeError {
87 fn from(value: ExportError) -> Self {
88 Self::Export(value.to_string())
89 }
90}
91
92/// One composable layer already decoded to the export pixel contract.
93#[derive(Debug, Clone)]
94pub struct LayerMaps {
95 pub label: String,
96 pub width: u32,
97 pub height: u32,
98 pub diffuse: Vec<u8>,
99 pub normal: Vec<u8>,
100 pub specular: Vec<u8>,
101 /// Interleaved gray, alpha (`width*height*2`), same layout as `MapImage::Depth16`.
102 pub depth: Vec<u16>,
103}
104
105impl LayerMaps {
106 pub fn from_maps(
107 label: impl Into<String>,
108 diffuse: &MapImage,
109 normal: &MapImage,
110 specular: &MapImage,
111 depth: &MapImage,
112 ) -> Result<Self, ComposeError> {
113 let (dw, dh, drgba) = expect_rgba(diffuse, "diffuse")?;
114 let (nw, nh, nrgba) = expect_rgba(normal, "normal")?;
115 let (sw, sh, srgba) = expect_rgba(specular, "specular")?;
116 let (zw, zh, samples) = expect_depth(depth)?;
117 if !(dw == nw && nw == sw && sw == zw && dh == nh && nh == sh && sh == zh) {
118 return Err(ComposeError::Dimension(
119 "laagmaps hebben niet dezelfde afmetingen".into(),
120 ));
121 }
122 let pixels = (dw as usize)
123 .checked_mul(dh as usize)
124 .ok_or_else(|| ComposeError::Dimension("afmetingen overlopen".into()))?;
125 if drgba.len() != pixels * 4
126 || nrgba.len() != pixels * 4
127 || srgba.len() != pixels * 4
128 || samples.len() != pixels * 2
129 {
130 return Err(ComposeError::Map(
131 "buffergrootte past niet bij breedte×hoogte".into(),
132 ));
133 }
134 Ok(Self {
135 label: label.into(),
136 width: dw,
137 height: dh,
138 diffuse: drgba,
139 normal: nrgba,
140 specular: srgba,
141 depth: samples,
142 })
143 }
144
145 /// Load the four target maps for one pose from a completed export run.
146 pub fn from_export_run(
147 run: &ExportRun,
148 label: impl Into<String>,
149 clip_token: &str,
150 frame: u32,
151 pitch: i32,
152 yaw: u32,
153 ) -> Result<Self, ComposeError> {
154 let diffuse = run.load_map(clip_token, frame, pitch, yaw, MapKind::Diffuse)?;
155 let normal = run.load_map(clip_token, frame, pitch, yaw, MapKind::Normal)?;
156 let specular = run.load_map(clip_token, frame, pitch, yaw, MapKind::Specular)?;
157 let depth = run.load_map(clip_token, frame, pitch, yaw, MapKind::Depth)?;
158 Self::from_maps(label, &diffuse, &normal, &specular, &depth)
159 }
160}
161
162fn expect_rgba(image: &MapImage, kind: &str) -> Result<(u32, u32, Vec<u8>), ComposeError> {
163 match image {
164 MapImage::Rgba8 {
165 width,
166 height,
167 rgba,
168 } => Ok((*width, *height, rgba.clone())),
169 MapImage::Depth16 { .. } => Err(ComposeError::Map(format!(
170 "{kind} verwacht RGBA8, kreeg depth16"
171 ))),
172 }
173}
174
175fn expect_depth(image: &MapImage) -> Result<(u32, u32, Vec<u16>), ComposeError> {
176 match image {
177 MapImage::Depth16 {
178 width,
179 height,
180 samples,
181 } => Ok((*width, *height, samples.clone())),
182 MapImage::Rgba8 { .. } => Err(ComposeError::Map(
183 "depth verwacht gray-alpha16, kreeg RGBA8".into(),
184 )),
185 }
186}
187
188/// Shared export contract that body + equipment layers must agree on.
189#[derive(Debug, Clone, PartialEq)]
190pub struct CompatKey {
191 pub project_id: String,
192 pub sex: String,
193 pub body_model_sha256: String,
194 pub target: [u32; 2],
195 pub fps: u32,
196 pub framing: crate::project::CameraFraming,
197 pub near: f32,
198 pub far: f32,
199 pub clip_tokens: Vec<(String, u32)>,
200}
201
202impl CompatKey {
203 pub fn from_run(run: &ExportRun) -> Self {
204 Self {
205 project_id: run.render.project.id.clone(),
206 sex: run.render.sex.clone(),
207 body_model_sha256: run.render.body_model.sha256.clone(),
208 target: run.render.target,
209 fps: run.render.fps,
210 framing: run.render.framing,
211 near: run.render.near,
212 far: run.render.far,
213 clip_tokens: run
214 .render
215 .clips
216 .iter()
217 .map(|c| (c.token.clone(), c.frames))
218 .collect(),
219 }
220 }
221}
222
223/// Reject runs that cannot be depth-composited together.
224pub fn assert_compatible(runs: &[&ExportRun]) -> Result<(), ComposeError> {
225 if runs.is_empty() {
226 return Err(ComposeError::Empty);
227 }
228 let first = CompatKey::from_run(runs[0]);
229 for run in runs.iter().skip(1) {
230 let key = CompatKey::from_run(run);
231 if key.project_id != first.project_id {
232 return Err(ComposeError::Incompatible(
233 "project.id verschilt tussen exportsets".into(),
234 ));
235 }
236 if key.sex != first.sex {
237 return Err(ComposeError::Incompatible(
238 "geslacht verschilt tussen exportsets".into(),
239 ));
240 }
241 if key.body_model_sha256 != first.body_model_sha256 {
242 return Err(ComposeError::Incompatible(
243 "body_model fingerprint verschilt".into(),
244 ));
245 }
246 if key.target != first.target {
247 return Err(ComposeError::Incompatible(
248 "target-resolutie verschilt".into(),
249 ));
250 }
251 if key.fps != first.fps {
252 return Err(ComposeError::Incompatible("fps verschilt".into()));
253 }
254 if key.framing != first.framing {
255 return Err(ComposeError::Incompatible("framing verschilt".into()));
256 }
257 if (key.near - first.near).abs() > f32::EPSILON
258 || (key.far - first.far).abs() > f32::EPSILON
259 {
260 return Err(ComposeError::Incompatible("near/far verschilt".into()));
261 }
262 if key.clip_tokens != first.clip_tokens {
263 return Err(ComposeError::Incompatible(
264 "clip-inventaris verschilt".into(),
265 ));
266 }
267 }
268 Ok(())
269}
270
271/// Composed target maps plus the winning layer index per pixel (`u8::MAX` = empty).
272#[derive(Debug, Clone)]
273pub struct ComposedMaps {
274 pub width: u32,
275 pub height: u32,
276 pub diffuse: Vec<u8>,
277 pub normal: Vec<u8>,
278 pub specular: Vec<u8>,
279 pub depth: Vec<u16>,
280 pub winner: Vec<u8>,
281}
282
283/// Select nearest valid surface per pixel; bind all maps from that layer.
284pub fn compose(layers: &[LayerMaps]) -> Result<ComposedMaps, ComposeError> {
285 if layers.is_empty() {
286 return Err(ComposeError::Empty);
287 }
288 let width = layers[0].width;
289 let height = layers[0].height;
290 if width == 0 || height == 0 {
291 return Err(ComposeError::Dimension("lege afmetingen".into()));
292 }
293 let pixels = (width as usize)
294 .checked_mul(height as usize)
295 .ok_or_else(|| ComposeError::Dimension("afmetingen overlopen".into()))?;
296 for (index, layer) in layers.iter().enumerate() {
297 if layer.width != width || layer.height != height {
298 return Err(ComposeError::Dimension(format!(
299 "laag {index} ('{}') heeft andere afmetingen",
300 layer.label
301 )));
302 }
303 if layer.diffuse.len() != pixels * 4
304 || layer.normal.len() != pixels * 4
305 || layer.specular.len() != pixels * 4
306 || layer.depth.len() != pixels * 2
307 {
308 return Err(ComposeError::Map(format!(
309 "laag {index} ('{}') heeft inconsistente buffers",
310 layer.label
311 )));
312 }
313 if layers.len() > u8::MAX as usize {
314 return Err(ComposeError::Map(
315 "maximaal 255 lagen voor winner-index".into(),
316 ));
317 }
318 }
319
320 let mut diffuse = vec![0u8; pixels * 4];
321 let mut normal = vec![0u8; pixels * 4];
322 let mut specular = vec![0u8; pixels * 4];
323 let mut depth = vec![0u16; pixels * 2];
324 let mut winner = vec![u8::MAX; pixels];
325
326 for i in 0..pixels {
327 let mut best_depth: Option<u16> = None;
328 let mut best_layer: Option<usize> = None;
329 for (layer_idx, layer) in layers.iter().enumerate() {
330 let depth_alpha = layer.depth[i * 2 + 1];
331 let diffuse_alpha = layer.diffuse[i * 4 + 3];
332 if depth_alpha == 0 || diffuse_alpha == 0 {
333 continue;
334 }
335 let gray = layer.depth[i * 2];
336 match (best_depth, best_layer) {
337 (None, _) => {
338 best_depth = Some(gray);
339 best_layer = Some(layer_idx);
340 }
341 (Some(bd), Some(_)) => {
342 if gray.saturating_add(DEPTH_EPSILON_U16) < bd {
343 // Strictly nearer beyond epsilon.
344 best_depth = Some(gray);
345 best_layer = Some(layer_idx);
346 } else if gray <= bd.saturating_add(DEPTH_EPSILON_U16) {
347 // Tie (within epsilon): keep lower layer index already chosen.
348 }
349 // else: farther — ignore
350 }
351 _ => unreachable!(),
352 }
353 }
354 if let (Some(_), Some(layer_idx)) = (best_depth, best_layer) {
355 let layer = &layers[layer_idx];
356 let o = i * 4;
357 diffuse[o..o + 4].copy_from_slice(&layer.diffuse[o..o + 4]);
358 normal[o..o + 4].copy_from_slice(&layer.normal[o..o + 4]);
359 specular[o..o + 4].copy_from_slice(&layer.specular[o..o + 4]);
360 depth[i * 2] = layer.depth[i * 2];
361 depth[i * 2 + 1] = layer.depth[i * 2 + 1];
362 winner[i] = layer_idx as u8;
363 }
364 }
365
366 Ok(ComposedMaps {
367 width,
368 height,
369 diffuse,
370 normal,
371 specular,
372 depth,
373 winner,
374 })
375}
376
377/// View-space directional light for consumer-side relighting.
378#[derive(Debug, Clone, Copy, PartialEq)]
379pub struct Light {
380 /// Direction **toward** the light, view space (will be normalized).
381 pub direction_view: [f32; 3],
382 pub ambient: f32,
383 pub diffuse: f32,
384 pub specular: f32,
385 pub shininess: f32,
386}
387
388impl Default for Light {
389 fn default() -> Self {
390 Self {
391 // Slightly above and toward +Z so a front-facing normal responds.
392 direction_view: [0.35, 0.55, 0.75],
393 ambient: 0.15,
394 diffuse: 0.75,
395 specular: 0.4,
396 shininess: DEFAULT_SHININESS,
397 }
398 }
399}
400
401/// Relight composed albedo/normal/specular into an sRGB RGBA image.
402pub fn relight(composed: &ComposedMaps, light: &Light) -> Result<ImageBuf, ComposeError> {
403 let pixels = (composed.width as usize)
404 .checked_mul(composed.height as usize)
405 .ok_or_else(|| ComposeError::Dimension("afmetingen overlopen".into()))?;
406 if composed.diffuse.len() != pixels * 4
407 || composed.normal.len() != pixels * 4
408 || composed.specular.len() != pixels * 4
409 {
410 return Err(ComposeError::Map(
411 "composed buffers passen niet bij afmetingen".into(),
412 ));
413 }
414 let l = normalize(light.direction_view);
415 let view = [0.0_f32, 0.0, 1.0];
416 let half = normalize([l[0] + view[0], l[1] + view[1], l[2] + view[2]]);
417 let mut rgba = vec![0u8; pixels * 4];
418 for i in 0..pixels {
419 let o = i * 4;
420 let a = composed.diffuse[o + 3];
421 if a == 0 || composed.winner[i] == u8::MAX {
422 continue;
423 }
424 let albedo = [
425 linear_srgb(composed.diffuse[o]),
426 linear_srgb(composed.diffuse[o + 1]),
427 linear_srgb(composed.diffuse[o + 2]),
428 ];
429 let n = decode_normal([
430 composed.normal[o],
431 composed.normal[o + 1],
432 composed.normal[o + 2],
433 ]);
434 let shiny = composed.specular[o] as f32 / 255.0;
435 let ndotl = dot(n, l).max(0.0);
436 let ndoth = dot(n, half).max(0.0);
437 let spec = shiny * ndoth.powf(light.shininess.max(1.0));
438 let mut lit = [0.0_f32; 3];
439 for c in 0..3 {
440 lit[c] = light.ambient * albedo[c]
441 + light.diffuse * ndotl * albedo[c]
442 + light.specular * spec;
443 lit[c] = lit[c].clamp(0.0, 1.0);
444 }
445 rgba[o] = encode_srgb(lit[0]);
446 rgba[o + 1] = encode_srgb(lit[1]);
447 rgba[o + 2] = encode_srgb(lit[2]);
448 rgba[o + 3] = a;
449 }
450 Ok(ImageBuf {
451 width: composed.width,
452 height: composed.height,
453 rgba,
454 })
455}
456
457/// Compose then relight in one step.
458pub fn compose_and_relight(
459 layers: &[LayerMaps],
460 light: &Light,
461) -> Result<(ComposedMaps, ImageBuf), ComposeError> {
462 let composed = compose(layers)?;
463 let lit = relight(&composed, light)?;
464 Ok((composed, lit))
465}
466
467/// Maximum additive linear rim intensity (after clamp).
468pub const MAX_RIM_INTENSITY: f32 = 0.45;
469
470/// Default subtle rim intensity used by the 2D preview.
471pub const DEFAULT_RIM_INTENSITY: f32 = 0.20;
472
473/// Default Fresnel exponent for `(1 − N·V)^power`.
474pub const DEFAULT_RIM_POWER: f32 = 3.0;
475
476/// Depth gray delta (u16 codes) that saturates the depth-edge term.
477pub const DEFAULT_DEPTH_EDGE_SCALE: f32 = 2048.0;
478
479/// Consumer-side rim / silhouette edge highlight parameters.
480///
481/// Applied after [`relight`]. Does not touch saved production albedo.
482#[derive(Debug, Clone, Copy, PartialEq)]
483pub struct RimParams {
484 /// Additive linear intensity; clamped to `[0, MAX_RIM_INTENSITY]`.
485 pub intensity: f32,
486 /// Fresnel exponent; values below 1 clamp up to 1.
487 pub power: f32,
488 /// Blend weight of the depth-discontinuity term in `[0, 1]`.
489 /// `0` = Fresnel only; `1` = depth edges only.
490 pub depth_edge_weight: f32,
491 /// Depth gray delta that maps to a full depth-edge contribution.
492 pub depth_edge_scale: f32,
493}
494
495impl Default for RimParams {
496 fn default() -> Self {
497 Self {
498 intensity: DEFAULT_RIM_INTENSITY,
499 power: DEFAULT_RIM_POWER,
500 depth_edge_weight: 0.35,
501 depth_edge_scale: DEFAULT_DEPTH_EDGE_SCALE,
502 }
503 }
504}
505
506impl RimParams {
507 /// Explicitly disabled rim (byte-identical to relight-only output).
508 pub fn off() -> Self {
509 Self {
510 intensity: 0.0,
511 ..Self::default()
512 }
513 }
514
515 /// Finite, clamped parameters used by [`apply_rim`].
516 pub fn clamped(self) -> Self {
517 Self {
518 intensity: if self.intensity.is_finite() {
519 self.intensity.clamp(0.0, MAX_RIM_INTENSITY)
520 } else {
521 0.0
522 },
523 power: if self.power.is_finite() {
524 self.power.max(1.0)
525 } else {
526 DEFAULT_RIM_POWER
527 },
528 depth_edge_weight: if self.depth_edge_weight.is_finite() {
529 self.depth_edge_weight.clamp(0.0, 1.0)
530 } else {
531 0.0
532 },
533 depth_edge_scale: if self.depth_edge_scale.is_finite() && self.depth_edge_scale > 1.0 {
534 self.depth_edge_scale
535 } else {
536 DEFAULT_DEPTH_EDGE_SCALE
537 },
538 }
539 }
540}
541
542/// Add a bounded rim/edge highlight on top of an already-relit sRGB image.
543///
544/// - Fresnel term from view-space normals with view = `+Z`.
545/// - Optional depth-edge term from max |Δdepth| to covered 4-neighbors.
546/// - Alpha/coverage is copied unchanged from `lit`.
547/// - Zero intensity returns a byte-identical clone of `lit`.
548pub fn apply_rim(
549 composed: &ComposedMaps,
550 lit: &ImageBuf,
551 rim: &RimParams,
552) -> Result<ImageBuf, ComposeError> {
553 let width = composed.width;
554 let height = composed.height;
555 let pixels = (width as usize)
556 .checked_mul(height as usize)
557 .ok_or_else(|| ComposeError::Dimension("afmetingen overlopen".into()))?;
558 if lit.width != width
559 || lit.height != height
560 || lit.rgba.len() != pixels * 4
561 || composed.diffuse.len() != pixels * 4
562 || composed.normal.len() != pixels * 4
563 || composed.depth.len() != pixels * 2
564 || composed.winner.len() != pixels
565 {
566 return Err(ComposeError::Map(
567 "rim buffers passen niet bij composed/lit afmetingen".into(),
568 ));
569 }
570
571 let rim = rim.clamped();
572 if rim.intensity == 0.0 {
573 return Ok(ImageBuf {
574 width,
575 height,
576 rgba: lit.rgba.clone(),
577 });
578 }
579
580 let view = [0.0_f32, 0.0, 1.0];
581 let mut rgba = lit.rgba.clone();
582 let w = width as usize;
583 let h = height as usize;
584
585 for y in 0..h {
586 for x in 0..w {
587 let i = y * w + x;
588 let o = i * 4;
589 let a = rgba[o + 3];
590 if a == 0 || composed.winner[i] == u8::MAX {
591 continue;
592 }
593 let n = decode_normal([
594 composed.normal[o],
595 composed.normal[o + 1],
596 composed.normal[o + 2],
597 ]);
598 let ndotv = dot(n, view).clamp(0.0, 1.0);
599 let fresnel = (1.0 - ndotv).powf(rim.power);
600
601 let mut depth_edge = 0.0_f32;
602 if rim.depth_edge_weight > 0.0 {
603 let center = composed.depth[i * 2];
604 let mut max_delta = 0u16;
605 let mut silhouette = false;
606 for (dx, dy) in [(-1isize, 0), (1, 0), (0, -1), (0, 1)] {
607 let nx = x as isize + dx;
608 let ny = y as isize + dy;
609 if nx < 0 || ny < 0 || nx as usize >= w || ny as usize >= h {
610 // Image border next to covered geometry counts as a silhouette edge.
611 silhouette = true;
612 break;
613 }
614 let ni = ny as usize * w + nx as usize;
615 if composed.winner[ni] == u8::MAX || composed.depth[ni * 2 + 1] == 0 {
616 silhouette = true;
617 break;
618 }
619 let d = center.abs_diff(composed.depth[ni * 2]);
620 max_delta = max_delta.max(d);
621 }
622 depth_edge = if silhouette {
623 1.0
624 } else {
625 (max_delta as f32 / rim.depth_edge_scale).clamp(0.0, 1.0)
626 };
627 }
628
629 let factor =
630 fresnel * (1.0 - rim.depth_edge_weight) + depth_edge * rim.depth_edge_weight;
631 if factor <= 0.0 {
632 continue;
633 }
634 let add = rim.intensity * factor;
635 let mut lin = [
636 linear_srgb(rgba[o]),
637 linear_srgb(rgba[o + 1]),
638 linear_srgb(rgba[o + 2]),
639 ];
640 for channel in &mut lin {
641 *channel = (*channel + add).clamp(0.0, 1.0);
642 }
643 rgba[o] = encode_srgb(lin[0]);
644 rgba[o + 1] = encode_srgb(lin[1]);
645 rgba[o + 2] = encode_srgb(lin[2]);
646 // Alpha intentionally untouched.
647 }
648 }
649
650 Ok(ImageBuf {
651 width,
652 height,
653 rgba,
654 })
655}
656
657/// Compose, relight, then apply rim in one step.
658pub fn compose_relight_and_rim(
659 layers: &[LayerMaps],
660 light: &Light,
661 rim: &RimParams,
662) -> Result<(ComposedMaps, ImageBuf), ComposeError> {
663 let (composed, lit) = compose_and_relight(layers, light)?;
664 let rimmed = apply_rim(&composed, &lit, rim)?;
665 Ok((composed, rimmed))
666}
667
668fn decode_normal(rgb: [u8; 3]) -> [f32; 3] {
669 normalize([
670 rgb[0] as f32 / 255.0 * 2.0 - 1.0,
671 rgb[1] as f32 / 255.0 * 2.0 - 1.0,
672 rgb[2] as f32 / 255.0 * 2.0 - 1.0,
673 ])
674}
675
676fn normalize(v: [f32; 3]) -> [f32; 3] {
677 let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
678 if len > 1e-10 {
679 [v[0] / len, v[1] / len, v[2] / len]
680 } else {
681 [0.0, 0.0, 1.0]
682 }
683}
684
685fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
686 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
687}
688
689fn linear_srgb(x: u8) -> f32 {
690 static LUT: std::sync::OnceLock<[f32; 256]> = std::sync::OnceLock::new();
691 LUT.get_or_init(|| {
692 std::array::from_fn(|i| {
693 let v = i as f32 / 255.;
694 if v <= 0.04045 {
695 v / 12.92
696 } else {
697 ((v + 0.055) / 1.055).powf(2.4)
698 }
699 })
700 })[x as usize]
701}
702
703fn encode_srgb(x: f32) -> u8 {
704 let v = if x <= 0.0031308 {
705 x * 12.92
706 } else {
707 1.055 * x.powf(1. / 2.4) - 0.055
708 };
709 (v * 255.).round().clamp(0., 255.) as u8
710}
711
712#[cfg(test)]
713mod tests {
714 use super::*;
715 use crate::digest;
716 use crate::export_set::{complete_identity_json, ExportRun};
717 use crate::production::{write_depth, write_rgba, FAR, NEAR, PASSES};
718 use crate::project::CameraFraming;
719 use std::collections::BTreeMap;
720 use std::fs;
721 use std::path::{Path, PathBuf};
722 use std::sync::atomic::{AtomicU64, Ordering};
723
724 struct Temp(PathBuf);
725 impl Temp {
726 fn new() -> Self {
727 static N: AtomicU64 = AtomicU64::new(0);
728 let id = N.fetch_add(1, Ordering::Relaxed);
729 let path = std::env::temp_dir().join(format!(
730 "paperdoll-compositor-{}-{}",
731 std::process::id(),
732 id
733 ));
734 let _ = fs::remove_dir_all(&path);
735 fs::create_dir_all(&path).unwrap();
736 Self(path)
737 }
738 }
739 impl Drop for Temp {
740 fn drop(&mut self) {
741 let _ = fs::remove_dir_all(&self.0);
742 }
743 }
744
745 fn framing() -> CameraFraming {
746 CameraFraming {
747 height_milli: 2600,
748 center_y_milli: 1000,
749 pivot_y_milli: Some(1460),
750 }
751 }
752
753 #[allow(clippy::too_many_arguments)]
754 fn solid_layer(
755 label: &str,
756 w: u32,
757 h: u32,
758 diffuse: [u8; 4],
759 normal: [u8; 4],
760 specular: [u8; 4],
761 depth_gray: u16,
762 depth_alpha: u16,
763 ) -> LayerMaps {
764 let pixels = (w * h) as usize;
765 LayerMaps {
766 label: label.into(),
767 width: w,
768 height: h,
769 diffuse: diffuse.repeat(pixels),
770 normal: normal.repeat(pixels),
771 specular: specular.repeat(pixels),
772 depth: [depth_gray, depth_alpha]
773 .into_iter()
774 .cycle()
775 .take(pixels * 2)
776 .collect(),
777 }
778 }
779
780 fn pixel_layer(
781 label: &str,
782 w: u32,
783 h: u32,
784 fill: impl Fn(usize) -> ([u8; 4], [u8; 4], [u8; 4], u16, u16),
785 ) -> LayerMaps {
786 let pixels = (w * h) as usize;
787 let mut diffuse = vec![0; pixels * 4];
788 let mut normal = vec![0; pixels * 4];
789 let mut specular = vec![0; pixels * 4];
790 let mut depth = vec![0u16; pixels * 2];
791 for i in 0..pixels {
792 let (d, n, s, g, a) = fill(i);
793 diffuse[i * 4..i * 4 + 4].copy_from_slice(&d);
794 normal[i * 4..i * 4 + 4].copy_from_slice(&n);
795 specular[i * 4..i * 4 + 4].copy_from_slice(&s);
796 depth[i * 2] = g;
797 depth[i * 2 + 1] = a;
798 }
799 LayerMaps {
800 label: label.into(),
801 width: w,
802 height: h,
803 diffuse,
804 normal,
805 specular,
806 depth,
807 }
808 }
809
810 #[test]
811 fn front_occludes_back_and_binds_same_layer_maps() {
812 // 2×1: left pixel has front red equipment over blue body; right is body only.
813 let body = pixel_layer("body", 2, 1, |i| {
814 let depth = if i == 0 { 30000 } else { 20000 };
815 (
816 [0, 0, 200, 255],
817 [128, 128, 255, 255],
818 [0, 0, 0, 255],
819 depth,
820 65535,
821 )
822 });
823 let gear = pixel_layer("sword", 2, 1, |i| {
824 if i == 0 {
825 (
826 [200, 0, 0, 255],
827 [255, 128, 128, 255], // +X-ish normal from gear
828 [180, 180, 180, 255],
829 10000, // nearer
830 65535,
831 )
832 } else {
833 ([0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], 0, 0)
834 }
835 });
836 let composed = compose(&[body, gear]).unwrap();
837 assert_eq!(composed.winner[0], 1, "front equipment wins left");
838 assert_eq!(&composed.diffuse[0..4], &[200, 0, 0, 255]);
839 assert_eq!(&composed.normal[0..4], &[255, 128, 128, 255]);
840 assert_eq!(&composed.specular[0..4], &[180, 180, 180, 255]);
841 assert_eq!(composed.depth[0], 10000);
842 assert_eq!(composed.winner[1], 0, "body remains on right");
843 assert_eq!(&composed.diffuse[4..8], &[0, 0, 200, 255]);
844 assert_eq!(&composed.specular[4..8], &[0, 0, 0, 255]);
845 }
846
847 #[test]
848 fn epsilon_ties_prefer_lower_layer_index() {
849 let body = solid_layer(
850 "body",
851 1,
852 1,
853 [10, 20, 30, 255],
854 [128, 128, 255, 255],
855 [0, 0, 0, 255],
856 20000,
857 65535,
858 );
859 // Within DEPTH_EPSILON_U16 of body depth.
860 let gear = solid_layer(
861 "hat",
862 1,
863 1,
864 [90, 80, 70, 255],
865 [128, 200, 128, 255],
866 [50, 50, 50, 255],
867 20000 + DEPTH_EPSILON_U16,
868 65535,
869 );
870 let composed = compose(&[body.clone(), gear.clone()]).unwrap();
871 assert_eq!(composed.winner[0], 0);
872 assert_eq!(&composed.diffuse[..4], &[10, 20, 30, 255]);
873
874 // Reverse order: now gear is index 0 and wins the tie.
875 let reversed = compose(&[gear, body]).unwrap();
876 assert_eq!(reversed.winner[0], 0);
877 assert_eq!(&reversed.diffuse[..4], &[90, 80, 70, 255]);
878 }
879
880 #[test]
881 fn strictly_nearer_beyond_epsilon_beats_earlier_layer() {
882 let body = solid_layer(
883 "body",
884 1,
885 1,
886 [1, 2, 3, 255],
887 [128, 128, 255, 255],
888 [0, 0, 0, 255],
889 20000,
890 65535,
891 );
892 let gear = solid_layer(
893 "cloak",
894 1,
895 1,
896 [9, 8, 7, 255],
897 [128, 128, 255, 255],
898 [0, 0, 0, 255],
899 20000 - DEPTH_EPSILON_U16 - 1,
900 65535,
901 );
902 let composed = compose(&[body, gear]).unwrap();
903 assert_eq!(composed.winner[0], 1);
904 assert_eq!(&composed.diffuse[..4], &[9, 8, 7, 255]);
905 }
906
907 #[test]
908 fn transparency_and_alpha_edges_follow_documented_policy() {
909 // Center soft-edge body; equipment only on left with full coverage;
910 // right pixel has transparent equipment over body — body keeps soft alpha.
911 let body = pixel_layer("body", 3, 1, |i| {
912 let alpha = match i {
913 0 => 255,
914 1 => 128, // soft edge
915 _ => 255,
916 };
917 (
918 [40, 40, 40, alpha],
919 [128, 128, 255, alpha],
920 [0, 0, 0, alpha],
921 25000,
922 u16::from(alpha) * 257,
923 )
924 });
925 let gear = pixel_layer("pad", 3, 1, |i| match i {
926 0 => (
927 [255, 0, 0, 255],
928 [128, 128, 255, 255],
929 [0, 0, 0, 255],
930 10000,
931 65535,
932 ),
933 // Transparent equipment must not steal the soft body edge.
934 1 => ([255, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], 5000, 0),
935 // Semi-transparent losing layer behind would also be ignored if farther;
936 // here nearer but alpha 0 — skipped.
937 _ => ([0, 255, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], 5000, 0),
938 });
939 let composed = compose(&[body, gear]).unwrap();
940 assert_eq!(composed.winner[0], 1);
941 assert_eq!(composed.diffuse[3], 255);
942 assert_eq!(composed.winner[1], 0);
943 assert_eq!(
944 composed.diffuse[7], 128,
945 "winner soft alpha preserved; no cross-layer blend"
946 );
947 assert_eq!(composed.winner[2], 0);
948 assert_eq!(&composed.diffuse[8..12], &[40, 40, 40, 255]);
949 }
950
951 #[test]
952 fn lighting_responds_to_normal_and_light_direction() {
953 let facing = solid_layer(
954 "front",
955 1,
956 1,
957 [200, 200, 200, 255],
958 [128, 128, 255, 255], // ~+Z
959 [0, 0, 0, 255],
960 10000,
961 65535,
962 );
963 let away = solid_layer(
964 "back",
965 1,
966 1,
967 [200, 200, 200, 255],
968 [128, 128, 0, 255], // ~-Z
969 [0, 0, 0, 255],
970 10000,
971 65535,
972 );
973 let light = Light {
974 direction_view: [0.0, 0.0, 1.0],
975 ambient: 0.05,
976 diffuse: 0.9,
977 specular: 0.0,
978 shininess: 16.0,
979 };
980 let lit_front = relight(&compose(&[facing]).unwrap(), &light).unwrap();
981 let lit_back = relight(&compose(&[away]).unwrap(), &light).unwrap();
982 let front_luma =
983 lit_front.rgba[0] as u32 + lit_front.rgba[1] as u32 + lit_front.rgba[2] as u32;
984 let back_luma = lit_back.rgba[0] as u32 + lit_back.rgba[1] as u32 + lit_back.rgba[2] as u32;
985 assert!(
986 front_luma > back_luma + 40,
987 "front-facing should be brighter: {front_luma} vs {back_luma}"
988 );
989
990 let shiny = solid_layer(
991 "metal",
992 1,
993 1,
994 [180, 180, 180, 255],
995 [128, 128, 255, 255],
996 [255, 255, 255, 255],
997 10000,
998 65535,
999 );
1000 let matte = solid_layer(
1001 "matte",
1002 1,
1003 1,
1004 [180, 180, 180, 255],
1005 [128, 128, 255, 255],
1006 [0, 0, 0, 255],
1007 10000,
1008 65535,
1009 );
1010 let spec_light = Light {
1011 direction_view: [0.0, 0.0, 1.0],
1012 ambient: 0.0,
1013 diffuse: 0.2,
1014 specular: 1.0,
1015 shininess: 64.0,
1016 };
1017 let lit_shiny = relight(&compose(&[shiny]).unwrap(), &spec_light).unwrap();
1018 let lit_matte = relight(&compose(&[matte]).unwrap(), &spec_light).unwrap();
1019 let shiny_luma =
1020 lit_shiny.rgba[0] as u32 + lit_shiny.rgba[1] as u32 + lit_shiny.rgba[2] as u32;
1021 let matte_luma =
1022 lit_matte.rgba[0] as u32 + lit_matte.rgba[1] as u32 + lit_matte.rgba[2] as u32;
1023 assert!(
1024 shiny_luma > matte_luma + 20,
1025 "specular map must brighten highlight: {shiny_luma} vs {matte_luma}"
1026 );
1027 assert_eq!(lit_shiny.rgba[3], 255);
1028 }
1029
1030 #[test]
1031 fn empty_and_mismatched_layers_error() {
1032 assert!(matches!(compose(&[]), Err(ComposeError::Empty)));
1033 let a = solid_layer(
1034 "a",
1035 1,
1036 1,
1037 [1, 1, 1, 255],
1038 [128, 128, 255, 255],
1039 [0, 0, 0, 255],
1040 1,
1041 65535,
1042 );
1043 let b = solid_layer(
1044 "b",
1045 2,
1046 1,
1047 [1, 1, 1, 255],
1048 [128, 128, 255, 255],
1049 [0, 0, 0, 255],
1050 1,
1051 65535,
1052 );
1053 assert!(matches!(compose(&[a, b]), Err(ComposeError::Dimension(_))));
1054 }
1055
1056 fn write_atomic_json(root: &Path, name: &str, value: &serde_json::Value) {
1057 let data = serde_json::to_vec_pretty(value).unwrap();
1058 let path = root.join(name);
1059 let temp = path.with_extension(format!("part-{}", std::process::id()));
1060 fs::write(&temp, &data).unwrap();
1061 fs::hard_link(&temp, &path).unwrap();
1062 let _ = fs::remove_file(temp);
1063 }
1064
1065 fn publish_pose(
1066 root: &Path,
1067 sex: &str,
1068 set: &str,
1069 diffuse: [u8; 4],
1070 normal: [u8; 4],
1071 specular: [u8; 4],
1072 depth_gray: u16,
1073 ) -> BTreeMap<String, String> {
1074 let mut map = BTreeMap::new();
1075 let base = format!("{sex}/{set}/clip000-bind/f000000/p+00_y000");
1076 let rels = [
1077 (format!("{base}_diffuse.png"), "diffuse"),
1078 (format!("{base}_depth.png"), "depth"),
1079 (format!("{base}_normal.png"), "normal"),
1080 (format!("{base}_specular.png"), "specular"),
1081 ];
1082 for (rel, kind) in rels {
1083 let path = Path::new(&rel);
1084 match kind {
1085 "depth" => {
1086 write_depth(root, path, 1, 1, &[depth_gray, 65535]).unwrap();
1087 }
1088 "normal" => {
1089 write_rgba(
1090 root,
1091 path,
1092 &ImageBuf {
1093 width: 1,
1094 height: 1,
1095 rgba: normal.to_vec(),
1096 },
1097 )
1098 .unwrap();
1099 }
1100 "specular" => {
1101 write_rgba(
1102 root,
1103 path,
1104 &ImageBuf {
1105 width: 1,
1106 height: 1,
1107 rgba: specular.to_vec(),
1108 },
1109 )
1110 .unwrap();
1111 }
1112 _ => {
1113 write_rgba(
1114 root,
1115 path,
1116 &ImageBuf {
1117 width: 1,
1118 height: 1,
1119 rgba: diffuse.to_vec(),
1120 },
1121 )
1122 .unwrap();
1123 }
1124 }
1125 let bytes = fs::read(root.join(&rel)).unwrap();
1126 map.insert(rel, digest::sha256_hex(&bytes));
1127 }
1128 map
1129 }
1130
1131 fn write_completed_run(
1132 root: &Path,
1133 set: &str,
1134 equipment_sha: Option<&str>,
1135 checksums: BTreeMap<String, String>,
1136 body_bytes: &[u8],
1137 ) {
1138 let body_hash = digest::sha256_hex(body_bytes);
1139 let mut render = serde_json::json!({
1140 "format_version": 3,
1141 "status": "incomplete",
1142 "project": {"id": "demo", "name": "Demo"},
1143 "sex": "male",
1144 "set": set,
1145 "body_model": {"path": "assets/body/x.glb", "sha256": body_hash},
1146 "equipment_preset": null,
1147 "target": [1, 1],
1148 "fps": 12,
1149 "highres_scale": 8,
1150 "framing": framing(),
1151 "pivot_source": {"kind": "manual"},
1152 "near": NEAR,
1153 "far": FAR,
1154 "camera_distance": 10.0,
1155 "yaw_zero": "camera on -Z",
1156 "normal_space": "view (+Y up), PNG rows top-down",
1157 "pitches": [-30, 0, 30],
1158 "yaw_step": 30,
1159 "sampling": "test",
1160 "alpha": "straight",
1161 "diffuse": "unlit",
1162 "depth": "16-bit",
1163 "specular": "scalar",
1164 "pass_order": PASSES,
1165 "saved_maps": ["diffuse", "depth", "normal", "specular"],
1166 "clips": [{"name": "", "token": "clip000-bind", "frames": 1, "duration": 0.0}],
1167 "images": 1,
1168 "passes": 6,
1169 "png_files": 4
1170 });
1171 if let Some(sha) = equipment_sha {
1172 render["equipment_preset"] = serde_json::json!({"name": set, "sha256": sha});
1173 }
1174 write_atomic_json(root, "render.json", &render);
1175 let identity = complete_identity_json(&render).unwrap();
1176 write_atomic_json(
1177 root,
1178 "complete.json",
1179 &serde_json::json!({
1180 "format_version": 3,
1181 "status": "complete",
1182 "identity": identity,
1183 "passes": 6,
1184 "png_files": 4,
1185 "png_sha256": checksums
1186 }),
1187 );
1188 }
1189
1190 #[test]
1191 fn export_set_layers_compose_through_safe_reader() {
1192 let body_tmp = Temp::new();
1193 let gear_tmp = Temp::new();
1194 let body_bytes = b"shared-body";
1195 let body_maps = publish_pose(
1196 &body_tmp.0,
1197 "male",
1198 "body",
1199 [20, 40, 60, 255],
1200 [128, 128, 255, 255],
1201 [0, 0, 0, 255],
1202 30000,
1203 );
1204 write_completed_run(&body_tmp.0, "body", None, body_maps, body_bytes);
1205 let gear_hash = digest::sha256_hex(b"sword-ppe");
1206 let gear_maps = publish_pose(
1207 &gear_tmp.0,
1208 "male",
1209 "sword",
1210 [220, 10, 10, 255],
1211 [255, 128, 128, 255],
1212 [200, 200, 200, 255],
1213 12000,
1214 );
1215 write_completed_run(
1216 &gear_tmp.0,
1217 "sword",
1218 Some(&gear_hash),
1219 gear_maps,
1220 body_bytes,
1221 );
1222
1223 let body_run = ExportRun::open(&body_tmp.0).unwrap();
1224 let gear_run = ExportRun::open(&gear_tmp.0).unwrap();
1225 assert_compatible(&[&body_run, &gear_run]).unwrap();
1226
1227 let body_layer =
1228 LayerMaps::from_export_run(&body_run, "body", "clip000-bind", 0, 0, 0).unwrap();
1229 let gear_layer =
1230 LayerMaps::from_export_run(&gear_run, "sword", "clip000-bind", 0, 0, 0).unwrap();
1231 let (composed, lit) =
1232 compose_and_relight(&[body_layer, gear_layer], &Light::default()).unwrap();
1233 assert_eq!(composed.winner[0], 1);
1234 assert_eq!(&composed.diffuse[..4], &[220, 10, 10, 255]);
1235 assert_eq!(&composed.specular[..4], &[200, 200, 200, 255]);
1236 assert_eq!(lit.rgba[3], 255);
1237 assert!(lit.rgba[0] > 0);
1238 }
1239
1240 #[test]
1241 fn incompatible_export_sets_are_rejected() {
1242 let a = Temp::new();
1243 let b = Temp::new();
1244 let maps_a = publish_pose(
1245 &a.0,
1246 "male",
1247 "body",
1248 [1, 2, 3, 255],
1249 [128, 128, 255, 255],
1250 [0, 0, 0, 255],
1251 1000,
1252 );
1253 write_completed_run(&a.0, "body", None, maps_a, b"body-a");
1254 let maps_b = publish_pose(
1255 &b.0,
1256 "male",
1257 "body",
1258 [1, 2, 3, 255],
1259 [128, 128, 255, 255],
1260 [0, 0, 0, 255],
1261 1000,
1262 );
1263 write_completed_run(&b.0, "body", None, maps_b, b"body-b");
1264 let run_a = ExportRun::open(&a.0).unwrap();
1265 let run_b = ExportRun::open(&b.0).unwrap();
1266 let err = assert_compatible(&[&run_a, &run_b]).unwrap_err();
1267 assert!(matches!(err, ComposeError::Incompatible(_)));
1268 }
1269
1270 fn encode_normal_rgb(n: [f32; 3]) -> [u8; 4] {
1271 let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
1272 let n = if len > 1e-10 {
1273 [n[0] / len, n[1] / len, n[2] / len]
1274 } else {
1275 [0.0, 0.0, 1.0]
1276 };
1277 [
1278 ((n[0] * 0.5 + 0.5) * 255.0).round().clamp(0.0, 255.0) as u8,
1279 ((n[1] * 0.5 + 0.5) * 255.0).round().clamp(0.0, 255.0) as u8,
1280 ((n[2] * 0.5 + 0.5) * 255.0).round().clamp(0.0, 255.0) as u8,
1281 255,
1282 ]
1283 }
1284
1285 fn luma(rgba: &[u8]) -> u32 {
1286 rgba[0] as u32 + rgba[1] as u32 + rgba[2] as u32
1287 }
1288
1289 #[test]
1290 fn rim_zero_intensity_matches_relight_exactly() {
1291 let layer = solid_layer(
1292 "body",
1293 1,
1294 1,
1295 [180, 40, 40, 200],
1296 encode_normal_rgb([0.7, 0.1, 0.2]),
1297 [0, 0, 0, 255],
1298 12000,
1299 65535,
1300 );
1301 let composed = compose(&[layer]).unwrap();
1302 let lit = relight(&composed, &Light::default()).unwrap();
1303 let rimmed = apply_rim(&composed, &lit, &RimParams::off()).unwrap();
1304 assert_eq!(rimmed.rgba, lit.rgba);
1305 let (_c, via) = compose_relight_and_rim(
1306 &[solid_layer(
1307 "body",
1308 1,
1309 1,
1310 [180, 40, 40, 200],
1311 encode_normal_rgb([0.7, 0.1, 0.2]),
1312 [0, 0, 0, 255],
1313 12000,
1314 65535,
1315 )],
1316 &Light::default(),
1317 &RimParams::off(),
1318 )
1319 .unwrap();
1320 assert_eq!(via.rgba, lit.rgba);
1321 }
1322
1323 #[test]
1324 fn rim_flat_front_facing_surface_stays_near_relight() {
1325 // Uniform +Z normal → Fresnel ≈ 0; no depth neighbors → depth edge from border only.
1326 // Use depth_edge_weight = 0 so only Fresnel matters.
1327 let layer = solid_layer(
1328 "flat",
1329 3,
1330 3,
1331 [120, 120, 120, 255],
1332 encode_normal_rgb([0.0, 0.0, 1.0]),
1333 [0, 0, 0, 255],
1334 20000,
1335 65535,
1336 );
1337 let composed = compose(&[layer]).unwrap();
1338 let lit = relight(&composed, &Light::default()).unwrap();
1339 let rim = RimParams {
1340 intensity: MAX_RIM_INTENSITY,
1341 power: DEFAULT_RIM_POWER,
1342 depth_edge_weight: 0.0,
1343 depth_edge_scale: DEFAULT_DEPTH_EDGE_SCALE,
1344 };
1345 let rimmed = apply_rim(&composed, &lit, &rim).unwrap();
1346 // Center pixel has no Fresnel contribution on a perfect front-facing normal.
1347 let c = 16; // (row 1, col 1) in 3×3
1348 assert_eq!(&rimmed.rgba[c..c + 4], &lit.rgba[c..c + 4]);
1349 // Alpha preserved everywhere.
1350 for i in 0..9 {
1351 assert_eq!(rimmed.rgba[i * 4 + 3], lit.rgba[i * 4 + 3]);
1352 }
1353 }
1354
1355 #[test]
1356 fn rim_grazing_angle_adds_bounded_highlight() {
1357 let facing = solid_layer(
1358 "face",
1359 1,
1360 1,
1361 [80, 80, 80, 255],
1362 encode_normal_rgb([0.0, 0.0, 1.0]),
1363 [0, 0, 0, 255],
1364 15000,
1365 65535,
1366 );
1367 let grazing = solid_layer(
1368 "edge",
1369 1,
1370 1,
1371 [80, 80, 80, 255],
1372 encode_normal_rgb([0.95, 0.0, 0.05]),
1373 [0, 0, 0, 255],
1374 15000,
1375 65535,
1376 );
1377 let light = Light {
1378 ambient: 0.2,
1379 diffuse: 0.0,
1380 specular: 0.0,
1381 ..Light::default()
1382 };
1383 let rim = RimParams {
1384 intensity: 0.30,
1385 power: 2.0,
1386 depth_edge_weight: 0.0,
1387 depth_edge_scale: DEFAULT_DEPTH_EDGE_SCALE,
1388 };
1389 let face_c = compose(&[facing]).unwrap();
1390 let edge_c = compose(&[grazing]).unwrap();
1391 let face_lit = relight(&face_c, &light).unwrap();
1392 let edge_lit = relight(&edge_c, &light).unwrap();
1393 let face_rim = apply_rim(&face_c, &face_lit, &rim).unwrap();
1394 let edge_rim = apply_rim(&edge_c, &edge_lit, &rim).unwrap();
1395 assert_eq!(
1396 face_rim.rgba, face_lit.rgba,
1397 "flat front must not gain Fresnel rim"
1398 );
1399 assert!(
1400 luma(&edge_rim.rgba) > luma(&edge_lit.rgba),
1401 "grazing must brighten: {} vs {}",
1402 luma(&edge_rim.rgba),
1403 luma(&edge_lit.rgba)
1404 );
1405 // Bound: additive intensity cannot exceed MAX after clamp; RGB stay ≤ 255 and alpha intact.
1406 assert_eq!(edge_rim.rgba[3], 255);
1407 assert_eq!(edge_rim.rgba[3], edge_lit.rgba[3]);
1408 for c in 0..3 {
1409 assert!(edge_rim.rgba[c] >= edge_lit.rgba[c]);
1410 }
1411 // Over-large intensity clamps; result still finite and ≤ full white from black+max.
1412 let over = RimParams {
1413 intensity: 99.0,
1414 power: 1.0,
1415 depth_edge_weight: 0.0,
1416 depth_edge_scale: DEFAULT_DEPTH_EDGE_SCALE,
1417 };
1418 assert_eq!(over.clamped().intensity, MAX_RIM_INTENSITY);
1419 let clamped_img = apply_rim(&edge_c, &edge_lit, &over).unwrap();
1420 // Over-max intensity still preserves alpha and stays within sRGB encoding.
1421 assert_eq!(clamped_img.rgba[3], 255);
1422 assert!(luma(&clamped_img.rgba) >= luma(&edge_lit.rgba));
1423 }
1424
1425 #[test]
1426 fn rim_depth_edge_highlights_silhouette_not_flat_interior() {
1427 // 3×3 with uniform normals facing camera; center depth differs strongly from ring.
1428 let layer = pixel_layer("sil", 3, 3, |i| {
1429 let x = i % 3;
1430 let y = i / 3;
1431 let border = x == 0 || y == 0 || x == 2 || y == 2;
1432 let gray = if border { 10000 } else { 40000 };
1433 (
1434 [60, 60, 60, 255],
1435 encode_normal_rgb([0.0, 0.0, 1.0]),
1436 [0, 0, 0, 255],
1437 gray,
1438 65535,
1439 )
1440 });
1441 let composed = compose(&[layer]).unwrap();
1442 let light = Light {
1443 ambient: 0.25,
1444 diffuse: 0.0,
1445 specular: 0.0,
1446 ..Light::default()
1447 };
1448 let lit = relight(&composed, &light).unwrap();
1449 let rim = RimParams {
1450 intensity: 0.35,
1451 power: 4.0,
1452 depth_edge_weight: 1.0, // depth only
1453 depth_edge_scale: 5000.0,
1454 };
1455 let rimmed = apply_rim(&composed, &lit, &rim).unwrap();
1456 let center = 16; // (row 1, col 1) in 3×3
1457 let edge = 4; // top-middle (row 0, col 1)
1458 // Interior center sees large depth deltas to neighbors → highlighted.
1459 assert!(
1460 luma(&rimmed.rgba[center..center + 4]) > luma(&lit.rgba[center..center + 4]),
1461 "depth jump at interior should rim"
1462 );
1463 assert!(
1464 luma(&rimmed.rgba[edge..edge + 4]) > luma(&lit.rgba[edge..edge + 4]),
1465 "border pixel next to depth jump should rim"
1466 );
1467 for i in 0..9 {
1468 assert_eq!(rimmed.rgba[i * 4 + 3], 255);
1469 }
1470 }
1471
1472 #[test]
1473 fn rim_preserves_transparent_and_soft_alpha() {
1474 let layer = pixel_layer("a", 2, 1, |i| {
1475 if i == 0 {
1476 ([50, 50, 50, 0], [0, 0, 0, 0], [0, 0, 0, 0], 0, 0)
1477 } else {
1478 (
1479 [50, 50, 50, 128],
1480 encode_normal_rgb([0.9, 0.0, 0.1]),
1481 [0, 0, 0, 255],
1482 12000,
1483 65535,
1484 )
1485 }
1486 });
1487 let composed = compose(&[layer]).unwrap();
1488 let lit = relight(&composed, &Light::default()).unwrap();
1489 let rimmed = apply_rim(&composed, &lit, &RimParams::default()).unwrap();
1490 assert_eq!(&rimmed.rgba[0..4], &[0, 0, 0, 0]);
1491 assert_eq!(rimmed.rgba[7], 128);
1492 assert_eq!(rimmed.rgba[7], lit.rgba[7]);
1493 }
1494}