Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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);
}