Large-model viewport performance: bounding-box culling for face/edge/vertex picking, cached per-body boxes and model extent, picks reused while nothing changed and skipped while the view moves (400 bodies: hover 18.7 -> 1.9 ms, orbit 21 -> 2.6 ms; docs/perf.md)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 06:24:22 +00:00
1 parent 04726747f2
commit 4cba923bec
2 files changed
+138 -34

No files matched your search

+117 -2
View File
@@ -24,6 +24,10 @@ pub enum NavMode {
#[derive(Default)]
pub struct ViewportState {
/// Per-body bounding boxes for fast picking, keyed by the model they were taken from.
pick_bounds: std::sync::Mutex<Option<(usize, std::sync::Arc<Vec<(String, solvecraft_engine::geom::Aabb3)>>)>>,
/// The last pick (its key and hits): reused while nothing it depends on changed.
last_pick: Option<(u64, Vec<Hit>)>,
pub rect: Option<Rect>,
pub gpu: Option<GpuTarget>,
slot: SceneSlot,
@@ -122,7 +126,7 @@ fn rgba(c: Rgb) -> [u8; 4] {
}
pub fn scene_radius(app: &SolveApp) -> f64 {
let mut b = solvecraft_engine::view::bounds(&app.session);
let mut b = model_bounds(app);
if !app.preview.bounds.is_empty() {
b = b.union(&app.preview.bounds);
}
@@ -342,9 +346,103 @@ pub fn construction_quads(app: &SolveApp) -> Vec<(String, Vec3, [Vec3; 4])> {
/// Pick what is under `pos`: sketch points, sketch curves, vertices, edges and axes first, then
/// the surfaces along the ray (profiles, faces, planes) nearest first.
/// Bounding boxes of the shown model's bodies (cached until the model changes).
fn body_bounds(
app: &SolveApp,
st: &std::sync::Arc<solvecraft_engine::doc::ModelState>,
) -> std::sync::Arc<Vec<(String, solvecraft_engine::geom::Aabb3)>> {
let key = std::sync::Arc::as_ptr(st) as usize;
if let Ok(mut g) = app.viewport.pick_bounds.lock() {
if let Some((k, b)) = g.as_ref()
&& *k == key
{
return b.clone();
}
let b = std::sync::Arc::new(st.bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect::<Vec<_>>());
*g = Some((key, b.clone()));
return b;
}
std::sync::Arc::new(Vec::new())
}
/// The model's extent: bodies (from the cached boxes) and sketches.
pub fn model_bounds(app: &SolveApp) -> solvecraft_engine::geom::Aabb3 {
let st = app.session.world_state();
let mut b = body_bounds(app, &st).iter().fold(solvecraft_engine::geom::Aabb3::EMPTY, |a, (_, x)| a.union(x));
for ss in &st.sketches {
if let Some((lo, hi)) = ss.sketch.bounds() {
for p in [lo, hi] {
b.add(ss.plane.to_world(p));
}
}
}
b
}
/// Where a ray enters a box (None: it misses).
fn ray_box(o: Vec3, d: Vec3, b: &solvecraft_engine::geom::Aabb3, pad: f64) -> Option<f64> {
let (mut t0, mut t1) = (f64::NEG_INFINITY, f64::INFINITY);
for (oo, dd, lo, hi) in [(o.x, d.x, b.min.x, b.max.x), (o.y, d.y, b.min.y, b.max.y), (o.z, d.z, b.min.z, b.max.z)] {
let (lo, hi) = (lo - pad, hi + pad);
if dd.abs() < 1e-15 {
if oo < lo || oo > hi {
return None;
}
} else {
let (a, c) = ((lo - oo) / dd, (hi - oo) / dd);
t0 = t0.max(a.min(c));
t1 = t1.min(a.max(c));
}
}
(t0 <= t1).then_some(t0)
}
/// A box's outline on screen, grown by `px` (None: behind the eye).
fn screen_box(proj: &Proj, b: &solvecraft_engine::geom::Aabb3, px: f32) -> Option<Rect> {
let mut r = Rect::NOTHING;
for i in 0..8 {
let p = Vec3::new(
if i & 1 == 0 { b.min.x } else { b.max.x },
if i & 2 == 0 { b.min.y } else { b.max.y },
if i & 4 == 0 { b.min.z } else { b.max.z },
);
r.extend_with(proj.to_screen(p)?);
}
Some(r.expand(px))
}
/// What is under `pos`, reusing the last answer while the cursor, the camera and the model are
/// unchanged.
fn pick_cached(app: &mut SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
let mut h = std::collections::hash_map::DefaultHasher::new();
pos.x.to_bits().hash(&mut h);
pos.y.to_bits().hash(&mut h);
for v in [app.cam.yaw, app.cam.pitch, app.cam.distance, app.cam.target.x, app.cam.target.y, app.cam.target.z] {
v.to_bits().hash(&mut h);
}
app.session.revision.hash(&mut h);
(std::sync::Arc::as_ptr(&app.session.world_state()) as usize).hash(&mut h);
app.session.active_sketch.hash(&mut h);
app.dialog.as_ref().and_then(|d| d.active_input()).map(|i| i.accept).hash(&mut h);
app.ui.hidden_bodies.hash(&mut h);
app.ui.perspective.hash(&mut h);
(proj.rect.width().to_bits(), proj.rect.height().to_bits()).hash(&mut h);
let key = h.finish();
if let Some((k, hits)) = &app.viewport.last_pick
&& *k == key
{
return hits.clone();
}
let hits = pick(app, proj, pos);
app.viewport.last_pick = Some((key, hits.clone()));
hits
}
pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
let s = &app.session;
let st = s.world_state();
let bounds = body_bounds(app, &st);
let bound = |name: &str| bounds.iter().find(|(n, _)| n == name).map(|(_, b)| *b);
let mut hits = Vec::new();
let (o, d) = proj.ray(pos);
// Sketch points of the active sketch (of every shown sketch while a command takes points).
@@ -392,6 +490,15 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
if app.ui.hidden_bodies.contains(&b.name) {
continue;
}
// Bodies whose box the ray misses (or enters behind the best hit so far) are skipped.
if let Some(bb) = bound(&b.name) {
let pad = bb.diagonal() * 1e-6 + 1e-9;
match ray_box(o, d, &bb, pad) {
None => continue,
Some(t0) if bestf.as_ref().is_some_and(|(bt, _)| t0 > *bt) => continue,
_ => {}
}
}
let m = b.mesh();
if let Some((t, ti)) = m.raycast(o, d)
&& bestf.as_ref().is_none_or(|(bt, _)| t < *bt)
@@ -409,6 +516,12 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
if app.ui.hidden_bodies.contains(&b.name) {
continue;
}
// Only bodies whose outline on screen is near the cursor can have an edge under it.
if let Some(bb) = bound(&b.name)
&& screen_box(proj, &bb, 8.0).is_some_and(|r| !r.contains(pos))
{
continue;
}
let m = b.mesh();
for (ei, e) in m.edges.iter().enumerate() {
if m.seams.get(ei).copied().unwrap_or(false) {
@@ -997,7 +1110,9 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
}
// ---- interaction ----
let hits = hover.filter(|_| inside && app.viewport.boxsel.is_none()).map(|p| pick(app, &proj, p)).unwrap_or_default();
// No picking while the view moves (orbit, pan, zoom): nothing can be hovered meanwhile.
let navigating = scroll != 0.0 || ((middle || secondary_down) && delta != egui::Vec2::ZERO) || (app.viewport.nav.is_some() && primary_down);
let hits = hover.filter(|_| inside && app.viewport.boxsel.is_none() && !navigating).map(|p| pick_cached(app, &proj, p)).unwrap_or_default();
let cand = if app.tool.is_some() { hits.first().cloned().map(|h| (h, None)) } else { candidate(app, &hits).map(|(h, s)| (h, Some(s))) };
app.viewport.hover = cand.as_ref().map(|c| c.0.clone());
let add = mods.shift || mods.command || mods.ctrl;
+21 -32
View File
@@ -1,36 +1,25 @@
# Performance
# Viewport performance
## Large sketches
Measured with the control channel (`ui.inspect` `frame_ms`: CPU time of one UI frame, which
includes picking under the cursor) on the sample plate patterned 20 × 20 (400 bodies, about
600k triangles), 1600 × 1000 window under Xvfb, release build. Hover: 60 cursor moves across
the model; orbit: a right-drag.
Measured with `cargo test -p solvecraft-engine --release sketch_perf -- --ignored --nocapture`
(`crates/engine/src/cmd/sketch_perf_tests.rs`). The sketch holds `n` rectangles (four lines,
horizontal/vertical constraints and two length dimensions each) and `n` circles. All times are
wall-clock milliseconds on the development machine while other builds were running (load
average around 60), so treat them as upper bounds.
| | hover median | hover p90 | orbit median |
|---|---|---|---|
| before | 18.7 ms | 37.5 ms | 21.0 ms |
| after | 1.9 ms | 2.4 ms | 2.6 ms |
| after, Browser hidden | 0.4 ms | — | 0.4 ms |
| n | curves | points | constraints | solve | profiles | draw lists | snap query | add a line (command) | drag a point (command) |
|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| 250 | 1 250 | 1 251 | 1 500 | 15 | 30 | 12 | 0.1 | 68 | 48 |
| 500 | 2 500 | 2 501 | 3 000 | 35 | 47 | 10 | 0.1 | 123 | 100 |
| 1000 | 5 000 | 5 001 | 6 000 | 65 | 134 | 1 | 0.3 | 232 | 100 |
What changed:
- **Picking culls by bounding box.** Per-body boxes are cached until the model changes. The
face raycast skips bodies whose box the ray misses or enters behind the nearest hit so far.
Edge and vertex tests skip bodies whose box, projected on screen, is not under the cursor.
- **Picks are reused** while the cursor, camera and model are unchanged, and **no picking runs
while the view moves** (orbit, pan, zoom).
- **The model extent is cached** (it sets the view's depth range and was recomputed from every
mesh several times per frame).
"Add a line" and "drag a point" are whole commands: they copy the sketch, solve it, store it and
re-evaluate the timeline (a second solve, plus profile finding). "Draw lists" is
`view::sketch_lines`, the polylines the viewport draws.
Before the 2026-10-08 changes the n = 1000 sketch took 1 280 ms to solve, 590 ms to find
profiles and 5.1 s per command:
- The solver built one dense Jacobian over every variable of the sketch, for each connected
component's iterations and again for the degree-of-freedom analysis (5 000 × 10 000 entries,
then reduced row echelon form). It now builds each component's Jacobian over that component's
own variables. The sketch's Jacobian is block diagonal, so its rank is the sum of the
components' ranks, and components were already solved separately.
- Grouping variables into components searched a list per variable. It now uses a map.
- Profile finding looked at every curve end for each crossing it found, and sorted profiles with
a linear id lookup in the comparison. It now uses the grid it already builds for crossings,
and sorts on precomputed keys.
Projection (linked reference geometry) is cached per model state, so edits to a sketch with
projected silhouettes or sections on a ~20 000-triangle model cost about 3 ms each instead of
about 80 ms.
With 49 bodies the frame was already 2.8 ms (hover) before these changes. The remaining frame
time with the Browser shown is mostly the Browser listing every body row. GPU ID-buffer picking
was not needed at this size.