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/gpu.rs

//! **GPU depth-tested 3D-map renderer** (feature `wgpu`) — the genuine
//! game/flight-sim occlusion for the extruded OSM city.
//!
//! The CPU path ([`crate::Map3D::shade_faces`]) uses the **painter's algorithm**:
//! it depth-SORTS triangles far→near and overdraws. That mis-sorts interpenetrating
//! / large triangles (the terrain quad vs a building wall), which is what made the
//! viewer read flat-grey with spurious spokes. This module instead renders the mesh
//! into an **offscreen colour + depth32 target** with a real GPU **depth test**
//! (`CompareFunction::Less`): per pixel, the nearest fragment wins — true Z-buffer
//! occlusion, no sort. The offscreen colour is then sampled (blit) into egui's
//! colour-only render pass (egui supplies no depth attachment, so we own ours).
//!
//! Upload-once: the extruded mesh goes to VRAM on the first paint; per-frame only
//! the camera view-projection uniform changes. The depth+colour targets are
//! (re)created when the pane size changes.
//!
//! The CPU painter remains the headless / no-GPU fallback; this is the opt-in fast
//! path (and the path the correctness self-test drives on a real device).

#![allow(clippy::too_many_lines, reason = "one self-contained depth-tested pipeline")]
#![allow(
    clippy::collapsible_if,
    reason = "nested guards keep the per-pass GPU resource borrow-scoping legible"
)]

use egui_wgpu::wgpu;

use crate::extrude::{Mesh3, WaterMesh};

// CONS-CORE Phase C: the offscreen colour+depth target + blit + widget-scissor are
// now the shared `facett_core::render::gpu::offscreen` helper. The rect-clip math
// (`ScissorPx` / `gpu_scissor_px` / `scissor_intersection`) is re-exported here for
// back-compat (map3d's `lib.rs` + tests + the demo still name `gpu::gpu_scissor_px`).
// `shade_faces_clip` (the CPU clip bug oracle) is untouched.
pub use facett_core::render::gpu::offscreen::{
    gpu_scissor_px, scissor_intersection, OffscreenColorDepth, ScissorPx,
};
use facett_core::render::gpu::offscreen::{DEPTH_FORMAT, OFFSCREEN_FORMAT};

/// One mesh vertex for the GPU: position + normal + RGBA colour. Matches `VsIn`.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
pub struct Vertex {
    pub pos: [f32; 3],
    pub nrm: [f32; 3],
    pub col: [f32; 4],
}

/// Per-frame uniform: camera view-projection (column-major) + light dir + ambient +
/// atmospheric fog (rgb = the background colour, w = max fog strength). The
/// fragment shader fades distant faces toward the fog colour so the GPU city
/// recedes into haze, matching the CPU painter's depth cue.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Uniforms {
    view_proj: [f32; 16],
    /// World-space sun direction (xyz, points FROM the surface TO the sun) + ambient (w).
    light: [f32; 4],
    /// Fog colour (rgb) + max fog strength (w).
    fog: [f32; 4],
    /// Fog ramp: near (x), far (y) in camera-space depth; height-fog k (z); eye height (w).
    fog_range: [f32; 4],
    /// Directional **sun colour** (rgb) + sun intensity (w) — PBR direct light.
    sun_color: [f32; 4],
    /// **Sky / ambient** tint (rgb) used for image-based ambient + the analytic sky
    /// gradient zenith colour (w = horizon blend bias).
    sky_color: [f32; 4],
    /// World-space **eye position** (xyz) for the PBR view vector + Fresnel (w unused).
    eye: [f32; 4],
    /// PBR + misc params: roughness (x), metallic (y), time-seconds (z), shadow strength (w).
    pbr: [f32; 4],
    /// **Cascaded shadow maps** (T3.3): one world→light-clip per cascade (column-major,
    /// tight→wide ortho fitted around the scene centre). The fragment picks the
    /// tightest cascade that covers it.
    light_vp: [[f32; 16]; 3],
    /// Cascade params: split0 (x) + split1 (y) in camera-space depth; PCSS light size
    /// in light-uv units (z); soft-shadow flag (w: 1 = PCSS, 0 = hard control).
    cascade: [f32; 4],
    /// SSAO apply (T3.1): target resolution px (xy) + AO strength (z) + AO-on (w).
    ao_params: [f32; 4],
    /// **IBL** (split-sum): diffuse irradiance as SH-L2 (9 vec3 radiance coeffs,
    /// integrated from the analytic sky each frame; `[3]` padding). `.rgb` used.
    sh: [[f32; 4]; 9],
    /// IBL knobs: diffuse scale (x), specular scale (y), spec-AO strength (z),
    /// reflection sun-glow scale (w).
    ibl: [f32; 4],
}

/// CPU-side upload payload (expanded so each triangle's colour rides its 3 verts).
///
/// Deliberately **not `Clone`**: at korp's 600k-triangle budget this payload is
/// ~72 MB, and the per-frame handoff to the paint callback used to deep-copy it
/// under a comment claiming "cheap Rc clone". The handoff is an `Arc<GpuGeometry>`
/// refcount bump now, and removing `Clone` makes that true BY CONSTRUCTION
/// (LAW #5): the copy cannot come back without failing to compile.
///
/// The mesh draw is **non-indexed** (the vertex stream is already expanded to 3
/// verts per triangle so each triangle's colour can ride its vertices) — which is
/// why there is no `indices` field: the identity index list `build_gpu_geometry`
/// used to build here was never uploaded and never read by anything.
pub struct GpuGeometry {
    pub vertices: Vec<Vertex>,
    /// The tessellated water grid (Gerstner-displaced in the water pass). Empty for
    /// data with no `Surface::Water` polygons.
    pub water_positions: Vec<[f32; 3]>,
    pub water_indices: Vec<u32>,
    /// **Build stamp** — a process-wide monotone counter taken at
    /// [`build_gpu_geometry`] time. [`Map3dRenderer`]'s `upload` keeps the stamp of
    /// the payload it last wrote to the device and re-writes the buffers exactly
    /// when the stamp differs — so re-sending the same cached payload every frame
    /// costs a compare, while a REBUILT scene (korp constructs a fresh `Map3D` per
    /// rebuild, whose backend caches a fresh payload) actually reaches the GPU. The
    /// old gate was `self.vbuf.is_some()`: the first mesh a process ever uploaded
    /// was the only mesh it could ever draw.
    pub generation: u64,
}

/// The [`GpuGeometry::generation`] allocator — bumped once per built payload.
/// An atomic only because paint callbacks may be prepared off the building thread;
/// nothing here spawns one (LAW #3).
static GEOMETRY_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);

/// One water-grid vertex: position only (the Gerstner displacement + normal are
/// computed in the water vertex shader from `time`).
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct WaterVertex {
    pos: [f32; 3],
}

/// Per-frame water uniform — mirrors `WUniforms` in `water.wgsl`.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct WaterUniforms {
    view_proj: [f32; 16],
    refl_vp: [f32; 16],
    eye: [f32; 4],
    sun: [f32; 4],
    sun_color: [f32; 4],
    sky_color: [f32; 4],
    shallow: [f32; 4],
    deep: [f32; 4],
    params: [f32; 4],
}

/// Per-frame uniform for the **skybox / atmosphere** pass (`sky.wgsl`). Mirrors
/// `SkyUniforms` in WGSL: the inverse view-projection (to reconstruct world view
/// rays) + the `SunSky` model (sun dir/colour/intensity, zenith & horizon tints).
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct SkyUniforms {
    /// inverse(camera view·projection), column-major — NDC → world for the ray.
    inv_view_proj: [f32; 16],
    /// World-space eye (xyz); w unused.
    eye: [f32; 4],
    /// Surface→sun dir (xyz) + sun-disc HDR intensity (w).
    sun_dir: [f32; 4],
    /// Sun colour (rgb) + sun-glow intensity (w).
    sun_color: [f32; 4],
    /// Zenith sky tint (rgb) + horizon-blend bias (w).
    sky_color: [f32; 4],
    /// Horizon/haze colour (rgb) + ground-darkening factor (w).
    horizon: [f32; 4],
    /// **Physical-sky transmittance LUT** controls (FLIGHT-SIM #7 wiring):
    /// `x` = LUT-on flag (`0` ⇒ the analytic sun tint, the CPU-fallback path;
    /// `> 0.5` ⇒ multiply the sun disc/glow by the sampled atmospheric
    /// transmittance-to-sun, so the sun physically reddens at low elevation);
    /// `y` = viewer altitude normalised into the LUT's altitude axis (`0..1`);
    /// `z`,`w` reserved. Byte-identical output to the pre-LUT pass when `x == 0`.
    lut: [f32; 4],
}

/// Per-cascade uniform for the **shadow depth pass** (`shadow.wgsl`). One tiny
/// buffer per cascade so the depth render binds its own light-space matrix without
/// threading a cascade index through the big scene `Uniforms`.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct CascadeUniform {
    light_vp: [f32; 16],
}

/// Per-frame uniform for the **SSAO** pass (`ssao.wgsl`). Mirrors `SsaoU`: the
/// view-projection (to project hemisphere samples to screen) + its inverse (to
/// unproject the stored G-buffer depth back to world space) + the AO tuning.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct SsaoUniforms {
    view_proj: [f32; 16],
    inv_view_proj: [f32; 16],
    /// World eye (xyz); w unused.
    eye: [f32; 4],
    /// radius (x), bias (y), intensity (z), power (w).
    params: [f32; 4],
    /// res_x, res_y, 1/res_x, 1/res_y.
    res: [f32; 4],
}

/// Number of shadow cascades (T3.3) — tight/mid/wide ortho slices around the scene.
const N_CASCADES: usize = 3;

/// HDR intensity of the **sun disc** written into the `Rgba16Float` offscreen —
/// well above 1.0 so the shared bloom pass blooms it and ACES rolls it to a
/// near-white core (a real sun, not a clipped dot).
const SUN_DISC_INTENSITY: f32 = 12.0;
/// Intensity of the broad atmospheric **glow** halo around the sun.
const SUN_GLOW_INTENSITY: f32 = 2.0;
/// Horizon-band gradient bias (`pow` exponent): <1 thickens the warm haze band.
const SKY_HORIZON_BIAS: f32 = 0.42;
/// Ground-side darkening factor applied below the horizon line.
const SKY_GROUND_DARKEN: f32 = 0.55;

/// Per-pass uniform for the HDR post stack (`bloom.wgsl`). Mirrors `BloomUniforms`
/// in WGSL: tone/threshold params + the source texel size & blur direction.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct BloomUniforms {
    /// threshold (x), knee (y), bloom intensity (z), exposure (w).
    params: [f32; 4],
    /// source texel size (xy = 1/w, 1/h) + blur direction (zw, in texels).
    texel: [f32; 4],
}

/// Bloom bright-pass threshold (linear radiance): only fragments brighter than this
/// bloom. The scene writes exposed-ish linear, so ~1.0 keeps bloom on highlights
/// (sun glints, emissive) — not the whole frame.
const BLOOM_THRESHOLD: f32 = 1.0;
/// Soft-knee width below the threshold (smooth bloom fade-in, no hard cutoff).
const BLOOM_KNEE: f32 = 0.6;
/// Additive bloom strength in the composite.
const BLOOM_INTENSITY: f32 = 0.65;
// ── Physically-based exposure (Filament "physically based camera") ────────────
// EV100 = log2(N²/t · 100/S) for aperture N (f-number), shutter t (s), ISO S.
// The linear exposure multiplier fed to the tonemap is exposure = 1/(1.2·2^EV100).
// VERIFIED against Filament's `filament/src/Exposure.cpp` (`exposure()` returns
// `1.0f / (1.2f * pow(2.0f, ev100))`) — the truncated research value was correct;
// the 1.2 folds lens attenuation / vignetting + specular headroom (ISO 12232 / the
// Saturation-Based-Speed metering calibration constant).
// Retained as the documented camera→EV100 entry point for a future lux-calibrated
// lighting pipeline; exercised by the exposure unit test. (dead in non-test builds.)
#[inline]
#[allow(dead_code)]
fn ev100_from_camera(aperture: f32, shutter: f32, iso: f32) -> f32 {
    (aperture * aperture / shutter * 100.0 / iso).log2()
}
#[inline]
fn exposure_from_ev100(ev100: f32) -> f32 {
    1.0 / (1.2 * 2f32.powf(ev100))
}
/// The flythrough's single exposure knob. The map renders **relative** radiance
/// (sun intensity ≈ a few units, not physical lux), so this sits far below a real
/// sunlit EV100 (~15). It is calibrated so `exposure_from_ev100(DEFAULT_EV100)`
/// ≈ 1.55 — the prior baked post-exposure — so moving to the physical model does
/// not change brightness; only the ACES tonemap swap shifts pixels. When the scene
/// is lux-calibrated, feed `ev100_from_camera(N, t, S)` instead.
const DEFAULT_EV100: f32 = -0.8954;

// ── IBL (image-based lighting, split-sum) tuning ─────────────────────────────
/// Diffuse SH irradiance scale (× the ambient strength). 1.0 keeps the SH diffuse
/// calibrated to the prior flat-hemisphere ambient brightness on average, adding
/// only the directional sky bounce.
const IBL_DIFFUSE_SCALE: f32 = 1.25;
/// Ambient **specular** reflection scale — the new sky reflections on the buildings.
/// Kept modest so matte walls read as a grounded sheen, not chrome.
const IBL_SPECULAR_SCALE: f32 = 0.4;
/// Specular-occlusion strength (0 = off, 1 = full Filament spec-AO on the IBL lobe).
const IBL_SPEC_AO_STRENGTH: f32 = 1.0;
/// Reflection sun-glow scale (how bright the sun reads in glossy reflections).
const IBL_REFLECT_GLOW: f32 = 0.35;

/// **sRGB 8-bit → LINEAR**, the IEC 61966-2-1 electro-optical transfer function.
///
/// # Why the vertex stream needs it and the egui painter does not
///
/// Every colour in this crate is authored as an **sRGB hex** — the cartographic palette
/// (`#E2DBCF`, `#C8E6C9`), the roof tints, `building:colour` straight off an OSM tag.
/// The CPU painter hands those bytes to egui, which paints them into an sRGB surface
/// unchanged: correct, and the reason the flat 2D map has always looked right.
///
/// The wgpu lane is a **physically-based** chain — Cook-Torrance, SH-L2 sky irradiance,
/// a split-sum IBL, exponential extinction, ACES — and every one of those terms is a
/// product or an integral of *radiance*. Feeding it an sRGB code value as if it were
/// linear reflectance is a units error: `#E2DBCF` means albedo `(0.76, 0.71, 0.63)`, and
/// the raw bytes claim `(0.89, 0.86, 0.81)`. The error is not a uniform brightening —
/// the transfer function's slope falls with intensity, so it **compresses the ratios
/// between colours**, and it lands the whole near-white cartographic palette high on the
/// ACES shoulder where the curve is flattest. Two defects compounding: the differences
/// are shrunk before tone mapping and then tone-mapped through the least contrasty part
/// of the curve. That is the mechanism by which a lit town of distinct greys arrives as
/// one white sheet.
///
/// The other end is already correct and must stay untouched: the post stack writes
/// linear radiance and the surface (or `present_encode_srgb`) does the linear→sRGB
/// encode on the way out. This function is the missing DECODE on the way in.
fn srgb_to_linear(byte: u8) -> f32 {
    let c = f32::from(byte) / 255.0;
    if c <= 0.040_45 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
}

/// Build the GPU payload from the extruded [`Mesh3`]. The mesh carries a colour
/// **per triangle**; the GPU wants it per vertex, so we expand to a flat,
/// non-indexed vertex stream (3 verts per triangle, each tinted by the tri colour
/// and carrying its vertex normal). Deterministic.
///
/// The per-triangle colour is decoded sRGB→linear ([`srgb_to_linear`]) because the
/// fragment shader consumes `in.col` as **linear albedo**; see that function for the
/// measurement.
pub fn build_gpu_geometry(mesh: &Mesh3) -> GpuGeometry {
    GpuGeometry {
        vertices: expand_vertices(mesh, &[]),
        water_positions: Vec::new(),
        water_indices: Vec::new(),
        generation: GEOMETRY_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
    }
}

/// The one triangle→vertex-stream expansion both payload builders share (LAW 5).
///
/// `emissive` is the per-triangle emissive channel (empty = all zero). It rides the
/// vertex colour's **w**: `1.0` = an ordinary lit vertex (the value every basemap
/// vertex has always carried), `1.0 + e` = self-lit — the fragment shader decodes
/// `e = max(col.a - 1, 0)` and, when `e > 0`, outputs `albedo · e` UNLIT into the
/// linear-HDR offscreen, where the EXISTING bloom bright-pass (threshold 1.0) catches
/// anything scaled past 1 — the halo costs no new pass. Encoding it in `w` means no
/// vertex-layout change, no second pipeline, and the zero path is byte-identical.
fn expand_vertices(mesh: &Mesh3, emissive: &[f32]) -> Vec<Vertex> {
    let n_tris = mesh.indices.len() / 3;
    let mut vertices = Vec::with_capacity(n_tris * 3);
    for t in 0..n_tris {
        let c = mesh.colors.get(t).copied().unwrap_or(egui::Color32::GRAY);
        let w = 1.0 + emissive.get(t).copied().unwrap_or(0.0).max(0.0);
        let col = [srgb_to_linear(c.r()), srgb_to_linear(c.g()), srgb_to_linear(c.b()), w];
        for k in 0..3 {
            let vi = mesh.indices[t * 3 + k] as usize;
            let pos = mesh.positions[vi];
            let nrm = mesh.normals[vi];
            vertices.push(Vertex { pos, nrm, col });
        }
    }
    vertices
}

/// Build the GPU payload for the **per-frame overlay** (V2.5 D.3): the same expanded
/// vertex stream as [`build_gpu_geometry`] plus the per-triangle emissive channel in
/// the colour's w (see [`expand_vertices`]). Called once per frame by the backend —
/// the overlay is ~100 verts by contract, so the fresh build is a few KB, and it goes
/// to the renderer's tiny dedicated buffer, never through the generation-stamped main
/// mesh cache.
#[must_use]
pub fn build_overlay_geometry(mesh: &Mesh3, emissive_by_tri: &[f32]) -> GpuGeometry {
    GpuGeometry {
        vertices: expand_vertices(mesh, emissive_by_tri),
        water_positions: Vec::new(),
        water_indices: Vec::new(),
        generation: GEOMETRY_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
    }
}

/// Build the GPU payload from the opaque [`Mesh3`] **and** the [`WaterMesh`] — the
/// water grid rides along so the renderer can run the reflective water pass.
pub fn build_gpu_geometry_with_water(mesh: &Mesh3, water: &WaterMesh) -> GpuGeometry {
    let mut g = build_gpu_geometry(mesh);
    g.water_positions = water.positions.clone();
    g.water_indices = water.indices.clone();
    g
}

/// Persistent GPU resources for the depth-tested 3D map.
pub struct Map3dRenderer {
    mesh_pipeline: wgpu::RenderPipeline,
    /// A twin of `mesh_pipeline` with the depth test DISABLED (`Always`, no depth
    /// write) — built only so the self-test can prove, on the very same scene +
    /// device, that turning the Z-buffer OFF breaks the occlusion (last-drawn
    /// wins). It is never used by the live path.
    mesh_pipeline_no_depth: wgpu::RenderPipeline,
    /// When set (test hook), `prepare` draws with `mesh_pipeline_no_depth`.
    no_depth_for_test: bool,
    uniform: wgpu::Buffer,
    /// The main lighting bind group `{uniform, shadow array, cmp+depth samplers, ao
    /// tex+sampler}`. Rebuilt on resize because it names the (pane-sized) AO target.
    mesh_bind: Option<wgpu::BindGroup>,
    /// Layout for `mesh_bind` / `mirror_bind` (kept so they can be rebuilt on resize).
    mesh_bgl: wgpu::BindGroupLayout,

    /// **Cascaded shadow maps** (T3.3) — a depth-only render of the mesh from the
    /// sun, ONE draw per cascade into a depth-texture-array layer. Tight→wide ortho
    /// slices fitted around the scene centre give high-res near + coarse far, and the
    /// main pass PCSS-samples them (blocker search + Poisson disk) for contact-
    /// hardening soft shadows of the building blocks onto the ground + each other.
    shadow_pipeline: wgpu::RenderPipeline,
    /// One per-cascade uniform buffer (its light-space view-projection).
    csm_uniform: [wgpu::Buffer; N_CASCADES],
    /// One uniform-only bind group per cascade for the depth pass (binding 0).
    csm_bind: [wgpu::BindGroup; N_CASCADES],
    /// Per-cascade depth-pass attachment views (the shadow render writes each layer).
    shadow_attach: [wgpu::TextureView; N_CASCADES],

    /// **SSAO / G-buffer** (T3.1): a normal+depth prepass (`gbuf`) fills `gbuffer`
    /// (rgb = world normal, a = NDC depth); the `ssao_pipeline` reads it and writes
    /// the per-pixel ambient-occlusion factor into `ao_view`, which the main pass
    /// modulates the ambient term by. `gbuf_bind` is the uniform-only prepass binding.
    gbuf_pipeline: wgpu::RenderPipeline,
    gbuf_bind: wgpu::BindGroup,
    gbuffer: OffscreenColorDepth,
    ssao_pipeline: wgpu::RenderPipeline,
    ssao_uniform: wgpu::Buffer,
    ssao_bgl: wgpu::BindGroupLayout,
    ssao_bind: Option<wgpu::BindGroup>,
    ssao_sampler: wgpu::Sampler,
    /// The AO target (`R8Unorm`, pane-sized) + its size + the sampler the main pass
    /// reads it with. (Re)allocated on resize.
    ao_view: Option<wgpu::TextureView>,
    ao_size: (u32, u32),
    ao_sampler: wgpu::Sampler,
    /// Shared (resizable) bind size used to gate rebuilding `mesh_bind`/`ssao_bind`.
    bind_size: (u32, u32),

    /// Persistent shadow-array views + samplers (named by `mesh_bind`/`mirror_bind`).
    shadow_array_view: wgpu::TextureView,
    shadow_cmp_sampler: wgpu::Sampler,
    shadow_depth_sampler: wgpu::Sampler,

    vbuf: Option<wgpu::Buffer>,
    index_count: u32,
    /// The [`GpuGeometry::generation`] this renderer last uploaded (`None` before
    /// the first upload) — THE replace-on-change gate: `upload` re-writes the vertex +
    /// water buffers exactly when the stamp differs. Replaces the old
    /// `vbuf.is_some()` latch, which pinned the first-ever mesh for the life of the
    /// process: korp rebuilds its scene into a fresh payload per viewport move, and
    /// the GPU never saw a single rebuild.
    uploaded_generation: Option<u64>,
    /// **The per-frame overlay body** (V2.5 D.3): its own tiny vertex buffer,
    /// re-written EVERY frame by [`Self::upload_overlay`] — deliberately outside the
    /// generation-stamped main mesh path above, so a flying avatar never invalidates
    /// (or waits on) the 600k-triangle city upload. `None`/`0` = no overlay drawn.
    overlay_vbuf: Option<wgpu::Buffer>,
    overlay_count: u32,

    /// **Reflective water** (Phase 2): the water pipeline + its uniform + per-size
    /// bind group (samples the offscreen depth + the reflection target) + the
    /// uploaded water grid vertex buffer. Built once; the pass runs only when the
    /// LOD tier is `Ground` (camera altitude < 300 m) and there is water geometry.
    water_pipeline: wgpu::RenderPipeline,
    water_uniform: wgpu::Buffer,
    water_bgl: wgpu::BindGroupLayout,
    water_bind: Option<wgpu::BindGroup>,
    water_bind_size: (u32, u32),
    water_sampler: wgpu::Sampler,
    water_vbuf: Option<wgpu::Buffer>,
    water_ibuf: Option<wgpu::Buffer>,
    water_index_count: u32,
    /// Half-res planar **reflection** colour target (the mirror-camera scene render
    /// the water samples). A second `OffscreenColorDepth`, allocated lazily.
    reflection: OffscreenColorDepth,
    /// A second uniform + bind group for the MIRROR mesh render (the reflection
    /// pass re-renders the opaque mesh with the mirrored view-projection). Rebuilt on
    /// resize (it also names the pane-sized AO target, ignored via ao_params.w=0).
    mirror_uniform: wgpu::Buffer,
    mirror_bind: Option<wgpu::BindGroup>,

    /// **Skybox / atmosphere** (T3.2): a fullscreen-triangle analytic sky + HDR sun
    /// disc, drawn FIRST into the offscreen HDR colour (the mesh/water draw over it),
    /// so the background is a real gradient+sun sky the bloom/tonemap catch — not the
    /// flat fog clear. Built once; the bind group names the persistent sky uniform.
    sky_pipeline: wgpu::RenderPipeline,
    sky_uniform: wgpu::Buffer,
    sky_bind: wgpu::BindGroup,
    /// The sky bind-group **layout**, kept so the bind group can be rebuilt when the
    /// physical-sky transmittance LUT texture is (re)uploaded (`ensure_sky_lut`).
    sky_bgl: wgpu::BindGroupLayout,
    /// **Physical-sky transmittance LUT** (FLIGHT-SIM #7 wiring): the `Rgba32Float`
    /// texture the sky fragment samples for the sun's atmospheric tint. Starts as a
    /// 1×1 all-ones (transmittance = 1) default so the analytic pass is unchanged
    /// until a real LUT (the flight crate's GPU scattering-LUT readback) is uploaded.
    sky_lut_tex: wgpu::Texture,
    sky_lut_view: wgpu::TextureView,
    /// A **non-filtering** (nearest) sampler — `Rgba32Float` is not filterable without
    /// the `float32-filterable` feature, so the LUT is point-sampled.
    sky_lut_sampler: wgpu::Sampler,
    /// Current LUT texture dims `(mu_size, h_size)` — a resize triggers a texture
    /// realloc + a sky-bind rebuild; a same-size update just re-writes the texels.
    sky_lut_dims: (u32, u32),
    /// The exact LUT payload the device texels came from, held as a `Weak` (never
    /// keeps a dropped host payload alive) and compared by **`Arc` identity**:
    /// `SkyTransmittanceLut` is immutable behind its `Arc` — the host swaps LUTs by
    /// building a new one (`Map3D::set_sky_transmittance_lut`) — so the same
    /// upgraded pointer means the device already holds exactly these texels and
    /// `ensure_sky_lut` skips the write. It used to `queue.write_texture` the whole
    /// `Rgba32Float` LUT every frame the host supplied one; the dims check only
    /// gated the REALLOC. A failed upgrade re-writes: a fresh allocation at a
    /// recycled address can never alias a live stamp.
    sky_lut_src: Option<std::sync::Weak<crate::SkyTransmittanceLut>>,
    /// Texel writes `ensure_sky_lut` actually issued — the observable that keeps the
    /// skip honest (the device test pins "same `Arc` twice ⇒ one write, new `Arc`
    /// ⇒ the new texels land").
    sky_lut_writes: u32,

    /// Shared offscreen colour+depth target + blit + widget scissor (CONS-CORE L0).
    offscreen: OffscreenColorDepth,

    /// HDR post stack: bright-pass → bloom → ACES tonemap → present (T2.1).
    post: PostStack,
    /// Screen-space post passes recorded by the last `prepare` — see
    /// [`begin_post_pass`]. `0` means the 2D-ortho bypass took the whole chain out.
    post_passes: u32,

    /// **The in-GPU pass clock** for this lane (perf/v2.3): TIMESTAMP_QUERY
    /// bracketing everything `prepare` records into its encoder — shadow cascades,
    /// G-buffer + SSAO, reflection, sky, the main offscreen pass, water and the
    /// bloom/ACES post stack — resolved non-blocking into
    /// `facett_core::render::lane`'s clock stats. Lazily probed on first `prepare`
    /// (the queue's timestamp period is not available at construction); a device
    /// without the feature records the honest `Some(false)` verdict once and costs
    /// one enum check per frame after.
    pass_clock: facett_core::render::gpu::PassClockSlot,
    /// The clock's frame number: one per `prepare`. This lane draws ONE pane per
    /// renderer, so a prepare IS a frame here; `PassClock::begin` wants the number so
    /// a lane that brackets twice per frame (facett-map's two panes, GG #37) can say so.
    clock_frame: u64,
}

/// The shadow-map atlas resolution per cascade. 2048 is crisp for a city-block
/// scene without being heavy.
const SHADOW_DIM: u32 = 2048;

/// The SSAO target format — a single-channel 8-bit AO factor (0 = occluded, 1 = open).
const AO_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R8Unorm;

impl Map3dRenderer {
    pub fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self {
        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("facett_map3d_depth"),
            source: wgpu::ShaderSource::Wgsl(include_str!("map3d.wgsl").into()),
        });

        let uniform = device.create_buffer(&wgpu::BufferDescriptor {
            label: Some("map3d_uniforms"),
            size: std::mem::size_of::<Uniforms>() as u64,
            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
            mapped_at_creation: false,
        });

        // ── Cascaded shadow maps (T3.3): a depth32 texture ARRAY (one layer per
        // cascade) rendered from the sun. The main pass PCSS-samples it with BOTH a
        // comparison sampler (the Poisson PCF) and a plain depth sampler (the blocker
        // search reads raw occluder depth).
        let shadow_tex = device.create_texture(&wgpu::TextureDescriptor {
            label: Some("map3d_shadow_cascades"),
            size: wgpu::Extent3d {
                width: SHADOW_DIM,
                height: SHADOW_DIM,
                depth_or_array_layers: N_CASCADES as u32,
            },
            mip_level_count: 1,
            sample_count: facett_core::render::gpu::NO_MSAA_SAMPLES,
            dimension: wgpu::TextureDimension::D2,
            format: DEPTH_FORMAT,
            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
            view_formats: &[],
        });
        // Array view for sampling all cascades in the main pass.
        let shadow_array_view = shadow_tex.create_view(&wgpu::TextureViewDescriptor {
            label: Some("map3d_shadow_array_view"),
            dimension: Some(wgpu::TextureViewDimension::D2Array),
            ..Default::default()
        });
        // One single-layer attachment view per cascade (the depth render target).
        let shadow_attach: [wgpu::TextureView; N_CASCADES] = std::array::from_fn(|i| {
            shadow_tex.create_view(&wgpu::TextureViewDescriptor {
                label: Some("map3d_shadow_layer"),
                dimension: Some(wgpu::TextureViewDimension::D2),
                base_array_layer: i as u32,
                array_layer_count: Some(1),
                ..Default::default()
            })
        });
        let shadow_cmp_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
            label: Some("map3d_shadow_cmp"),
            mag_filter: wgpu::FilterMode::Linear,
            min_filter: wgpu::FilterMode::Linear,
            compare: Some(wgpu::CompareFunction::LessEqual),
            ..Default::default()
        });
        // Non-comparison (nearest) sampler for the PCSS blocker-depth reads.
        let shadow_depth_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
            label: Some("map3d_shadow_depth"),
            mag_filter: wgpu::FilterMode::Nearest,
            min_filter: wgpu::FilterMode::Nearest,
            ..Default::default()
        });
        // AO sampler (linear clamp) the main pass reads the SSAO target with.
        let ao_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
            label: Some("map3d_ao_sampler"),
            mag_filter: wgpu::FilterMode::Linear,
            min_filter: wgpu::FilterMode::Linear,
            address_mode_u: wgpu::AddressMode::ClampToEdge,
            address_mode_v: wgpu::AddressMode::ClampToEdge,
            ..Default::default()
        });

        // Main lighting BGL: uniform + shadow array + cmp/depth samplers + AO tex+smp.
        let tex_depth_array = |binding: u32| wgpu::BindGroupLayoutEntry {
            binding,
            visibility: wgpu::ShaderStages::FRAGMENT,
            ty: wgpu::BindingType::Texture {
                sample_type: wgpu::TextureSampleType::Depth,
                view_dimension: wgpu::TextureViewDimension::D2Array,
                multisampled: false,
            },
            count: None,
        };
        let mesh_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
            label: Some("map3d_bgl"),
            entries: &[
                wgpu::BindGroupLayoutEntry {
                    binding: 0,
                    visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
                    ty: wgpu::BindingType::Buffer {
                        ty: wgpu::BufferBindingType::Uniform,
                        has_dynamic_offset: false,
                        min_binding_size: None,
                    },
                    count: None,
                },
                tex_depth_array(1),
                wgpu::BindGroupLayoutEntry {
                    binding: 2,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison),
                    count: None,
                },
                wgpu::BindGroupLayoutEntry {
                    binding: 3,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
                    count: None,
                },
                wgpu::BindGroupLayoutEntry {
                    binding: 4,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Texture {
                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
                        view_dimension: wgpu::TextureViewDimension::D2,
                        multisampled: false,
                    },
                    count: None,
                },
                wgpu::BindGroupLayoutEntry {
                    binding: 5,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
                    count: None,
                },
            ],
        });
        // `mesh_bind` is built lazily in `prepare` (it names the pane-sized AO target).

        // Uniform-only BGL — reused by the gbuffer prepass (bound to the big uniform)
        // AND the per-cascade shadow depth pass (bound to each small cascade uniform).
        let uonly_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
            label: Some("map3d_uonly_bgl"),
            entries: &[wgpu::BindGroupLayoutEntry {
                binding: 0,
                visibility: wgpu::ShaderStages::VERTEX,
                ty: wgpu::BindingType::Buffer {
                    ty: wgpu::BufferBindingType::Uniform,
                    has_dynamic_offset: false,
                    min_binding_size: None,
                },
                count: None,
            }],
        });
        // Per-cascade uniform buffers + bind groups for the shadow depth pass.
        let csm_uniform: [wgpu::Buffer; N_CASCADES] = std::array::from_fn(|_| {
            device.create_buffer(&wgpu::BufferDescriptor {
                label: Some("map3d_cascade_uniform"),
                size: std::mem::size_of::<CascadeUniform>() as u64,
                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
                mapped_at_creation: false,
            })
        });
        let csm_bind: [wgpu::BindGroup; N_CASCADES] = std::array::from_fn(|i| {
            device.create_bind_group(&wgpu::BindGroupDescriptor {
                label: Some("map3d_cascade_bind"),
                layout: &uonly_bgl,
                entries: &[wgpu::BindGroupEntry { binding: 0, resource: csm_uniform[i].as_entire_binding() }],
            })
        });
        // The gbuffer prepass binds the big uniform (it needs `view_proj`).
        let gbuf_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
            label: Some("map3d_gbuf_bind"),
            layout: &uonly_bgl,
            entries: &[wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }],
        });
        let shadow_pll = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
            label: Some("map3d_shadow_pll"),
            bind_group_layouts: &[Some(&uonly_bgl)],
            immediate_size: 0,
        });

        let mesh_pll = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
            label: Some("map3d_mesh_pll"),
            bind_group_layouts: &[Some(&mesh_bgl)],
            immediate_size: 0,
        });
        let make_mesh_pipeline = |label: &str, depth: Option<wgpu::DepthStencilState>| {
            device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
                label: Some(label),
                layout: Some(&mesh_pll),
                vertex: wgpu::VertexState {
                    module: &shader,
                    entry_point: Some("vs"),
                    compilation_options: Default::default(),
                    buffers: &[Some(wgpu::VertexBufferLayout {
                        array_stride: std::mem::size_of::<Vertex>() as u64,
                        step_mode: wgpu::VertexStepMode::Vertex,
                        attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x4],
                    })],
                },
                primitive: wgpu::PrimitiveState {
                    topology: wgpu::PrimitiveTopology::TriangleList,
                    // No back-face cull: the occlusion must come from the depth
                    // test (not winding), so the no-depth twin provably breaks.
                    cull_mode: None,
                    ..Default::default()
                },
                depth_stencil: depth,
                multisample: facett_core::render::gpu::msaa_state(),
                fragment: Some(wgpu::FragmentState {
                    module: &shader,
                    entry_point: Some("fs"),
                    compilation_options: Default::default(),
                    targets: &[Some(wgpu::ColorTargetState {
                        format: OFFSCREEN_FORMAT,
                        blend: None,
                        write_mask: wgpu::ColorWrites::ALL,
                    })],
                }),
                multiview_mask: None,
                cache: None,
            })
        };
        // THE FIX: a real depth attachment + LESS test → GPU Z-buffer occlusion.
        let mesh_pipeline = make_mesh_pipeline(
            "map3d_mesh_pipeline",
            Some(wgpu::DepthStencilState {
                format: DEPTH_FORMAT,
                depth_write_enabled: Some(true),
                depth_compare: Some(wgpu::CompareFunction::Less),
                stencil: wgpu::StencilState::default(),
                bias: wgpu::DepthBiasState::default(),
            }),
        );
        // The depth-OFF twin (Always, no write) — the self-test's negative control.
        let mesh_pipeline_no_depth = make_mesh_pipeline(
            "map3d_mesh_pipeline_no_depth",
            Some(wgpu::DepthStencilState {
                format: DEPTH_FORMAT,
                depth_write_enabled: Some(false),
                depth_compare: Some(wgpu::CompareFunction::Always),
                stencil: wgpu::StencilState::default(),
                bias: wgpu::DepthBiasState::default(),
            }),
        );

        let vbuf_layout = wgpu::VertexBufferLayout {
            array_stride: std::mem::size_of::<Vertex>() as u64,
            step_mode: wgpu::VertexStepMode::Vertex,
            attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x4],
        };

        // ── Cascaded shadow depth pass pipeline (T3.3): depth-only, renders the mesh
        // from the sun once per cascade. Its own tiny `shadow.wgsl` module reads only
        // the per-cascade light matrix (binding 0), decoupled from the big uniform.
        let shadow_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("facett_map3d_shadow"),
            source: wgpu::ShaderSource::Wgsl(include_str!("shadow.wgsl").into()),
        });
        let shadow_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
            label: Some("map3d_shadow_pipeline"),
            layout: Some(&shadow_pll),
            vertex: wgpu::VertexState {
                module: &shadow_shader,
                entry_point: Some("shadow_vs"),
                compilation_options: Default::default(),
                buffers: &[Some(vbuf_layout.clone())],
            },
            primitive: wgpu::PrimitiveState {
                topology: wgpu::PrimitiveTopology::TriangleList,
                cull_mode: None,
                ..Default::default()
            },
            depth_stencil: Some(wgpu::DepthStencilState {
                format: DEPTH_FORMAT,
                depth_write_enabled: Some(true),
                depth_compare: Some(wgpu::CompareFunction::Less),
                stencil: wgpu::StencilState::default(),
                // A constant + slope depth bias in the shadow render reduces acne
                // (the receiver self-shadowing) on the ground + roofs.
                bias: wgpu::DepthBiasState { constant: 2, slope_scale: 2.0, clamp: 0.0 },
            }),
            multisample: facett_core::render::gpu::msaa_state(),
            fragment: None, // depth only
            multiview_mask: None,
            cache: None,
        });

        // ── G-buffer NORMAL prepass pipeline (T3.1): renders the mesh into the normal
        // +depth gbuffer the SSAO pass reads. Uses the big uniform (view_proj) via the
        // uniform-only layout; writes world normal (rgb) + NDC depth (a).
        let gbuf_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
            label: Some("map3d_gbuf_pipeline"),
            layout: Some(&shadow_pll), // uniform-only (binding 0 = big uniform)
            vertex: wgpu::VertexState {
                module: &shader,
                entry_point: Some("gbuf_vs"),
                compilation_options: Default::default(),
                buffers: &[Some(vbuf_layout.clone())],
            },
            primitive: wgpu::PrimitiveState {
                topology: wgpu::PrimitiveTopology::TriangleList,
                cull_mode: None,
                ..Default::default()
            },
            depth_stencil: Some(wgpu::DepthStencilState {
                format: DEPTH_FORMAT,
                depth_write_enabled: Some(true),
                depth_compare: Some(wgpu::CompareFunction::Less),
                stencil: wgpu::StencilState::default(),
                bias: wgpu::DepthBiasState::default(),
            }),
            multisample: facett_core::render::gpu::msaa_state(),
            fragment: Some(wgpu::FragmentState {
                module: &shader,
                entry_point: Some("gbuf_fs"),
                compilation_options: Default::default(),
                targets: &[Some(wgpu::ColorTargetState {
                    format: OFFSCREEN_FORMAT,
                    blend: None,
                    write_mask: wgpu::ColorWrites::ALL,
                })],
            }),
            multiview_mask: None,
            cache: None,
        });

        // ── SSAO pass pipeline (T3.1): fullscreen, reads the gbuffer + the SSAO
        // uniform, writes the AO factor into the (R8) AO target.
        let ssao_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("facett_map3d_ssao"),
            source: wgpu::ShaderSource::Wgsl(include_str!("ssao.wgsl").into()),
        });
        let ssao_uniform = device.create_buffer(&wgpu::BufferDescriptor {
            label: Some("map3d_ssao_uniform"),
            size: std::mem::size_of::<SsaoUniforms>() as u64,
            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
            mapped_at_creation: false,
        });
        let ssao_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
            label: Some("map3d_ssao_bgl"),
            entries: &[
                wgpu::BindGroupLayoutEntry {
                    binding: 0,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Buffer {
                        ty: wgpu::BufferBindingType::Uniform,
                        has_dynamic_offset: false,
                        min_binding_size: None,
                    },
                    count: None,
                },
                wgpu::BindGroupLayoutEntry {
                    binding: 1,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Texture {
                        sample_type: wgpu::TextureSampleType::Float { filterable: false },
                        view_dimension: wgpu::TextureViewDimension::D2,
                        multisampled: false,
                    },
                    count: None,
                },
                wgpu::BindGroupLayoutEntry {
                    binding: 2,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
                    count: None,
                },
            ],
        });
        let ssao_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
            label: Some("map3d_ssao_gbuf_sampler"),
            mag_filter: wgpu::FilterMode::Nearest,
            min_filter: wgpu::FilterMode::Nearest,
            address_mode_u: wgpu::AddressMode::ClampToEdge,
            address_mode_v: wgpu::AddressMode::ClampToEdge,
            ..Default::default()
        });
        let ssao_pll = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
            label: Some("map3d_ssao_pll"),
            bind_group_layouts: &[Some(&ssao_bgl)],
            immediate_size: 0,
        });
        let ssao_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
            label: Some("map3d_ssao_pipeline"),
            layout: Some(&ssao_pll),
            vertex: wgpu::VertexState {
                module: &ssao_shader,
                entry_point: Some("ssao_vs"),
                compilation_options: Default::default(),
                buffers: &[],
            },
            primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() },
            depth_stencil: None,
            multisample: facett_core::render::gpu::msaa_state(),
            fragment: Some(wgpu::FragmentState {
                module: &ssao_shader,
                entry_point: Some("ssao_fs"),
                compilation_options: Default::default(),
                targets: &[Some(wgpu::ColorTargetState {
                    format: AO_FORMAT,
                    blend: None,
                    write_mask: wgpu::ColorWrites::ALL,
                })],
            }),
            multiview_mask: None,
            cache: None,
        });

        // ── Reflective WATER pipeline (Phase 2) ──────────────────────────────────
        let water_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("facett_map3d_water"),
            source: wgpu::ShaderSource::Wgsl(include_str!("water.wgsl").into()),
        });
        let water_uniform = device.create_buffer(&wgpu::BufferDescriptor {
            label: Some("map3d_water_uniforms"),
            size: std::mem::size_of::<WaterUniforms>() as u64,
            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
            mapped_at_creation: false,
        });
        let water_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
            label: Some("map3d_water_bgl"),
            entries: &[
                wgpu::BindGroupLayoutEntry {
                    binding: 0,
                    visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
                    ty: wgpu::BindingType::Buffer {
                        ty: wgpu::BufferBindingType::Uniform,
                        has_dynamic_offset: false,
                        min_binding_size: None,
                    },
                    count: None,
                },
                wgpu::BindGroupLayoutEntry {
                    binding: 1,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Texture {
                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
                        view_dimension: wgpu::TextureViewDimension::D2,
                        multisampled: false,
                    },
                    count: None,
                },
                wgpu::BindGroupLayoutEntry {
                    binding: 2,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
                    count: None,
                },
                wgpu::BindGroupLayoutEntry {
                    binding: 3,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Texture {
                        sample_type: wgpu::TextureSampleType::Depth,
                        view_dimension: wgpu::TextureViewDimension::D2,
                        multisampled: false,
                    },
                    count: None,
                },
            ],
        });
        let water_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
            label: Some("map3d_water_sampler"),
            mag_filter: wgpu::FilterMode::Linear,
            min_filter: wgpu::FilterMode::Linear,
            ..Default::default()
        });
        let water_pll = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
            label: Some("map3d_water_pll"),
            bind_group_layouts: &[Some(&water_bgl)],
            immediate_size: 0,
        });
        let water_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
            label: Some("map3d_water_pipeline"),
            layout: Some(&water_pll),
            vertex: wgpu::VertexState {
                module: &water_shader,
                entry_point: Some("water_vs"),
                compilation_options: Default::default(),
                buffers: &[Some(wgpu::VertexBufferLayout {
                    array_stride: std::mem::size_of::<WaterVertex>() as u64,
                    step_mode: wgpu::VertexStepMode::Vertex,
                    attributes: &wgpu::vertex_attr_array![0 => Float32x3],
                })],
            },
            primitive: wgpu::PrimitiveState {
                topology: wgpu::PrimitiveTopology::TriangleList,
                cull_mode: None,
                ..Default::default()
            },
            // No depth attachment: the offscreen depth is SAMPLED by the water
            // shader (the depth-tint + a manual occlusion discard), so it cannot also
            // be a writable attachment in the same pass. The shader discards water
            // fragments that sit BEHIND the opaque scene (buildings occlude water).
            depth_stencil: None,
            multisample: facett_core::render::gpu::msaa_state(),
            fragment: Some(wgpu::FragmentState {
                module: &water_shader,
                entry_point: Some("water_fs"),
                compilation_options: Default::default(),
                targets: &[Some(wgpu::ColorTargetState {
                    format: OFFSCREEN_FORMAT,
                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
                    write_mask: wgpu::ColorWrites::ALL,
                })],
            }),
            multiview_mask: None,
            cache: None,
        });

        // Mirror-camera uniform + bind group for the reflection pass (re-renders the
        // opaque mesh with the mirrored view-projection). Reuses the mesh BGL (the
        // shadow texture is bound but unused by the mirror — it just needs a valid
        // binding), with its OWN uniform so it doesn't clobber the main view.
        let mirror_uniform = device.create_buffer(&wgpu::BufferDescriptor {
            label: Some("map3d_mirror_uniforms"),
            size: std::mem::size_of::<Uniforms>() as u64,
            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
            mapped_at_creation: false,
        });
        // `mirror_bind` is built lazily in `prepare` (it names the pane-sized AO target).

        // ── SKYBOX / ATMOSPHERE pipeline (T3.2) ──────────────────────────────────
        // A fullscreen-triangle pass: uniform-only bind group; no vertex buffer, no
        // depth. Writes HDR linear radiance into the offscreen colour BEFORE the mesh
        // (the mesh pass then LOADs and draws over it where geometry is present).
        let sky_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("facett_map3d_sky"),
            source: wgpu::ShaderSource::Wgsl(include_str!("sky.wgsl").into()),
        });
        let sky_uniform = device.create_buffer(&wgpu::BufferDescriptor {
            label: Some("map3d_sky_uniforms"),
            size: std::mem::size_of::<SkyUniforms>() as u64,
            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
            mapped_at_creation: false,
        });
        let sky_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
            label: Some("map3d_sky_bgl"),
            entries: &[
                wgpu::BindGroupLayoutEntry {
                    binding: 0,
                    visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
                    ty: wgpu::BindingType::Buffer {
                        ty: wgpu::BufferBindingType::Uniform,
                        has_dynamic_offset: false,
                        min_binding_size: None,
                    },
                    count: None,
                },
                // binding 1: the physical-sky transmittance LUT (Rgba32Float, NOT
                // filterable without the float32-filterable feature → point-sampled).
                wgpu::BindGroupLayoutEntry {
                    binding: 1,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Texture {
                        sample_type: wgpu::TextureSampleType::Float { filterable: false },
                        view_dimension: wgpu::TextureViewDimension::D2,
                        multisampled: false,
                    },
                    count: None,
                },
                // binding 2: the non-filtering (nearest) sampler for the LUT.
                wgpu::BindGroupLayoutEntry {
                    binding: 2,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
                    count: None,
                },
            ],
        });
        // Default LUT: a 1×1 all-ones (transmittance = 1.0) texture, so the sky pass
        // is byte-identical to the pre-LUT analytic path until a real LUT is uploaded
        // (L2 — additive, the CPU/analytic fallback stays the default).
        let sky_lut_tex = device.create_texture(&wgpu::TextureDescriptor {
            label: Some("map3d_sky_lut_default"),
            size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 },
            mip_level_count: 1,
            sample_count: facett_core::render::gpu::NO_MSAA_SAMPLES,
            dimension: wgpu::TextureDimension::D2,
            format: wgpu::TextureFormat::Rgba32Float,
            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
            view_formats: &[],
        });
        let sky_lut_view = sky_lut_tex.create_view(&Default::default());
        let sky_lut_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
            label: Some("map3d_sky_lut_sampler"),
            address_mode_u: wgpu::AddressMode::ClampToEdge,
            address_mode_v: wgpu::AddressMode::ClampToEdge,
            address_mode_w: wgpu::AddressMode::ClampToEdge,
            mag_filter: wgpu::FilterMode::Nearest,
            min_filter: wgpu::FilterMode::Nearest,
            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
            ..Default::default()
        });
        let sky_lut_dims = (1u32, 1u32);
        let sky_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
            label: Some("map3d_sky_bind"),
            layout: &sky_bgl,
            entries: &[
                wgpu::BindGroupEntry { binding: 0, resource: sky_uniform.as_entire_binding() },
                wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&sky_lut_view) },
                wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&sky_lut_sampler) },
            ],
        });
        let sky_pll = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
            label: Some("map3d_sky_pll"),
            bind_group_layouts: &[Some(&sky_bgl)],
            immediate_size: 0,
        });
        let sky_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
            label: Some("map3d_sky_pipeline"),
            layout: Some(&sky_pll),
            vertex: wgpu::VertexState {
                module: &sky_shader,
                entry_point: Some("sky_vs"),
                compilation_options: Default::default(),
                buffers: &[],
            },
            primitive: wgpu::PrimitiveState {
                topology: wgpu::PrimitiveTopology::TriangleList,
                ..Default::default()
            },
            depth_stencil: None, // background: no depth test/write
            multisample: facett_core::render::gpu::msaa_state(),
            fragment: Some(wgpu::FragmentState {
                module: &sky_shader,
                entry_point: Some("sky_fs"),
                compilation_options: Default::default(),
                targets: &[Some(wgpu::ColorTargetState {
                    format: OFFSCREEN_FORMAT,
                    blend: None,
                    write_mask: wgpu::ColorWrites::ALL,
                })],
            }),
            multiview_mask: None,
            cache: None,
        });

        // Blit pipeline + offscreen colour/depth targets + widget scissor: the
        // shared L0 helper (extracted from this kernel's old bespoke plumbing).
        let offscreen = OffscreenColorDepth::new(device, target_format);
        let reflection = OffscreenColorDepth::new(device, OFFSCREEN_FORMAT);
        // The G-buffer (normal+depth) the SSAO prepass fills — colour is Rgba16Float
        // (world normal in rgb, NDC depth in a), depth is the prepass Z-buffer.
        let gbuffer = OffscreenColorDepth::new(device, OFFSCREEN_FORMAT);
        let post = PostStack::new(device, target_format);

        Self {
            mesh_pipeline,
            mesh_pipeline_no_depth,
            no_depth_for_test: false,
            uniform,
            mesh_bind: None,
            mesh_bgl,
            shadow_pipeline,
            csm_uniform,
            csm_bind,
            shadow_attach,
            gbuf_pipeline,
            gbuf_bind,
            gbuffer,
            ssao_pipeline,
            ssao_uniform,
            ssao_bgl,
            ssao_bind: None,
            ssao_sampler,
            ao_view: None,
            ao_size: (0, 0),
            ao_sampler,
            bind_size: (0, 0),
            shadow_array_view,
            shadow_cmp_sampler,
            shadow_depth_sampler,
            water_pipeline,
            water_uniform,
            water_bgl,
            water_bind: None,
            water_bind_size: (0, 0),
            water_sampler,
            water_vbuf: None,
            water_ibuf: None,
            water_index_count: 0,
            reflection,
            mirror_uniform,
            mirror_bind: None,
            sky_pipeline,
            sky_uniform,
            sky_bind,
            sky_bgl,
            sky_lut_tex,
            sky_lut_view,
            sky_lut_sampler,
            sky_lut_dims,
            sky_lut_src: None,
            sky_lut_writes: 0,
            vbuf: None,
            index_count: 0,
            uploaded_generation: None,
            overlay_vbuf: None,
            overlay_count: 0,
            offscreen,
            post,
            post_passes: 0,
            pass_clock: facett_core::render::gpu::PassClockSlot::default(),
            clock_frame: 0,
        }
    }

    /// (Re)allocate the AO target + rebuild the (resize-dependent) `mesh_bind`,
    /// `mirror_bind` and `ssao_bind` bind groups when the pane size changes. Called
    /// each frame from `prepare`; idempotent within a size.
    fn ensure_ssao_targets(&mut self, device: &wgpu::Device, w: u32, h: u32) {
        let (w, h) = (w.max(1), h.max(1));
        self.gbuffer.ensure(device, w, h);
        if self.ao_size != (w, h) || self.ao_view.is_none() {
            let ao = device.create_texture(&wgpu::TextureDescriptor {
                label: Some("map3d_ao_target"),
                size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
                mip_level_count: 1,
                sample_count: facett_core::render::gpu::NO_MSAA_SAMPLES,
                dimension: wgpu::TextureDimension::D2,
                format: AO_FORMAT,
                usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
                view_formats: &[],
            });
            self.ao_view = Some(ao.create_view(&Default::default()));
            self.ao_size = (w, h);
        }
        if self.bind_size == (w, h) && self.mesh_bind.is_some() {
            return;
        }
        let Some(ao_view) = self.ao_view.as_ref() else { return };
        // The main lighting bind group + the mirror twin both name the AO target.
        let mk_lit = |label: &str, uni: &wgpu::Buffer| {
            device.create_bind_group(&wgpu::BindGroupDescriptor {
                label: Some(label),
                layout: &self.mesh_bgl,
                entries: &[
                    wgpu::BindGroupEntry { binding: 0, resource: uni.as_entire_binding() },
                    wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&self.shadow_array_view) },
                    wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.shadow_cmp_sampler) },
                    wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::Sampler(&self.shadow_depth_sampler) },
                    wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(ao_view) },
                    wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::Sampler(&self.ao_sampler) },
                ],
            })
        };
        self.mesh_bind = Some(mk_lit("map3d_bind", &self.uniform));
        self.mirror_bind = Some(mk_lit("map3d_mirror_bind", &self.mirror_uniform));
        // SSAO reads the gbuffer colour (normal+depth).
        if let Some(gbuf_color) = self.gbuffer.color_view() {
            self.ssao_bind = Some(device.create_bind_group(&wgpu::BindGroupDescriptor {
                label: Some("map3d_ssao_bind"),
                layout: &self.ssao_bgl,
                entries: &[
                    wgpu::BindGroupEntry { binding: 0, resource: self.ssao_uniform.as_entire_binding() },
                    wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(gbuf_color) },
                    wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.ssao_sampler) },
                ],
            }));
        }
        self.bind_size = (w, h);
    }

    /// (Re)upload the **physical-sky transmittance LUT** (FLIGHT-SIM #7 wiring) onto
    /// the render device and point the sky bind group at it. `texels` is row-major
    /// `h_size × mu_size` RGBA — exactly the readback layout of the flight crate's GPU
    /// scattering-LUT pass (`facett_map3d_flight::GpuLut::texels`), so that compute
    /// pass feeds this seam directly. A dimension change reallocates the texture and
    /// rebuilds the sky bind group; a same-size update just re-writes the texels. The
    /// texture must live on the SAME device the renderer draws with, which is why the
    /// seam takes raw texel DATA (device-neutral) rather than a `wgpu::Texture` (a
    /// texture cannot cross devices). Idempotent-cheap in BOTH halves: a dims match
    /// skips the realloc, and the same `Arc` as last time skips the texel write too
    /// (see `sky_lut_src` — the host re-sends the `Arc` every frame, and an
    /// unchanged sky must not cost a full `Rgba32Float` upload per frame).
    fn ensure_sky_lut(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, lut: &std::sync::Arc<crate::SkyTransmittanceLut>) {
        let (w, h) = (lut.mu_size.max(1), lut.h_size.max(1));
        let want = (w as usize) * (h as usize);
        if lut.texels.len() < want {
            return; // malformed payload → keep the current (default/analytic) LUT
        }
        // The per-frame fast path: the device texels came from THIS payload (Arc
        // identity, see `sky_lut_src`) at these dims → nothing to do.
        if self.sky_lut_dims == (w, h)
            && self
                .sky_lut_src
                .as_ref()
                .and_then(std::sync::Weak::upgrade)
                .is_some_and(|prev| std::sync::Arc::ptr_eq(&prev, lut))
        {
            return;
        }
        if self.sky_lut_dims != (w, h) {
            self.sky_lut_tex = device.create_texture(&wgpu::TextureDescriptor {
                label: Some("map3d_sky_lut"),
                size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
                mip_level_count: 1,
                sample_count: facett_core::render::gpu::NO_MSAA_SAMPLES,
                dimension: wgpu::TextureDimension::D2,
                format: wgpu::TextureFormat::Rgba32Float,
                // COPY_SRC so the device test can read the APPLIED texels back — the
                // proof that the per-frame skip never swallows a REAL LUT change
                // asserts texture contents, not a state round-trip (LAW 2).
                usage: wgpu::TextureUsages::TEXTURE_BINDING
                    | wgpu::TextureUsages::COPY_DST
                    | wgpu::TextureUsages::COPY_SRC,
                view_formats: &[],
            });
            self.sky_lut_view = self.sky_lut_tex.create_view(&Default::default());
            self.sky_lut_dims = (w, h);
            self.sky_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
                label: Some("map3d_sky_bind"),
                layout: &self.sky_bgl,
                entries: &[
                    wgpu::BindGroupEntry { binding: 0, resource: self.sky_uniform.as_entire_binding() },
                    wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&self.sky_lut_view) },
                    wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.sky_lut_sampler) },
                ],
            });
        }
        queue.write_texture(
            wgpu::TexelCopyTextureInfo {
                texture: &self.sky_lut_tex,
                mip_level: 0,
                origin: wgpu::Origin3d::ZERO,
                aspect: wgpu::TextureAspect::All,
            },
            bytemuck::cast_slice(&lut.texels[..want]),
            wgpu::TexelCopyBufferLayout {
                offset: 0,
                bytes_per_row: Some(w * std::mem::size_of::<[f32; 4]>() as u32),
                rows_per_image: Some(h),
            },
            wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
        );
        self.sky_lut_src = Some(std::sync::Arc::downgrade(lut));
        self.sky_lut_writes += 1;
    }

    /// Upload `geo` onto the device, **replacing** whatever mesh the renderer holds
    /// when the payload's build stamp ([`GpuGeometry::generation`]) differs from the
    /// one last written. The backend re-sends its cached `Arc` every frame (so the
    /// upload lands whichever frame the host's wgpu backend `prepare`s first), which
    /// makes the per-frame cost of an UNCHANGED scene one integer compare — and a
    /// rebuilt scene actually reaches the GPU, which the old `vbuf.is_some()` latch
    /// made impossible for the life of the process. An empty payload keeps the
    /// current mesh: the live backend never builds one (it falls back to the CPU
    /// painter), and a stray empty upload must not null the draw.
    fn upload(&mut self, device: &wgpu::Device, geo: &GpuGeometry) {
        use wgpu::util::DeviceExt;
        if geo.vertices.is_empty() || self.uploaded_generation == Some(geo.generation) {
            return;
        }
        // ── THE UPLOAD WALL CLOCK (perf/v2.3). Started AFTER the generation gate,
        // so the every-frame re-send of an unchanged `Arc` (one integer compare)
        // never counts as an upload event — `uploads_total` counts re-bakes that
        // actually moved bytes, which is what makes it a byte-accounting the device
        // test can pin with equality (LAW 2). Noted at the bottom, here and only
        // here, so `prepare` and `upload_for_test` route through one writer
        // (LAW #5).
        let upload_t0 = web_time::Instant::now();
        let mut uploaded_bytes = bytemuck::cast_slice::<_, u8>(&geo.vertices).len() as u64;
        let vbuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
            label: Some("map3d_vbuf"),
            contents: bytemuck::cast_slice(&geo.vertices),
            // COPY_SRC so the device test can read the APPLIED bytes back — the proof
            // that a swapped mesh reached the GPU asserts buffer contents, not a
            // state round-trip (LAW 2).
            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_SRC,
        });
        self.vbuf = Some(vbuf);
        self.index_count = geo.vertices.len() as u32;

        // Upload the water grid (if any) — vertex positions only; the water vertex
        // shader Gerstner-displaces them per frame from `time`. A payload with NO
        // water must also CLEAR the previous grid: the old scene's lake floating in
        // the new scene is the replace-on-change twin of the pinned-first-mesh bug.
        if geo.water_positions.is_empty() {
            self.water_vbuf = None;
            self.water_ibuf = None;
            self.water_index_count = 0;
        } else {
            let wverts: Vec<WaterVertex> = geo.water_positions.iter().map(|&p| WaterVertex { pos: p }).collect();
            uploaded_bytes += (bytemuck::cast_slice::<_, u8>(&wverts).len()
                + bytemuck::cast_slice::<_, u8>(&geo.water_indices).len())
                as u64;
            let wvbuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
                label: Some("map3d_water_vbuf"),
                contents: bytemuck::cast_slice(&wverts),
                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::INDEX,
            });
            // We draw the water grid as an INDEXED list — upload the index buffer too.
            let wibuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
                label: Some("map3d_water_ibuf"),
                contents: bytemuck::cast_slice(&geo.water_indices),
                usage: wgpu::BufferUsages::INDEX,
            });
            self.water_vbuf = Some(wvbuf);
            self.water_ibuf = Some(wibuf);
            self.water_index_count = geo.water_indices.len() as u32;
        }
        self.uploaded_generation = Some(geo.generation);
        // Wall µs + the exact payload bytes (mesh vertices, plus the water grid's
        // vertices and indices when present). Everything `create_buffer_init` was
        // handed above, nothing else — the device test asserts EQUALITY.
        facett_core::render::lane::note_upload(
            lane_name(),
            upload_t0.elapsed().as_micros() as u64,
            uploaded_bytes,
        );
    }

    /// **Upload the per-frame overlay body** (V2.5 D.3) — every call, no generation
    /// gate, ON PURPOSE: the overlay is a live pose (~100 verts, ~4 KB) and a
    /// staleness gate here would be a second cache to keep honest for a payload
    /// cheaper than the compare machinery. `None` clears the buffer, so leaving
    /// flight mode removes the body the same frame — the replace-on-change lesson
    /// (the old scene's lake floating in the new scene) applied in advance.
    fn upload_overlay(&mut self, device: &wgpu::Device, geo: Option<&GpuGeometry>) {
        use wgpu::util::DeviceExt;
        match geo {
            Some(g) if !g.vertices.is_empty() => {
                let vbuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
                    label: Some("map3d_overlay_vbuf"),
                    contents: bytemuck::cast_slice(&g.vertices),
                    // COPY_SRC so the device proof reads the APPLIED bytes back —
                    // the posed world positions and the emissive w (LAW 2).
                    usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_SRC,
                });
                self.overlay_vbuf = Some(vbuf);
                self.overlay_count = g.vertices.len() as u32;
            }
            _ => {
                self.overlay_vbuf = None;
                self.overlay_count = 0;
            }
        }
    }

    /// Test hook: drive the real `upload_overlay` path directly (the device proof
    /// swaps pose A → B and reads the applied buffer back).
    #[doc(hidden)]
    pub fn upload_overlay_for_test(&mut self, device: &wgpu::Device, geo: Option<&GpuGeometry>) {
        self.upload_overlay(device, geo);
    }

    /// Test hook: the live overlay vertex buffer + the vertex count the next draw
    /// uses (`None`/`0` = no overlay drawn) — carries `COPY_SRC` for readback.
    #[doc(hidden)]
    pub fn overlay_for_test(&self) -> Option<(&wgpu::Buffer, u32)> {
        self.overlay_vbuf.as_ref().map(|b| (b, self.overlay_count))
    }

    /// Test hook: drive the real `upload` path directly (the same seam facett-map's
    /// `GpuMapRenderer::upload_for_test` exposes) so the device proof can swap
    /// geometry A → B without a full egui pass.
    #[doc(hidden)]
    pub fn upload_for_test(&mut self, device: &wgpu::Device, geo: &GpuGeometry) {
        self.upload(device, geo);
    }

    /// Test hook: the live vertex buffer + the vertex count the next draw uses —
    /// read back by the device proof (the buffer carries `COPY_SRC` for exactly this).
    #[doc(hidden)]
    pub fn vbuf_for_test(&self) -> Option<(&wgpu::Buffer, u32)> {
        self.vbuf.as_ref().map(|b| (b, self.index_count))
    }

    /// Test hook: the water-grid index count the next water draw uses (`0` = no water).
    #[doc(hidden)]
    pub fn water_index_count_for_test(&self) -> u32 {
        self.water_index_count
    }

    /// Test hook: drive the real `ensure_sky_lut` path directly.
    #[doc(hidden)]
    pub fn ensure_sky_lut_for_test(
        &mut self,
        device: &wgpu::Device,
        queue: &wgpu::Queue,
        lut: &std::sync::Arc<crate::SkyTransmittanceLut>,
    ) {
        self.ensure_sky_lut(device, queue, lut);
    }

    /// Test hook: texel writes `ensure_sky_lut` has issued + the live LUT texture
    /// (read back by the device proof).
    #[doc(hidden)]
    pub fn sky_lut_state_for_test(&self) -> (u32, &wgpu::Texture) {
        (self.sky_lut_writes, &self.sky_lut_tex)
    }
}

/// **HDR post stack**: bright-pass → separable Gaussian bloom → ACES tonemap →
/// present blit (`bloom.wgsl`). The opaque + water passes now write **linear
/// radiance** into the `Rgba16Float` offscreen; this turns that HDR buffer into the
/// 8-bit egui surface, with real emissive/highlight bloom that the old per-fragment
/// tonemap clipped away. Built once; the half-res bloom ping-pong + the LDR present
/// target are (re)allocated on resize, with the bind groups that name the live
/// offscreen HDR view.
/// Decide whether the **present** pass must apply the linear→sRGB encode in
/// the shader, given the egui surface (`target_format`) it blits into.
///
/// The HDR post stack (`bloom.wgsl` composite → TAA resolve → `present.wgsl`) produces
/// **display-referred** colour and expects the sRGB gamma encode to happen when the
/// value lands on the final surface. On a native desktop that surface is an
/// `*-srgb` format (e.g. `Bgra8UnormSrgb`) and the GPU encodes on write, so the
/// shader must NOT encode again (`0.0`). The **browser WebGPU** canvas is the
/// non-sRGB `Bgra8Unorm`, so nothing encodes and the identical output renders dark
/// and muddy-brown — the shader must apply the encode itself (`1.0`).
///
/// Returns the flag as an `f32` (uniform-friendly): `1.0` = encode in-shader (target
/// is NOT sRGB), `0.0` = leave linear for the hardware (target IS sRGB).
fn present_encode_srgb(target_format: wgpu::TextureFormat) -> f32 {
    if target_format.is_srgb() { 0.0 } else { 1.0 }
}

/// **Begin a screen-space POST pass and count it.** The ONE place a post pass is
/// opened, so the number a test reads back cannot drift from the number of passes
/// actually recorded (LAW #5 — one writer, rather than a counter watched for agreement
/// with the call sites).
///
/// "Post" here means every pass whose output is an intermediate the final image is
/// derived from — shadow depth, the G-buffer, SSAO, the planar reflection, the sky, and
/// the bright/blur/blur/composite chain. The main depth-tested geometry pass and the
/// water pass are NOT post: they draw the scene itself, and a 2D map still needs them.
///
/// What it counts is honest and narrow: passes **recorded into the encoder**, tallied at
/// the `begin_render_pass` call, not passes the GPU executed. The device test pairs it
/// with pixel assertions for exactly that reason.
fn begin_post_pass<'e>(
    count: &mut u32,
    encoder: &'e mut wgpu::CommandEncoder,
    desc: &wgpu::RenderPassDescriptor<'_>,
) -> wgpu::RenderPass<'e> {
    *count += 1;
    encoder.begin_render_pass(desc)
}

/// **Which temporal lane the present path runs.** `On` is the shipped path; the other
/// two exist so `tests/taa_present.rs` can measure against them on the same device.
///
/// The mutation that matters is [`ZeroVelocity`](TaaMode::ZeroVelocity), not `Off`. A
/// **static** camera converges with no motion vectors at all — the history lands back
/// on the same pixel by luck — so a proof taken from a still camera cannot tell a wired
/// TAA from a half-wired one. Flight mode is a permanently moving camera, which is the
/// case that smears, so `ZeroVelocity` is exactly "TAA wired up without motion vectors"
/// and the proof moves the camera to catch it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TaaMode {
    /// The shipped path: Halton-jittered projection + depth-reprojected motion vectors.
    #[default]
    On,
    /// Identical jitter, identical resolve — but the resolve is handed `TaaPass`'s 1×1
    /// zero-velocity texture instead of the velocity target. The negative control for
    /// part (b) of the wiring.
    ZeroVelocity,
    /// No temporal lane at all: no jitter, no velocity pass, no resolve. The tonemapped
    /// LDR target is presented directly — an aliased frame, but never a smeared one.
    Off,
}

/// The motion-vector target's format. `Rg16Float` because the resolve **filters** it
/// (`textureSampleLevel` through a `Filtering` sampler) and 16-bit float is filterable
/// on every backend without the `float32-filterable` feature; a UV-per-frame velocity
/// needs nothing like 32 bits of mantissa.
const VELOCITY_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rg16Float;

/// Uniform for `velocity.wgsl`. Three 4×4s and a `vec4` — 208 B.
#[repr(C)]
#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
struct VelocityUniforms {
    /// Inverse of the CURRENT frame's **jittered** view-projection (clip → world).
    inv_vp_jitter: [f32; 16],
    /// Current frame's **unjittered** view-projection.
    vp_cur: [f32; 16],
    /// Previous frame's **unjittered** view-projection.
    vp_prev: [f32; 16],
    /// `res_x, res_y, 1/res_x, 1/res_y`.
    res: [f32; 4],
}

/// **THE MOTION VECTORS — part (b) of the TAA wiring, and the part every earlier
/// estimate of this work left out.**
///
/// `TaaPass` holds a 1×1 zero-velocity texture so its resolve keeps one code path
/// whether or not the caller has motion vectors. That is a convenience, not a
/// substitute: with zero velocity the history never reprojects, so a moving camera
/// blends every pixel against where the world was on screen last frame and **smears**.
/// A still camera converges anyway, which is why `facett-core/tests/gpu_taa.rs` is
/// green and proves nothing about flight mode.
///
/// A map needs no per-object motion vectors: the city is static and only the camera
/// moves, so the velocity is a pure **camera reprojection** off the depth buffer the
/// main pass already wrote — unproject with the current jittered inverse VP, reproject
/// with the current and previous unjittered VPs, difference the two screen positions.
/// No second geometry pass, no per-vertex previous position, one extra fullscreen pass
/// reading a depth target that is allocated regardless. `velocity.wgsl` carries the
/// derivation and the reason step 2 keeps the jitter while step 3 drops it.
struct VelocityPass {
    pipeline: wgpu::RenderPipeline,
    bgl: wgpu::BindGroupLayout,
    uniform: wgpu::Buffer,
    /// The motion-vector target + its view. `COPY_SRC` so `read_velocity_for_test` can
    /// measure the field against the image motion it claims to predict.
    target: Option<wgpu::Texture>,
    view: Option<wgpu::TextureView>,
    /// `{uniform, scene depth}` — rebuilt when the pane resizes, because that is when
    /// the offscreen's depth view is replaced.
    bind: Option<wgpu::BindGroup>,
    size: (u32, u32),
}

impl VelocityPass {
    fn new(device: &wgpu::Device) -> Self {
        let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("map3d_velocity"),
            source: wgpu::ShaderSource::Wgsl(include_str!("velocity.wgsl").into()),
        });
        let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
            label: Some("map3d_velocity_bgl"),
            entries: &[
                wgpu::BindGroupLayoutEntry {
                    binding: 0,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Buffer {
                        ty: wgpu::BufferBindingType::Uniform,
                        has_dynamic_offset: false,
                        min_binding_size: None,
                    },
                    count: None,
                },
                // The scene depth, read with `textureLoad` — no sampler, so no
                // filtering question to get wrong on a depth format.
                wgpu::BindGroupLayoutEntry {
                    binding: 1,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Texture {
                        sample_type: wgpu::TextureSampleType::Depth,
                        view_dimension: wgpu::TextureViewDimension::D2,
                        multisampled: false,
                    },
                    count: None,
                },
            ],
        });
        let pll = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
            label: Some("map3d_velocity_pll"),
            bind_group_layouts: &[Some(&bgl)],
            immediate_size: 0,
        });
        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
            label: Some("map3d_velocity_pipeline"),
            layout: Some(&pll),
            vertex: wgpu::VertexState {
                module: &module,
                entry_point: Some("vel_vs"),
                compilation_options: Default::default(),
                buffers: &[],
            },
            primitive: wgpu::PrimitiveState {
                topology: wgpu::PrimitiveTopology::TriangleList,
                ..Default::default()
            },
            depth_stencil: None,
            multisample: facett_core::render::gpu::msaa_state(),
            fragment: Some(wgpu::FragmentState {
                module: &module,
                entry_point: Some("vel_fs"),
                compilation_options: Default::default(),
                targets: &[Some(wgpu::ColorTargetState {
                    format: VELOCITY_FORMAT,
                    blend: None,
                    write_mask: wgpu::ColorWrites::ALL,
                })],
            }),
            multiview_mask: None,
            cache: None,
        });
        Self {
            pipeline,
            bgl,
            uniform: device.create_buffer(&wgpu::BufferDescriptor {
                label: Some("map3d_velocity_uniform"),
                size: std::mem::size_of::<VelocityUniforms>() as u64,
                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
                mapped_at_creation: false,
            }),
            target: None,
            view: None,
            bind: None,
            size: (0, 0),
        }
    }

    /// (Re)allocate the target and rebind against the live scene depth. Idempotent
    /// within a size — which is also exactly when the offscreen keeps its depth view,
    /// so one gate covers both.
    fn ensure(&mut self, device: &wgpu::Device, depth: &wgpu::TextureView, w: u32, h: u32) {
        let (w, h) = (w.max(1), h.max(1));
        if self.size == (w, h) && self.bind.is_some() {
            return;
        }
        let tex = device.create_texture(&wgpu::TextureDescriptor {
            label: Some("map3d_velocity_target"),
            size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
            mip_level_count: 1,
            sample_count: facett_core::render::gpu::NO_MSAA_SAMPLES,
            dimension: wgpu::TextureDimension::D2,
            format: VELOCITY_FORMAT,
            usage: wgpu::TextureUsages::RENDER_ATTACHMENT
                | wgpu::TextureUsages::TEXTURE_BINDING
                | wgpu::TextureUsages::COPY_SRC,
            view_formats: &[],
        });
        self.view = Some(tex.create_view(&Default::default()));
        self.target = Some(tex);
        self.bind = Some(device.create_bind_group(&wgpu::BindGroupDescriptor {
            label: Some("map3d_velocity_bind"),
            layout: &self.bgl,
            entries: &[
                wgpu::BindGroupEntry { binding: 0, resource: self.uniform.as_entire_binding() },
                wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(depth) },
            ],
        }));
        self.size = (w, h);
    }

    fn write_uniforms(&self, queue: &wgpu::Queue, u: &VelocityUniforms) {
        queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(u));
    }

    /// Record the reprojection into `encoder`. `false` when the target is not
    /// allocated, so a caller can tell "no motion vectors" from "zero motion".
    fn record(&self, encoder: &mut wgpu::CommandEncoder, count: &mut u32) -> bool {
        let (Some(view), Some(bind)) = (self.view.as_ref(), self.bind.as_ref()) else {
            return false;
        };
        let mut rp = begin_post_pass(count, encoder, &wgpu::RenderPassDescriptor {
            label: Some("map3d_velocity_pass"),
            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                view,
                resolve_target: None,
                depth_slice: None,
                ops: wgpu::Operations {
                    load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
                    store: wgpu::StoreOp::Store,
                },
            })],
            depth_stencil_attachment: None,
            timestamp_writes: None,
            occlusion_query_set: None,
            multiview_mask: None,
        });
        rp.set_pipeline(&self.pipeline);
        rp.set_bind_group(0, bind, &[]);
        rp.draw(0..3, 0..1);
        true
    }
}

/// **Jitter a view-projection by a sub-pixel screen offset** — part (a) of the TAA
/// wiring, and the half `TaaPass` cannot do for itself: the offset has to be baked into
/// where the geometry landed, so only the host that owns the matrix can apply it.
///
/// The offset is a *clip-space translation*, `T · VP` with `T` the identity plus
/// `dx, dy` in its fourth column. Because `clip.x' = clip.x + dx·clip.w`, the NDC shift
/// is `dx` at **every depth** — a uniform sub-pixel move of the whole image, which is
/// what a jitter must be. (Writing `dx` into `VP`'s third column instead — the trick
/// that works on a bare projection matrix — is wrong here: that column multiplies world
/// `z`, not view `z`, once view and projection are combined.)
///
/// `j` is in pixels, y-down (the convention `TaaPass::jitter_px` reports); NDC y is up,
/// hence the sign flip on the second component.
fn jitter_view_proj(vp: &[f32; 16], j: [f32; 2], w: u32, h: u32) -> [f32; 16] {
    let dx = 2.0 * j[0] / w.max(1) as f32;
    let dy = -2.0 * j[1] / h.max(1) as f32;
    let mut out = *vp;
    // Column-major: element (row, col) lives at `col * 4 + row`.
    for c in 0..4 {
        out[c * 4] += dx * vp[c * 4 + 3];
        out[c * 4 + 1] += dy * vp[c * 4 + 3];
    }
    out
}

struct PostStack {
    /// HDR (full-res) → bloomA (half-res), soft-knee threshold.
    bright: wgpu::RenderPipeline,
    /// Half-res Gaussian, ping-pong; the blur axis rides the per-pass uniform.
    blur: wgpu::RenderPipeline,
    /// HDR scene + bloom → exposure + ACES → LDR (`ldr_format`).
    composite: wgpu::RenderPipeline,
    /// The AA lane's output → egui pass: the final scissored blit (`present.wgsl`).
    /// A plain resample + the sRGB gate; the antialiasing happened upstream, in time.
    present: wgpu::RenderPipeline,

    /// **The temporal AA lane** (parts (a) and (c) of the wiring). Resolves the
    /// tonemapped LDR target against a reprojected history and owns the jitter phase
    /// the projection is offset by — so the offset the host applies and the frame the
    /// resolve accumulates cannot disagree.
    taa: facett_core::render::gpu::TaaPass,
    /// The motion vectors the resolve reprojects with (part (b)).
    velocity: VelocityPass,
    /// Which lane runs. `On` everywhere but the proofs' controls.
    taa_mode: TaaMode,
    /// **The previous frame's UNJITTERED view-projection** — the whole of part (a)'s
    /// state. `None` before the first frame (and after a resize), where the velocity
    /// pass is handed `vp_prev == vp_cur` and therefore reports identically zero, which
    /// is the correct answer when there is no previous frame to reproject into.
    prev_view_proj: Option<[f32; 16]>,

    /// Layout `{uniform, tex, sampler}` shared by bright / blur / present.
    sample_bgl: wgpu::BindGroupLayout,
    /// Layout `{uniform, hdr tex, sampler, bloom tex}` for the composite.
    composite_bgl: wgpu::BindGroupLayout,
    sampler: wgpu::Sampler,

    u_bright: wgpu::Buffer,
    u_blur_h: wgpu::Buffer,
    u_blur_v: wgpu::Buffer,
    u_composite: wgpu::Buffer,

    ldr_format: wgpu::TextureFormat,

    /// `1.0` when the egui `target_format` is a **non-sRGB** surface (the WebGPU
    /// browser canvas is `Bgra8Unorm`, NOT `*-srgb`), so the FXAA present pass must
    /// apply the linear→sRGB encode ITSELF; `0.0` when the target is an `*-srgb`
    /// surface (native desktop) and the hardware does the encode on write.
    ///
    /// THE NATIVE-vs-BROWSER FIX: the post stack writes display-referred colour that
    /// relies on the final surface carrying the sRGB encode. Native picks a
    /// `Bgra8UnormSrgb` surface (hardware encodes → correct); the browser WebGPU
    /// surface is `Bgra8Unorm` (no encode) so the identical bytes render **dark /
    /// muddy-brown**. Encoding in-shader on the non-sRGB target restores parity.
    present_encode_srgb: f32,

    // ── Resized resources (None before the first `ensure`). ──
    bloom_a: Option<wgpu::TextureView>,
    bloom_b: Option<wgpu::TextureView>,
    ldr: Option<wgpu::TextureView>,
    bg_bright: Option<wgpu::BindGroup>,
    bg_blur_h: Option<wgpu::BindGroup>,
    bg_blur_v: Option<wgpu::BindGroup>,
    bg_composite: Option<wgpu::BindGroup>,
    /// Present reading the tonemapped LDR target directly — the [`TaaMode::Off`] path.
    bg_present: Option<wgpu::BindGroup>,
    /// Present reading the TAA history, **one per ping-pong slot**, built once at
    /// `ensure` and selected by `TaaPass::history_index`. Prebuilt rather than
    /// allocated per frame: preallocate and reuse (LAW), and a bind group is the one
    /// per-frame allocation a resolve lane would otherwise add.
    bg_present_taa: Option<[wgpu::BindGroup; 2]>,
    size: (u32, u32),
}

impl PostStack {
    fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self {
        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("map3d_bloom"),
            source: wgpu::ShaderSource::Wgsl(include_str!("bloom.wgsl").into()),
        });
        let uniform_entry = |binding: u32| wgpu::BindGroupLayoutEntry {
            binding,
            visibility: wgpu::ShaderStages::FRAGMENT,
            ty: wgpu::BindingType::Buffer {
                ty: wgpu::BufferBindingType::Uniform,
                has_dynamic_offset: false,
                min_binding_size: None,
            },
            count: None,
        };
        let tex_entry = |binding: u32| wgpu::BindGroupLayoutEntry {
            binding,
            visibility: wgpu::ShaderStages::FRAGMENT,
            ty: wgpu::BindingType::Texture {
                sample_type: wgpu::TextureSampleType::Float { filterable: true },
                view_dimension: wgpu::TextureViewDimension::D2,
                multisampled: false,
            },
            count: None,
        };
        let smp_entry = |binding: u32| wgpu::BindGroupLayoutEntry {
            binding,
            visibility: wgpu::ShaderStages::FRAGMENT,
            ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
            count: None,
        };
        // `{uniform, tex, sampler}` — bright / blur / present.
        let sample_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
            label: Some("map3d_post_sample_bgl"),
            entries: &[uniform_entry(0), tex_entry(1), smp_entry(2)],
        });
        // `{uniform, hdr, sampler, bloom}` — composite.
        let composite_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
            label: Some("map3d_post_composite_bgl"),
            entries: &[uniform_entry(0), tex_entry(1), smp_entry(2), tex_entry(3)],
        });
        let sample_pll = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
            label: Some("map3d_post_sample_pll"),
            bind_group_layouts: &[Some(&sample_bgl)],
            immediate_size: 0,
        });
        let composite_pll = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
            label: Some("map3d_post_composite_pll"),
            bind_group_layouts: &[Some(&composite_bgl)],
            immediate_size: 0,
        });
        // Helper to spin a fullscreen-triangle post pipeline (over a chosen module).
        let make = |label: &str,
                    module: &wgpu::ShaderModule,
                    layout: &wgpu::PipelineLayout,
                    fs: &str,
                    format: wgpu::TextureFormat,
                    blend: Option<wgpu::BlendState>| {
            device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
                label: Some(label),
                layout: Some(layout),
                vertex: wgpu::VertexState {
                    module,
                    entry_point: Some("post_vs"),
                    compilation_options: Default::default(),
                    buffers: &[],
                },
                primitive: wgpu::PrimitiveState {
                    topology: wgpu::PrimitiveTopology::TriangleList,
                    ..Default::default()
                },
                depth_stencil: None,
                multisample: facett_core::render::gpu::msaa_state(),
                fragment: Some(wgpu::FragmentState {
                    module,
                    entry_point: Some(fs),
                    compilation_options: Default::default(),
                    targets: &[Some(wgpu::ColorTargetState {
                        format,
                        blend,
                        write_mask: wgpu::ColorWrites::ALL,
                    })],
                }),
                multiview_mask: None,
                cache: None,
            })
        };
        let bright = make("map3d_post_bright", &shader, &sample_pll, "bright_fs", OFFSCREEN_FORMAT, None);
        let blur = make("map3d_post_blur", &shader, &sample_pll, "blur_fs", OFFSCREEN_FORMAT, None);
        let composite = make("map3d_post_composite", &shader, &composite_pll, "composite_fs", target_format, None);
        // ── THE PRESENT PASS IS NO LONGER AN AA PASS (part (c) of the TAA wiring).
        // It was FXAA (`fxaa.wgsl`, deleted with this change), a luma-based edge blur
        // over the tonemapped LDR target. Running it *and* TAA is mushy in a specific
        // way: FXAA blurs precisely the edges TAA is trying to converge, and the
        // history then accumulates the blur, so the pair is worse than either alone.
        // The present pass now samples whatever the AA lane resolved and applies only
        // the surface sRGB gate.
        let present_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("map3d_present"),
            source: wgpu::ShaderSource::Wgsl(include_str!("present.wgsl").into()),
        });
        let present = make(
            "map3d_post_present",
            &present_shader,
            &sample_pll,
            "present_fs",
            target_format,
            Some(wgpu::BlendState::ALPHA_BLENDING),
        );

        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
            label: Some("map3d_post_sampler"),
            mag_filter: wgpu::FilterMode::Linear,
            min_filter: wgpu::FilterMode::Linear,
            address_mode_u: wgpu::AddressMode::ClampToEdge,
            address_mode_v: wgpu::AddressMode::ClampToEdge,
            ..Default::default()
        });
        let mkbuf = |label: &str| device.create_buffer(&wgpu::BufferDescriptor {
            label: Some(label),
            size: std::mem::size_of::<BloomUniforms>() as u64,
            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
            mapped_at_creation: false,
        });
        Self {
            bright,
            blur,
            composite,
            present,
            // The history/output format is the LDR target's, so the resolve runs on
            // display-referred colour the composite already tonemapped — one texture
            // format across composite → resolve → present, no re-encode in between.
            taa: facett_core::render::gpu::TaaPass::new(device, target_format),
            velocity: VelocityPass::new(device),
            taa_mode: TaaMode::On,
            prev_view_proj: None,
            sample_bgl,
            composite_bgl,
            sampler,
            u_bright: mkbuf("map3d_post_u_bright"),
            u_blur_h: mkbuf("map3d_post_u_blur_h"),
            u_blur_v: mkbuf("map3d_post_u_blur_v"),
            u_composite: mkbuf("map3d_post_u_composite"),
            ldr_format: target_format,
            // The shader-side sRGB encode is a workaround for surfaces the hardware
            // does NOT encode on write. In practice that is exactly the **WebGPU
            // browser canvas** (`Bgra8Unorm`, non-sRGB) — the reported dark/brown bug.
            // The native desktop always negotiates an `*-srgb` window surface (the GPU
            // encodes → already correct), and the ONLY native non-sRGB consumer is the
            // offscreen `egui_kittest` TEST harness, whose luma goldens are calibrated
            // to the raw (un-encoded) path. So the workaround is scoped to wasm: the
            // browser gets parity while native (real app AND the headless proofs) stays
            // byte-for-byte stable. `present_encode_srgb` itself is the correct format
            // predicate (unit-tested); wasm is where a non-sRGB target actually occurs.
            present_encode_srgb: if cfg!(target_arch = "wasm32") {
                present_encode_srgb(target_format)
            } else {
                0.0
            },
            bloom_a: None,
            bloom_b: None,
            ldr: None,
            bg_bright: None,
            bg_blur_h: None,
            bg_blur_v: None,
            bg_composite: None,
            bg_present: None,
            bg_present_taa: None,
            size: (0, 0),
        }
    }

    /// (Re)allocate the half-res bloom ping-pong + the full-res LDR present target
    /// for a pane of `w×h`, and rebuild the bind groups against the live offscreen
    /// HDR view (`hdr`). Idempotent within a size.
    fn ensure(
        &mut self,
        device: &wgpu::Device,
        hdr: &wgpu::TextureView,
        depth: Option<&wgpu::TextureView>,
        w: u32,
        h: u32,
    ) {
        let w = w.max(1);
        let h = h.max(1);
        // The TAA lane is (re)allocated FIRST and unconditionally, because its own
        // `ensure` is what RESETS the jitter phase on a resize — and `prepare` reads
        // `jitter_px()` immediately after, to build the matrix this frame's geometry is
        // rasterised with. A resize that reset the phase *after* the matrix was built
        // would render one frame at a jitter the resolve never saw.
        self.taa.ensure(device, w, h);
        if let Some(depth) = depth {
            self.velocity.ensure(device, depth, w, h);
        }
        if self.size == (w, h) && self.bloom_a.is_some() {
            return;
        }
        // A resize invalidates every reprojection: the previous frame is a different
        // size, so its view-projection cannot be differenced against this one.
        self.prev_view_proj = None;
        let (hw, hh) = ((w / 2).max(1), (h / 2).max(1));
        let mk = |label: &str, tw: u32, th: u32, format: wgpu::TextureFormat| {
            device
                .create_texture(&wgpu::TextureDescriptor {
                    label: Some(label),
                    size: wgpu::Extent3d { width: tw, height: th, depth_or_array_layers: 1 },
                    mip_level_count: 1,
                    sample_count: facett_core::render::gpu::NO_MSAA_SAMPLES,
                    dimension: wgpu::TextureDimension::D2,
                    format,
                    usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
                    view_formats: &[],
                })
                .create_view(&Default::default())
        };
        let bloom_a = mk("map3d_post_bloom_a", hw, hh, OFFSCREEN_FORMAT);
        let bloom_b = mk("map3d_post_bloom_b", hw, hh, OFFSCREEN_FORMAT);
        let ldr = mk("map3d_post_ldr", w, h, self.ldr_format);

        let sample_bg = |label: &str, u: &wgpu::Buffer, tex: &wgpu::TextureView| {
            device.create_bind_group(&wgpu::BindGroupDescriptor {
                label: Some(label),
                layout: &self.sample_bgl,
                entries: &[
                    wgpu::BindGroupEntry { binding: 0, resource: u.as_entire_binding() },
                    wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(tex) },
                    wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.sampler) },
                ],
            })
        };
        self.bg_bright = Some(sample_bg("map3d_post_bg_bright", &self.u_bright, hdr));
        self.bg_blur_h = Some(sample_bg("map3d_post_bg_blur_h", &self.u_blur_h, &bloom_a));
        self.bg_blur_v = Some(sample_bg("map3d_post_bg_blur_v", &self.u_blur_v, &bloom_b));
        self.bg_present = Some(sample_bg("map3d_post_bg_present", &self.u_composite, &ldr));
        // One present binding per TAA ping-pong slot; `record` advances which is
        // current, and `present` picks with `history_index()`.
        self.bg_present_taa = match (self.taa.history_view(0), self.taa.history_view(1)) {
            (Some(a), Some(b)) => Some([
                sample_bg("map3d_post_bg_present_taa0", &self.u_composite, a),
                sample_bg("map3d_post_bg_present_taa1", &self.u_composite, b),
            ]),
            _ => None,
        };
        self.bg_composite = Some(device.create_bind_group(&wgpu::BindGroupDescriptor {
            label: Some("map3d_post_bg_composite"),
            layout: &self.composite_bgl,
            entries: &[
                wgpu::BindGroupEntry { binding: 0, resource: self.u_composite.as_entire_binding() },
                wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(hdr) },
                wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.sampler) },
                wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&bloom_a) },
            ],
        }));
        self.bloom_a = Some(bloom_a);
        self.bloom_b = Some(bloom_b);
        self.ldr = Some(ldr);
        self.size = (w, h);
    }

    /// Write the per-pass uniforms (texel sizes + the constant tone params). Cheap;
    /// called each frame so a resize is always reflected.
    fn write_uniforms(&self, queue: &wgpu::Queue, w: u32, h: u32, bloom_on: bool) {
        let (w, h) = (w.max(1), h.max(1));
        let (hw, hh) = ((w / 2).max(1), (h / 2).max(1));
        // exposure = 1/(1.2·2^EV100) — the physically-based single knob (see DEFAULT_EV100).
        let exposure = exposure_from_ev100(DEFAULT_EV100);
        // Bloom off (the 2D-ortho view + the with_bloom(false) render-proof control):
        // zero the composite's bloom intensity so the HDR stack adds no glow. `record`
        // then also SKIPS the three bright/blur passes — this exact `0.0` is what makes
        // that skip pixel-identical rather than merely cheap (see `record`'s doc).
        let intensity = if bloom_on { BLOOM_INTENSITY } else { 0.0 };
        let params = [BLOOM_THRESHOLD, BLOOM_KNEE, intensity, exposure];
        let full_texel = [1.0 / w as f32, 1.0 / h as f32];
        let half_texel = [1.0 / hw as f32, 1.0 / hh as f32];
        queue.write_buffer(&self.u_bright, 0, bytemuck::bytes_of(&BloomUniforms {
            params,
            texel: [full_texel[0], full_texel[1], 0.0, 0.0],
        }));
        queue.write_buffer(&self.u_blur_h, 0, bytemuck::bytes_of(&BloomUniforms {
            params,
            texel: [half_texel[0], half_texel[1], 1.0, 0.0],
        }));
        queue.write_buffer(&self.u_blur_v, 0, bytemuck::bytes_of(&BloomUniforms {
            params,
            texel: [half_texel[0], half_texel[1], 0.0, 1.0],
        }));
        // u_composite also feeds the FXAA present pass: texel.xy = 1/resolution of
        // the (full-res) LDR target it samples; texel.z carries the sRGB-encode flag
        // (1.0 on a non-sRGB — WebGPU browser — surface, 0.0 on a native *-srgb one).
        // The composite pass ignores `texel` entirely, so overloading .z is safe.
        queue.write_buffer(&self.u_composite, 0, bytemuck::bytes_of(&BloomUniforms {
            params,
            texel: [full_texel[0], full_texel[1], self.present_encode_srgb, 0.0],
        }));
    }

    /// Record the bloom + tonemap passes into `encoder` (after the scene + water
    /// have filled the HDR offscreen). Leaves the tonemapped result in `ldr`, which
    /// [`present`](Self::present) blits into egui's pass.
    ///
    /// # The composite is MANDATORY; the bloom chain is not (GFX_V2 §V2.9b)
    ///
    /// `bloom_on == false` skips **bright → blur H → blur V** — three fullscreen passes
    /// that were running on every bloom-off frame and could not affect a pixel, because
    /// `write_uniforms` zeroes the composite's bloom intensity and `bloom.wgsl` reaches
    /// the bloom texture only through `bloom * U.params.z`. Multiplying by an exact
    /// `0.0` is what makes the skip a *no-op rather than an approximation*: the bright
    /// pass's output is `soft_threshold` of finite HDR radiance, so `bloom_a` is always
    /// finite and finite × 0.0 is 0.0 — there is no NaN path by which stale bloom
    /// content could leak into the frame. `tests/bloom_off_costs_nothing.rs` asserts
    /// exactly that, by dirtying `bloom_a` with a bloom-ON frame first.
    ///
    /// The **composite always runs**, including for the 2D-ortho view. It is not an
    /// effect — it is the exposure + ACES + gamma display transform that turns the
    /// `Rgba16Float` offscreen's LINEAR RADIANCE into something a screen can show.
    /// GFX_V2 item 7 read "2D costs nothing extra" as "run no post pass at all", and
    /// the 2026-08-05 regression that produced was a plan view about a gamma too dark
    /// (measured luma spread 3.30 against 14.70 before `f8772ad1`, RTX 4090). A frame
    /// still has to be tonemapped to be a picture; what 2D genuinely costs nothing for
    /// is sky, SSAO, shadows, reflection and bloom, all of which `constrain_to_view`
    /// clears and every one of which is gated on its own flag.
    fn record(
        &mut self,
        device: &wgpu::Device,
        queue: &wgpu::Queue,
        encoder: &mut wgpu::CommandEncoder,
        count: &mut u32,
        bloom_on: bool,
    ) {
        let (Some(bloom_a), Some(bloom_b), Some(ldr)) =
            (self.bloom_a.as_ref(), self.bloom_b.as_ref(), self.ldr.as_ref())
        else {
            return;
        };
        let (Some(bg_bright), Some(bg_blur_h), Some(bg_blur_v), Some(bg_composite)) = (
            self.bg_bright.as_ref(),
            self.bg_blur_h.as_ref(),
            self.bg_blur_v.as_ref(),
            self.bg_composite.as_ref(),
        ) else {
            return;
        };
        let pass = |count: &mut u32,
                    encoder: &mut wgpu::CommandEncoder,
                    label: &str,
                    view: &wgpu::TextureView,
                    pipeline: &wgpu::RenderPipeline,
                    bind: &wgpu::BindGroup| {
            let mut rp = begin_post_pass(count, encoder, &wgpu::RenderPassDescriptor {
                label: Some(label),
                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                    view,
                    resolve_target: None,
                    depth_slice: None,
                    ops: wgpu::Operations {
                        load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
                        store: wgpu::StoreOp::Store,
                    },
                })],
                depth_stencil_attachment: None,
                timestamp_writes: None,
                occlusion_query_set: None,
                multiview_mask: None,
            });
            rp.set_pipeline(pipeline);
            rp.set_bind_group(0, bind, &[]);
            rp.draw(0..3, 0..1);
        };
        // bright (HDR full → bloomA half) → blur H (bloomA → bloomB) → blur V
        // (bloomB → bloomA) — the GLOW, skipped entirely when it is switched off.
        if bloom_on {
            pass(count, encoder, "map3d_post_bright", bloom_a, &self.bright, bg_bright);
            pass(count, encoder, "map3d_post_blur_h", bloom_b, &self.blur, bg_blur_h);
            pass(count, encoder, "map3d_post_blur_v", bloom_a, &self.blur, bg_blur_v);
        }
        // …then composite (HDR + bloomA → ldr): exposure + ACES + gamma. ALWAYS.
        pass(count, encoder, "map3d_post_composite", ldr, &self.composite, bg_composite);

        // ── THE AA LANE (parts (b) + (c)): motion vectors, then the temporal resolve.
        //
        // **THE 2D-ORTHO BYPASS, AND THE CHOICE MADE HERE.** `flat_2d` does not reach
        // this code and that is deliberate, so say why rather than leave it implied:
        // **the temporal lane runs for the 2D-ortho view too — ONE present path.**
        //
        //  1. The 2D plan view is the one whose long straight streets alias worst, and
        //     the last cure on this seam (§V2.9b) was precisely the deletion of a
        //     `flat_2d` present fork that left it as the only view with no AA at all.
        //     Re-forking here would put that defect straight back through a new door.
        //  2. Nothing about an orthographic view makes the motion vectors invalid. The
        //     velocity is a depth reprojection — unproject with an inverse VP,
        //     reproject with two others — and an ortho VP inverts and reprojects
        //     exactly as a perspective one does. It is not a perspective-only trick.
        //  3. The cost it adds to a 2D frame is two fullscreen passes (a depth read
        //     plus three mat4×vec4, and nine taps plus a mix). GFX_V2 item 7's claim
        //     is restated once more, honestly: 2D still costs nothing extra for sky,
        //     AO, shadows, reflection and glow — it costs the composite that makes
        //     radiance into a picture, and now the two passes that antialias it.
        //     `tests/postfx_bypass.rs` asserts that exact count so a *third* pass
        //     cannot creep in unnoticed.
        //
        // The one thing that IS gated is the mode: `Off` records nothing here and
        // `present` falls back to the raw LDR blit, which is the aliased control the
        // proofs measure against.
        if self.taa_mode != TaaMode::Off {
            self.velocity.record(encoder, count);
            let velocity = match self.taa_mode {
                // The mutation: identical jitter, identical resolve, no motion vectors.
                TaaMode::ZeroVelocity => None,
                _ => self.velocity.view.as_ref(),
            };
            // The resolve's `begin_render_pass` is inside `facett_core`'s `TaaPass`, so
            // it cannot go through `begin_post_pass`. Counted here instead, and said
            // out loud because it is the ONE post pass this lane does not open itself:
            // `TaaPass::record` returns whether it recorded, and the count follows that
            // answer rather than assuming it.
            if self.taa.record(device, queue, encoder, ldr, velocity) {
                *count += 1;
            }
        }
    }

    /// Blit the tonemapped LDR target into egui's `render_pass`, scissored to the
    /// widget rect (same contract as `OffscreenColorDepth::blit`). No-op when not
    /// ready or fully clipped; returns the scissor it applied.
    fn present(
        &self,
        info: &egui::PaintCallbackInfo,
        render_pass: &mut wgpu::RenderPass<'static>,
    ) -> ScissorPx {
        // Which texture holds the finished frame: the TAA lane's most recent resolve,
        // or — with the temporal lane off — the tonemapped LDR target itself. The
        // binding pair is prebuilt (see `bg_present_taa`); this only chooses.
        let bg = match (self.taa_mode, self.bg_present_taa.as_ref()) {
            (TaaMode::Off, _) | (_, None) => self.bg_present.as_ref(),
            (_, Some(pair)) => Some(&pair[self.taa.history_index() % 2]),
        };
        let Some(bg) = bg else { return ScissorPx { x: 0, y: 0, w: 0, h: 0 } };
        let s = gpu_scissor_px(info);
        if s.w == 0 || s.h == 0 {
            return s;
        }
        render_pass.set_scissor_rect(s.x, s.y, s.w, s.h);
        render_pass.set_pipeline(&self.present);
        render_pass.set_bind_group(0, bg, &[]);
        render_pass.draw(0..3, 0..1);
        s
    }
}

/// Per-frame paint callback for the depth-tested 3D map.
pub struct Map3dPaintCallback {
    /// The scene's GPU payload, re-sent EVERY frame as a refcount bump on the
    /// backend's cached `Arc` (never a copy — `GpuGeometry` is deliberately not
    /// `Clone`; the `Box` that sat here was a ~72 MB deep copy per frame at korp's
    /// 600k-triangle budget). The renderer's `upload` compares
    /// [`GpuGeometry::generation`] and touches the device only when the payload is
    /// actually new. `None` only for a host with no geometry to offer.
    pub geometry: Option<std::sync::Arc<GpuGeometry>>,
    /// **The per-frame overlay body** (V2.5 D.3), built fresh each frame by the
    /// backend from the caller's posed mesh (~100 verts). Uploaded EVERY frame into
    /// the renderer's tiny dedicated buffer — see [`Map3dRenderer::upload_overlay`];
    /// the main `geometry` cache and its generation stamp are never touched by it.
    /// `None` = no overlay this frame (and the buffer clears).
    pub overlay: Option<std::sync::Arc<GpuGeometry>>,
    /// Camera view-projection (column-major) for this frame.
    pub view_proj: [f32; 16],
    /// **Inverse** of `view_proj` (column-major) for the skybox pass — the sky
    /// fragment reconstructs each pixel's world view ray from it. Identity-ish /
    /// ignored when `sky_on` is false.
    pub inv_view_proj: [f32; 16],
    /// Whether the **skybox / atmosphere** background pass runs this frame. `true`
    /// paints the analytic gradient + sun disc; `false` keeps the legacy flat fog
    /// clear (the no-sky control + any host that opts out).
    pub sky_on: bool,
    /// World-space light direction (xyz) + ambient term (w).
    pub light: [f32; 4],
    /// Background clear colour `[r,g,b,a]` (0..1) for the offscreen pass.
    pub clear: [f32; 4],
    /// Atmospheric fog ramp `[near, far, max_strength, _]` in camera-space depth
    /// (distance-relative, computed from the camera so it matches the CPU painter).
    /// Distant faces fade toward the clear colour across `[near, far]`.
    pub fog: [f32; 4],
    /// Directional **sun colour** (rgb) + intensity (w) — the PBR direct light.
    pub sun_color: [f32; 4],
    /// **Sky / ambient** tint (rgb) + horizon-blend bias (w).
    pub sky_color: [f32; 4],
    /// World-space **eye** position (xyz, w unused) for the PBR view vector.
    pub eye: [f32; 4],
    /// PBR params: roughness (x), metallic (y), time-seconds (z), height-fog k (w).
    pub pbr: [f32; 4],
    /// **Cascaded shadow maps** (T3.3): one world→light-clip per cascade (column-major,
    /// tight→wide ortho around the scene centre). The depth pass renders the mesh
    /// through each; the main pass PCSS-samples the array.
    pub light_vp: [[f32; 16]; 3],
    /// Cascade split distances in camera-space depth `[split0→1, split1→2]`.
    pub cascade_splits: [f32; 2],
    /// PCSS light size (penumbra scale) in light-uv units.
    pub light_size: f32,
    /// Whether soft (PCSS contact-hardening) shadows run; `false` = a hard PCF control.
    pub soft_shadows: bool,
    /// Shadow strength `0..1` (how dark cast shadows get); `0` ⇒ shadows off.
    pub shadow_strength: f32,
    /// **THE 2D-ORTHO POST-FX BYPASS** (GFX_V2 items 6 + 7). `true` when the view
    /// satisfied every 2D constraint, so the whole screen-space post chain is skipped
    /// and the offscreen colour is presented directly. Derived from the view by
    /// [`SceneFlags::constrain_to_view`](crate::backend::SceneFlags::constrain_to_view),
    /// never set beside it.
    pub flat_2d: bool,
    /// **SSAO** (T3.1): whether the G-buffer + screen-space AO pass runs this frame.
    pub ssao_on: bool,
    /// **Bloom** on (T2.1): `false` ⇒ the composite adds zero bloom (flat fallback).
    pub bloom_on: bool,
    /// SSAO sample radius (world/design units) + occlusion intensity.
    pub ssao_radius: f32,
    pub ssao_intensity: f32,
    /// **Reflective water** (Phase 2): the mirror-camera view-projection for the
    /// planar reflection pass + the water sample (column-major).
    pub refl_vp: [f32; 16],
    /// Whether the **reflective water pass** runs this frame (LOD `Ground` tier +
    /// water geometry present). `false` ⇒ no water drawn (far tiers / no water).
    pub water_on: bool,
    /// Whether the **planar reflection pass** runs (`true`) or the water uses the
    /// cheaper sky-gradient reflection fallback (`false`, the wasm / far path).
    pub reflect_on: bool,
    /// Shallow water tint (rgb) + wave amplitude (w).
    pub water_shallow: [f32; 4],
    /// Deep water tint (rgb) + wave steepness (w).
    pub water_deep: [f32; 4],
    /// Water params: foam strength (x), max depth NDC (y), base wavelength (z), unused (w).
    pub water_params: [f32; 4],
    /// Pane size in physical px (the offscreen target size).
    pub px_w: u32,
    pub px_h: u32,
    /// **Physical-sky transmittance LUT** (FLIGHT-SIM #7 wiring). `Some` ⇒ upload the
    /// LUT onto the render device and sample it in the sky pass for the sun's
    /// atmospheric tint (the sun physically reddens at low elevation); `None` ⇒ the
    /// analytic sun tint (the embeddable / CPU-fallback default, L2). The `Arc` keeps
    /// the per-frame re-send a cheap refcount bump, matching the geometry cache.
    pub lut: Option<std::sync::Arc<crate::SkyTransmittanceLut>>,
}

impl egui_wgpu::CallbackTrait for Map3dPaintCallback {
    fn prepare(
        &self,
        device: &wgpu::Device,
        queue: &wgpu::Queue,
        _sd: &egui_wgpu::ScreenDescriptor,
        encoder: &mut wgpu::CommandEncoder,
        resources: &mut egui_wgpu::CallbackResources,
    ) -> Vec<wgpu::CommandBuffer> {
        let Some(r) = resources.get_mut::<Map3dRenderer>() else {
            return vec![];
        };
        if let Some(geo) = &self.geometry {
            r.upload(device, geo);
        }
        // The overlay body (V2.5 D.3): re-written every frame, cleared on None —
        // BEFORE the early returns below so leaving flight mode always clears it.
        r.upload_overlay(device, self.overlay.as_deref());
        r.offscreen.ensure(device, self.px_w, self.px_h);
        // SSAO / G-buffer targets + the resize-dependent lighting bind groups.
        r.ensure_ssao_targets(device, self.px_w, self.px_h);

        // ── THE POST + AA TARGETS, ALLOCATED HERE AND NOT AT THE POST STACK.
        // They used to be ensured just before `post.record`, which is where they are
        // used. The TAA lane moved them up, and the reason is part (a): `TaaPass`
        // owns the jitter phase, its `ensure` RESETS that phase on a resize, and the
        // phase has to be read *before* the matrices below are built — the offset must
        // be baked into where the geometry lands or the resolve is averaging frames
        // that all sampled the same position.
        if let (Some(hdr), Some(depth)) = (r.offscreen.color_view(), r.offscreen.depth_sample_view()) {
            r.post.ensure(device, hdr, Some(depth), self.px_w, self.px_h);
        }

        // ── PART (a): THE JITTERED PROJECTION, AND THE PREVIOUS FRAME'S UNJITTERED ONE.
        //
        // Everything drawn into the offscreen this frame — mesh, overlay, sky, water,
        // and the G-buffer the SSAO pass reads — is rasterised through `vp_jitter`, so
        // the whole image moves by the same sub-pixel offset and the resolve is
        // accumulating N genuinely different sample positions. The REFLECTION camera
        // (`refl_vp`) is left alone on purpose: it renders a half-res mirror that is
        // then sampled by the water shader, so a sub-pixel offset there is neither
        // visible nor reprojectable.
        //
        // `vp_cur` / `vp_prev` stay UNJITTERED. Differencing two jittered matrices
        // would report half a pixel of motion on a still camera, the resolve would
        // reproject the history off the pixel it is meant to be accumulating on, and
        // TAA would converge on nothing. `velocity.wgsl`'s header carries the full
        // derivation of which of the three matrices keeps the jitter and why.
        let taa_on = r.post.taa_mode != TaaMode::Off;
        let jitter = if taa_on { r.post.taa.jitter_px() } else { [0.0, 0.0] };
        let vp_jitter = if taa_on {
            jitter_view_proj(&self.view_proj, jitter, self.px_w, self.px_h)
        } else {
            self.view_proj
        };
        // With the lane off, the caller's own inverse is used verbatim rather than
        // re-derived — so a `TaaMode::Off` control frame is bit-for-bit the frame this
        // renderer drew before the TAA wiring, which is what makes it a control.
        let inv_vp_jitter = if taa_on {
            glam::Mat4::from_cols_array(&vp_jitter).inverse().to_cols_array()
        } else {
            self.inv_view_proj
        };
        // ── PART (b)'s uniform. `vp_prev` falls back to `vp_cur` on the first frame
        // after an install or a resize, which makes the velocity identically zero —
        // the correct answer when there is no previous frame to reproject into.
        //
        // HONEST BOUND: `prev_view_proj` is per-RENDERER, so two `Map3D` widgets
        // sharing one render state would each see the other's camera as motion. Every
        // shipped host draws one. Stated rather than discovered.
        let vel_uniforms = VelocityUniforms {
            inv_vp_jitter,
            vp_cur: self.view_proj,
            vp_prev: r.post.prev_view_proj.unwrap_or(self.view_proj),
            res: [
                self.px_w.max(1) as f32,
                self.px_h.max(1) as f32,
                1.0 / self.px_w.max(1) as f32,
                1.0 / self.px_h.max(1) as f32,
            ],
        };
        r.post.velocity.write_uniforms(queue, &vel_uniforms);
        r.post.prev_view_proj = Some(self.view_proj);
        // ── PHYSICAL-SKY TRANSMITTANCE LUT (FLIGHT-SIM #7 wiring): upload the LUT (the
        // flight crate's GPU scattering-LUT readback) onto THIS device + point the sky
        // bind group at it. `lut_ctl` drives the sky uniform's `lut` field below —
        // `[0,…]` (analytic sun tint) when absent, `[1, alt_norm,…]` when present.
        let lut_ctl = match &self.lut {
            Some(lut) => {
                r.ensure_sky_lut(device, queue, lut);
                [1.0, lut.alt_norm.clamp(0.0, 1.0), 0.0, 0.0]
            }
            None => [0.0, 0.0, 0.0, 0.0],
        };
        let ssao_on = self.ssao_on;
        // Cascade params + AO apply params shared by the main + mirror uniforms.
        let cascade = [self.cascade_splits[0], self.cascade_splits[1], self.light_size, if self.soft_shadows { 1.0 } else { 0.0 }];
        let ao_params = [self.px_w as f32, self.px_h as f32, 1.0, if ssao_on { 1.0 } else { 0.0 }];
        // IBL: integrate the analytic sky into SH-L2 (diffuse irradiance) once per
        // frame; shared by the main + mirror uniforms. Horizon = the background clear
        // (== the shader's fog.rgb), zenith = sky_color, plus the directional sun.
        let sh = crate::sky::sky_sh_l2(
            [self.light[0], self.light[1], self.light[2]],
            [self.sun_color[0], self.sun_color[1], self.sun_color[2]],
            self.sun_color[3],
            [self.sky_color[0], self.sky_color[1], self.sky_color[2]],
            [self.clear[0], self.clear[1], self.clear[2]],
        );
        let ibl = [IBL_DIFFUSE_SCALE, IBL_SPECULAR_SCALE, IBL_SPEC_AO_STRENGTH, IBL_REFLECT_GLOW];
        queue.write_buffer(
            &r.uniform,
            0,
            bytemuck::bytes_of(&Uniforms {
                view_proj: vp_jitter,
                light: self.light,
                // Fog colour = the background clear; w = max haze strength (matches
                // the CPU painter's FOG_MAX so both render paths read the same).
                fog: [self.clear[0], self.clear[1], self.clear[2], self.fog[2]],
                // x=near, y=far (camera-space depth), z=height-fog k, w=eye height.
                fog_range: [self.fog[0], self.fog[1], self.pbr[3], self.eye[1]],
                sun_color: self.sun_color,
                sky_color: self.sky_color,
                eye: self.eye,
                // pbr.w carries the SHADOW STRENGTH for the shader (the height-fog k
                // was already folded into fog_range.z above).
                pbr: [self.pbr[0], self.pbr[1], self.pbr[2], self.shadow_strength],
                light_vp: self.light_vp,
                cascade,
                ao_params,
                sh,
                ibl,
            }),
        );
        // Per-cascade depth-pass uniforms (each cascade's light-space matrix).
        for i in 0..N_CASCADES {
            queue.write_buffer(
                &r.csm_uniform[i],
                0,
                bytemuck::bytes_of(&CascadeUniform { light_vp: self.light_vp[i] }),
            );
        }
        // SSAO uniform: project/unproject matrices + eye + tuning + resolution.
        queue.write_buffer(
            &r.ssao_uniform,
            0,
            bytemuck::bytes_of(&SsaoUniforms {
                // The G-buffer this pass reads was rasterised jittered, so the
                // project/unproject pair has to be the jittered one or the AO kernel
                // walks a half-pixel off the surface it is sampling.
                view_proj: vp_jitter,
                inv_view_proj: inv_vp_jitter,
                eye: self.eye,
                // radius, bias (cosine, rejects near-tangent flat self-occlusion),
                // intensity, power.
                params: [self.ssao_radius, 0.025, self.ssao_intensity, 1.4],
                res: [
                    self.px_w as f32,
                    self.px_h as f32,
                    1.0 / self.px_w.max(1) as f32,
                    1.0 / self.px_h.max(1) as f32,
                ],
            }),
        );

        // ── SKYBOX uniform (T3.2): inverse view-projection (for the per-pixel world
        // ray) + the SunSky model. Horizon = the background/haze colour (`self.clear`)
        // — the same tint the geometry fogs toward, so sky and aerial-perspective
        // agree at the horizon line. Sun disc/glow ride in the .w channels.
        queue.write_buffer(
            &r.sky_uniform,
            0,
            bytemuck::bytes_of(&SkyUniforms {
                // Jittered too: the sky is background pixels of the same image, and a
                // sky that did not move with the geometry would be the one surface the
                // temporal lane could not converge.
                inv_view_proj: inv_vp_jitter,
                eye: self.eye,
                sun_dir: [self.light[0], self.light[1], self.light[2], SUN_DISC_INTENSITY],
                sun_color: [self.sun_color[0], self.sun_color[1], self.sun_color[2], SUN_GLOW_INTENSITY],
                sky_color: [self.sky_color[0], self.sky_color[1], self.sky_color[2], SKY_HORIZON_BIAS],
                horizon: [self.clear[0], self.clear[1], self.clear[2], SKY_GROUND_DARKEN],
                lut: lut_ctl,
            }),
        );

        // ── Reflective WATER setup (Phase 2): only when the LOD tier gates it on
        // AND there's water geometry. Allocate the (half-res) reflection target,
        // write the mirror + water uniforms, and (re)build the water bind group.
        let do_water = self.water_on && r.water_index_count > 0;
        if do_water {
            let (w, h) = (self.px_w.max(1), self.px_h.max(1));
            // Half-res reflection target (reflections tolerate it) — min 1px.
            r.reflection.ensure(device, (w / 2).max(1), (h / 2).max(1));
            // Mirror-camera uniform for the reflection mesh render (only view_proj
            // differs; reuse the main lighting so the reflected city is lit the same).
            queue.write_buffer(
                &r.mirror_uniform,
                0,
                bytemuck::bytes_of(&Uniforms {
                    view_proj: self.refl_vp,
                    light: self.light,
                    fog: [self.clear[0], self.clear[1], self.clear[2], self.fog[2]],
                    fog_range: [self.fog[0], self.fog[1], self.pbr[3], self.eye[1]],
                    sun_color: self.sun_color,
                    sky_color: self.sky_color,
                    eye: self.eye,
                    pbr: [self.pbr[0], self.pbr[1], self.pbr[2], 0.0], // no shadows in the reflection
                    light_vp: self.light_vp,
                    cascade,
                    // AO off in the reflection (w = 0) — cheap + the mirror tolerates it.
                    ao_params: [self.px_w as f32, self.px_h as f32, 1.0, 0.0],
                    sh,
                    ibl,
                }),
            );
            queue.write_buffer(
                &r.water_uniform,
                0,
                bytemuck::bytes_of(&WaterUniforms {
                    // The water grid draws into the same offscreen as the city, so it
                    // takes the same sub-pixel offset — otherwise the shoreline and the
                    // buildings would be sampled half a pixel apart.
                    view_proj: vp_jitter,
                    refl_vp: self.refl_vp,
                    eye: [self.eye[0], self.eye[1], self.eye[2], if self.reflect_on { 1.0 } else { 0.0 }],
                    sun: self.light,
                    sun_color: [self.sun_color[0], self.sun_color[1], self.sun_color[2], self.pbr[2]],
                    sky_color: [self.sky_color[0], self.sky_color[1], self.sky_color[2], self.water_params[0]],
                    shallow: self.water_shallow,
                    deep: self.water_deep,
                    params: [self.fog[0], self.fog[1], self.water_params[1], self.water_params[2]],
                }),
            );
            // (Re)build the water bind group against the live reflection + depth views.
            let rebuild = r.water_bind.is_none() || r.water_bind_size != r.offscreen.size();
            if rebuild {
                if let (Some(refl_color), Some(scene_depth)) =
                    (r.reflection.color_view(), r.offscreen.depth_sample_view())
                {
                    let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
                        label: Some("map3d_water_bind"),
                        layout: &r.water_bgl,
                        entries: &[
                            wgpu::BindGroupEntry { binding: 0, resource: r.water_uniform.as_entire_binding() },
                            wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(refl_color) },
                            wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&r.water_sampler) },
                            wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(scene_depth) },
                        ],
                    });
                    r.water_bind = Some(bind);
                    r.water_bind_size = r.offscreen.size();
                }
            }
        }

        let (Some(vbuf), Some(color_view), Some(depth_view)) =
            (&r.vbuf, r.offscreen.color_view(), r.offscreen.depth_view())
        else {
            return vec![];
        };
        if r.index_count == 0 {
            return vec![];
        }

        // **THE POST-PASS TALLY** (GFX_V2 item 7). Every screen-space post pass below
        // is opened through `begin_post_pass`, which bumps this. Stored on the renderer
        // at the end of `prepare` so a host or a test can read what this frame actually
        // recorded — `0` is the 2D-ortho bypass, and it is an assertion, not a comment.
        let mut post_passes: u32 = 0;

        // ── THE IN-GPU PASS CLOCK (perf/v2.3): opening timestamp. ─────────────
        // Placed AFTER every early return above, so an unmeasurable frame (no mesh,
        // no targets) never arms a slot — and before the first pass below, so the
        // window covers everything this lane records: shadow → G-buffer/SSAO →
        // reflection → sky → main → water → post. The closing bracket sits after
        // `post.record`. On a device without TIMESTAMP_QUERY the slot probes once,
        // records `gpu_timestamps = Some(false)` for this lane (an About box / korp
        // reads `None`, never a fake 0), and this line is one enum check.
        r.clock_frame += 1;
        let clock_frame = r.clock_frame;
        if let Some(clock) = r.pass_clock.get_or_init(device, queue, lane_name()) {
            clock.begin(device, encoder, clock_frame);
        }

        // ── CASCADED SHADOW DEPTH PASS (T3.3): render the mesh from the SUN into each
        // cascade's array layer (depth only; the main fragment shader PCSS-samples
        // these for contact-hardening soft shadows). Skipped when shadows are off
        // (shadow_strength == 0) so the wasm/cheap path + the no-shadow control don't
        // pay for it.
        if self.shadow_strength > 0.0 {
            for c in 0..N_CASCADES {
                let mut sp = begin_post_pass(&mut post_passes, encoder, &wgpu::RenderPassDescriptor {
                    label: Some("map3d_shadow_pass"),
                    color_attachments: &[],
                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
                        view: &r.shadow_attach[c],
                        depth_ops: Some(wgpu::Operations {
                            load: wgpu::LoadOp::Clear(1.0),
                            store: wgpu::StoreOp::Store,
                        }),
                        stencil_ops: None,
                    }),
                    timestamp_writes: None,
                    occlusion_query_set: None,
                    multiview_mask: None,
                });
                sp.set_pipeline(&r.shadow_pipeline);
                sp.set_bind_group(0, &r.csm_bind[c], &[]);
                sp.set_vertex_buffer(0, vbuf.slice(..));
                sp.draw(0..r.index_count, 0..1);
                drop(sp);
            }
        }

        // ── G-BUFFER NORMAL PREPASS + SSAO (T3.1): render the mesh normals+depth into
        // the gbuffer, then compute screen-space ambient occlusion into the AO target
        // the main pass modulates the ambient term by. Skipped when SSAO is off.
        if ssao_on {
            if let (Some(gn), Some(gd)) = (r.gbuffer.color_view(), r.gbuffer.depth_view()) {
                let mut gp = begin_post_pass(&mut post_passes, encoder, &wgpu::RenderPassDescriptor {
                    label: Some("map3d_gbuffer_pass"),
                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                        view: gn,
                        resolve_target: None,
                        depth_slice: None,
                        // Clear normal to 0 + depth-alpha to the far plane (1.0) so the
                        // SSAO shader reads background where no geometry drew.
                        ops: wgpu::Operations {
                            load: wgpu::LoadOp::Clear(wgpu::Color { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }),
                            store: wgpu::StoreOp::Store,
                        },
                    })],
                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
                        view: gd,
                        depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Store }),
                        stencil_ops: None,
                    }),
                    timestamp_writes: None,
                    occlusion_query_set: None,
                    multiview_mask: None,
                });
                gp.set_pipeline(&r.gbuf_pipeline);
                gp.set_bind_group(0, &r.gbuf_bind, &[]);
                gp.set_vertex_buffer(0, vbuf.slice(..));
                gp.draw(0..r.index_count, 0..1);
                drop(gp);
            }
            if let (Some(ao_view), Some(ssao_bind)) = (r.ao_view.as_ref(), r.ssao_bind.as_ref()) {
                let mut ap = begin_post_pass(&mut post_passes, encoder, &wgpu::RenderPassDescriptor {
                    label: Some("map3d_ssao_pass"),
                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                        view: ao_view,
                        resolve_target: None,
                        depth_slice: None,
                        ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::WHITE), store: wgpu::StoreOp::Store },
                    })],
                    depth_stencil_attachment: None,
                    timestamp_writes: None,
                    occlusion_query_set: None,
                    multiview_mask: None,
                });
                ap.set_pipeline(&r.ssao_pipeline);
                ap.set_bind_group(0, ssao_bind, &[]);
                ap.draw(0..3, 0..1);
                drop(ap);
            }
        }

        // ── REFLECTION PASS (Phase 2 planar reflection): re-render the opaque mesh
        // with the MIRROR camera (reflected about the water plane) into the half-res
        // reflection target. The water fragment samples this for the mirrored skyline.
        if do_water && self.reflect_on {
            if let (Some(rc), Some(rd), Some(mirror_bind)) =
                (r.reflection.color_view(), r.reflection.depth_view(), r.mirror_bind.as_ref())
            {
                let mut rp = begin_post_pass(&mut post_passes, encoder, &wgpu::RenderPassDescriptor {
                    label: Some("map3d_reflection_pass"),
                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                        view: rc,
                        resolve_target: None,
                        depth_slice: None,
                        ops: wgpu::Operations {
                            load: wgpu::LoadOp::Clear(wgpu::Color {
                                r: self.sky_color[0] as f64,
                                g: self.sky_color[1] as f64,
                                b: self.sky_color[2] as f64,
                                a: 1.0,
                            }),
                            store: wgpu::StoreOp::Store,
                        },
                    })],
                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
                        view: rd,
                        depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Store }),
                        stencil_ops: None,
                    }),
                    timestamp_writes: None,
                    occlusion_query_set: None,
                    multiview_mask: None,
                });
                rp.set_pipeline(&r.mesh_pipeline);
                rp.set_bind_group(0, mirror_bind, &[]);
                rp.set_vertex_buffer(0, vbuf.slice(..));
                rp.draw(0..r.index_count, 0..1);
                drop(rp);
            }
        }

        // ── SKYBOX / ATMOSPHERE pass (T3.2): paint the analytic gradient + HDR sun
        // disc into the offscreen colour FIRST, so the background is a real sky the
        // bloom + tonemap catch (not the flat fog clear). The mesh pass below then
        // LOADs this colour and draws the lit city over it. Skipped when `sky_on` is
        // false (the no-sky control / opt-out), in which case the mesh pass clears to
        // the legacy flat fog colour instead.
        if self.sky_on {
            let mut skp = begin_post_pass(&mut post_passes, encoder, &wgpu::RenderPassDescriptor {
                label: Some("map3d_sky_pass"),
                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                    view: color_view,
                    resolve_target: None,
                    depth_slice: None,
                    // Cleared anyway by the fullscreen triangle; CLEAR keeps it valid
                    // on tiles the triangle's conservative raster might miss.
                    ops: wgpu::Operations {
                        load: wgpu::LoadOp::Clear(wgpu::Color {
                            r: self.clear[0] as f64,
                            g: self.clear[1] as f64,
                            b: self.clear[2] as f64,
                            a: self.clear[3] as f64,
                        }),
                        store: wgpu::StoreOp::Store,
                    },
                })],
                depth_stencil_attachment: None,
                timestamp_writes: None,
                occlusion_query_set: None,
                multiview_mask: None,
            });
            skp.set_pipeline(&r.sky_pipeline);
            skp.set_bind_group(0, &r.sky_bind, &[]);
            skp.draw(0..3, 0..1);
            drop(skp);
        }

        // ── the offscreen depth-tested pass: the genuine Z-buffer occlusion ──
        // Colour LOADs when the sky pass already filled the background; otherwise it
        // CLEARs to the flat fog colour (legacy / no-sky path).
        let color_load = if self.sky_on {
            wgpu::LoadOp::Load
        } else {
            wgpu::LoadOp::Clear(wgpu::Color {
                r: self.clear[0] as f64,
                g: self.clear[1] as f64,
                b: self.clear[2] as f64,
                a: self.clear[3] as f64,
            })
        };
        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
            label: Some("map3d_offscreen_pass"),
            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                view: color_view,
                resolve_target: None,
                depth_slice: None,
                ops: wgpu::Operations {
                    load: color_load,
                    store: wgpu::StoreOp::Store,
                },
            })],
            depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
                view: depth_view,
                depth_ops: Some(wgpu::Operations {
                    load: wgpu::LoadOp::Clear(1.0), // far plane
                    store: wgpu::StoreOp::Store,
                }),
                stencil_ops: None,
            }),
            timestamp_writes: None,
            occlusion_query_set: None,
            multiview_mask: None,
        });
        if let Some(mesh_bind) = r.mesh_bind.as_ref() {
            let pipeline = if r.no_depth_for_test { &r.mesh_pipeline_no_depth } else { &r.mesh_pipeline };
            pass.set_pipeline(pipeline);
            pass.set_bind_group(0, mesh_bind, &[]);
            pass.set_vertex_buffer(0, vbuf.slice(..));
            pass.draw(0..r.index_count, 0..1);
            // ── the per-frame overlay body (V2.5 D.3): same pipeline, same uniforms,
            // its own tiny buffer — depth-tested against the city it flies over. It is
            // drawn ONLY here: no shadow cast, no G-buffer/SSAO footprint, no mirror in
            // the water (a ~100-vert avatar earns none of those passes; documented, not
            // forgotten).
            if r.overlay_count > 0 {
                if let Some(ovb) = r.overlay_vbuf.as_ref() {
                    pass.set_vertex_buffer(0, ovb.slice(..));
                    pass.draw(0..r.overlay_count, 0..1);
                }
            }
        }
        drop(pass);

        // ── WATER PASS (Phase 2): draw the Gerstner-displaced water grid OVER the
        // opaque scene into the same offscreen colour (alpha-blended; no depth
        // attachment — the shader samples the offscreen depth for the depth-tint +
        // a manual occlusion discard). Runs only when the LOD tier gated it on.
        if do_water {
            if let (Some(wv), Some(wi), Some(wb)) =
                (r.water_vbuf.as_ref(), r.water_ibuf.as_ref(), r.water_bind.as_ref())
            {
                let mut wp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
                    label: Some("map3d_water_pass"),
                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                        view: color_view,
                        resolve_target: None,
                        depth_slice: None,
                        ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store },
                    })],
                    depth_stencil_attachment: None,
                    timestamp_writes: None,
                    occlusion_query_set: None,
                    multiview_mask: None,
                });
                wp.set_pipeline(&r.water_pipeline);
                wp.set_bind_group(0, wb, &[]);
                wp.set_vertex_buffer(0, wv.slice(..));
                wp.set_index_buffer(wi.slice(..), wgpu::IndexFormat::Uint32);
                wp.draw_indexed(0..r.water_index_count, 0, 0..1);
                drop(wp);
            }
        }

        // ── HDR POST STACK (T2.1): the scene + water have filled the Rgba16Float
        // offscreen with linear radiance. Run bright-pass → bloom → ACES tonemap
        // into the LDR present target; `paint` then blits that into egui. Disjoint
        // field borrows: `hdr` reads r.offscreen while r.post is mutated.
        //
        // **THE 2D-ORTHO BYPASS, CORRECTED** (GFX_V2 items 6 + 7, then §V2.9b). `flat_2d`
        // means the view satisfied every 2D constraint, so `SceneFlags::constrain_to_view`
        // already cleared sky / ssao / shadows / reflection / BLOOM — and every one of
        // those passes is gated on its own flag, so all of them are skipped already.
        //
        // What the original bypass ALSO skipped was the composite, and that was the
        // defect. Its stated reason — "a 2D ortho map's colours are already
        // display-referred (theme colours in 0..1, no HDR range), so there is nothing for
        // an exposure + ACES tonemap to compress" — was TRUE while the vertex stream
        // carried sRGB code values, and `f8772ad1` correctly made it carry LINEAR albedo
        // instead. From that commit the offscreen held linear radiance, the bypass blitted
        // it raw, and the plan view shipped about a gamma too dark for two and a half
        // weeks (measured 2026-08-05 → 2026-08-24: luma spread 3.30 against 14.70 before
        // `f8772ad1`, against a 3D control of 70.00, RTX 4090 / Vulkan).
        //
        // So the composite is no longer optional: it is the display transform, not an
        // effect. `bloom_on` (false for every 2D view) skips the three bright/blur passes
        // instead, which is where the actual saving always was. A 2D frame now records
        // exactly ONE post pass, and GFX_V2 item 7's claim is restated honestly: 2D costs
        // nothing extra for sky, AO, shadows, reflection and glow — it still costs the
        // one pass that makes a buffer of radiance into a picture.
        //
        // The two are coupled by `constrain_to_view` and nothing else enforces it, so say
        // so where it would break: a 2D view that somehow arrived with bloom ON would pay
        // for three passes whose result it cannot show.
        debug_assert!(
            !self.flat_2d || !self.bloom_on,
            "a 2D-ortho view must arrive with bloom cleared (SceneFlags::constrain_to_view)"
        );
        // (`post.ensure` ran at the top of `prepare` — see the note there: the TAA
        // lane's allocation owns the jitter phase, so it has to happen before the
        // matrices this frame's geometry was rasterised with were built.)
        r.post.write_uniforms(queue, self.px_w, self.px_h, self.bloom_on);
        r.post.record(device, queue, encoder, &mut post_passes, self.bloom_on);
        // ── Pass clock, closing bracket: timestamp + resolve + 16-byte copy into
        // the readback ring. Non-blocking — the sample is read a couple of frames
        // from now; the frame never waits on it.
        if let Some(clock) = r.pass_clock.ready_mut() {
            clock.end(encoder);
        }
        r.post_passes = post_passes;
        // **THIS LANE NOW COUNTS ITS FRAMES.** Reported live 2026-08-08, korp-ui on a 4090:
        //
        //     GPU lane: unknown — 1 installed lane(s)
        //     (CloudRenderer, Map3dRenderer, GpuMapRenderer) do not report frames
        //
        // `Map3dRenderer` was the silent one. Its two neighbours both call
        // `note_reports_frames` at install and `note_frame` where they encode; this lane
        // promised nothing, so `fold_lane_use` could never say more than "unknown" for the
        // WHOLE registry — one silent lane degrades the verdict for all of them. The 3D map
        // is exactly the lane a person asking "am I on the GPU?" means, and it was the one
        // that could not answer.
        //
        // Counted HERE, at the end of `prepare`, because this is where the work is genuinely
        // encoded: geometry uploaded, offscreen targets live, G-buffer and post passes
        // recorded. The early return above (no `Map3dRenderer` in the callback resources —
        // the harness case the CPU painter covers) does not reach it, which is the
        // distinction the counter has to make.
        //
        // Lane ATTRIBUTION, not correctness: a frame of nothing still bumps it. It answers
        // *who painted*, which no pixel diff can, and it is only meaningful beside one.
        facett_core::render::lane::note_frame(lane_name());
        vec![]
    }

    fn paint(
        &self,
        info: egui::PaintCallbackInfo,
        render_pass: &mut wgpu::RenderPass<'static>,
        resources: &egui_wgpu::CallbackResources,
    ) {
        let Some(r) = resources.get::<Map3dRenderer>() else { return };
        if r.index_count == 0 {
            return;
        }
        // ── FIX (a): scissor the GPU pass to the widget rect (the GPU twin of the
        // CPU painter's `clip_poly_to_rect`), then sample the depth-resolved
        // offscreen colour into egui's pass. Both are now the shared L0
        // `OffscreenColorDepth::blit` — it computes the widget viewport ∩ clip_rect
        // scissor (so NOTHING the 3D pass paints lands outside the component,
        // independent of the host's clip-rect batching — `scissor` step 7 on the GPU
        // lane) and draws the fullscreen-triangle blit. A no-op when fully clipped.
        // ONE PRESENT PATH (GFX_V2 §V2.9b). This used to fork on `flat_2d` and blit the
        // raw offscreen for a 2D view, because `prepare` had skipped the whole post
        // chain — and that fork carried two defects at once: the 2D frame arrived
        // UNTONEMAPPED (linear radiance straight to the surface: measured 3.30 luma
        // spread against 14.70, RTX 4090), and it skipped FXAA, so the one view whose
        // long straight streets alias worst was the only view with no edge AA at all.
        // `prepare` now always records the composite, so there is exactly one target
        // that can hold the frame and nothing left for the two halves to disagree about.
        let _scissor = r.post.present(&info, render_pass);
    }
}

/// **Screen-space post passes the last frame recorded** for the installed renderer.
///
/// The device-side answer to "does a 2D-ortho view really cost nothing extra?" — `0`
/// when the bypass engaged, and the full chain's count when it did not. Counted at the
/// `begin_render_pass` call site by [`begin_post_pass`], the one function that opens a
/// post pass; see its doc for what that does and does not claim.
///
/// `None` when no renderer is installed, which is a different answer from `Some(0)`:
/// nothing ran at all, versus the chain ran zero passes.
#[must_use]
pub fn post_passes_recorded(render_state: &egui_wgpu::RenderState) -> Option<u32> {
    render_state
        .renderer
        .read()
        .callback_resources
        .get::<Map3dRenderer>()
        .map(|r| r.post_passes)
}

/// **The `WgpuSetup` a facett render PROOF should build its harness with**, as an
/// override of the harness default.
///
/// THE FINDING THIS EXISTS FOR. `egui_kittest::wgpu::default_wgpu_setup()` installs an
/// adapter selector whose own comment reads *"Prefer software rasterizers"* — so every
/// facett render proof that calls it runs on **llvmpipe**, on the CPU, and none of them
/// said so. Measured on oden: `llvmpipe (LLVM 21.1.8, 256 bits) (Vulkan, Cpu)`, while an
/// RTX 4090 in the same box answered facett-core's `request_best_adapter` tests. That is
/// not a bug upstream — a software rasteriser is bit-deterministic, which is what a
/// snapshot suite wants — but it changes what those proofs are evidence ABOUT: they show
/// the WGSL is correct under llvmpipe, not on the hardware the app ships on.
///
/// **The policy is the DEFAULT since 2026-08-31**, and the naive harness setup is the
/// thing you now have to ask for:
///
/// * `Some(..)` (the default) — facett's OWN policy
///   ([`facett_wgpu_options`](facett_core::render::gpu::facett_wgpu_options)), which ranks
///   a discrete GPU first and sinks llvmpipe last: the selector the real app uses;
/// * `None` with `FACETT_TEST_GPU=default` — `egui_kittest`'s own setup, i.e. whatever
///   wgpu's internal ordering hands back.
///
/// It used to be the other way round — the policy behind `FACETT_TEST_GPU=discrete`, the
/// naive path by default — with the reasoning that existing luma thresholds "keep the
/// device they were calibrated against". MEASURED 2026-08-31, that reasoning cost more
/// than it bought: in a whole-workspace run `tests/postfx_bypass.rs` printed
/// `device: llvmpipe (LLVM 21.1.8, 256 bits) (Vulkan, Cpu)` and reported luma numbers off
/// a CPU rasteriser. A correct adapter that nobody selects is not a policy, it is a note;
/// and an opt-in that the suite never sets is off in exactly the runs that matter.
///
/// Nothing was actually calibrated against llvmpipe: re-running the same two failures on
/// the 4090 reproduced them to two decimal places (`luma spread 6.65` vs `6.66`,
/// `cull span 0.27018228` both times), so flipping the default changed no verdict — it
/// only made the verdict mean what it says.
///
/// Returning an override rather than a whole setup keeps `egui_kittest` (a
/// dev-dependency) out of the library; a proof writes
/// `proof_wgpu_setup().unwrap_or_else(default_wgpu_setup)`.
///
/// A proof should still print `render_state.adapter.get_info()` — and now ASSERT on it
/// with [`refuse_software_adapter`](facett_core::render::gpu::refuse_software_adapter). A
/// number measured on a CPU rasteriser and reported as a GPU number is exactly the kind of
/// claim this track keeps having to retract, and a printed adapter is a line a human has
/// to notice.
#[must_use]
pub fn proof_wgpu_setup() -> Option<egui_wgpu::WgpuSetup> {
    (std::env::var("FACETT_TEST_GPU").as_deref() != Ok("default"))
        .then(|| facett_core::render::gpu::facett_wgpu_options().wgpu_setup)
}

/// Install a [`Map3dRenderer`] into an egui-wgpu `RenderState`'s callback
/// resources (call once at host startup, like facett-map's `install_renderer`).
pub fn install_renderer(render_state: &egui_wgpu::RenderState) {
    // The shared install-once writer (LAW #5) — it also registers this lane with
    // `facett_core::render::lane`, so an About box can report that a GPU lane exists.
    let _ = facett_core::render::gpu::install_renderer(render_state, Map3dRenderer::new);
    // This lane COUNTS its frames (see `Map3dPaintCallback::prepare`), so a zero means
    // "installed and never painted" rather than "did not say". Without this promise
    // `fold_lane_use` degrades the verdict for the ENTIRE registry to `unknown` — what a
    // 4090 was reporting on 2026-08-08 while two other lanes counted happily.
    facett_core::render::lane::note_reports_frames(lane_name());
}

/// **The lane name `install_renderer` registers under** — `std::any::type_name` of the
/// renderer, which is what the shared install writer passes to
/// [`facett_core::render::lane::note_installed`]. Derived, never a literal, so a rename of
/// the type moves both sides at once.
#[must_use]
pub fn lane_name() -> &'static str {
    std::any::type_name::<Map3dRenderer>()
}

/// **This lane's GPU clocks** (perf/v2.3) — the tail of a host's mousewheel→GPU
/// TRACE line: `{last_upload_us, last_upload_bytes, uploads_total}` from the
/// generation-stamped replace-on-change upload, and `{last_gpu_pass_us,
/// gpu_timestamps}` from the TIMESTAMP_QUERY bracket around everything `prepare`
/// records (shadow → G-buffer/SSAO → reflection → sky → main → water → post).
///
/// Honesty notes a caller must not paper over:
/// * `last_gpu_pass_us == None` with `gpu_timestamps == Some(false)` means the
///   device cannot measure — print "n/a", never `0`;
/// * the final egui-pass blit (`paint`) is outside the window (egui owns that pass);
/// * a sample resolves a few frames after the passes ran (non-blocking ring);
/// * re-sending an unchanged payload is NOT an upload event — `uploads_total`
///   counts re-bakes that moved bytes.
#[must_use]
pub fn lane_clock_stats() -> facett_core::render::lane::LaneClockStats {
    facett_core::render::lane::clock_stats_of(lane_name())
}

/// **Has a [`Map3dRenderer`] been installed in this process?**
///
/// # The blindness this closes
///
/// [`crate::backend::WgpuVelloBackend::paint`] used to report `used_gpu = true` for any
/// frame in which it ran, and it runs whether or not a renderer exists to dispatch to: the
/// paint callback simply no-ops. MEASURED 2026-08-06 on an RTX 4090 [DiscreteGpu/Vulkan]:
/// `with_gpu(true)` and **no** `install_renderer` still reported `used_gpu = Some(true)`.
///
/// The two halves of the GPU path are useless apart — that sentence is already in korp's
/// own comment at the install site — and the observable that exists to tell them apart
/// could not see the second one missing. "The install is not reaching" was the leading
/// theory for a flat 3D pane for most of that day, and the field everyone was reading would
/// have said `true` either way.
///
/// # Honest bound
///
/// The lane registry is per-PROCESS, not per-`RenderState`. So this answers "a
/// `Map3dRenderer` was installed somewhere in this process", which is exactly right for a
/// host with one window (every shipped one) and would over-report for a host holding two
/// render states where only one had the install. That is a real limit and it is a far
/// smaller one than always answering `true`.
#[must_use]
pub fn renderer_installed() -> bool {
    let name = lane_name();
    facett_core::render::lane::lanes().iter().any(|l| l.name == name && l.installed)
}

/// **Frames the temporal lane has resolved** on the installed renderer.
///
/// The observable that separates "the resolve ran" from "the picture happens to look
/// smooth". `Some(0)` means the lane is installed but has resolved nothing — which is
/// exactly what [`TaaMode::Off`] must report and what a wiring that silently fell
/// through would report too. `None` means no renderer at all, a different answer.
#[must_use]
pub fn taa_frames_resolved(render_state: &egui_wgpu::RenderState) -> Option<u64> {
    render_state
        .renderer
        .read()
        .callback_resources
        .get::<Map3dRenderer>()
        .map(|r| r.post.taa.frame())
}

/// Test hook: install the renderer with an explicit [`TaaMode`], so a proof can measure
/// the shipped path against its two controls on the same scene and device.
///
/// Unlike [`install_renderer`] this **replaces** any renderer already present — the
/// mode is the thing being varied, so an install-once would silently hand the second
/// arm of a comparison the first arm's renderer.
#[doc(hidden)]
pub fn install_renderer_with_taa_for_test(render_state: &egui_wgpu::RenderState, mode: TaaMode) {
    let mut r = Map3dRenderer::new(&render_state.device, render_state.target_format);
    r.post.taa_mode = mode;
    render_state.renderer.write().callback_resources.insert(r);
    facett_core::render::lane::note_installed(lane_name());
    facett_core::render::lane::note_reports_frames(lane_name());
}

/// Test hook: read back the **motion vectors the last frame computed**, as
/// `(width, height, UV-per-frame velocity in row-major order)`.
///
/// The field is what the resolve reprojected with, off the device — so a proof can warp
/// the previous frame by it and check that the result lands on this one, rather than
/// re-deriving the same matrix arithmetic in Rust and comparing a formula with itself.
/// `None` when no renderer is installed or the target has not been allocated.
#[doc(hidden)]
#[must_use]
pub fn read_velocity_for_test(
    render_state: &egui_wgpu::RenderState,
) -> Option<(u32, u32, Vec<[f32; 2]>)> {
    let guard = render_state.renderer.read();
    let r = guard.callback_resources.get::<Map3dRenderer>()?;
    let tex = r.post.velocity.target.as_ref()?;
    let (w, h) = (tex.width(), tex.height());
    let bytes = facett_core::render::gpu::read_texture_region(
        &render_state.device,
        &render_state.queue,
        tex,
        4, // Rg16Float
        0,
        0,
        w,
        h,
    );
    if bytes.len() < (w as usize) * (h as usize) * 4 {
        return None;
    }
    let px = bytes
        .as_chunks::<4>().0.iter()
        .map(|c| {
            [
                f16_to_f32(u16::from_le_bytes([c[0], c[1]])),
                f16_to_f32(u16::from_le_bytes([c[2], c[3]])),
            ]
        })
        .collect();
    Some((w, h, px))
}

/// IEEE-754 binary16 → binary32. Spelled out rather than pulled in: the ONLY consumer
/// is [`read_velocity_for_test`], and a proof crate is the wrong place to acquire a
/// dependency. Handles the three cases a velocity field can actually contain —
/// subnormal (a sub-hundredth-of-a-pixel motion), normal, and zero — plus inf/NaN for
/// completeness.
fn f16_to_f32(h: u16) -> f32 {
    let sign = u32::from(h & 0x8000) << 16;
    let exp = (h >> 10) & 0x1f;
    let mant = u32::from(h & 0x03ff);
    let bits = match exp {
        0 if mant == 0 => sign,
        // Subnormal: renormalise by shifting the mantissa up until its implicit bit
        // appears, decrementing the exponent for each shift.
        0 => {
            let mut m = mant;
            let mut e: i32 = -1;
            while m & 0x0400 == 0 {
                m <<= 1;
                e -= 1;
            }
            let e = (127 - 15 + e + 1) as u32;
            sign | (e << 23) | ((m & 0x03ff) << 13)
        }
        0x1f => sign | 0x7f80_0000 | (mant << 13),
        _ => sign | ((u32::from(exp) + 127 - 15) << 23) | (mant << 13),
    };
    f32::from_bits(bits)
}

/// Test hook: install the renderer with the **depth test DISABLED** (negative
/// control) so the self-test can prove, on the same scene + device, that the
/// Z-buffer is what produces the occlusion. NOT for production use.
#[doc(hidden)]
pub fn install_renderer_no_depth_for_test(render_state: &egui_wgpu::RenderState) {
    let mut r = Map3dRenderer::new(&render_state.device, render_state.target_format);
    r.no_depth_for_test = true;
    render_state.renderer.write().callback_resources.insert(r);
}

#[cfg(test)]
mod tests {
    use super::*;

    /// **GPU-LESS RED-when-broken proof for the native-vs-browser sRGB fix.**
    ///
    /// The HDR post stack emits display-referred colour that must be sRGB-encoded on
    /// the way to the surface. A native desktop picks an `*-srgb` surface (the GPU
    /// encodes on write); the **browser WebGPU** canvas is the non-sRGB `Bgra8Unorm`,
    /// so nothing encodes and the render came out dark/muddy-brown. The present pass
    /// must therefore encode in-shader **iff** the target is not sRGB.
    ///
    /// This asserts (a) the format→flag decision (`1.0` for the browser `Bgra8Unorm`,
    /// `0.0` for the native `Bgra8UnormSrgb`), and (b) that `present.wgsl` actually
    /// reads the flag and applies the encode. Flip either — drop the `is_srgb` branch,
    /// or stop gating the encode on `U.texel.z` — and this goes RED with no GPU.
    ///
    /// It read `fxaa.wgsl` until the TAA wiring deleted that file. The sRGB gate is a
    /// property of the SURFACE, not of the antialiasing, so it moved to the present
    /// pass intact and this half of the proof moved with it.
    #[test]
    fn present_encode_srgb_matches_surface_and_shader_gates_it() {
        // (a) The format→flag decision. Browser WebGPU surfaces are non-sRGB → encode.
        assert_eq!(
            present_encode_srgb(wgpu::TextureFormat::Bgra8Unorm), 1.0,
            "the browser WebGPU surface (Bgra8Unorm) is NOT sRGB → the shader must encode",
        );
        assert_eq!(
            present_encode_srgb(wgpu::TextureFormat::Rgba8Unorm), 1.0,
            "a non-sRGB Rgba8Unorm surface must be encoded in-shader too",
        );
        // Native desktop surfaces are `*-srgb` → the hardware encodes → shader must NOT.
        assert_eq!(
            present_encode_srgb(wgpu::TextureFormat::Bgra8UnormSrgb), 0.0,
            "the native Bgra8UnormSrgb surface encodes in hardware → shader stays linear",
        );
        assert_eq!(
            present_encode_srgb(wgpu::TextureFormat::Rgba8UnormSrgb), 0.0,
            "any *-srgb surface leaves the encode to the hardware",
        );

        // (b) The shader wiring: the present shader declares the encode and gates it on
        // the `U.texel.z` flag the uniform carries (see `write_uniforms`).
        let wgsl = include_str!("present.wgsl");
        assert!(wgsl.contains("fn linear_to_srgb"), "the present pass declares the sRGB encode");
        assert!(wgsl.contains("U.texel.z > 0.5"), "the encode is gated on the non-sRGB flag");
        assert!(wgsl.contains("present_encode("), "the presented colour is routed through the gated encode");

        #[cfg(feature = "testmatrix")]
        facett_core::testmatrix::emit(
            "facett-map3d",
            "present_srgb_parity",
            true,
            "browser_bgra8unorm_flag=1.0 native_bgra8unormsrgb_flag=0.0 shader_gated=true",
        );
    }

    /// **GPU-LESS RED-when-broken wiring proof** for the physical-sky transmittance
    /// LUT (FLIGHT-SIM #7). Needs NO adapter: it asserts the CPU-side contract the
    /// sky pass depends on — the uniform carries the LUT-control field at the right
    /// size, and `sky.wgsl` actually declares the LUT texture/sampler bindings and
    /// samples them gated on the flag. Break any of those (drop the `lut` uniform
    /// field, delete a binding, or stop sampling the LUT) and this goes RED before a
    /// GPU is ever touched, so the on-screen wiring can't silently rot.
    #[test]
    fn sky_lut_wiring_registered_and_uniform_sized() {
        // The sky uniform grew by exactly one vec4 (the `lut` control) → still 16-byte
        // aligned, and the `lut` field is the last 16 bytes.
        assert_eq!(std::mem::size_of::<SkyUniforms>() % 16, 0, "SkyUniforms stays vec4-aligned");
        assert_eq!(
            std::mem::size_of::<SkyUniforms>(),
            std::mem::offset_of!(SkyUniforms, lut) + std::mem::size_of::<[f32; 4]>(),
            "the LUT control vec4 is the final field"
        );
        // The WGSL declares the LUT bindings + samples them, gated on the on-flag.
        let wgsl = include_str!("sky.wgsl");
        assert!(wgsl.contains("lut:        vec4<f32>"), "sky uniform mirrors the `lut` control field");
        assert!(wgsl.contains("@group(0) @binding(1) var lut_tex: texture_2d<f32>"), "LUT texture binding present");
        assert!(wgsl.contains("@group(0) @binding(2) var lut_samp: sampler"), "LUT sampler binding present");
        assert!(wgsl.contains("textureSampleLevel(lut_tex, lut_samp"), "the sun tint samples the LUT");
        assert!(wgsl.contains("S.lut.x > 0.5"), "the LUT sample is gated on the on-flag (analytic fallback kept)");
        // The public seam packs RGB readback → RGBA texels the renderer uploads.
        let lut = crate::SkyTransmittanceLut::from_rgb(2, 1, &[[0.9, 0.4, 0.2], [1.0, 1.0, 1.0]], 0.0);
        assert_eq!(lut.mu_size, 2);
        assert_eq!(lut.h_size, 1);
        assert_eq!(lut.texels.len(), 2, "row-major h×mu texels");
        assert_eq!(lut.texels[0], [0.9, 0.4, 0.2, 1.0], "RGB padded to RGBA with a=1");
        #[cfg(feature = "testmatrix")]
        facett_core::testmatrix::emit(
            "facett-map3d",
            "sky_lut_wiring",
            true,
            &format!(
                "registered=true sky_uniform_bytes={} lut_offset={} bindings=3 gated=true",
                std::mem::size_of::<SkyUniforms>(),
                std::mem::offset_of!(SkyUniforms, lut),
            ),
        );
    }

    /// Physically-based exposure (Filament) — pins the VERIFIED constant so a
    /// regression in the EV100 → exposure transfer is caught deterministically.
    /// Filament `Exposure.cpp`: exposure = 1/(1.2·2^EV100); EV100 = log2(N²/t·100/S).
    #[test]
    fn ev100_exposure_matches_filament() {
        // Sunny-16: f/16, 1/125 s, ISO 100 → EV100 ≈ 15.0 (textbook full-sun value).
        let ev = ev100_from_camera(16.0, 1.0 / 125.0, 100.0);
        assert!((ev - 14.966).abs() < 0.01, "sunny-16 EV100 ≈ 15 (got {ev})");
        // The exposure transfer is exactly 1/(1.2·2^EV100) — check against a hand
        // value at EV100 = 0 (exposure = 1/1.2) and monotonic decrease with light.
        assert!((exposure_from_ev100(0.0) - 1.0 / 1.2).abs() < 1e-6);
        assert!(exposure_from_ev100(1.0) < exposure_from_ev100(0.0));
        // Our calibrated default preserves the prior baked ~1.55 post-exposure.
        let e = exposure_from_ev100(DEFAULT_EV100);
        assert!((e - 1.55).abs() < 0.05, "default exposure ≈ 1.55 (got {e})");
    }

    /// INJECT-ASSERT (FIX (a), rect math): a widget whose viewport lies fully
    /// inside the screen, with a clip rect equal to it, scissors to exactly that
    /// rect — the GPU pass paints only the widget. (No GPU device needed.)
    #[test]
    fn scissor_equals_widget_when_clip_covers_it() {
        // 200×150 widget at (50,40) on a 640×480 framebuffer; clip == widget.
        let s = scissor_intersection((50, 40, 200, 150), (50, 40, 200, 150), [640, 480]);
        assert_eq!(s, ScissorPx { x: 50, y: 40, w: 200, h: 150 });
    }

    /// INJECT-ASSERT: when the surrounding clip rect is the WHOLE PANEL (wider than
    /// the widget — the real bug condition, since the callback's clip can be
    /// panel-wide), the scissor is still pinned to the widget viewport, NOT the
    /// panel. This is the GPU twin of `clip_poly_to_rect`: no ink outside the rect.
    #[test]
    fn scissor_is_pinned_to_widget_not_panel_clip() {
        // Widget is a small pane; clip rect is the entire 640×480 panel.
        let s = scissor_intersection((300, 200, 120, 90), (0, 0, 640, 480), [640, 480]);
        assert_eq!(s, ScissorPx { x: 300, y: 200, w: 120, h: 90 }, "scissor confined to the widget viewport");
        // The scissor's far corner never exceeds the widget viewport corner.
        assert!(s.x + s.w <= 300 + 120 && s.y + s.h <= 200 + 90, "no ink outside the widget rect");
    }

    /// INJECT-ASSERT: a clip rect that only partially overlaps the widget (the
    /// widget is half-scrolled out of a scroll area) scissors to the OVERLAP, and
    /// never past the framebuffer or the widget.
    #[test]
    fn scissor_is_the_intersection_and_clamped() {
        // Widget at (500,400) 300×300 would extend to (800,700) — past the 640×480
        // screen; clip rect cuts its top-left further. Result is the intersection,
        // clamped to the screen.
        let s = scissor_intersection((500, 400, 300, 300), (550, 420, 300, 300), [640, 480]);
        // left = max(500,550,0)=550 ; top = max(400,420,0)=420
        // right = min(800, 850, 640)=640 ; bottom = min(700, 720, 480)=480
        assert_eq!(s, ScissorPx { x: 550, y: 420, w: 90, h: 60 });
        assert!(s.x + s.w <= 640 && s.y + s.h <= 480, "clamped to framebuffer");
    }

    /// INJECT-ASSERT: a clip rect fully disjoint from the widget yields a zero-area
    /// scissor → the GPU pass paints nothing (no ink anywhere outside).
    #[test]
    fn scissor_zero_area_when_disjoint() {
        let s = scissor_intersection((0, 0, 100, 100), (300, 300, 50, 50), [640, 480]);
        assert_eq!(s.w, 0, "disjoint → zero width");
        assert!(s.w == 0 || s.h == 0, "zero-area scissor paints nothing");
    }

    /// INJECT-ASSERT: the public `gpu_scissor_px` reads a real
    /// `egui::PaintCallbackInfo` (viewport ∩ clip) and produces the same confined
    /// rect — the exact value `paint()` feeds `set_scissor_rect`.
    #[test]
    fn gpu_scissor_px_from_callback_info() {
        let info = egui::PaintCallbackInfo {
            viewport: egui::Rect::from_min_size(egui::pos2(40.0, 30.0), egui::vec2(200.0, 150.0)),
            // Panel-wide clip (the bug condition): far larger than the widget.
            clip_rect: egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(800.0, 600.0)),
            pixels_per_point: 1.0,
            screen_size_px: [800, 600],
        };
        let s = gpu_scissor_px(&info);
        assert_eq!(s, ScissorPx { x: 40, y: 30, w: 200, h: 150 }, "scissor == widget viewport, not the panel clip");
    }
}