Rendering and scale
reticle-render is the wgpu renderer. It targets WebGPU in the browser and
Vulkan, Metal, or DX12 natively, with a WebGL2 fallback for reach (ADR 0009).
GPU-driven culling
The central trick for scale is to keep the hierarchy on the GPU. Rather than the CPU
walking billions of leaf shapes, a compute shader tests each cell’s bounding box
against the current view and flags the visible ones, the first stage of a GPU-driven
draw list. Compacting the survivors into an indirect draw is built rather than
pending: CellCompactor is exported from crates/reticle-render/src/lib.rs and the
GPU-resident hierarchy below runs expand, cull and compact in one pass every frame.
Check: cargo nextest run -p reticle-render -E 'binary(compact_gpu)', which acquires
a real GPU adapter and is skipped on a machine without one. The work is
proportional to the number of cells considered, not the flattened shape count. The
interactive egui canvas currently culls on the CPU with the same R-tree, and the GPU
compute cull is validated against that CPU result in a golden test.
GPU-resident hierarchy
The cull stage above flags visible cells; GpuHierarchy closes the loop by keeping the
whole arrayed hierarchy resident on the GPU and never touching a per-element draw list on
the CPU. A compact table of array placements (one record per array reference, not per
element) plus a table of leaf cells is uploaded once. Every frame a single compute pass
(expand_cull_compact.wgsl) does three things at once: it expands each array
element-by-element (one thread per element, binary-searching the placement table by a
precomputed cumulative element offset), culls each element’s transformed bounding box
against the viewport with the same half-open rule as the CPU, and compacts the
survivors into ready-to-draw RectInstanceT buffers with a per-workgroup prefix scan and
a single atomic range reservation, filling an indirect instance count. One
draw_indirect per chunk then draws exactly the survivors; the GPU, not the CPU, decides
how many.
A single compute dispatch is bounded two ways: at most max_compute_workgroups_per_dimension
(65,535) workgroups of 256 threads, and a storage binding no larger than
max_storage_buffer_binding_size (128 MiB, so about 2.79M 48-byte survivors on a
default-limits device). GpuHierarchy escapes both by splitting the global element space
into fixed-size chunks and issuing one dispatch and one draw per chunk; the cap is
beaten by chunk count, never by a bigger dispatch, so the design scales to arbitrarily
many elements at a fixed per-chunk cost. A 100M-element array (a via, fill, or bit-cell
field, routine in real layout) spans 36 chunks; a 30M array spans 11.
Because the per-frame path iterates only the chunk list (a handful of entries) and the
scene tables are uploaded once, the CPU does no per-element work per frame; the
cpu_expand_ops counter, bumped only by the CPU reference expansion, stays flat across
frames, which a test asserts. Measured throughput and the honest 100M shortfall are in
the performance chapter and docs/PERF.md: expansion and culling run
at 3.4-3.7 billion elements per second, a 100M design pans interactively at 111 fps when
culling keeps the on-screen subset, and drawing all 100M sub-pixel quads at once stays
fill-bound at 10 fps (an LOD follow-up, for which this GPU-resident expansion is the
prerequisite).
Instanced draws and tessellation
Axis-aligned rectangles are drawn as instanced quads; polygons and paths are
tessellated once into vertex and index buffers (lyon) and drawn with per-layer style.
Colors come from the technology layer table with a fallback palette.
The index carries a tile and level-of-detail pyramid (LodPyramid,
crates/reticle-index/src/lod.rs), and it is a coarser tiling, not a coarser
representation of the geometry. Its own module doc states the rule: a shape is
recorded in every tile its bounding box overlaps at every level, so a coarse tile
still names every shape inside it. That bounds how much a streamed archive has to
fetch for a given viewport, which is the 188 KiB first-view figure; it does not
reduce how much a zoomed-out frame has to draw. Reducing that is level-of-detail
rendering, and the app-side switch ships: at or below 0.02 px/DBU the canvas paints
a density impression plus cell bounding boxes instead of shapes
(culling::lod_for_zoom and chunk_lod, crates/reticle-app/src/culling/lod.rs),
dispatched on all three paint paths (app/canvas_ui.rs, app/render.rs,
app/render_archive.rs) and pinned by tests in app/tests_view.rs. What is still
missing is a coarser representation INSIDE the reticle-render pipeline, which is why
the worst case below stands. See
Targets for what that costs, measured.
Check: git grep -n "lod_for_zoom" -- crates/reticle-app/src.
Offscreen and live rendering
Two paths share the same reticle-render pipeline. An offscreen Rgba8Unorm target
with CPU readback (OffscreenTarget, crates/reticle-render/src/target.rs) drives
the golden-image tests and the media capture (the hero image and browse GIF); check
grep -n "pub fn render_document_offscreen" crates/reticle-render/src/lib.rs. The
interactive canvas renders separately, straight onto eframe’s live egui-wgpu
surface every frame through a paint callback (App::draw_shapes_gpu,
crates/reticle-app/src/app/retained.rs, invoked from
crates/reticle-app/src/app/canvas_ui.rs), composited under the egui overlays
queued the same frame. Window and surface presentation is not a follow-up: it has
shipped since Wave A (docs/STATUS.md).
The overlays this chapter used to list as follow-ups all ship too: the minimap
(App::draw_minimap, crates/reticle-app/src/app/render.rs), DRC violation markers
(App::draw_drc_markers, crates/reticle-app/src/app/render_overlays.rs),
connected-net highlighting (App::highlight_net_of,
crates/reticle-app/src/app/verify.rs, wired from a canvas click in
crates/reticle-app/src/app/interact.rs), and the 3D layer-stack cross-section
(show_view3d_panel, show_xsection_panel,
crates/reticle-app/src/app/dialogs_ui.rs). Check:
grep -n "fn draw_minimap(\|fn draw_drc_markers\|fn highlight_net_of\|fn show_xsection_panel" crates/reticle-app/src/app/render.rs crates/reticle-app/src/app/render_overlays.rs crates/reticle-app/src/app/verify.rs crates/reticle-app/src/app/dialogs_ui.rs
(four matches, one per symbol).
Targets, and where each one stands
One million flat shapes at a sustained 60 fps at typical zoom: met, measured at
295 fps at 1920x1080 on the recorded host (RTX 4060 Ti, Vulkan). Ten million
interactive at 30 fps or better: met, measured at 113 fps on the same host. Both
figures come from cargo run -p reticle-render --example fps_bench --release.
Hierarchical designs with effectively billions of leaf shapes: architecturally
supported, not fps-benchmarked. That is the wording docs/PERF.md’s targets table
already carries for this row, and it is the accurate one. Hierarchy is never
flattened for browsing, and cell culling plus the compute-shader cull stage are
implemented and tested; no frame-rate figure for a billion-leaf design has been
measured, so none is claimed here.
What has been measured is a 100M-element arrayed design, on the same host, with
cargo run -p reticle-render --example gpu_hierarchy_bench --release: 111 fps
panning, where culling keeps the on-screen subset (10,201 of 100M drawn), and
10.0 fps drawing all 100M sub-pixel quads at once. The second number is the
honest worst case and it is not interactive. It is fill and vertex bound rather
than expansion bound: the expand-and-cull pass alone runs at 37 fps on the same
scene. The full table and its measurement context are in docs/PERF.md.
A render-crate level of detail is the fix for that worst case, and only the app-side
switch is built. The app decides what to ask for below 0.02 px/DBU
(culling::lod_for_zoom); reticle-render has no reduced-geometry level of its own, so
a frame that does ask for all the quads still draws all the quads. A coarser
representation of the whole design when the whole design is in view is the missing
piece, and the GPU-resident expansion described above is its prerequisite rather
than a substitute for it. It is wave-6 work in the current plan; no date is
promised, and this chapter gains a number for it only when the wiring lands and the
number is measured. Every other measured number is in the
performance chapter.