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/bloom.wgsl

// **HDR bloom + ACES tonemap post stack** for the map3d offscreen (`Rgba16Float`).
//
// The opaque mesh + water passes now write **linear radiance** (no baked tonemap)
// into the HDR offscreen colour. This shader is the post chain that turns that HDR
// buffer into the 8-bit egui surface:
//
//   bright-pass (soft-knee threshold, → half-res)  →  separable Gaussian blur
//   (H then V, half-res ping-pong)  →  composite (HDR scene + bloom, exposure,
//   Narkowicz **ACES** filmic tonemap, sRGB encode → LDR)  →  present blit.
//
// All four entry points share one fullscreen-triangle vertex shader. `present_fs`
// is the trivial final blit of the tonemapped LDR target into egui's pass (an FXAA
// variant replaces it in `fxaa.wgsl`).

struct BloomUniforms {
    // threshold (x), knee (y), bloom intensity (z), exposure (w)
    params: vec4<f32>,
    // source texel size (xy = 1/width, 1/height) + blur direction (zw, in texels)
    texel: vec4<f32>,
};

@group(0) @binding(0) var<uniform> U: BloomUniforms;
@group(0) @binding(1) var src_tex: texture_2d<f32>;
@group(0) @binding(2) var src_smp: sampler;

struct VsOut {
    @builtin(position) clip: vec4<f32>,
    @location(0) uv: vec2<f32>,
};

// Fullscreen triangle (clip-space), uv with the y-flip the rest of the stack uses.
@vertex
fn post_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;
}

// ── Bright pass: keep only what is brighter than the threshold (with a soft knee so
// the bloom fades in smoothly instead of a hard cutoff). A 4-tap box pre-filter on
// the full-res HDR while downsampling to half-res tames single-pixel fireflies. ──
fn soft_threshold(c: vec3<f32>, threshold: f32, knee: f32) -> vec3<f32> {
    let br = max(c.r, max(c.g, c.b));
    // Quadratic knee around `threshold` (Jimenez / Unity style).
    let soft = clamp(br - threshold + knee, 0.0, 2.0 * knee);
    let soft2 = soft * soft / (4.0 * knee + 1e-5);
    let contrib = max(soft2, br - threshold) / max(br, 1e-5);
    return c * max(contrib, 0.0);
}

@fragment
fn bright_fs(in: VsOut) -> @location(0) vec4<f32> {
    let t = U.texel.xy; // texel of the HDR SOURCE (full-res)
    // 4-tap box at half-texel offsets → stable downsample.
    let o = t * 0.5;
    var c = textureSample(src_tex, src_smp, in.uv + vec2<f32>( o.x,  o.y)).rgb;
    c = c + textureSample(src_tex, src_smp, in.uv + vec2<f32>(-o.x,  o.y)).rgb;
    c = c + textureSample(src_tex, src_smp, in.uv + vec2<f32>( o.x, -o.y)).rgb;
    c = c + textureSample(src_tex, src_smp, in.uv + vec2<f32>(-o.x, -o.y)).rgb;
    c = c * 0.25;
    return vec4<f32>(soft_threshold(c, U.params.x, U.params.y), 1.0);
}

// ── Separable Gaussian blur (9-tap, σ≈2). `texel.zw` selects the axis (H = (1,0),
// V = (0,1)); two passes (H then V) give the 2D blur at O(2n) cost. ──
@fragment
fn blur_fs(in: VsOut) -> @location(0) vec4<f32> {
    let step = U.texel.xy * U.texel.zw; // per-tap offset along the blur axis
    // Normalised 1D Gaussian weights for offsets 0..4.
    let w0 = 0.2270270270;
    let w1 = 0.1945945946;
    let w2 = 0.1216216216;
    let w3 = 0.0540540541;
    let w4 = 0.0162162162;
    var c = textureSample(src_tex, src_smp, in.uv).rgb * w0;
    c = c + textureSample(src_tex, src_smp, in.uv + step * 1.0).rgb * w1;
    c = c + textureSample(src_tex, src_smp, in.uv - step * 1.0).rgb * w1;
    c = c + textureSample(src_tex, src_smp, in.uv + step * 2.0).rgb * w2;
    c = c + textureSample(src_tex, src_smp, in.uv - step * 2.0).rgb * w2;
    c = c + textureSample(src_tex, src_smp, in.uv + step * 3.0).rgb * w3;
    c = c + textureSample(src_tex, src_smp, in.uv - step * 3.0).rgb * w3;
    c = c + textureSample(src_tex, src_smp, in.uv + step * 4.0).rgb * w4;
    c = c + textureSample(src_tex, src_smp, in.uv - step * 4.0).rgb * w4;
    return vec4<f32>(c, 1.0);
}

// Narkowicz ACES filmic tonemap (linear in → display-referred linear out).
fn aces(x: vec3<f32>) -> vec3<f32> {
    let a = 2.51;
    let b = 0.03;
    let c = 2.43;
    let d = 0.59;
    let e = 0.14;
    return clamp((x * (a * x + b)) / (x * (c * x + d) + e), vec3<f32>(0.0), vec3<f32>(1.0));
}

// ── Composite: scene HDR + blurred bloom, then expose + ACES tonemap + sRGB encode
// to LDR. Two source textures: binding 1 = HDR scene (full-res), binding 3 = bloom. ──
@group(0) @binding(3) var bloom_tex: texture_2d<f32>;

@fragment
fn composite_fs(in: VsOut) -> @location(0) vec4<f32> {
    let scene = textureSample(src_tex, src_smp, in.uv).rgb;
    let bloom = textureSample(bloom_tex, src_smp, in.uv).rgb;
    var c = scene + bloom * U.params.z;     // additive bloom (intensity)
    c = c * U.params.w;                      // physically-based exposure (EV100, see gpu.rs)
    // Tonemap: **Narkowicz ACES** filmic (linear HDR → display-referred), replacing
    // the old Reinhard `x/(x+0.85)`. ACES gives the punchier filmic shoulder/toe
    // (highlights roll off, shadows deepen) that reads far more "cinematic" on the
    // HDR city. The mild `1/1.15` gamma lift is kept (a small artistic midtone
    // brighten, on top of the LDR target's own sRGB encode). This changes pixel
    // content vs Reinhard, so the colour-classifier render-proofs are recalibrated.
    c = aces(c);
    c = pow(clamp(c, vec3<f32>(0.0), vec3<f32>(1.0)), vec3<f32>(1.0 / 1.15));
    return vec4<f32>(c, 1.0);
}