perf(hover): stop scanning every wire on hover for large drawings
Two per-hover O(N) costs on big drawings, both keyed off the cursor: - The selection/hover xray overlay rebuilt by scanning ALL wires and string-parsing each `name` into a handle on every hover change. Index handle → wire slots once per wire upload instead, so the overlay gathers only the highlighted entity's wires (O(highlighted)). - click_hit (runs on every mouse move) projected every vertex of every wire to screen. In the flat top-down ortho view it now pre-rejects a wire by its world-space AABB (four corners projected, so a Z-rotated plan view is still correct) and only projects points for wires actually near the cursor. Tilted/orbit views fall back to the full test, so no pick is ever missed; covered by a unit test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
ebae67166e
commit
14e85171b3
2 changed files with 90 additions and 8 deletions
|
|
@ -32,8 +32,40 @@ pub fn click_hit<'a>(
|
||||||
let mut best_dist = CLICK_THRESHOLD_PX;
|
let mut best_dist = CLICK_THRESHOLD_PX;
|
||||||
let mut best: Option<&str> = None;
|
let mut best: Option<&str> = None;
|
||||||
|
|
||||||
|
// World z only shifts the *screen* x/y when the view is tilted (orbit /
|
||||||
|
// perspective). In the flat top-down ortho view — the case where hover lag
|
||||||
|
// on large drawings actually bites — a wire's screen position depends only
|
||||||
|
// on its world x/y, so its world-space AABB projects exactly and we can
|
||||||
|
// reject wires nowhere near the cursor without projecting any of their
|
||||||
|
// points (the dominant per-move cost on 100 k-wire drawings).
|
||||||
|
let z_flat = view_proj.z_axis.x.abs() < 1e-9 && view_proj.z_axis.y.abs() < 1e-9;
|
||||||
|
|
||||||
// Q: lazy projection — no Vec allocation per wire; NaN resets the segment chain.
|
// Q: lazy projection — no Vec allocation per wire; NaN resets the segment chain.
|
||||||
for wire in wires {
|
for wire in wires {
|
||||||
|
// Cheap AABB pre-reject (flat view only; never for the unbounded
|
||||||
|
// sentinel used by previews / greeked text).
|
||||||
|
if z_flat && wire.aabb != WireModel::UNBOUNDED_AABB {
|
||||||
|
let [minx, miny, maxx, maxy] = wire.aabb;
|
||||||
|
// Project all four corners — a plan view can be rotated about Z, so
|
||||||
|
// the screen footprint isn't axis-aligned and the two diagonal
|
||||||
|
// corners alone wouldn't bound it.
|
||||||
|
let mut sx0 = f32::MAX;
|
||||||
|
let mut sy0 = f32::MAX;
|
||||||
|
let mut sx1 = f32::MIN;
|
||||||
|
let mut sy1 = f32::MIN;
|
||||||
|
for (cx, cy) in [(minx, miny), (maxx, miny), (maxx, maxy), (minx, maxy)] {
|
||||||
|
let s = world_to_screen(Vec3::new(cx, cy, 0.0), view_proj, bounds);
|
||||||
|
sx0 = sx0.min(s.x);
|
||||||
|
sx1 = sx1.max(s.x);
|
||||||
|
sy0 = sy0.min(s.y);
|
||||||
|
sy1 = sy1.max(s.y);
|
||||||
|
}
|
||||||
|
let t = CLICK_THRESHOLD_PX;
|
||||||
|
if cursor.x < sx0 - t || cursor.x > sx1 + t || cursor.y < sy0 - t || cursor.y > sy1 + t
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
let mut prev: Option<Point> = None;
|
let mut prev: Option<Point> = None;
|
||||||
for &[px, py, pz] in &wire.points {
|
for &[px, py, pz] in &wire.points {
|
||||||
if px.is_nan() {
|
if px.is_nan() {
|
||||||
|
|
@ -590,3 +622,32 @@ fn dist_point_to_segment(p: Point, a: Point, b: Point) -> f32 {
|
||||||
let dy = p.y - cy;
|
let dy = p.y - cy;
|
||||||
(dx * dx + dy * dy).sqrt()
|
(dx * dx + dy * dy).sqrt()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod aabb_reject_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn wire(name: &str, pts: Vec<[f32; 3]>, aabb: [f32; 4]) -> WireModel {
|
||||||
|
let mut w = WireModel::solid(name.to_string(), pts, [1.0; 4], false);
|
||||||
|
w.aabb = aabb;
|
||||||
|
w
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identity ortho view: world (x,y) → screen ((x+1)*100, (1-y)*100) for a
|
||||||
|
// 200×200 viewport. The view is flat (z_axis.xy == 0) so the AABB pre-reject
|
||||||
|
// is active — these tests guard it against false negatives.
|
||||||
|
#[test]
|
||||||
|
fn aabb_reject_keeps_near_wire_drops_far() {
|
||||||
|
let vp = Mat4::IDENTITY;
|
||||||
|
let bounds = Rectangle { x: 0.0, y: 0.0, width: 200.0, height: 200.0 };
|
||||||
|
let cursor = Point::new(100.0, 100.0); // world origin
|
||||||
|
|
||||||
|
let near = wire("5", vec![[-0.02, 0.0, 0.0], [0.02, 0.0, 0.0]], [-0.02, 0.0, 0.02, 0.0]);
|
||||||
|
let far = wire("9", vec![[0.9, 0.9, 0.0], [0.95, 0.9, 0.0]], [0.9, 0.9, 0.95, 0.9]);
|
||||||
|
|
||||||
|
assert_eq!(click_hit(cursor, std::slice::from_ref(&near), vp, bounds), Some("5"));
|
||||||
|
assert_eq!(click_hit(cursor, std::slice::from_ref(&far), vp, bounds), None);
|
||||||
|
// The far wire must be rejected without hiding the near one.
|
||||||
|
assert_eq!(click_hit(cursor, &[far, near], vp, bounds), Some("5"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,11 @@ pub struct Pipeline {
|
||||||
/// a pick bumps only `selection_generation`, refreshing the overlay without
|
/// a pick bumps only `selection_generation`, refreshing the overlay without
|
||||||
/// touching the main wire buffers.
|
/// touching the main wire buffers.
|
||||||
pub cached_selection: (u64, u64),
|
pub cached_selection: (u64, u64),
|
||||||
|
/// Handle → indices into the resident wire set, built once per wire upload
|
||||||
|
/// (when `cached_wire_id` changes). Lets the selection/hover xray overlay
|
||||||
|
/// gather just the highlighted entity's wires (`O(highlighted)`) instead of
|
||||||
|
/// scanning and string-parsing every wire on each hover change.
|
||||||
|
wire_handle_index: rustc_hash::FxHashMap<u64, Vec<u32>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Pipeline {
|
impl Pipeline {
|
||||||
|
|
@ -905,6 +910,7 @@ impl Pipeline {
|
||||||
cached_epoch: (u64::MAX, u64::MAX, u64::MAX),
|
cached_epoch: (u64::MAX, u64::MAX, u64::MAX),
|
||||||
cached_wire_id: u64::MAX,
|
cached_wire_id: u64::MAX,
|
||||||
cached_selection: (u64::MAX, u64::MAX),
|
cached_selection: (u64::MAX, u64::MAX),
|
||||||
|
wire_handle_index: rustc_hash::FxHashMap::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -939,6 +945,17 @@ impl Pipeline {
|
||||||
i = j;
|
i = j;
|
||||||
}
|
}
|
||||||
self.gpu_wires = batches;
|
self.gpu_wires = batches;
|
||||||
|
|
||||||
|
// Index handle → wire slots once, here, so the per-hover selection
|
||||||
|
// overlay can gather just the highlighted wires instead of scanning +
|
||||||
|
// string-parsing the whole set every time the hovered entity changes.
|
||||||
|
self.wire_handle_index.clear();
|
||||||
|
self.wire_handle_index.reserve(wires.len());
|
||||||
|
for (idx, w) in wires.iter().enumerate() {
|
||||||
|
if let Ok(h) = w.name.parse::<u64>() {
|
||||||
|
self.wire_handle_index.entry(h).or_default().push(idx as u32);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the selection xray overlay: full-brightness copies of the wires
|
/// Build the selection xray overlay: full-brightness copies of the wires
|
||||||
|
|
@ -960,19 +977,23 @@ impl Pipeline {
|
||||||
self.gpu_selected_wires = vec![];
|
self.gpu_selected_wires = vec![];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Gather only the highlighted entities' wires via the prebuilt index —
|
||||||
|
// O(highlighted), no per-wire string parse / full-set scan. Indices are
|
||||||
|
// collected then sorted so the xray run keeps the original draw order.
|
||||||
|
let mut slots: Vec<u32> = Vec::new();
|
||||||
|
for h in highlight {
|
||||||
|
if let Some(idxs) = self.wire_handle_index.get(&h.value()) {
|
||||||
|
slots.extend_from_slice(idxs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slots.sort_unstable();
|
||||||
// Recolor to the selection highlight: the xray pass uses the normal
|
// Recolor to the selection highlight: the xray pass uses the normal
|
||||||
// wire shader (no forced colour), so the highlight now lives here
|
// wire shader (no forced colour), so the highlight now lives here
|
||||||
// instead of being baked into the tessellation. Drawn on top with
|
// instead of being baked into the tessellation. Drawn on top with
|
||||||
// depth-compare Always, so it overrides the base-coloured main pass.
|
// depth-compare Always, so it overrides the base-coloured main pass.
|
||||||
let selected: Vec<WireModel> = wires
|
let selected: Vec<WireModel> = slots
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|w| {
|
.filter_map(|&i| wires.get(i as usize))
|
||||||
w.name
|
|
||||||
.parse::<u64>()
|
|
||||||
.ok()
|
|
||||||
.map(acadrust::Handle::new)
|
|
||||||
.is_some_and(|h| highlight.contains(&h))
|
|
||||||
})
|
|
||||||
.map(|w| {
|
.map(|w| {
|
||||||
let mut c = w.clone();
|
let mut c = w.clone();
|
||||||
c.color = WireModel::SELECTED;
|
c.color = WireModel::SELECTED;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue