facett-map/src/gpu/mod.rs
//! GPU render pipeline for OSM ways — **ported nearly verbatim** from
//! katana-osm's `osm-viewer/src/render/gpu/mod.rs` (`GpuMapRenderer`). The only
//! changes for facett: it imports `egui_wgpu` + `wgpu` as direct crates (the
//! source went through `eframe`'s re-exports), and `build_gpu_geometry` reads
//! the facett [`Way`]'s `style.color` instead of osm-viewer's `style.stroke`.
//!
//! Architecture:
//! - All way geometry (all LODs) uploaded once to `geom_buf` in VRAM on the
//! first frame. Never touched again during pan/zoom. It is the DRAW's vertex
//! buffer as well as the cull's input.
//! - Each frame: update the viewport uniform → dispatch the compute cull →
//! `draw_indexed_indirect` off `geom_buf` through `index_buf`. Zero CPU vertex
//! work per frame.
//!
//! Compute (cull.wgsl): one invocation per way. Frustum + LOD test; visible ways'
//! vertex INDICES are written into `index_buf` in bake order (an ordered
//! compaction — count, scan, copy). `indirect_buf` is the control block (binding 3):
//! the `DrawIndexedIndirectArgs` the draw consumes (index_count at offset 0) plus
//! the scan's scratch.
//!
//! ## VRAM is the geometry plus a sixth (GG audit row 21)
//! The compaction used to COPY vertices into a `compact_buf` allocated at the full
//! size of the chunk, so every chunk held its geometry twice: MEASURED on t14s /
//! Radeon 890M, 2026-09-06, 22 000 ways = 74.8 MB of geometry + meta → 147.8 MB
//! allocated, **1.975×**. An index is 4 B whatever the vertex is, so the compaction
//! now costs 1/6 of a 24 B stream and 1/12 of a 48 B one; `tests/vram_ratio.rs`
//! holds the whole bill at ≤ 1.2× and the arithmetic bound is in `upload_vertex_chunks`.
//!
//! ## Buffer chunking (fix #12)
//! A whole country (Sweden ≈ 314 MB of `Vertex`) exceeds wgpu's default
//! `max_buffer_size` (256 MiB), so a single `geom` buffer would fail
//! `Device::create_buffer` with a Validation Error and panic. The upload now
//! **splits the geometry into chunks**, each `geom`+`meta`+`compact` buffer kept
//! `≤ device.limits().max_buffer_size`, with **a `cull` dispatch + a
//! `draw_indirect` per chunk**. Ways are never split across a chunk boundary
//! (each way's vertices live wholly in one chunk; `vert_start` is chunk-relative),
//! so the proven per-way cull shader is unchanged. `chunk_count` /
//! `chunk_buffer_bytes()` are surfaced for the headless self-test.
#![allow(clippy::too_many_lines, reason = "verbatim port of the proven pipeline")]
use egui_wgpu::wgpu;
use egui_wgpu::wgpu::util::DeviceExt as _;
use crate::{Way, style::LOD_COUNT};
// ── GPU-side types (must match cull.wgsl and draw.wgsl layouts) ──────────────
//
// CONS-CORE Phase C: these are now **re-exported from `facett_core::render::gpu`**
// (the shared L0 plumbing) so the chunking helpers (`partition_chunks` etc.) and
// facett-map's `upload` agree on one type. The structs are byte-identical to the
// originals (same `#[repr(C)]` layout, same wgsl-matching offsets — the core
// `gpu_struct_sizes_match_wgsl_layouts` test pins this), so the cull/draw shader
// contracts are unchanged.
pub use facett_core::render::gpu::{
DrawIndirectArgs, DrawUniforms, GpuWayMeta, Vertex, ViewportCull, rebase_vertex_origin,
};
// The pretty GPU line lane (triangle-strip quads + miter/bevel joins + SDF-AA +
// casing) — additive to the LineList pipeline below, dedicated `LineVertex` (48 B)
// so the pinned shared `Vertex` (24 B, facett-map3d) is untouched.
pub mod line;
/// The baked-vertex store hook + blob codec (the client-side NVMe tier's facett half).
pub mod bake;
pub use line::{
LinePolyline, LineRenderer, LineVertex, MapLineUniforms, build_line_geometry,
build_line_geometry_from_ways,
};
// ── Geometry payload (CPU → GPU, first frame only) ────────────────────────────
/// **The retained 1 px `LineList` hairline road lane — OFF.** (stinky `mapdraw-4`.)
///
/// The hairline lane was the original road draw. [`LineRenderer`]'s quad/miter/SDF-AA
/// lane superseded it and `MapPaintCallback::paint` has drawn only the quads ever
/// since — but the hairline stream was still BAKED, still uploaded to VRAM, still
/// compute-culled 60 times a second, and (since the baked-vertex tier landed) still
/// written to the on-disk blob. Its `compact_buf` and the `indirect_buf` counter the
/// cull atomically bumps were read by nothing.
///
/// MEASURED on the 20 000-way Sweden payload before this constant existed: **618 030
/// `Vertex` = 14.83 MB geom + 14.83 MB compact + 0.40 MB meta = 30.07 MB, 14.4 % of
/// the map's 208 MB of VRAM**, and 198 cull workgroups a frame whose entire output
/// was dead.
///
/// Kept as a constant rather than deleted because the fallback claim is written into
/// three doc comments in this file and they should stay honest: flipping this to
/// `true` restores the bake, the upload and the dispatch together — which is exactly
/// what "a one-line revert path" was supposed to mean, and what it did not mean while
/// a shipped frame was paying for it. `paint` still draws nothing from the lane; a
/// revert that wants pixels must also re-add the draw loop.
pub const HAIRLINE_LANE: bool = false;
/// All way geometry for GPU upload. Built once from `MapIndex`, then dropped.
pub struct GpuGeometry {
/// The 24 B `LineList` hairline stream. **Empty unless [`HAIRLINE_LANE`]** — see
/// that constant; nothing draws it.
pub vertices: Vec<Vertex>,
/// Per-way meta for [`Self::vertices`]. Empty unless [`HAIRLINE_LANE`].
pub way_meta: Vec<GpuWayMeta>,
/// **Pretty-line lane** geometry — triangle-list quads (48 B [`LineVertex`]) with
/// per-vertex miter/bevel joins + screen-px width, built in the SAME pass (and
/// with the SAME origin bake) as the raw [`Vertex`]/`way_meta` `LineList`
/// geometry. The live paint callback draws THESE through the [`LineRenderer`]
/// (casing + SDF-AA fill) instead of the old 1px hairlines.
pub line_vertices: Vec<LineVertex>,
/// Per-way metadata for the pretty-line lane, in **`LineVertex` units** — the
/// quad-stream twin of [`Self::way_meta`]. This is what lets the GPU cull compact
/// the quads it actually draws (`VPV = 12`) instead of the hairlines nobody
/// draws, so the draw becomes a real `draw_indirect` off a GPU-computed count.
/// Ordered lod-ascending, matching [`Self::line_vertices`].
pub line_meta: Vec<GpuWayMeta>,
/// Cumulative `line_vertices` length at the END of each LOD tier
/// (`[country, region, city]`), because [`build_gpu_geometry`] is fed ways
/// lod-ascending.
///
/// **No longer drives the draw.** It was the CPU twin of the cull's cumulative
/// `lod <= vp.lod` test, back when `paint` drew a CPU-computed prefix range; the
/// cull now applies that test itself, per way, on the GPU — alongside the frustum
/// test this lane never had. Retained as an uploaded-geometry fact (the self-test
/// asserts the tiers tile the quad buffer).
pub line_lod_end: [u32; LOD_COUNT],
/// **Fill lane** geometry — the filled ways' cached ear clip, expanded to a
/// triangle list, plus their ring outlines as a line list. Empty for every
/// caller that does not build it (see [`build_gpu_fill_geometry`]), which is
/// what keeps the existing line-only callers byte-identical.
pub fills: GpuFillGeometry,
}
/// **The GPU fill lane's payload** — what a filled way costs the device, once,
/// instead of what it cost the CPU painter every frame.
///
/// MEASURED 2026-08-03 on a real 1600×1000 window against an RTX 4090
/// (Vulkan, DiscreteGpu), 20 000 real Swedish ways, median of 200 frames, fills
/// only: `MapFacet::ui` was 10.08 ms, of which **7.99 ms remained with BOTH the
/// mesh and the outline stroke suppressed**. The tessellation was already cached
/// (`FillTessCache`); what was left is `w.lonlat.clone()` per way and
/// `MapView::project` per vertex — a `tan` and a `ln` (Mercator) for every one of
/// 81 906 vertices, 60 times a second, to re-derive a number that had not changed.
///
/// That is why BOTH streams are here. Moving only the mesh leaves the projection on
/// the CPU and buys nothing: the ring still has to be projected to stroke its
/// outline. The lane is only a lane if the filled way stops being touched per frame
/// at all.
///
/// The triangle stream is 24 B [`Vertex`] culled by `cull.wgsl` at its default
/// `VPV = 6`; the outline stream is 48 B [`LineVertex`] culled by the SAME shader at
/// `VPV = 12`, exactly as the road quads are (LAW #5 — one cull, one expander, one
/// pipeline). The outline stream used to be a raw `LineList` on `draw_pipeline`; see
/// [`GpuFillGeometry::outline_vertices`] for what that cost in pixels.
///
/// ## What the ratio actually is, per payload
///
/// The numbers above come from `sweden-recut/ways.parquet`, a 20 000-way monolith
/// extract — and that is **not what korp serves**. The live tile pyramid under
/// `~/.korp/work/sweden/tiles` carries **2.0–2.3 vertices per way** and, over 120
/// sampled tiles, **zero polygons and zero hole vertices** (`stinky` `prof-6`,
/// `prof-7`): osm-katana's `tile_pixel_tol` simplifies to one pixel of a 256 px
/// tile while facett draws that tile across 1920, so Douglas-Peucker keeps little
/// more than the endpoints. A two-point "ring" yields no triangles at all, which is
/// why the *tessellation* is 0.9 % of a served frame rather than 85 %.
///
/// The lane still pays for itself there, because the cost it removes was never
/// mainly the ear clip — it is the per-frame Mercator (`tan` + `ln` per vertex) and
/// the clone in front of it, and those scale with way COUNT, which the pyramid does
/// not reduce. MEASURED 2026-08-03, the SAME release binary with the renderer
/// installed and not (`NOGPU=1`), real 1600×1000 window, RTX 4090 Vulkan
/// DiscreteGpu, median of 200 frames:
///
/// ```text
/// payload layers CPU GPU ratio
/// sweden-recut monolith, 20 000 ways, z6 all 67.76 ms 0.975 ms 69.5x
/// lines 11.28 ms 0.332 ms 34.0x
/// fills 10.60 ms 0.580 ms 18.3x
/// served tile z12/2253/1204 (densest) all 15.85 ms 0.593 ms 26.7x
/// lines 4.54 ms 0.364 ms 12.5x
/// fills 2.72 ms 0.229 ms 11.9x
/// served tile z6/34/19 (densest z6) all 1.23 ms 0.534 ms 2.3x
/// ```
///
/// So: 11.9× on the densest served tile's fills, not 17.3×, and only 2.3× whole-pane
/// on a z6 tile that has almost no geometry left to draw. The 69.5× is real for the
/// monolith path and is what a fixed `tile_pixel_tol` would move the pyramid toward
/// — the lane is built for the geometry the pyramid is *supposed* to carry, and it
/// is already the larger win on the geometry it carries today.
#[derive(Default)]
pub struct GpuFillGeometry {
/// The fill triangles, **expanded** (3 vertices per triangle; the bake carries no
/// index buffer). The cull's compaction IS an index list now (GG audit row 21), so
/// the device draws these indexed — but the indices it draws through are the
/// cull's per-frame survivors, not a baked de-duplication of shared corners. That
/// de-duplication would be a bake-format change on top; at 20 000 Swedish ways the
/// whole expansion is 5.6 MB, so it has not earned one.
pub tri_vertices: Vec<Vertex>,
/// One entry per filled way: its origin-local bbox, its span in
/// [`Self::tri_vertices`], its LOD tier.
pub tri_meta: Vec<GpuWayMeta>,
/// The same ways' outer + hole rings as **closed anti-aliased quads** — the stroke
/// the CPU painter draws with `Shape::line(ring, Stroke::new(width, colour))`,
/// through the SAME [`LineExpander`](line::LineExpander) the road lane uses.
///
/// It was a raw 24 B [`Vertex`] `LineList` (stinky `mapgpu-1`): a hard 1 px
/// hairline with no coverage ramp, against an epaint stroke feathered over ~2 px.
/// MEASURED on this box 2026-08-22, a 7x7 grid of small lakes rendered through both
/// lanes on an RTX 4090: the CPU pane held **80 distinct colours** and the GPU pane
/// **5** — the whole image, not a crop. At this zoom the outline, not the fill, is
/// most of what a small area reads as on screen, so the ramp is not cosmetic.
pub outline_vertices: Vec<line::LineVertex>,
/// One entry per filled way, spanning ALL its rings, so the cull keeps a way's
/// outer ring and its holes together (a hole outline without its outer ring is a
/// floating island).
pub outline_meta: Vec<GpuWayMeta>,
}
impl GpuFillGeometry {
/// Whether this payload has anything for the device to draw.
#[must_use]
pub fn is_empty(&self) -> bool {
self.tri_meta.is_empty() && self.outline_meta.is_empty()
}
}
/// Screen-px per themed `style.width` unit for the pretty-line lane. `style.width`
/// tops out ≈ 9 (motorway), so ×1.0 yields 1–9 px cased roads — the same on-screen
/// ceiling the CPU painter's `style.width * zoom_w` reaches at mid zoom. Width is
/// baked once (screen-space px), so a road keeps a constant on-screen thickness at
/// any zoom (the module's stated design).
const LINE_PX_PER_WIDTH: f32 = 1.0;
/// Dark casing ink drawn UNDER the fill (the road outline), extra half-width, and
/// AA edge softness for the live pretty-line lane. Constants here (the callback has
/// no palette handle); a wider rework could thread the palette's casing colour.
/// The FALLBACK casing ink, for a callback built without a palette. `MapFacet` always
/// supplies `theme.palette.casing`; this near-black navy is only what a bare
/// `MapPaintCallback { .. Default::default() }` in a test gets, and it is the value the
/// lane shipped with before the palette was threaded through.
///
/// Why the palette had to be threaded (`kalm-4`, second half). This constant WAS the
/// casing on every map korp draws, and on the cartographic DAY palette the casing is
/// `#B7AE9E`, a warm grey on warm paper — while what actually reached the screen was
/// `#191C24`, near-black navy. MEASURED with `korp tools/mapdrive/ink.py` on the live
/// z12/z15 panes: the palette's own casing hue covered 0.03-0.06 % of the canvas, i.e.
/// it was not on the map at all, while the pane still carried 0.85 % ink darker than
/// grey 70. A light paper map cased in navy is the one colour decision no palette
/// switch could reach.
const LINE_CASING_COL: [f32; 4] = [0.10, 0.11, 0.14, 1.0];
/// **Ceiling** on the casing's extra half-width (px) — was the flat value.
const LINE_CASING_PX: f32 = crate::style::CASING_MAX_PX;
/// The casing law, from [`crate::style`] — the ONE place the ratio, the floor and the
/// ceiling are written (`kalm-4`). They used to be three literals here and the same
/// three numbers again in `line.wgsl`'s uniform doc, with the CPU painter carrying no
/// casing at all; `style::casing_extra_px` is now what both lanes resolve through.
const LINE_CASING_RATIO: f32 = crate::style::CASING_RATIO;
const LINE_CASING_MIN_PX: f32 = crate::style::CASING_MIN_PX;
const LINE_FEATHER_PX: f32 = 1.0;
/// **`instance_count` of a ROAD chunk's draw: the casing and the fill** (GG audit
/// row 21). Instance 0 reads the casing uniform, instance 1 the fill uniform
/// (`line.wgsl` selects on `instance_index`), and the GPU rasterises instance 0's
/// primitives before instance 1's — so the casing still lands under the fill, chunk by
/// chunk, exactly as the two draws it replaces did (lane GG #26's argument is
/// unchanged; only the command count is). Rust twin of `line.wgsl`'s `array<Uniforms, 2>`.
const ROAD_INSTANCES: u32 = 2;
/// A fill-OUTLINE chunk has no casing pass (stinky `mapgpu-1`): one instance.
const OUTLINE_INSTANCES: u32 = 1;
// ── Persistent GPU resources ──────────────────────────────────────────────────
/// One chunk of way geometry: a `geom`+`meta`+`index`+`indirect` set whose
/// buffers each stay `≤ max_buffer_size`. Each holds a contiguous slice of ways
/// (no way straddles a boundary), so the per-way cull shader runs unchanged with
/// chunk-relative `vert_start`. One `cull` dispatch + one `draw_indexed_indirect`
/// per chunk: the geometry is the vertex buffer, the cull's index list the index
/// buffer, and the control block the args.
struct GeomChunk {
/// The geometry — the cull's binding 1 AND the draw's vertex buffer
/// (`STORAGE | VERTEX`). Uploaded once, never rewritten.
geom_buf: wgpu::Buffer,
#[allow(dead_code, reason = "kept alive for cull_bind")]
meta_buf: wgpu::Buffer,
/// **The cull's output: one `u32` vertex index per surviving vertex, in bake
/// order** — the draw's index buffer (`INDEX | STORAGE`). Sized to one index per
/// vertex of the chunk, which is the most a compaction can produce and 1/6 of
/// what the vertex copy it replaced had to be sized to (GG audit row 21).
index_buf: wgpu::Buffer,
/// The control block: `DrawIndexedIndirectArgs` at offset 0 (the cull's scan
/// pass STORES `index_count`) plus the ordered compaction's scratch — see
/// [`GpuMapRenderer::ctl_bytes`] and `cull.wgsl`'s binding-3 note.
indirect_buf: wgpu::Buffer,
cull_bind: wgpu::BindGroup,
way_count: u32,
/// Byte sizes of the chunk's four buffers — surfaced to the self-tests so the
/// chunking and the VRAM bill are provable as data.
geom_bytes: u64,
meta_bytes: u64,
index_bytes: u64,
ctl_bytes: u64,
}
impl GeomChunk {
/// Every byte this chunk holds on the card.
fn bytes(&self) -> u64 {
self.geom_bytes + self.meta_bytes + self.index_bytes + self.ctl_bytes
}
}
/// One chunk of the **pretty-line quad** stream, with its own cull + indirect set.
///
/// The mirror of [`GeomChunk`] for the 48 B `LineVertex` lane. Before this existed
/// the quad lane had no cull at all: `paint` drew a CPU-computed LOD prefix of every
/// road in the dataset, every frame, with no frustum test — while the cull compute
/// pass ran anyway and compacted the 24 B hairline stream that nothing drew.
struct LineChunk {
/// The baked quads — the cull's binding 1 AND the draw's vertex buffer
/// (`STORAGE | VERTEX`, plus `COPY_SRC` for the test readback that resolves the
/// index list back into the vertices the shader pulls).
src_buf: wgpu::Buffer,
#[allow(dead_code, reason = "kept alive for line_cull_bind")]
meta_buf: wgpu::Buffer,
/// The cull's index list — the draw's index buffer. See [`GeomChunk::index_buf`].
index_buf: wgpu::Buffer,
/// The control block — see [`GeomChunk::indirect_buf`].
indirect_buf: wgpu::Buffer,
cull_bind: wgpu::BindGroup,
way_count: u32,
/// `instance_count` the per-frame reset writes into the args: **2** for the road
/// stream (instance 0 is the casing, instance 1 the fill — one draw, two passes,
/// GG audit row 21) and **1** for the fill-outline stream, which has no casing.
instances: u32,
src_bytes: u64,
meta_bytes: u64,
index_bytes: u64,
ctl_bytes: u64,
}
impl LineChunk {
/// Every byte this chunk holds on the card.
fn bytes(&self) -> u64 {
self.src_bytes + self.meta_bytes + self.index_bytes + self.ctl_bytes
}
}
pub struct GpuMapRenderer {
/// The `LineList` pipeline — **the retained hairline fallback, and nothing draws
/// it.** It was the raw 1 px road draw; the pretty [`LineRenderer`] superseded that
/// (`mapdraw-4`, [`HAIRLINE_LANE`]), and it then had a second life as the
/// fill-OUTLINE draw until stinky `mapgpu-1` measured what a topology with no
/// coverage ramp costs: 5 distinct colours in a GPU pane against 80 in the CPU pane
/// it is meant to look like. The outlines now ride the quad lane too.
///
/// Kept, not deleted, for the same reason `HAIRLINE_LANE` is a `const` and not a
/// `git revert`: it is one `set_pipeline` away from being an A/B, and it costs one
/// pipeline object at renderer construction and nothing per frame. Its twin
/// [`Self::fill_pipeline`] is built from the SAME descriptor closure and IS drawn,
/// so this is not an unexercised code path — only an unsubmitted topology.
#[allow(dead_code, reason = "retained LineList A/B path; superseded by LineRenderer for BOTH streams — mapgpu-1")]
draw_pipeline: wgpu::RenderPipeline,
draw_uniform: wgpu::Buffer,
#[allow(dead_code, reason = "the bind for the retained draw_pipeline above")]
draw_bind: wgpu::BindGroup,
/// The **fill** pipeline: byte-for-byte `draw_pipeline` with
/// `PrimitiveTopology::TriangleList`. One shader (`draw.wgsl`), one uniform
/// block, one vertex layout — the topology is the only difference, so a filled
/// area and a road are projected by the same line of WGSL (LAW #5).
fill_pipeline: wgpu::RenderPipeline,
/// The fill draw's own copy of the uniform block — identical to
/// [`Self::draw_uniform`] except for [`DrawUniforms::tint`], which carries the
/// pitched-horizon fade the CPU painter applies to a FILL and not to its
/// outline. Two buffers off one layout is what lets that stay a *draw*-scoped
/// value without a dynamic offset.
fill_uniform: wgpu::Buffer,
fill_bind: wgpu::BindGroup,
cull_pipeline: wgpu::ComputePipeline,
/// The ordered compaction's count + scan passes (see `cull.wgsl`). Same module,
/// same layout, no `VPV` — they move offsets, not vertices.
cull_count_pipeline: wgpu::ComputePipeline,
cull_scan_pipeline: wgpu::ComputePipeline,
cull_bgl: wgpu::BindGroupLayout,
cull_vp_buf: wgpu::Buffer,
geom_ready: bool,
/// All geometry, split so each buffer is `≤ max_buffer_size` (fix #12).
chunks: Vec<GeomChunk>,
/// The device's `max_buffer_size` captured at construction — the chunk budget.
max_buffer_size: u64,
/// **Pretty-line lane** — the quad/miter/SDF-AA/casing pipeline that REPLACES
/// the raw `LineList` hairlines the `draw_pipeline` above emitted. Its own
/// pipeline + uniform buffers (self-contained; the pinned `Vertex`/`DrawUniforms`
/// are untouched).
line_renderer: LineRenderer,
/// The uploaded pretty-line chunks. Split so each buffer stays
/// `≤ max_buffer_size` — the same fix-#12 partition `chunks` uses, over the 48 B
/// `LineVertex` stride (`partition_chunks_stride`), so a way never straddles a
/// boundary and the per-way cull runs unchanged with chunk-relative `vert_start`.
/// Culled by the ONE [`Self::cull_pipeline`]: the compaction is an index list, so
/// the 48 B stride is the vertex layout's business and never the cull's.
line_chunks: Vec<LineChunk>,
/// Cumulative pretty-line vertex count at the end of each LOD tier (mirrors
/// [`GpuGeometry::line_lod_end`]). Retained as an uploaded-geometry fact; the LOD
/// filter itself now runs per-way in the cull, not as a CPU prefix here.
line_lod_end: [u32; LOD_COUNT],
/// **The map's own colour+depth targets, ONE PER PANE** (lane MP-a; per pane since
/// GG #37). Empty until the first `prepare`. A pane's target is allocated when its
/// key is first seen and again when the surface size changes — never per frame —
/// and freed once the pane has gone a whole frame without a `prepare`. See
/// [`PaneTarget`] for why the blit uniform lives there too.
panes: Vec<PaneTarget>,
/// The surface format the offscreen colour target must match.
target_format: wgpu::TextureFormat,
/// The pipeline that blits a pane's [`OffscreenTarget::resolved`] into egui's pass.
blit_pipeline: wgpu::RenderPipeline,
blit_bgl: wgpu::BindGroupLayout,
blit_sampler: wgpu::Sampler,
/// The queue, kept from `prepare` so `paint` can write the blit's UV rect — the
/// pane rect is only knowable from `PaintCallbackInfo`, which `prepare` never sees.
queue: Option<wgpu::Queue>,
/// **The in-GPU pass clock** for this lane (perf/v2.3): TIMESTAMP_QUERY around
/// the cull compute pass, 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). What it
/// times is the CULL pass — the one pass this lane records into its own
/// encoder; the fill/line draws ride egui's shared render pass in `paint`,
/// which a callback cannot bracket (no encoder there, and the pass belongs to
/// egui). The stats doc says so rather than pretending to cover the draws.
pass_clock: facett_core::render::gpu::PassClockSlot,
/// **Fill lane** — the filled ways' cached ear clip as a triangle list, culled by
/// the same `cull.wgsl` at `VPV = 6` and drawn by [`Self::fill_pipeline`].
fill_chunks: Vec<GeomChunk>,
/// **Fill-outline lane** — the same ways' closed rings as a line list, drawn by
/// [`Self::draw_pipeline`]. A separate chunk set rather than a second stream in
/// `fill_chunks`, because a `LineList` and a `TriangleList` cannot share one draw
/// and the cull compacts per buffer.
outline_chunks: Vec<LineChunk>,
/// The draw/fill uniform bind-group LAYOUT, kept so the TILE lane can mint
/// per-tile uniform binds against the same pipelines.
draw_bgl: wgpu::BindGroupLayout,
/// **The TILE lane's VRAM cache** — content-keyed chunk sets that SURVIVE facet
/// rebuilds. The whole point: korp rebuilds its facet on every viewport apply,
/// and the legacy path re-uploaded the entire payload each time; here a pan
/// re-uploads only the tiles that are genuinely new, and a zoom back onto data
/// the cache still holds uploads NOTHING.
tile_gpu: std::collections::HashMap<u128, TileGpu>,
/// This frame's draw set, in hand-over order: `(key, transform, cull bbox, draw
/// band)`. Written by `prepare`, read by `paint` — the same one-frame contract
/// `geom_ready` carries for the legacy path.
///
/// **Band-major.** The facet sorts its hand-over on `(band, tile_key)`, so equal
/// bands are contiguous here and `paint` can walk one run per band without sorting
/// anything itself.
tile_current: Vec<(u128, MapTransform, [f32; 4], i32)>,
/// Frame counter for the tile LRU (bumped once per tiled prepare).
tile_frame: u64,
/// Test override for the tile VRAM budget (None = the env/default budget).
tile_budget_override: Option<u64>,
/// **How many cull compute passes [`Self::dispatch_cull`] has recorded**, over
/// both lanes. The still-frame contract's observable (lane GG #13): 100 frames
/// with the camera at rest must leave this where the first frame put it. A
/// count and not a stopwatch, for the reason `tests/still_frame_work.rs` gives —
/// work that does not happen is invisible in the output, and *0 dispatches over
/// 100 still frames* is the same answer on any box. Atomic because
/// `dispatch_cull` is `&self` (it records into a borrowed pass).
cull_dispatches: std::sync::atomic::AtomicU64,
/// Bumped by every [`Self::upload_tile`] and every eviction — the "nothing
/// uploaded" half of the still-frame gate in [`Self::run_cull_tiles_clocked`].
tile_upload_gen: u64,
/// **The still-frame gate's memory, ONE ENTRY PER PANE** — lane HO-T4. See
/// [`TileCullGate`]. Two or three entries at the very most (a main map plus an
/// inset), walked linearly; the same shape and the same eviction rule as
/// [`Self::panes`], for the same reason.
tile_gates: Vec<TileCullGate>,
}
/// **What one PANE's last tile cull was computed from** — lane HO-T4.
///
/// GG #13's still-frame gate held this on the RENDERER, and GG #37 then gave every pane
/// its own [`PaneTarget`]: with a main map and an inset up, the two callbacks alternated
/// through the one gate, neither ever matched its own previous frame, and #13's
/// zero-dispatch rest was lost for as long as the inset was on screen — MEASURED on t14s
/// / Radeon 890M, `tests/gpu_lane_two_panes.rs`: 0 cull dispatches over 16 still frames
/// with one pane, **32** with two (2.00 per frame, one per pane).
///
/// So the memory is keyed by [`PaneStamp::pane`], exactly as the target is, and evicted
/// by exactly the same `retain(last_frame + 1 >= frame_nr)` rule — a pane that stops
/// being drawn leaks neither its target nor its gate.
struct TileCullGate {
/// [`PaneStamp::pane`] — which pane's gate this is.
key: u64,
/// The last [`PaneStamp::frame_nr`] this gate was consulted in (the eviction rule).
last_frame: u64,
/// **What the last tile cull FOR THIS PANE was computed from** — the scalar inputs
/// plus the upload generation they were seen under. `None` until this pane's first
/// cull. (`tile_upload_gen` stays per renderer on purpose: the tile VRAM cache is
/// shared, so an upload made under one pane really does invalidate the other's
/// indirect counters.)
last: Option<(TileFrameInputs, u64)>,
/// The draw set that cull ran over — the retained twin of `tile_current`, compared
/// element-wise (no hash, so no collision can ever skip a cull that was due).
/// Cleared and refilled in place, never reallocated per frame.
current: Vec<(u128, MapTransform, [f32; 4], i32)>,
/// How many times this pane's gate let the body through. The per-pane half of
/// How many times this pane's gate let the body through — the per-pane half of
/// `cull_dispatches`, read by [`GpuMapRenderer::tile_cull_dispatches_for_pane`].
dispatches: u64,
}
/// **Everything a tile frame's per-tile writes are a function of, besides the draw
/// set itself** — the cull viewport's scalars (`lod`, `min_fill_extent`) and the five
/// draw uniforms' inputs (pane size, fill tint, casing colour, width scale). Two
/// frames with equal inputs AND an equal `tile_current` AND no upload between them
/// write identical bytes into identical buffers and dispatch an identical cull, which
/// is why [`GpuMapRenderer::run_cull_tiles_clocked`] may skip the second one
/// outright. `f32` compares with `==`, so a NaN input can never compare equal and
/// never wrongly skips — it only ever redoes.
#[derive(Clone, Copy, Debug, PartialEq)]
struct TileFrameInputs {
lod: u32,
min_fill_extent: f32,
logical_w: f32,
logical_h: f32,
fill_tint: f32,
casing_col: [f32; 4],
width_scale: f32,
}
impl GpuMapRenderer {
pub fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self {
// ── Draw pipeline ─────────────────────────────────────────────────────
let draw_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("facett_map_draw"),
// THE draw shader — one copy, owned by facett-core. This crate used to
// carry a byte-identical `src/gpu/draw.wgsl` next to core's, kept honest
// by a guard test; the twin is gone (LAW #5: fix by construction).
source: wgpu::ShaderSource::Wgsl(
facett_core::render::gpu::wgsl(facett_core::render::gpu::DRAW_WGSL).into(),
),
});
let draw_uniform = alloc_buffer(device, &wgpu::BufferDescriptor {
label: Some("draw_uniforms"),
size: std::mem::size_of::<DrawUniforms>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let fill_uniform = alloc_buffer(device, &wgpu::BufferDescriptor {
label: Some("fill_uniforms"),
size: std::mem::size_of::<DrawUniforms>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let draw_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("draw_bgl"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
// FRAGMENT too, since `DrawUniforms::tint` is read there. A block the
// fragment stage cannot see makes `create_render_pipeline` fail loudly
// at bring-up rather than drawing untinted, which is the failure mode
// that would have looked like "the tilt fade just does not work".
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let bind_of = |label: &str, buf: &wgpu::Buffer| {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some(label),
layout: &draw_bgl,
entries: &[wgpu::BindGroupEntry { binding: 0, resource: buf.as_entire_binding() }],
})
};
let draw_bind = bind_of("draw_bg", &draw_uniform);
let fill_bind = bind_of("fill_bg", &fill_uniform);
// ONE descriptor body, two topologies — a `LineList` for the hairline /
// fill-outline stream and a `TriangleList` for the fill stream. Written as a
// closure rather than twice, so the shader, the vertex layout, the blend and
// the bind-group layout cannot drift between the two (LAW #5); the topology
// is the only thing that varies and it is the only argument.
let draw_pll = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("draw_pll"),
bind_group_layouts: &[Some(&draw_bgl)],
immediate_size: 0,
});
let make_draw_pipeline = |label: &str, topology: wgpu::PrimitiveTopology| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&draw_pll),
vertex: wgpu::VertexState {
module: &draw_shader,
entry_point: Some("vs_main"),
compilation_options: wgpu::PipelineCompilationOptions::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: 0,
shader_location: 0,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x4,
offset: 8,
shader_location: 1,
},
],
})],
},
fragment: Some(wgpu::FragmentState {
module: &draw_shader,
entry_point: Some("fs_main"),
compilation_options: wgpu::PipelineCompilationOptions::default(),
targets: &[Some(wgpu::ColorTargetState {
format: target_format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState { topology, ..Default::default() },
// Lane MP-a: the map draws into its OWN colour+depth target, so the rank
// ordering survives being split across tiles and chunks.
depth_stencil: Some(map_depth_state()),
// 4×, because the target is 4× — see `map_msaa_state`. A pipeline left on
// the shared no-MSAA state here is a wgpu validation failure at draw time.
multisample: map_msaa_state(),
multiview_mask: None,
cache: None,
})
};
let draw_pipeline = make_draw_pipeline("draw_pipeline", wgpu::PrimitiveTopology::LineList);
let fill_pipeline = make_draw_pipeline("fill_pipeline", wgpu::PrimitiveTopology::TriangleList);
// ── Cull compute pipeline ─────────────────────────────────────────────
let cull_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("facett_map_cull"),
// THE cull shader — one copy, owned by facett-core (see the draw shader
// above). No prelude: the compute kernel needs none of the shared maths.
source: wgpu::ShaderSource::Wgsl(facett_core::render::gpu::CULL_WGSL.into()),
});
let cull_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("cull_bgl"),
entries: &[
bgl_storage(0, false),
bgl_storage(1, false),
bgl_storage(2, true),
bgl_storage(3, true),
bgl_uniform(4),
],
});
let cull_pll = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("cull_pll"),
bind_group_layouts: &[Some(&cull_bgl)],
immediate_size: 0,
});
// The copy pass writes INDICES, so ONE pipeline compacts the 24 B `Vertex`
// streams and the 48 B `LineVertex` streams alike (GG audit row 21). It used to
// be two pipelines off one module, specialised by a `VPV` words-per-vertex
// override; the override is gone because nothing in the cull knows a vertex
// any more.
let cull_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("cull_pipeline"),
layout: Some(&cull_pll),
module: &cull_shader,
entry_point: Some("main"),
compilation_options: wgpu::PipelineCompilationOptions::default(),
cache: None,
});
// ── The ordered compaction's first two passes: ONE pipeline each, serving
// every stream (LAW #5).
let cull_count_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("cull_count_pipeline"),
layout: Some(&cull_pll),
module: &cull_shader,
entry_point: Some("count_pass"),
compilation_options: wgpu::PipelineCompilationOptions::default(),
cache: None,
});
let cull_scan_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("cull_scan_pipeline"),
layout: Some(&cull_pll),
module: &cull_shader,
entry_point: Some("scan_pass"),
compilation_options: wgpu::PipelineCompilationOptions::default(),
cache: None,
});
let cull_vp_buf = alloc_buffer(device, &wgpu::BufferDescriptor {
label: Some("cull_vp"),
size: std::mem::size_of::<ViewportCull>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// ── Pretty-line pipeline (quads + miter joins + SDF-AA + casing) ──────
let line_renderer = LineRenderer::new(device, target_format);
// ── the BLIT: the map's offscreen colour into egui's pass (lane MP-a) ──
let blit_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("facett_map_blit"),
source: wgpu::ShaderSource::Wgsl(include_str!("blit.wgsl").into()),
});
let blit_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("facett_map_blit_bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
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: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
// NOT `bgl_uniform(2)`: that helper declares COMPUTE visibility, which
// is right for the cull's bindings and wrong here — the blit reads the
// UV rect in the VERTEX stage.
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
let blit_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("facett_map_blit"),
layout: Some(&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("facett_map_blit_layout"),
bind_group_layouts: &[Some(&blit_bgl)],
immediate_size: 0,
})),
vertex: wgpu::VertexState {
module: &blit_shader,
entry_point: Some("vs_main"),
compilation_options: wgpu::PipelineCompilationOptions::default(),
buffers: &[],
},
fragment: Some(wgpu::FragmentState {
module: &blit_shader,
entry_point: Some("fs_main"),
compilation_options: wgpu::PipelineCompilationOptions::default(),
targets: &[Some(wgpu::ColorTargetState {
format: target_format,
// **PREMULTIPLIED, and that is a correctness fix, not a preference.**
//
// The offscreen target clears to transparent black and every draw into
// it uses `ALPHA_BLENDING`, whose colour equation is
// `src·srcA + dst·(1−srcA)`. Against a zero destination that leaves
// `α·ink` in the texture, and it stays consistent through every
// subsequent draw: what the resolve produces is PREMULTIPLIED colour
// with its own correct alpha beside it.
//
// Blitting that with `ALPHA_BLENDING` multiplies by the alpha a SECOND
// time, so everywhere the map is not fully opaque — which is every
// road edge, every AA ramp, every partial fill — the ink lands at
// `α²` of its strength. Opaque interiors hide it completely (`α = 1`),
// which is why it survived MP-a.
//
// MEASURED on oden / RTX 4090, 2026-09-05: at the motorway's ⅜-covered
// edge pixel it cost the residential underneath about two thirds of
// the contribution the 4× resolve had correctly computed for it.
blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState::default(),
// The BLIT itself needs no depth: the ordering was already resolved in the
// offscreen pass, and egui's target has no depth attachment to test against.
depth_stencil: None,
// **And no MSAA either.** This is the one map pipeline that does NOT draw into
// `OffscreenTarget` — it draws into EGUI's pass, which is single-sample. It
// reads the already-resolved texture, so the coverage work is behind it.
multisample: facett_core::render::gpu::msaa_state(),
multiview_mask: None,
cache: None,
});
let blit_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("facett_map_blit_sampler"),
mag_filter: wgpu::FilterMode::Nearest,
min_filter: wgpu::FilterMode::Nearest,
..Default::default()
});
// The blit's UV uniform is PER PANE (`PaneTarget::blit_uniform`, GG #37) and is
// created with the pane's target, not here.
Self {
draw_pipeline,
draw_uniform,
draw_bind,
fill_pipeline,
fill_uniform,
fill_bind,
fill_chunks: Vec::new(),
outline_chunks: Vec::new(),
cull_pipeline,
cull_count_pipeline,
cull_scan_pipeline,
cull_bgl,
cull_vp_buf,
draw_bgl,
tile_gpu: std::collections::HashMap::new(),
tile_current: Vec::new(),
tile_frame: 0,
tile_budget_override: None,
cull_dispatches: std::sync::atomic::AtomicU64::new(0),
tile_upload_gen: 0,
tile_gates: Vec::new(),
geom_ready: false,
chunks: Vec::new(),
// **A storage BINDING has a lower ceiling than a buffer, and every chunk
// here is bound as one.** `max_buffer_size` (256 MiB by default) governs
// `create_buffer`; the geom/meta/compact/src buffers below are then handed
// to `create_bind_group` as STORAGE bindings, which wgpu governs by the
// separate and SMALLER `max_storage_buffer_binding_size` (128 MiB by
// default). Budgeting the partition against the larger of the two produced
// chunks that `create_buffer` happily accepted and `create_bind_group` then
// refused — a panic one call after the allocation that looked fine.
//
// MEASURED 2026-08-04, oden, RTX 4090, korp-ui thin on the live server, the
// moment the camera stepped onto the new z15 rung of the [6, 9, 12, 15]
// pyramid:
//
// wgpu error: Validation Error
// In Device::create_bind_group, label = 'line_cull_bg'
// Buffer binding 1 range 198672192 exceeds
// `max_*_buffer_binding_size` limit 134217728
//
// 198 672 192 B is 189.5 MiB: comfortably under the 256 MiB the chunker was
// aiming at, and 1.48x over the 128 MiB it is actually allowed to bind. The
// older [6, 9, 12] pyramid never produced a chunk that large, which is why
// this sat unfired — the defect was always here, the data grew into it.
//
// The `min` is the whole fix, and it is the right shape: one budget, taken
// at construction, that every stream (hairlines, fill triangles, fill
// outlines, pretty lines) already reads through `self.max_buffer_size`.
// Note that `chunk_buffer_bytes()`'s "provably <= max_buffer_size" and
// `gpu_chunk.rs`'s assertion were both TRUE throughout the crash: they
// check the limit this line used to read, not the limit the device
// enforces, which is why a green suite watched it happen.
max_buffer_size: device
.limits()
.max_buffer_size
.min(device.limits().max_storage_buffer_binding_size),
line_renderer,
line_chunks: Vec::new(),
line_lod_end: [0; LOD_COUNT],
panes: Vec::new(),
target_format,
blit_pipeline,
blit_bgl,
blit_sampler,
queue: None,
pass_clock: facett_core::render::gpu::PassClockSlot::default(),
}
}
/// How many geometry chunks the upload split the ways into (fix #12 emit).
pub fn chunk_count(&self) -> usize {
self.chunks.len()
}
/// `(geom_bytes, meta_bytes, index_bytes)` per chunk — every entry is
/// provably `≤ max_buffer_size`. Surfaced so the headless self-test asserts
/// the chunking on data, not pixels. The third figure was the vertex-copy
/// `compact` buffer's size until GG audit row 21 made the compaction an index list.
pub fn chunk_buffer_bytes(&self) -> Vec<(u64, u64, u64)> {
self.chunks.iter().map(|c| (c.geom_bytes, c.meta_bytes, c.index_bytes)).collect()
}
/// The device buffer-size budget each chunk respects.
pub fn max_buffer_size(&self) -> u64 {
self.max_buffer_size
}
/// `(src_bytes, meta_bytes, index_bytes)` per **pretty-line** chunk — the lane
/// that actually panicked at z15.
///
/// [`Self::chunk_buffer_bytes`] reports `self.chunks`, the 24 B hairline `Vertex`
/// lane. The binding wgpu refused on 2026-08-04 was
/// `label = 'line_cull_bg' … Buffer binding 1 range 198672192`, i.e. a 48 B
/// `LineVertex` buffer out of `self.line_chunks` — 4 138 996 quad vertices — and
/// nothing could read those bytes, so no assertion could be written over the stream
/// that crashed. Test hook only; the live path never asks.
#[doc(hidden)]
pub fn line_chunk_buffer_bytes(&self) -> Vec<(u64, u64, u64)> {
self.line_chunks
.iter()
.map(|c| (c.src_buf.size(), c.meta_buf.size(), c.index_buf.size()))
.collect()
}
/// Test/host hook: run the chunked VRAM upload directly (the same path the
/// first-frame paint callback uses). Exposed so the headless self-test can
/// drive a country-scale upload on a real device and assert `chunk_count` +
/// per-buffer bytes WITHOUT a panic — the fix-#12 proof, as data.
#[doc(hidden)]
pub fn upload_for_test(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, geo: &GpuGeometry) {
self.upload(device, queue, geo);
}
/// Test hook: force a tiny chunk budget so a small fixture still splits into
/// several chunks (proves the partition logic without a 256 MiB upload).
#[doc(hidden)]
pub fn set_max_buffer_size_for_test(&mut self, bytes: u64) {
self.max_buffer_size = bytes;
}
/// **The cull pass** — reset every chunk's indirect counter, publish this
/// frame's viewport/LOD, and dispatch the per-way frustum + LOD test for BOTH
/// lanes.
///
/// Lifted out of [`MapPaintCallback::prepare`] so the proof
/// (`gpu_cull_drives_the_indirect_draw_count`) drives the REAL dispatch rather
/// than a re-spelled copy of it. A test that rebuilt this loop would prove only
/// that the copy works — exactly the twin-watcher shape LAW #5 rejects, and the
/// reason this path went untested for as long as it did.
#[doc(hidden)]
/// **THE cull dispatch — count, scan, copy — for one lane's chunks.**
///
/// ONE writer, because there are TWO lanes: the legacy whole-payload
/// [`Self::run_cull`] and the tile lane's [`Self::run_cull_tiles_clocked`]. While
/// the cull was a single dispatch they were two three-line loops and the twinning
/// was invisible. The ordered compaction made it a three-pass sequence, and the
/// first version of that change updated `run_cull` and left the tile lane on the
/// bare copy pass — a tile lane whose `main` reads offsets `count_pass` never
/// wrote and whose `draw_count` stays at the CPU's reset, i.e. **a map that draws
/// nothing**. The whole suite stayed green, because nothing drives the tile cull
/// and reads back what it produced (`tests/tile_gpu_cache.rs` asserts residency and
/// bytes, not pixels). That is the reason this is a function and not a pattern.
///
/// `v24`/`v48` are `(bind group, way count)` for the 24 B `Vertex` streams
/// (hairlines, fill triangles) and the 48 B `LineVertex` streams (roads, fill
/// outlines). Since the compaction became an index list (GG audit row 21) no pass
/// differs between them; the two lists are kept apart only because the two lanes
/// hand them over as two lists.
fn dispatch_cull(
&self,
cpass: &mut wgpu::ComputePass<'_>,
v24: &[(&wgpu::BindGroup, u32)],
v48: &[(&wgpu::BindGroup, u32)],
) {
self.cull_dispatches.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let groups = |n: u32| n.div_ceil(64);
// 1. count + in-workgroup scan, both strides.
cpass.set_pipeline(&self.cull_count_pipeline);
for (bind, n) in v24.iter().chain(v48) {
cpass.set_bind_group(0, *bind, &[]);
cpass.dispatch_workgroups(groups(*n), 1, 1);
}
// 2. one invocation per chunk: the exclusive scan over its workgroup totals,
// and the grand total into `draw_indirect`'s `vertex_count`.
cpass.set_pipeline(&self.cull_scan_pipeline);
for (bind, _) in v24.iter().chain(v48) {
cpass.set_bind_group(0, *bind, &[]);
cpass.dispatch_workgroups(1, 1, 1);
}
// 3. the copy — of INDICES, so one pipeline for every stride.
cpass.set_pipeline(&self.cull_pipeline);
for (bind, n) in v24.iter().chain(v48) {
cpass.set_bind_group(0, *bind, &[]);
cpass.dispatch_workgroups(groups(*n), 1, 1);
}
}
/// **The per-frame reset of one chunk's indirect args** — the ONE writer both cull
/// lanes go through. `index_count` is zeroed for the scan pass to STORE into;
/// `instance_count` is what the draw will multiply the index list by (2 for the
/// road stream: casing then fill, as instance 0 then 1 of one draw — GG audit
/// row 21; 1 for everything else); the three offsets are zero because a chunk's
/// index list and geometry both start at their buffers' start.
fn reset_ctl(queue: &wgpu::Queue, ctl: &wgpu::Buffer, instances: u32) {
let reset = wgpu::util::DrawIndexedIndirectArgs {
index_count: 0,
instance_count: instances,
first_index: 0,
base_vertex: 0,
first_instance: 0,
};
queue.write_buffer(ctl, 0, reset.as_bytes());
}
pub fn run_cull(
&self,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
viewport_bbox: [f32; 4],
lod: u32,
min_fill_extent: f32,
) {
let [x0, y0, x1, y1] = viewport_bbox;
// `index_count: 0` is what the cull's scan pass STORES over; it is the field
// `draw_indexed_indirect` reads back out.
for chunk in self.chunks.iter().chain(&self.fill_chunks) {
Self::reset_ctl(queue, &chunk.indirect_buf, 1);
}
for chunk in self.line_chunks.iter().chain(&self.outline_chunks) {
Self::reset_ctl(queue, &chunk.indirect_buf, chunk.instances);
}
queue.write_buffer(
&self.cull_vp_buf,
0,
bytemuck::bytes_of(&ViewportCull { min: [x0, y0], max: [x1, y1], lod, min_fill_extent, _pad: [0; 2] }),
);
let mut cpass =
encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("cull"), timestamp_writes: None });
// The ordered compaction — count, scan, copy — through the ONE dispatcher both
// lanes go through (see `dispatch_cull`). The 24 B `Vertex` streams (hairlines,
// fill triangles) and the 48 B quad streams (roads, fill outlines) share the
// count and the scan; only the copy knows a stride.
let v24: Vec<(&wgpu::BindGroup, u32)> =
self.chunks.iter().chain(&self.fill_chunks).map(|c| (&c.cull_bind, c.way_count)).collect();
let v48: Vec<(&wgpu::BindGroup, u32)> =
self.line_chunks.iter().chain(&self.outline_chunks).map(|c| (&c.cull_bind, c.way_count)).collect();
self.dispatch_cull(&mut cpass, &v24, &v48);
}
/// [`Self::run_cull`] bracketed by the lane's **pass clock** — the body
/// `MapPaintCallback::prepare` calls, and the body the device test drives, so
/// the timing cannot be measured on a re-spelled copy of the dispatch (LAW #5;
/// the same one-writer reasoning that lifted `run_cull` itself out of
/// `prepare`).
///
/// On a device without TIMESTAMP_QUERY the clock slot probes once, records the
/// honest `gpu_timestamps = Some(false)` verdict, and this is `run_cull` plus
/// one enum check. `begin`/`end` bracket only the encoder commands, so what is
/// measured is the cull compute pass — see the `pass_clock` field doc for why
/// the draws (egui's shared pass) are out of reach.
///
/// `frame` is the host frame this cull belongs to ([`PaneStamp::frame_nr`]): the
/// clock needs it to tell a second pane in the same frame from a new frame (GG #37).
#[doc(hidden)]
#[allow(clippy::too_many_arguments)] // public signature hosts call: kept as is
pub fn run_cull_clocked(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
viewport_bbox: [f32; 4],
lod: u32,
min_fill_extent: f32,
frame: u64,
) {
if let Some(clock) = self.pass_clock.get_or_init(device, queue, lane_name()) {
clock.begin(device, encoder, frame);
}
self.run_cull(queue, encoder, viewport_bbox, lod, min_fill_extent);
if let Some(clock) = self.pass_clock.ready_mut() {
clock.end(encoder);
}
}
/// **How many cull compute passes this renderer has recorded so far** — see the
/// `cull_dispatches` field. Test hook; the live path never reads it.
#[doc(hidden)]
#[must_use]
pub fn cull_dispatch_count(&self) -> u64 {
self.cull_dispatches.load(std::sync::atomic::Ordering::Relaxed)
}
/// **How many PANES hold retained still-frame gate state** (lane HO-T4).
///
/// The gate of lane GG #13 skips a tile frame's uniform writes, indirect resets and
/// cull dispatch when nothing changed. What it compares against lives here, and this
/// says how many panes' worth of it the renderer is holding — the number that must be
/// 2 while a main map and an inset are both up, and must fall back to 1 the frame
/// after the inset stops being drawn (the same `retain(last_frame + 1 >= frame_nr)`
/// eviction [`Self::pane_target`] applies, so a gone pane leaks nothing).
#[doc(hidden)]
#[must_use]
pub fn tile_cull_gate_len(&self) -> usize {
self.tile_gates.len()
}
/// **How many times EACH pane's still-frame gate let the cull body through**, as
/// `(pane key, dispatches)` — the per-pane split of [`Self::cull_dispatch_count`]
/// (lane HO-T4). The renderer's total says the gate holds; this says it holds *for
/// every pane* rather than for one of them twice as often. Test hook — it allocates,
/// and the live path never reads it.
#[doc(hidden)]
#[must_use]
pub fn tile_cull_dispatches_per_pane(&self) -> Vec<(u64, u64)> {
self.tile_gates.iter().map(|g| (g.key, g.dispatches)).collect()
}
/// How many pretty-line chunks the upload produced.
#[doc(hidden)]
pub fn line_chunk_count(&self) -> usize {
self.line_chunks.len()
}
/// How many **fill-triangle** chunks the upload produced, and how many
/// **fill-outline** chunks. Uploaded-geometry facts, so a guard can say the lane
/// exists before it says the lane drew.
#[doc(hidden)]
pub fn fill_chunk_count(&self) -> usize {
self.fill_chunks.len()
}
#[doc(hidden)]
pub fn outline_chunk_count(&self) -> usize {
self.outline_chunks.len()
}
/// The **fill lane's** GPU-computed vertex counts — `(triangle_verts,
/// outline_verts)` summed over chunks, read straight out of the buffers
/// `draw_indirect` consumes.
///
/// **This is the applied output of the fill cull**, and it is the only number
/// that can tell a working fill lane from a lane that uploaded geometry and then
/// culled all of it away. A frame drawn by the GPU and a frame drawn by the CPU
/// painter are meant to look the same — which is precisely why a picture cannot
/// be the guard here (LAW 2). Test hook only: the live path never reads these
/// back.
#[doc(hidden)]
pub fn fill_draw_counts(&self, device: &wgpu::Device, queue: &wgpu::Queue) -> (u32, u32) {
let tris: u32 =
self.fill_chunks.iter().map(|c| Self::read_indirect_count(device, queue, &c.indirect_buf)).sum();
let outlines: u32 =
self.outline_chunks.iter().map(|c| Self::read_indirect_count(device, queue, &c.indirect_buf)).sum();
(tris, outlines)
}
/// **Copy the first `bytes` of a device buffer to the CPU** — the ONE readback body
/// every test hook in this module shares (the count hooks, the compaction readback,
/// the tile lane's). Blocking; test hooks only, the live path never reads back.
/// `bytes` must be a multiple of `COPY_BUFFER_ALIGNMENT` (4).
fn read_back(device: &wgpu::Device, queue: &wgpu::Queue, src: &wgpu::Buffer, bytes: u64) -> Vec<u8> {
if bytes == 0 {
return Vec::new();
}
let staging = alloc_buffer(device, &wgpu::BufferDescriptor {
label: Some("readback"),
size: bytes,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_buffer_to_buffer(src, 0, &staging, 0, bytes);
queue.submit(Some(enc.finish()));
staging.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).ok();
let out = staging.slice(..).get_mapped_range().expect("wgpu: get_mapped_range on a mapped buffer").to_vec();
staging.unmap();
out
}
/// Copy one control block's `index_count` — word 0 of its `DrawIndexedIndirectArgs`,
/// the number the GPU's scan pass stored and the draw consumes — off the device.
fn read_indirect_count(device: &wgpu::Device, queue: &wgpu::Queue, indirect: &wgpu::Buffer) -> u32 {
let words = Self::read_back(device, queue, indirect, 4);
u32::from_le_bytes([words[0], words[1], words[2], words[3]])
}
/// Read back each pretty-line chunk's `index_count` — the number the GPU itself
/// computed and that `draw_indexed_indirect` consumes.
///
/// **Test hook only.** The live path never does this: the whole point of item 2
/// is that the count is produced and consumed on the device with no readback.
/// Copying it to the CPU here is how the proof observes what the draw saw.
#[doc(hidden)]
pub fn line_draw_counts(&self, device: &wgpu::Device, queue: &wgpu::Queue) -> Vec<u32> {
self.line_chunks.iter().map(|c| Self::read_indirect_count(device, queue, &c.indirect_buf)).collect()
}
/// Read back the first `verts` `LineVertex` of a chunk's compacted stream — the
/// vertices the vertex shader will actually pull, in the order it pulls them.
///
/// **Test hook only.** The compaction is an index list (GG audit row 21), so this
/// reads the first `verts` indices and resolves each through the chunk's geometry
/// buffer — which is exactly what the indexed draw does on the device. An index the
/// cull never wrote reads as 0 and resolves to the chunk's first vertex, which is
/// as wrong a vertex as any other for the order proofs in `tests/cull_order.rs` and
/// `tests/cull_indirect.rs`; an index past the geometry is reported as a panic here
/// rather than the out-of-range fetch it would be on the device.
#[doc(hidden)]
pub fn line_compact_readback(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
chunk: usize,
verts: u32,
) -> Vec<LineVertex> {
let Some(c) = self.line_chunks.get(chunk) else { return Vec::new() };
if verts == 0 {
return Vec::new();
}
let idx_bytes = Self::read_back(device, queue, &c.index_buf, u64::from(verts) * 4);
let indices: &[u32] = bytemuck::cast_slice(&idx_bytes);
let src_bytes = Self::read_back(device, queue, &c.src_buf, c.src_bytes);
let src: &[LineVertex] = bytemuck::cast_slice(&src_bytes);
indices.iter().map(|&i| src[i as usize]).collect()
}
/// **The ONE chunk builder** for a 24 B [`Vertex`] stream: partition it under
/// `max_buffer_size` (fix #12), upload each span's geometry + per-way meta, and
/// wire the cull bind group that will compact it.
///
/// Three streams route through this — the retained hairlines, the fill
/// triangles and the fill outlines. They differ only in the topology the DRAW
/// uses; the partition, the rebase, the buffer usages and the cull binding are
/// identical, so they are written once (LAW #5). An empty stream yields no
/// chunks, which is exactly what a lines-only or fills-only pane needs.
/// **The ordered compaction's scratch for one chunk**, and the cull bind group
/// that carries it. ONE writer: the 24 B chunk uploader, the 48 B quad uploader
/// and the tile lane all mint their cull bind group here, so a chunk cannot end
/// up with a `way_off` sized for a different way count than its own meta.
///
/// `way_off` is one `u32` per way; `wg_sum` is one per 64-way workgroup. Both are
/// written before they are read within the same compute pass, so neither needs an
/// initial value — but a zero-way chunk would ask for a zero-sized buffer, which
/// wgpu refuses, hence the `max(1)`.
#[allow(clippy::too_many_arguments)] // one argument per binding of the bind group it builds
fn cull_bind_group(
device: &wgpu::Device,
layout: &wgpu::BindGroupLayout,
label: &str,
meta_buf: &wgpu::Buffer,
geom_buf: &wgpu::Buffer,
index_buf: &wgpu::Buffer,
ctl_buf: &wgpu::Buffer,
vp_buf: &wgpu::Buffer,
) -> wgpu::BindGroup {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some(label),
layout,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: meta_buf.as_entire_binding() },
wgpu::BindGroupEntry { binding: 1, resource: geom_buf.as_entire_binding() },
wgpu::BindGroupEntry { binding: 2, resource: index_buf.as_entire_binding() },
wgpu::BindGroupEntry { binding: 3, resource: ctl_buf.as_entire_binding() },
wgpu::BindGroupEntry { binding: 4, resource: vp_buf.as_entire_binding() },
],
})
}
/// **The size of one chunk's control block** — the `DrawIndexedIndirectArgs` header
/// plus the ordered compaction's scan scratch, in ONE buffer because a compute stage
/// is guaranteed only FOUR storage bindings (see `cull.wgsl`'s binding-3 note).
///
/// `5` words of indexed indirect args (the shader's `HEADER_WORDS`; the twin is
/// asserted below against wgpu's own struct) + one word per 64-way workgroup + one
/// word per way. ONE writer, asked by both chunk uploaders and by the tile lane, so
/// a buffer can never be minted too small for the way count its meta carries.
fn ctl_bytes(way_count: u32) -> u64 {
const HEADER_WORDS: u64 = (std::mem::size_of::<wgpu::util::DrawIndexedIndirectArgs>() / 4) as u64;
const _: () = assert!(HEADER_WORDS == 5);
(HEADER_WORDS + u64::from(way_count.div_ceil(64)) + u64::from(way_count)) * 4
}
fn upload_vertex_chunks(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
verts: &[Vertex],
metas: &[GpuWayMeta],
label: &str,
// WHICH viewport-cull uniform binding 4 points at: the renderer's shared one
// (legacy whole-payload path) or a TILE's own (per-tile origins mean per-tile
// rebased cull viewports).
vp_buf: &wgpu::Buffer,
) -> Vec<GeomChunk> {
if verts.is_empty() || metas.is_empty() {
return Vec::new();
}
let budget = facett_core::render::gpu::chunk_budget(self.max_buffer_size);
let mut out = Vec::new();
for span in facett_core::render::gpu::partition_chunks(metas, budget) {
let (lo, hi, vlo, vhi) = (span.way_lo, span.way_hi, span.vert_lo, span.vert_hi);
let chunk_verts = &verts[vlo as usize..vhi as usize];
// Rebase per-way meta to this chunk's geom buffer (vert_start - vlo),
// via the shared core helper.
let chunk_metas: Vec<GpuWayMeta> = facett_core::render::gpu::rebase_chunk_meta(metas, span);
let vert_bytes = bytemuck::cast_slice::<Vertex, u8>(chunk_verts);
let meta_bytes = bytemuck::cast_slice::<GpuWayMeta, u8>(&chunk_metas);
// The geometry is BOTH the cull's storage input and the draw's vertex buffer:
// the compaction refers into it by index and nothing copies it again.
let geom_buf = alloc_buffer(device, &wgpu::BufferDescriptor {
label: Some(label),
size: vert_bytes.len() as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(&geom_buf, 0, vert_bytes);
let meta_buf = alloc_buffer(device, &wgpu::BufferDescriptor {
label: Some("way_meta"),
size: meta_bytes.len() as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(&meta_buf, 0, meta_bytes);
// ── THE INDEX LIST, one u32 per vertex of the chunk (GG audit row 21). ──
// This is the bound the cull's output needs and the bound the VRAM claim
// rests on. A compaction can emit at most every vertex once, so V indices
// always suffice; and V indices cost 4·V bytes against the 24·V (or 48·V)
// the vertex copy it replaces had to reserve. Per chunk of V vertices and
// n ways the whole bill is then
//
// geom + meta + index + ctl = 24V + 32n + 4V + 4(5 + ⌈n/64⌉ + n)
//
// whose overhead over the geometry+meta payload is 4V/(24V+32n) + (4n +
// 4⌈n/64⌉ + 20)/(24V+32n) < 1/6 + 1/30 for any chunk of ≥ 16 ways — under
// the 1.2× `tests/vram_ratio.rs` holds, by arithmetic and not by fixture.
// Preallocated once with the chunk and reused every frame: the cull
// rewrites its prefix, the draw reads `index_count` of it, nothing grows.
let index_buf = alloc_buffer(device, &wgpu::BufferDescriptor {
label: Some("compact_idx"),
size: (chunk_verts.len() * 4) as u64,
usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let way_count = (hi - lo) as u32;
// The indirect args AND the ordered compaction's scan scratch — see
// `Self::ctl_bytes` and `cull.wgsl`'s binding-3 note.
let indirect_buf = alloc_buffer(device, &wgpu::BufferDescriptor {
label: Some("indirect"),
size: Self::ctl_bytes(way_count),
usage: wgpu::BufferUsages::INDIRECT
| wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::COPY_DST
| wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let cull_bind = Self::cull_bind_group(
device,
&self.cull_bgl,
"cull_bg",
&meta_buf,
&geom_buf,
&index_buf,
&indirect_buf,
vp_buf,
);
out.push(GeomChunk {
geom_bytes: geom_buf.size(),
meta_bytes: meta_buf.size(),
index_bytes: index_buf.size(),
ctl_bytes: indirect_buf.size(),
geom_buf,
meta_buf,
index_buf,
indirect_buf,
cull_bind,
way_count,
});
}
out
}
/// Upload all geometry to VRAM, **split into chunks** (fix #12). Called once on
/// first frame. Partitions ways so each chunk's `geom`/`compact` buffer stays
/// `≤ max_buffer_size`; a way never straddles a boundary (its vertices live in
/// one chunk, with chunk-relative `vert_start`). egui's own colour pass also
/// caps the per-buffer index/vertex sizes, so respecting `max_buffer_size`
/// keeps the whole submission valid.
fn upload(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, geo: &GpuGeometry) {
// ── THE UPLOAD WALL CLOCK (perf/v2.3). ────────────────────────────────
// Started before the clear so the drop of the previous payload's VRAM is
// inside the measured window — it is real per-event cost of a re-bake.
// Noted at the bottom, HERE and only here, so the live `prepare` path and
// `upload_for_test` count the same event through the same writer (LAW #5).
let upload_t0 = web_time::Instant::now();
// ── REPLACE, do not append. ────────────────────────────────────────────
// This is not "called once on first frame" any more and has not been since
// `MapFacet` got a *signature* latch instead of a bool one. Its whole point is
// that a toggled layer or a freshly streamed viewport RE-BAKES — and `push`ing
// the new chunks onto the old left the previous payload in VRAM, still
// dispatched by `run_cull` and still drawn by `paint`. A layer switched off kept
// its roads on screen (the exact failure `gpu_geom_sig`'s doc says a bool latch
// would cause), and every pan/fetch leaked a payload's worth of VRAM.
// MEASURED on a real device before this line existed: two uploads left **6**
// chunks where a lone upload of the second payload leaves 1.
// Dropping the `Buffer`s here releases their VRAM; the bind groups that
// referenced them go with the chunks.
self.chunks.clear();
self.line_chunks.clear();
self.fill_chunks.clear();
self.outline_chunks.clear();
self.geom_ready = false;
// Every stream may legitimately be absent on its own: a fills-only pane has
// no quads, a lines-only pane has no `fills`. Bailing on the FIRST one (which
// this did) left `geom_ready` false for a fills-only pane, so the fill lane
// could never encode a frame no matter what was in the payload.
//
// The test is over the streams that DRAW. It used to name `vertices`/`way_meta`
// — the hairline stream — which nothing draws and which is now empty by
// construction (`HAIRLINE_LANE`); reading the gate off a dead stream would have
// made a lines-only pane bail and taken the whole quad lane down with it.
if geo.line_meta.is_empty() && geo.fills.is_empty() {
return; // graceful: no ways → no GPU buffers, nothing draws
}
// CONS-CORE Phase C: the load-bearing fix-#12 partition logic now lives once
// in `facett_core::render::gpu::buffer` (extracted verbatim). We call it here
// so the chunking is a single source of truth — the bytes produced are
// identical (the selftest's chunk_count / chunk_buffer_bytes are unchanged),
// and `cull.wgsl` / `draw.wgsl` semantics are untouched.
//
// TWO streams share this one body (hairlines, fill triangles) — both 24 B
// `Vertex` culled at the default `VPV = 6`, so a second copy of the
// chunk-building would be the twin LAW #5 forbids. The fill OUTLINES left this
// body with `mapgpu-1`: they are 48 B `LineVertex` now and go through
// `upload_line_chunks` beside the roads.
let vp = self.cull_vp_buf.clone();
self.chunks = self.upload_vertex_chunks(device, queue, &geo.vertices, &geo.way_meta, "geom", &vp);
self.fill_chunks =
self.upload_vertex_chunks(device, queue, &geo.fills.tri_vertices, &geo.fills.tri_meta, "fill_tri", &vp);
let (outline_chunks, outline_payload_bytes) =
self.upload_line_chunks(device, &geo.fills.outline_vertices, &geo.fills.outline_meta, &vp, OUTLINE_INSTANCES);
self.outline_chunks = outline_chunks;
// ── Pretty-line quad geometry → chunked cull + indirect sets ──────────
// The quads now go through the SAME per-way cull the hairlines do, so each
// chunk gets the full geom/meta/index/indirect set. Partitioned by the
// shared fix-#12 chunker over the 48 B `LineVertex` stride, so a way never
// straddles a boundary and `vert_start` stays chunk-relative.
self.line_lod_end = geo.line_lod_end;
// Payload bytes the pretty-line loop below hands the device, summed at the ONE
// place the buffers are filled.
let (line_chunks, line_payload_bytes) =
self.upload_line_chunks(device, &geo.line_vertices, &geo.line_meta, &vp, ROAD_INSTANCES);
self.line_chunks = line_chunks;
self.geom_ready = true;
// ── NOTE THE UPLOAD EVENT: wall µs + the exact payload bytes handed over. ─
// Bytes are what the device was actually GIVEN — every `write_buffer` /
// `create_buffer_init` payload: the three 24 B `Vertex` streams' geometry +
// per-way meta (the chunk partition preserves totals: every span's rebased
// meta has the span's way count, and the spans tile the input) plus the
// pretty-line quads + their meta. Index/indirect buffers are ALLOCATED but
// never written here, so they are capacity, not upload, and are excluded —
// the device test asserts EQUALITY against the payload it fed, which is what
// keeps this accounting from silently dropping a stream (LAW 2).
let uploaded_bytes = self
.chunks
.iter()
.chain(&self.fill_chunks)
.map(|c| c.geom_bytes + c.meta_bytes)
.sum::<u64>()
+ line_payload_bytes
+ outline_payload_bytes;
facett_core::render::lane::note_upload(
lane_name(),
upload_t0.elapsed().as_micros() as u64,
uploaded_bytes,
);
}
/// **Build the pretty-line chunk set for one payload** — the ONE body the legacy
/// whole-payload `upload` and the tile lane's `upload_tile` both go through
/// (LAW #5). `vp_buf` is which viewport-cull uniform binding 4 points at;
/// `instances` is the draw's instance count — [`ROAD_INSTANCES`] for the road stream
/// (casing + fill as two instances of one draw), [`OUTLINE_INSTANCES`] for the
/// fill outlines. Returns the chunks plus the payload bytes actually handed to the
/// device.
fn upload_line_chunks(
&self,
device: &wgpu::Device,
line_vertices: &[LineVertex],
line_meta: &[GpuWayMeta],
vp_buf: &wgpu::Buffer,
instances: u32,
) -> (Vec<LineChunk>, u64) {
let mut chunks = Vec::new();
let mut payload_bytes: u64 = 0;
if line_vertices.is_empty() || line_meta.is_empty() {
return (chunks, payload_bytes);
}
let stride = std::mem::size_of::<LineVertex>() as u64;
let budget = facett_core::render::gpu::chunk_budget_stride(self.max_buffer_size, stride);
let spans = facett_core::render::gpu::partition_chunks_stride(line_meta, budget, stride);
for span in spans {
let verts = &line_vertices[span.vert_lo as usize..span.vert_hi as usize];
let metas: Vec<GpuWayMeta> = facett_core::render::gpu::rebase_chunk_meta(line_meta, span);
let vert_bytes = bytemuck::cast_slice::<LineVertex, u8>(verts);
let meta_bytes = bytemuck::cast_slice::<GpuWayMeta, u8>(&metas);
payload_bytes += (vert_bytes.len() + meta_bytes.len()) as u64;
// The quads: the cull's storage input AND the draw's vertex buffer (see
// `upload_vertex_chunks`). `COPY_SRC` is for `line_compact_readback`, which
// resolves the index list back into vertices the way the draw does.
let src_buf = alloc_buffer_init(device, &wgpu::util::BufferInitDescriptor {
label: Some("line_src"),
contents: vert_bytes,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_SRC,
});
let meta_buf = alloc_buffer_init(device, &wgpu::util::BufferInitDescriptor {
label: Some("line_meta"),
contents: meta_bytes,
usage: wgpu::BufferUsages::STORAGE,
});
// One index per vertex of the chunk — the cull's output bound and the VRAM
// bound, argued at `upload_vertex_chunks`; at 48 B a vertex the index list
// is 1/12 of the geometry it replaces a copy of.
let index_buf = alloc_buffer(device, &wgpu::BufferDescriptor {
label: Some("line_compact_idx"),
size: (verts.len() * 4) as u64,
usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let way_count = (span.way_hi - span.way_lo) as u32;
let indirect_buf = alloc_buffer(device, &wgpu::BufferDescriptor {
label: Some("line_indirect"),
size: Self::ctl_bytes(way_count),
usage: wgpu::BufferUsages::INDIRECT
| wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::COPY_DST
| wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let cull_bind = Self::cull_bind_group(
device,
&self.cull_bgl,
"line_cull_bg",
&meta_buf,
&src_buf,
&index_buf,
&indirect_buf,
vp_buf,
);
chunks.push(LineChunk {
src_bytes: src_buf.size(),
meta_bytes: meta_buf.size(),
index_bytes: index_buf.size(),
ctl_bytes: indirect_buf.size(),
src_buf,
meta_buf,
index_buf,
indirect_buf,
cull_bind,
way_count,
instances,
});
}
(chunks, payload_bytes)
}
/// **Upload ONE tile's payload into the VRAM cache** — keyed by the CONTENT key
/// the facet computed, so an identical tile can never be uploaded twice and a
/// changed one can never be served stale. Everything a tile needs to cull and
/// draw against its OWN origin lives on the entry: its viewport-cull uniform,
/// its draw/fill uniform pair and its pretty-line uniform pair.
fn upload_tile(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, key: u128, geo: &GpuGeometry) {
let upload_t0 = web_time::Instant::now();
let cull_vp_buf = alloc_buffer(device, &wgpu::BufferDescriptor {
label: Some("tile_cull_vp"),
size: std::mem::size_of::<ViewportCull>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let chunks = self.upload_vertex_chunks(device, queue, &geo.vertices, &geo.way_meta, "tile_geom", &cull_vp_buf);
let fill_chunks =
self.upload_vertex_chunks(device, queue, &geo.fills.tri_vertices, &geo.fills.tri_meta, "tile_fill", &cull_vp_buf);
let (outline_chunks, outline_bytes) = self.upload_line_chunks(
device,
&geo.fills.outline_vertices,
&geo.fills.outline_meta,
&cull_vp_buf,
OUTLINE_INSTANCES,
);
let (line_chunks, line_bytes) =
self.upload_line_chunks(device, &geo.line_vertices, &geo.line_meta, &cull_vp_buf, ROAD_INSTANCES);
let mk_uniform = |label: &str| {
alloc_buffer(device, &wgpu::BufferDescriptor {
label: Some(label),
size: std::mem::size_of::<DrawUniforms>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
};
let draw_uniform = mk_uniform("tile_draw_uniforms");
let fill_uniform = mk_uniform("tile_fill_uniforms");
let bind_of = |label: &str, buf: &wgpu::Buffer| {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some(label),
layout: &self.draw_bgl,
entries: &[wgpu::BindGroupEntry { binding: 0, resource: buf.as_entire_binding() }],
})
};
let draw_bind = bind_of("tile_draw_bg", &draw_uniform);
let fill_bind = bind_of("tile_fill_bg", &fill_uniform);
let line_u = self.line_renderer.tile_uniforms(device);
// What this tile HOLDS in VRAM — every buffer minted above, by its real size.
// The budget is enforced against this number, so it is the card's bill and not
// an estimate of it: `tests/vram_ratio.rs` pins the cache census to the
// allocator's own count. (It used to be `(line + outline payload) * 2`, an
// estimate that overcounted the meta and omitted the control blocks and the
// line uniforms — 1.1 MB off on eight tiles.)
let bytes: u64 = chunks.iter().chain(&fill_chunks).map(GeomChunk::bytes).sum::<u64>()
+ outline_chunks.iter().chain(&line_chunks).map(LineChunk::bytes).sum::<u64>()
+ draw_uniform.size()
+ fill_uniform.size()
+ cull_vp_buf.size()
+ line_u.bytes();
let uploaded: u64 = chunks
.iter()
.chain(&fill_chunks)
.map(|c| c.geom_bytes + c.meta_bytes)
.sum::<u64>()
+ line_bytes
+ outline_bytes;
self.tile_gpu.insert(
key,
TileGpu {
chunks,
fill_chunks,
outline_chunks,
line_chunks,
draw_uniform,
draw_bind,
fill_uniform,
fill_bind,
line_u,
cull_vp_buf,
bytes,
last_used: self.tile_frame,
},
);
tile_registry_mark_known(key);
// A fresh tile's indirect counters have never been written: the still-frame
// gate in `run_cull_tiles_clocked` must see a new generation and cull.
self.tile_upload_gen += 1;
facett_core::render::lane::note_upload(lane_name(), upload_t0.elapsed().as_micros() as u64, uploaded);
}
/// Evict least-recently-DRAWN tiles until the cache fits the VRAM budget. The
/// current frame's set (`self.tile_current`, read in place — it used to arrive as
/// a clone of itself) is never a candidate (it is about to be drawn). Evicted
/// keys leave the known-registry, so the facet rebuilds and resends them if they
/// are ever wanted again — the miss is a re-upload, never a blank tile.
fn evict_tiles_over_budget(&mut self) {
let budget = self.tile_budget_override.unwrap_or_else(tile_vram_budget_bytes);
let mut total: u64 = self.tile_gpu.values().map(|t| t.bytes).sum();
while total > budget {
let victim = self
.tile_gpu
.iter()
.filter(|(k, _)| !self.tile_current.iter().any(|(c, _, _, _)| c == *k))
.min_by_key(|(_, t)| t.last_used)
.map(|(k, _)| *k);
let Some(k) = victim else { break };
if let Some(t) = self.tile_gpu.remove(&k) {
total -= t.bytes;
}
// Residency changed: whatever the still-frame gate saw last is no longer
// what it would see. (Belt and braces — a current tile is never evicted.)
self.tile_upload_gen += 1;
tile_registry_mark_unknown(k);
}
}
/// **The tile lane's per-frame GPU writes, and the cull they feed — or nothing,
/// when the frame is still.** For every current tile: its five draw uniforms
/// (draw, fill, casing, line fill, outline), the reset of every chunk's indirect
/// counter, its OWN rebased cull viewport (per-tile origins ⇒ per-tile cull
/// spaces), then one compute pass dispatching every current tile's chunks under
/// both cull pipelines. The clock brackets the pass exactly as `run_cull_clocked`
/// does the legacy body.
///
/// # The still-frame gate (lane GG #13)
///
/// Every byte this writes is a function of `self.tile_current`, `inputs`, and
/// which tiles are resident (`tile_upload_gen`). When all three equal what the
/// last call saw, the writes would be identical and the cull would land on the
/// identical `vertex_count`s — so the whole body is skipped, and the indirect and
/// compact buffers the previous frame left behind ARE this frame's draw. That is
/// only sound because the reset is skipped TOGETHER with the dispatch: a reset
/// without a cull would hand `draw_indirect` a zero. Measured on t14s / Radeon
/// 890M (`tests/tile_gpu_cache.rs::a_still_frame_dispatches_no_cull_..`): 100
/// dispatches over 100 still frames before, 0 after.
///
/// # …and it is PER PANE (lane HO-T4)
///
/// `self.tile_current` is refilled by every callback's `prepare`, and GG #37 made two
/// callbacks in one frame the shipped case (korp's main map plus an inset). With the
/// gate's memory on the RENDERER the two panes alternated through it and neither ever
/// matched its own previous frame — 0 dispatches over 16 still frames with one pane,
/// **32** with two (t14s / Radeon 890M, `tests/gpu_lane_two_panes.rs`). So the memory
/// is a [`TileCullGate`] keyed by `pane` ([`PaneStamp::pane`]), evicted on the same
/// rule [`Self::pane_target`] evicts a target on.
///
/// The key has to be the pane and not the renderer for CORRECTNESS as well as cost: a
/// per-renderer key that happened to match would skip pane B's writes while the tile
/// uniforms and indirect counters still held pane A's camera, and B would draw A's
/// culled set. The two-pane pixel oracle asserts against exactly that.
fn run_cull_tiles_clocked(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
pane: u64,
inputs: &TileFrameInputs,
frame: u64,
) {
// A pane not prepared in this frame or the one before is gone: drop its gate, as
// `pane_target` drops its target. `retain` over two or three entries, moving
// nothing when nothing goes.
self.tile_gates.retain(|g| g.last_frame + 1 >= frame);
let i = match self.tile_gates.iter().position(|g| g.key == pane) {
Some(i) => i,
None => {
self.tile_gates.push(TileCullGate {
key: pane,
last_frame: 0,
last: None,
current: Vec::new(),
dispatches: 0,
});
self.tile_gates.len() - 1
}
};
self.tile_gates[i].last_frame = frame;
let seen = (*inputs, self.tile_upload_gen);
if self.tile_gates[i].last == Some(seen) && self.tile_current == self.tile_gates[i].current {
return;
}
// Disjoint fields: the gate is borrowed mutably while `tile_current` is read.
let gate = &mut self.tile_gates[i];
gate.last = Some(seen);
gate.dispatches += 1;
gate.current.clear();
gate.current.extend_from_slice(&self.tile_current);
if let Some(clock) = self.pass_clock.get_or_init(device, queue, lane_name()) {
clock.begin(device, encoder, frame);
}
let TileFrameInputs { lod, min_fill_extent, logical_w, logical_h, fill_tint, casing_col, width_scale } = *inputs;
for (key, transform, bbox, _) in &self.tile_current {
let Some(t) = self.tile_gpu.get(key) else { continue };
queue.write_buffer(&t.draw_uniform, 0, bytemuck::bytes_of(&transform.uniforms(logical_w, logical_h)));
queue.write_buffer(
&t.fill_uniform,
0,
bytemuck::bytes_of(&transform.uniforms_tinted(logical_w, logical_h, fill_tint)),
);
self.line_renderer.write_uniforms_to(
queue,
&t.line_u.u_road,
&t.line_u.u_outline,
transform,
logical_w,
logical_h,
LINE_CASING_PX,
casing_col,
LINE_FEATHER_PX,
width_scale,
);
for chunk in t.chunks.iter().chain(&t.fill_chunks) {
Self::reset_ctl(queue, &chunk.indirect_buf, 1);
}
for chunk in t.line_chunks.iter().chain(&t.outline_chunks) {
Self::reset_ctl(queue, &chunk.indirect_buf, chunk.instances);
}
let [x0, y0, x1, y1] = *bbox;
queue.write_buffer(
&t.cull_vp_buf,
0,
bytemuck::bytes_of(&ViewportCull { min: [x0, y0], max: [x1, y1], lod, min_fill_extent, _pad: [0; 2] }),
);
}
let mut cpass =
encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("tile_cull"), timestamp_writes: None });
// The SAME three-pass dispatcher the legacy path runs. It has to be the same
// one: while it was two hand-written loops, the ordered compaction landed on
// one of them and left this lane drawing nothing, green.
let mut v24: Vec<(&wgpu::BindGroup, u32)> = Vec::new();
let mut v48: Vec<(&wgpu::BindGroup, u32)> = Vec::new();
for (key, _, _, _) in &self.tile_current {
let Some(t) = self.tile_gpu.get(key) else { continue };
for chunk in t.chunks.iter().chain(&t.fill_chunks) {
v24.push((&chunk.cull_bind, chunk.way_count));
}
for chunk in t.line_chunks.iter().chain(&t.outline_chunks) {
v48.push((&chunk.cull_bind, chunk.way_count));
}
}
self.dispatch_cull(&mut cpass, &v24, &v48);
drop(cpass);
if let Some(clock) = self.pass_clock.ready_mut() {
clock.end(encoder);
}
}
/// **Test hook — DRIVE THE TILE LANE'S CULL and read back what it produced.**
///
/// This did not exist, and its absence is why the ordered compaction could land on
/// the legacy cull, leave the tile lane on the bare copy pass, and pass the whole
/// suite: `tile_gpu_cache.rs` asserts residency, bytes and the wanted-registry, all
/// of which are true of a lane that draws nothing. The applied output of a cull is
/// the `vertex_count` `draw_indirect` will read, and nothing was reading it for a
/// tile.
///
/// `tiles` is `(content key, cull bbox in that tile's OWN rebased space)`.
#[doc(hidden)]
pub fn cull_tiles_for_test(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
tiles: &[(u128, [f32; 4])],
lod: u32,
min_fill_extent: f32,
) {
self.tile_current = tiles
.iter()
.map(|(k, bb)| (*k, MapTransform { zoom_x: 1.0, zoom_y: 1.0, ref_x: 0.0, ref_y: 0.0, screen_center_x: 0.0, screen_center_y: 0.0, bearing_deg: 0.0, tilt_deg: 0.0 }, *bb, 0))
.collect();
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("tile_cull_test") });
// The uniform half of the inputs is inert here — the hook reads back indirect
// counts, never pixels — but it goes through the ONE gated writer all the same.
let inputs = TileFrameInputs {
lod,
min_fill_extent,
logical_w: 1.0,
logical_h: 1.0,
fill_tint: 0.0,
casing_col: LINE_CASING_COL,
width_scale: 1.0,
};
// One hook call is one submitted frame, so the tile-lane prepare counter
// stands in for the host's frame number here.
self.tile_frame += 1;
let frame = self.tile_frame;
// One hook, one pane: the default `PaneStamp::pane` a callback built without a
// `Ui` carries, so the gate this drives is the same one a single-pane host uses.
self.run_cull_tiles_clocked(device, queue, &mut enc, PaneStamp::default().pane, &inputs, frame);
queue.submit(Some(enc.finish()));
device.poll(wgpu::PollType::wait_indefinitely()).ok();
}
/// **Test hook** — the `vertex_count` every chunk of one resident tile would draw,
/// as `(24 B streams, 48 B quad stream)`. Read straight out of the buffers
/// `draw_indirect` reads.
#[doc(hidden)]
#[must_use]
pub fn tile_draw_counts(&self, device: &wgpu::Device, queue: &wgpu::Queue, key: u128) -> (Vec<u32>, Vec<u32>) {
let Some(t) = self.tile_gpu.get(&key) else { return (Vec::new(), Vec::new()) };
let read = |b: &wgpu::Buffer| -> u32 { Self::read_indirect_count(device, queue, b) };
(
t.chunks.iter().chain(&t.fill_chunks).map(|c| read(&c.indirect_buf)).collect(),
t.line_chunks.iter().chain(&t.outline_chunks).map(|c| read(&c.indirect_buf)).collect(),
)
}
/// How many tiles the VRAM cache currently holds (test/observability hook).
#[doc(hidden)]
#[must_use]
pub fn tile_cache_len(&self) -> usize {
self.tile_gpu.len()
}
/// Total bytes the tile cache accounts for (test/observability hook).
#[doc(hidden)]
#[must_use]
pub fn tile_cache_bytes(&self) -> u64 {
self.tile_gpu.values().map(|t| t.bytes).sum()
}
/// Drive [`Self::upload_tile`] from a test (the live caller is `prepare`).
#[doc(hidden)]
pub fn upload_tile_for_test(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, key: u128, geo: &GpuGeometry) {
self.upload_tile(device, queue, key, geo);
}
/// Is this key resident in the tile cache? (test hook)
#[doc(hidden)]
#[must_use]
pub fn tile_resident(&self, key: u128) -> bool {
self.tile_gpu.contains_key(&key)
}
/// Pin the tile VRAM budget for a test (bytes).
#[doc(hidden)]
pub fn set_tile_vram_budget_for_test(&mut self, bytes: u64) {
self.tile_budget_override = Some(bytes);
}
/// Drive the LRU eviction from a test with an explicit current set.
#[doc(hidden)]
pub fn evict_tiles_for_test(&mut self, current_keys: &[u128]) {
self.tile_frame += 1;
let frame = self.tile_frame;
for k in current_keys {
if let Some(t) = self.tile_gpu.get_mut(k) {
t.last_used = frame;
}
}
let cur: Vec<(u128, MapTransform, [f32; 4], i32)> = current_keys
.iter()
.map(|k| {
(
*k,
MapTransform::new([0.0, 0.0], [0.0, 0.0], 1.0, 1.0, egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1.0, 1.0))),
[0.0; 4],
0,
)
})
.collect();
self.tile_current = cur;
self.evict_tiles_over_budget();
}
}
/// **One tile's resident VRAM** — its chunk sets plus the per-tile uniforms that
/// let it cull and draw against its OWN bake origin. Lives in
/// [`GpuMapRenderer::tile_gpu`], which SURVIVES facet rebuilds — that survival is
/// the entire point of the tile lane.
struct TileGpu {
chunks: Vec<GeomChunk>,
fill_chunks: Vec<GeomChunk>,
outline_chunks: Vec<LineChunk>,
line_chunks: Vec<LineChunk>,
draw_uniform: wgpu::Buffer,
/// The bind for the retained `LineList` pipeline — see
/// [`GpuMapRenderer::draw_pipeline`]. Unsubmitted since `mapgpu-1`.
#[allow(dead_code, reason = "retained LineList A/B path — mapgpu-1")]
draw_bind: wgpu::BindGroup,
fill_uniform: wgpu::Buffer,
fill_bind: wgpu::BindGroup,
line_u: line::LineTileUniforms,
cull_vp_buf: wgpu::Buffer,
/// What this tile costs the card (payload + compact capacity + uniforms).
bytes: u64,
/// The last [`GpuMapRenderer::tile_frame`] this tile was in the draw set.
last_used: u64,
}
/// **One tile's hand-over from the facet to the renderer** — the per-frame unit of
/// the tile lane. `geometry` is `Some` only when the facet believes the renderer
/// does not hold this key (first sight, or the renderer reported it wanted); every
/// other frame the key alone rides over, which is what makes a pan over cached
/// ground upload nothing.
pub struct TileHandover {
/// The CONTENT key — a hash of everything the payload is a function of (the
/// ways' geometry + style + the tile's own origin). Computed by the facet
/// (`layer::tile_content_key`); the renderer treats it as opaque.
pub key: u128,
/// **The group's DRAW BAND** — `crate::paint_order::way_draw_band` of every way in
/// it (a group is single-band by construction; see
/// `MapFacet::add_gpu_tiled_callback`). `paint` walks the hand-over list band-major
/// and submits fills → outlines → lines *per band*, which is what makes the declared
/// order hold ACROSS tiles instead of only inside one.
pub band: i32,
/// This tile's transform for THIS frame (its own origin, this camera).
pub transform: MapTransform,
/// This tile's cull viewport, rebased against ITS origin.
pub viewport_bbox: [f32; 4],
/// The payload, when the facet believes the renderer needs it.
pub geometry: Option<Box<GpuGeometry>>,
}
/// The tile lane's **known/wanted registry** — how the facet (UI side) and the
/// renderer (device side) agree about which keys are resident without the facet
/// ever holding a device handle.
///
/// * `known`: keys the renderer HOLDS. Written on upload, erased on evict.
/// * `wanted`: keys a `prepare` saw in its draw set, without payload, that the
/// renderer does NOT hold (an evicted key still in some facet's roster, or a
/// second window's renderer). The facet drains its keys from here and resends —
/// a self-healing loop that converges in one frame.
///
/// Content-addressed keys are what make ONE process-wide registry sound: the same
/// key IS the same bytes, so two facets naming one key genuinely share one upload.
struct TileRegistry {
known: std::collections::HashSet<u128>,
wanted: std::collections::HashSet<u128>,
}
fn tile_registry() -> &'static std::sync::Mutex<TileRegistry> {
static CELL: std::sync::OnceLock<std::sync::Mutex<TileRegistry>> = std::sync::OnceLock::new();
CELL.get_or_init(|| {
std::sync::Mutex::new(TileRegistry {
known: std::collections::HashSet::new(),
wanted: std::collections::HashSet::new(),
})
})
}
fn tile_registry_mark_known(key: u128) {
if let Ok(mut r) = tile_registry().lock() {
r.known.insert(key);
r.wanted.remove(&key);
}
}
fn tile_registry_mark_unknown(key: u128) {
if let Ok(mut r) = tile_registry().lock() {
r.known.remove(&key);
}
}
/// **A renderer in a draw set needs this key's payload and does not hold it.**
///
/// Marked UNCONDITIONALLY, and that is the whole point. It used to be
/// `if !r.known.contains(&key) { … }` — a guard that reads as "don't ask for what
/// somebody already has", and `known` is **process-global while residency is
/// per-renderer**. So the only caller (`MapPaintCallback::prepare`, which has already
/// established that *this* renderer does not hold the key) was silently ignored for
/// exactly the keys that most needed resending, and the tile stayed **blank forever**:
///
/// * a second window's renderer, on every key the first window uploaded;
/// * a renderer re-created after a device loss;
/// * a fresh `install_renderer` in the same process — which is how this was caught
/// (`tests/gpu_lane_paint_order.rs`, oden / RTX 4090, 2026-08-23: the lake simply did
/// not paint, and the non-vacuity assertion said so instead of the ordering claim
/// passing on an empty pane).
///
/// The doc on [`TileRegistry`] promised "a self-healing loop that converges in one
/// frame". It could not converge at all while `known` was non-empty from another
/// renderer. `tile_payload_needed` takes the key back out of `wanted` as it answers, so
/// an unconditional mark is still exactly ONE resend per miss.
fn tile_registry_mark_wanted(key: u128) {
if let Ok(mut r) = tile_registry().lock() {
r.wanted.insert(key);
}
}
/// **Does the facet need to BUILD and SEND this key's payload this frame?**
/// True when the renderer does not hold it, or has explicitly asked for it again.
/// A poisoned lock answers `true` — degraded to re-sending, never to a blank tile.
#[must_use]
pub fn tile_payload_needed(key: u128) -> bool {
match tile_registry().lock() {
Ok(mut r) => !r.known.contains(&key) || r.wanted.remove(&key),
Err(_) => true,
}
}
/// **[`tile_payload_needed`] for a whole frame's worth of keys, under ONE lock.**
///
/// The per-key form takes the registry mutex once per call, and a real korp viewport is
/// hundreds of groups (MEASURED on the real device, `tests/gpu_lane_share.rs`: **900
/// groups over 150 producer-stamped tiles for 1 200 ways**, 6.00 groups per tile) — so
/// asking it per group is hundreds of lock/unlock pairs per frame for an answer that is
/// almost always "no" for every one of them. One lock, one pass, same semantics per key
/// including the `wanted` one-shot take.
///
/// Answers `true` for every key on a poisoned lock, exactly as the per-key form does:
/// degraded to re-sending, never to a blank tile.
#[must_use]
pub fn tile_payloads_needed(keys: impl Iterator<Item = u128>) -> Vec<bool> {
match tile_registry().lock() {
Ok(mut r) => keys.map(|k| !r.known.contains(&k) || r.wanted.remove(&k)).collect(),
Err(_) => keys.map(|_| true).collect(),
}
}
/// The tile cache's VRAM budget: `FACETT_MAP_TILE_VRAM_MB` (default **2048**).
/// Sized for the box this lane was built on (an RTX 4090 idling at 7 % memory
/// use); a smaller card sets the env. Floor 64 MB so a typo cannot thrash.
fn tile_vram_budget_bytes() -> u64 {
let mb = std::env::var("FACETT_MAP_TILE_VRAM_MB")
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.unwrap_or(2048)
.max(64);
mb * 1024 * 1024
}
// ── Per-frame paint callback ──────────────────────────────────────────────────
/// **Which pane, in which frame** — lane GG #37.
///
/// A [`MapPaintCallback`] carries one so the renderer can keep ONE offscreen target per
/// pane and free the target of a pane that stopped being drawn. korp draws a main map
/// and can draw an inset (a second `MapFacet`) in the same frame; egui-wgpu runs every
/// callback's `prepare` and then every callback's `paint`, so anything the renderer
/// held per RENDERER — the cleared target, the blit's UV uniform, the pass clock's ring
/// — was overwritten by the second callback before the first had painted. MEASURED on
/// t14s / Radeon 890M, `tests/gpu_lane_two_panes.rs`, 2026-09-05.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct PaneStamp {
/// A stable id for the pane: the facet's response `egui::Id`. egui keeps it the
/// same across frames for a stable layout and distinct between two facets in one
/// frame — which is exactly what a VRAM cache key wants. NOT the pane rect: a rect
/// changes on every layout animation and would reallocate ~52 MB per frame.
pub pane: u64,
/// egui's `cumulative_frame_nr` when the callback was placed. The pass clock
/// counts frames by it, and a pane unseen for a whole frame is freed by it.
pub frame_nr: u64,
}
impl PaneStamp {
/// The stamp for the pane `id` names, placed in the `ui`'s current frame.
#[must_use]
pub fn of(ui: &egui::Ui, id: egui::Id) -> Self {
Self { pane: id.value(), frame_nr: ui.ctx().cumulative_frame_nr() }
}
}
pub struct MapPaintCallback {
/// Which pane this callback paints, in which frame — see [`PaneStamp`].
pub stamp: PaneStamp,
/// First-frame geometry payload — `Some` once, then `None` every later frame.
pub geometry: Option<Box<GpuGeometry>>,
/// **The TILE lane's hand-over** — `Some` switches this callback to the
/// content-keyed VRAM cache (per-tile origins, per-tile uniforms, survives
/// facet rebuilds); `None` is the legacy whole-payload path, byte-identical
/// to before the lane existed.
pub tiles: Option<Vec<TileHandover>>,
/// Current viewport in **origin-local** Mercator space [x0, y0, x1, y1] (same
/// space as the rebased per-way meta bboxes — [`MapTransform::viewport_bbox`]).
pub viewport_bbox: [f32; 4],
/// Current LOD level: 0=country 1=region 2=city.
pub lod: u32,
/// The origin-local per-axis line transform (see [`MapTransform`]). The viewport
/// size is folded in during `prepare` (only known from the `ScreenDescriptor`).
pub transform: MapTransform,
/// deck.gl `widthScale` for this frame's zoom
/// ([`crate::style::line_width_scale`]) — the line shader multiplies the baked raw
/// width by this and clamps into the pixel band, so widths track zoom live and
/// match the CPU painter exactly.
pub width_scale: f32,
/// The **fill lane's** RGB multiplier for this frame — `1.0` on a flat map,
/// `1 − 0.5·(tilt/MAX_TILT)` on a pitched one. See [`DrawUniforms::tint`]; it is
/// the CPU painter's `layer::fade`, moved to where a static VRAM bake can still
/// obey it.
pub fill_tint: f32,
/// The casing ink for this frame, straight off the active palette
/// (`MapTheme::palette.casing`) — linear-ish sRGB f32 as the shader wants it.
/// `None` falls back to [`LINE_CASING_COL`], which is what a callback built in a
/// test without a palette gets.
pub casing_col: Option<[f32; 4]>,
}
impl egui_wgpu::CallbackTrait for MapPaintCallback {
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(renderer) = resources.get_mut::<GpuMapRenderer>() else {
return vec![];
};
// ── THE TILE LANE ────────────────────────────────────────────────────
// Content-keyed chunk sets that survive facet rebuilds: upload only the
// hand-overs that carry a payload, note the ones the cache is missing
// (self-healing via the wanted-registry), evict past the VRAM budget,
// write every current tile's per-frame uniforms, cull, done. The legacy
// whole-payload body below never runs for a tiled callback.
if let Some(tiles) = &self.tiles {
renderer.tile_frame += 1;
let frame = renderer.tile_frame;
for h in tiles {
if let Some(geo) = &h.geometry {
if !renderer.tile_gpu.contains_key(&h.key) {
renderer.upload_tile(device, queue, h.key, geo);
}
} else if !renderer.tile_gpu.contains_key(&h.key) {
// In the draw set, no payload, not resident: an evicted key
// (or another renderer's). Ask the facet to resend.
tile_registry_mark_wanted(h.key);
}
if let Some(t) = renderer.tile_gpu.get_mut(&h.key) {
t.last_used = frame;
}
}
// Refilled in place: the draw set is the same length frame after frame,
// so this allocates only when a frame carries more tiles than any before.
renderer.tile_current.clear();
renderer.tile_current.extend(tiles.iter().map(|h| (h.key, h.transform, h.viewport_bbox, h.band)));
renderer.evict_tiles_over_budget();
// Per-tile uniforms + indirect resets + cull, through the ONE gated writer
// (`run_cull_tiles_clocked`), which skips all of it when this frame is
// byte-identical to the last. The minimum-extent gate is computed from
// THIS frame's camera by the one Rust writer and handed to the shader as
// a number (stinky `kalm-3`). `zoom_x` IS `MapView::world_px` — see
// `MapTransform::new`'s call site.
let inputs = TileFrameInputs {
lod: self.lod,
min_fill_extent: crate::style::min_fill_extent_merc(f64::from(self.transform.zoom_x)),
logical_w: sd.size_in_pixels[0] as f32 / sd.pixels_per_point,
logical_h: sd.size_in_pixels[1] as f32 / sd.pixels_per_point,
fill_tint: self.fill_tint,
casing_col: self.casing_col.unwrap_or(LINE_CASING_COL),
width_scale: self.width_scale,
};
// THIS pane's gate (HO-T4) — the same `PaneStamp` key `pane_target` uses.
renderer.run_cull_tiles_clocked(device, queue, encoder, self.stamp.pane, &inputs, self.stamp.frame_nr);
facett_core::render::lane::note_frame(lane_name());
self.record_offscreen(device, queue, encoder, sd, renderer);
return vec![];
}
renderer.tile_current.clear();
if let Some(geo) = &self.geometry {
renderer.upload(device, queue, geo);
}
if !renderer.geom_ready {
return vec![];
}
let logical_w = sd.size_in_pixels[0] as f32 / sd.pixels_per_point;
let logical_h = sd.size_in_pixels[1] as f32 / sd.pixels_per_point;
queue.write_buffer(
&renderer.draw_uniform,
0,
bytemuck::bytes_of(&self.transform.uniforms(logical_w, logical_h)),
);
// The fill draw's copy: same camera, plus the pitched-horizon fade the CPU
// painter applies to a FILL and not to its outline. `fill_tint == 1.0` (a flat
// map) makes this buffer byte-identical to the one above.
queue.write_buffer(
&renderer.fill_uniform,
0,
bytemuck::bytes_of(&self.transform.uniforms_tinted(logical_w, logical_h, self.fill_tint)),
);
// Pretty-line lane: refresh the casing + fill uniforms for THIS frame's
// camera (same origin-local transform as the draw uniform above). The quad
// vertex buffer is uploaded once; only these per-frame uniforms change.
renderer.line_renderer.write_uniforms(
queue,
&self.transform,
logical_w,
logical_h,
LINE_CASING_PX,
self.casing_col.unwrap_or(LINE_CASING_COL),
LINE_FEATHER_PX,
self.width_scale,
);
// The clocked body (perf/v2.3): `run_cull` bracketed by the lane's pass
// clock — TIMESTAMP_QUERY where the device has it, an honest recorded
// "unavailable" where it does not. One writer; the device test drives the
// same method.
renderer.run_cull_clocked(
device,
queue,
encoder,
self.viewport_bbox,
self.lod,
crate::style::min_fill_extent_merc(f64::from(self.transform.zoom_x)),
self.stamp.frame_nr,
);
// LANE ATTRIBUTION (`facett_core::render::lane`): a frame of real GPU work is
// now encoded against a real device — uniforms written, cull dispatched, the
// geometry ready. Counted HERE, after every fail-safe early-return above, so
// the count can never claim a frame the GPU did not see. This is what lets an
// About box say "GPU lane: IN USE" instead of inferring it from
// `renderer_installed()`, which is true even when nothing ever paints.
facett_core::render::lane::note_frame(lane_name());
self.record_offscreen(device, queue, encoder, sd, renderer);
vec![]
}
fn paint(
&self,
info: egui::PaintCallbackInfo,
render_pass: &mut wgpu::RenderPass<'static>,
resources: &egui_wgpu::CallbackResources,
) {
let Some(renderer) = resources.get::<GpuMapRenderer>() else { return };
// `geom_ready` is the LEGACY latch — the tile lane's readiness is the
// resident cache itself, consulted per key below.
if self.tiles.is_none() && !renderer.geom_ready {
return;
}
// ── THE VIEWPORT MUST BE THE WHOLE SURFACE, because the vertices are in
// WHOLE-SURFACE coordinates. ────────────────────────────────────────
//
// `prepare` writes `DrawUniforms::viewport_{w,h}` from the ScreenDescriptor —
// the whole logical surface — and the shader's `px_to_ndc` divides the
// vertex's screen-space px by exactly that. So the vertex stream lives in the
// WINDOW's coordinate space, and NDC ±1 must therefore mean the WINDOW's
// edges.
//
// egui-wgpu does not leave it that way. `Renderer::render` sets the render
// pass viewport to the CALLBACK'S OWN RECT before invoking us ("a default
// viewport … as a courtesy … the user still has the possibility of setting
// their own custom viewport during the paint callback, effectively overriding
// this one" — egui-wgpu 0.35 `renderer.rs`). NDC ±1 then means the PANE's
// edges, and the whole window coordinate space is squeezed into the pane:
//
// drawn_px = pane_origin + window_px · (pane_size / window_size)
//
// MEASURED on the shipped korp-ui on a real Xvfb window, 2026-08-03, at two
// different window sizes, all four corners within 2 px of that formula: at
// 1900×1150 with the map pane at (36,382) 1258×728, Sweden's north-west
// corner belongs at screen (563.0, 491.5) and was drawn at (407, 691); at
// 1300×900 with the pane at (36,400) 850×460 it belongs at (359.0, 375.5)
// and was drawn at (269, 593) — predicted (270.7, 591.9). The landmass fill
// and every label, which the CPU painter projects through `MapView::project`,
// landed on the correct pixel in the same frame. So the map showed its
// coastline and its roads as a second, smaller, displaced Sweden beside the
// right one.
//
// Why no test saw it: `tests/map_facet_gpu_lane.rs` asserts `MapTransform`
// agrees with `MapView::project` — a CPU-side comparison that never converts
// to NDC and never learns what viewport the pass will carry. And a pane that
// IS the whole surface (every offscreen `Harness` render) has
// `pane_size == window_size` and `pane_origin == 0`, which makes the wrong
// formula the identity. The bug is invisible in exactly the posture the
// tests render in, and present in exactly the posture a human lands in.
//
// The scissor is NOT touched: egui-wgpu sets `set_scissor_rect(clip_rect)`
// before the callback and that still clips us to the pane. Only the
// coordinate mapping changes.
// **THE MAP IS ALREADY DRAWN.** Since lane MP-a the geometry goes into this
// lane's OWN colour+depth target in `prepare`, so the rank ordering survives
// being split across tiles and chunks — `PaintKey` cannot reach across two
// submissions and depth can. What is left here is one textured triangle.
//
// egui has already set the pass viewport to this callback's pane rect, so the
// triangle covers that viewport and samples the PANE'S sub-rectangle of the
// whole-surface texture. Getting the two the wrong way round draws the whole
// window shrunk into the pane — the same failure this lane measured on 2026-08-03.
// THIS pane's target and blit uniform (GG #37) — `prepare` placed it under the
// stamp this callback carries; a pane `prepare` never reached has nothing to blit.
let Some(pane) = renderer.panes.iter().find(|p| p.key == self.stamp.pane) else { return };
let t0 = web_time::Instant::now();
// The pane's sub-rectangle of the whole-surface texture, in 0..1. Computed HERE
// because `PaintCallbackInfo` is what knows the callback's rect, and written
// through the queue the renderer kept from `prepare` — a uniform write is the
// only thing `paint` needs a queue for. Into the PANE's uniform: a
// `write_buffer` lands before the submit, not between two callbacks, so one
// shared uniform gave every pane the LAST pane's rect.
let vp = info.viewport_in_pixels();
let (sw, sh) = pane.target.size;
let uv = [
vp.left_px as f32 / sw as f32,
vp.top_px as f32 / sh as f32,
(vp.left_px + vp.width_px) as f32 / sw as f32,
(vp.top_px + vp.height_px) as f32 / sh as f32,
];
if let Some(q) = renderer.queue.as_ref() {
q.write_buffer(&pane.blit_uniform, 0, bytemuck::cast_slice(&uv));
}
render_pass.set_pipeline(&renderer.blit_pipeline);
render_pass.set_bind_group(0, &pane.blit_bind, &[]);
render_pass.draw(0..3, 0..1);
note_blit_us(t0.elapsed().as_micros() as u64);
}
}
impl MapPaintCallback {
/// **Draw the map into its own colour+depth target** — lane MP-a.
///
/// Called from BOTH of `prepare`'s exits, which is the whole reason it is a method:
/// the tile lane returns early (it has its own uniform-writing loop and then
/// `note_frame`), so an offscreen block written only at the end of `prepare` never
/// ran for the path every GPU oracle uses — six of seven went dark and the failure
/// read as "the lake did not paint", not as "the pass was never recorded".
fn record_offscreen(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
sd: &egui_wgpu::ScreenDescriptor,
renderer: &mut GpuMapRenderer,
) {
//
// egui's pass has no depth attachment, so the map draws into its own and
// `paint` blits the result. The target is the WHOLE SURFACE and the viewport is
// left at the full texture: the vertices are already in whole-surface
// coordinates (`DrawUniforms::viewport_*` is the ScreenDescriptor's size and the
// shader divides by exactly that), so there are none of the pane-rect gymnastics
// egui's pass forces on `paint`.
//
// Depth clears to 1.0 and the comparison is `Less` (`map_depth_state`), which is
// what `paint_order::depth_of` was built against: higher rank, smaller depth,
// closer — and it holds across tiles and chunks, which is the whole point.
//
// **ONE TARGET PER PANE** (GG #37). korp's main map and its inset are two
// callbacks in one frame, and egui-wgpu runs both `prepare`s before either
// `paint`: with one target the second clear wiped the first pane's map before
// it was blitted. The target is looked up by the pane key the callback carries
// and allocated on a new key or a size change only.
if renderer.queue.is_none() {
renderer.queue = Some(queue.clone());
}
let size = (sd.size_in_pixels[0].max(1), sd.size_in_pixels[1].max(1));
let pane = renderer.pane_target(device, self.stamp, size);
{
let t = &renderer.panes[pane].target;
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("facett_map_offscreen"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &t.colour,
depth_slice: None,
// **The resolve** (lane GG #2). The 4× samples are averaged into the
// single-sample texture the blit reads, at the end of the pass, by the
// hardware. `StoreOp::Discard` on the 4× attachment itself: nothing
// reads it after the resolve, so there is no reason to write 4× a
// full surface back to memory.
resolve_target: Some(&t.resolved),
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Discard,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &t.depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Discard,
}),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
self.record_draws(&mut pass, renderer, size.0, size.1);
}
}
/// **Record the map's draws into a pass that HAS a depth attachment** — lane MP-a.
///
/// Lifted verbatim out of `paint`, which now blits instead. The body is unchanged:
/// the band-major walk, the per-tile bind groups, the three topologies in painter's
/// order. What changed is the pass it records into — one with depth, so everything
/// finer than a band (the tier, the area fallback, the osm id) is decided by Z
/// rather than by which tile happened to be submitted first.
fn record_draws(
&self,
render_pass: &mut wgpu::RenderPass<'_>,
renderer: &GpuMapRenderer,
sw: u32,
sh: u32,
) {
// ── THE TILE LANE's draw: **BAND-MAJOR**. One run per declared draw band,
// ascending, and inside a run the three topologies in painter's order
// (fills → their outlines → lines). Per-tile bind groups carry each tile's
// own origin transform.
//
// What this replaced: fills across ALL tiles, then outlines across all, then
// lines across all. Its comment claimed that held the class order across
// tiles; it held it only for the SAME class. Each tile's stream is sorted on
// `PaintKey` independently, so a building in tile 1 and the lake overlapping
// it in tile 2 stacked by `tile_key` — MEASURED on oden / RTX 4090,
// 2026-08-23: moving that pair between the two tiles changed 9 660 of 208 000
// pixels (4.64 %) and in one arrangement the building was not on screen at
// all (`tests/gpu_lane_paint_order.rs`).
//
// A group is single-band by construction (`add_gpu_tiled_callback` keys on
// `(draw_band, tile_key)`), and the hand-over list is sorted on that pair —
// so equal bands are already contiguous and this walks runs, sorting
// nothing. Everything finer than a band (the `Lod` tier, the area fallback,
// the osm id) is still decided inside the run by the ordered compaction.
// The draw census (GG audit row 21): commands issued, `(group, stream, chunk)`
// units walked, band runs walked — tallied here and published once at the end.
let (mut draws, mut units, mut runs) = (0u64, 0u64, 0u64);
if self.tiles.is_some() {
let cur = &renderer.tile_current;
let mut i = 0usize;
while i < cur.len() {
let band = cur[i].3;
let mut j = i;
while j < cur.len() && cur[j].3 == band {
j += 1;
}
let run = &cur[i..j];
runs += 1;
// **The BAND's depth, through the viewport's depth range** (lane MP-a).
//
// The line lane packs a per-WAY depth into its vertex, because inside one
// band the tier still separates a motorway from a residential. A FILL has
// no such problem: `way_draw_band` for a fill IS its class (water 100,
// park 200, building 300), so one depth per run is exactly right — and
// `set_viewport`'s min/max depth maps the shader's `z = 0` onto it with
// no change to the 24 B fill `Vertex`, which is as load-bearing as the
// line one.
//
// Without this every fill sat at z = 0, the closest possible value, and
// painted over every line — which is what two of the seven oracles caught
// the moment depth testing came on.
let d = crate::paint_order::depth_of(band, false);
render_pass.set_viewport(0.0, 0.0, sw as f32, sh as f32, d, d);
render_pass.set_pipeline(&renderer.fill_pipeline);
for (key, _, _, _) in run {
let Some(t) = renderer.tile_gpu.get(key) else { continue };
render_pass.set_bind_group(0, &t.fill_bind, &[]);
for chunk in &t.fill_chunks {
draws += draw_chunk(render_pass, &chunk.geom_buf, &chunk.index_buf, &chunk.indirect_buf);
units += 1;
}
}
// **Back to the full depth range for the LINES.** The fills above are
// collapsed onto one band depth, which is right for them; a line carries
// its OWN per-way depth in the vertex, and leaving the range pinned would
// flatten every road in the run onto the band and undo exactly the
// cross-tile ordering this lane exists to fix. Measured: with the range
// left pinned, six oracles pass and the cross-tile ROAD one still fails.
render_pass.set_viewport(0.0, 0.0, sw as f32, sh as f32, 0.0, 1.0);
for (key, _, _, _) in run {
let Some(t) = renderer.tile_gpu.get(key) else { continue };
for chunk in &t.outline_chunks {
draws += renderer.line_renderer.draw_indirect_outline(
render_pass,
&chunk.src_buf,
&chunk.index_buf,
&chunk.indirect_buf,
&t.line_u.bind_outline,
);
units += 1;
}
}
for (key, _, _, _) in run {
let Some(t) = renderer.tile_gpu.get(key) else { continue };
for chunk in &t.line_chunks {
draws += renderer.line_renderer.draw_indirect_bound(
render_pass,
&chunk.src_buf,
&chunk.index_buf,
&chunk.indirect_buf,
&t.line_u,
);
units += 1;
}
}
i = j;
}
note_draws(draws, units, runs);
return;
}
// ── Pretty GPU line lane, drawn INDIRECTLY (GFX_V2 item 2) ────────────
// The index count comes from the GPU. `prepare`'s cull pass frustum- and
// LOD-tested every way and compacted the survivors' vertex indices into
// `index_buf`, storing `indirect_buf.index_count`; the draw below reads that
// count straight out of the buffer without the CPU ever learning it. No
// readback, no round-trip, no CPU-side visible count.
//
// What this replaced: `0..line_lod_end[lod]`, a CPU-computed prefix of the
// WHOLE dataset with no frustum test at all — every road in the region was
// submitted every frame and clipped by the rasteriser. Meanwhile the cull
// pass ran anyway and compacted the 24 B hairline stream that nothing drew.
//
// Each road draw is two INSTANCES (dark casing UNDER, bright fill on top) of
// one command, both reading the same count from the same buffer.
//
// ── NO WHOLE-CLASS STENCIL CASING PASS HERE, and it is measured (lane GG #26) ─
// The audit asks this function for "a casing pass for the whole class before
// fills (stencil Replace)", on the premise that casings of one class do not
// merge at intersections. They do. The casing/fill split is per CHUNK, not per
// way, and across chunks and tiles the merge rides on the rank: a casing sits one
// `CASING_DEPTH_BIAS` behind its fill, `LessEqual` accepts an equal rank and
// rejects a farther one, and two ways of one class share a rank. Pixel evidence
// and the reason a stencil pass would be a regression against GG #2's sample-mask
// nesting: `line::LineRenderer::draw_indirect`.
// ── FILLS FIRST, then their outlines, then the roads ──────────────────
// The CPU painter got this ordering from `DrawFeature::z` and a
// `partition_point` in `MapFacet::ui`: areas (z = Polygon) under lines
// (z = Line). Inside one callback there is no z, so the order is the
// submission order — which is why the two fill draws are here and not after
// the roads. A lake drawn last would paint over every road crossing it.
render_pass.set_pipeline(&renderer.fill_pipeline);
render_pass.set_bind_group(0, &renderer.fill_bind, &[]);
for chunk in &renderer.fill_chunks {
draws += draw_chunk(render_pass, &chunk.geom_buf, &chunk.index_buf, &chunk.indirect_buf);
units += 1;
}
for chunk in &renderer.outline_chunks {
draws += renderer.line_renderer.draw_indirect_outline(
render_pass,
&chunk.src_buf,
&chunk.index_buf,
&chunk.indirect_buf,
renderer.line_renderer.outline_bind(),
);
units += 1;
}
for chunk in &renderer.line_chunks {
draws +=
renderer.line_renderer.draw_indirect(render_pass, &chunk.src_buf, &chunk.index_buf, &chunk.indirect_buf);
units += 1;
}
// The whole payload is one implicit band run.
note_draws(draws, units, 1);
}
}
/// **The ONE indexed indirect draw of a chunk** — geometry as the vertex buffer, the
/// cull's index list as the index buffer, the control block as the args. Returns the
/// number of draw commands it issued (one), so the draw census is tallied where the
/// command is recorded and nowhere else. Pipeline and bind group are the caller's:
/// they are set once per run, not once per chunk.
pub(crate) fn draw_chunk(
pass: &mut wgpu::RenderPass<'_>,
vertices: &wgpu::Buffer,
indices: &wgpu::Buffer,
ctl: &wgpu::Buffer,
) -> u64 {
pass.set_vertex_buffer(0, vertices.slice(..));
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed_indirect(ctl, 0);
1
}
/// Publish one `record_draws` walk's census — see [`DRAW_CALLS`].
fn note_draws(draws: u64, units: u64, runs: u64) {
DRAW_CALLS.fetch_add(draws, std::sync::atomic::Ordering::Relaxed);
DRAW_UNITS.fetch_add(units, std::sync::atomic::Ordering::Relaxed);
DRAW_BAND_RUNS.fetch_add(runs, std::sync::atomic::Ordering::Relaxed);
}
// ── Bind-group layout helpers ─────────────────────────────────────────────────
fn bgl_storage(binding: u32, read_write: bool) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: !read_write },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}
}
fn bgl_uniform(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}
}
// ── CPU helpers ───────────────────────────────────────────────────────────────
// CONS-CORE Phase C: re-exported from the shared L0 plumbing (byte-identical to the
// old facett-map fn — the core `color32_to_f32_maps_channels` test pins it).
pub use facett_core::render::gpu::color32_to_f32;
// ── Origin-local transform (the high-zoom f32-cancellation fix) ───────────────
//
// Both 2D GPU line callers (`facett_geomap::osm2d::OsmView` and
// `facett_map::MapView`) used to hand-roll `screen = mercator*zoom + offset`, which
// added two ~4e7 f32 whose true pixel was their tiny difference → catastrophic
// cancellation and multi-pixel drift at street-level zoom. The fix (shared here per
// the REUSE law) bakes vertices relative to a **data-local origin** and projects
// `screen = (pos − ref)*zoom + screen_center`, mirroring the CPU `to_px`'s
// subtract-before-scale. See `draw.wgsl`.
/// The dataset's Mercator **bbox-min**, computed once in f64 — the data-local origin
/// every vertex, the shader `ref`, the cull viewport and the per-way meta bboxes are
/// rebased against. Camera-independent, so it is stable across frames (the
/// upload-once vertex latch is preserved). Uses each way's precomputed Mercator bbox
/// (O(ways), not O(vertices)), so it is cheap to call every frame. Callers MUST pass
/// the SAME origin to [`build_gpu_geometry`] and [`MapTransform::new`] (single source
/// of truth) or the frustum cull silently drops visible ways.
pub fn ways_origin<'a>(ways: impl Iterator<Item = &'a Way>) -> Option<[f64; 2]> {
let mut o: Option<[f64; 2]> = None;
for w in ways {
let (x, y) = (w.bbox[0], w.bbox[1]);
o = Some(match o {
None => [x, y],
Some([a, b]) => [a.min(x), b.min(y)],
});
}
o
}
/// The origin-local, per-axis line transform the GPU draw shader consumes. Both 2D
/// callers build one via [`MapTransform::new`] (single shared constructor), so the
/// `DrawUniforms` packing + the rebased cull viewport live in ONE place.
///
/// Fields are already SMALL (relative to `origin`): `ref = camera_center − origin`
/// and `screen_center` is a pixel coordinate, so the shader's `(pos − ref)` forms a
/// precise small difference before scaling by `zoom`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MapTransform {
pub zoom_x: f32,
pub zoom_y: f32,
pub ref_x: f32,
pub ref_y: f32,
pub screen_center_x: f32,
pub screen_center_y: f32,
/// **The camera's compass bearing in degrees** — 0 is north-up.
///
/// This field is the whole of `mapdraw-3`. Without it `MapTransform` was the 2D
/// view core *with the rotation deleted*, so `MapFacet::way_lane_plan` had no
/// choice but to answer `CpuOnly::RotatedCamera` and hand every way back to the
/// CPU painter the moment the bearing left zero. MEASURED 2026-08-03, real
/// 1600×1000 window on an RTX 4090, line ways only: **0.857 ms → 12.28 ms, a
/// 14.3× regression, with `lane_frames` 249 → 0**. The control that made it a
/// diagnosis rather than a guess: fills-only moved +0.3 % across the same change,
/// because fills were never on the GPU to lose.
pub bearing_deg: f32,
/// The camera's tilt in degrees — 0 is flat. A *tilted orthographic* view
/// foreshortens the vertical axis by exactly `cos(tilt)` with no perspective
/// convergence, which is a real projection and not a fake; see
/// [`facett_core::render::View::ortho_2d_compass`].
pub tilt_deg: f32,
}
impl MapTransform {
/// Build from the data-local `origin`, the Mercator point the camera centres on
/// (`center_merc`, in absolute Mercator so `ref` is formed in f64), the per-axis
/// `zoom` (equal for a uniform camera; different for a non-uniform fit), and the
/// pane `rect` (whose centre is where `center_merc` lands). Mirrors the CPU
/// `to_px = rect.center + (merc − center)·zoom`.
pub fn new(origin: [f64; 2], center_merc: [f64; 2], zoom_x: f32, zoom_y: f32, rect: egui::Rect) -> Self {
let sc = rect.center();
Self {
zoom_x,
zoom_y,
ref_x: (center_merc[0] - origin[0]) as f32,
ref_y: (center_merc[1] - origin[1]) as f32,
screen_center_x: sc.x,
screen_center_y: sc.y,
bearing_deg: 0.0,
tilt_deg: 0.0,
}
}
/// **Turn the camera** — the compass bearing (degrees clockwise, 0 = north-up)
/// and tilt this transform projects through.
///
/// A builder rather than a `new` parameter so every existing caller keeps the
/// north-up flat camera it already asked for, and only a caller that has a
/// bearing has to say so.
#[must_use]
pub fn with_compass(mut self, bearing_deg: f32, tilt_deg: f32) -> Self {
self.bearing_deg = bearing_deg;
self.tilt_deg = tilt_deg;
self
}
/// The **ground frame** this transform applies, at `f64` — rotate then
/// foreshorten, evaluated by the shared
/// [`facett_core::render::GroundFrame2d`]. Taken from the DEGREES (not from
/// [`view`](Self::view)'s `f32` radians), which is what makes it bit-identical to
/// the CPU painter's `MapView::ground_frame`.
#[must_use]
pub fn ground_frame(&self) -> facett_core::render::GroundFrame2d {
facett_core::render::View::ground_frame_2d_compass_deg(
f64::from(self.bearing_deg),
f64::from(self.tilt_deg),
)
}
/// The ground rotation as the shader sees it (`f32`). `[1, 0, 0, 1]` north-up.
#[must_use]
pub fn rot_f32(&self) -> [f32; 4] {
let r = self.ground_frame().rot;
[r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]
}
/// `cos(tilt)` as the shader sees it (`f32`). `1.0` flat.
#[must_use]
pub fn foreshorten_f32(&self) -> f32 {
self.ground_frame().foreshorten as f32
}
/// Pack into the draw-shader uniform (viewport size is only known in `prepare`),
/// with no colour tint — what every stream but the map's fills wants.
pub fn uniforms(&self, viewport_w: f32, viewport_h: f32) -> DrawUniforms {
self.uniforms_tinted(viewport_w, viewport_h, DrawUniforms::NO_TINT)
}
/// [`Self::uniforms`] with the fill lane's pitched-horizon `tint` — see
/// [`DrawUniforms::tint`]. ONE packer; the tint is the only argument, so the two
/// uniform buffers cannot drift on anything else.
pub fn uniforms_tinted(&self, viewport_w: f32, viewport_h: f32, tint: f32) -> DrawUniforms {
DrawUniforms {
zoom_x: self.zoom_x,
zoom_y: self.zoom_y,
screen_center_x: self.screen_center_x,
screen_center_y: self.screen_center_y,
ref_x: self.ref_x,
ref_y: self.ref_y,
viewport_w,
viewport_h,
rot: self.rot_f32(),
foreshorten: self.foreshorten_f32(),
tint,
_pad: [0.0; 2],
}
}
/// **The unified view this 2D transform IS** — GFX_V2 item 6. A `MapTransform`
/// is not a separate kind of camera from the 3D one; it is
/// [`facett_core::render::View`] under the 2D constraints (tilt 0, bearing 0,
/// orthographic, `Z(x,y) ≡ 0`, post-FX bypassed), with `target` at the
/// origin-local `ref` and `px_per_unit` the per-axis `zoom`.
///
/// [`project`](Self::project) and [`viewport_bbox`](Self::viewport_bbox)
/// **delegate to it**, so the 2D lane has no projection writer of its own and
/// an AA / miter / precision fix in the core lands in 2D and 3D at once (LAW
/// #5). The reduction is bit-exact, not approximate — see
/// `facett-map3d/tests/gfx_v2_item6_parity.rs`.
#[must_use]
pub fn view(&self) -> facett_core::render::View {
facett_core::render::View::ortho_2d_compass(
[self.ref_x, self.ref_y],
[self.zoom_x, self.zoom_y],
self.bearing_deg,
self.tilt_deg,
)
}
/// Where [`view`](Self::view)'s `target` lands in pixels (the pane centre).
#[inline]
fn centre_px(&self) -> (f32, f32) {
(self.screen_center_x, self.screen_center_y)
}
/// Project an **origin-local** vertex `pos` to a logical screen pixel — the exact
/// CPU twin of `draw.wgsl`'s `vs_main`, so tests can assert the GPU projection
/// against an f64 reference without a GPU.
///
/// Delegates to the unified view core (ONE writer). `half_h` is unused because
/// the 2D constraint is orthographic — nothing here divides by depth.
#[must_use]
pub fn project(&self, pos: [f32; 2]) -> [f32; 2] {
let p = self.view().project_ground(pos, self.centre_px(), 1.0);
[p.x, p.y]
}
/// The cull viewport bbox `[x0, y0, x1, y1]` in **origin-local** Mercator space —
/// the same space [`build_gpu_geometry`] rebases the per-way meta bboxes into, so
/// the compute cull compares like-for-like. (Inverse of [`project`](Self::project)
/// on the pane corners.)
/// Delegates to the unified view core's ground unprojection, so the cull space
/// and the draw space are **exact inverses by construction** rather than two
/// hand-written affines that have to be kept agreeing. If they ever disagreed
/// the compute cull would silently drop ways that are on screen.
/// # All FOUR corners, because two is only right at bearing 0
///
/// This used to unproject `left_top` and `right_bottom` alone and take their
/// min/max. Under a north-up camera the ground image of the pane is an
/// axis-aligned rectangle and those two corners are its extremes, so the answer
/// was right — and the bug was invisible for exactly as long as the GPU lane
/// refused to run at any other bearing.
///
/// Rotate the camera and the pane's ground image is a rotated rectangle whose
/// axis-aligned envelope is set by all four corners, and the diagonal pair
/// under-covers it. At 45° the two-corner box has **half** the area of the true
/// envelope, so the compute cull would drop ways that are plainly on screen —
/// roads winking out along two opposite edges as the map turns. It would have
/// landed the same day the rotation started working, looking like a bug in the
/// rotation.
///
/// At bearing 0 / tilt 0 the two extra corners are interior to the same box and
/// the result is bit-identical to the old one, so this cannot move a north-up
/// frame.
#[must_use]
pub fn viewport_bbox(&self, rect: egui::Rect) -> [f32; 4] {
let v = self.view();
let c = self.centre_px();
// Ortho never misses the plane, so the `expect` is unreachable by
// construction (`View::unproject_ground` only returns `None` for a
// perspective ray that cannot reach the ground).
let un = |p: egui::Pos2| v.unproject_ground((p.x, p.y), c, 1.0).expect("ortho always hits the plane");
let corners =
[rect.left_top(), rect.right_top(), rect.right_bottom(), rect.left_bottom()].map(un);
let (mut x0, mut y0) = (f32::MAX, f32::MAX);
let (mut x1, mut y1) = (f32::MIN, f32::MIN);
for [x, y] in corners {
x0 = x0.min(x);
y0 = y0.min(y);
x1 = x1.max(x);
y1 = y1.max(y);
}
// ── DILATED BY THE WIDEST STROKE THE PAINTER CAN DRAW (stinky `seam-15`).
//
// The corners give the pane's exact envelope, and a way's meta bbox is its
// CENTRELINE's. A motorway whose centreline misses the pane by a pixel is
// culled, and with it goes its whole 24 px ribbon, its casing and its feather —
// the road stops in mid-air at the edge of the canvas and the next pan snaps it
// back. `zoom_x` IS `MapView::world_px`, so one call to the shared writer
// converts the px pad into this space; the CPU cull widens by the same number.
let pad = crate::style::cull_pad_merc(f64::from(self.zoom_x)) as f32;
[x0 - pad, y0 - pad, x1 + pad, y1 + pad]
}
}
/// One pretty-line polyline of [`build_gpu_geometry`]: its declared paint key, LOD,
/// the polyline, and its origin-local bbox min/max.
type LinePolyEntry = (crate::paint_order::PaintKey, u32, LinePolyline, [f32; 2], [f32; 2]);
/// Build `GpuGeometry` from all ways across all LOD tiers. Runs once at first
/// paint; the result is uploaded to VRAM then dropped. Reads the facett [`Way`]'s
/// `style.color` (instead of osm-viewer's `style.stroke`).
///
/// **Origin-local vertices** (high-zoom precision fix): each vertex is reprojected
/// from the source `lonlat` (f64) via [`crate::mercator`], then rebased against the
/// data-local `origin` — `pos = mercator_f64(lonlat) − origin` — so the GPU never
/// sees an absolute Mercator coordinate. The per-way bbox is rebased against the same
/// `origin` so the compute cull matches [`MapTransform::viewport_bbox`]. `origin` MUST
/// be the SAME value the per-frame [`MapTransform`] uses.
pub fn build_gpu_geometry<'a>(ways: impl Iterator<Item = (&'a Way, u32)>, origin: [f64; 2]) -> GpuGeometry {
let mut vertices: Vec<Vertex> = Vec::new();
let mut way_meta: Vec<GpuWayMeta> = Vec::new();
// ── Pretty-line polylines in ONE list, carrying the DECLARED paint key.
//
// They were bucketed into four LOD tiers here and expanded tier-ascending, which
// made the on-screen stacking of the road network the LOD ladder RUN BACKWARDS: a
// `Country` motorway went into bucket 0 and was therefore drawn FIRST, i.e. under
// every `City` cul-de-sac that happened to be in the viewport. Inside one bucket
// the order was the input order — the order the tiles were read in.
//
// The tier buckets are not needed for the filter any more and have not been since
// the cull went per-way (`meta.lod <= vp.lod` in `cull.wgsl`); `line_lod_end` is a
// reported fact, rebuilt below from the sorted stream so it stays monotone and
// still counts "vertices at or under tier t".
//
// Each entry carries its origin-local bbox so the quad lane gets per-way meta
// for the GPU cull — the SAME bbox `way_meta` uses, so both lanes cull alike.
let mut line_polys: Vec<LinePolyEntry> = Vec::new();
for (way, lod) in ways {
if way.lonlat.len() < 2 {
continue;
}
let col = color32_to_f32(way.style.color);
let vert_start = vertices.len() as u32;
// Reproject each source (lon,lat) to Mercator in f64, rebased to `origin`.
// A per-way f64 Mercator vec avoids projecting shared endpoints twice.
let merc: Vec<[f32; 2]> = way
.lonlat
.iter()
.map(|&(lon, lat)| {
let (mx, my) = crate::mercator(lon, lat);
rebase_vertex_origin(mx, my, origin)
})
.collect();
// The hairline stream — baked only when the lane is on (stinky `mapdraw-4`).
// Expanding it is roughly half of this function's work and nothing draws it.
if HAIRLINE_LANE {
for (a, b) in merc.iter().zip(merc.iter().skip(1)) {
vertices.push(Vertex { pos: *a, col });
vertices.push(Vertex { pos: *b, col });
}
}
let vert_count = vertices.len() as u32 - vert_start;
// Rebase the bbox to the same origin-local space as the vertices + the cull
// viewport (else the frustum cull drops visible ways). Computed ONCE and used
// by both lanes' meta, so the hairline and quad culls can never disagree
// about where a way is.
let bbox_min = [(way.bbox[0] - origin[0]) as f32, (way.bbox[1] - origin[1]) as f32];
let bbox_max = [(way.bbox[2] - origin[0]) as f32, (way.bbox[3] - origin[1]) as f32];
if HAIRLINE_LANE {
way_meta.push(GpuWayMeta { bbox_min, bbox_max, vert_start, vert_count, lod, flags: 0 });
}
// Pretty-line source: the SAME origin-local centreline, with the themed
// width converted to screen px. `merc` is moved in (its last use).
line_polys.push((
crate::paint_order::way_paint_key(way),
lod,
LinePolyline {
pts: merc,
// RAW themed width — the deck.gl width resolution (scale + pixel clamp)
// runs per frame in the vertex shader, not at bake time.
width_px: way.style.width * LINE_PX_PER_WIDTH,
col,
depth: crate::paint_order::depth_of(crate::paint_order::way_rank(way), false),
// The dash gate, from the style (HO-T3): a rail's core is cut along its
// arc length, a road's is not. Rides the vertex — see `pack_dash_half_px`.
dash: way.style.dashed(),
},
bbox_min,
bbox_max,
));
}
// Expand the quads lod-ascending, recording a per-way span as we go so the GPU
// cull can frustum+LOD filter the quad stream. Expanding one polyline at a time
// is a pure regrouping of the old whole-tier call — `LineExpander::push` treats
// polylines independently and simply concatenates, which
// `per_way_quad_expansion_concatenates_to_the_whole_tier_expansion` pins.
//
// **Straight into the shared buffer** (stinky `mapdraw-5`). The regrouping first
// shipped as `extend_from_slice(&build_line_geometry(from_ref(poly)))`, i.e. one
// heap allocation and one full memcpy of every way's quads — MEASURED at 9.01
// allocations a way (108 073 for 12 000 ways, 352 MB of transient heap). The
// expander appends onto `line_vertices` itself and carries its own join scratch,
// so a way costs zero allocations here whatever its length.
let mut line_vertices: Vec<LineVertex> = Vec::new();
let mut line_meta: Vec<GpuWayMeta> = Vec::new();
// **THE declared order** — the same `PaintKey` the CPU painter sorts its
// `DrawFeature`s by, so a road that is above a road on one lane is above it on the
// other. `sort_unstable_by_key` is safe here precisely because the key ends in the
// osm id: there are no ties left for stability to decide.
line_polys.sort_unstable_by_key(|(k, ..)| *k);
let mut per_tier = [0u32; LOD_COUNT];
let mut expander = crate::gpu::line::LineExpander::new();
for (key, lod, poly, bbox_min, bbox_max) in &line_polys {
let vert_start = line_vertices.len() as u32;
// **The way's rank rides into the vertex as a depth** (lane MP-a). `PaintKey`
// orders the stream inside ONE submission and cannot reach across two; the tile
// lane submits per (band, tile) and `way_draw_band` drops the tier, so two roads
// of one band in two tiles are ordered by tile. Depth is what crosses that.
// The depth rides on the polyline (see `LinePolyline::depth`), so the per-way
// bake here and the whole-tier `build_line_geometry` produce the same bytes.
let _ = key;
expander.push(&mut line_vertices, poly);
let vert_count = line_vertices.len() as u32 - vert_start;
let tier = (*lod as usize).min(LOD_COUNT - 1);
per_tier[tier] += vert_count;
line_meta.push(GpuWayMeta {
bbox_min: *bbox_min,
bbox_max: *bbox_max,
vert_start,
vert_count,
lod: tier as u32,
flags: 0,
});
}
// Cumulative "vertices belonging to a way at or under tier t". Monotone by
// construction and `last() == line_vertices.len()`, which is what `decode_baked`
// validates — it is no longer a contiguous span, and nothing draws off it.
let mut line_lod_end = [0u32; LOD_COUNT];
let mut run = 0u32;
for (t, c) in per_tier.iter().enumerate() {
run += c;
line_lod_end[t] = run;
}
GpuGeometry { vertices, way_meta, line_vertices, line_meta, line_lod_end, fills: GpuFillGeometry::default() }
}
/// **Bake the filled ways for the GPU fill lane** — the fill triangles and the ring
/// outlines, in the SAME origin-local Mercator space `build_gpu_geometry` bakes the
/// lines into, so both lanes cull against one viewport box and project through one
/// uniform block.
///
/// `tris` is the way's **cached** ear clip
/// ([`crate::tessellate::FillTessCache::tris`]), indexing into
/// [`crate::tessellate::fill_vertices`]`(outer, holes)` — outer ring first, then each
/// hole ring with ≥ 3 vertices, concatenated. That is exactly the vertex order built
/// below, so the CPU mesh and the GPU mesh are the same triangles off the same
/// indices; a way whose cache slot is empty is skipped rather than re-clipped here
/// (the ear clip must stay off the frame path — it was 85.4 % of it).
///
/// **Why Mercator and not screen space.** The triangulation is affine-invariant, so
/// the indices computed on the Mercator ring are valid at any camera — which is the
/// whole reason the cache works, and it is what lets the triangles be baked ONCE
/// into VRAM instead of re-derived per frame. See the [`crate::tessellate`] note.
///
/// A way is dropped from the lane if it has no cached triangulation or fewer than 3
/// vertices — it then keeps painting on the CPU, because [`MapFacet`] asks the same
/// predicate (`crate::layer::MapFacet::gpu_lane_fills`) rather than a second one.
///
/// [`MapFacet`]: crate::layer::MapFacet
pub fn build_gpu_fill_geometry<'a>(
fills: impl Iterator<Item = (&'a Way, u32, crate::tessellate::FillTris)>,
origin: [f64; 2],
) -> GpuFillGeometry {
let mut g = GpuFillGeometry::default();
// ONE quad expander and ONE closed-ring scratch for the whole bake — both reused
// across every way and every ring, so a ring costs zero allocations of its own
// (the rule `mapdraw-5` established for the road lane, applied to the outline
// stream it did not reach).
let mut expander = line::LineExpander::new();
let mut ring_pts: Vec<[f32; 2]> = Vec::new();
// **THE declared order**, the same key the line lane and the CPU painter sort on.
// Without it the fill stream was the input order — a lake decoded after a park
// painted over it, and which of two overlapping buildings won changed with the
// tile merge. Collected once (an `O(log n)` growth, not the per-way allocation
// `mapdraw-5` removed) because the caller hands an iterator.
let mut fills: Vec<(&'a Way, u32, crate::tessellate::FillTris)> = fills.collect();
fills.sort_unstable_by_key(|(w, _, _)| crate::paint_order::way_paint_key(w));
for (way, lod, tris) in fills {
if way.lonlat.len() < 3 || tris.is_empty() {
continue;
}
let fill_col = color32_to_f32(way.fill.unwrap_or(way.style.color));
let line_col = color32_to_f32(way.style.color);
// The plane this way's outline rides on — its own fill's band, one bias in front
// (lane GG #1). Computed per way, beside the colours it belongs with.
let outline_depth =
crate::paint_order::fill_outline_depth(crate::paint_order::way_draw_band(way));
// The concatenated ring vertex list the cached indices address, rebased to
// `origin`. Built once per way and read by BOTH streams below, so the fill
// and its outline cannot land on different coordinates.
let mut ring_spans: Vec<(usize, usize)> = Vec::with_capacity(1 + way.holes.len());
let mut merc: Vec<[f32; 2]> = Vec::with_capacity(way.lonlat.len());
let push_ring = |merc: &mut Vec<[f32; 2]>, r: &[(f64, f64)]| {
let lo = merc.len();
merc.extend(r.iter().map(|&(lon, lat)| {
let (mx, my) = crate::mercator(lon, lat);
rebase_vertex_origin(mx, my, origin)
}));
(lo, merc.len())
};
ring_spans.push(push_ring(&mut merc, &way.lonlat));
for h in &way.holes {
// `fill_vertices` skips hole rings with < 3 vertices; skip the same ones
// here or every cached index past the first hole is off by that ring.
if h.len() >= 3 {
ring_spans.push(push_ring(&mut merc, h));
}
}
let bbox_min = [(way.bbox[0] - origin[0]) as f32, (way.bbox[1] - origin[1]) as f32];
let bbox_max = [(way.bbox[2] - origin[0]) as f32, (way.bbox[3] - origin[1]) as f32];
// ── Fill triangles ────────────────────────────────────────────────────
let tri_start = g.tri_vertices.len() as u32;
let n = merc.len() as u32;
for &[a, b, c] in tris.iter() {
// The same bounds test `mesh_from_tris` applies: a cache built for a
// different ring is a bug, but it must not take a frame down.
if a < n && b < n && c < n {
for i in [a, b, c] {
g.tri_vertices.push(Vertex { pos: merc[i as usize], col: fill_col });
}
}
}
let tri_count = g.tri_vertices.len() as u32 - tri_start;
if tri_count > 0 {
g.tri_meta.push(GpuWayMeta {
bbox_min,
bbox_max,
vert_start: tri_start,
vert_count: tri_count,
lod,
// AREA geometry: the minimum-extent gate applies (stinky `kalm-3`).
flags: facett_core::render::gpu::GPU_WAY_FLAG_FILL,
});
} else {
g.tri_vertices.truncate(tri_start as usize);
}
// ── Ring outlines, CLOSED (last → first), as ANTI-ALIASED QUADS ───────
//
// stinky `mapgpu-1`. These were a raw `LineList` — two `Vertex` per segment,
// drawn by `draw_pipeline` as a 1 px aliased hairline with no coverage ramp,
// while the CPU painter feathered the same ring over ~2 px. They now go
// through the SAME `LineExpander` the road lane uses, so the ring gets miter
// joins, the inner-miter clamp and the SDF edge for free, and there is one
// quad expander in this crate instead of a quad expander and a hairline
// emitter (LAW #5).
//
// The width is resolved by `style::fill_outline_width_px` — the ONE writer the
// CPU painter also reads. It used to be `f.width.max(0.5)` there and an
// implicit `1.0` here, which is the second half of the same defect.
let out_start = g.outline_vertices.len() as u32;
let ring_width = crate::style::fill_outline_width_px(way.style.width);
for &(lo, hi) in &ring_spans {
if hi - lo < 2 {
continue;
}
// Closed: the expander joins consecutive points, so the ring is its own
// vertices plus a repeat of the first. `pts` is reused across rings so a
// way with holes still costs one allocation for the longest ring, not one
// per ring (`mapdraw-5`'s rule, applied to the stream it did not reach).
ring_pts.clear();
ring_pts.extend_from_slice(&merc[lo..hi]);
ring_pts.push(merc[lo]);
// **At its own fill's plane, one bias in front** — lane GG #1. Not `0.0`,
// which is the closest plane there is and made every ring outline beat every
// road drawn after it; and not the rank, which carries a tier the fill's own
// pinned band depth does not. See `paint_order::fill_outline_depth`.
expander.push_pts_at_depth(
&mut g.outline_vertices,
&ring_pts,
ring_width,
line_col,
outline_depth,
);
}
let out_count = g.outline_vertices.len() as u32 - out_start;
if out_count > 0 {
g.outline_meta.push(GpuWayMeta {
bbox_min,
bbox_max,
vert_start: out_start,
vert_count: out_count,
lod,
// The fill's own ring outline — it must vanish with its fill, not
// survive as a wireframe of a building nobody can see.
flags: facett_core::render::gpu::GPU_WAY_FLAG_FILL,
});
}
}
g
}
/// Install a [`GpuMapRenderer`] into an egui-wgpu `RenderState`'s callback
/// resources. Hosts call this **once** (e.g. in `eframe`'s creation closure with
/// `cc.wgpu_render_state`) so the paint callback can find the renderer. Mirrors
/// osm-viewer's `setup_gpu`.
///
/// CONS-CORE Phase C: the install-once lifecycle is delegated to the shared
/// `facett_core::render::gpu::install_renderer` (the same get-or-insert pattern all
/// skin kernels hand-rolled), so there is one source of truth. `GpuMapRenderer`
/// itself (the cull + `draw_indirect` pipeline) is unchanged.
pub fn install_renderer(render_state: &egui_wgpu::RenderState) {
facett_core::render::gpu::install_renderer::<GpuMapRenderer, _>(render_state, |device, fmt| {
GpuMapRenderer::new(device, fmt)
});
// This lane COUNTS its frames (see `MapPaintCallback::prepare`), so a zero means
// "installed and never painted" rather than "did not say" — which is what lets
// `facett_core::render::lane::lane_use()` report `installed_unused` for this lane
// instead of degrading to `unknown`.
facett_core::render::lane::note_reports_frames(lane_name());
// ── The reason a freshly installed lane has not painted: NOBODY HAS DRAWN THE PANE.
//
// MEASURED on 2026-08-02, and it is the state a user is most likely to be looking at.
// korp installs this lane at startup and opens on the 🧊 Tables tab behind a modal
// infra chooser, so the map pane has not been painted when the About card is first
// read. The card said "installed but UNUSED … drew 0 frames (the CPU painter is doing
// the work)" on an RTX 4090 — every word true, and it reads like a fault. It cost
// The owner and a coordinating agent real time chasing a bug that was not there.
//
// `MapFacet::ui` overwrites this the first time it runs (with `None` when it takes
// the lane), so the row only says this while it is actually TRUE.
note_idle_reason(Some(facett_core::render::lane::PANE_NOT_PAINTED_YET));
RENDERER_INSTALLED.store(true, std::sync::atomic::Ordering::Relaxed);
}
/// Has a host installed the [`GpuMapRenderer`] in this process?
///
/// A pane that opts into the GPU line lane **stops drawing those lines on the
/// CPU** — the GPU owns the layer for that frame. If no renderer was ever
/// installed the paint callback silently does nothing, so an opted-in pane on a
/// host that forgot [`install_renderer`] renders a map with **no line ways at
/// all**. (The headless `egui_kittest` harness is exactly that host: it has a
/// `RenderState`, so "is there a RenderState" is not the question to ask.)
///
/// The callback resources are only reachable from inside the callback, so the
/// install is latched here instead and a pane consults this before handing the
/// layer over. Fail-safe by construction: unknown ⇒ draw on the CPU.
pub fn renderer_installed() -> bool {
RENDERER_INSTALLED.load(std::sync::atomic::Ordering::Relaxed)
}
/// **THE name this lane is registered under** in `facett_core::render::lane` — one
/// writer, so the frame counter, the upload ACK, the idle reason and the About row
/// cannot end up keyed differently (LAW #5). Derived from the type, so it cannot drift
/// from the renderer it names.
#[must_use]
pub fn lane_name() -> &'static str {
std::any::type_name::<GpuMapRenderer>()
}
/// **Say why the map lane is not painting** — forwarded to
/// [`facett_core::render::lane::note_idle_reason`] under [`lane_name`].
///
/// `facett_map::layer::MapFacet` calls this every frame with its `CpuOnly` token, so the
/// About box can print *"installed but UNUSED … because GpuMapRenderer:
/// rotated_camera"* instead of a sentence that a correct choice and a broken lane share.
pub fn note_idle_reason(reason: Option<&str>) {
facett_core::render::lane::note_idle_reason(lane_name(), reason);
}
/// **How many frames this lane has encoded** (`facett_core::render::lane`) — the number
/// an About box shows, read here by name once so no caller has to spell the
/// `type_name::<GpuMapRenderer>()` key.
///
/// A host uses it as the *upload ACK*: a frame counted means `MapPaintCallback::prepare`
/// really ran against a device, so any geometry payload that rode on that callback is in
/// VRAM. Handing a payload to `painter.add` proves nothing — egui can discard the frame.
#[must_use]
pub fn lane_frames() -> u64 {
facett_core::render::lane::frames_of(lane_name())
}
/// **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
/// re-bake/upload path (which only fires on geometry change), and
/// `{last_gpu_pass_us, gpu_timestamps}` from the TIMESTAMP_QUERY pass clock around
/// the cull compute pass.
///
/// 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 pass time covers the CULL pass only: the fill/line draws execute inside
/// egui's shared render pass, which a paint callback cannot bracket;
/// * a sample resolves a few frames after the pass ran (non-blocking ring).
#[must_use]
pub fn lane_clock_stats() -> facett_core::render::lane::LaneClockStats {
facett_core::render::lane::clock_stats_of(lane_name())
}
static RENDERER_INSTALLED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
/// **Test hook** — force the installed latch, so a headless test can drive the
/// GPU-lane *plan* path (`MapFacet::way_lane_plan`'s scan + memo) without a device.
/// Process-global: only a test binary that owns its whole process state may use it.
#[doc(hidden)]
pub fn set_renderer_installed_for_test(on: bool) {
RENDERER_INSTALLED.store(on, std::sync::atomic::Ordering::Relaxed);
}
// ═════════════════════════════════════════════════════════════════════════════
// THE OFFSCREEN COLOUR+DEPTH TARGET — lane MP-a, 2026-09-05.
//
// egui's render pass carries no depth attachment, and giving it one means setting
// `WgpuConfiguration::depth_stencil_format` for the whole app — which would put 22
// pipelines across 9 crates on a matching state, one wrong and that pane stops
// drawing. The owner's call, 06:08: take the offscreen pass, confined to this crate.
//
// The map draws into its own colour+depth textures in `prepare` (which owns an
// encoder), and `paint` blits the result into egui's pass. MSAA is
// `NO_MSAA_SAMPLES == 1`, so there is no resolve step.
//
// The vertices are already in WHOLE-SURFACE coordinates — `DrawUniforms::viewport_*`
// is the ScreenDescriptor's size and the shader's `px_to_ndc` divides by exactly that
// — so the offscreen pass wants the whole surface and NO viewport games. That is the
// half of this that is simpler offscreen than in egui's pass, where the callback rect
// squeezes the window's coordinate space into the pane (see `paint`'s note).
// ═════════════════════════════════════════════════════════════════════════════
/// **The last blit's wall time, in microseconds.**
///
/// A process-global rather than a renderer field because the renderer lives inside
/// egui's `CallbackResources` and a test cannot reach it — the same reason
/// `facett_core::render::lane` counts frames globally. It is a measurement hook, so a
/// last-value-wins store is the honest shape: there is no aggregate here pretending to
/// be one.
static BLIT_US: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn note_blit_us(us: u64) {
BLIT_US.store(us, std::sync::atomic::Ordering::Relaxed);
}
/// The last blit's wall time in microseconds — see [`BLIT_US`].
#[must_use]
pub fn last_blit_us() -> u64 {
BLIT_US.load(std::sync::atomic::Ordering::Relaxed)
}
/// How many [`OffscreenTarget`]s have EVER been allocated in this process, and how many
/// are live right now. Process-wide like [`BLIT_US`], for the same reason: a test drives
/// the renderer through egui's `CallbackResources` and has no handle on it. A still
/// frame must not move the first number (`tests/gpu_lane_two_panes.rs`); a pane that
/// stops being drawn must lower the second.
static OFFSCREEN_ALLOCS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
static OFFSCREEN_LIVE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// Offscreen targets allocated so far, process-wide — see [`OFFSCREEN_ALLOCS`].
#[must_use]
pub fn offscreen_target_allocs() -> u64 {
OFFSCREEN_ALLOCS.load(std::sync::atomic::Ordering::Relaxed)
}
/// Offscreen targets live right now, process-wide — see [`OFFSCREEN_LIVE`].
#[must_use]
pub fn offscreen_targets_live() -> u64 {
OFFSCREEN_LIVE.load(std::sync::atomic::Ordering::Relaxed)
}
// ── GG audit row 21: the card's bill and the frame's draw count, as data ─────────
/// **Every byte this renderer has asked the device for, cumulative** — bumped by
/// [`alloc_buffer`] / [`alloc_buffer_init`], the ONE door every `create_buffer` in this
/// module and in [`line`] goes through. The delta across an upload is what that upload
/// cost the card, and `tests/vram_ratio.rs` holds it to ≤ 1.2× the geometry handed over.
/// Cumulative and never decremented: it counts requests, not residency — the tile
/// cache's residency is [`GpuMapRenderer::tile_cache_bytes`], which the same test pins
/// to this counter's delta so the two cannot disagree about what a tile costs.
static GPU_BUFFER_BYTES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// The one `create_buffer` — see [`GPU_BUFFER_BYTES`].
pub(crate) fn alloc_buffer(device: &wgpu::Device, desc: &wgpu::BufferDescriptor<'_>) -> wgpu::Buffer {
GPU_BUFFER_BYTES.fetch_add(desc.size, std::sync::atomic::Ordering::Relaxed);
device.create_buffer(desc)
}
/// The one `create_buffer_init` — see [`GPU_BUFFER_BYTES`]. Counts the buffer's real
/// size (`create_buffer_init` pads a payload up to `COPY_BUFFER_ALIGNMENT`).
pub(crate) fn alloc_buffer_init(device: &wgpu::Device, desc: &wgpu::util::BufferInitDescriptor<'_>) -> wgpu::Buffer {
let buf = device.create_buffer_init(desc);
GPU_BUFFER_BYTES.fetch_add(buf.size(), std::sync::atomic::Ordering::Relaxed);
buf
}
/// Bytes of GPU buffers this renderer has allocated so far, process-wide — see
/// [`GPU_BUFFER_BYTES`].
#[must_use]
pub fn gpu_buffer_bytes_allocated() -> u64 {
GPU_BUFFER_BYTES.load(std::sync::atomic::Ordering::Relaxed)
}
/// **The draw census** — three cumulative counters written by
/// `MapPaintCallback::record_draws`, the one function that records the map's draws:
/// how many indirect draw commands it issued, how many `(group, stream, chunk)` units it
/// walked, and how many band runs it walked. Per frame, `tests/draw_count.rs` holds
/// draws ≤ units (one draw per chunk per stream kind) and FILES draws vs band runs — the
/// row's ideal, which needs the tile slab arena before it can be asserted.
static DRAW_CALLS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
static DRAW_UNITS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
static DRAW_BAND_RUNS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// Indirect draw commands the map has recorded so far, process-wide — see [`DRAW_CALLS`].
#[must_use]
pub fn draw_calls_total() -> u64 {
DRAW_CALLS.load(std::sync::atomic::Ordering::Relaxed)
}
/// `(group, stream, chunk)` units the draw walk has visited so far — see [`DRAW_UNITS`].
#[must_use]
pub fn draw_units_total() -> u64 {
DRAW_UNITS.load(std::sync::atomic::Ordering::Relaxed)
}
/// Band runs the draw walk has visited so far — see [`DRAW_BAND_RUNS`].
#[must_use]
pub fn draw_band_runs_total() -> u64 {
DRAW_BAND_RUNS.load(std::sync::atomic::Ordering::Relaxed)
}
/// The depth format the map's offscreen pass uses. `Depth32Float` because the rank
/// ladder needs the range: [`crate::paint_order::CASING_DEPTH_BIAS`] is `1e-6`, and a
/// 24-bit normalised depth resolves `6e-8` — enough, but with no headroom if the ladder
/// ever grows a tier. Float32 costs nothing here (one full-surface texture) and removes
/// the question.
pub const MAP_DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
/// **The sample count the map's OFFSCREEN pair is allocated at — the one deliberate
/// exception to [`facett_core::render::gpu::msaa_state`]'s no-MSAA ruling** (lane GG #2,
/// audit #32).
///
/// The ruling is right about everything it was written about: TAA is the 3D scheme, the
/// analytic feather in `line.wgsl` is the 2D one, and MSAA does not compose with a
/// deferred G-buffer. None of that is what this buys. This buys **per-sample DEPTH**, and
/// nothing else in the crate can supply it.
///
/// # Why the offscreen pair needs it and no other target does
///
/// Lane MP-a turned depth testing on so the declared rank could survive being split
/// across tiles and chunks. With one depth value per PIXEL, an edge fragment of a
/// higher-ranked way has to choose: composite (and let the way beneath draw over it) or
/// own the pixel. It owns it. The way beneath is depth-REJECTED, and a rejected fragment
/// is not blended away — it never runs — so what is left is the winner over bare paper.
///
/// MEASURED on oden / RTX 4090, 2026-09-05, before this constant existed
/// (`tests/gpu_lane_paint_order.rs::a_partially_covered_road_edge_composites_with_the_road_beneath_it`):
/// a residential spanning the pane under a diagonal motorway in another tile, pane row
/// 200. Across the motorway's whole 7 px footprint (x 256..=262) the frame with both roads
/// was **bit-identical** to the frame with the motorway alone — the residential
/// contributed nothing anywhere, not merely at the fringe. At x = 256 the casing covers
/// about ⅜ of the pixel and the frame read `[28, 28, 34]`, casing over PAPER
/// `[31, 31, 38]`, with the residential simply gone. After: `[36, 40, 48]`, the casing
/// over a residential that reads `[52, 58, 70]` on its own.
///
/// # What this is NOT, and it was measured
///
/// **4× on its own changes nothing here.** With the target and every pipeline at 4× and
/// the shader untouched, the crossing frame came back byte-for-byte identical to the
/// single-sample one. `line.wgsl` rasterises its quad one `feather_px` WIDER than the
/// road, so at exactly the pixels this defect lives on the rasteriser sees FULL coverage
/// and hands the fragment all four samples; the partial coverage is in the fragment's
/// alpha, where the rasteriser cannot see it. This constant is the substrate that lets
/// `line.wgsl` hand it back through `@builtin(sample_mask)` — the mask is the cure, and
/// audit #32's "4× MSAA on the offscreen target" is half of the answer, not the answer.
///
/// # Why 4 and not 2 or 8
///
/// A sample mask can only express coverage in `1/samples`, and the sole thing that
/// quantisation costs is how much of a pixel's DEPTH a way claims — `line.wgsl`'s
/// `coverage_alpha` cancels it out of the colour exactly, so there is no anti-aliasing
/// error to trade. 2× would let an edge over-claim half a pixel, which is the same order
/// as the defect being fixed. 8× doubles the bandwidth of a full-surface
/// `Depth32Float` + colour pair to take the over-claim from 25 % of a pixel to 12 %,
/// invisible at the widths a road is drawn at. 4× is also the count every desktop GPU
/// implements in fast hardware.
///
/// The cost is memory, and it is not nothing: at 1600×900 the offscreen set goes from one
/// colour + one depth (~11 MB) to a 4× pair plus the single-sample resolve (~52 MB) —
/// **per pane** since GG #37 (main map + inset = ~104 MB at that size), and given back
/// the frame after a pane stops being drawn.
///
/// # What it does for a FILL, which is not nothing either (audit #32)
///
/// Audit #32 says *"fill triangles have NO AA; the 0.5 px outline IS the AA"*. That was
/// true of the single-sample target and stopped being true here: `fill_pipeline` draws
/// into this pair through `map_msaa_state()`, and a fill triangle's edge IS its geometric
/// edge — nothing is rasterised wider than the shape, so the rasteriser sees the partial
/// coverage directly and no `sample_mask` is needed (the road case above is the opposite,
/// which is why 4× alone did nothing THERE). MEASURED on t14s / Radeon 890M, 2026-09-06,
/// with the ring outline fully transparent: a diagonal shoreline's 415 shore pixels sit
/// at exactly ¼, ½ and ¾ water (103 / 207 / 105) — three levels where a hard edge has
/// none. `tests/fill_edge_aa.rs` pins that floor; a per-vertex edge distance would lift
/// it to 256 levels at the price of a boundary-aware vertex format, and the frame did not
/// ask for it.
pub const MAP_MSAA_SAMPLES: u32 = 4;
/// **The multisample state every pipeline that draws into [`OffscreenTarget`] shares** —
/// see [`MAP_MSAA_SAMPLES`] for why this lane is the exception to the no-MSAA ruling.
///
/// Built FROM `facett_core`'s descriptor rather than as a fresh literal, so the mask and
/// the alpha-to-coverage flag still have exactly one writer and this function states only
/// the single field the map's offscreen pair deviates in. A pipeline left on
/// `msaa_state()` while the target is 4× is a wgpu **validation failure**, not a soft
/// downgrade — which is precisely why it is one call and not a number typed at four
/// pipelines.
#[must_use]
pub fn map_msaa_state() -> wgpu::MultisampleState {
let mut ms = facett_core::render::gpu::msaa_state();
ms.count = MAP_MSAA_SAMPLES;
// **`alpha_to_coverage` stays OFF, and that is a measurement, not a default.**
//
// The sample count alone fixes nothing for a road, which is the counter-intuitive
// part: `line.wgsl` rasterises its quad one `feather_px` WIDER than the road, so at a
// pixel straddling the road's true edge the quad covers the pixel COMPLETELY and the
// rasteriser hands the fragment all four samples. The partial coverage lives in the
// fragment's alpha, where the rasteriser cannot see it — so it has to be handed back
// to the sample mask by the shader.
//
// Alpha-to-coverage is the one-line way to do that, and it was tried first and is
// WRONG here, twice over. MEASURED on oden / RTX 4090, 2026-09-05, with A2C on and
// the pipelines otherwise unchanged:
//
// * It multiplies coverage in TWICE. The masked samples still alpha-blend by the same
// alpha, so a road edge composites at `a²`. The residential's own mid-row colour
// went `[46, 51, 62]` → `[29, 30, 37]` against paper `[31, 31, 38]` — the road very
// nearly vanished.
// * It cannot tell the two edges of a road apart. A road's fill meets its own CASING
// with the same alpha ramp, and there the analytic feather was already exactly
// right and needs no per-sample anything. A2C punches the fill's ramp into a
// 4-level dither over the casing, so the pixel resolves mostly casing.
//
// What `line.wgsl` does instead is write `@builtin(sample_mask)` itself, from the
// GEOMETRIC coverage only, and leave the material alpha to the blend — the two were
// one number in `fs_main` and had to be separated. A2C would fight that mask, and it
// would be equally wrong on the fill and draw pipelines, whose alpha is a material
// property and not a coverage at all.
ms
}
/// The map's own colour+depth render target, sized to the whole surface.
struct OffscreenTarget {
/// The 4× colour attachment the map actually draws into.
colour: wgpu::TextureView,
/// The single-sample texture [`Self::colour`] RESOLVES into, and the only one the
/// blit ever samples. Kept as a separate texture rather than resolving in a shader:
/// the resolve is a render-pass store operation the hardware does for free at the end
/// of the pass it already had to write out.
resolved: wgpu::TextureView,
/// The 4× depth attachment — the point of the whole exercise. Depth is per SAMPLE
/// here, so an edge fragment stops owning the pixels it only partly covers.
depth: wgpu::TextureView,
/// What it was allocated for, so a resize is a comparison rather than a guess.
size: (u32, u32),
}
impl OffscreenTarget {
/// Allocate for `size`: a 4× colour+depth pair to draw into ([`MAP_MSAA_SAMPLES`])
/// and the single-sample texture the colour resolves into for the blit.
fn new(device: &wgpu::Device, format: wgpu::TextureFormat, size: (u32, u32)) -> Self {
// The three descriptors are struct-updates off ONE base rather than three calls to
// a `make(label, format, samples, usage)` helper, and that is for the scanner's
// sake as much as the reader's: `facett-core/tests/one_msaa_writer.rs` requires
// every `sample_count:` in the workspace to name a CONSTANT, so that grepping the
// tree still answers "what is multisampled here" truthfully. A helper taking the
// count as a parameter hides the answer behind a local and puts this file's one
// sanctioned exception out of the scanner's reach — which is exactly the shape the
// scanner exists to refuse.
let colour = wgpu::TextureDescriptor {
label: Some("facett_map_offscreen_colour"),
size: wgpu::Extent3d {
width: size.0.max(1),
height: size.1.max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: MAP_MSAA_SAMPLES,
dimension: wgpu::TextureDimension::D2,
format,
// No `TEXTURE_BINDING`: a 4× texture cannot be read by the blit's filtering
// sampler, and it is not meant to be — `resolved` is.
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
};
let depth = wgpu::TextureDescriptor {
label: Some("facett_map_offscreen_depth"),
format: MAP_DEPTH_FORMAT,
..colour.clone()
};
let resolved = wgpu::TextureDescriptor {
label: Some("facett_map_offscreen_resolved"),
sample_count: facett_core::render::gpu::NO_MSAA_SAMPLES,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
..colour.clone()
};
let view = |d: &wgpu::TextureDescriptor| {
device.create_texture(d).create_view(&wgpu::TextureViewDescriptor::default())
};
OFFSCREEN_ALLOCS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
OFFSCREEN_LIVE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Self { colour: view(&colour), resolved: view(&resolved), depth: view(&depth), size }
}
}
impl Drop for OffscreenTarget {
fn drop(&mut self) {
OFFSCREEN_LIVE.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
}
}
/// **One pane's share of the offscreen lane** — lane GG #37.
///
/// Keyed by [`PaneStamp::pane`]. The target is the WHOLE surface whatever the pane's
/// rect, because the vertex stream is in whole-surface coordinates and a wgpu viewport
/// cannot sit outside its attachment (see `record_offscreen`) — so a pane costs a full
/// [`OffscreenTarget`] (~52 MB at 1600×900, [`MAP_MSAA_SAMPLES`]). That is the price of
/// keeping the blit a single textured triangle and the coordinate space untouched; a
/// scissored share of one target was the other option and it cannot hold an inset that
/// OVERLAPS the main map, which is what an inset is.
///
/// The blit UV uniform and its bind group are per pane too: `paint` writes the UV with
/// `queue.write_buffer`, which lands before the submit and not between two callbacks,
/// so one shared uniform handed every pane the LAST pane's rect.
struct PaneTarget {
key: u64,
target: OffscreenTarget,
/// `uv_min`/`uv_max` — which sub-rectangle of the whole-surface texture this pane is.
blit_uniform: wgpu::Buffer,
/// Names `target.resolved`, so it is rebuilt with the target.
blit_bind: wgpu::BindGroup,
/// The last [`PaneStamp::frame_nr`] this pane was prepared in. A pane unseen for a
/// whole frame is freed — its VRAM comes back the frame after it stops being drawn.
last_frame: u64,
}
/// The blit's bind group for one pane: **`resolved`, never `colour`** — the 4× attachment
/// is not sampleable and the blit must read what the resolve produced (lane GG #2).
fn blit_bind_for(
device: &wgpu::Device,
layout: &wgpu::BindGroupLayout,
sampler: &wgpu::Sampler,
target: &OffscreenTarget,
uniform: &wgpu::Buffer,
) -> wgpu::BindGroup {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("facett_map_blit_bind"),
layout,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&target.resolved) },
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(sampler) },
wgpu::BindGroupEntry { binding: 2, resource: uniform.as_entire_binding() },
],
})
}
impl GpuMapRenderer {
/// **The pane's slot in [`Self::panes`]**, for the pane and frame `stamp` names.
///
/// Allocates on a NEW key or a size change only — a still frame allocates nothing
/// (`tests/gpu_lane_two_panes.rs` counts). On the way in, every pane that was not
/// prepared in this frame or the one before is dropped, which frees its target: a
/// pane that disappears gives its VRAM back one frame later. `retain` over two or
/// three entries, on every prepare, moving nothing when nothing goes.
fn pane_target(&mut self, device: &wgpu::Device, stamp: PaneStamp, size: (u32, u32)) -> usize {
self.panes.retain(|p| p.last_frame + 1 >= stamp.frame_nr);
let i = match self.panes.iter().position(|p| p.key == stamp.pane) {
Some(i) => i,
None => {
let blit_uniform = alloc_buffer(device, &wgpu::BufferDescriptor {
label: Some("facett_map_blit_uniform"),
size: 16,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let target = OffscreenTarget::new(device, self.target_format, size);
let blit_bind = blit_bind_for(device, &self.blit_bgl, &self.blit_sampler, &target, &blit_uniform);
self.panes.push(PaneTarget { key: stamp.pane, target, blit_uniform, blit_bind, last_frame: 0 });
self.panes.len() - 1
}
};
let p = &mut self.panes[i];
p.last_frame = stamp.frame_nr;
if p.target.size != size {
p.target = OffscreenTarget::new(device, self.target_format, size);
p.blit_bind = blit_bind_for(device, &self.blit_bgl, &self.blit_sampler, &p.target, &p.blit_uniform);
}
i
}
}
/// **The depth state every map pipeline shares** — `Less` against a buffer cleared to
/// `1.0`, writing. One writer, so a pipeline cannot drift from the clear value or the
/// comparison the depth packing was designed around.
#[must_use]
pub fn map_depth_state() -> wgpu::DepthStencilState {
wgpu::DepthStencilState {
format: MAP_DEPTH_FORMAT,
depth_write_enabled: Some(true),
// **`LessEqual`, not `Less`** — lane GG #3.
//
// The tile lane pins a whole band RUN of fills to ONE depth (`set_viewport`,
// :2221), which is right: `way_draw_band` for a fill IS its class, so a run has
// nothing left to separate. Inside the run the stream is still ordered by
// `PaintKey`, whose second term is area, largest first — a courtyard inside a
// block, a pond inside a park, an island in a lake are emitted LATER precisely so
// they land on top.
//
// Under `Less` the first fragment of the run writes the depth and every later one
// is rejected for being EQUAL, so that order is not merely lost, it is exactly
// INVERTED: the biggest polygon wins every containment it is part of. Measured on
// oden / RTX 4090: a building inside a building read as the outer one's ink at the
// centre.
//
// `LessEqual` restores it and takes nothing away, because "equal depth" means
// "same band" and inside a band the submission order IS the declared order — the
// stream is sorted on the whole `PaintKey` and the compaction preserves it
// (`tests/cull_order.rs`). Across bands the depths differ and the comparison is
// unchanged.
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The f64 reference projection a uniform Mercator camera's `to_px` computes
/// (`rect.center + (merc − center)·zoom`) — the ground truth the GPU line lane
/// must track pixel-for-pixel.
fn ref_to_px(merc: [f64; 2], center: [f64; 2], zoom: f64, rect: egui::Rect) -> [f64; 2] {
[
f64::from(rect.center().x) + (merc[0] - center[0]) * zoom,
f64::from(rect.center().y) + (merc[1] - center[1]) * zoom,
]
}
/// RED-WHEN-BROKEN (the mandated correctness proof): at OsmView street-level max
/// zoom, every GPU line vertex — baked origin-local (`mercator_f64(lonlat) −
/// origin`) and projected by [`MapTransform::project`], the exact CPU twin of
/// `draw.wgsl`'s `vs_main` — lands within 1px of the f64 reference `to_px`. The
/// OLD absolute path (`mercator_f32*zoom + offset`, two ~4e7 f32 differenced)
/// drifts several pixels, which the second assert pins so a regression to
/// absolute coordinates fails loudly.
#[test]
fn gpu_line_vertex_within_1px_of_f64_reference_at_max_zoom() {
let zoom = 8.0e7_f64; // OsmView ZOOM_MAX
let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1200.0, 800.0));
// A street-level cluster near lon 9.52, lat 47.14 (Liechtenstein-ish).
let mercs: Vec<[f64; 2]> = (0..32)
.map(|i| {
let (lon, lat) = (9.52 + f64::from(i) * 3.1e-4, 47.14 + f64::from(i) * 2.3e-4);
let (x, y) = crate::mercator(lon, lat);
[x, y]
})
.collect();
// Origin = Mercator bbox-min over the cluster (f64); camera centred on it.
let origin = mercs.iter().fold([f64::MAX; 2], |a, m| [a[0].min(m[0]), a[1].min(m[1])]);
let center = mercs[mercs.len() / 2];
let transform = MapTransform::new(origin, center, zoom as f32, zoom as f32, rect);
// OLD absolute path's offset: screen = merc_f32*zoom + offset (all f32).
let off_x = rect.center().x - center[0] as f32 * zoom as f32;
let off_y = rect.center().y - center[1] as f32 * zoom as f32;
let (mut worst_rebased, mut worst_naive) = (0.0_f64, 0.0_f64);
for m in &mercs {
let want = ref_to_px(*m, center, zoom, rect);
// NEW origin-local GPU path (vertex bake + shader twin).
let pos = rebase_vertex_origin(m[0], m[1], origin);
let got = transform.project(pos);
worst_rebased = worst_rebased
.max((f64::from(got[0]) - want[0]).abs())
.max((f64::from(got[1]) - want[1]).abs());
// OLD absolute-f32 path (the bug).
let naive_x = (m[0] as f32) * zoom as f32 + off_x;
let naive_y = (m[1] as f32) * zoom as f32 + off_y;
worst_naive = worst_naive
.max((f64::from(naive_x) - want[0]).abs())
.max((f64::from(naive_y) - want[1]).abs());
}
assert!(worst_rebased < 1.0, "origin-local GPU vertices land <1px of f64 ref at max zoom: {worst_rebased}px");
assert!(worst_naive > 1.0, "old absolute-f32 path drifts >1px (the bug this fixes): {worst_naive}px");
}
/// INJECT-ASSERT: the cull viewport bbox [`MapTransform::viewport_bbox`] is
/// expressed in the SAME origin-local space as [`build_gpu_geometry`]'s rebased
/// per-way meta bboxes — a way whose absolute Mercator bbox is inside the pane
/// stays inside the rebased viewport. Guards against the two sides diverging
/// (which would silently cull visible ways).
#[test]
fn cull_viewport_and_meta_share_origin_space() {
let zoom = 5.0e5_f64;
let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1000.0, 700.0));
let (lon, lat) = (9.52_f64, 47.14_f64);
let (cx, cy) = crate::mercator(lon, lat);
let origin = [cx - 1e-4, cy - 1e-4];
let center = [cx, cy];
let transform = MapTransform::new(origin, center, zoom as f32, zoom as f32, rect);
// A tiny way straddling the pane centre → its rebased bbox must overlap the
// rebased viewport bbox.
let (mnx, mny) = (cx - 2e-6, cy - 2e-6);
let (mxx, mxy) = (cx + 2e-6, cy + 2e-6);
let meta_min = [(mnx - origin[0]) as f32, (mny - origin[1]) as f32];
let meta_max = [(mxx - origin[0]) as f32, (mxy - origin[1]) as f32];
let [vx0, vy0, vx1, vy1] = transform.viewport_bbox(rect);
let overlaps = !(meta_max[0] < vx0 || meta_min[0] > vx1 || meta_max[1] < vy0 || meta_min[1] > vy1);
assert!(overlaps, "centred way's rebased bbox overlaps the rebased cull viewport");
}
}