diff --git a/crates/gpu/src/shaders/advanced3d.wgsl b/crates/gpu/src/shaders/advanced3d.wgsl index 4eea78c8c..182868423 100644 --- a/crates/gpu/src/shaders/advanced3d.wgsl +++ b/crates/gpu/src/shaders/advanced3d.wgsl @@ -194,6 +194,25 @@ fn tap_hash(x: i32, y: i32) -> u32 { return h; } +fn tap_jitter_at(x: i32, y: i32) -> vec2 { + let h = tap_hash(x, y); + return vec2(f32(h & 0xFFFFu) * (1.0 / 65536.0) - 0.5, f32((h >> 16u) & 0xFFFFu) * (1.0 / 65536.0) - 0.5); +} + +// shade::tap_jitter: the hash at the four corners of the receiver's 1/8-texel lattice +// cell (cx, cy), blended bilinearly by the receiver's position w in the cell. +fn tap_jitter(cx: i32, cy: i32, w: vec2, i: i32, j: i32) -> vec2 { + let x = cx + i * 7919; + let y = cy + j * 7919; + let c00 = tap_jitter_at(x, y); + let c10 = tap_jitter_at(x + 1, y); + let c01 = tap_jitter_at(x, y + 1); + let c11 = tap_jitter_at(x + 1, y + 1); + let a = c00 + (c10 - c00) * w.x; + let b = c01 + (c11 - c01) * w.x; + return a + (b - a) * w.y; +} + const PCF_MAX_TAPS: i32 = 10; const PLANE_SLOPE_MAX: f32 = 64.0; @@ -244,7 +263,8 @@ fn shadow_factor(m: u32, p: vec3, ng: vec3, n_l: f32) -> f32 { // 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. + // lattice and tap index, blended across the receiver's lattice cell, and bilinear + // compares of the four nearest texel centers. // 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. @@ -268,16 +288,17 @@ fn shadow_factor(m: u32, p: vec3, ng: vec3, n_l: f32) -> f32 { 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)); + let gu = u * 8.0; + let gv = v * 8.0; + let cx = i32(floor(gu)); + let cy = i32(floor(gv)); + let wc = vec2(gu - floor(gu), gv - floor(gv)); var lit = 0.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; + let jit = tap_jitter(cx, cy, wc, i, j); + let su = u + ((f32(i) - half) + jit.x) * step; + let sv = v + ((f32(j) - half) + jit.y) * 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; diff --git a/crates/render/src/three_d/adv/shade.rs b/crates/render/src/three_d/adv/shade.rs index c7c1a40f1..0168a769e 100644 --- a/crates/render/src/three_d/adv/shade.rs +++ b/crates/render/src/three_d/adv/shade.rs @@ -217,6 +217,25 @@ fn tap_hash(x: i32, y: i32) -> u32 { h } +/// The jitter of tap (`i`, `j`), each axis in [−0.5, 0.5): [`tap_hash`] values at the four +/// corners of the receiver's 1/8-texel lattice cell (`cx`, `cy`), blended bilinearly by the +/// receiver's position in the cell (`wx`, `wy`). The jitter is continuous in the receiver's +/// map position, so a position that differs between backends in its last bits moves the +/// taps by an amount proportional to that difference and does not switch them to another +/// cell's jitter. +fn tap_jitter(cx: i32, cy: i32, wx: f32, wy: f32, i: i32, j: i32) -> (f32, f32) { + let corner = |dx: i32, dy: i32| -> (f32, f32) { + let h = tap_hash(cx.wrapping_add(dx).wrapping_add(i.wrapping_mul(7919)), cy.wrapping_add(dy).wrapping_add(j.wrapping_mul(7919))); + (((h & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5, (((h >> 16) & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5) + }; + let (c00, c10, c01, c11) = (corner(0, 0), corner(1, 0), corner(0, 1), corner(1, 1)); + let ax = c00.0 + (c10.0 - c00.0) * wx; + let bx = c01.0 + (c11.0 - c01.0) * wx; + let ay = c00.1 + (c10.1 - c00.1) * wx; + let by = c01.1 + (c11.1 - c01.1) * wx; + (ax + (bx - ax) * wy, ay + (by - ay) * wy) +} + /// 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). @@ -232,8 +251,9 @@ const PLANE_SLOPE_MAX: f32 = 64.0; /// /// 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 +/// texel footprint, each tap jittered by [`tap_jitter`] (an integer hash of the +/// receiver's sub-texel lattice and the tap index, blended across the receiver's +/// lattice cell), 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 @@ -301,13 +321,13 @@ pub fn shadow_factor(m: &ShadowMap, texels: &[f32], p: [f32; 3], ng: [f32; 3], n 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 (gu, gv) = (u * 8.0, v * 8.0); + let (cx, cy) = (gu.floor() as i32, gv.floor() as i32); + let (wx, wy) = (gu - gu.floor(), gv - gv.floor()); let mut lit = 0.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 (jx, jy) = tap_jitter(cx, cy, wx, wy, i, j); 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. diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index 3f5ee4813..f752406d7 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -887,6 +887,26 @@ fn penumbra_dithers_along_a_straight_edge() { assert!(distinct(&within) >= 4, "coverage inside one texel takes {} distinct values", distinct(&within)); } +#[test] +fn penumbra_jitter_is_continuous_across_its_lattice() { + // Receiver pairs 1/5000 texel either side of a line of the 1/8-texel jitter lattice, + // across u and across v, inside the penumbra. The GPU places a receiver within a few + // ulps of the CPU, so coverage that jumps at a lattice line differs between the two + // backends there; both sides of each line must agree within one 8-bit level. + let (m, texels) = wall_map(64, 1.0, 6.0); + let f = |p: [f32; 2]| shade::shadow_factor(&m, &texels, [p[0], p[1], 13.5], FACING, 1.0); + let mut worst = 0.0f32; + for k in 0..16 { + let line = k as f32 * 0.125; + for (a, b) in [([-16.0 + line - 2e-4, 0.3], [-16.0 + line + 2e-4, 0.3]), ([-16.3, line - 2e-4], [-16.3, line + 2e-4])] { + let (fa, fb) = (f(a), f(b)); + assert!(fa > 0.05 && fa < 0.95, "{a:?} lies in the penumbra: {fa}"); + worst = worst.max((fa - fb).abs()); + } + } + assert!(worst < 1.0 / 255.0, "coverage jumps by {worst} across a jitter lattice line"); +} + #[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