6 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 4cbb155cb7 |
perf(cad): stroke per group, not per item -- Phase 4 of the render plan
Some checks failed
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
nigig-build (CAD) / cad-widget-coverage (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
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.
|
|||
| dc1defd7e8 |
perf(cad): one draw call per shape, not per part -- Phase 3 of the render plan
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
nigig-build (CAD) / cad-widget-coverage (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
A 2,000-part model of six repeated shapes now draws in six calls. It
drew in 2,000 before, and in 733 after Phase 1 culling at a working
camera distance -- but at 400 m, with the whole site on screen and
nothing to cull, it still drew in 2,000. Culling decides which parts are
submitted; instancing decides how many calls carry them. They are
different axes and this is the one that does not care where the camera
is.
No shader change was needed and that was the surprise of the phase.
`DrawCadMesh`'s `transform`, `color`, `depth_clip` and `display_mode`
are `#[live]` fields after `#[deref] draw_vars` in a `#[repr(C)]` struct,
which is precisely the per-instance row `DrawVars::as_slice` packs and
sends. The loop was already sending instance rows -- one per call. Three
methods (`begin_instances`, `push_instance`, `end_instances`), ported
from `DrawPbr::begin_many_instances_for_mesh` in the pinned fork, keep
the call open across a group instead.
New `batching.rs` (pure, 100% covered, floored):
group_in_first_appearance_order(items) -> Vec<(K, Vec<T>)>
batch_count(keys) -> usize
First-appearance order rather than iterating a `HashMap`, deliberately.
`HashMap` order is randomised per process, so batch order -- and the
order parts reach the GPU -- would differ between runs and after a
rehash. For opaque depth-tested geometry that is invisible, which is
exactly what makes it a bad thing to depend on: the day someone adds a
translucent material it becomes a flicker that reproduces on one machine
in five.
`FrameBudget` now carries `MeshSubmission { instances, batches }`. Named
fields rather than a second positional `usize` because the entire point
of the phase is that the two numbers now differ, and a caller that
swapped them would report the win backwards.
Measured (`bench_frame_submission_budget`, counts):
camera 20 m, 2000 parts: 733 visible -> 6 draw calls (was 2000)
camera 80 m, 2000 parts: 1459 visible -> 6 draw calls (was 2000)
camera 400 m, 2000 parts: 2000 visible -> 6 draw calls (was 2000)
...and with every part a different size, 733/1459/2000 -- the ceiling,
which is in the table for the same reason Phase 2's is.
WHAT IS NOT VERIFIED, plainly. The instanced submission has never run.
There is no GPU, no window and no `Cx` here, and tests/ui.rs still fails
at child-build exit 101. What is verified: it compiles against the real
Makepad API; the grouping is right (seven tests, including "no item is
lost or duplicated", which is the failure mode hardest to see in a
screenshot); the budget arithmetic is right; and the batch cannot be
left open on any path -- `every_instanced_batch_is_closed_before_the_loop_turns`
is a source check in the same style as the redraw_all guard, because a
batch left open drops its rows and the parts simply vanish with no error
anywhere.
Someone with a window needs to open a 3D model and confirm the picture
is unchanged. Two things limit the damage if it is not: `begin_instances`
returning false falls back to the old one-call-per-part loop (it returns
false while the draw shader is still compiling, which happens on the
first frames of every window), and batch order is deterministic, so a
defect reproduces instead of flickering.
Verified: tools/test-cad-coverage.sh green -- batching.rs 100%, cull.rs
100%, total 97.30%, all floors met; cargo check --locked -p nigig-build
--lib clean at the 127-warning baseline; cargo test --lib 1100 passed
(1091 + 9 new); --test cad_integration 154 passed; CAD_BENCH=1 harness
green; cargo fmt --check and git diff --check clean.
|
|||
| 1e5d014198 |
perf(cad): one GPU buffer per shape, not per part -- Phase 2 of the render plan
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
nigig-build (CAD) / cad-widget-coverage (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
Two hundred identical columns held two hundred GPU vertex buffers. They build byte-identical triangles -- `CadSolid::build_mesh` is a pure function of the solid payload and returns local-space geometry, and the upload path (`part_mesh_buffers_from_mesh` -> `MeshSpace::Model`) carries no transform and no colour -- so the duplication bought nothing but memory and upload time. `part_geoms` is now `HashMap<ShapeHash, Geometry>`. **ShapeHash**, new in cad_scene.rs: the solid payload and nothing else. No node id, no transform, no material. `ParamHash` is now *defined in terms of it* -- `id` then `ShapeHash` -- rather than repeating the match arm by arm. That is deliberate: last commit corrected a plan written on the assumption that equal `ParamHash` meant "same shape", which the leading `node.id.hash()` quietly made false. With one shared body the two hashes cannot drift apart again, and `param_hash_moves_whenever_the_shape_hash_moves` pins the join. Three consequences worth naming: - **Staleness became structural.** The key IS the content hash, so the draw loop's `.filter(|(hash, _)| *hash == ParamHash::from_node(part))` is gone: an edited part looks up a key that does not exist yet and `ensure_part_geometry` uploads it on the same frame. There is no "entry uploaded from different parameters" state left to guard against, which is the class of bug `subdivide_selected` shipped. - **Eviction is by live shape, not live id.** This is the hazard the change introduces and it is not obvious: deleting one of two hundred identical columns must NOT drop the buffer the other 199 draw from. An eviction written as "remove the deleted node's entry" would blank most of the model. `geometry_is_retained_by_live_shape_not_by_live_id` pins it. - **Uploads deduplicate within the frame.** Without the `queued` set, the first frame of a 200-column scene would call `get_or_build` two hundred times before the map had anything in it. Measured (`bench_geometry_buffers_shared_by_shape`, counts not timings): 200 identical walls 200 parts -> 1 buffer (200x) 420-part repetitive model 420 parts -> 6 buffers (70x) 420 all-distinct parts 420 parts -> 420 buffers (1x) The last row is in the table on purpose. Sharing is a property of the model, not of the code; a scene where every part differs gets nothing from this phase. And draw calls are unchanged -- still one per visible part -- exactly as the plan predicted. Collapsing those is Phase 3, which needed these shared buffers to be possible at all. Estimated at a week, took under a day. Two things the estimate did not know: `MeshCache::get_or_build` was already a pure function of `node.solid`, and the upload path already produced transform-free, colour-free buffers. It assumed both would need untangling; they were built right the first time. Verified: tools/test-cad-coverage.sh green -- total 97.27%, cad_scene.rs 98.56%, cull.rs 100%, all floors met; cargo check --locked -p nigig-build --lib clean (and one warning fewer: the `ParamHash` re-export in mod.rs is no longer needed by the widget layer); cargo test --lib 1091 passed (1085 + 6 new); --test cad_integration 154 passed; CAD_BENCH=1 harness green; cargo fmt --check and git diff --check clean. |
|||
| 4aaebafe4e |
perf(cad): cull parts against the viewport -- Phase 1 of the render plan
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
nigig-build (CAD) / cad-widget-coverage (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
Before this, `grep -niE "cull|frustum|offscreen|in_view"` over the 2,438
line renderer returned nothing. Every part in the document was
tessellated in 2D and issued its own draw call in 3D, on screen or not.
At a 5 m drafting zoom over a 200 m site, a 2,000-part model spent 2,000
tessellations and 2,001 draw calls per frame to show twelve parts.
New `cull.rs` -- pure, host-testable, 100% covered with a floor in
tools/test-cad-coverage.sh:
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
It lives in its own module for the same reason nav_pad.rs and
render_budget.rs do: viewport_render.rs is 2,438 lines at 0.00% coverage
and needs a Cx, so a predicate written inline there could not be tested
at all. Both draw loops AND CadViewport::frame_budget call these same
functions, which is what stops the reported budget from drifting away
from what is drawn -- the discipline render_budget.rs already applies to
the grid loops.
Measured (bench_frame_submission_budget, 1920x1080, parts on a 200 m
site; full tables in BENCH_BASELINE.md):
2D, 5 m zoom, 2,000 parts: 12 visible. 2,170 -> 182 tessellations,
2,001 -> 13 draw calls.
3D, 20 m camera, 2,000: 733 draw calls, 63% culled.
3D, 80 m camera, 2,000: 1,459 draw calls, 27% culled.
Whole site on screen: nothing culled, and nothing should be.
That last row is the honest half and it is in the doc too: when the view
holds the whole model, all 2,000 parts are genuinely visible and culling
cannot help. Phases 2 and 3 (shape-shared geometry, then instancing) are
what address that case.
Three things came out different from the plan I wrote last commit, and
the plan was wrong about each:
- **AABB, not bounding sphere.** A 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_sphere helper, and in particular no bounding_sphere FIELD on
CadNode: the AABB is already cached by PlacedHash in SceneCache, which
invalidates itself on a move or a resize, where a stored field would
have to be maintained at 77 construction sites.
- **world_aabb_from_local_bounds is shared with pick_part**, replacing
the eight-corner transform that was inline there. Two copies would be
two chances to disagree about a part's bounds, and picking a part the
renderer culled is precisely the bug that disagreement produces.
- **The "drawn extent" hazard split into two concrete rules.** The 2D
test takes part_to_plane_2d and part_size_on_plane -- the very values
the draw uses -- so it tests the drawn rect, not the model extent. And
no selected or hovered part is ever culled: the highlight and the
tooltip are drawn at the cursor, arbitrarily far from the part.
The frustum comes from Gribb-Hartmann row arithmetic on
projection * view, deliberately not from mat4_inverse -- that function
had a mistyped index in all sixteen cofactors until
|
|||
| 42b1e7152a |
docs(cad): merge a second render review into the plan, and correct the hash both reviews got wrong
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
nigig-build (CAD) / cad-widget-coverage (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
A second optimisation plan arrived (frustum culling, geometry merging, GPU instancing, octree/LOD). Rather than run two plans, every claim in it was checked against the tree at |
|||
| f27ace8b7f |
docs(cad): render optimisation plan — and a correction to the analysis it rests on
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
Writing the plan meant checking the assumption the analysis rested on,
and the assumption was wrong.
I had called `draw_vector.stroke()` a "tessellation/flush point" and then
reasoned about the 2D path as though each cost a draw call — "~670 flush
points per frame where four would do". Reading
draw/src/shader/draw_vector.rs instead of inferring from a comment:
begin() clears CPU accumulation buffers
stroke() calls tessellate_path_stroke + append_geometry — no draw call
end() the ONLY place cx.new_draw_call appears, twice, both inside it
The whole 2D vector scene is **one draw call**. Makepad's DrawVector is
already a batching design and the CAD code uses it correctly in that
respect. The guard test's wording — "thousands of tessellations per
frame" — was accurate and literal, and I read "draw call" into it.
What survives: no culling anywhere, which was and remains the main
finding; and 3D issuing one draw call per part, which is where draw-call
multiplication is actually real. What changes: batching the 2D loops is
a CPU per-call-overhead win, not a draw-call win, so it drops from
second place to third in the plan and the document says plainly that it
is the small one.
The plan itself, ordered on the corrected facts:
0. Make it measurable. profile_benchmarks.rs has sixteen benchmarks
and none measures frame submission. Check whether Cx already counts
draw calls; if not, add a counting seam. This doubles as the first
test surface viewport_render.rs has ever had — it is 2,083 lines at
0.00% coverage.
1. Cull against the viewport, reusing the cached world AABB that is
already benchmarked at 4.72x and already wired to the mouse-move
path but not the per-frame one.
2. Key part_geoms by ParamHash instead of part id so identical parts
share geometry — the real draw-call win, and the one that matters
for drawings full of repeated columns.
3. Reduce 2D tessellation calls with the queue-then-stroke idiom the
axis grid already uses.
4. LOD, only if the Phase 0 numbers justify it.
Explicitly not doing: a BVH (a linear pass over cached AABBs is
microseconds at this scale) or a render-path rewrite (0% coverage).
One implementation hazard recorded in Phase 1: cull on the drawn extent,
not the model extent. Selection outlines and hover highlights exceed a
part's AABB, and culling on the AABB alone makes them vanish at the
viewport edge.
|