perf(scene): decouple selection from wire tessellation

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 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-05 10:04:22 +03:00
1 parent 0d18e0d1bf
commit ff9f49511b
4 files changed
+140 -34

No files matched your search

+11
View File
@@ -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
+61 -28
View File
@@ -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<Option<(u64, Arc<Vec<WireModel>>, Arc<Vec<WireModel>>)>>,
/// 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<Option<(u64, Arc<HashSet<Handle>>)>>,
}
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<HashSet<Handle>> {
{
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<Handle>) {
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<Handle>| 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<Handle> = 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<Handle> = HashSet::default();
let sel: &HashSet<Handle> = &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 ──────────────────────────────────────────────────────
+45 -6
View File
@@ -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<WireModel> = 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<acadrust::Handle>,
depth_map: &rustc_hash::FxHashMap<u64, f32>,
) {
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<WireModel> = wires
.iter()
.filter(|w| {
w.name
.parse::<u64>()
.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);
}
+23
View File
@@ -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<rustc_hash::FxHashSet<acadrust::Handle>>,
/// 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,
})
}