mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 13:08:07 -06:00
Advanced 3D: shadow jitter blends across its lattice cell, so GPU penumbrae match the CPU
The PCF tap jitter was keyed on the receiver's 1/8-texel cell in the shadow map. The GPU computes the receiver's map position with last-bit differences from the CPU, so a receiver next to a cell line could take another cell's jitter on one backend, and every tap moved. In a wide penumbra that changed the coverage by several 8-bit levels: gpu_matches_cpu_wide_penumbrae failed on Windows with 15 of 4159 shadowed pixels on an NVIDIA RTX 5080 and 22 on an AMD Radeon iGPU, against a budget of 8. tap_jitter now blends the hashes at the four corners of the cell by the receiver's position in it, in shade.rs and in advanced3d.wgsl. At the lattice points the jitter is the per-cell hash it was before. A CPU test checks that the coverage does not jump at a lattice line.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user