Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.