Merge #2283: Advanced 3D: shadow jitter blends across its lattice cell, so GPU penumbrae match the CPU

This commit is contained in:
dotCooCoo
2026-10-25 20:17:12 -07:00
3 changed files with 75 additions and 14 deletions
+29 -8
View File
@@ -194,6 +194,25 @@ fn tap_hash(x: i32, y: i32) -> u32 {
return h;
}
fn tap_jitter_at(x: i32, y: i32) -> vec2<f32> {
let h = tap_hash(x, y);
return vec2<f32>(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<f32>, i: i32, j: i32) -> vec2<f32> {
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<f32>, ng: vec3<f32>, 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<f32>, ng: vec3<f32>, 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<f32>(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;
+26 -6
View File
@@ -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.
+20
View File
@@ -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