| 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. |
| 52 | use crate::{ |
| 53 | export_set::{ExportError, ExportRun, MapImage, MapKind}, |
| 54 | render_job::ImageBuf, |
| 55 | }; |
| 56 | use std::fmt; |
| 57 | |
| 58 | /// Depth codes within this inclusive distance count as a tie. |
| 59 | pub const DEPTH_EPSILON_U16: u16 = 1; |
| 60 | |
| 61 | /// Default Blinn-Phong exponent for the simple preview light. |
| 62 | pub const DEFAULT_SHININESS: f32 = 32.0; |
| 63 | |
| 64 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 65 | pub enum ComposeError { |
| 66 | Empty, |
| 67 | Dimension(String), |
| 68 | Map(String), |
| 69 | Incompatible(String), |
| 70 | Export(String), |
| 71 | } |
| 72 | |
| 73 | impl 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 | |
| 84 | impl std::error::Error for ComposeError {} |
| 85 | |
| 86 | impl 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)] |
| 94 | pub 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 | |
| 105 | impl 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 | |
| 162 | fn 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 | |
| 175 | fn 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)] |
| 190 | pub 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 | |
| 202 | impl 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. |
| 224 | pub 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)] |
| 273 | pub 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. |
| 284 | pub 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)] |
| 379 | pub 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 | |
| 388 | impl 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. |
| 402 | pub 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. |
| 458 | pub 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). |
| 468 | pub const MAX_RIM_INTENSITY: f32 = 0.45; |
| 469 | |
| 470 | /// Default subtle rim intensity used by the 2D preview. |
| 471 | pub const DEFAULT_RIM_INTENSITY: f32 = 0.20; |
| 472 | |
| 473 | /// Default Fresnel exponent for `(1 − N·V)^power`. |
| 474 | pub const DEFAULT_RIM_POWER: f32 = 3.0; |
| 475 | |
| 476 | /// Depth gray delta (u16 codes) that saturates the depth-edge term. |
| 477 | pub 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)] |
| 483 | pub 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 | |
| 495 | impl 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 | |
| 506 | impl 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`. |
| 548 | pub 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. |
| 658 | pub 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 | |
| 668 | fn 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 | |
| 676 | fn 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 | |
| 685 | fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { |
| 686 | a[0] * b[0] + a[1] * b[1] + a[2] * b[2] |
| 687 | } |
| 688 | |
| 689 | fn 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 | |
| 703 | fn 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)] |
| 713 | mod 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 | } |