From 462d9b4b663e530808fa75c965d067ad1aa4468d Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 10 Jun 2026 21:20:45 +0300 Subject: [PATCH] perf(hover): don't rebuild/re-upload hatches on every rollover (0.5.1 regr) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selected hatches carry a tint (#71), so 0.5.1 keyed both the hatch model cache and the static GPU buffer re-upload on `selection_generation`. But that counter also bumps on hover, so moving the cursor over a new entity rebuilt EVERY hatch model and re-uploaded the hatch + face3d buffers each time — the large-file hover stutter introduced between 0.5.0 and 0.5.1. The hatch tint depends only on the *selected set*, never on hover. Key both on an order-independent signature of `selected` instead, so a rollover keeps the caches warm; an actual select/deselect still rebuilds. Also cache the picking wire set on `geometry_epoch` alone (separate from the camera-keyed render cache) so a pan/zoom no longer re-tessellates the whole un-culled model on the next mouse move. Co-Authored-By: Claude Opus 4.8 --- src/scene/mod.rs | 51 +++++++++++++++++++++++++++++++++++++++------ src/scene/render.rs | 12 +++++++++-- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 16327b9f1..816bcac81 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -633,6 +633,13 @@ pub struct Scene { /// invalidates the cull-dependent wire list as well as a geometry change. /// Uses `Arc` so `build_primitive()` avoids a full Vec clone during navigation. wire_cache: RefCell>)>>, + /// Camera-invariant full wire set for **picking** (Model layout), keyed on + /// `geometry_epoch` alone. The wires are world-space, so a pan/zoom must not + /// re-tessellate the whole un-culled model just to hit-test the next mouse + /// move (seconds on a 100k-entity drawing). Built once per geometry change + /// and reused across every camera move; a slightly stale curve tol is + /// harmless for an 8 px pick threshold. + hit_wire_cache: RefCell>)>>, /// Per-Model-tile cached tessellation. Each tile has its own camera /// (live for the active tile, stored snapshot for the others), so /// LOD / frustum culling has to run independently — the shared @@ -820,6 +827,7 @@ impl Scene { block_epoch: GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed), selection_generation: 0, wire_cache: RefCell::new(None), + hit_wire_cache: RefCell::new(None), model_tile_wire_cache: RefCell::new(HashMap::default()), sort_cache: RefCell::new(None), draw_depth_cache: RefCell::new(None), @@ -1875,22 +1883,37 @@ impl Scene { } pub(super) fn hatch_models_arc(&self) -> Arc> { + // Hatch models bake the selection tint (issue #71), so they depend on + // the *selected set* — but NOT on hover. Keying on `selection_generation` + // (which also bumps on every hover) made each hover-over a new entity + // rebuild every hatch model: an O(N-hatch) stutter on hatch-heavy + // drawings. Key on a signature of `selected` instead, so hover (which + // never changes `selected`) keeps the cache warm. + let sel_sig = self.selected_set_sig(); { let cache = self.hatch_cache.borrow(); if let Some((cached_epoch, cached_sel, ref arc)) = *cache { - if cached_epoch == self.geometry_epoch - && cached_sel == self.selection_generation - { + if cached_epoch == self.geometry_epoch && cached_sel == sel_sig { return Arc::clone(arc); } } } let arc = Arc::new(self.synced_hatch_models()); - *self.hatch_cache.borrow_mut() = - Some((self.geometry_epoch, self.selection_generation, Arc::clone(&arc))); + *self.hatch_cache.borrow_mut() = Some((self.geometry_epoch, sel_sig, Arc::clone(&arc))); arc } + /// Order-independent signature of the selected set. Cheap (the set is + /// normally a handful of entities) and unchanged by hover, so caches that + /// only depend on what's *selected* don't thrash on rollover. + fn selected_set_sig(&self) -> u64 { + let mut sig: u64 = self.selected.len() as u64; + for h in self.selected.iter() { + sig ^= h.value().wrapping_mul(0x9E37_79B9_7F4A_7C15); + } + sig + } + pub(super) fn wipeout_models_arc(&self) -> Arc> { { let cache = self.wipeout_cache.borrow(); @@ -2068,7 +2091,23 @@ impl Scene { /// only — paper-space entities are NOT interactive. pub fn hit_test_wires(&self) -> Arc> { if self.current_layout == "Model" { - return self.entity_wires_arc(); + // Camera-invariant pick cache: reuse the full wire set across pan / + // zoom (world-space wires don't change), rebuilding only when the + // geometry changes. `entity_wires_arc` itself keys on the camera, + // so calling it on every mouse move after a navigate would + // re-tessellate the whole un-culled model — the large-file hover + // stutter. Gate on `geometry_epoch` here instead. + { + let c = self.hit_wire_cache.borrow(); + if let Some((g, ref arc)) = *c { + if g == self.geometry_epoch { + return Arc::clone(arc); + } + } + } + let arc = self.entity_wires_arc(); + *self.hit_wire_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&arc))); + return arc; } let layout_block = self.current_layout_block_handle(); match self.active_viewport { diff --git a/src/scene/render.rs b/src/scene/render.rs index 7c88a9862..fc2a74f5b 100644 --- a/src/scene/render.rs +++ b/src/scene/render.rs @@ -92,6 +92,10 @@ pub struct ViewportData { /// Bumped on selection / hover change. Paired with `wire_content_id` to /// decide when the xray overlay batch needs rebuilding. pub(super) selection_generation: u64, + /// Signature of the *selected set* only (not hover). Gates the static-buffer + /// re-upload (hatch tint, issue #71) so a hover doesn't re-upload every + /// hatch / face3d buffer on hatch-heavy drawings. + pub(super) selected_sig: 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. @@ -221,7 +225,10 @@ impl shader::Primitive for Primitive { let (uo_y, us_y) = uv_crop_axis(sr.y, sr.height); inner.upload_blit_uv(queue, [uo_x, uo_y], [us_x, us_y]); inner.upload_uniforms(queue, &vp.uniforms); - let cur_key = (vp.geometry_epoch, vp.camera_generation, vp.selection_generation); + // Third component is the *selected-set* signature (not + // selection_generation, which also bumps on hover) so a rollover + // doesn't re-upload the static hatch / face3d buffers. + let cur_key = (vp.geometry_epoch, vp.camera_generation, vp.selected_sig); let fill_mode = vp.fill_mode; // 3D face fill requires *both* the doc-level FILLMODE *and* the // per-view Solid toggle. Hatches / wipeouts deliberately ignore @@ -233,7 +240,7 @@ impl shader::Primitive for Primitive { // a selection change (issue #71); images / meshes only need a // geometry change. let geo_changed = vp.geometry_epoch != inner.cached_epoch.0; - let sel_changed = vp.selection_generation != inner.cached_epoch.2; + let sel_changed = vp.selected_sig != inner.cached_epoch.2; if geo_changed || sel_changed { if fill_mode { inner.upload_hatches(device, &vp.hatches[..]); @@ -788,6 +795,7 @@ impl Scene { wire_content_id, highlight_handles: self.highlight_handles(), selection_generation: self.selection_generation, + selected_sig: self.selected_set_sig(), screen_rect, }) }