A 2,000-part plan view with everything on screen cost 2,270 tessellation
calls a frame. It now costs four: two for the grid, two for the parts.
`stroke()` tessellates the whole accumulated path and clears it --
`tessellate_path_stroke` ends in `path.clear()` -- so queueing many
subpaths and stroking once is one tessellation instead of N. The idiom
was already in this file: `queue_dashed_line` has done it for the axis
grid since Phase 3.9, guarded by a test. Phase 4 applies it to the two
loops that never adopted it.
**Base grid: two passes, two strokes.** Minors queued and stroked at
0.55, majors at 1.6 -- the stroke width is the one thing that genuinely
needs its own call. `GridRange::has_minor_lines`/`has_major_lines`
decide whether a pass runs at all and `frame_budget` counts strokes with
the same two predicates, because an empty `stroke()` still enters the
tessellator and a budget that assumed two when the renderer made one
would be wrong in the direction that hides work. Minors stroke first so
majors land on top where they cross; same colour either way, so the only
visible difference is that the thicker line wins a crossing, which is
the right answer.
**Parts grouped by colour.** New `batching::ColorKey` -- the bit pattern,
because `f32` is not `Hash` and two colours whose bits differ are two
colours -- feeding the same `group_in_first_appearance_order` that
Phase 3 groups shapes with. The colour policy moved out of the two draw
loops into `constants::part_outline_color`, so the renderer and
`frame_budget` cannot disagree about how many groups a frame has; the 2D
loop had `vec4(1.0, 0.82, 0.40, 1.0)` written out where
`PART_SELECT_COLOR` already existed.
**Selected and hovered parts stroke last**, in their own groups, so a
highlight is never hidden under a neighbour's outline. They were
interleaved in document order before and could be.
`FrameBudget` gained `grid_lines` and `part_outlines` beside the call
counts. Geometry volume and call count are different numbers now and
both are worth reading -- `VectorSubmission { outlines, stroke_calls }`
mirrors Phase 3's `MeshSubmission` for the same reason.
Measured (bench_frame_submission_budget, 1920x1080, 200 m site):
zoom 5 m, 2000 parts: 12 visible outlines -> 4 tessellations (was 2170)
zoom 200 m, 2000 parts: 2000 visible outlines -> 4 tessellations (was 2270)
The second row is the point, and it is the row Phase 1 could not move:
everything is on screen, culling removes nothing, and the frame still
costs four calls.
WHAT THIS DOES NOT DO: vertex volume is unchanged. The same 2,000
rectangles are tessellated -- in two calls rather than 2,000. What is
saved is per-call overhead: tessellator setup, two `std::mem::take`s and
an `append_geometry` each time. If a 2,000-part plan view is still slow
after this, the remaining cost is triangles, which is Phase 5 and should
only happen if a measurement asks for it.
One visible-behaviour caveat, stated rather than buried: parts of the
same colour are now drawn together, so where two outlines of *different*
colours overlap, which is on top can change. They are 1.8 px outlines
and the highlight ordering got strictly better, but it is a change to
what is drawn, not only to how.
Two tests were wrong before the code was, which is becoming this plan's
pattern. `constants.rs` fell to 81.82% and the coverage floor caught it
-- `part_outline_color` had no tests, and it now has five. And the guard
test's first draft looked for a closing brace at a fixed indentation,
matched the wrong one, and failed on correct code; it matches braces
properly now.
Verified: tools/test-cad-coverage.sh green -- total 97.33%, batching.rs
100%, cull.rs 100%, render_budget.rs 99.68%, constants.rs 98.55%, all
floors met; cargo check --locked -p nigig-build --lib clean; cargo test
--lib 1114 passed (1100 + 14 new); --test cad_integration 154 passed;
CAD_BENCH=1 harness green; cargo fmt --check and git diff --check clean.
22 KiB
CAD render optimisation: a phased plan
Companion to REVIEWS/CAD_DRAWCALL_STRATEGY_ANALYSIS.md, which contains
the evidence and one significant correction. Read its section 0 first:
the 2D vector scene is one draw call, not hundreds, and the plan
below is ordered on the corrected facts rather than the original ones.
Revision 2 merges a second, independently written optimisation plan (frustum culling → geometry merging → GPU instancing → octree/LOD) into this one. Section "Merging a second review" below records what that plan got right, what it got wrong, and — the part that matters most — the place where it and this document were both wrong about the same thing.
The three findings this plan acts on
- No viewport culling anywhere.
grep -niE "cull|frustum|offscreen|in_view"over the 2,461-line renderer returns nothing. Every part is submitted every frame, on screen or not — in 2D that is wasted tessellation, in 3D a wasted draw call. - 3D issues one draw call per part, each with its own geometry
buffer, transform and colour.
part_geomsis keyed by part id, so 200 identical columns are 200 uploads and 200 draws. - The base grid and the parts loop tessellate per item, where the
file's own
queue_dashed_lineidiom — queue segments, stroke once, guarded by a test — shows how not to.
Merging a second review
Every claim below was checked against the tree at 14aa0d5 before it
was accepted or rejected. Line numbers in the incoming plan were a few
off (draw_scene is at viewport_render.rs:325, its parts loop at 345;
DrawCadMesh is at mod.rs:152), which is drift, not error.
| Incoming proposal | Verdict | Evidence |
|---|---|---|
| Frustum-plane culling in 3D | Adopted | SceneState3D { view, projection } is captured every frame into self.last_view / self.last_proj (viewport.rs:3781-3782) immediately before draw_scene. Planes come out of projection * view by adding and subtracting rows — no matrix inverse, which matters here (see below). |
Store bounding_sphere on CadNode |
Rejected as stored, adopted as derived | See "The field that must not exist". |
Cull only in draw_scene |
Widened | 2D plan view is where drafting happens and where per-part cost is tessellation. Both loops get the predicate. |
| "Log skipped-part count per frame" | Replaced | Phase 0 already built the counting seam (render_budget::FrameBudget, bench_frame_submission_budget). A log line nobody reads is a step that cannot fail. |
| "Expect 30-60% of parts skipped" | Not adopted as a target | Unfalsifiable as written — it depends entirely on zoom. The exit criterion is a number in BENCH_BASELINE.md at a stated zoom, not a guess. |
Merge geometry per PartKind with transforms baked into vertices |
Rejected | See "Why merging bakes in a regression". |
| GPU instancing, one call per group | Adopted, and cheaper than the incoming plan thought | The machinery is already upstream. See "Instancing is not a shader rewrite". |
| Octree + LOD above 500 parts | Still rejected at that threshold | The cached world-AABB pass costs 20 µs at 500 parts (bench_pick_broadphase_world_aabb_recompute_vs_cache) — 0.12% of a 16.7 ms frame. A tree that replaces 20 µs cannot pay for itself. Revisit with a measurement, not a part count. |
| Dependency order 1 → 2 → 3, 4 optional | Agreed | Instancing batches per geometry, so shared geometry genuinely gates it. |
The correction that applies to both plans
ParamHash::from_node hashes node.id first (cad_scene.rs:1871),
before it touches a single geometric parameter.
This document previously said, in Phase 2:
Parts with equal
ParamHashare geometrically identical by definition — that is what the hash means.
True, and useless: equal ParamHash also means the same node. Two
identical columns at different coordinates have different hashes, so
re-keying part_geoms by ParamHash would share nothing at all. The
incoming plan inherited the same assumption from the same type name.
The id is harmless in the hash's current uses — MeshCache and
part_geoms are keyed by NodeId and only ask "is this entry still
valid for this node", where the id is a constant — so this is not a bug
to fix, it is a second hash to add. ShapeHash: the same body with the
id omitted. Pinned by
param_hash_is_not_a_shape_key_it_includes_the_node_id in
cad_scene.rs, added with this revision so the claim cannot be made a
third time by reading the type name.
The field that must not exist
The incoming plan opens with "add bounding_sphere: (DVec3, f64) to
CadNode, computed on add_part / update". Three reasons not to:
- There are 77
CadNode { ... }construction sites across the crate and its tests. Every one becomes a place to get the sphere wrong, and the compiler only catches the ones that forget the field, not the ones that fill it in stale. - This codebase has already paid for exactly this mistake. The
part_geomsstaleness bug (viewport.rs:4629) was a map that had to be maintained by hand at every edit site; one site was missed and the viewport drew the pre-edit shape. The fix was to make the entry carry a hash that invalidates itself.ParamHashandPlacedHashexist because of that lesson. - The derived version already exists and is already cached.
SceneCache::world_aabb_foris keyed byPlacedHash— so it is invalidated by a move and by a resize — and is benchmarked at 4.72× faster than recomputing (94 µs → 20 µs at 500 parts). A sphere is two lines from an AABB:centre = (min + max) / 2,radius = |max - min| / 2.
So: bounding_sphere(aabb) -> (DVec3, f64), pure, derived, in the cull
module. No new field, no new invalidation surface.
Why merging bakes in a regression
Incoming Phase 2 proposes replacing part_geoms with one merged buffer
per PartKind, "with per-part transform baked into vertex positions".
MeshCache stores local-space meshes on purpose. Its own docstring
is explicit: "The transform and the material are NOT hashed. The cached
mesh is local-space, so neither can change it." Baking transforms into
vertices inverts that invariant, and the cost lands on the interaction
users perform most:
- Today, dragging a part re-uploads that part's buffer, or nothing
at all if only the transform changed — the transform never enters a
vertex buffer, it is a per-draw shader value the vertex stage applies
(
mod.rs:377), andParamHashdeliberately excludes it for that reason. - With transforms baked in, dragging one column invalidates the merged buffer for every column in the model, and re-concatenates and re-uploads all of it, on every frame of the drag.
The incoming plan's own draw loop shows the tension: it merges the buffers but still writes "offset handled via transform uniform set before each draw" — which is still one draw call per part, so the merge buys nothing it set out to buy. The two halves of that phase contradict each other.
Per-part colour is the second casualty. Selection and hover recolour a
single part (viewport_render.rs:358-370). One merged buffer with one
colour cannot express that without per-vertex colour and a full re-upload
on every hover.
Keep local-space geometry; share it between parts of the same shape; vary transform and colour per instance. That is Phase 2 + Phase 3 below, and it is what the hardware wants anyway.
Instancing is not a shader rewrite
The incoming plan rates instancing "high effort, high risk, ~7 days,
requires shader work" and hedges with "use cx.add_instances(...) if
supported". It is supported, and the shader work is already done.
In the pinned Makepad fork (ecf5a572):
Cx::begin_many_instances/end_many_instances(draw/src/draw_list_2d.rs:315,337) accumulate instance rows into a single draw item.DrawPbralready wraps that for exactly this case:begin_many_instances_for_mesh(cx, mesh),push_many_instance_with_transform(transform),end_many_instances(cx)(draw/src/shader/draw_pbr.rs:2696-2745) — one geometry, N transforms, one draw call. About thirty lines to mirror ontoDrawCadMesh.- Per-instance data is simply the
#[live]fields declared after#[deref] draw_varsin a#[repr(C)]shader struct (DrawVars::as_slicedocuments that layout).DrawCadMeshalready has that shape:color,transform,depth_clip,display_mode(mod.rs:152-171), and the vertex shader already readsself.transform(mod.rs:377).
One risk the incoming plan did not raise and one it did not need to:
- Batching reorders drawing. Safe here:
DrawCadMeshis declaredalpha_blend: false(mod.rs:346), so the mesh path is opaque and depth-tested. Had it been blended, regrouping would have changed the picture. - A batch is per geometry. So Phase 2 really does gate Phase 3.
The phases
Phase 0 — Make it measurable — done (14aa0d5)
render_budget.rs (100% covered, floored in tools/test-cad-coverage.sh)
owns the grid-loop decision and counts it, so the count and the drawing
cannot disagree. grid_range() drives draw_2d_vector_scene's loops.
bench_frame_submission_budget records tessellation and draw-call counts
at 100/500/2,000 parts and two zoom levels in BENCH_BASELINE.md.
Baseline to beat, 1920×1080, 2,000 parts: 2,170 tessellations, 2,001 draw calls zoomed in; 2,270 / 2,001 zoomed out.
Phase 1 — Cull against the viewport — done (this commit)
New module cull.rs (100% covered, floored in tools/test-cad-coverage.sh),
in the style of nav_pad.rs and render_budget.rs — pure, host-testable,
and called by both draw loops and CadViewport::frame_budget, so the
reported budget and the drawing cannot disagree:
draw_part_2d(centre, half_w, half_h, view, decorated) -> bool
Frustum::from_view_projection(view, projection) -> Frustum
Frustum::draw_part_3d(aabb, decorated) -> bool
world_aabb_from_local_bounds(min, max, model) -> WorldAabb
Three things came out different from the plan above, and the plan was wrong about each:
- AABB, not sphere. A bounding sphere around an AABB is looser at
the same cost — a 6 m wall gets a 3 m radius ball. The p-vertex
AABB-versus-plane test is strictly tighter, so there is no
bounding_spherehelper at all, and nothing to go stale. world_aabb_from_local_boundsis shared withpick_part, whose inline eight-corner transform it replaces. Two copies would be two chances to disagree about a part's bounds, and picking a part the renderer culled is exactly what that disagreement produces.- The "drawn extent" hazard resolved into two concrete rules, not
one margin: the 2D test uses
part_to_plane_2dandpart_size_on_plane— the very values the draw uses, so it tests the drawn rect — and any selected or hovered part is never culled, because a selection highlight and a tooltip drawn at the cursor extend arbitrarily far from the part.
Both tests are conservative by construction: NaN geometry, negative extents and an identity camera matrix all fall through to "draw it". A part kept but invisible costs one submission; a part culled but visible is a bug the user sees.
Measured (bench_frame_submission_budget, 1920×1080, parts on a
200 m site — full tables in BENCH_BASELINE.md): drafting at a 5 m zoom
a 2,000-part scene submits 12 parts instead of 2,000 (2,170 → 182
tessellations, 2,001 → 13 draw calls). In 3D at a 20 m working distance
63% of a 2,000-part model is off camera. The honest half: at a zoom
or camera distance that fits the whole site on screen, culling removes
nothing — all 2,000 parts are genuinely visible, and that case is
what Phases 2 and 3 are for.
Not done here: the two hard-coded 1.2 margins in
viewport_render.rs (397-400 and 1374-1377) still do not read
render_budget::VIEW_MARGIN. cull.rs does, so the cull and the grid
agree today by construction of the constant, not by construction of the
code. Worth folding into Phase 4, which touches those loops anyway.
Phase 2 — Share geometry between parts of the same shape — done (this commit)
The prerequisite for instancing, and on its own it collapses GPU memory for the models this app is for: architectural drawings are repeated columns, windows and doors.
ShapeHashincad_scene.rs: the solid payload and nothing else — no node id, no transform, no material.ParamHashis now defined in terms of it (id+ShapeHash), so the two cannot drift apart the way the plan's premise and the code did.part_geomsis keyed byShapeHash, valueGeometry. The(hash, Geometry)pair and the "is this entry still valid" filter in the draw loop are both gone: the key is the content hash, so an edited part looks up a key that does not exist and gets a fresh upload. Staleness became structural rather than checked.- Eviction is by live shape, not live id. This is the one hazard
the change introduces and it is not obvious: deleting one of two
hundred identical columns must not drop the buffer the other 199
are drawing from.
geometry_is_retained_by_live_shape_not_by_live_idpins it. - Geometry stays local-space; transform and colour stay per-draw values, exactly as before. Nothing is baked into a vertex buffer.
Measured (bench_geometry_buffers_shared_by_shape): 200 identical
walls → 1 buffer. A 420-part repetitive model (columns, three wall
lengths, two opening types) → 6 buffers, 70×. 420 all-distinct parts
→ 420, 1× — sharing is a property of the model, and that row is in
the table so the ceiling is visible.
Draw calls are unchanged, as predicted: still one per visible part. That is Phase 3's job, and it is now unblocked.
Effort: under a day, against the week estimated. Two things made it
cheap that the estimate did not know: MeshCache::get_or_build is
already a pure function of node.solid, and the upload path
(part_mesh_buffers_from_mesh → MeshSpace::Model) already produced
transform-free, colour-free buffers. The estimate assumed those would
need untangling; they were built right.
Phase 3 — Instance the 3D draw loop — done (this commit)
- Three methods on
DrawCadMesh—begin_instances,push_instance,end_instances— ported fromDrawPbr::begin_many_instances_for_mesh/push_many_instance_with_transform/end_many_instancesin the pinned fork. No shader change, as predicted:transformandcolorare#[live]fields after#[deref] draw_vars, which is exactly the per-instance rowDrawVars::as_slicesends. The new#[rust] many_instancesfield sits beforedraw_varsfor the same layout reason. draw_scenecollects the visible parts as(ShapeHash, (transform, colour)), groups them withbatching::group_in_first_appearance_order, and issues one call per group.frame_budgetreportsMeshSubmission { instances, batches }— named fields, because the whole point of the phase is that the two now differ and a caller that swapped twousizes would report the win backwards.
Measured (bench_frame_submission_budget): a 2,000-part model of
six shapes draws in 6 calls instead of 2,000 — at every camera
distance, including 400 m where the whole site is on screen and culling
removes nothing. With every part a different size the count falls back
to the visible-part count, which is the honest ceiling and is in the
table.
What is not verified, plainly. The submission itself has never
run: there is no GPU, no window and no Cx in this environment, and
tests/ui.rs still fails at child-build exit 101 (Phase 0 of
REVIEWS/CAD_COVERAGE_100_PLAN.md). What is verified is that it
compiles against the real Makepad API, that the grouping is right
(batching.rs, 100%, seven tests including "no item is lost or
duplicated"), that the budget arithmetic is right, and that the batch
cannot be left open on any path
(every_instanced_batch_is_closed_before_the_loop_turns, a source check
in the house style). Someone with a window needs to open a 3D model
and confirm the picture is unchanged. Two things reduce the blast
radius if it is not: begin_instances returning false falls back to
the old one-call-per-part loop, and the batch order is deterministic, so
a defect will reproduce rather than flicker.
Effort: a day. Risk: the highest of the four phases, for the reason above — not because the change is large.
Phase 4 — Reduce tessellation calls in 2D — done (this commit)
The honest small one, and it turned out to be the one that fixed the case Phase 1 could not: a 2,000-part model with everything on screen.
stroke() tessellates the whole accumulated path and then clears it
(tessellate_path_stroke ends in path.clear()), so queueing many
subpaths and stroking once costs one tessellation instead of N. That
idiom was already in the file, in queue_dashed_line for the axis grid.
Phase 4 applies it to the two loops that had not adopted it.
- Base grid: two passes, two strokes. Minor lines queued and
stroked at 0.55, majors at 1.6 — the width is what genuinely needs a
separate call.
GridRange::has_minor_lines/has_major_linesdecide whether a pass runs, andframe_budgetcounts strokes with the same two predicates, so an empty group is not charged for. Minors are stroked first so majors land on top where they cross. - Parts grouped by colour.
batching::ColorKey(bit-pattern key, exact round trip back to the colour) plus the samegroup_in_first_appearance_orderPhase 3 groups shapes with. The colour policy moved toconstants::part_outline_colorso the renderer andframe_budgetcannot disagree about how many groups a frame has. - Selected and hovered parts stroke last, in their own groups, so a highlight is never hidden under a neighbour's outline. Before, they were interleaved in document order and could be.
FrameBudgetgainedgrid_linesandpart_outlinesalongside the call counts: geometry volume and call count are now different numbers and both are worth reading.
Measured (bench_frame_submission_budget, 1920×1080, 200 m site):
| Zoom | Parts | Visible outlines | Tessellations | Before |
|---|---|---|---|---|
| 5 m | 2,000 | 12 | 4 | 2,170 |
| 200 m | 2,000 | 2,000 | 4 | 2,270 |
The second row is the point. Everything is on screen, culling removes nothing, and the frame still costs four tessellation calls.
What this does not do: vertex volume is unchanged. The same 2,000
rectangles are tessellated; they are tessellated in two calls instead of
2,000. What is saved is per-call overhead — tessellator setup, two
std::mem::takes and an append_geometry each time. If a 2,000-part
plan view is still slow, the remaining cost is triangles, and that is
Phase 5.
Guard: the_base_grid_and_the_parts_loop_stroke_per_group in
viewport.rs matches braces to check that neither stroke() sits
inside an item loop. Its first draft looked for a closing brace at a
fixed indent, matched the wrong one, and failed on correct code — the
test was wrong before the code was, for the second time in this plan.
Effort: a day. Risk: low, with one visible-behaviour caveat worth stating: parts of the same colour are now drawn together, so where two outlines of different colours overlap, which one is on top can change. Outlines are 1.8 px and the highlight ordering got strictly better, but it is a change to what is drawn, not only to how.
Phase 5 — Level of detail
A datagrid cell is never sub-pixel; a zoomed-out CAD part often is. Below a few pixels a filled box is indistinguishable from the mesh.
Gate on the numbers from Phases 1–4. If a 2,000-part scene at full zoom-out is comfortable by then, skip it: LOD adds a visual-fidelity axis to every future change and should not be paid for speculatively.
Effort: 1 week, if the numbers justify it.
Effort and risk, merged
| Phase | Effort | Risk | Benefit |
|---|---|---|---|
| 0 Measurement | done | — | Every claim below is now falsifiable |
| 1 Culling (2D rect + 3D frustum) | done | Low | Submissions scale with visible parts |
| 2 Shape-shared geometry | done | Medium | 200 walls → 1 buffer; 70× on a mixed model |
| 3 Instancing | done | Medium | 2,000 parts, 6 shapes → 6 draw calls |
| 4 2D tessellation batching | done | Low | 2,000 parts on screen: 2,270 → 4 tessellations |
| 5 LOD | ~1 w | Low | Only if measured |
What this plan deliberately does not do
- No BVH or octree. At 500–5,000 parts a linear pass over cached AABBs is 20–200 µs. A tree earns its complexity somewhere past ~50k parts and nothing suggests that is the target. If a benchmark ever shows the linear pass in the frame budget, revisit — with the number.
- No
bounding_spherefield onCadNode. Derived from the cached AABB instead. See above. - No merged vertex buffers with baked transforms. They convert an O(1) move into an O(parts-of-that-kind) re-upload per drag frame, and they cannot express per-part selection colour. See above.
- No render-path rewrite. Each phase is a local change behind a
tested predicate or a key change.
viewport_render.rsis 2,083 lines at 0% coverage; a rewrite there without the Phase 0 seam would be unverifiable.
Order and why
Phase 0 first because everything after it is otherwise unfalsifiable, and because it doubles as the first test coverage the render path has ever had. Phase 1 next: cheapest, largest, and it shrinks the input to every later phase. Phase 2 exists to make Phase 3 possible and is the invasive one, so it goes after the cheap wins are banked. Phase 3 is the real draw-call win and is now a port rather than a design. Phase 4 is small and honest about being small. Phase 5 only if measured.