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

// 3D OSM map — depth-tested, lit prism/terrain mesh. The genuine game/flight-sim
// occlusion: every triangle is transformed by the camera view-projection and the
// GPU **depth buffer** (a depth32float attachment, LESS test) decides which
// fragment wins per pixel — NOT a CPU painter's-algorithm sort.

struct Uniforms {
    view_proj: mat4x4<f32>,  // column-major camera view·projection
    light:     vec4<f32>,    // world-space SUN dir (xyz, surface→sun) + ambient (w)
    fog:       vec4<f32>,    // background fog colour (rgb) + max fog strength (w)
    fog_range: vec4<f32>,    // near (x), far (y) camera depth; height-haze strength k (z); eye.y (w)
    sun_color: vec4<f32>,    // directional sun colour (rgb) + intensity (w)
    sky_color: vec4<f32>,    // sky/ambient tint (rgb) + horizon bias (w)
    eye:       vec4<f32>,    // world-space eye position (xyz) + HAZE SCALE
                             // HEIGHT in design units (w) — a metric length
                             // (`backend::haze_scale_height`), never an absolute.
    pbr:       vec4<f32>,    // roughness (x), metallic (y), time (z), shadow_strength (w)
    // **Cascaded shadow maps** (T3.3): one world→light-clip per cascade (tight→wide
    // ortho around the scene centre). The fragment picks the tightest cascade that
    // covers it (high-res near, coarse far → softens with distance).
    light_vp:  array<mat4x4<f32>, 3>,
    // cascade split distances in camera depth (x = cascade0→1, y = cascade1→2),
    // PCSS light size in light-uv units (z), soft-shadow flag (w: 1 = PCSS, 0 = hard).
    cascade:   vec4<f32>,
    // SSAO apply (T3.1): target resolution (xy, px) + AO strength (z) + AO-on (w).
    ao_params: vec4<f32>,
    // ── IBL (image-based lighting, split-sum) ──────────────────────────────────
    // Diffuse irradiance as **SH L2** (9 vec3 coeffs, raw radiance projection),
    // integrated from the analytic sky on the CPU each frame. `.xyz` used; `.w` pad.
    sh: array<vec4<f32>, 9>,
    // IBL knobs: diffuse scale (x), specular scale (y), spec-AO/horizon strength (z),
    // reflection sun-glow scale (w).
    ibl: vec4<f32>,
};

@group(0) @binding(0) var<uniform> U: Uniforms;
// Cascaded shadow maps: the sun's depth render of the scene (one array layer per
// cascade) + a comparison sampler (PCF) AND a plain sampler (PCSS blocker search).
@group(0) @binding(1) var shadow_tex: texture_depth_2d_array;
@group(0) @binding(2) var shadow_cmp: sampler_comparison;
@group(0) @binding(3) var shadow_depth: sampler;
// SSAO (T3.1): the screen-space ambient-occlusion target the SSAO pass wrote.
@group(0) @binding(4) var ao_tex: texture_2d<f32>;
@group(0) @binding(5) var ao_smp: sampler;

struct VsIn {
    @location(0) pos: vec3<f32>,
    @location(1) nrm: vec3<f32>,
    @location(2) col: vec4<f32>,   // per-vertex source colour (from per-tri colour)
};

// ── G-BUFFER NORMAL PREPASS (T3.1): render the mesh into a normal+depth target
// the SSAO pass reads. rgb = world-space normal, a = clip-space NDC depth. ──────
struct GbufOut {
    @builtin(position) clip: vec4<f32>,
    @location(0) nrm: vec3<f32>,
};

@vertex
fn gbuf_vs(in: VsIn) -> GbufOut {
    var out: GbufOut;
    out.clip = U.view_proj * vec4<f32>(in.pos, 1.0);
    out.nrm = in.nrm;
    return out;
}

@fragment
fn gbuf_fs(in: GbufOut) -> @location(0) vec4<f32> {
    // a = NDC depth (the builtin position already carries clip.z/clip.w in .z).
    return vec4<f32>(normalize(in.nrm), in.clip.z);
}

// ── Cascaded PCSS shadow (T3.3) ──────────────────────────────────────────────
// 16-tap Poisson disk (unit), reused for the blocker search + the PCF filter.
fn poisson(i: i32) -> vec2<f32> {
    var d = array<vec2<f32>, 16>(
        vec2<f32>(-0.6116, -0.2389), vec2<f32>( 0.2007, -0.5871),
        vec2<f32>( 0.6859,  0.1153), vec2<f32>(-0.1734,  0.6411),
        vec2<f32>(-0.9109,  0.2310), vec2<f32>( 0.4953, -0.8210),
        vec2<f32>( 0.1132,  0.1369), vec2<f32>(-0.3651, -0.7424),
        vec2<f32>( 0.9009, -0.3187), vec2<f32>(-0.5318,  0.8118),
        vec2<f32>( 0.2470,  0.9251), vec2<f32>(-0.8517, -0.4943),
        vec2<f32>( 0.7335,  0.6334), vec2<f32>( 0.0316, -0.9783),
        vec2<f32>(-0.2226,  0.1146), vec2<f32>( 0.4015,  0.4538),
    );
    return d[i];
}

struct CascadeHit {
    idx: i32,
    uv: vec2<f32>,
    z: f32,
    ok: bool,
};
// Pick the tightest cascade whose light-space uv covers `world`: a camera-depth
// hint chooses the start cascade, then fall through coarser ones until coverage.
fn pick_cascade(world: vec3<f32>, view_depth: f32) -> CascadeHit {
    var start = 0;
    if (view_depth > U.cascade.x) { start = 1; }
    if (view_depth > U.cascade.y) { start = 2; }
    var out: CascadeHit;
    out.ok = false;
    for (var c = start; c < 3; c = c + 1) {
        let lc = U.light_vp[c] * vec4<f32>(world, 1.0);
        let p = lc.xyz / lc.w;
        let uv = vec2<f32>(p.x * 0.5 + 0.5, 1.0 - (p.y * 0.5 + 0.5));
        // A small margin keeps the PCF/PCSS kernel inside the cascade.
        if (uv.x >= 0.02 && uv.x <= 0.98 && uv.y >= 0.02 && uv.y <= 0.98 && p.z <= 1.0) {
            out.idx = c;
            out.uv = uv;
            out.z = p.z;
            out.ok = true;
            return out;
        }
    }
    return out;
}

// PCSS: blocker search → penumbra estimate (contact hardening) → Poisson PCF.
// Returns 1.0 = fully lit, 0.0 = fully shadowed. `cascade.z` is the light size
// (penumbra scale); `cascade.w` toggles soft (PCSS) vs a tight hard filter.
fn shadow_factor(world: vec3<f32>, n_dot_l: f32, view_depth: f32) -> f32 {
    let hit = pick_cascade(world, view_depth);
    if (!hit.ok) {
        return 1.0; // outside every cascade → no shadow data → lit
    }
    let layer = hit.idx;
    let uv = hit.uv;
    let bias = clamp(0.0016 * (1.0 - n_dot_l), 0.0008, 0.004);
    let recv = hit.z;
    // `cascade.z` is the **softness scale** (how fast the penumbra grows with the
    // occluder→receiver gap). The blocker SEARCH itself stays a small fixed radius so
    // a flat lit surface never finds false blockers in its own depth gradient (the
    // cause of flat self-shadowing when the search spans too many texels).
    let softness = U.cascade.z;
    let search = 0.022;

    // ── 1. Blocker search: average the depth of samples in front of the receiver.
    // `textureLoad` reads raw occluder depth at integer texels (no sampler, valid in
    // the non-uniform control flow below). An extra search bias guards flat surfaces.
    let dims_u = textureDimensions(shadow_tex);
    let dims_f = vec2<f32>(dims_u);
    let search_bias = bias + 0.0015;
    var blocker_sum = 0.0;
    var blocker_cnt = 0.0;
    for (var i = 0; i < 16; i = i + 1) {
        let o = poisson(i) * search;
        let c = vec2<i32>(clamp((uv + o) * dims_f, vec2<f32>(0.0), dims_f - 1.0));
        let d = textureLoad(shadow_tex, c, layer, 0);
        if (d < recv - search_bias) {
            blocker_sum = blocker_sum + d;
            blocker_cnt = blocker_cnt + 1.0;
        }
    }
    if (blocker_cnt < 0.5) {
        return 1.0; // no occluder → fully lit (flat open ground hits this)
    }
    let avg_blocker = blocker_sum / blocker_cnt;

    // ── 2. Penumbra: the larger the receiver↔blocker gap, the wider (soft) — the
    // contact-hardening law. Near contact (recv≈blocker) → tiny → crisp; a high
    // occluder → wide. `softness` scales the gap ratio into a uv filter radius.
    let gap = (recv - avg_blocker) / max(avg_blocker, 1e-4);
    var filter_radius = clamp(gap * softness, 0.0009, 0.03);
    if (U.cascade.w < 0.5) {
        // Hard control: a fixed minimal kernel (≈ the old 3×3 PCF footprint).
        filter_radius = 1.2 / dims_f.x;
    }

    // ── 3. Poisson PCF at the penumbra radius. `textureSampleCompareLevel` samples
    // at LOD 0 (no implicit derivatives) so it is valid in this non-uniform flow.
    var sum = 0.0;
    for (var i = 0; i < 16; i = i + 1) {
        let o = poisson(i) * filter_radius;
        sum = sum + textureSampleCompareLevel(shadow_tex, shadow_cmp, uv + o, layer, recv - bias);
    }
    return sum / 16.0;
}

struct VsOut {
    @builtin(position) clip: vec4<f32>,
    @location(0) nrm: vec3<f32>,
    @location(1) col: vec3<f32>,
    @location(2) depth: f32,       // camera-space depth (clip.w) for fog
    @location(3) world: vec3<f32>, // world-space position (PBR view vector + height fog)
    // Emissive intensity, decoded from the vertex colour's w (V2.5 D.3 overlay
    // channel): every basemap vertex carries w = 1.0 → 0 here (the lit path,
    // byte-identical); an overlay vertex may carry w = 1 + e → e, the self-lit HDR
    // scale the fragment shader shortcuts on.
    @location(4) emissive: f32,
};

@vertex
fn vs(in: VsIn) -> VsOut {
    var out: VsOut;
    out.clip = U.view_proj * vec4<f32>(in.pos, 1.0);
    out.nrm = in.nrm;
    out.col = in.col.rgb;
    out.world = in.pos;
    // clip.w is the perspective camera-space depth — a clean fog distance proxy.
    out.depth = out.clip.w;
    out.emissive = max(in.col.a - 1.0, 0.0);
    return out;
}

const PI: f32 = 3.14159265359;
// Dielectric reflectance (Filament): f0 = 0.16·reflectance² → default 0.5 gives the
// canonical ~0.04 (4%) normal-incidence reflectance of common insulators.
const REFLECTANCE: f32 = 0.5;

// ── PBR BRDF core — Filament metallic-roughness (Cook-Torrance) ───────────────
// Author *perceptual* roughness; the specular lobe is driven by α = perceptualRoughness².
// These are Filament's numerically-stable formulations (matching Bevy 0.19's
// `pbr_lighting.wgsl`, wgpu-29): specular = D · V · F, where V already folds the
// 1/(4·NoV·NoL) denominator (no extra divide in the caller). `a` below is ALPHA (α),
// NOT perceptual roughness — the caller squares perceptualRoughness first.
//
// D_GGX — GGX/Trowbridge-Reitz normal distribution (the specular highlight shape).
fn D_GGX(n_dot_h: f32, a: f32) -> f32 {
    let one_minus_noh2 = 1.0 - n_dot_h * n_dot_h;
    let k = a / (one_minus_noh2 + n_dot_h * n_dot_h * a * a);
    return k * k * (1.0 / PI);
}
// V_SmithGGXCorrelated — height-correlated Smith visibility (geometry masking +
// shadowing) with the 4·NoV·NoL denominator folded in (Filament / Heitz 2014).
fn V_SmithGGXCorrelated(n_dot_v: f32, n_dot_l: f32, a: f32) -> f32 {
    let a2 = a * a;
    let ggx_v = n_dot_l * sqrt(n_dot_v * n_dot_v * (1.0 - a2) + a2);
    let ggx_l = n_dot_v * sqrt(n_dot_l * n_dot_l * (1.0 - a2) + a2);
    return 0.5 / max(ggx_v + ggx_l, 1e-5);
}
// F_Schlick — Schlick Fresnel (grazing-angle reflectance rise), vector f0.
fn F_Schlick(v_dot_h: f32, f0: vec3<f32>) -> vec3<f32> {
    return f0 + (vec3<f32>(1.0) - f0) * pow(clamp(1.0 - v_dot_h, 0.0, 1.0), 5.0);
}

// ── IBL split-sum helpers ─────────────────────────────────────────────────────
// DIFFUSE: reconstruct irradiance E(n) from the CPU-integrated SH-L2 radiance
// coefficients (Ramamoorthi & Hanrahan, "An Efficient Representation for Irradiance
// Environment Maps"). The 9 `U.sh[i].rgb` are the raw radiance projection L_lm; the
// per-band constants below fold in the cosine (Lambert) convolution, so this returns
// irradiance directly (caller applies albedo/π).
fn sh_irradiance(n: vec3<f32>) -> vec3<f32> {
    let x = n.x; let y = n.y; let z = n.z;
    let L00 = U.sh[0].rgb; let L1m1 = U.sh[1].rgb; let L10 = U.sh[2].rgb;
    let L11 = U.sh[3].rgb; let L2m2 = U.sh[4].rgb; let L2m1 = U.sh[5].rgb;
    let L20 = U.sh[6].rgb; let L21 = U.sh[7].rgb; let L22 = U.sh[8].rgb;
    let c1 = 0.429043; let c2 = 0.511664; let c3 = 0.743125;
    let c4 = 0.886227; let c5 = 0.247708;
    let E = c1 * L22 * (x * x - y * y) + c3 * L20 * (z * z) + c4 * L00 - c5 * L20
          + 2.0 * c1 * (L2m2 * x * y + L21 * x * z + L2m1 * y * z)
          + 2.0 * c2 * (L11 * x + L1m1 * y + L10 * z);
    return max(E, vec3<f32>(0.0));
}

// SPECULAR: the prefiltered environment for the reflection direction. The sky here
// is an analytic function (no cubemap texture exists), so "prefiltering" is the sky
// gradient (which survives any blur) plus a sun glow whose sharpness & strength fall
// as roughness rises — a matte wall blurs the sun into the sky, a glossy one keeps a
// tight reflection. Below the world horizon the sample darkens to the ground haze, so
// ambient specular does NOT leak up from under surfaces (horizon occlusion).
fn env_gradient(dir: vec3<f32>) -> vec3<f32> {
    let bias = clamp(U.sky_color.w, 0.05, 4.0);
    let t = pow(clamp(dir.y, 0.0, 1.0), bias);
    var col = mix(U.fog.rgb, U.sky_color.rgb, t);
    let below = clamp(-dir.y * 3.0, 0.0, 1.0);
    col = mix(col, U.fog.rgb * 0.6, below);
    return col;
}
fn env_prefiltered(dir: vec3<f32>, perceptual: f32) -> vec3<f32> {
    var col = env_gradient(dir);
    let l = normalize(U.light.xyz);
    let cosang = clamp(dot(dir, l), 0.0, 1.0);
    let sharp = mix(64.0, 4.0, perceptual);
    let glow = pow(cosang, sharp) * (1.0 - perceptual) * U.ibl.w;
    col = col + U.sun_color.rgb * U.sun_color.w * glow;
    return col;
}
// DFG: Karis' analytic environment-BRDF approximation ("Physically Based Shading on
// Mobile") — the split-sum second factor without a baked DFG LUT. Returns (scale,
// bias) so specular = prefiltered · (f0·scale + bias).
fn env_brdf_approx(n_dot_v: f32, perceptual: f32) -> vec2<f32> {
    let c0 = vec4<f32>(-1.0, -0.0275, -0.572, 0.022);
    let c1 = vec4<f32>(1.0, 0.0425, 1.04, -0.04);
    let r = perceptual * c0 + c1;
    let a004 = min(r.x * r.x, exp2(-9.28 * n_dot_v)) * r.x + r.y;
    return vec2<f32>(-1.04, 1.04) * a004 + r.zw;
}
// Filament specular occlusion from the (diffuse) AO + view — keeps ambient specular
// out of creases/contacts where the environment is occluded (multi-scatter AO).
fn spec_occlusion(n_dot_v: f32, ao: f32, a: f32) -> f32 {
    return clamp(pow(n_dot_v + ao, exp2(-16.0 * a - 1.0)) - 1.0 + ao, 0.0, 1.0);
}

@fragment
fn fs(in: VsOut) -> @location(0) vec4<f32> {
    // ── EMISSIVE shortcut (V2.5 D.3, the overlay's eyes): a self-lit surface owes
    // the BRDF nothing — its own linear albedo scaled by the emissive intensity goes
    // straight into the linear-HDR offscreen. Scaled past 1.0 it crosses the bloom
    // bright-pass threshold and the EXISTING bright/blur/composite chain grows the
    // halo — no new pass, no fog (an eye LED shines through haze).
    if (in.emissive > 0.0) {
        return vec4<f32>(in.col * in.emissive, 1.0);
    }
    let n = normalize(in.nrm);
    let l = normalize(U.light.xyz);            // surface → sun
    let v = normalize(U.eye.xyz - in.world);   // surface → eye
    let h = normalize(l + v);

    // Perceptual roughness authored in U.pbr.x → α = perceptualRoughness² drives the
    // GGX lobe (Filament). Clamp perceptual to ≥0.045 so the specular highlight stays
    // finite (a mirror-sharp α→0 aliases on a low-res highlight).
    let perceptual = clamp(U.pbr.x, 0.045, 1.0);
    let a = perceptual * perceptual;                 // α (alpha)
    let metallic = clamp(U.pbr.y, 0.0, 1.0);
    let albedo = in.col;

    let n_dot_l = max(dot(n, l), 0.0);
    let n_dot_v = max(dot(n, v), 1e-4);
    let n_dot_h = max(dot(n, h), 0.0);
    let v_dot_h = max(dot(v, h), 0.0);

    // F0 (Filament): dielectrics = 0.16·reflectance² (≈0.04), metals tint f0 by albedo
    // and carry no diffuse. Metals also darken the diffuse albedo to zero (k_d below).
    let f0 = mix(vec3<f32>(0.16 * REFLECTANCE * REFLECTANCE), albedo, metallic);

    // Cook-Torrance specular: D · V · F. V_SmithGGXCorrelated already folds the
    // 1/(4·NoV·NoL) visibility denominator, so there is no separate divide here.
    let ndf = D_GGX(n_dot_h, a);
    let vis = V_SmithGGXCorrelated(n_dot_v, n_dot_l, a);
    let f = F_Schlick(v_dot_h, f0);
    let spec = ndf * vis * f;

    // Diffuse (energy-conserving Lambert: metals have no diffuse; kd removes the
    // fraction of light reflected specularly).
    let k_d = (vec3<f32>(1.0) - f) * (1.0 - metallic);
    let diffuse = k_d * albedo / PI;

    // Direct sun radiance — gated by the cascaded shadow map (PCF). A fragment in
    // shadow loses its direct sun (blocks cast onto the street + onto each other),
    // keeping only the sky ambient. `pbr.w` scales how dark shadows get.
    let sun = U.sun_color.rgb * U.sun_color.w;
    let shadow = mix(1.0, shadow_factor(in.world, n_dot_l, in.depth), clamp(U.pbr.w, 0.0, 1.0));
    let direct = (diffuse + spec) * sun * n_dot_l * shadow;

    // ── SSAO (T3.1): sample the screen-space AO for the ambient terms (creases/
    // contacts come back darker). ao_params.z scales it, .w gates it (1 on / 0 off).
    let suv = in.clip.xy / max(U.ao_params.xy, vec2<f32>(1.0));
    let ao_raw = textureSampleLevel(ao_tex, ao_smp, suv, 0.0).r;
    let ao = mix(1.0, ao_raw, U.ao_params.z * U.ao_params.w);

    // ── IBL split-sum ambient (replaces the flat hemisphere fill) ──────────────
    // DIFFUSE: SH-L2 irradiance from the analytic sky → directional sky bounce (the
    // sun-lit side of the sky warms the fill). Lambert radiosity = albedo·E/π, scaled
    // by the ambient strength (light.w) so the old ambient knob still applies.
    let irradiance = sh_irradiance(n);
    let diffuse_ibl = irradiance * albedo * (1.0 - metallic) / PI * U.light.w * U.ibl.x;
    // SPECULAR: prefiltered sky reflection × the split-sum DFG term (f0·dfg.x + dfg.y),
    // plus multi-scatter energy compensation and specular occlusion. This is the new
    // "expensive look": buildings pick up sky colour + soft ambient reflections, and
    // low-roughness faces get a real skyline/sun reflection.
    let r_refl = reflect(-v, n);
    let prefiltered = env_prefiltered(r_refl, perceptual);
    let dfg = env_brdf_approx(n_dot_v, perceptual);
    var spec_ibl = prefiltered * (f0 * dfg.x + vec3<f32>(dfg.y));
    // Multi-scatter energy compensation (Filament). The Karis *analytic* DFG under-
    // estimates the bias on rough dielectrics, so 1/dfg.y would explode (~4×) and
    // flood the ambient — floor dfg.y so the compensation stays a small (~≤1.4×) lift.
    spec_ibl = spec_ibl * (1.0 + f0 * (1.0 / max(dfg.y, 0.1) - 1.0));
    let spec_ao = spec_occlusion(n_dot_v, ao, a);
    let spec_ibl_final = spec_ibl * U.ibl.y * mix(1.0, spec_ao, clamp(U.ibl.z, 0.0, 1.0));

    let ambient = diffuse_ibl * ao + spec_ibl_final;

    var lit = direct + ambient;

    // ── Aerial perspective: EXPONENTIAL extinction through a HEIGHT-GRADED atmosphere ──
    //
    // Distance fog is exp(−dist·density) (real atmospheric extinction, Filament):
    // near faces stay crisp, distant ones desaturate smoothly into the sky.
    //
    // Height haze modulates the **density along the ray** — it is never ADDED to the
    // result. That is the whole fix, and it is two defects deep:
    //
    //   1. The layer was `0.25` DESIGN UNITS tall. A design unit is 625 m in the
    //      rabbit fixture and 1875 m in the 3 km town window korp flies, so the layer
    //      was 469 m of air over a city whose tallest house stands 0.013 du. The term
    //      `0.25 − world.y` then varied by under 6 % across the whole scene: not a
    //      ramp, a CONSTANT. It is now `eye.w` — HAZE_SCALE_HEIGHT_METRES through the
    //      scene's own du_per_m (`backend::haze_scale_height`), the same cure
    //      `ground_band` and `building_rise_eps` already applied to their constants.
    //
    //   2. Being ADDITIVE, it hazed a fragment ONE METRE from the camera exactly as
    //      hard as one at the far plane. MEASURED (2026-08-04, 480 561-way Stockholm
    //      extract, chromaticity probe): a flat +0.268 of sky on every ground pixel
    //      before distance fog was counted, taking the pane's green cover from a
    //      16.64 % mesh area to **0.14 % of pixels**. Optical depth is density ×
    //      PATH LENGTH; a zero-length path is never hazed, at any altitude. So the
    //      height term multiplies `density` and the distance term keeps its zero.
    //
    // The ray's mean height (eye ↔ fragment) is what sets the density it travels
    // through, which also folds in the old eye-altitude thinning: climb and the whole
    // column thins, one term instead of two.
    let near = U.fog_range.x;
    let span = max(U.fog_range.y - near, 1e-3);
    let dist = max(in.depth - near, 0.0);
    let density = 2.5 / span;                    // ~92% hazed at the far plane
    let k = U.fog_range.z;                       // height-haze strength (dimensionless)
    let haze_h = max(U.eye.w, 1e-6);             // haze scale height (du, from metres)
    let h_mid = max(0.0, 0.5 * (in.world.y + U.fog_range.w));
    let ground_air = 1.0 + k * exp(-h_mid / haze_h);
    let dist_t = 1.0 - exp(-dist * density * ground_air);
    let fog_t = clamp(dist_t * U.fog.w, 0.0, 1.0);
    // Inscatter colour: distant geometry recedes into the *sky* (bg fog → sky tint,
    // by the horizon bias), warmed toward the sun with distance (forward scatter) —
    // the readability win that separates far buildings from near ones.
    let sky_inscatter = mix(U.fog.rgb, U.sky_color.rgb, clamp(U.sky_color.w, 0.0, 1.0));
    let haze = mix(sky_inscatter, U.sun_color.rgb, 0.18 * dist_t);
    let out_col = mix(lit, haze, fog_t);
    // HDR out: write **linear radiance** (no tonemap here). The post stack
    // (bright-pass → bloom → ACES) in `gpu.rs` exposes + tonemaps the Rgba16Float
    // offscreen down to the egui surface, so emissive/highlight values above 1.0
    // survive to drive real bloom instead of clipping in the fragment shader.
    return vec4<f32>(out_col, 1.0);
}

// ── Blit: sample the offscreen colour target into egui's render pass ──────────
@group(0) @binding(0) var blit_tex: texture_2d<f32>;
@group(0) @binding(1) var blit_smp: sampler;

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

@vertex
fn blit_vs(@builtin(vertex_index) vi: u32) -> BlitOut {
    // Fullscreen triangle.
    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: BlitOut;
    let xy = p[vi];
    out.clip = vec4<f32>(xy, 0.0, 1.0);
    // Map clip [-1,1] → uv [0,1]; flip y (texture origin top-left).
    out.uv = vec2<f32>(xy.x * 0.5 + 0.5, 1.0 - (xy.y * 0.5 + 0.5));
    return out;
}

@fragment
fn blit_fs(in: BlitOut) -> @location(0) vec4<f32> {
    return textureSample(blit_tex, blit_smp, in.uv);
}