From 32ae41c4ebd35ed15b527f219a7b659c2e134690 Mon Sep 17 00:00:00 2001 From: Christopher Linnett Date: Fri, 23 Oct 2026 16:21:21 +0800 Subject: [PATCH] #2094 (adv): crisp cast shadows so diffusion only softens Shadow maps use the full scene-derived size (point-light faces capped at 2048) under a 26M-texel scene budget halved largest-first; the PCF filter measures its reach in output pixels with a two-texel minimum, samples texel centres on a jittered adaptive 5..10 grid, and compares each texel against the receiver plane's own depth so wide kernels never self-shadow lit casters. The WGSL mirror is parenthesised (Tint rejects the old form) and guarded by a shader-wide grammar test; the CPU reference and shader remain step-for-step parity-tested. --- crates/gpu/src/lib.rs | 2 + crates/gpu/src/shaders/advanced3d.wgsl | 114 ++++++-- crates/gpu/src/tests_adv3d.rs | 38 +++ crates/gpu/src/tests_wgsl_grammar.rs | 191 +++++++++++++ crates/render/src/three_d/adv/raster.rs | 104 ++++++- crates/render/src/three_d/adv/scene.rs | 3 +- crates/render/src/three_d/adv/shade.rs | 139 +++++++-- crates/render/src/three_d/adv/tests.rs | 366 ++++++++++++++++++++++++ 8 files changed, 910 insertions(+), 47 deletions(-) create mode 100644 crates/gpu/src/tests_wgsl_grammar.rs diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 1cff8be0..1064a216 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -461,3 +461,5 @@ mod tests_fx_warp; mod tests_init; #[cfg(test)] mod tests_particles; +#[cfg(test)] +mod tests_wgsl_grammar; diff --git a/crates/gpu/src/shaders/advanced3d.wgsl b/crates/gpu/src/shaders/advanced3d.wgsl index 06becba4..4eea78c8 100644 --- a/crates/gpu/src/shaders/advanced3d.wgsl +++ b/crates/gpu/src/shaders/advanced3d.wgsl @@ -179,7 +179,41 @@ fn equirect_uv(d: vec3, rot: f32) -> vec2 { return vec2(u - floor(u), v); } -fn shadow_factor(m: u32, p: vec3, n_l: f32) -> f32 { +fn shadow_texel_at(offset: u32, sz: i32, x: i32, y: i32) -> f32 { + if (x < 0 || y < 0 || x >= sz || y >= sz) { + return 1e30; // outside the map: the light was never blocked there (finite stand-in for the CPU path's infinity sentinel) + } + return shadow_texels[offset + u32(y * sz + x)]; +} + +fn tap_hash(x: i32, y: i32) -> u32 { + var h: u32 = (u32(x) * 0x9E3779B9u) ^ (u32(y) * 0x85EBCA6Bu); + h = h ^ (h >> 16u); + h = h * 0x2545F491u; + h = h ^ (h >> 15u); + return h; +} + +const PCF_MAX_TAPS: i32 = 10; +const PLANE_SLOPE_MAX: f32 = 64.0; + +// shade::shadow_factor's `open`: 1 where texel (x, y) does not occlude the receiver +// plane (depth slope -t in texel footprints per texel) at the texel's centre; the +// perspective compare runs on inverse depth (iz + g·iq), which is linear across the map. +fn shadow_open(offset: u32, sz: i32, x: i32, y: i32, ortho: bool, z: f32, texel: f32, bias: f32, t: vec2, u: f32, v: f32, iz: f32, iq: f32) -> f32 { + let s = shadow_texel_at(offset, sz, x, y) + bias; + let g = t.x * (f32(x) + 0.5 - u) + t.y * (f32(y) + 0.5 - v); + var lit: bool; + if (ortho) { + lit = z - g * texel <= s; + } else { + let inv = iz + g * iq; + lit = inv <= 0.0 || inv * s >= 1.0; + } + return select(0.0, 1.0, lit); +} + +fn shadow_factor(m: u32, p: vec3, ng: vec3, n_l: f32) -> f32 { let r0 = shadows[m]; let r1 = shadows[m + 1u]; let r2 = shadows[m + 2u]; @@ -205,28 +239,66 @@ fn shadow_factor(m: u32, p: vec3, n_l: f32) -> f32 { } let z = q.z; let texel = select(abs(z) / focal, 1.0 / focal, ortho); - let bias = extra.x * texel * (1.0 + extra.y) * (1.0 + 3.0 * (1.0 - clamp(n_l, 0.0, 1.0))); - let step = extra.y / 2.0; + // CPU mirror (shade::shadow_factor), step for step: slope-scaled bias at texel + // scale (independent of the PCF radius extra.y; a diffusion-scaled bias lifts + // shadows off their casters), each texel compared against the receiver plane's + // depth at its centre so wide kernels never self-shadow a lit surface, an N×N grid + // jittered by an integer hash (bit-identical across backends) of the sub-texel + // cell and tap index, and bilinear compares of the four nearest texel centres. + // extra.y is the wanted penumbra in output pixels; dividing by the receiver's + // texel footprint keeps it in output pixels at any map resolution, and the reach + // is at least two texels so hard edges ramp over texel steps instead of showing them. + let bias = extra.x * texel * (1.0 + 3.0 * (1.0 - clamp(n_l, 0.0, 1.0))); let sz = i32(size); + let nv = vec3(dot(r0.xyz, ng), dot(r1.xyz, ng), dot(r2.xyz, ng)); + var den0 = nv.z; + if (!ortho) { + den0 = nv.x * ((u - c) / focal) + nv.y * ((v - c) / focal) + nv.z; + } + var t = vec2(0.0, 0.0); + if (den0 < -1e-6) { + t = vec2(clamp(nv.x / den0, -PLANE_SLOPE_MAX, PLANE_SLOPE_MAX), clamp(nv.y / den0, -PLANE_SLOPE_MAX, PLANE_SLOPE_MAX)); + } + let iz = 1.0 / z; + let iq = 1.0 / (focal * z); + var reach = clamp(extra.y / texel, 2.0, 256.0); + if (!(reach < 1e30)) { + reach = 1.0; + } + let n = clamp(i32(ceil(reach)) + 1, 5, PCF_MAX_TAPS); + let step = reach / f32(n - 1); + let half = f32(n - 1) * 0.5; + let cx = i32(floor(u * 8.0)); + let cy = i32(floor(v * 8.0)); var lit = 0.0; - for (var j = -2; j <= 2; j = j + 1) { - for (var i = -2; i <= 2; i = i + 1) { - let x = i32(floor(u + f32(i) * step)); - let y = i32(floor(v + f32(j) * step)); - if (x < 0 || y < 0 || x >= sz || y >= sz) { - lit = lit + 1.0; - continue; - } - let d = shadow_texels[offset + u32(y * sz + x)]; - if (z - bias <= d) { - lit = lit + 1.0; - } + for (var j = 0; j < n; j = j + 1) { + for (var i = 0; i < n; i = i + 1) { + let h = tap_hash(cx + i * 7919, cy + j * 7919); + let jx = f32(h & 0xFFFFu) * (1.0 / 65536.0) - 0.5; + let jy = f32((h >> 16u) & 0xFFFFu) * (1.0 / 65536.0) - 0.5; + let su = u + ((f32(i) - half) + jx) * step; + let sv = v + ((f32(j) - half) + jy) * step; + // Texel (x, y) holds the depth at its centre (x + 0.5, y + 0.5); each of the + // four is compared against the receiver plane at that centre. + let tu = su - 0.5; + let tv = sv - 0.5; + let fx = tu - floor(tu); + let fy = tv - floor(tv); + let x0 = i32(floor(tu)); + let y0 = i32(floor(tv)); + let h00 = shadow_open(offset, sz, x0, y0, ortho, z, texel, bias, t, u, v, iz, iq); + let h10 = shadow_open(offset, sz, x0 + 1, y0, ortho, z, texel, bias, t, u, v, iz, iq); + let h01 = shadow_open(offset, sz, x0, y0 + 1, ortho, z, texel, bias, t, u, v, iz, iq); + let h11 = shadow_open(offset, sz, x0 + 1, y0 + 1, ortho, z, texel, bias, t, u, v, iz, iq); + let a = h00 + (h10 - h00) * fx; + let b = h01 + (h11 - h01) * fx; + lit = lit + a + (b - a) * fy; } } - return lit / 25.0; + return lit / f32(n * n); } -fn light_shadow(li: u32, kind: u32, lpos: vec3, shadow: i32, darkness: f32, p: vec3, n_l: f32) -> f32 { +fn light_shadow(li: u32, kind: u32, lpos: vec3, shadow: i32, darkness: f32, p: vec3, ng: vec3, n_l: f32) -> f32 { if (shadow < 0) { return 1.0; } @@ -242,7 +314,7 @@ fn light_shadow(li: u32, kind: u32, lpos: vec3, shadow: i32, darkness: f32, face = select(5u, 4u, d.z > 0.0); } } - let f = shadow_factor((u32(shadow) + face) * SHADOW_STRIDE, p, n_l); + let f = shadow_factor((u32(shadow) + face) * SHADOW_STRIDE, p, ng, n_l); return 1.0 - darkness * (1.0 - f); } @@ -330,7 +402,7 @@ fn shadow_only_amount(unlit: bool, normal: vec3, front: bool, pos: vec3 FOut { if (!front) { n = -n; } + // Shadow lookups follow the geometric surface, not the normal map. + let ng = n; let tex_n = i32(m3.z); if (tex_n >= 0) { let t = sample_tex(tex_n, i.uv); @@ -446,7 +520,7 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut { } var sh = 1.0; if (receives_shadows) { - sh = light_shadow(li, kind, l0.xyz, i32(l3.w), l4.x, i.pos, n_l); + sh = light_shadow(li, kind, l0.xyz, i32(l3.w), l4.x, i.pos, ng, n_l); } let b = brdf(rgb, metallic, rough, spec_k, diff_k, n, v, to_l); acc = acc + b * l2.xyz * k * sh; diff --git a/crates/gpu/src/tests_adv3d.rs b/crates/gpu/src/tests_adv3d.rs index 231dc976..21422aa6 100644 --- a/crates/gpu/src/tests_adv3d.rs +++ b/crates/gpu/src/tests_adv3d.rs @@ -167,6 +167,44 @@ fn gpu_matches_cpu_shadow_catcher() { compare(&s, &g); } +#[test] +fn gpu_matches_cpu_wide_penumbrae() { + // Both lights cast with wide Shadow Diffusion. Penumbrae cover much of the frame, so a + // backend drift in the PCF (jitter hash, tap grid, receiver-plane compare) would hide + // inside the scene-wide 1.5 % silhouette budget; here every shadowed pixel off a + // silhouette must match the CPU within 2/255. + let Some(g) = gpu() else { return }; + let (mut p, cid) = scene_project(); + let comp = p.comp_mut(cid).unwrap(); + set(&mut comp.layers[1], "lightOptions/shadowDiffusion", Value::Scalar(48.0)); + set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(true)); + set(&mut comp.layers[0], "lightOptions/shadowDiffusion", Value::Scalar(30.0)); + let s = scene(&p, cid); + let (cpu, gt) = (adv::raster::render(&s), g.raster_3d(&s).expect("gpu raster")); + for l in &mut p.comp_mut(cid).unwrap().layers[..2] { + set(l, "lightOptions/castsShadows", Value::Bool(false)); + } + let unshadowed = adv::raster::render(&scene(&p, cid)); + let w = cpu.width as usize; + let luma = |c: &[f32; 4]| (c[0] + c[1] + c[2]) / 3.0; + // Off a silhouette: the pixel and its four neighbours see one continuous surface. + let smooth = |i: usize| -> bool { + let (x, y) = (i % w, i / w); + let d = cpu.depth[i]; + x > 0 + && y > 0 + && x + 1 < w + && i + w < cpu.depth.len() + && d.is_finite() + && [i - 1, i + 1, i - w, i + w].iter().all(|&j| (cpu.depth[j] - d).abs() < 0.01 * d.abs().max(1.0)) + }; + let shadowed: Vec = (0..cpu.color.len()).filter(|&i| luma(&unshadowed.color[i]) - luma(&cpu.color[i]) > 0.01 && smooth(i)).collect(); + let bad = shadowed.iter().filter(|&&i| (0..4).any(|k| (cpu.color[i][k] - gt.color[i][k]).abs() / cpu.color[i][k].abs().max(1.0) > 2.0 / 255.0)).count(); + eprintln!("adv3d wide penumbrae: {bad}/{} shadowed pixels beyond 2/255", shadowed.len()); + assert!(shadowed.len() > 2000, "the shadows cover a large area: {}", shadowed.len()); + assert!(bad as f64 <= 0.002 * shadowed.len() as f64, "{bad}/{} shadowed pixels differ", shadowed.len()); +} + #[test] fn gpu_raster_perf_smoke() { let Some(g) = gpu() else { return }; diff --git a/crates/gpu/src/tests_wgsl_grammar.rs b/crates/gpu/src/tests_wgsl_grammar.rs new file mode 100644 index 00000000..18fef7a0 --- /dev/null +++ b/crates/gpu/src/tests_wgsl_grammar.rs @@ -0,0 +1,191 @@ +//! WGSL rules naga accepts but stricter front-ends (Tint, i.e. the browser's +//! WebGPU, which receives the WGSL source verbatim on wasm) reject. +//! +//! WGSL gives the bitwise operators `&`, `|` and `^` no precedence against any other +//! binary operator: `a * b ^ c` must be written `(a * b) ^ c`, and `a & b == c` as +//! `(a & b) == c`. Only a chain of the same bitwise operator may go unparenthesised. + +use std::fs; +use std::path::Path; + +#[derive(Clone, Copy, PartialEq)] +enum Tok<'a> { + Operand, + Open, + Close, + Op(&'a str), + /// A unary prefix (`!`, `~`): the operator after it cannot be binary. + Prefix, + /// Ends an expression: `,` `;` `{` `}` `:` `=` compound assignment, `->`, `@`. + Sep, +} + +const BITWISE: [&str; 3] = ["&", "|", "^"]; + +/// Drop comments and template argument lists (`vec3`, `array>`), whose +/// angle brackets are not relational operators. +fn strip(src: &str) -> String { + let mut out = String::with_capacity(src.len()); + let b = src.as_bytes(); + let mut i = 0; + while i < b.len() { + if b[i..].starts_with(b"//") { + while i < b.len() && b[i] != b'\n' { + i += 1; + } + } else if b[i..].starts_with(b"/*") { + i += 2; + while i + 1 < b.len() && !b[i..].starts_with(b"*/") { + out.push(if b[i] == b'\n' { '\n' } else { ' ' }); + i += 1; + } + i += 2; + } else { + out.push(b[i] as char); + i += 1; + } + } + loop { + let mut changed = false; + let mut next = String::with_capacity(out.len()); + let c: Vec = out.chars().collect(); + let mut i = 0; + while i < c.len() { + if c[i] == '<' && i > 0 && (c[i - 1].is_alphanumeric() || c[i - 1] == '_') { + let mut j = i + 1; + while j < c.len() && (c[j].is_alphanumeric() || c[j] == '_' || c[j] == ',' || c[j] == ' ') { + j += 1; + } + if j < c.len() && c[j] == '>' { + i = j + 1; + changed = true; + continue; + } + } + next.push(c[i]); + i += 1; + } + out = next; + if !changed { + return out; + } + } +} + +/// `(line, message)` for every bitwise operator mixed with another binary operator at +/// the same parenthesis level. +fn violations(src: &str) -> Vec<(usize, String)> { + let s = strip(src); + let b = s.as_bytes(); + let mut out = vec![]; + // Binary operators seen at each open parenthesis level. + let mut levels: Vec> = vec![vec![]]; + let mut prev = Tok::Sep; + let (mut i, mut line) = (0, 1); + const OPS: [&str; 18] = ["&&", "||", "==", "!=", "<=", ">=", "<<", ">>", "<", ">", "+", "-", "*", "/", "%", "&", "|", "^"]; + while i < b.len() { + let ch = b[i]; + if ch == b'\n' { + line += 1; + i += 1; + continue; + } + if ch.is_ascii_whitespace() { + i += 1; + continue; + } + let tok = if ch.is_ascii_alphanumeric() || ch == b'_' || ch == b'.' { + // Identifier, member access or number (`1e-6`, `0x9E3779B9u`). + let start = i; + while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_' || b[i] == b'.') { + if (b[i] == b'e' || b[i] == b'E') + && b[start].is_ascii_digit() + && !s[start..i].starts_with("0x") + && i + 1 < b.len() + && (b[i + 1] == b'-' || b[i + 1] == b'+') + { + i += 1; + } + i += 1; + } + Tok::Operand + } else if ch == b'(' || ch == b'[' { + i += 1; + levels.push(vec![]); + Tok::Open + } else if ch == b')' || ch == b']' { + i += 1; + if levels.len() > 1 { + levels.pop(); + } + Tok::Close + } else if b[i..].starts_with(b"->") || b[i..].starts_with(b"++") || b[i..].starts_with(b"--") { + i += 2; + Tok::Sep + } else if matches!(ch, b',' | b';' | b'{' | b'}' | b':' | b'@') { + i += 1; + Tok::Sep + } else if let Some(op) = OPS.iter().find(|op| b[i..].starts_with(op.as_bytes())) { + i += op.len(); + if b.get(i) == Some(&b'=') && !matches!(*op, "==" | "!=" | "<=" | ">=") { + i += 1; + Tok::Sep // compound assignment + } else { + Tok::Op(op) + } + } else if ch == b'=' { + i += 1; + Tok::Sep + } else { + i += 1; + Tok::Prefix + }; + match tok { + Tok::Sep => { + if let Some(l) = levels.last_mut() { + l.clear(); + } + } + Tok::Op(op) if matches!(prev, Tok::Operand | Tok::Close) => { + let level = levels.last_mut().expect("level"); + let mixes = |a: &str, b: &str| a != b && (BITWISE.contains(&a) || BITWISE.contains(&b)); + if let Some(other) = level.iter().find(|o| mixes(o, op)) { + out.push((line, format!("mixing '{other}' and '{op}' requires parentheses"))); + } + level.push(op); + } + _ => {} + } + prev = tok; + } + out +} + +#[test] +fn checker_flags_unparenthesised_bitwise_mixes() { + assert_eq!(violations("let h = u32(x) * 3u ^ u32(y) * 5u;").len(), 2); + assert_eq!(violations("let a = b & c == d;").len(), 1); + assert!(violations("let h = (u32(x) * 3u) ^ (u32(y) * 5u);").is_empty()); + assert!(violations("let h = a ^ b ^ c; let m = f32(h & 0xFFFFu) * (1.0 / 65536.0) - 0.5;").is_empty()); + assert!(violations("let v: vec3 = vec3(1e-6, -x, a < b); if (a < b && c > d) { h ^= h >> 16u; }").is_empty()); + assert!(violations("var p = &x; let y = -a & b;").is_empty()); +} + +#[test] +fn shaders_parenthesise_bitwise_operators() { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/shaders"); + let mut bad = vec![]; + let mut files = 0; + for entry in fs::read_dir(&dir).expect("shader dir") { + let path = entry.expect("entry").path(); + if path.extension().is_some_and(|e| e == "wgsl") { + files += 1; + let src = fs::read_to_string(&path).expect("shader source"); + for (line, msg) in violations(&src) { + bad.push(format!("{}:{line}: {msg}", path.file_name().map(|n| n.to_string_lossy()).unwrap_or_default())); + } + } + } + assert!(files > 10, "found the shaders: {files}"); + assert!(bad.is_empty(), "WGSL a strict front-end rejects:\n{}", bad.join("\n")); +} diff --git a/crates/render/src/three_d/adv/raster.rs b/crates/render/src/three_d/adv/raster.rs index 972a627b..39505793 100644 --- a/crates/render/src/three_d/adv/raster.rs +++ b/crates/render/src/three_d/adv/raster.rs @@ -8,6 +8,9 @@ //! (already sorted back to front) are then shaded per fragment and blended over, depth-tested //! against the opaque surfaces. +use std::cmp::Reverse; +use std::collections::BinaryHeap; + use rayon::prelude::*; use super::scene::{Scene, ShadowMap, Vertex}; @@ -422,6 +425,81 @@ fn shadow_raster(s: &Scene, m: &ShadowMap, casters: &[usize]) -> Vec { d } +/// Hard cap on the shadow depth texels one render allocates (f32 depth texels). +/// Quality map sizes derive from the raster resolution, and both the resolution and +/// the light count come from the project, so without a scene-wide budget an +/// arbitrarily large comp with arbitrarily many casting lights (point lights cost +/// six faces) could balloon the allocation. 26 M texels (≈ 104 MB) holds one point +/// light's full 2048-texel cube (6 × 2048² ≈ 25.2 M) and stays under WebGPU's default +/// 128 MiB storage-binding limit; adapters with less fall back to the CPU raster. +pub(crate) const MAX_SHADOW_TEXELS: u64 = 26_000_000; + +/// Long side of a quality shadow map: one map texel per output pixel, to a power of +/// two, clamped to 512..4096 (raster sizes are user data and may be absurd). Draft +/// (interactive fast-preview) renders keep the flat 512 budget, unchanged. +pub(crate) fn shadow_map_size(width: u32, height: u32, draft: bool) -> u32 { + if draft { + return 512; + } + let side = width.max(height).clamp(512, 4096); + let mut size = 512u32; + while size < side { + size *= 2; + } + size +} + +/// Per-face size of a point light's cube map: the full map size, since each face spans +/// 90° and anything smaller is coarser than a spot light's map in the same comp (a +/// point light low over a floor shows exactly that as stair-steps), capped at 2048 so +/// one cube fits [`MAX_SHADOW_TEXELS`]. Draft keeps half the map size as before. +pub(crate) fn shadow_point_face_size(size: u32, draft: bool) -> u32 { + if draft { (size / 2).max(1) } else { size.min(2048) } +} + +/// Per-light map sizes (`None` = this light gets no shadow map). `kinds`: 0 = no +/// shadows (costs nothing), 1 = single map (spot/parallel), 2 = point cube (budgeted +/// as its six faces). Lights start at their full size; while the scene exceeds +/// [`MAX_SHADOW_TEXELS`] the largest map above its floor is halved (power-of-two +/// steps, the earliest light first on ties), and once every light is at its floor the +/// last lights lose their shadows entirely. Deterministic, and O(n log n) in the +/// light count: each light is halved at most a handful of times and dropped once. +pub(crate) fn shadow_sizes(width: u32, height: u32, draft: bool, kinds: &[u32]) -> Vec> { + let base = shadow_map_size(width, height, draft); + let faces = |k: u32| -> u64 { if k == 2 { 6 } else { 1 } }; + let floor = |k: u32| -> u32 { if k == 2 { 128 } else { 512 } }; + let mut sizes: Vec = kinds + .iter() + .map(|&k| match k { + 0 => 0, + 2 => shadow_point_face_size(base, draft), + _ => base, + }) + .collect(); + let cost = |z: u32, k: u32| -> u64 { (z as u64) * (z as u64) * faces(k) }; + let mut total: u64 = sizes.iter().zip(kinds).map(|(&z, &k)| cost(z, k)).sum(); + // Largest size first, then the lowest index. + let mut heap: BinaryHeap<(u32, Reverse)> = + sizes.iter().zip(kinds).enumerate().filter(|(_, (z, k))| **z > floor(**k)).map(|(i, (&z, _))| (z, Reverse(i))).collect(); + while total > MAX_SHADOW_TEXELS { + let Some((z, Reverse(i))) = heap.pop() else { break }; + let k = kinds[i]; + total -= cost(z, k) - cost(z / 2, k); + sizes[i] = z / 2; + if z / 2 > floor(k) { + heap.push((z / 2, Reverse(i))); + } + } + for i in (0..sizes.len()).rev() { + if total <= MAX_SHADOW_TEXELS { + break; + } + total -= cost(sizes[i], kinds[i]); + sizes[i] = 0; + } + sizes.into_iter().map(|z| (z > 0).then_some(z)).collect() +} + /// Build the shadow maps of every shadow-casting light (lights marked `shadow == 0`). pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) { let casters: Vec = (0..s.indices.len() / 3) @@ -443,15 +521,37 @@ pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) { } let centre = [0, 1, 2].map(|k| (lo[k] + hi[k]) * 0.5); let radius = ((hi[0] - lo[0]).powi(2) + (hi[1] - lo[1]).powi(2) + (hi[2] - lo[2]).powi(2)).sqrt() * 0.5 + 1.0; - let size = if draft { 512 } else { 1024 }; + // Map sizes come from the raster (a quality map gives ~one texel per output + // pixel); the per-light budget keeps the scene allocation bounded because + // comp size and light count are user input (point lights cost six faces). + let kinds: Vec = s + .lights + .iter() + .map(|l| { + if l.shadow != 0 { + 0 + } else if l.kind > 1 { + 2 + } else { + 1 + } + }) + .collect(); + let sizes = shadow_sizes(s.width, s.height, draft, &kinds); let mut maps: Vec = vec![]; for li in 0..s.lights.len() { + let Some(size) = sizes.get(li).copied().flatten() else { + s.lights[li].shadow = -1; + continue; + }; if s.lights[li].shadow != 0 { s.lights[li].shadow = -1; continue; } let l = s.lights[li]; let first = maps.len() as i32; + // Penumbra width in output pixels (the shader turns it into a tap reach at + // the receiver); the sampling bias is deliberately independent of it. let soft = 1.0 + (l.shadow_diffusion * 0.5).min(24.0); let pos = l.pos.map(|v| v as f64); let dir = l.dir.map(|v| v as f64); @@ -468,7 +568,7 @@ pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) { maps.push(ShadowMap { view: basis(pos, dir, hint), focal, ortho: false, size, offset: 0, bias: 1.5, radius: soft }); } _ => { - let fs = size / 2; + let fs = size; for (f, d) in [ ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), ([-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index b75d5af7..75b9c976 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -157,7 +157,8 @@ pub struct ShadowMap { pub offset: u32, /// Depth bias in texels (scaled by the texel footprint at the receiver). pub bias: f32, - /// PCF spread (texels). + /// Penumbra width (output pixels) requested by Shadow Diffusion; the shader + /// converts it to a tap reach via the receiver's texel footprint. pub radius: f32, } diff --git a/crates/render/src/three_d/adv/shade.rs b/crates/render/src/three_d/adv/shade.rs index 666f2e44..c7c1a40f 100644 --- a/crates/render/src/three_d/adv/shade.rs +++ b/crates/render/src/three_d/adv/shade.rs @@ -206,36 +206,125 @@ pub fn shadow_coord(m: &ShadowMap, p: [f32; 3]) -> Option<(f32, f32, f32)> { } } -/// Fraction of light reaching `p` through shadow map `m` (1 = lit), PCF over a 5×5 grid -/// spread over `radius` texels. -pub fn shadow_factor(m: &ShadowMap, texels: &[f32], p: [f32; 3], n_l: f32) -> f32 { +/// Integer tap jitter hash: pure u32 bit math so the WGSL backend computes +/// bit-identical jitter from the same inputs (a float hash would diverge between +/// backends and break CPU/GPU parity). +fn tap_hash(x: i32, y: i32) -> u32 { + let mut h = (x as u32).wrapping_mul(0x9E37_79B9) ^ (y as u32).wrapping_mul(0x85EB_CA6B); + h ^= h >> 16; + h = h.wrapping_mul(0x2545_F491); + h ^= h >> 15; + h +} + +/// Upper bound of the PCF grid's taps per axis. The grid grows with the reach so taps +/// stay about one texel apart for moderate penumbrae; the cap bounds the per-fragment +/// cost of very wide ones (bilinear taps still overlap and the jitter dithers the rest). +const PCF_MAX_TAPS: i32 = 10; + +/// Steepest receiver-plane slope the PCF extrapolates, in texel footprints of depth per +/// texel (about 89°). Steeper planes are edge-on to the light, where their depth runs +/// away; they are clamped to this slope. +const PLANE_SLOPE_MAX: f32 = 64.0; + +/// Fraction of light reaching `p` through shadow map `m` (1 = lit). `ng` is the +/// receiver's geometric (not normal-mapped) normal facing the viewer. +/// +/// PCF over an N×N tap grid (5 ≤ N ≤ [`PCF_MAX_TAPS`]) spread over a reach derived +/// from `radius` (the wanted penumbra in output pixels) divided by the receiver's +/// texel footprint, each tap jittered by an integer hash of the receiver's +/// sub-texel cell and the tap index, and each tap comparing against the four +/// nearest texel centres with bilinear weights. The jitter detaches the tap grid +/// from the texel axes (axis-aligned binary taps are what stair-step a straight +/// shadow edge), and keying it below texel size keeps the dither finer than an +/// output pixel even where one texel covers several. The bilinear compare makes +/// each tap's contribution vary smoothly inside a texel instead of snapping. +/// +/// Every texel is compared against the depth the receiver's own plane has at that +/// texel's centre, not the receiver point's depth: a wide kernel on a surface tilted +/// towards the light would otherwise see its own nearer depth and shadow itself. +/// With that, the bias stays at texel scale; a diffusion-scaled bias lifts shadows +/// off their casters. +pub fn shadow_factor(m: &ShadowMap, texels: &[f32], p: [f32; 3], ng: [f32; 3], n_l: f32) -> f32 { let Some((u, v, z)) = shadow_coord(m, p) else { return 1.0 }; let size = m.size as i64; - // Slope-scaled bias (world units are pixels). - // Texel footprint (world units) × PCF reach, steeper for grazing light. + // Slope-scaled bias at texel scale (world units are pixels), steeper for + // grazing light: it absorbs depth-raster error and the gap between the shading + // normal's plane and the faceted surface the map rasterised. let texel = if m.ortho { 1.0 / m.focal } else { z.abs() / m.focal }; - let bias = m.bias * texel * (1.0 + m.radius) * (1.0 + 3.0 * (1.0 - n_l.clamp(0.0, 1.0))); - let step = m.radius / 2.0; + let bias = m.bias * texel * (1.0 + 3.0 * (1.0 - n_l.clamp(0.0, 1.0))); + let at = |xi: i64, yi: i64| -> f32 { + if xi < 0 || yi < 0 || xi >= size || yi >= size { + return f32::INFINITY; // outside the map: the light was never blocked there (WGSL mirrors this with a large finite depth) + } + texels.get(m.offset as usize + (yi * size + xi) as usize).copied().unwrap_or(f32::INFINITY) + }; + // The receiver plane in light space as the slope of its depth across the map, in + // texel footprints per texel (`tx`, `ty`; depth grows along −t), clamped where the + // plane grazes the light. `den0` is the plane normal against the light ray through + // the receiver (the depth axis for an orthographic map), negative while it faces it. + let w = &m.view; + let nv = [0, 1, 2].map(|r| w[r][0] * ng[0] + w[r][1] * ng[1] + w[r][2] * ng[2]); + let c = m.size as f32 * 0.5; + let den0 = if m.ortho { nv[2] } else { nv[0] * ((u - c) / m.focal) + nv[1] * ((v - c) / m.focal) + nv[2] }; + let (tx, ty) = if den0 < -1e-6 { + ((nv[0] / den0).clamp(-PLANE_SLOPE_MAX, PLANE_SLOPE_MAX), (nv[1] / den0).clamp(-PLANE_SLOPE_MAX, PLANE_SLOPE_MAX)) + } else { + (0.0, 0.0) + }; + // 1 where texel (xi, yi) does not occlude the receiver plane at the texel's centre. + // Perspective depth is not linear across the map but its inverse is, so that + // compare runs on 1/depth (inv · (texel + bias) ≥ 1) without a division; a plane + // whose inverse depth reaches 0 has left the light's side and cannot be occluded. + let (iz, iq) = (1.0 / z, 1.0 / (m.focal * z)); + let open = |xi: i64, yi: i64| -> f32 { + let s = at(xi, yi) + bias; + let g = tx * (xi as f32 + 0.5 - u) + ty * (yi as f32 + 0.5 - v); + let lit = if m.ortho { + z - g * texel <= s + } else { + let inv = iz + g * iq; + inv <= 0.0 || inv * s >= 1.0 + }; + if lit { 1.0 } else { 0.0 } + }; + // `radius` is the wanted penumbra in output pixels; converting it through the + // receiver's texel footprint keeps the penumbra width in output pixels at any + // map resolution (and lets perspective penumbra grow with distance from the light). + // At least two texels: a hard edge then ramps across two texels, which hides the + // texel steps of the binary depth compare where one texel spans several output + // pixels (receivers grazing the light); a one-texel ramp leaves a sawtooth. + let mut reach = (m.radius / texel).clamp(2.0, 256.0); + if !reach.is_finite() { + reach = 1.0; + } + let n = (reach.ceil() as i32 + 1).clamp(5, PCF_MAX_TAPS); + let step = reach / (n - 1) as f32; + let half = (n - 1) as f32 * 0.5; + let (cx, cy) = ((u * 8.0).floor() as i32, (v * 8.0).floor() as i32); let mut lit = 0.0; - for j in -2..=2 { - for i in -2..=2 { - let x = (u + i as f32 * step).floor() as i64; - let y = (v + j as f32 * step).floor() as i64; - if x < 0 || y < 0 || x >= size || y >= size { - lit += 1.0; - continue; - } - let d = texels[m.offset as usize + (y * size + x) as usize]; - if z - bias <= d { - lit += 1.0; - } + for j in 0..n { + for i in 0..n { + let h = tap_hash(cx.wrapping_add(i.wrapping_mul(7919)), cy.wrapping_add(j.wrapping_mul(7919))); + let jx = ((h & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5; + let jy = (((h >> 16) & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5; + let (su, sv) = (u + ((i as f32 - half) + jx) * step, v + ((j as f32 - half) + jy) * step); + // Texel (x, y) holds the depth at its centre (x + 0.5, y + 0.5); each of the + // four is compared against the receiver plane at that centre. + let (tu, tv) = (su - 0.5, sv - 0.5); + let (fx, fy) = (tu - tu.floor(), tv - tv.floor()); + let (x0, y0) = (tu.floor() as i64, tv.floor() as i64); + let (h00, h10, h01, h11) = (open(x0, y0), open(x0 + 1, y0), open(x0, y0 + 1), open(x0 + 1, y0 + 1)); + let a = h00 + (h10 - h00) * fx; + let b = h01 + (h11 - h01) * fx; + lit += a + (b - a) * fy; } } - lit / 25.0 + lit / (n * n) as f32 } /// The visibility of light `li` at `p` (1 = unshadowed), darkened by Shadow Darkness. -pub fn light_shadow(s: &Scene, l: &Light, p: [f32; 3], n_l: f32) -> f32 { +pub fn light_shadow(s: &Scene, l: &Light, p: [f32; 3], ng: [f32; 3], n_l: f32) -> f32 { if l.shadow < 0 { return 1.0; } @@ -258,7 +347,7 @@ pub fn light_shadow(s: &Scene, l: &Light, p: [f32; 3], n_l: f32) -> f32 { s.shadows.get(first) }; let Some(m) = m else { return 1.0 }; - let f = shadow_factor(m, &s.shadow_texels, p, n_l); + let f = shadow_factor(m, &s.shadow_texels, p, ng, n_l); 1.0 - l.shadow_darkness * (1.0 - f) } @@ -367,6 +456,8 @@ pub fn shade(s: &Scene, f: &Frag) -> Option<[f32; 4]> { if !f.front { n = scale(n, -1.0); } + // Shadow lookups follow the geometric surface, not the normal map. + let ng = n; if let Some(t) = tex(m.tex_normal) { let tn = [(t[0] * 2.0 - 1.0) * m.normal_scale, (t[1] * 2.0 - 1.0) * m.normal_scale, t[2] * 2.0 - 1.0]; let tg = [f.tangent[0], f.tangent[1], f.tangent[2]]; @@ -392,7 +483,7 @@ pub fn shade(s: &Scene, f: &Frag) -> Option<[f32; 4]> { if n_l <= 0.0 { continue; } - let sh = if m.receives_shadows { light_shadow(s, l, f.pos, n_l) } else { 1.0 }; + let sh = if m.receives_shadows { light_shadow(s, l, f.pos, ng, n_l) } else { 1.0 }; let b = brdf(rgb, metallic, rough, m.specular_k, m.diffuse_k, n, v, to_l); for c in 0..3 { out[c] += b[c] * l.color[c] * k * sh; @@ -433,7 +524,7 @@ fn shadow_only(s: &Scene, m: &Material, f: &Frag, alpha: f32) -> [f32; 4] { if n_l <= 0.0 { continue; } - amount = amount.max(1.0 - light_shadow(s, l, f.pos, n_l)); + amount = amount.max(1.0 - light_shadow(s, l, f.pos, n, n_l)); } [0.0, 0.0, 0.0, alpha * amount] } diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index 694a5c2b..3f5ee481 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -681,3 +681,369 @@ fn stroke_regions_are_rings() { assert_eq!(outlines.len(), 1); assert_eq!(outlines[0].holes.len(), 1); } + +// ---------------------------------------------------------------- shadow quality + +use super::raster::{MAX_SHADOW_TEXELS, shadow_map_size, shadow_point_face_size, shadow_sizes}; +use super::scene::ShadowMap; + +/// Geometric normal of a receiver squarely facing a `flat_map` light (which looks along +z). +const FACING: [f32; 3] = [0.0, 0.0, -1.0]; + +/// Identity-view orthographic map: map coords are world x, y × `focal` offset by +/// `size/2` (`focal` texels per world unit). +fn flat_map(size: u32, focal: f32, radius: f32) -> ShadowMap { + ShadowMap { view: [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]], focal, ortho: true, size, offset: 0, bias: 1.5, radius } +} + +/// Wall: texel columns ≥ a quarter of the map at depth 10, the rest unoccluded (1000). +/// Its edge sits at world x = −size / (4·focal). +fn wall_map(size: u32, focal: f32, radius: f32) -> (ShadowMap, Vec) { + let texels: Vec = (0..size * size).map(|i| if i % size >= size / 4 { 10.0 } else { 1000.0 }).collect(); + (flat_map(size, focal, radius), texels) +} + +/// Luma of a render of `cid` with its top layer (a light) set to cast shadows or not, +/// at the given Shadow Diffusion. +fn render_light_luma(p: &mut Project, cid: ItemId, casting: bool, diffusion: f64) -> Vec { + let comp = p.comp_mut(cid).unwrap(); + set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(casting)); + set(&mut comp.layers[0], "lightOptions/shadowDiffusion", Value::Scalar(diffusion)); + render(p, cid).data.iter().map(|q| (q[0] + q[1] + q[2]) / 3.0).collect() +} + +#[test] +fn shadow_map_size_tracks_raster_resolution() { + assert_eq!(shadow_map_size(640, 360, false), 1024); + assert_eq!(shadow_map_size(1920, 1080, false), 2048); + assert_eq!(shadow_map_size(3840, 2160, false), 4096); + // Clamped to the pow2 range: small or absurd rasters stay inside 512..4096. + assert_eq!(shadow_map_size(64, 64, false), 512); + assert_eq!(shadow_map_size(0, 0, false), 512); + assert_eq!(shadow_map_size(u32::MAX, u32::MAX, false), 4096); + assert_eq!(shadow_map_size(7, 100000, false), 4096); + // The fast-preview (draft) budget stays at 512 regardless, so interactivity is + // untouched by the quality path's new sizing. + assert_eq!(shadow_map_size(3840, 2160, true), 512); + assert_eq!(shadow_map_size(0, 0, true), 512); +} + +#[test] +fn shadow_point_faces_are_capped() { + // Cube faces take the full map size up to 2048 (one 2048 cube ≈ 25 M f32 texels). + assert_eq!(shadow_point_face_size(4096, false), 2048); + assert_eq!(shadow_point_face_size(2048, false), 2048); + assert_eq!(shadow_point_face_size(1024, false), 1024); + assert_eq!(shadow_point_face_size(512, false), 512); + // Draft keeps the historical half-size face. + assert_eq!(shadow_point_face_size(512, true), 256); +} + +fn budget_texels(sizes: &[Option], kinds: &[u32]) -> u64 { + sizes.iter().zip(kinds).map(|(s, &k)| s.map(|z| (z as u64) * (z as u64) * if k == 2 { 6 } else { 1 }).unwrap_or(0)).sum() +} + +#[test] +fn shadow_sizes_stay_within_the_scene_budget() { + // Within budget a light gets its full size: one 4K point cube keeps 2048 faces. + assert_eq!(shadow_sizes(4096, 2160, false, &[2]), vec![Some(2048)]); + // Several cubes overflow: deterministically halved (largest first, earliest light + // on ties) until inside: three or four settle at 1024, five push two to 512. + assert_eq!(shadow_sizes(4096, 2160, false, &[2; 3]), vec![Some(1024); 3]); + assert_eq!(shadow_sizes(4096, 2160, false, &[2; 4]), vec![Some(1024); 4]); + let sizes = shadow_sizes(4096, 2160, false, &[2; 5]); + assert_eq!(sizes, vec![Some(512), Some(512), Some(1024), Some(1024), Some(1024)]); + assert!(budget_texels(&sizes, &[2; 5]) <= MAX_SHADOW_TEXELS); + // A hostile light count can never break the cap; sizes only shrink or drop to None. + let many = vec![2u32; 1000]; + let sizes = shadow_sizes(3840, 2160, false, &many); + assert_eq!(sizes.len(), 1000); + assert!(budget_texels(&sizes, &many) <= MAX_SHADOW_TEXELS); + assert!(sizes.iter().flatten().all(|&z| (128..=2048).contains(&z))); + // Deterministic: same inputs, same plan. + assert_eq!(sizes, shadow_sizes(3840, 2160, false, &many)); + // Non-point lights and a modest scene keep full quality sizes. + assert_eq!(shadow_sizes(1920, 1080, false, &[1; 2]), vec![Some(2048), Some(2048)]); + // A light that casts nothing costs nothing: it stays None and leaves its + // full-size budget to the lights that do cast. + assert_eq!(shadow_sizes(640, 360, false, &[0, 1]), vec![None, Some(1024)]); + assert!(shadow_sizes(640, 360, false, &[0; 500]).iter().all(Option::is_none)); +} + +#[test] +fn diffusion_does_not_lift_a_deep_shadow() { + // Shadow Diffusion may widen the penumbra but must not scale the depth bias: a + // point deep inside a shadow stays fully shadowed at any diffusion. + let (m, texels) = wall_map(64, 1.0, 1.0); + let deep = [0.0f32, 0.0, 13.5]; // u = 32: ±16 texels of margin around the edge at 16; z − bias 12 > 10 + let hard = shade::shadow_factor(&m, &texels, deep, FACING, 1.0); + assert!(hard < 0.01, "deep shadow at radius 1: {hard}"); + let wide = shade::shadow_factor(&flat_map(64, 1.0, 25.0), &texels, deep, FACING, 1.0); + assert!(wide < 0.01, "deep shadow at radius 25 (bias must not grow with radius): {wide}"); +} + +#[test] +fn pcf_varies_within_a_texel() { + // Nearest-texel PCF snaps the edge to texel boundaries: two receivers 1/20 texel + // apart get bit-identical factors, which is what stair-steps straight edges. + // The filtering must produce coverage that varies within a single texel. + let (m, texels) = wall_map(64, 1.0, 2.0); + let mut pairs = 0; + let mut varying = 0; + let mut u = -17.0f32; + while u < -15.3 { + let a = shade::shadow_factor(&m, &texels, [u, 0.0, 13.5], FACING, 1.0); + let b = shade::shadow_factor(&m, &texels, [u + 0.05, 0.0, 13.5], FACING, 1.0); + pairs += 1; + if (a - b).abs() > 1e-6 { + varying += 1; + } + assert!((0.0..=1.0).contains(&a) && (0.0..=1.0).contains(&b), "{a} {b}"); + u += 0.05; + } + assert!(pairs >= 30); + assert_eq!(varying, pairs, "coverage varies inside every texel along the edge"); +} + +#[test] +fn wide_diffusion_keeps_shadow_depth_and_contact() { + // Scene: camera-facing floor, sphere casting, point light behind the camera and + // off to the upper right so most of the shadow lands beside the sphere, not + // behind it. Raising Shadow Diffusion must neither wash the shadow out (a + // diffusion-scaled bias leaks light into it) nor move where it touches. + let (mut p, cid) = project(200, 150); + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| { + set(l, "geometryOptions/width", Value::Scalar(400.0)); + set(l, "geometryOptions/height", Value::Scalar(400.0)); + set(l, "transform/position", Value::Vec3([100.0, 75.0, 200.0])); + set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0])); + }); + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| { + set(l, "geometryOptions/radius", Value::Scalar(30.0)); + set(l, "transform/position", Value::Vec3([100.0, 75.0, 50.0])); + set(l, "materialOptions/castsShadows", Value::Enum(1)); + }); + add(&mut p, cid, LayerSource::Light { kind: LightKind::Point }, |l| { + set(l, "transform/position", Value::Vec3([180.0, 20.0, -300.0])); + set(l, "lightOptions/castsShadows", Value::Bool(true)); + }); + let noshadow = render_light_luma(&mut p, cid, false, 0.0); + let d0 = render_light_luma(&mut p, cid, true, 0.0); + let d40 = render_light_luma(&mut p, cid, true, 40.0); + // Per-column darkening vs the shadowless render: depth (max darkening) and the + // deepest dark row (contact edge). Diffusion may blur the edge but must keep the + // shadow dark (no bias leak) and keep it where it touches (no lift). + let dep = |img: &[f32], x: usize| -> f32 { (20..140).map(|y| noshadow[y * 200 + x] - img[y * 200 + x]).fold(0.0f32, f32::max) }; + let cols: Vec = (30..170).filter(|&x| dep(&d0, x) > 0.1).collect(); + assert!(cols.len() > 30, "shadow covers many columns: {}", cols.len()); + // Core depth: the 90th-percentile darkening over the d0 shadow's pixels. A blur + // only softens the rim and leaves the core dark; a bias leak lightens all of it. + let shadow_px: Vec = (20..140).flat_map(|y| (30..170).map(move |x| y * 200 + x)).filter(|&i| noshadow[i] - d0[i] > 0.1).collect(); + let core = |img: &[f32]| -> f32 { + let mut v: Vec = shadow_px.iter().map(|&i| noshadow[i] - img[i]).collect(); + v.sort_by(f32::total_cmp); + v[v.len() * 9 / 10] + }; + let (core0, core40) = (core(&d0), core(&d40)); + // Width of the shadow across its centre row: a diffusion-scaled bias shrinks the + // shadow (light leaks under its edges), while a correct filter keeps the extent + // and only softens the boundary (threshold crossings move at most with the + // penumbra, not with the bias). + let width = |img: &[f32]| -> f32 { + (30..130) + .map(|y| { + let dark: Vec = (0..200).map(|x| noshadow[y * 200 + x] - img[y * 200 + x] > 0.06).collect(); + match (dark.iter().position(|&d| d), dark.iter().rposition(|&d| d)) { + (Some(l), Some(r)) if r > l => (r - l) as f32, + _ => 0.0, + } + }) + .fold(0.0f32, f32::max) + }; + let (w0, w40) = (width(&d0), width(&d40)); + let wratio = w40 / w0; + assert!(w0 > 20.0, "shadow is wide enough to judge: {w0}"); + assert!(core40 >= 0.9 * core0, "diffusion 40 keeps the shadow core dark: {core40:.4} vs {core0:.4}"); + assert!(wratio >= 0.8, "diffusion 40 does not shrink the shadow: width {w40} vs {w0}"); +} + +#[test] +fn penumbra_dithers_along_a_straight_edge() { + // Receivers at the same distance from a straight edge, one texel apart along it. + // A fixed tap grid gives each the same coverage, so the penumbra repeats texel by + // texel (stair-steps and bands); the jitter, keyed below texel size, must vary it. + let (m, texels) = wall_map(64, 1.0, 6.0); + let distinct = |vs: &[f32]| -> usize { + let mut bits: Vec = vs.iter().map(|v| v.to_bits()).collect(); + bits.sort_unstable(); + bits.dedup(); + bits.len() + }; + let along: Vec = (0..32).map(|k| shade::shadow_factor(&m, &texels, [-16.0, -16.0 + k as f32, 13.5], FACING, 1.0)).collect(); + assert!(distinct(&along) >= 16, "coverage along the edge takes {} distinct values", distinct(&along)); + // And finer than a texel: where one texel spans several output pixels (a grazing + // floor), dither keyed per texel would show as texel-sized blocks of grain. + let within: Vec = (0..8).map(|k| shade::shadow_factor(&m, &texels, [-16.0, 0.0625 + k as f32 * 0.125, 13.5], FACING, 1.0)).collect(); + assert!(distinct(&within) >= 4, "coverage inside one texel takes {} distinct values", distinct(&within)); +} + +#[test] +fn penumbra_width_is_independent_of_map_resolution() { + // One wall (edge at world x = −16) in a 64-texel map at 1 texel per world unit and + // a 256-texel map at 4. Shadow Diffusion sets the penumbra in output pixels (world + // units here), so a finer map must not narrow it. + let width = |size: u32, focal: f32| -> f32 { + let (m, texels) = wall_map(size, focal, 8.0); + let xs: Vec = (0..400).map(|k| -30.0 + k as f32 * 0.07).collect(); + let f: Vec = xs.iter().map(|&x| shade::shadow_factor(&m, &texels, [x, 0.0, 13.5], FACING, 1.0)).collect(); + let first_below = |t: f32| f.iter().position(|&v| v < t).map_or(f32::NAN, |i| xs[i]); + first_below(0.1) - first_below(0.9) + }; + let (w64, w256) = (width(64, 1.0), width(256, 4.0)); + assert!(w64 > 4.0 && w64 < 12.0, "penumbra spans about the requested 8 units: {w64}"); + assert!((w256 / w64 - 1.0).abs() < 0.25, "same penumbra at any map resolution: {w64} vs {w256}"); +} + +#[test] +fn tilted_receivers_do_not_shadow_themselves() { + // A receiver plane z = 100 + 4x (76° from the light, n·l ≈ 0.24), each texel holding + // its depth half a texel off-centre the way a rasteriser can. Wide taps must compare + // against the plane rather than the receiver point, and the slope-scaled bias must + // absorb the half-texel error, at every diffusion. + let (size, g) = (128u32, 4.0f32); + let c = size as f32 * 0.5; + let texels: Vec = (0..size * size).map(|i| 100.0 + g * ((i % size) as f32 - c)).collect(); + let len = (1.0 + g * g).sqrt(); + let (ng, n_l) = ([g / len, 0.0, -1.0 / len], 1.0 / len); + for radius in [1.0f32, 6.0, 25.0] { + let m = flat_map(size, 1.0, radius); + for k in 0..40 { + let x = -20.0 + k as f32 * 0.97; + let f = shade::shadow_factor(&m, &texels, [x, 3.3, 100.0 + g * x], ng, n_l); + assert!(f > 0.99, "radius {radius}, x {x}: lit tilted receiver shadowed itself ({f})"); + } + } +} + +#[test] +fn lit_casters_do_not_shadow_themselves() { + // A sphere and a turned cube that cast shadows, lit by each light kind: the lit + // surface must render as bright as with shadows off at any Shadow Diffusion (a + // wide kernel on a surface tilted to the light must not see its own depth). + // 640×360: faces then span enough texels that near-grazing ones exercise the + // receiver plane's slope limit. + for (prim, rot_y) in [(PrimitiveKind::Sphere, 0.0), (PrimitiveKind::Cube, 35.0)] { + for kind in [LightKind::Point, LightKind::Spot, LightKind::Parallel] { + let (mut p, cid) = project(640, 360); + add(&mut p, cid, LayerSource::Primitive { kind: prim }, |l| { + set(l, "transform/position", Value::Vec3([320.0, 180.0, 100.0])); + if prim == PrimitiveKind::Sphere { + set(l, "geometryOptions/radius", Value::Scalar(120.0)); + } else { + for k in ["width", "height", "depth"] { + set(l, &format!("geometryOptions/{k}"), Value::Scalar(216.0)); + } + set(l, "transform/rotationX", Value::Scalar(-25.0)); + } + set(l, "transform/rotationY", Value::Scalar(rot_y)); + set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0])); + set(l, "materialOptions/castsShadows", Value::Enum(1)); + }); + add(&mut p, cid, LayerSource::Light { kind }, |l| { + set(l, "transform/position", Value::Vec3([-280.0, -108.0, -600.0])); + if let Some(poi) = l.props.prop_mut("transform/pointOfInterest") { + poi.value = Value::Vec3([320.0, 180.0, 100.0]); + } + }); + let noshadow = render_light_luma(&mut p, cid, false, 0.0); + let lit: Vec = (0..noshadow.len()).filter(|&i| noshadow[i] > 0.15).collect(); + assert!(lit.len() > 10_000, "{prim:?} {kind:?}: lit area {}", lit.len()); + for diffusion in [0.0, 20.0, 48.0] { + let img = render_light_luma(&mut p, cid, true, diffusion); + let dark = lit.iter().filter(|&&i| img[i] < 0.9 * noshadow[i]).count() as f32 / lit.len() as f32; + assert!(dark <= 0.02, "{prim:?} {kind:?} diffusion {diffusion}: {:.1} % of the lit surface self-shadowed", dark * 100.0); + } + } + } +} + +#[test] +fn shadow_sizes_match_the_halve_largest_rule_and_scale_to_many_lights() { + // The specification `shadow_sizes` implements, step by step: halve the largest map + // above its floor (earliest light on ties) until the scene fits, then drop maps + // from the last light. + let spec = |w: u32, h: u32, draft: bool, kinds: &[u32]| -> Vec> { + let base = shadow_map_size(w, h, draft); + let mut sizes: Vec = kinds + .iter() + .map(|&k| { + if k == 0 { + 0 + } else if k == 2 { + shadow_point_face_size(base, draft) + } else { + base + } + }) + .collect(); + let total = |s: &[u32]| -> u64 { s.iter().zip(kinds).map(|(&z, &k)| (z as u64) * (z as u64) * if k == 2 { 6 } else { 1 }).sum() }; + while total(&sizes) > MAX_SHADOW_TEXELS { + let pick = (0..sizes.len()).filter(|&i| sizes[i] > if kinds[i] == 2 { 128 } else { 512 }).max_by_key(|&i| (sizes[i], std::cmp::Reverse(i))); + match pick { + Some(i) => sizes[i] /= 2, + None => match sizes.iter().rposition(|&z| z > 0) { + Some(i) => sizes[i] = 0, + None => break, + }, + } + } + sizes.into_iter().map(|z| (z > 0).then_some(z)).collect() + }; + let mut seed = 0x2545_F491u32; + for case in 0..300 { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let n = (seed >> 8) as usize % 40; + let kinds: Vec = (0..n) + .map(|_| { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (seed >> 16) % 3 + }) + .collect(); + let (w, h, draft) = ([640, 1920, 3840, 8000][case % 4], [360, 1080, 2160, 100][case % 4], case % 7 == 0); + assert_eq!(shadow_sizes(w, h, draft, &kinds), spec(w, h, draft, &kinds), "case {case}: {kinds:?}"); + } + // A hostile light count stays fast (n log n) and inside the budget. + let many = vec![2u32; 100_000]; + let sizes = shadow_sizes(3840, 2160, false, &many); + assert!(budget_texels(&sizes, &many) <= MAX_SHADOW_TEXELS); +} + +#[test] +fn wide_penumbrae_are_smooth() { + // Coverage at a fixed distance across a wide penumbra, sampled along a straight + // edge, should be one smooth value plus fine dither. A 5×5 grid stretched over a + // 20-texel reach leaves gaps the jitter turns into coarse grain (σ ≈ 0.04, ten + // 8-bit levels); the grid grows with the reach to keep it below 0.02. + let (m, texels) = wall_map(256, 1.0, 20.0); + for off in [-5.0f32, 0.0, 5.0] { + let vs: Vec = (0..200).map(|k| shade::shadow_factor(&m, &texels, [-64.0 + off, -100.0 + k as f32, 13.5], FACING, 1.0)).collect(); + let mean = vs.iter().sum::() / vs.len() as f32; + let sd = (vs.iter().map(|v| (v - mean).powi(2)).sum::() / vs.len() as f32).sqrt(); + assert!(sd < 0.02, "offset {off}: penumbra grain σ = {sd:.4}"); + } +} + +#[test] +fn hard_edges_ramp_over_two_texels() { + // At Shadow Diffusion 0 the edge still ramps across about two texels (10–90 %): + // where one texel spans several output pixels (a floor grazing a point light) a + // one-texel ramp leaves the binary compare's texel steps visible as a sawtooth. + let (m, texels) = wall_map(64, 1.0, 1.0); + for v0 in [0.0f32, 0.3, 7.6] { + let xs: Vec = (0..400).map(|k| -20.0 + k as f32 * 0.02).collect(); + let f: Vec = xs.iter().map(|&x| shade::shadow_factor(&m, &texels, [x, v0, 13.5], FACING, 1.0)).collect(); + let first_below = |t: f32| f.iter().position(|&v| v < t).map_or(f32::NAN, |i| xs[i]); + let w = first_below(0.1) - first_below(0.9); + assert!((1.7..3.0).contains(&w), "v {v0}: hard edge ramps over {w} texels"); + } +}