Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

.nornir/render-engine-reasoning.md

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" and workspace_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

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

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:

  1. 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
  1. Emitters are FALLBACK, not default (EMIT-DOCTRINE): assert on the return value / state_json() first; implant the in-function functional_status emitter 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.
  2. Compiled during dev / test-matrix runs only. The emitters sit behind the testmatrix cargo feature — release builds strip them / no-op (zero runtime cost). Same gate the rest of the constellation uses; rows append to NORNIR_TESTMATRIX_OUT (atomic O_APPEND, cross-process).
  3. 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_status detail carries a blob ref. So an agent can READ the actual rendered image without asking the human (Read the emitted PNG), turning "looks wrong" into a pixel the agent inspects itself. Pairs with the wgpu golden-PNG snapshot tests (egui_kittest).
  4. 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_clipped low at a street-level dolled-in pose), recurse the wrong half. Every fix earns a fail-on-bug / pass-on-fix matrix row.
  5. Drive the LIVE surface. nornir-robotui (egui_kittest AccessKit → click/scroll/ orbit) drives the real viewer; each step reads back state_json() + the emit chain → one TestResultRow per 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 own Bencher entry (the facett.map3d.<step> column), with injected synthetic input sized to isolate that step. The end-to-end bench stays as the rollup.

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) in facett/.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.

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_cpu scalar, 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)

  1. Text path → parley (DECIDED). Linebender's parley for 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_cpu glyph raster on CPU row, vello glyph atlas on GPU row). Not cosmic-text — parley is the modern, smaller-stack choice that composes with the renderer we already chose.
  2. wasm / WebGL2 → target WebGPU, vello_hybrid covers the rest (DECIDED). The web GPU path targets WebGPU (modern). WebGL2-only browsers degrade through vello_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.
  3. graphs-over-maps → add the SEAM now (one line), not the feature. facett-core gets a shared Camera + a z-ordered layer stack so 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:

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:

Bench bars

Hard LAWS — speed, parallelism, scale, look&feel (Rickard 2026-06-21, "last words")

  1. ZERO-COPY / ZERO-ALLOC render pipeline (mirror znippy Law 1 & 2). Part-steps pass slot indices into pre-allocated buffers, never Vec clones 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.
  2. GATLING multicore (znippy znippy-zoomies gatling 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.
  3. 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.
  4. 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 via facett_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 in state_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:

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.