From 4cba923bec2004b9e43a698b6bdebc0dc6a6e823 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 06:17:49 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/ui-egui/src/viewport.rs | 119 ++++++++++++++++++++++++++++++++- docs/perf.md | 53 ++++++--------- 2 files changed, 138 insertions(+), 34 deletions(-) diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index acfb7a2b..481688dc 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -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>)>>, + /// The last pick (its key and hits): reused while nothing it depends on changed. + last_pick: Option<(u64, Vec)>, pub rect: Option, pub gpu: Option, 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, +) -> std::sync::Arc> { + 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::>()); + *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 { + 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 { + 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 { + 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 { 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 { 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 { 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; diff --git a/docs/perf.md b/docs/perf.md index 90446d77..32f9cb65 100644 --- a/docs/perf.md +++ b/docs/perf.md @@ -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.