The render engine — reasoning
Class:
reasoning. This file holds the why, the trade-offs, and every design we rejected. The descriptive shape we actually run is inrender-design.md, which by rule mentions none of this. If you want to know "why not X?" — X is here.
Why exactly two hardware categories (CPU / CPU+GPU)
The instinct is a ladder of tiers (no-SIMD, SIMD, WebGL2, WebGPU, discrete GPU…). We rejected the ladder for two categories only:
| category | hardware reality | data ceiling |
| CPU | any CPU (SIMD assumed) — device / tank / headless / server / no-GPU | ~10–100k features, LOD-decimated |
| CPU + GPU | desktop / laptop / phone with a working GPU | millions+, planet-scale |
The reasoning: the consumer must never hand-pick hardware — the box auto-detects (decide(GpuProbe)). More tiers means more auto-detect branches and more test-matrix cells for paths almost nobody hits. So:
- No-SIMD is not a designed tier. It survives only as
vello_cpu's free scalar fallback. x86-64 always has SSE2; ARM64/Apple always has NEON. The only genuinely no-SIMD cases — wasm withoutsimd128, ancient 32-bit ARM (Pi Zero/1) — are not real targets. (Rickard, 2026-06-21, "last words": those stuck on 1990s hardware can fuck off.) - native vs wasm is a build axis, not a hardware tier — orthogonal. wasm is the only place "SIMD or not" is a build-time fork (ship two bundles, sniff the browser).
Why L0 / L1, and why L0 must stand alone
We split the kernel into L0 (the SDF instanced base, GPU and CPU) and L1 (the optional vello beauty overlay). The rejected alternative was "vello is the renderer" — one lane, always vello. Rejected because:
- Device / tank / headless / wasm-CPU must render great with zero fat-GPU deps. If beauty lived only in
vello(compute), a no-GPU box gets nothing. So L0 is the speed and beauty base, and L1 is a desktop-only cherry that degrades to the bare L0 frame (compose_l0_l1returnsl1_drawn=false, not an error). - The default build carries zero GPU dependency edge. That is only possible if the GPU + vello lanes are behind features (
wgpu,l1-vello) and the seam still compiles without them — hencedecide()keys the GPU arm offgpu_arm_compiled()(cfg!(feature = "wgpu")), so a probe that claims a GPU still resolves toVelloCpuwhen the feature is off. The seam is real without the dep.
Rejected: DIY-ing the rasterizer
Writing our own coverage/strip/blend/AA rasterizer was tempting (full control). We rejected it: that is vello_cpu — thousands of lines of proven strip/coverage/ blend/AA with a free SIMD speedup (fearless_simd: scalar → SSE4.2 → AVX2+FMA / NEON, selected at runtime, ~4–5× on the raster ALU stage). We DIY only the small geo kernels (project, bbox/frustum cull, decimate) and buy the rasterizer. The SIMD division of labor has three non-competing owners: Ragnar stree for search (what's in view), DIY for the geo kernels, vello_cpu for pixels.
Why one coverage math mirrored on both lanes (the parity contract)
cpu::sdf is the single source of truth; sdf.wgsl / line.wgsl mirror it byte-for-byte. The alternative — let each lane compute coverage its own way — was rejected because then a CPU golden and a GPU frame drift, and you can never assert "the GPU is correct" against the always-available CPU reference. Keeping one math buys the gpu_sdf_parity test: render the same instances both ways, assert matching pixels (skips gracefully with no adapter). It also means a decoration or a custom raster can call the same quad_coverage/line_coverage the shader runs.
Why SDF instances (not meshes / not egui shapes)
A node, a city marker, a POI dot, a waypoint are all a QuadInstance; a road, an edge, a leader line are all a LineInstance. Making them one flat bytemuck-able POD with a u32 shape discriminant (not a Rust enum) means one quad pipeline draws every shape and an instance array uploads to the GPU verbatim. The rejected shape — per-shape pipelines or tessellated meshes per primitive — costs a pipeline switch per kind and a CPU tessellation pass the SDF fragment shader makes unnecessary (the AA is analytic, resolution-independent, free of a mesh). Domain-agnostic instances are also what let map and graph share one kernel with two thin skins.
Rejected: rayon for the CPU raster (GATLING is LAW)
The CPU raster fans across cores. The obvious choice is rayon. We rejected it in favour of znippy's gatling scanline fork-join (gatling_scanlines): each worker self-dispatches whole rows and writes into that row's disjoint pixel slice — no barrier, no wait-for-a-core, zero-copy (row-index hand-off), zero-alloc in the hot loop. Two consequences that a naive parallel raster loses: the per-pixel composite order stays bit-identical to the sequential path (blends never contend because a row has one owner — pinned by parallel_raster_matches_sequential), and the background clear + un-premultiply run inside the parallel region instead of as a serial Amdahl tail. The y-bucket index (YBuckets) exists for the same reason: it turns the per-row reject from O(all_prims × h) into O(touches) without changing a pixel (pinned by ybucket_raster_matches_brute_force_all_primitives). This is znippy Law 1 (zero-copy/zero-alloc) + Law 2 (gatling multicore) applied to rendering.
Why the SCAN-THE-PIXELS oracle exists (the smear bug)
On 2026-06-21 the deployed wasm OSM-3D map smeared green spokes across the whole frame while every render test was green — because no test analyzed the image; they only checked "non-blank / has lit pixels / wrote a PNG". That passes on a smear, a starburst, a garbage frame. So imgscan computes real structure from the pixels (spoke_score, high_freq_ratio, coverage, centroid, bbox) and a render test asserts a viewport-tight, sensitivity-proven bound — the oracle ships a test that proves it can go RED on the bug (clean vs smeared are clearly separated). "It drew" is not "it's correct."
Related law: the pixel stages that can't be observed from a return value (did it draw, and only inside the rect?) carry a functional_status emitter — fallback, not default — behind the testmatrix feature, stripped to a no-op in release. CpuRenderer::present and GpuSdfHeadless::present each emit a uniquely-named row so a matrix test can assert that exact lane ran. The clip bug hid precisely because the GPU draw_map_gpu lane emitted nothing; the render-paths inventory now enumerates every lane so "every path produced a row" is assertable.
Rejected: per-skin bespoke GPU pipelines
facett-map, facett-map3d, and facett-graph3d each hand-rolled the identical egui_wgpu::CallbackTrait plumbing (a *Renderer in callback_resources, an install-once, a paint callback) plus their own Vertex/WayMeta/uniform structs and cull/draw shaders. We rejected keeping three copies: the shared scaffold (install_renderer, types, buffer, the byte-identical CULL_WGSL/DRAW_WGSL) was extracted so the skins re-point at one kernel (Phase C). The shaders are asserted byte-identical to facett-map's so the extraction can't silently change semantics — the country-scale chunking fix (#12: split geometry ≤ max_buffer_size on way boundaries) is load-bearing and must stay verbatim.
Rejected: WebGL2 as a first-class lane; and the text stack
- WebGL2 (no compute shaders) is a corner case, not a lane we build. Modern-first doctrine: target WebGPU + wgpu compute; WebGL2-only browsers degrade through
vello_hybrid— a free fallback, not a bespoke no-compute instanced path we maintain. - Text →
parley(Linebender), not cosmic-text. Same ecosystem as vello, whose glyph-run API consumes parley output natively; shaping is a CPU hard-dep in both categories, only the raster forks (vello_cpuglyph raster on the CPU row, avello/MSDF atlas on the GPU row). The smaller modern stack over the bigger compat matrix.
Why the shared Camera / LayerStack seams landed early (as seams, not features)
"Graphs overlaid on maps" is a later feature, and a shared renderer driving both map and graph is later still. But retrofitting a shared camera and a z-order into two skins that each own their own camera is a rewrite. So the seams landed now — one Camera that is the bit-for-bit superset of graphview's 2D pan/zoom and map3d's 3D orbit (goldened by the camera_seam test), and a z-ordered LayerStack — with no consumer wired. One line of seam now buys a no-retrofit overlay later; the skins keep their own camera types this milestone (zero behavior change).
The road here (phases, and what is not yet real)
The kernel landed in phases (feat/l0-wgpu-vello, 2026-07-02):
- Phase A — scaffold (DONE):
camera/layerseams, the moved scissor, the wgpu install/callback lifecycle + bytemuck plumbing. - Phase B — L0 kernel + backend seam (DONE):
primvocabulary,cpulane (CpuCanvas→ vello_cpu, gatling),gpulane (GpuSdfRenderer+ offscreen parity),decide. TheCanvas/Renderertraits both lanes satisfy. - Phase B′ — L1 vello seam (DONE):
L1Overlay→vello::Scene→ headlessrender_overlay→compose_l0_l1. Behindl1-vello; L0 stands alone.
Not yet real (do not read these as current design — they belong here, not in render-design.md):
- Phase C — re-point the skins (NEXT): facett-graphview re-exports
backend::decide(drops its own copy); facett-map / facett-map3d draw their base primitives through the L0 kernel instead of their own pipelines; theegui_wgpu::CallbackTraitcomposite (GpuSdfRenderer::recordin a host-owned pass) replaces per-skin bespoke pipelines. Today the skins still draw themselves directly — the kernel is the additive seam they will consume. The camera and layer stack are shipped but no consumer is wired. - Phase D — L1 depth (LATER): parley glyph-runs →
velloglyph atlas (GPU text raster), true HDR bloom on theglowaccent, a frosted-blur backdrop, and deeper use of the SOTA post modules. Thecolorgrade/particles/msdf/blurmodules exist and are proven on-device, but are composable opt-ins not yet wired into a skin's overlay;GaussianBluris explicitly "not yet wired intooverlay.rs".
Part-step decomposition — why the pipeline is many small functions
"Draw the map" is not one opaque call — the clip stage alone is five sub-steps (view-space near-plane clip → project → frustum cull → back-face cull → shade → depth-sort → rect scissor → rasterize/composite). That granularity is deliberate: the live clipping bug (draws, but ink escapes the rect) hid because "clipping" was treated as one thing. Each part-step is independently injectable, emittable (near_clip/project/cull/shade/scissor/…), and benchable — a composable architecture demands part-step benchers so "the clip step got 3× slower" is a visible, attributable regression instead of noise drowned in the end-to-end rollup. The brittle heuristics this replaced (a "near-singularity spoke reject" + a "screen-extent guard") were guesses that missed faces shifted far together — that was the bug; a correct geometric near-clip plus the rect scissor replaces both.
Design references (patterns, not dependencies)
The backend-swap seam is galileo-style (Renderer/Canvas trait = the 2-category swap as one trait) and the layer stack is its layer-trinity idea — but galileo is wgpu-only (can't serve the CPU floor), early and churny, so we adopt the patterns and write our own. galileo itself planning a vello backend is part of what validated the vello mix-in.