Skip to content

Rendering and Performance

CapsaicinBunny edited this page Jun 21, 2026 · 2 revisions

Rendering & Performance

How PolyGraph draws thousands of nodes smoothly, the scaling work behind it, and the telemetry that measures it.

The Vello renderer (vello-renderer/)

A Rust crate compiled to WASM that draws the graph as GPU vectors via Vello (0.9) over WebGPU. It's loaded client-side via dynamic import() (components/VelloGraphCanvas.tsx) because WebGPU is browser-only; the compiled output lives in vello-renderer/pkg/ and is committed.

It exposes one handle, VelloCanvas:

Method Purpose
create(canvas) Async init against an HTMLCanvasElement
set_data(json) Feed a new scene ({ nodes, edges, clusters })
set_camera(x, y, scale) / fit() Pan/zoom; fit() returns the framing transform
set_selection(id?) / set_search(query) Highlight state (selection, search dimming)
set_phase(p) Drives the marching-ants edge animation
set_theme(dark) Light/dark
render() Draw a frame
pick(px, py) Hit-test → node / cluster / edge id
stats() Per-frame render telemetry (JSON)

It draws: cards (light fill, color-coded left edge, tinted icon chip, language badge on file nodes), curved animated edges colored by relationship — thicker with a ×N label for aggregated relationships — and GPU-rendered text labels. Pan/zoom/selection/search and edge picking all update a single GPU scene.

Level-of-detail caps

To bound per-frame work the renderer applies several caps (vello-renderer/src/lib.rs):

Cap Value Effect
EDGE_DASH_BUDGET 300 Above this many on-screen edges, stop dashing (solid strokes) — dash tessellation is the expensive part
EDGE_RENDER_CAP 60 000 Max edges encoded per frame; above it, a uniform subset is sampled
EDGE_RENDER_CAP_LOD 8 000 Tighter edge cap when zoomed out (below the label scale)
DOT_LOD_PX 7 px Below this on-screen card height, draw a dot instead of a full card

These make frame cost track what's on screen, not the size of the graph.

Scaling to 100k+ nodes — "Nanite for graphs"

The tracking design is docs/SCALE-100K.md. The target is rendering a repo like linux/drivers (~100k nodes). Three independent walls made that fail:

  1. Layout hangs firstsmart → dagre on one giant component is O(V·E), with no cap or timeout.
  2. The JSON bridge would OOM — serializing ~100k nodes + ~200k edges on the main thread each scene change.
  3. The Rust edge encoder overruns — every visible curve re-encoded per frame.

The fix borrows UE5 Nanite's idea: store the whole hierarchy once; each frame pick a per-region cut where on-screen error is ~1px. A codebase is a hierarchy (symbol → file → directory → package → workspace), so the cut opens a cluster to its children only when its on-screen box is big enough to be legible. Payoff: laid-out/rendered element count tracks screen real estate, not repo size.

Status (phased):

  • v0 — make it render now (done): renderer edge cap + font hoist; adaptive auto-collapse (seed the collapsed set with top-level dirs); layout size cap + worker timeout. These addressed all three walls enough to render.
  • v1 — adaptive LOD (mostly done): the cut is computed in pure TypeScript (lib/graph/lod-cut.ts computeCut), reading each directory's box straight from the live scene and changing only which directories are collapsed as the camera zooms — the Vello renderer is unchanged. It's wired behind an adaptiveLod flag (now default on); the remaining step is calibrating the thresholds (LOD_OPEN_PX, LOD_MAX_CARDS, band step) on a real WebGPU run.
  • v2 — lazy hierarchical layout in Rust (not started): lay out each cluster's interior on demand and move force/grid layout into the WASM engine with rayon.

The layout half of this story — budget guards, the PivotMDS stress engine — is in Layout Engine Internals.

Telemetry (lib/telemetry/)

Deep, structured instrumentation — and the reason v1's calibration is even possible. Telemetry's stated focus is exactly LOD (the adaptive cut) and rendering, plus the analysis engine.

  • events.ts — a bounded ring buffer of { t, category, level, event, data? }. Categories: analysis | layout | scene | lod | render | interaction | app.
  • metrics.ts — named rolling histograms (count, mean, p50/p95/p99, min/max).
  • telemetry.ts — the singleton bus: event(...), metric(...), count(...), time(...), toNDJSON(). Enabled by default, persisted to localStorage["polygraph.telemetry"]; when disabled, every hook is a zero-cost no-op.
  • global-errors.ts + the React ErrorBoundary — capture uncaught errors / promise rejections / render crashes into the same log, so failures are visible rather than silent.

What's recorded: per-frame fps/ms and render-scene stats (nodes drawn/culled, edges encoded) from the Rust stats(); LOD recompute decisions; analysis timings (scanMs/analyzeMs/round-trip); and layout runs + cache hits.

Surfacing: there's no live HUD. The Settings pane has a Local logs on/off toggle and a Download log button (NDJSON). Console mirroring is gated by the toggle.

Benchmarks

Performance and correctness are guarded by the bench/ suite — golden-graph snapshots, layout-stability (±2px), and a synthetic 100k-scale path. See Development & Building.

Clone this wiki locally