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