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

nordisk/facett

.nornir/osm-rendering-techniques.md

OSM rendering techniques — facett 2D + 3D (wgpu + CPU fallback)

How facett draws OSM maps fast, navigably, and from any angle — the algorithms, spatial indices, and cutting-edge tricks actually used. Companion to the build spec osm-viewer-spec.md. Grounded in the real impls: facett-geomap/src/osm2d.rs (2D), facett-map/src/gpu (wgpu accel), facett-map3d/src/{lib.rs,camera.rs} (3D).

Device-adaptive design (the big idea)

Two render paths behind one viewer, picked by device class:

device 2D 3D
GPU (wgpu) upload-once VRAM geometry + compute-shader frustum cull + draw_indirect (facett-map/src/gpu) — pan/zoom is a uniform write, zero per-frame vertex upload instanced prism meshes + GPU depth buffer (reuses facett-graph3d logo3d::gpu)
CPU-only / low-power (no GPU) egui painter (CPU tessellator → backend-agnostic raster, works on llvmpipe/software); all culling/LOD on CPU via the spatial index CPU perspective projection + a software z-buffer (true 1/z per-pixel depth test) + ACES tonemap → egui painter

The CPU path is universal (the default viewer renders through the egui painter, so it runs with no GPU at all); the wgpu compute path is the fast-lane for massive datasets when a GPU is present. The spatial index, LOD, projection, and clipping below run identically on both paths — only the final draw differs (GPU draw_indirect vs CPU-tessellated painter shapes).

2D techniques

Projection. Web Mercator units; MercatorCamera (osm2d.rs) maps merc↔px with center+zoom. viewport_merc_bbox(rect) is the cull key every frame (it unprojects all four pane corners, so a tilted view's receding trapezoid is still fully culled). Optional 2.5D tilt: pitch_deg (with_tilt/Msg::SetTilt, clamped [0,60]) leans the flat ground plane back toward the horizon — a perspective foreshortening applied inside the single to_px (about the pane's horizontal centre axis: north recedes, south comes forward) and exactly inverted in to_merc, so zoom-to-cursor + hit-testing stay truthful when leaned. Because every layer projects through that one to_px, ways/points/pins/graph tilt as one and stay registered; pitch_deg == 0 is byte-identical to the top-down map. No textures/lighting — this is the 2D viewer's cheap lean, distinct from the full 3D facett-map3d city. The GPU line lane is flat, so it falls back to the CPU painter while tilted.

Overlays (shared coordinate space — one to_px, no twin): the cartographic palette (with_palette/cartographic(day)) reskins the built way set + paints a preset land background as the Garmin car-navigator basemap (see the subsection below); address pins (with_pins) are labelled markers registered to (lon,lat) with a stable-id click hit-test + per-pin AccessKit node; the graph overlay (with_graph) draws a reused facett-graphview GraphModel with each node placed at a geo (lon,lat) and projected through the SAME map to_px (dodging graphview's [0.25,4] zoom clamp) — a node co-located with a way vertex lands on it (<1px alignment guard). All report through state_json.

Garmin car-navigator basemap (cartographic mode — facett-geomap/src/osm2d.rs). cartographic(day) paints the way set as a real turn-by-turn nav map, not a flat theme reskin (#50):

Spatial indices (O(visible), not O(all)):

Level of detail (LOD):

Segment clipping (the flicker fix — cutting-edge correctness): a bbox-culled way can still have vertices far off-screen; at high zoom to_px maps them to ~1e8 px, and egui's line/mesh tessellation of a near-infinite point flickers / shoots lines off-screen. So geometry is clipped to a margin-expanded viewport before drawing — Liang–Barsky per-segment for line ways (emitting clipped sub-polylines), Sutherland–Hodgman polygon clip for filled ways (the rect is convex, so it's correct for concave OSM polygons too). Preserves slopes (no point-clamping). Implemented in osm2d.rs.

Zero per-frame allocation. A reused scratch: Vec<Pos2> + vis_pts buffer — the hot polyline/point path never heap-allocates. (wgpu path goes further: geometry is uploaded once to VRAM; a frame only writes the camera uniform.)

Navigation (state of the art map UX): wheel zoom-to-cursor (zoom_to anchors the geo point under the cursor — the slippy-map standard), drag-pan, keyboard pan/zoom via the per-OS keymap, damped zoom (1-exp ease), macOS pinch/two-finger folded through egui's zoom_delta/scroll honoring OS natural-scroll.

3D techniques

Camera (facett-map3d/src/camera.rs OrbitCamera): spherical coords (azimuth + elevation + distance) around a target; real perspective look-at (foreshortening + parallax); pole-clamped elevation (no gimbal flip). Dolly-to-cursor shifts the target along the cursor ray so the point under the cursor stays put. View presets (top/iso/oblique/front) snap azimuth+elevation. Frame-rate- independent damping 1-exp(-k·dt) toward goal pose (deterministic settle() for headless tests).

Geometry + perf:

Cutting-edge tricks, in one list

  1. Compute-shader frustum cull + draw_indirect (GPU does the culling; CPU never walks the full set on the fast path).
  2. Upload-once VRAM geometry — pan/zoom = a uniform write, not a re-upload.
  3. Spatial index everywhere (grid / LOD-tier+bbox / R-tree) → O(visible).
  4. Zoom→LOD ladder: point-stride decimation + Douglas–Peucker line simplification.
  5. Liang–Barsky + Sutherland–Hodgman clipping to kill far-coordinate tessellation artifacts (the corner-case flicker).
  6. Zero per-frame heap alloc (reused scratch buffers).
  7. Dolly/zoom-to-cursor (the point under the cursor stays fixed) in both 2D and 3D.
  8. Critically-damped, frame-rate-independent camera (1-exp(-k·dt)) — smooth + deterministic under an injected clock (so it's testable headless).
  9. Per-OS input feel (macOS pinch/natural-scroll/momentum vs Windows wheel) read from the active keymap/scroll preset — never re-invert egui's normalized deltas.
  10. One viewer, two backends: universal CPU painter path (no GPU needed) + wgpu compute/instancing fast-lane — same index/LOD/projection, different draw.
  11. One to_px for every layer → overlays (pins, geo-placed graph) register to the basemap for free; a co-located feature is <1px off (shared coordinate space).
  12. Optional 2.5D tilt as a perspective divide inside to_px (exactly inverted in to_merc) — the whole map + overlays lean as one, pitch=0 is byte-identical, no textures/lighting (the 2D viewer's cheap lean, not the 3D city).
  13. Garmin car-navigator 2D basemap — road-class hierarchy (ranked), casing-then-fill passes, one-way chevrons, a cased route ribbon, and priority road-name labels with collision-avoidance (pins reserve boxes first).
  14. Software z-buffer on the CPU 3D path (true 1/z per-pixel depth, no painter's-sort tearing) + ACES tonemap matching the wgpu look — the whole city renders correctly on llvmpipe / wasm with no GPU.

Where it lives