From ff9f49511b600c371cacf5ecc8a3154ca0dd64a8 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 5 Jun 2026 10:04:22 +0300 Subject: [PATCH] perf(scene): decouple selection from wire tessellation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picking an entity called bump_geometry, which invalidated the wire cache and re-tessellated the entire model just to repaint one entity — 30ms to 400ms on a large drawing (visible as a tess-ms spike on the PERF HUD). Selection highlight is no longer baked into tessellation. wires_for_block_ culled now feeds tessellate_entity an empty selection set, so wires are always base-coloured and the wire cache is selection-independent. The highlight is applied in the GPU xray overlay: prepare filters the cached wires by the live `selected ∪ hover` handle set, recolours them to WireModel::SELECTED, and batches them — rebuilt only when a new selection_generation (or the underlying wire content) changes. Pure select / deselect / hover paths now call bump_selection instead of bump_geometry, so a pick refreshes just the overlay — no re-tessellation, no main wire re-upload (geometry_epoch and wire_content_id are unchanged). Geometry edits, undo and file-open still bump_geometry and rebuild everything. Grips/properties refresh through their existing app paths. Co-Authored-By: Claude Opus 4.8 --- ROADMAP.md | 11 +++++ src/scene/mod.rs | 89 +++++++++++++++++++++++++++------------ src/scene/pipeline/mod.rs | 51 +++++++++++++++++++--- src/scene/render.rs | 23 ++++++++++ 4 files changed, 140 insertions(+), 34 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e27bd2536..9441d8a5e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -176,6 +176,17 @@ outright — no re-tessellation, `tess ms` drops to ~0 on the PERF HUD. Zoom exactly as before; the cull margin is unchanged, so miss cost is identical (no zoom regression). +**Partial (landed): selection decoupled.** Picking an entity used to call +`bump_geometry`, invalidating the wire cache and re-tessellating the WHOLE +model just to repaint one entity (30 ms → 400 ms on a large drawing). The +highlight is no longer baked into tessellation: wires are always base-coloured +(`sel` is empty in `wires_for_block_culled`), and the selection highlight is +applied in the GPU xray overlay from the live `selected ∪ hover` set, +recoloured to `WireModel::SELECTED`. Selection / hover now bump a cheap +`selection_generation` instead of `geometry_epoch`, so the overlay refreshes +without any re-tessellation or main-buffer re-upload. `tess ms` stays flat +when selecting. + **Partial (landed): per-frame split.** `build_primitive` ran `split_face3d_wires` every frame — an O(N) per-wire handle lookup + clone to separate Face3D wires — even on a pan that reused the tessellation. It's now diff --git a/src/scene/mod.rs b/src/scene/mod.rs index ca905bc85..2cad01ec9 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -616,6 +616,11 @@ pub struct Scene { /// preview wires, layer visibility, layout). The GPU pipeline uses this to /// skip re-uploading unchanged geometry buffers every frame. pub geometry_epoch: u64, + /// Incremented when the selection / hover-highlight set changes WITHOUT a + /// geometry change. The wire tessellation is selection-independent, so a + /// pick only refreshes the GPU xray overlay (cheap) instead of bumping + /// `geometry_epoch` and re-tessellating the whole model. + pub selection_generation: u64, /// Cached tessellation of all visible entity wires for the current layout. /// Keyed by `(geometry_epoch, camera_generation)` so a camera change /// invalidates the cull-dependent wire list as well as a geometry change. @@ -759,6 +764,10 @@ pub struct Scene { #[allow(clippy::type_complexity)] split_cache: RefCell>, Arc>)>>, + /// Cached `selected ∪ hover` handle set for the GPU xray overlay, keyed by + /// `selection_generation`. Rebuilt only when the selection changes so + /// `build_primitive` doesn't clone the set every frame. + highlight_set_cache: RefCell>)>>, } impl Scene { @@ -787,6 +796,7 @@ impl Scene { interim_wire: None, camera_generation: 0, geometry_epoch: GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed), + selection_generation: 0, wire_cache: RefCell::new(None), model_tile_wire_cache: RefCell::new(HashMap::default()), sort_cache: RefCell::new(None), @@ -821,6 +831,7 @@ impl Scene { last_model_wire_gen: std::cell::Cell::new(0), wire_force_nonce: std::cell::Cell::new(0), split_cache: RefCell::new(None), + highlight_set_cache: RefCell::new(None), } } @@ -934,6 +945,35 @@ impl Scene { self.geometry_epoch = GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed); } + /// Mark the selection / hover-highlight set dirty without invalidating the + /// (selection-independent) wire tessellation. Only the GPU xray overlay is + /// rebuilt — no re-tessellation. Use this for pure select / deselect / + /// hover changes; use [`bump_geometry`] when the geometry itself changed. + pub fn bump_selection(&mut self) { + self.selection_generation = self.selection_generation.wrapping_add(1); + } + + /// `selected ∪ hover` handles, cached per `selection_generation`. Drives the + /// GPU xray overlay that highlights the current selection without baking it + /// into the wire tessellation. + pub(super) fn highlight_handles(&self) -> Arc> { + { + let c = self.highlight_set_cache.borrow(); + if let Some((gen, arc)) = &*c { + if *gen == self.selection_generation { + return Arc::clone(arc); + } + } + } + let mut hl = self.selected.clone(); + if let Some(h) = self.hover_highlight { + hl.insert(h); + } + let arc = Arc::new(hl); + *self.highlight_set_cache.borrow_mut() = Some((self.selection_generation, Arc::clone(&arc))); + arc + } + /// Re-evaluate every cached mesh's color through `render_style` so a /// Register a Model-tab solid: cache its truck B-rep (for boolean ops) and /// tessellate it into the shaded mesh pipeline under `handle`. The solid is @@ -1417,23 +1457,21 @@ impl Scene { } /// Set (or clear) the previewed entity that renders with the selection - /// highlight without joining the real selection. Re-tessellates on change. + /// highlight without joining the real selection. Only refreshes the GPU + /// xray overlay (no re-tessellation). pub fn set_hover_highlight(&mut self, handle: Option) { if self.hover_highlight == handle { return; } - // The hover entity is folded into the tessellation highlight set - // (selected ∪ {hover}); see `wires_for_block_culled`. A hover handle - // that's already selected contributes nothing, so the effective set — - // and thus every WireModel — is identical. Only re-tessellate when the - // effective highlight contribution actually changes; hovering over (or - // between) already-selected entities then costs nothing. The field is - // still updated so hit-test / UI state stays current. + // Hover is folded into the highlight set (selected ∪ {hover}) that + // drives the xray overlay. A hover handle that's already selected + // contributes nothing, so the effective set is unchanged — skip the + // overlay refresh then. The field is still updated for hit-test / UI. let contribution = |h: Option| h.filter(|h| !self.selected.contains(h)); let changed = contribution(self.hover_highlight) != contribution(handle); self.hover_highlight = handle; if changed { - self.bump_geometry(); + self.bump_selection(); } } @@ -2138,19 +2176,14 @@ impl Scene { // Tessellate in parallel across all available CPU cores. use rayon::prelude::*; let doc = &self.document; - // Fold the hover-preview entity into the highlight set so it renders - // selected without being part of the real selection. - let hl_set; - let sel: &HashSet = if let Some(h) = self.hover_highlight { - hl_set = { - let mut s = self.selected.clone(); - s.insert(h); - s - }; - &hl_set - } else { - &self.selected - }; + // Selection / hover highlight is NOT baked into tessellation. It is + // applied per frame in the GPU xray overlay pass from the live + // selection set (`Scene::selected` ∪ hover). Keeping `sel` empty here + // makes the wire cache selection-independent, so picking an entity + // bumps only `selection_generation` (cheap overlay refresh) instead of + // `geometry_epoch` (a full model re-tessellation). + let empty_sel: HashSet = HashSet::default(); + let sel: &HashSet = &empty_sel; let avp = self.active_viewport; // A paper-space content viewport renders MODEL block entities while // the user is sitting in a paper layout — that path expects @@ -4262,18 +4295,18 @@ impl Scene { self.selected.clear(); } self.selected.insert(handle); - self.bump_geometry(); + self.bump_selection(); } pub fn deselect_all(&mut self) { self.selected.clear(); - self.bump_geometry(); + self.bump_selection(); } /// Remove a single entity from the selection (Shift+click subtractive pick). pub fn deselect_entity(&mut self, handle: Handle) { if self.selected.remove(&handle) { - self.bump_geometry(); + self.bump_selection(); } } @@ -4329,7 +4362,7 @@ impl Scene { } } if added > 0 { - self.bump_geometry(); + self.bump_selection(); } added } @@ -4397,7 +4430,7 @@ impl Scene { matched += 1; } } - self.bump_geometry(); + self.bump_selection(); matched } @@ -4658,7 +4691,7 @@ impl Scene { for h in to_add { self.selected.insert(h); } - self.bump_geometry(); + self.bump_selection(); } // ── Layer helpers ────────────────────────────────────────────────────── diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index a8da45618..df7e0a5d3 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -122,6 +122,11 @@ pub struct Pipeline { /// Model-tile tessellation) and `upload_wires` is skipped. `u64::MAX` = /// nothing uploaded yet. pub cached_wire_id: u64, + /// `(wire_content_id, selection_generation)` the selection xray overlay + /// (`gpu_selected_wires`) was last built for. Rebuilt when either changes — + /// a pick bumps only `selection_generation`, refreshing the overlay without + /// touching the main wire buffers. + pub cached_selection: (u64, u64), } impl Pipeline { @@ -891,6 +896,7 @@ impl Pipeline { viewcube, cached_epoch: (u64::MAX, u64::MAX), cached_wire_id: u64::MAX, + cached_selection: (u64::MAX, u64::MAX), } } @@ -925,13 +931,46 @@ impl Pipeline { i = j; } self.gpu_wires = batches; + } - // Full-brightness copies of selected wires, drawn on top of everything - // in the selection overlay pass so they're always visible. The xray - // pass applies neither scissor nor mesh-edge skip, so all selected - // wires batch into one run (None / false) — select-all over a large - // drawing no longer costs one draw call per entity. Order preserved. - let selected: Vec = wires.iter().filter(|w| w.selected).cloned().collect(); + /// Build the selection xray overlay: full-brightness copies of the wires + /// whose entity handle is in `highlight`, drawn on top so the selection is + /// always visible. Selection is no longer baked into the wire tessellation, + /// so this is driven by the live highlight set and rebuilt only when the + /// selection (or the underlying wire content) changes — picking an entity + /// refreshes just this overlay instead of re-tessellating the model. The + /// xray pass applies neither scissor nor mesh-edge skip, so everything + /// merges into one order-preserving run. + pub fn upload_selected_wires( + &mut self, + device: &wgpu::Device, + wires: &[WireModel], + highlight: &rustc_hash::FxHashSet, + depth_map: &rustc_hash::FxHashMap, + ) { + if highlight.is_empty() { + self.gpu_selected_wires = vec![]; + return; + } + // Recolor to the selection highlight: the xray pass uses the normal + // wire shader (no forced colour), so the highlight now lives here + // instead of being baked into the tessellation. Drawn on top with + // depth-compare Always, so it overrides the base-coloured main pass. + let selected: Vec = wires + .iter() + .filter(|w| { + w.name + .parse::() + .ok() + .map(acadrust::Handle::new) + .is_some_and(|h| highlight.contains(&h)) + }) + .map(|w| { + let mut c = w.clone(); + c.color = WireModel::SELECTED; + c + }) + .collect(); self.gpu_selected_wires = WireGpu::from_run(device, &selected, depth_map, None, false); } diff --git a/src/scene/render.rs b/src/scene/render.rs index ef637b39f..4da230fa8 100644 --- a/src/scene/render.rs +++ b/src/scene/render.rs @@ -81,6 +81,13 @@ pub struct ViewportData { /// world-space wire buffer when only the camera moved. Non-tile and /// preview/interim frames carry a fresh id each time → always re-upload. pub(super) wire_content_id: u64, + /// `selected ∪ hover` handles for the GPU xray overlay. The highlight is no + /// longer baked into the wire tessellation, so `prepare` rebuilds the + /// selected-wire batch by filtering `wires` against this set. + pub(super) highlight_handles: Arc>, + /// Bumped on selection / hover change. Paired with `wire_content_id` to + /// decide when the xray overlay batch needs rebuilding. + pub(super) selection_generation: u64, /// Screen rectangle this viewport fills, **normalized** to the widget /// bounds (each component in 0..1). A single full-widget view is /// `(0, 0, 1, 1)`; tiled / floating viewports are sub-rectangles. @@ -251,6 +258,20 @@ impl shader::Primitive for Primitive { inner.upload_wires(device, &vp.wires[..], &vp.draw_depths); inner.cached_wire_id = vp.wire_content_id; } + // Selection xray overlay — rebuilt when the selection changes or the + // underlying wires changed. A pick bumps only selection_generation, + // so this refreshes without re-tessellating or re-uploading the main + // wire buffers. + let sel_key = (vp.wire_content_id, vp.selection_generation); + if sel_key != inner.cached_selection { + inner.upload_selected_wires( + device, + &vp.wires[..], + &vp.highlight_handles, + &vp.draw_depths, + ); + inner.cached_selection = sel_key; + } let vproj = vp.uniforms.view_proj; inner.compute_wire_scissors(vproj, clip_size.width, clip_size.height); inner.compute_wipeout_scissors(vproj, clip_size.width, clip_size.height); @@ -747,6 +768,8 @@ impl Scene { geometry_epoch: self.geometry_epoch, camera_generation: self.camera_generation, wire_content_id, + highlight_handles: self.highlight_handles(), + selection_generation: self.selection_generation, screen_rect, }) }