facett render engine — 2-category (CPU / CPU+GPU) map + graph viewers
Status: RATIFIED design (Rickard + agent, 2026-06-21). The canonical spec for facett's map and graph render path.
nornir/.nornir/nordisk_plan.md§"facett MAP components" andworkspace_nordisk/PLAN.md§E reference this doc in pieces. Builds on the L0/L1 doctrine already in the plan; supersedes the looser "each runtime-selects CPU vs GPU" wording.
The black box
┌─────────────────────────────────────────────┐
INPUTS ───► │ OSM extract (PBF/region) — carries: │
│ streets · water · buildings · landuse │
│ · coastline · POIs │
│ + border/admin polygons (.poly/GeoJSON) │ ──► navigable
│ — clip region + draw boundaries │ MAP / GRAPH
│ + style (facett Theme) │ facett
└───────────────┬─────────────────────────────┘ (pan/zoom/orbit)
(hidden inside the box) │
osm-katana convert → GeoParquet
→ Ragnar spatial index (what's in view)
→ LOD / tiling / cull
→ Symbol styling from Theme
→ render backend AUTO-SELECT ◄── the 2 categories live HERE, hidden
Input contract: (OSM extract, border polygons, Theme) → navigable viewer. OSM already contains streets/water/buildings/landuse — they are not fed separately. Borders come separately only because clean admin polygons (.poly / Natural Earth) clip and draw better than OSM's admin relations. Out of scope v1: terrain/DEM, satellite raster (additive layers later). The hardware category is auto-detected; the consumer never hand-picks it.
Hardware = 2 categories ONLY
| category | hardware reality | data ceiling |
| CPU | any CPU (SIMD assumed) — device / tank / headless / server-render / no-GPU | ~10–100k features, LOD-decimated |
| CPU + GPU | desktop / laptop / phone with a working GPU | millions+, planet-scale |
- 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 no-SIMD cases — wasm withoutsimd128, ancient 32-bit ARM (Pi Zero/1) — are not real targets. - Build axis (orthogonal, not a hardware tier): native + wasm. wasm is the only place "SIMD or not" is a build-time fork — ship two wasm bundles (
simd128/ no-simd), sniff the browser. On the web the "GPU" may be WebGL2 (no compute shaders) → the box internally usesvello_hybrid/ no-compute instancing instead ofcull.wgsl+draw_indirect. (This is the one hidden third path inside "CPU+GPU × wasm".)
velo = Vello (Linebender) — a family, used as the tier ladder
| crate | needs | role in facett |
vello |
fat GPU (compute) | the L1 beauty overlay on the CPU+GPU row |
vello_hybrid |
any GPU incl. WebGL2 | the GPU path on web / weak GPUs |
vello_cpu |
just a CPU | the entire renderer on the CPU row |
vello_cpu uses fearless_simd: Rust monomorphization emits scalar → SSE4.2 → AVX2+FMA (x86) / scalar → NEON (ARM), selected at runtime. SIMD is a free speedup, never a requirement (~4–5× on the rasterizer ALU stage — no vendor figure published; anchored to nornir's own AVX2 cosine kernel = 4.48× scalar on Zen2).
SIMD: a 3-way division of labor (the key decision)
Each SIMD stage has exactly one owner; they compose, they don't compete:
| stage | SIMD owner | status | buys |
| search / spatial index (what's in view, point/ID lookup, LOD tile index) | Ragnar stree (Ragnar Groot Koerkamp static-search-tree; stree/stree32 in znippy-zoomies) |
✅ ours, already used (osm-katana STree64) | reach the data ceiling on weak devices — cull millions→thousands fast, even with no GPU |
| geo kernels (projection lon/lat→screen, bbox/frustum cull, decimation) | DIY (our proven AVX2+scalar runtime-dispatch pattern, ~cosine-kernel sized) | to write | raw coords → screen-space fast on the CPU floor |
| rasterization (pixels) | vello_cpu (BUY, free, scalar fallback) |
dependency | 4× on the CPU row; irrelevant on CPU+GPU (the GPU rasterizes) |
Do NOT DIY a rasterizer — that is vello_cpu (thousands of lines of strip / coverage / blend / AA). DIY only the small geo kernels. Ragnar is SIMD for search, not rendering — it serves the index stage that decides what to draw, not the pixel stage.
Render doctrine — L0 / L1
- L0 = facett-core SDF instanced-wgpu base, no vello, runs on GPU and has a CPU raster fallback. The SPEED and BEAUTY base. Must look great with zero vello.
- L1 = optional vello overlay (curves / gradients / glyph text / effects); desktop only; never required; device/tank never pulls vello.
MAP render matrix (2 rows)
Shared spine (both categories, CPU): osm-katana ingest PBF→GeoParquet · facett-osm geom read (WKB) · osm-katana --poly clip · Ragnar stree index · facett-core camera + Theme · parley text shaping.
| Project geom→screen | Cull / LOD | Area fills | Lines / roads | Points / markers | Text raster | Effects | Composite → egui | |
| CPU | DIY SIMD kernel | Ragnar stree + CPU bbox | vello_cpu (ear-clip→fill) |
vello_cpu stroke |
vello_cpu / CPU-SDF |
vello_cpu raster |
off | blit pixmap → egui texture |
| CPU+GPU | wgpu vertex shader | cull.wgsl compute |
facett draw.wgsl (L0) |
facett instanced draw_indirect (L0) |
instanced SDF quads (L0) | vello glyph atlas (L1) |
vello + wgpu shaders (L1) |
egui_wgpu callback |
GRAPH render matrix (2 rows) — same engines, different domain stages
Shared spine (both, CPU): knut FalkorDB/openCypher adapter (facett never learns Cypher) · facett-core model / Navigable / core::edges / camera / Theme · parley shaping.
| Layout | Cull / cluster | Nodes | Edges (straight) | Edge beauty (curve/bundle/flow) | Text raster | Picking | Effects | Composite → egui | |
| CPU | facett-graphview (CPU force/Sugiyama) | CPU bbox + cluster→super-node | vello_cpu SDF |
vello_cpu stroke |
straight only | parley → vello_cpu |
CPU hit-test (Ragnar/quadtree) | off | blit pixmap → egui texture |
| CPU+GPU | graphview (CPU; GPU-compute force later) | compute cull + cluster | instanced SDF quads (L0) | facett instanced lines (L0) | vello L1 |
parley → vello L1 atlas |
GPU id-buffer readback | vello + wgpu shaders (L1) |
egui_wgpu callback |
Payoff: ONE render kernel (facett-core L0 + vello_cpu/vello L1), TWO domain skins (map, graph). Same 2 categories, same SIMD split — only the domain stages swap (map: project/tessellate/fills; graph: layout/edges/picking). Effects is the only hard OFF on the CPU row — the honest fidelity line between the categories. Graphs-overlaid-on-maps = LATER (compose two skins over a shared camera + z-order; not a new crate).
Crate consolidation
Rule: the render engine consolidates; domain "kinds" stay separate as thin skins.
┌─ facett-core ─────────────────────────────────────────────┐
│ THE SHARED RENDER KERNEL (maps AND graphs sit here) │
│ CPU painter · L0 SDF instanced-wgpu · vello L1 seam · │
│ Theme · camera · Navigable · core::edges · labels │
└───────────────┬───────────────────────────┬───────────────┘
┌────────────▼─────────┐ ┌────────────▼───────────────┐
│ MAP skins (kinds) │ │ facett-graphview │
│ facett-map (2D) │ │ THE GRAPH ENGINE │
│ facett-map3d (3D) │ │ layouts·cull·cluster· │
│ ← facett-osm (data) │ │ semantic-zoom·picking │
└──────────────────────┘ └────────────┬────────────────┘
facett-graph(2D)·facett-graph3d(3D)·
facett-syschart·facett-pipeline(+overlay)
| crate | verdict | why |
| facett-core | consolidate INTO | the one render kernel — L0 SDF + CPU painter + vello L1 seam + Theme + camera + Navigable + core::edges + labels. The 2-category swap lives here as ONE trait (galileo-style Renderer seam). |
| facett-graphview | the graph ENGINE | layouts / cull / clustering→super-node / semantic-zoom / picking over facett-core L0; the "beat gDotV + FalkorDB-browser" engine. |
| facett-graph, facett-graph3d | keep — thin skins | 2D vs 3D are different kinds; thin consumers of facett-graphview. |
| facett-syschart, facett-pipeline | keep — domain skins | system-map / DAG (+ status/gantt overlay) over graphview. |
| facett-map, facett-map3d | keep — different kinds | 2D vs 3D map; thin skins over facett-core L0. |
| facett-geomap | FOLD INTO facett-map | point-only / "dead" today → becomes facett-map's slippy/scroll mode, not a separate crate. |
| facett-osm | keep — data, not a viewer | the GeoParquet/WKB reader; feeds the map skins. |
Robot-UI testing — EVERY function from start → pixel, validated as DATA
LAW (Rickard 2026-06-21). A map/graph that draws something but draws it wrong (today's bug: the 3D map renders, but clipping doesn't work — geometry escapes the widget rect) is the canonical failure this section exists to catch. "It drew" is not "it's correct." So every stage from input to pixel is instrumented and matrix-tested, per
nornir/.nornir/agent-ui-test-fixing.md+ the EMIT-DOCTRINE.
Make EXTREME use of the test matrix + emitters. Concretely:
- Every render-matrix column is an emit checkpoint. Each stage emits its IN→OUT as
functional_status(component, check, ok, detail)(nornir-testmatrix 0.2.0). The chain for the map path, in order:
| stage | emit check | detail it carries |
| project | project |
coords_in → screen_out (counts equal, none NaN) |
| cull/LOD | cull |
features_in → visible_out (monotone, ≤ in) |
| clip | clip |
ink_outside_rect == 0 (the bug oracle), near_plane_clipped |
| tessellate | tessellate |
polys_in → tris_out (closed rings only) |
| raster | raster |
lit_px / total_px (non-blank) |
| composite | composite |
frame_px, alpha_nonzero |
- Emitters are FALLBACK, not default (EMIT-DOCTRINE): assert on the return value /
state_json()first; implant the in-functionfunctional_statusemitter only where the result can't be seen from outside — i.e. the GPU/CPU pixel stages ("did it actually draw, and only inside the rect?"). Clip + raster + composite are the can't-observe-from-outside cases; project/cull/tessellate are mostly return-value assertions. - Compiled during dev / test-matrix runs only. The emitters sit behind the
testmatrixcargo feature — release builds strip them / no-op (zero runtime cost). Same gate the rest of the constellation uses; rows append toNORNIR_TESTMATRIX_OUT(atomic O_APPEND, cross-process). - The FINAL PICTURE is emitted as a BLOB. The composite stage emits the rendered frame (downsampled PNG / framebuffer bytes) into the emit stream / warehouse —
functional_statusdetail carries a blob ref. So an agent can READ the actual rendered image without asking the human (Readthe emitted PNG), turning "looks wrong" into a pixel the agent inspects itself. Pairs with the wgpu golden-PNG snapshot tests (egui_kittest). - Debug by binary search over the chain. The clipping bug = walk the emit chain, find the first stage whose oracle is red (here:
clip.ink_outside_rect > 0/near_plane_clippedlow at a street-level dolled-in pose), recurse the wrong half. Every fix earns a fail-on-bug / pass-on-fix matrix row. - Drive the LIVE surface.
nornir-robotui(egui_kittest AccessKit → click/scroll/ orbit) drives the real viewer; each step reads backstate_json()+ the emit chain → oneTestResultRowper step. A dead/clipped frame = a RED matrix cell. The matrix is PROVABLY complete only when every stage of every category × {native, wasm} is green.
Acceptance for the clipping bug specifically: at the failing street-level camera pose, clip.ink_outside_rect reads RED before the fix and GREEN after, the emitted final-picture blob shows no ink outside the rect, and a matrix row pins it (no regression of the existing far-plane fix 7c535a8).
Part-step decomposition — the composable pipeline (real functions)
The render path is a chain of composable part-steps, not one opaque "draw map". The 3D map (facett-map3d shade_faces lib.rs:428 → clip_poly_to_rect :592) is the worked example; the 2D map and graph paths mirror it. "Clipping" alone is FIVE sub-steps — which is exactly why the live bug (draws, but ink escapes the rect) hid:
| # | part-step | fn anchor (map3d) | input → output | emit oracle | bencher |
| 1 | view-space near-plane clip (NEW — the fix) | Sutherland–Hodgman vs z=near before project |
tris → tris (split at near plane) | near_clip.crossing_split ≥0, none behind eye survive |
facett.map3d.near_clip |
| 2 | project | cam.project() :434–438 |
verts → screen px (once, reused buffer) | project.coords_in==screen_out, no NaN |
facett.map3d.project |
| 3 | frustum cull (near/far) | :469 | tris → tris | cull.in≥out, monotone |
facett.map3d.cull |
| 4 | back-face cull (CCW screen) | :489 | tris → front-facing | cull.backface_dropped |
(folded into cull bench) |
| 5 | shade (fog + directional light) | :452–514 | tris → coloured tris | shade.lit_fraction |
facett.map3d.shade |
| 6 | depth-sort (painter far→near) | :526 | tris → ordered | sort.is_monotone |
facett.map3d.depth_sort |
| 7 | 2D rect scissor | clip_poly_to_rect :592 |
tri → 3..7-gon ∩ rect | scissor.ink_outside_rect==0 ← the bug oracle |
facett.map3d.scissor |
| 8 | rasterize → composite | CPU painter / draw_map_gpu :539 |
geom → pixels | raster.lit_px>0, composite.frame (PNG blob) |
facett.map3d.composite |
The brittle heuristics replaced: the old "near-singularity spoke reject" + "screen- extent guard" (drop a face whose projected extent dwarfs the viewport) are guesses that miss faces shifted far together — that IS the bug. Step 1 (geometric view-space near clip) replaces both with a correct clip, then step 7 confines the rest. Each step is independently injectable, emittable, and benchable.
Bencher LAW — bench the PARTS, not just "draw map"
LAW (Rickard 2026-06-21). A composable architecture demands part-step benchers. Benching only the end scene (
facett.map_render) hides where time goes and lets a regression in one stage drown in the total. Every part-step in the decomposition above gets its ownBencherentry (thefacett.map3d.<step>column), with injected synthetic input sized to isolate that step. The end-to-end bench stays as the rollup.
- One bench per part-step (project / near_clip / cull / shade / depth_sort / scissor / composite), plus the existing
facett.map_renderrollup. Same for the 2D map and the graph path (facett.graph.<step>: layout / cull / nodes / edges / picking / composite). - Each bench feeds real input (per the inject-assert law) and reports cores-busy + the part's ns/op, into the warehouse Bencher engine.
- Benches are never heavy-gated (explicit
nornir bench run); part-benches are cheap. - This is what makes "the clip step got 3× slower" a visible, attributable regression.
Process SVGs — skade circuit-board style (GRAND, per multi-part process)
LAW. Every multi-part render process ships a skade-style SVG (dark PCB bg, DejaVu Sans, cyan flow paths, left→right; template
znippy/.nornir/assets/osm-katana-path-matrix.svg) infacett/.nornir/assets/, embedded in this design doc + the guide. The flow shows each part-step as a chip, the data shape on each edge, and the emit/bench tap on each step.
assets/map3d-clip-pipeline.svg— the 8-step chain above (the clipping process the user asked for, drawn as its real sub-parts). Ships in this change.assets/map-render-2d.svg,assets/graph-render.svg— the 2D map + graph pipelines.- Each tap (emit checkpoint + bencher) is drawn on its step, so the SVG, the emit chain, and the bench list are one picture.
Modern-first doctrine — small stack, no legacy tax
LAW (Rickard 2026-06-21). Prioritise modern software + hardware: WebGPU, wgpu compute, AVX2/NEON, the Linebender stack (Vello + parley + fearless_simd). Corner-case legacy (WebGL2-only browsers, no-SIMD CPUs, pre-2013 silicon) is served only by free fallbacks (
vello_cpuscalar,vello_hybrid) — if it costs nothing it stays, otherwise it's deferred. We optimise for a small modern stack, not a big compatibility matrix. Those stuck on 1990s hardware can fuck off.
Open-thread resolutions (modern-first)
- Text path →
parley(DECIDED). Linebender'sparleyfor shaping + layout — same ecosystem as Vello, whose glyph-run API consumes parley output natively. Hard dep in both categories (shaping is CPU); only the raster forks (vello_cpuglyph raster on CPU row,velloglyph atlas on GPU row). Not cosmic-text — parley is the modern, smaller-stack choice that composes with the renderer we already chose. - wasm / WebGL2 → target WebGPU,
vello_hybridcovers the rest (DECIDED). The web GPU path targets WebGPU (modern). WebGL2-only browsers degrade throughvello_hybrid— a free fallback, not a bespoke no-compute instanced path we build and maintain. WebGL2 is the corner case, not a first-class lane (modern-first). wasm still needs the two-bundle SIMD split for the CPU row. - graphs-over-maps → add the SEAM now (one line), not the feature. facett-core gets a shared
Camera+ az-ordered layer stackso a graph skin can later composite over a map skin without a retrofit. Just the seam (shared camera coords + layer z-index); the overlay feature itself stays later.
Design references (PATTERNS only — facett builds its own; NOT a dependency)
From galileo (github.com/Maximkaaa/galileo; wgpu-only so it can't serve the CPU floor; early/churny; galileo itself plans a vello backend — which validated the vello mix-in). Adopt the patterns, write our own:
- Renderer/Canvas backend-seam trait = the 2-category swap expressed as one trait.
- Layer trinity (raster / vector-MVT / feature) — explicit stackable layers vs one monolithic
MapView. Symboltrait — per-feature-class styling offTheme(replaces facett-map's hardcodedWayStyle/classify; folds into the paused MAP-2D-STYLE work).TileSchema+Lod— reusable LOD / tile-index math (one cull path feeds CPU-decimate and GPU planet-scale).- Transparent CRS reprojection seam in the feature layer (facett is Mercator-only today; keep the seam so non-Mercator isn't a rewrite).
- Roadmap: clusters / heatmaps / image-point symbols → confirms T3 clustering + facett-plotters heatmap-as-a-layer.
Tile source — planet-scale map data (znippy tile container)
Beyond direct GeoParquet (facett-osm), the planet/"zillions" tier is fed by tiles. Standard where it matters, novel only in the container:
- Addressing = XYZ / Web-Mercator (universal); content = MVT vector tiles (+ PNG/WebP raster) — do NOT invent a coordinate scheme.
- Container = znippy tile profile (see nordisk_plan TILES-ZNIPPY):
z/x/y→ Morton/Hilbert key → dense per-zoom offset array = O(1) lookup, tiles dedup-by-hash, zero-copy mmap, GATLING-built. Out-features PMTiles (which walks a root→leaf directory over HTTP Range) for the device/airgap tier. - Interop = read/write MBTiles + PMTiles — every existing tileset flows in; emit
.pmtilesfor web. - Flow: osm-katana → MVT → znippy tiles → facett reads tiles directly (mmap, O(1), zero-copy) → cull/LOD stage → render. Holger optionally exposes
GET /{z}/{x}/{y}for networked clients.
Bench bars
- Scale: galileo renders 3.6M points / ~4000 polys (500K verts) + 40K city points on plain wgpu → facett's GPU instanced path must match or beat it →
facett.map_renderbench assertion. - SIMD worth (anchors): nornir AVX2 cosine = 4.48× scalar; Ragnar stree32 = 9.4–20.9× vs binary search.
Hard LAWS — speed, parallelism, scale, look&feel (Rickard 2026-06-21, "last words")
- ZERO-COPY / ZERO-ALLOC render pipeline (mirror znippy Law 1 & 2). Part-steps pass slot indices into pre-allocated buffers, never
Vecclones across stages. Per-frame work reuses buffers (the project-once vertex buffer is the model — extend it: no per-tri, per-edge, per-node allocation in the hot path). Geometry uploaded once to VRAM; pan/zoom = a uniform poke, never a rebuild. Arrow/GeoParquet geometry is mmap/reinterpret-borrowed (znippy/skade bridge), not deserialized. A render stage that allocates in the hot path is a bug. - GATLING multicore (znippy
znippy-zoomiesgatling gun). Every parallel produce→ consume render/data stage uses the no-barrier worker pool: 1 reader → split (cheap, or candidate offsets + workers self-seek) → channel of slot indices (zero-copy) → N workers carry a unit through ALL stages, no barrier, self-dispatch → 1 writer (saves only). Apply to: projection of millions of points, area tessellation, tile/LOD build, GeoParquet decode, and graph layout for huge graphs. Verify cores-busy as data (1-32-32-32-1 saturation), and ship the skade-style SVG of the 1→N→1 flow per the SVG law. Keep eyes open for new gatling use-cases as the pipeline grows. - MILLIONS of sub-part tests + benches. The part-step test/bench matrix scales by parameter sweep — each sub-step (near_clip/project/cull/shade/scissor/… ; layout/edges/ picking/…) is hit with many injected cases: size sweeps (1k→10M), camera-pose sweeps, degenerate/edge geometry, every OS feel. Not one case per step — a generated grid. Green matrix ⟺ every sub-part proven across the sweep. Benches are the same grid, timed.
- LOOK & FEEL is per-OS — facett's whole point. FEEL = navigation/input: pan/zoom/ orbit gestures, scroll momentum + damping, dolly-to-cursor, keymap — differ for macOS vs Windows vs device (extend
InputFeel/with_os_feel, already in map3d). LOOK = chrome: navigation menus, scrollbars, focus rings, spacing — per-OS presets viafacett_core::look::Theme(windows/macos/device L+D already exist). Every component routes nav + chrome through these — never hardcoded. CLI/headless parity: the OS feel is selectable + observable instate_json, drivable by the robot matrix (assert macOS feel ≠ Windows feel as DATA), never GUI-only.
Post-processing / SOTA extensions (agent/facett-gfx-sota)
Beyond the baseline (GGX PBR · shadows+PCF · Gerstner water · sky · HDR offscreen · bloom+ACES · FXAA · Kawase blur · neon graph3d), the shared GPU seam facett_core::render::gpu now carries three composable, opt-in post/render modules that hang off the OffscreenColorDepth HDR (Rgba16Float) target without touching the map3d ACES chain:
colorgrade— final-look grade: AgX tonemap + CAS sharpen + vignette + film grain + blue-noise/IGN dither (an alternative to the ACES blit).particles— GPU compute boids drawn additively into the HDR target.msdf— multi-channel SDF text for crisp glowing labels at any zoom.
Full technique inventory, scoring, and the prioritized roadmap (GTAO/SSR/froxel fog/TAA/SSGI… and why each is deferred) live in graphics-sota-roadmap.md.