facett-map3d/src/ssao.wgsl
// ── Screen-space ambient occlusion (T3.1, SSAO/GTAO-style) ───────────────────
// A fullscreen pass that reads the G-buffer the normal prepass wrote (world-space
// normal in rgb, clip-space NDC depth in a) and estimates, per pixel, how much of
// the ambient sky hemisphere is occluded by nearby geometry. Creases, wall/ground
// contacts and the insides of the building blocks come back darker. The main mesh
// pass (`map3d.wgsl`) samples this AO target to modulate ONLY the ambient term, so
// direct sun + cast shadows are untouched.
//
// World-space hemisphere sampling: reconstruct the fragment's world position from
// the stored NDC depth + the inverse view-projection, build a TBN around the
// stored normal (rotated per-pixel by a deterministic hash → no temporal noise,
// FC-7 clean), march a fixed Poisson hemisphere kernel, and count samples whose
// projected screen location reads back CLOSER to the eye than the sample point
// (i.e. an occluder sits in front). Range-checked so distant geometry past the
// radius does not halo.
struct SsaoU {
view_proj: mat4x4<f32>, // world → clip (project sample points to screen uv)
inv_view_proj: mat4x4<f32>, // clip → world (unproject the stored depth)
eye: vec4<f32>, // world eye (xyz); w unused
params: vec4<f32>, // radius (x), bias (y), intensity (z), power (w)
res: vec4<f32>, // res_x, res_y, 1/res_x, 1/res_y
};
@group(0) @binding(0) var<uniform> U: SsaoU;
@group(0) @binding(1) var gbuf_tex: texture_2d<f32>;
@group(0) @binding(2) var gbuf_smp: sampler;
struct VsOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn ssao_vs(@builtin(vertex_index) vi: u32) -> VsOut {
var p = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>( 3.0, -1.0),
vec2<f32>(-1.0, 3.0),
);
var out: VsOut;
let xy = p[vi];
out.clip = vec4<f32>(xy, 0.0, 1.0);
out.uv = vec2<f32>(xy.x * 0.5 + 0.5, 1.0 - (xy.y * 0.5 + 0.5));
return out;
}
// 16-sample hemisphere kernel (z = up), lengths ramped so samples cluster near the
// origin (more weight to nearby occluders). Deterministic constants — no RNG.
const N_SAMPLES: i32 = 16;
fn kernel(i: i32) -> vec3<f32> {
var k = array<vec3<f32>, 16>(
vec3<f32>( 0.049, 0.013, 0.040),
vec3<f32>(-0.071, 0.055, 0.073),
vec3<f32>( 0.033, -0.092, 0.060),
vec3<f32>(-0.045, -0.038, 0.110),
vec3<f32>( 0.128, 0.061, 0.052),
vec3<f32>(-0.110, -0.090, 0.070),
vec3<f32>( 0.012, 0.150, 0.090),
vec3<f32>( 0.090, -0.140, 0.110),
vec3<f32>(-0.170, 0.030, 0.130),
vec3<f32>( 0.060, 0.070, 0.220),
vec3<f32>(-0.090, 0.180, 0.160),
vec3<f32>( 0.200, -0.060, 0.180),
vec3<f32>(-0.150, -0.190, 0.200),
vec3<f32>( 0.040, 0.250, 0.230),
vec3<f32>( 0.270, 0.120, 0.240),
vec3<f32>(-0.230, 0.090, 0.320),
);
return k[i];
}
// Deterministic per-pixel rotation vector (a hashed direction in the tangent
// plane) — decorrelates the kernel between neighbouring pixels so the AO is not
// banded, with no random state (hash of integer pixel coords → FC-7 stable).
fn rot_noise(px: vec2<u32>) -> vec2<f32> {
let h = (px.x * 1664525u + px.y * 1013904223u) ^ (px.x << 13u) ^ (px.y << 7u);
let a = f32(h & 1023u) / 1023.0 * 6.2831853;
return vec2<f32>(cos(a), sin(a));
}
fn world_from_depth(uv: vec2<f32>, depth: f32) -> vec3<f32> {
let ndc = vec3<f32>(uv.x * 2.0 - 1.0, (1.0 - uv.y) * 2.0 - 1.0, depth);
let w = U.inv_view_proj * vec4<f32>(ndc, 1.0);
return w.xyz / w.w;
}
@fragment
fn ssao_fs(in: VsOut) -> @location(0) vec4<f32> {
let g = textureSampleLevel(gbuf_tex, gbuf_smp, in.uv, 0.0);
let depth = g.a;
// Background (the sky/clear: the prepass cleared depth to the far plane 1.0 and
// normal to 0) → no occlusion (fully lit ambient).
let n_len = length(g.xyz);
if (depth >= 0.9999 || n_len < 0.1) {
return vec4<f32>(1.0, 1.0, 1.0, 1.0);
}
let n = g.xyz / n_len;
let pos = world_from_depth(in.uv, depth);
let radius = U.params.x;
let bias = U.params.y;
// TBN: tangent = the hashed in-plane direction (Gram-Schmidt against n).
let px = vec2<u32>(in.clip.xy);
let rn = rot_noise(px);
var rvec = vec3<f32>(rn.x, rn.y, 0.0);
let t = normalize(rvec - n * dot(rvec, n));
let b = cross(n, t);
// Horizon / Alchemy-style estimator: a sample only occludes if the REAL geometry
// at its screen location rises ABOVE the receiver's tangent plane (dot(n, dir) >
// bias). On a flat open field every neighbour lies IN the tangent plane → dot ≈ 0
// → no occlusion (no flat self-darkening); a crease/wall/contact rises above it →
// dot > 0 → it occludes. `bias` is an angular cosine threshold.
var occ = 0.0;
for (var i = 0; i < N_SAMPLES; i = i + 1) {
let k = kernel(i);
// tangent → world (hemisphere oriented along the surface normal) — the search
// direction; the actual occluder is whatever geometry is at that screen pixel.
let sample_dir = t * k.x + b * k.y + n * k.z;
let sp = pos + sample_dir * radius;
let clip = U.view_proj * vec4<f32>(sp, 1.0);
if (clip.w <= 0.0) { continue; }
let sndc = clip.xyz / clip.w;
let suv = vec2<f32>(sndc.x * 0.5 + 0.5, 1.0 - (sndc.y * 0.5 + 0.5));
if (suv.x < 0.0 || suv.x > 1.0 || suv.y < 0.0 || suv.y > 1.0) { continue; }
let sg = textureSampleLevel(gbuf_tex, gbuf_smp, suv, 0.0);
if (sg.a >= 0.9999) { continue; } // sky behind → no occluder
let sworld = world_from_depth(suv, sg.a);
let diff = sworld - pos;
let dist = length(diff);
if (dist < 1e-4) { continue; }
let rise = dot(n, diff / dist); // how far the occluder rises above the plane
// Distance falloff: full weight near, zero past the radius (no far haloing).
let range = clamp(1.0 - (dist - radius) / max(radius, 1e-4), 0.0, 1.0);
occ = occ + max(rise - bias, 0.0) * range;
}
occ = occ / f32(N_SAMPLES);
var ao = 1.0 - occ * U.params.z;
ao = pow(clamp(ao, 0.0, 1.0), U.params.w);
return vec4<f32>(ao, ao, ao, 1.0);
}