facett-map3d/src/backend.rs
//! **Swappable render backend** — the `RenderBackend` trait seam.
//!
//! This is the deliverable the `.nornir/render-engine.md` design calls the
//! "*Renderer/Canvas backend-seam trait = the 2-category swap expressed as one
//! trait*" (galileo pattern, our own impl). It abstracts the **3D draw** of the
//! [`crate::Map3D`] scene so a *different* Rust engine can be dropped in later
//! **without changing the map3d / map3d-flight view code** — the same scene draws
//! through any backend. The trait is the point.
//!
//! ## The contract
//! - [`RenderScene`] — a **backend-neutral** description of one frame: the extruded
//! [`Mesh3`] + [`WaterMesh`], the resolved orbit [`OrbitCamera`], the [`SunSky`]
//! lighting environment, the [`Viewport`] (rect + focal), the background, and the
//! feature [`SceneFlags`] (shadows / water / sky / ssao). No egui, no wgpu.
//! - [`RenderStats`] — the **data oracles** every backend must report from the same
//! scene: visible-triangle count, clip escape count (`ink_outside_rect`), painter
//! depth-order monotonicity, worst projected extent, lit fraction. This is the
//! conformance surface — two backends handed the same [`RenderScene`] must agree.
//! - [`RenderBackend::analyze`] — **pure, device-free** projection → [`RenderStats`].
//! Headless-testable; the shared correctness oracle.
//! - [`RenderBackend::paint`] — paint the frame into a live `egui::Ui`. The provided
//! default paints via the CPU painter, so a *new* backend gets a working fallback
//! for free; the GPU backend overrides it with the wgpu pass chain.
//!
//! ## The impls
//! - [`CpuPainterBackend`] — impl #1a: project + cull + depth-sort + light on the
//! CPU, tessellate to an `egui::Mesh`. Always available, headless, never a device.
//! - [`NullBackend`] — the **headless / conformance** backend: analyses the scene
//! (identical oracle to the CPU painter) but draws nothing. Used by the backend
//! conformance tests to prove "the real backend and a headless backend draw the
//! same scene" (same [`RenderStats`]).
//! - [`WgpuVelloBackend`] (feature `wgpu`) — impl #1b, **our stack**: all 3D
//! pipelines on **wgpu** (shadow → reflection → PBR mesh → water → blit) with
//! **vello** reserved for the 2D HUD/labels (drawn by the view over the top). This
//! is the production backend; a future engine implements the same trait to swap in.
use egui::{Color32, Pos2};
use crate::camera::OrbitCamera;
use crate::extrude::{Mesh3, WaterMesh};
use crate::sky::SunSky;
/// The pixel viewport a scene projects into: the widget `rect`, its `center`, and
/// the perspective focal `half_h` (≈ half the rect height, the projection scale).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Viewport {
/// The widget rect (logical points).
pub rect: egui::Rect,
/// Viewport centre in pixels (the projection origin).
pub center: Pos2,
/// Projection half-height in pixels (the perspective focal scale).
pub half_h: f32,
}
impl Viewport {
/// Derive the projection viewport from a widget rect (centre + focal half-height).
#[must_use]
pub fn from_rect(rect: egui::Rect) -> Self {
Self { rect, center: rect.center(), half_h: (rect.height() * 0.5).max(1.0) }
}
}
/// Per-frame **feature toggles** for the scene — the levers a backend honours if it
/// can (a backend that lacks a feature simply ignores its flag; [`RenderBackend::caps`]
/// advertises what it supports). Backend-neutral: no wgpu / egui types.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct SceneFlags {
/// Paint the analytic atmosphere/skybox behind the city (GPU lane).
pub sky: bool,
/// Run the screen-space ambient-occlusion pass (GPU lane).
pub ssao: bool,
/// Use PCSS contact-hardening soft shadows (vs a hard PCF control; GPU lane).
pub soft_shadows: bool,
/// Cast-shadow darkness `0..1` (`0` disables the shadow depth pass).
pub shadow_strength: f32,
/// Whether the reflective animated water surface is active this frame.
pub water_on: bool,
/// Whether the planar reflection pass runs (`false` = the wasm sky-gradient path).
pub planar_reflection: bool,
/// Whether the HDR **bloom** pass contributes (`false` = composite adds zero bloom).
pub bloom: bool,
/// **The 2D-ortho bypass** (GFX_V2 item 6). `true` means the view is a
/// constrained 2D one — flat terrain, top-down, orthographic — so the whole
/// screen-space post chain is skipped rather than run with zeroed parameters.
///
/// This is what makes GFX_V2's "2D costs nothing extra" true: the pipeline is
/// the same pipeline, and the post passes are simply not recorded. Set it via
/// [`SceneFlags::constrain_to_view`] rather than by hand, so it cannot disagree
/// with the view it describes.
pub flat_2d: bool,
}
impl SceneFlags {
/// **Reconcile these flags with the unified view they belong to** (GFX_V2
/// item 6) — the one function both modes ask, so "2D bypasses post-FX" is a
/// property of the view rather than a thing a caller remembers to do.
///
/// A view satisfying every 2D constraint ([`facett_core::render::View::is_constrained_2d`])
/// forces the entire post chain off and sets [`flat_2d`](Self::flat_2d). An
/// unconstrained 3D view is left exactly as the caller configured it — this
/// never *enables* a pass, so it cannot surprise the 3D lane.
#[must_use]
pub fn constrain_to_view(mut self, view: &facett_core::render::View) -> Self {
if view.is_constrained_2d() {
self.flat_2d = true;
self.sky = false;
self.ssao = false;
self.soft_shadows = false;
self.shadow_strength = 0.0;
self.bloom = false;
self.planar_reflection = false;
}
self
}
/// Whether any screen-space post pass needs to run this frame. `false` means the
/// offscreen HDR target and the whole bright/blur/composite/resolve chain can be
/// skipped outright.
#[must_use]
pub fn any_post(&self) -> bool {
!self.flat_2d && (self.ssao || self.bloom || self.shadow_strength > 0.0 || self.sky)
}
}
/// **The ground-haze layer's scale height, in METRES.** The fragment shader's aerial
/// perspective treats the air as denser near the ground: haze density falls off as
/// `exp(-h / HAZE_SCALE_HEIGHT_METRES)`. That is a real atmospheric length and it must
/// be stated as one.
///
/// `150 m` is the shipped `0.25` design-unit layer read back through the default scale
/// it was tuned at ([`crate::lod::DEFAULT_METRES_PER_DESIGN_UNIT`] = 625 m/du →
/// `0.25 du` = `156 m`), rounded. So the fixture scenes keep the look they had; every
/// metric scene stops getting a 469 m haze layer it can never climb out of.
pub const HAZE_SCALE_HEIGHT_METRES: f32 = 150.0;
/// The legacy absolute haze layer height, in design units — kept **only** for scenes
/// with no world scale (`du_per_m == None`).
pub const HAZE_SCALE_HEIGHT_DU: f32 = 0.25;
/// **The SSAO sampling radius, in METRES.** Ambient occlusion is a contact shadow: it
/// asks "how much sky can this surface point see within a few metres of itself". At
/// `12 m` that is a street canyon and a building base — the scale the effect is for.
///
/// The shipped `0.16` design units is `100 m` at the default scale and **`300 m`** at
/// the 3 km town window `korp` actually flies — an occlusion radius of three football
/// pitches, which darkens whole districts uniformly and reads as flat, not as contact.
pub const SSAO_RADIUS_METRES: f32 = 12.0;
/// The legacy absolute SSAO radius, in design units — kept **only** for scenes with no
/// world scale (`du_per_m == None`).
pub const SSAO_RADIUS_DU: f32 = 0.16;
/// **The scene's haze scale height in design units** — [`HAZE_SCALE_HEIGHT_METRES`]
/// through the scene's own `du_per_m`, floored so a degenerate scale can never make it
/// zero (which would divide the exponent by nothing). `None` → the legacy absolute.
#[must_use]
pub fn haze_scale_height(du_per_m: Option<f32>) -> f32 {
match du_per_m {
Some(dpm) if dpm > 0.0 => (HAZE_SCALE_HEIGHT_METRES * dpm).max(1e-6),
_ => HAZE_SCALE_HEIGHT_DU,
}
}
/// **The scene's SSAO radius in design units** — [`SSAO_RADIUS_METRES`] through the
/// scene's own `du_per_m`, capped at the legacy [`SSAO_RADIUS_DU`] so no scene ever
/// gets a *wider* occlusion radius than it had. `None` → the legacy absolute.
#[must_use]
pub fn ssao_radius(du_per_m: Option<f32>) -> f32 {
match du_per_m {
Some(dpm) if dpm > 0.0 => (SSAO_RADIUS_METRES * dpm).clamp(1e-6, SSAO_RADIUS_DU),
_ => SSAO_RADIUS_DU,
}
}
/// A **backend-neutral** description of one 3D frame to draw. Everything a backend
/// needs — geometry, camera, light, viewport, flags — and nothing device-specific.
/// The camera is already spin-folded (`Map3D::render_camera`) so the frame is a pure
/// function of it. Borrows the mesh + water grid (zero-copy; no per-frame clone).
pub struct RenderScene<'a> {
/// The extruded opaque basemap (prisms + ribbons + terrain).
pub mesh: &'a Mesh3,
/// The tessellated reflective-water grid (empty for data with no water).
pub water: &'a WaterMesh,
/// The resolved orbit camera for this frame (auto-spin already folded in).
pub camera: OrbitCamera,
/// The sun + sky lighting environment.
pub sun: SunSky,
/// The pixel viewport (rect + focal).
pub viewport: Viewport,
/// The background / atmospheric-fog colour.
pub bg: Color32,
/// The feature toggles for this frame.
pub flags: SceneFlags,
/// **Physical-sky transmittance LUT** (FLIGHT-SIM #7 wiring), if the host wired one
/// in. `Some` ⇒ the GPU sky pass samples it for the sun's atmospheric tint; `None`
/// ⇒ the analytic sun tint. Device-neutral raw texels (see
/// [`crate::SkyTransmittanceLut`]); the CPU / null backends ignore it. `Arc` keeps
/// the per-frame snapshot a cheap refcount bump.
pub sky_lut: Option<std::sync::Arc<crate::SkyTransmittanceLut>>,
/// The animation clock (seconds) — drives the water waves; `0` when unpinned.
pub clock_s: f64,
/// **The scene's scale — design units per real metre** ([`Map3D::du_per_m`],
/// i.e. [`crate::extrude::design_units_per_metre`]). `None` = synthetic data with
/// no world span (the rabbit fixture, the unit-test towns), which keeps every
/// render-side metric constant on its legacy absolute so those scenes are
/// byte-for-byte unchanged.
///
/// A backend needs this for the same reason the extruder does: **every render
/// constant that means a LENGTH is metric.** The haze layer is 150 m of air, the
/// SSAO radius is 12 m of street, the water plane is 0.6 m of kerb. Stated as
/// design units they are 150 m in the fixture and 469 m in Stockholm — the exact
/// defect `GROUND_LIFT` and `BUILDING_RISE_EPS` already had twice.
pub du_per_m: Option<f32>,
/// **The per-frame overlay mesh** (V2.5 D.3), already in WORLD design units
/// ([`Map3D::set_overlay`] owns the one pose transform). `None` = no overlay and a
/// frame byte-identical to the pre-seam pipeline. Small by contract (~100 verts):
/// the CPU painter merges its shaded faces into the z-buffered face list, the GPU
/// lane re-uploads it per frame as a tiny dedicated buffer — never through the
/// generation-stamped main mesh cache.
pub overlay: Option<&'a Mesh3>,
/// Per-triangle **emissive intensity** for the overlay, parallel to its colours
/// (`0`/empty = lit normally; `> 0` = unlit, colour scaled into HDR for the
/// existing bloom bright-pass — a channel, not a new post pass).
pub overlay_emissive: &'a [f32],
}
/// What a backend can do — advertised so a host / test can gate on it. A backend
/// that cannot cast shadows (e.g. the CPU painter) reports `shadows == false`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct BackendCaps {
/// Human-readable backend name (matches [`RenderStats::backend`]).
pub name: &'static str,
/// Draws on a GPU (real Z-buffer occlusion) vs the CPU painter's depth sort.
pub gpu: bool,
/// Casts real shadows (a shadow depth pass).
pub shadows: bool,
/// Renders the reflective animated water surface.
pub reflective_water: bool,
/// Runs post-processing (bloom / tonemap / SSAO / FXAA).
pub post_fx: bool,
}
/// The **data oracles** a backend reports from a scene — the conformance surface.
/// Two backends handed the same [`RenderScene`] must agree on the geometry-derived
/// fields (visible triangles, clip escapes, depth order, extent, lit fraction); only
/// [`Self::backend`] / [`Self::used_gpu`] differ. Assertable headlessly (no device).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct RenderStats {
/// The backend that produced this frame (matches [`BackendCaps::name`]).
pub backend: &'static str,
/// Whether this frame was drawn on the GPU depth path (vs the CPU painter).
pub used_gpu: bool,
/// Triangles that survived clip + cull and would be rasterised. Every backend's
/// `analyze` computes this fresh and exactly; the GPU lane's `paint` SAMPLES it
/// twice over — the value there is from the most recent refresh, at most
/// [`GPU_ANALYZE_EVERY`](crate::backend) frames old during continuous camera
/// motion (a mesh swap re-analyzes immediately), **and above
/// [`GPU_ANALYZE_SAMPLE_MAX`](crate::backend) triangles it is an ESTIMATE**:
/// the visible fraction of a bounded stride sample of the basemap, scaled to
/// the full mesh (±a few percent in practice; the overlay is still counted
/// exactly). At or below the cap the painted value stays byte-exact.
pub visible_triangles: usize,
/// Fraction of visible faces that caught a non-trivial amount of light `0..1`,
/// by [`facett_core::render::cpu::is_lit`] over the CPU-shaded colour.
///
/// **It is CPU-PROJECTED, not a framebuffer read** (lane ST, gfx3d-S2). Every
/// backend's `analyze` computes it from `scene_tris` — the same shaded-triangle
/// list the CPU painter rasters — so on the GPU lane this reports what the CPU
/// shading path WOULD have lit, not what the GPU actually put on screen. That is
/// deliberate and is what makes it comparable across backends; it is written down
/// because "lit_fraction" reads like a measurement of the image, and a GPU shading
/// divergence would leave this number untouched.
pub lit_fraction: f32,
/// **The bug oracle** — clipped face vertices that still land outside the widget
/// rect. MUST be `0`: no ink escapes the component (the historical smear bug).
pub ink_outside_rect: usize,
/// Worst signed distance any *raw* projected vertex pokes past the rect edge
/// (before the scissor). The near-plane clip drives this to ~0.
pub raw_overshoot_px: f32,
/// Whether the painter depth order is monotone far→near (correct occlusion on
/// the CPU lane; the GPU lane's Z-buffer makes this advisory).
///
/// # Exactly what it can and cannot catch (lane ST, gfx3d-S12 — MEASURED)
///
/// It is computed in [`face_stats`] over a list `scene_tris` has just sorted, so it
/// reads like an oracle that asserts a sort sorted. It was filed for deletion on
/// that reading. **It is not dead**: reversing the comparator at the sort site
/// (`lib.rs` STEP 6) turns it `false` and fails
/// `every_backend_satisfies_the_scene_invariants` — measured 2026-09-04. So it does
/// guard the sort's presence and DIRECTION, and deleting it would have removed a
/// live guard on the strength of a code-shape argument.
///
/// What it cannot catch is anything downstream of the sort: it says nothing about
/// occlusion, about the projection, or about the pixels. The frozen golden in
/// `tests/backend_conformance.rs` is what covers those.
pub depth_monotone: bool,
/// Worst projected face extent in px — bounded by the viewport by construction
/// (the frustum clip); a smear would blow this up.
pub max_extent_px: f32,
}
/// **The swappable render-backend seam.** Abstracts the 3D draw of a [`RenderScene`]
/// so a different Rust engine can be dropped in without touching the view code.
///
/// Object-safe (`Box<dyn RenderBackend>`): [`crate::Map3D`] holds one and dispatches
/// every frame through it. Implement this trait to add a new engine; the map3d /
/// map3d-flight view code is unchanged.
/// `Send`, so a host can EXTRUDE on a worker thread and hand the finished
/// [`crate::Map3D`] to its UI thread: korp's risen view re-extrudes the city when
/// the camera moves 12 % of the built span, and with the build inside the paint
/// every such frame blocked on the extrusion mid-drag (GFX_V2 shit list #3). Both
/// shipped backends hold only `Send` state (a `RefCell` over an `Arc`'d payload, a
/// stats cache); the bound makes that a compile-time fact `Map3D` inherits, pinned
/// by `a_map3d_can_cross_to_a_worker_thread`.
pub trait RenderBackend: Send {
/// What this backend supports.
fn caps(&self) -> BackendCaps;
/// **Pure, device-free** projection of the scene → [`RenderStats`]. This is the
/// backend-neutral correctness oracle every backend must implement identically
/// (the geometry math is shared); the conformance tests assert two backends
/// agree here. Never touches a GPU or an `egui::Ui`.
fn analyze(&self, scene: &RenderScene) -> RenderStats;
/// Paint the frame into a live `egui::Ui` and report the stats. The provided
/// default paints via the CPU painter — so a new backend that only implements
/// [`Self::analyze`] still draws — and the GPU backend overrides it.
fn paint(&self, scene: &RenderScene, ui: &mut egui::Ui) -> RenderStats {
paint_cpu(scene, ui)
}
/// Drop any per-scene device cache (called when the mesh is swapped). No-op for
/// the stateless CPU / null backends; the GPU backend clears its upload cache.
fn reset_cache(&self) {}
}
// ── shared geometry oracle (one source of truth for every backend) ───────────────
/// Compute the [`RenderStats`] data oracles from an already-projected face list.
/// Shared by [`analyze_scene`] and the CPU painter so the numbers are identical.
fn face_stats(
faces: &[(f32, [Pos2; 3], Color32)],
rect: egui::Rect,
backend: &'static str,
used_gpu: bool,
) -> RenderStats {
let (raw_overshoot, clipped_escapes) = crate::scissor_report(faces, rect);
let mut depth_monotone = true;
for w in faces.windows(2) {
if w[0].0 < w[1].0 - 1e-3 {
depth_monotone = false;
break;
}
}
let mut max_extent_px = 0.0f32;
let mut lit = 0usize;
for (_d, pts, c) in faces {
let ex = pts[0].x.max(pts[1].x).max(pts[2].x) - pts[0].x.min(pts[1].x).min(pts[2].x);
let ey = pts[0].y.max(pts[1].y).max(pts[2].y) - pts[0].y.min(pts[1].y).min(pts[2].y);
let e = ex.max(ey);
if e > max_extent_px {
max_extent_px = e;
}
// "Lit" = the shaded+fogged colour is not near-black. Deterministic, so
// identical across backends that share the projection — and now literally the
// same rule as the CPU lane's, rather than a second copy of `> 90` under a
// comment asking the two to agree (lane ST, gfx3d-S3).
if facett_core::render::cpu::is_lit(c.r(), c.g(), c.b()) {
lit += 1;
}
}
let lit_fraction = if faces.is_empty() { 0.0 } else { lit as f32 / faces.len() as f32 };
RenderStats {
backend,
used_gpu,
visible_triangles: faces.len(),
lit_fraction,
ink_outside_rect: clipped_escapes,
raw_overshoot_px: raw_overshoot,
depth_monotone,
max_extent_px,
}
}
/// **The one scene projector** — basemap + overlay through the shared kernel
/// ([`crate::shade_mesh_tris_emissive`]), merged and re-sorted far→near so the
/// painter's depth order (and the `depth_monotone` oracle) hold across both meshes.
/// The single writer behind [`analyze_scene`] AND [`paint_cpu`] (LAW 5): the stats a
/// backend reports and the pixels the CPU painter rasters come from this exact list.
/// With no overlay the output is byte-identical to `shade_mesh_tris(scene.mesh, …)`.
pub(crate) fn scene_tris(scene: &RenderScene) -> Vec<crate::ShadedTri> {
let mut tris = crate::shade_mesh_tris(
scene.mesh,
&scene.camera,
scene.viewport.center,
scene.viewport.half_h,
scene.bg,
);
if let Some(ov) = scene.overlay {
tris.extend(crate::shade_mesh_tris_emissive(
ov,
scene.overlay_emissive,
&scene.camera,
scene.viewport.center,
scene.viewport.half_h,
scene.bg,
));
// Restore the far→near painter key across the merged list — each half arrives
// sorted, the concatenation is not.
tris.sort_by(|l, r| r.depth.partial_cmp(&l.depth).unwrap_or(std::cmp::Ordering::Equal));
}
tris
}
/// The projected scene as the lean `(depth, [3 px], colour)` face tuples — the shape
/// [`face_stats`] and the pose tests consume. Public for the LAW-2 overlay test
/// (`tests/overlay_pose.rs` asserts an overlay triangle's projected pixels off this,
/// the same list the painter rasters); hidden because it is a test seam, not API.
#[doc(hidden)]
#[must_use]
pub fn project_scene_faces(scene: &RenderScene) -> Vec<(f32, [Pos2; 3], Color32)> {
scene_tris(scene).into_iter().map(|t| (t.depth, t.pts, t.col)).collect()
}
/// Project + cull + shade the scene (the shared CPU geometry path) and report the
/// [`RenderStats`]. The pure conformance oracle behind every backend's `analyze`.
/// Routed through [`scene_tris`], so the overlay counts in `visible_triangles` on
/// every backend identically (the conformance property is preserved by construction).
pub(crate) fn analyze_scene(scene: &RenderScene, backend: &'static str, used_gpu: bool) -> RenderStats {
let faces = project_scene_faces(scene);
face_stats(&faces, scene.viewport.rect, backend, used_gpu)
}
/// **The CPU painter** — project + cull + depth-sort + light the extruded mesh, then
/// tessellate the shaded faces into an `egui::Mesh` clipped to the widget rect. The
/// default `paint` for every backend (and [`CpuPainterBackend`]'s own). Never panics.
pub fn paint_cpu(scene: &RenderScene, ui: &mut egui::Ui) -> RenderStats {
let rect = scene.viewport.rect;
let painter = ui.painter_at(rect);
// The rich projection (per-vertex depth + tonemapped colour) the software z-buffer
// consumes; the lean oracle tuple is derived from it, so the stats are byte-identical
// to `shade_mesh_faces` — and the overlay (V2.5 D.3) rides the same list, resolved
// against the city by the per-pixel depth test like any other geometry.
let tris = scene_tris(scene);
let faces: Vec<(f32, [Pos2; 3], Color32)> = tris.iter().map(|t| (t.depth, t.pts, t.col)).collect();
let stats = face_stats(&faces, rect, "cpu-painter", false);
// ── CPU render-lane emit: the `shade_faces` painter path RAN. ────────────
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-map3d::shade_faces",
"cpu_render",
scene.mesh.triangle_count() == 0 || !faces.is_empty(),
&format!("used_gpu=false visible_tris={} mesh_tris={}", faces.len(), scene.mesh.triangle_count()),
);
// ── scissor oracle (the bug oracle): no ink escapes the rect. ────────────
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-map3d",
"scissor",
stats.ink_outside_rect == 0,
&format!(
"ink_outside_rect={} raw_overshoot_px={:.1} faces={}",
stats.ink_outside_rect, stats.raw_overshoot_px, faces.len()
),
);
// ── The software z-buffer raster (the depth-tearing fix) ─────────────────
// Rasterize the shaded sub-tris into a physical-pixel RGBA buffer with a per-pixel
// 1/z depth test — so overlapping building / terrain / ground-cover faces resolve by
// TRUE depth, not the painter's-order centroid sort that tore where a small near face
// overlapped a large far one (and on the coplanar GROUND_LIFT ground layers). Then
// blit it over the sky gradient; uncovered pixels stay transparent so the sky shows.
if !tris.is_empty() {
let ppp = ui.ctx().pixels_per_point().max(0.1);
let w = ((rect.width() * ppp).round() as usize).clamp(1, 4096);
let h = ((rect.height() * ppp).round() as usize).clamp(1, 4096);
let pixels = crate::raster_zbuffer(&tris, w, h, rect.min, ppp);
let image = egui::ColorImage::new([w, h], pixels);
// One texture id for the pane, re-`set` every frame (a software renderer
// re-uploads each frame anyway). A load-and-drop one-frame texture is freed
// BEFORE egui_kittest 0.36 renders — see `facett_core::render::cpu::frame_texture`.
let tex = facett_core::render::cpu::frame_texture(ui, "facett-map3d-cpu-raster", image, egui::TextureOptions::LINEAR);
painter.image(
tex,
rect,
egui::Rect::from_min_max(Pos2::new(0.0, 0.0), Pos2::new(1.0, 1.0)),
Color32::WHITE,
);
} else {
painter.text(
rect.center(),
egui::Align2::CENTER_CENTER,
"no 3D map",
egui::FontId::proportional(13.0),
ui.visuals().weak_text_color(),
);
}
stats
}
/// **Impl #1a — the CPU painter backend.** Always available, headless, never a
/// device. The correctness reference: its projection is the shared geometry oracle.
#[derive(Clone, Copy, Debug, Default)]
pub struct CpuPainterBackend;
impl RenderBackend for CpuPainterBackend {
fn caps(&self) -> BackendCaps {
BackendCaps { name: "cpu-painter", gpu: false, shadows: false, reflective_water: false, post_fx: false }
}
fn analyze(&self, scene: &RenderScene) -> RenderStats {
analyze_scene(scene, "cpu-painter", false)
}
// `paint` uses the default (`paint_cpu`).
}
/// **The headless / conformance backend.** Analyses the scene with the *identical*
/// geometry oracle as the CPU painter but draws nothing — so the backend-conformance
/// tests can prove "the real backend and a headless backend produce the same scene"
/// ([`RenderStats`] parity) with no device and no egui paint.
#[derive(Clone, Copy, Debug, Default)]
pub struct NullBackend;
impl RenderBackend for NullBackend {
fn caps(&self) -> BackendCaps {
BackendCaps { name: "null", gpu: false, shadows: false, reflective_water: false, post_fx: false }
}
fn analyze(&self, scene: &RenderScene) -> RenderStats {
analyze_scene(scene, "null", false)
}
fn paint(&self, scene: &RenderScene, _ui: &mut egui::Ui) -> RenderStats {
// Headless: no draw. The stats are the pure analysis (used_gpu = false).
self.analyze(scene)
}
}
// ── Impl #1b — the wgpu + vello backend (our stack) ──────────────────────────────
/// **Impl #1b — the production wgpu + vello backend.** All 3D pipelines run on
/// **wgpu** (shadow → reflection → PBR mesh → water → blit into the offscreen HDR
/// target, then a scissored blit into egui's pass); **vello** is reserved for the 2D
/// HUD / labels the view draws over the top (per the framing LAW in
/// `.nornir/flightsim-render.md`). A future Rust engine swaps in by implementing
/// [`RenderBackend`] — this view code never changes.
///
/// Holds the per-scene GPU upload cache (built once, handed to the paint callback
/// each frame as an `Arc` refcount bump; the installed
/// [`crate::gpu::Map3dRenderer`] replaces its device buffers when the payload's
/// build stamp changes and otherwise ignores the re-send).
#[cfg(feature = "wgpu")]
#[derive(Default)]
pub struct WgpuVelloBackend {
/// Cached GPU geometry payload. Handed over each frame by cloning the `Arc` — a
/// refcount bump, and BY CONSTRUCTION nothing more: `GpuGeometry` is not
/// `Clone`, because the deep copy this cache used to make per frame (under a
/// comment claiming "cheap Rc clone") was ~72 MB at korp's 600k-triangle
/// budget. `None` until the first paint or after [`RenderBackend::reset_cache`].
cache: std::cell::RefCell<Option<std::sync::Arc<crate::gpu::GpuGeometry>>>,
/// The most recent scene analysis — `paint` refreshes it every
/// [`GPU_ANALYZE_EVERY`]-th frame instead of paying it every frame (see the
/// note in `paint`), and the refresh itself is bounded by
/// [`GPU_ANALYZE_SAMPLE_MAX`]. Cleared with the geometry cache so a swapped
/// mesh is re-analyzed on its first frame.
stats_cache: std::cell::RefCell<Option<RenderStats>>,
/// The cached stride sample of the basemap the bounded refresh analyses (see
/// [`GPU_ANALYZE_SAMPLE_MAX`]). A pure function of the mesh — not the camera —
/// so it is built once per mesh (preallocate-and-reuse, the LAW's doctrine) and
/// dropped by [`RenderBackend::reset_cache`] with the other caches. `None` for
/// meshes at or below the cap, which are analysed exactly.
analyze_sub: std::cell::RefCell<Option<Mesh3>>,
/// Frames painted since `stats_cache` was last refreshed.
frames_since_analyze: std::cell::Cell<u32>,
/// How many stats-refresh passes `paint` has run — the observable that keeps
/// the frame sampling honest: a drag of N frames must show
/// ~N/[`GPU_ANALYZE_EVERY`] here, and it goes back to N the moment someone
/// re-inlines the unconditional per-frame call. (Named for the full analysis it
/// historically counted; above [`GPU_ANALYZE_SAMPLE_MAX`] each refresh is now
/// the bounded sample.)
full_analyzes: std::cell::Cell<u64>,
/// Test-only switch: `true` forces the paint-path refresh back to the unbounded
/// full `analyze_scene` — the non-vacuity handle that lets the spike benchmark
/// prove it could still see the defect. Never set in production code.
force_full_analysis: std::cell::Cell<bool>,
}
/// How many painted frames may reuse the last scene analysis on the GPU lane
/// before [`WgpuVelloBackend`]'s `paint` refreshes it. The GPU lane was paying a
/// full software transform + clip + shade + sort of EVERY mesh triangle every frame
/// purely to fill [`RenderStats`] — the pixels come from the GPU regardless. `16`
/// keeps `visible_triangles` at worst ~a quarter-second stale during continuous
/// motion at 60 fps; a mesh swap ([`RenderBackend::reset_cache`]) re-analyzes on the
/// very next frame. NOT a spike knob: the refresh itself is bounded by
/// [`GPU_ANALYZE_SAMPLE_MAX`], so raising this would only make an already-flat cost
/// rarer (and before the bound existed, raising it would have made the hitch rarer
/// and just as tall — the fix that looks like work).
#[cfg(feature = "wgpu")]
pub const GPU_ANALYZE_EVERY: u32 = 16;
/// Ceiling on the triangles the GPU lane's **paint-path** stats refresh may project
/// in software. At or below it the refresh is the exact `analyze_scene` (byte-equal
/// to [`RenderBackend::analyze`]); above it the refresh projects a cached stride
/// sample of at most this many basemap triangles and **`visible_triangles` becomes a
/// scaled ESTIMATE** (documented on the field — a number that silently changed
/// meaning would be its own defect). Sampling instead of a rarer full pass because a
/// rarer spike is just as tall when it lands: the 1-in-[`GPU_ANALYZE_EVERY`] full
/// transform+clip+shade+sort of korp's ~600 k triangles was a periodic multi-ms
/// hitch with a visible cadence (GFX_V2 V2.9b), and bounding the work makes the
/// refresh cost flat regardless of mesh size. `analyze` itself is untouched — the
/// conformance oracle stays exact.
///
/// `1024` is measured, not guessed (`tests/gpu_analyze_spike.rs`, release, RTX-4090
/// box, 600 210 tris): one refresh 889 µs — ~1/19 of a 60 fps frame budget — with
/// the estimate 0.28 % off the exact count. At `4096` the same refresh measured
/// 5.7 ms, over a third of the budget, because the projection kernel costs
/// ~1.5 µs/triangle in release (per-triangle allocations; see the kernel note in
/// `lib.rs::shade_mesh_tris_emissive`).
#[cfg(feature = "wgpu")]
pub const GPU_ANALYZE_SAMPLE_MAX: usize = 1024;
#[cfg(feature = "wgpu")]
impl WgpuVelloBackend {
/// A fresh wgpu+vello backend with an empty upload cache.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Stats-refresh passes `paint` has run so far — see [`GPU_ANALYZE_EVERY`].
/// Device-free observable for the frame sampling: painting 33 frames of an
/// unchanged scene leaves this at 3 (frames 0, 16, 32), and it reads 33 the
/// moment the unconditional per-frame refresh comes back. (Named for the full
/// analysis each refresh historically was; above [`GPU_ANALYZE_SAMPLE_MAX`] a
/// refresh is now the bounded sample.)
#[must_use]
pub fn full_analyzes(&self) -> u64 {
self.full_analyzes.get()
}
/// **The bounded paint-lane stats refresh.** At or below
/// [`GPU_ANALYZE_SAMPLE_MAX`] triangles (and under the test-only full-analysis
/// switch) this IS the exact [`analyze_scene`] — byte-equal to
/// [`RenderBackend::analyze`], which is why every small-scene suite is
/// untouched. Above the cap it analyses the cached stride sample of the
/// basemap and scales `visible_triangles` back to the full mesh — a documented
/// ESTIMATE (see [`RenderStats::visible_triangles`]) whose cost is flat
/// regardless of mesh size, instead of a 1-in-[`GPU_ANALYZE_EVERY`] multi-ms
/// hitch at korp's 600 k-triangle budget (GFX_V2 V2.9b). The overlay (a small
/// live pose) rides OUTSIDE the sample and is counted exactly.
fn refresh_stats(&self, scene: &RenderScene, installed: bool) -> RenderStats {
let n = scene.mesh.triangle_count();
if self.force_full_analysis.get() || n <= GPU_ANALYZE_SAMPLE_MAX {
return analyze_scene(scene, "wgpu-vello", installed);
}
if self.analyze_sub.borrow().is_none() {
*self.analyze_sub.borrow_mut() =
Some(stride_sub_mesh(scene.mesh, GPU_ANALYZE_SAMPLE_MAX));
}
let sub_slot = self.analyze_sub.borrow();
let sub = sub_slot.as_ref().expect("filled above");
let ns = sub.triangle_count().max(1);
// The sampled basemap, alone — the estimate's source. `sky_lut` never
// reaches the pure geometry analysis, so it is not cloned into the probe.
let base = analyze_scene(
&RenderScene {
mesh: sub,
water: scene.water,
camera: scene.camera.clone(),
sun: scene.sun,
viewport: scene.viewport,
bg: scene.bg,
flags: scene.flags,
sky_lut: None,
clock_s: scene.clock_s,
du_per_m: scene.du_per_m,
overlay: None,
overlay_emissive: &[],
},
"wgpu-vello",
installed,
);
// Scale the sampled visible count back to the full mesh, round-to-nearest.
// `base.visible_triangles ≤ ns`, so the estimate never exceeds `n`.
let scaled =
((base.visible_triangles as u64 * n as u64) + (ns as u64 / 2)) / ns as u64;
if scene.overlay.is_some() {
// Re-analyse the SAME sample with the overlay riding on it: the delta
// between the two counts is the overlay's true visible count (exact,
// not estimated — it is the live pose the user is watching), and the
// combined pass carries the overlay's effect on the other oracles.
let all = analyze_scene(
&RenderScene {
mesh: sub,
water: scene.water,
camera: scene.camera.clone(),
sun: scene.sun,
viewport: scene.viewport,
bg: scene.bg,
flags: scene.flags,
sky_lut: None,
clock_s: scene.clock_s,
du_per_m: scene.du_per_m,
overlay: scene.overlay,
overlay_emissive: scene.overlay_emissive,
},
"wgpu-vello",
installed,
);
let overlay_vis = all.visible_triangles.saturating_sub(base.visible_triangles);
let mut s = all;
s.visible_triangles = scaled as usize + overlay_vis;
s
} else {
let mut s = base;
s.visible_triangles = scaled as usize;
s
}
}
/// Test hook: force the paint-path stats refresh back to the UNBOUNDED full
/// analysis (every triangle, every refresh) — the non-vacuity switch for the
/// spike benchmark in `tests/gpu_analyze_spike.rs`, which must be able to show
/// the defect it guards against.
#[doc(hidden)]
pub fn force_full_analysis_for_test(&self, on: bool) {
self.force_full_analysis.set(on);
}
/// Test hook: the cached upload payload, compared by `Arc::ptr_eq` to prove the
/// per-frame handoff re-uses ONE build across frames rather than rebuilding.
#[doc(hidden)]
pub fn cached_geometry_for_test(&self) -> Option<std::sync::Arc<crate::gpu::GpuGeometry>> {
self.cache.borrow().clone()
}
}
/// The bounded stride sample of `mesh` the paint-lane refresh analyses above
/// [`GPU_ANALYZE_SAMPLE_MAX`]: every `k`-th triangle (`k = ⌈n / cap⌉`) with its
/// vertices compacted into a fresh vertex pool, so the projection kernel's
/// per-VERTEX transform (`O(positions)`) is bounded along with the per-triangle
/// work — sampling only the index list would still transform every vertex of the
/// full mesh. Deterministic and a pure function of the mesh, so the result is
/// cached per mesh and the systematic stride keeps the estimate stable frame to
/// frame (no sampling flicker in a HUD readout).
#[cfg(feature = "wgpu")]
fn stride_sub_mesh(mesh: &Mesh3, cap: usize) -> Mesh3 {
let n = mesh.triangle_count();
let k = n.div_ceil(cap).max(1);
let take = n.div_ceil(k);
let mut remap: Vec<u32> = vec![u32::MAX; mesh.positions.len()];
let mut sub = Mesh3::default();
sub.positions.reserve(take * 3);
sub.normals.reserve(take * 3);
sub.building.reserve(take * 3);
sub.colors.reserve(take);
sub.indices.reserve(take * 3);
for t in (0..n).step_by(k) {
for j in 0..3 {
let i = mesh.indices[t * 3 + j] as usize;
if remap[i] == u32::MAX {
remap[i] = sub.positions.len() as u32;
sub.positions.push(mesh.positions[i]);
sub.normals.push(mesh.normals[i]);
sub.building.push(mesh.building.get(i).copied().unwrap_or(false));
}
sub.indices.push(remap[i]);
}
sub.colors.push(mesh.colors[t]);
}
sub
}
#[cfg(feature = "wgpu")]
impl RenderBackend for WgpuVelloBackend {
fn caps(&self) -> BackendCaps {
BackendCaps { name: "wgpu-vello", gpu: true, shadows: true, reflective_water: true, post_fx: true }
}
fn analyze(&self, scene: &RenderScene) -> RenderStats {
// Same pure geometry oracle as every backend, tagged used_gpu = true: the
// GPU lane projects the SAME scene (the CPU projection IS the oracle for
// "what the camera framed"). Device-free, so it runs in the headless
// conformance test alongside the CPU / null backends.
analyze_scene(scene, "wgpu-vello", true)
}
fn reset_cache(&self) {
*self.cache.borrow_mut() = None;
// The stats belong to the mesh that was just dropped: a swapped scene gets a
// fresh full analysis on its next frame, not up to GPU_ANALYZE_EVERY frames
// of the old town's numbers.
*self.stats_cache.borrow_mut() = None;
// …and the stride sample belongs to that mesh too.
*self.analyze_sub.borrow_mut() = None;
self.frames_since_analyze.set(0);
}
fn paint(&self, scene: &RenderScene, ui: &mut egui::Ui) -> RenderStats {
// Empty mesh → nothing for the GPU pass; the CPU painter draws the fallback.
if scene.mesh.indices.is_empty() {
return paint_cpu(scene, ui);
}
let rect = scene.viewport.rect;
let cam = &scene.camera;
let aspect = (rect.width() / rect.height().max(1e-3)).max(1e-3);
let view_proj = cam.view_proj(aspect);
let inv_view_proj = crate::sky::invert(&view_proj).unwrap_or({
let mut id = [0.0f32; 16];
id[0] = 1.0;
id[5] = 1.0;
id[10] = 1.0;
id[15] = 1.0;
id
});
let light = scene.sun.light_uniform();
let sun_color = scene.sun.sun_uniform();
let sky_color = scene.sun.sky_uniform();
// `eye.w` carries the scene's **haze scale height in design units** — the one
// render-side length that has to reach the fragment shader. See
// `haze_scale_height`: 150 m of air, not 0.25 design units of nothing.
let haze_h = haze_scale_height(scene.du_per_m);
let eye = {
let e = cam.eye();
[e.x, e.y, e.z, haze_h]
};
let time = scene.clock_s as f32;
let pbr = [0.62f32, 0.0, time, 1.4];
let centre = [0.0, 0.4, 0.0];
let light_vp = [
scene.sun.light_view_proj(centre, 0.75),
scene.sun.light_view_proj(centre, 1.35),
scene.sun.light_view_proj(centre, 2.40),
];
let cascade_splits = [cam.distance * 0.85, cam.distance * 1.6];
let light_size = 0.45_f32;
let shadow_strength = scene.flags.shadow_strength;
let soft_shadows = scene.flags.soft_shadows;
let ssao_on = scene.flags.ssao;
let bloom_on = scene.flags.bloom;
let ssao_radius = ssao_radius(scene.du_per_m);
let ssao_intensity = 2.4_f32;
let water_on = scene.flags.water_on;
let reflect_on = water_on && scene.flags.planar_reflection;
// The reflection camera mirrors about the plane the water grid was BUILT on —
// `extrude::water_height` of this scene's scale, not the absolute constant. The
// two used to be the same literal read from two places; a metric water plane
// and an absolute mirror plane would put the reflection under the surface.
let refl_vp = cam.view_proj_mirrored(aspect, crate::extrude::water_height(scene.du_per_m));
// `shallow.w` is the Gerstner AMPLITUDE and `params.w` the base WAVELENGTH —
// both are world lengths the vertex shader displaces `in.pos` by, so both go
// through the scene's own scale exactly like `haze_scale_height` and
// `ssao_radius` four lines up. As raw design units (`0.0020` / `0.14`) they
// were 3.75 m of chop on a 262 m swell in korp's 3 km town window, and the
// troughs cut clean through the terrain the water sits on.
let wave_amp = crate::extrude::wave_amplitude(scene.du_per_m);
let wave_len = crate::extrude::wave_wavelength(scene.du_per_m);
let water_shallow = [0.18f32, 0.42, 0.52, wave_amp];
let water_deep = [0.02f32, 0.10, 0.20, 0.28];
let water_params = [0.45f32, 0.004, wave_len, 0.0];
let clear = {
let [r, g, b, a] = scene.bg.to_array();
[f32::from(r) / 255.0, f32::from(g) / 255.0, f32::from(b) / 255.0, f32::from(a) / 255.0]
};
// ONE writer (`crate::fog`) — the CPU painter evaluates `FogRamp::t` from the
// SAME struct, so the 4090 and the software lane cannot drift apart on haze.
let fog = crate::fog::FogRamp::from_camera_distance(cam.distance).as_uniform();
let ppp = ui.ctx().pixels_per_point();
let px_w = (rect.width() * ppp).round().max(1.0) as u32;
let px_h = (rect.height() * ppp).round().max(1.0) as u32;
// Build + cache the GPU payload once; hand the SAME `Arc` to the callback
// each frame — a refcount bump, and nothing more can happen here because
// `GpuGeometry` is not `Clone` (the line this replaces deep-copied ~72 MB
// per frame under a comment claiming "cheap Rc clone"). Re-sending every
// frame is what lands the upload whichever frame the host's wgpu backend
// `prepare`s first; the installed renderer's `upload` compares the payload's
// build stamp and touches the device only when this cache was rebuilt.
if self.cache.borrow().is_none() {
*self.cache.borrow_mut() =
Some(std::sync::Arc::new(crate::gpu::build_gpu_geometry_with_water(scene.mesh, scene.water)));
}
let geometry = self.cache.borrow().clone();
// ── the per-frame overlay (V2.5 D.3): built FRESH each frame, deliberately ──
// The overlay is a live pose (~100 verts), so caching would only add a staleness
// seam; the payload is ~4 KB and the renderer writes it to a tiny dedicated
// buffer — the main mesh cache above is untouched and its generation never
// bumps for an overlay move.
let overlay = scene
.overlay
.map(|m| std::sync::Arc::new(crate::gpu::build_overlay_geometry(m, scene.overlay_emissive)));
ui.painter_at(rect).add(egui_wgpu::Callback::new_paint_callback(
rect,
crate::gpu::Map3dPaintCallback {
geometry,
overlay,
view_proj,
inv_view_proj,
sky_on: scene.flags.sky,
light,
clear,
fog,
sun_color,
sky_color,
eye,
pbr,
light_vp,
cascade_splits,
light_size,
soft_shadows,
shadow_strength,
// GFX_V2 item 7: derived from the view by `constrain_to_view`, passed
// through verbatim. The GPU lane consults `any_post()`'s inverse here
// rather than each pass deciding for itself.
flat_2d: scene.flags.flat_2d,
ssao_on,
bloom_on,
ssao_radius,
ssao_intensity,
refl_vp,
water_on,
reflect_on,
water_shallow,
water_deep,
water_params,
px_w,
px_h,
// Physical-sky transmittance LUT (FLIGHT-SIM #7): re-send the cheap Arc
// each frame; the renderer only re-uploads the texels when it changes.
lut: scene.sky_lut.clone(),
},
));
// **`used_gpu` is "the GPU lane DISPATCHED", not "this backend ran".** It was the
// literal `true`, and this method runs whether or not a `Map3dRenderer` exists for
// the callback above to dispatch to — the callback simply no-ops. Measured
// 2026-08-06 on an RTX 4090: `with_gpu(true)` with no `install_renderer` reported
// `used_gpu = Some(true)`, so the one observable that exists to separate the two
// halves of the GPU path could not see either half missing. See
// `crate::gpu::renderer_installed` for the honest bound.
let installed = crate::gpu::renderer_installed();
// ── SAMPLED scene analysis (the stats, not the pixels) ────────────────────
// The full `analyze_scene` is a software transform+clip+shade+sort of every
// mesh triangle, and this lane once ran it every frame purely to fill
// `RenderStats` — during a drag, most of the frame's CPU went to computing a
// statistic about pixels the GPU draws regardless. Two bounds keep it off
// the frame: refresh only on the first frame after a mesh swap
// (`reset_cache` clears `stats_cache`) and every `GPU_ANALYZE_EVERY`-th
// frame — and the refresh itself is BOUNDED by `GPU_ANALYZE_SAMPLE_MAX`
// (`refresh_stats`), because the frame sampling alone left a 1-in-16
// multi-ms hitch at korp's 600 k-triangle budget, and a rhythmic stutter
// reads as broken where random jitter reads as load (GFX_V2 V2.9b). In
// between, re-report the last analysis. Only `used_gpu` is re-observed per
// frame — it is per-process state that can change under us (an install
// landing between frames). So on THIS lane `visible_triangles` means "as of
// the most recent sampled analysis, at most GPU_ANALYZE_EVERY frames old —
// and an ESTIMATE above the sample cap"; the field doc says so, and
// `full_analyzes` keeps the skip observable.
let refresh = self.stats_cache.borrow().is_none()
|| self.frames_since_analyze.get() + 1 >= GPU_ANALYZE_EVERY;
let stats = if refresh {
let s = self.refresh_stats(scene, installed);
self.full_analyzes.set(self.full_analyzes.get() + 1);
self.frames_since_analyze.set(0);
*self.stats_cache.borrow_mut() = Some(s.clone());
s
} else {
self.frames_since_analyze.set(self.frames_since_analyze.get() + 1);
let mut s = self.stats_cache.borrow().clone().expect("refresh gate checked is_none");
s.used_gpu = installed;
s
};
// ── STEP 7 emit (GPU lane): the scissor oracle (ink confined by the GPU
// scissor = viewport ∩ clip_rect, so ink_outside_rect is 0 by construction).
#[cfg(feature = "testmatrix")]
{
let ppp_f = ui.ctx().pixels_per_point();
let screen = {
let s = ui.ctx().content_rect();
[(s.width() * ppp_f).round().max(1.0) as u32, (s.height() * ppp_f).round().max(1.0) as u32]
};
let info = egui::PaintCallbackInfo {
viewport: rect,
clip_rect: rect,
pixels_per_point: ppp_f,
screen_size_px: screen,
};
let sc = crate::gpu::gpu_scissor_px(&info);
let rx = (rect.left() * ppp_f).floor() as i64;
let ry = (rect.top() * ppp_f).floor() as i64;
let rr = (rect.right() * ppp_f).ceil() as i64;
let rb = (rect.bottom() * ppp_f).ceil() as i64;
let inside = i64::from(sc.x) >= rx
&& i64::from(sc.y) >= ry
&& i64::from(sc.x) + i64::from(sc.w) <= rr
&& i64::from(sc.y) + i64::from(sc.h) <= rb;
facett_core::testmatrix::emit(
"facett-map3d",
"scissor",
inside,
&format!(
"lane=gpu ink_outside_rect=0 scissor=[{},{} {}x{}] rect_px=[{},{} {}x{}]",
sc.x,
sc.y,
sc.w,
sc.h,
rx,
ry,
(rr - rx).max(0),
(rb - ry).max(0)
),
);
}
// ── GPU render-lane emit: this exact path RAN and framed real geometry. ──
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-map3d::draw_map_gpu",
"gpu_render",
stats.visible_triangles > 0,
&format!(
"used_gpu=true visible_tris={} mesh_tris={}",
stats.visible_triangles,
scene.mesh.triangle_count()
),
);
stats
}
}