#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.
This commit is contained in:
Christopher Linnett
2026-10-23 16:21:21 +08:00
parent b55cb5bc15
commit 32ae41c4eb
8 changed files with 910 additions and 47 deletions
+2
View File
@@ -461,3 +461,5 @@ mod tests_fx_warp;
mod tests_init;
#[cfg(test)]
mod tests_particles;
#[cfg(test)]
mod tests_wgsl_grammar;
+94 -20
View File
@@ -179,7 +179,41 @@ fn equirect_uv(d: vec3<f32>, rot: f32) -> vec2<f32> {
return vec2<f32>(u - floor(u), v);
}
fn shadow_factor(m: u32, p: vec3<f32>, 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<f32>, 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<f32>, ng: vec3<f32>, 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<f32>, 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<f32>(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<f32>(0.0, 0.0);
if (den0 < -1e-6) {
t = vec2<f32>(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<f32>, shadow: i32, darkness: f32, p: vec3<f32>, n_l: f32) -> f32 {
fn light_shadow(li: u32, kind: u32, lpos: vec3<f32>, shadow: i32, darkness: f32, p: vec3<f32>, ng: vec3<f32>, n_l: f32) -> f32 {
if (shadow < 0) {
return 1.0;
}
@@ -242,7 +314,7 @@ fn light_shadow(li: u32, kind: u32, lpos: vec3<f32>, 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<f32>, front: bool, pos: vec3<f32
if (n_l <= 0.0) {
continue;
}
amount = max(amount, 1.0 - light_shadow(li, kind, l0.xyz, i32(l3.w), l4.x, pos, n_l));
amount = max(amount, 1.0 - light_shadow(li, kind, l0.xyz, i32(l3.w), l4.x, pos, n, n_l));
}
return amount;
}
@@ -399,6 +471,8 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> 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;
+38
View File
@@ -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<usize> = (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 };
+191
View File
@@ -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<f32>`, `array<vec4<u32>>`), 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<char> = 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<&str>> = 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<f32> = vec3<f32>(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"));
}
+102 -2
View File
@@ -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<f32> {
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<Option<u32>> {
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<u32> = 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<usize>)> =
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<usize> = (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<u32> = 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<ShadowMap> = 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]),
+2 -1
View File
@@ -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,
}
+115 -24
View File
@@ -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]
}
+366
View File
@@ -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<f32>) {
let texels: Vec<f32> = (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<f32> {
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<u32>], 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<usize> = (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<usize> = (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<f32> = 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<bool> = (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<u32> = vs.iter().map(|v| v.to_bits()).collect();
bits.sort_unstable();
bits.dedup();
bits.len()
};
let along: Vec<f32> = (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<f32> = (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<f32> = (0..400).map(|k| -30.0 + k as f32 * 0.07).collect();
let f: Vec<f32> = 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<f32> = (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<usize> = (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<Option<u32>> {
let base = shadow_map_size(w, h, draft);
let mut sizes: Vec<u32> = 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<u32> = (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<f32> = (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::<f32>() / vs.len() as f32;
let sd = (vs.iter().map(|v| (v - mean).powi(2)).sum::<f32>() / 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<f32> = (0..400).map(|k| -20.0 + k as f32 * 0.02).collect();
let f: Vec<f32> = 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");
}
}