mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
#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:
@@ -461,3 +461,5 @@ mod tests_fx_warp;
|
||||
mod tests_init;
|
||||
#[cfg(test)]
|
||||
mod tests_particles;
|
||||
#[cfg(test)]
|
||||
mod tests_wgsl_grammar;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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]),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user