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