perf: eliminate initial hover freeze

This commit is contained in:
Hakan Seven committed 2026-07-25 10:09:23 +03:00
1 parent 47423eae18
commit 2bc4c1c8f3
7 files changed
+554 -126

No files matched your search

+242 -100
View File
@@ -1032,6 +1032,10 @@ pub struct Scene {
Arc<crate::scene::pick::interaction_index::InteractionIndex>,
)>,
>,
/// Latest large source requested for off-thread preparation. A matching
/// cache miss returns no candidates temporarily instead of scanning the
/// entire scene on the UI thread.
interaction_index_pending_key: std::cell::Cell<Option<(u64, usize)>>,
/// Large resident interaction index kept as an immutable base across small
/// entity edits. Geometry-journal handles form a tombstone/delta overlay;
/// the exact index is rebuilt only after the overlay grows too large.
@@ -1063,9 +1067,6 @@ pub struct Scene {
Arc<crate::scene::pick::interaction_index::InteractionHandleIndex>,
)>,
>,
/// Estimated hatch/mesh/Insert interaction work, cached per geometry epoch
/// so the index threshold itself stays O(1) on pointer-move events.
interaction_aux_work_cache: std::cell::Cell<Option<(u64, u64, usize)>>,
/// Index built from every SortEntitiesTable in the document.
/// Maps block_handle → (entity_handle.value() → sort_handle.value()).
/// Replaces the O(objects) linear scan inside `wires_for_block()` with an O(1) lookup.
@@ -1357,10 +1358,10 @@ impl Scene {
selection_generation: 0,
wire_cache: RefCell::new(None),
interaction_index_cache: RefCell::new(Vec::new()),
interaction_index_pending_key: std::cell::Cell::new(None),
interaction_base_index_cache: RefCell::new(None),
interaction_overlay_index_cache: RefCell::new(None),
interaction_handle_index_cache: RefCell::new(None),
interaction_aux_work_cache: std::cell::Cell::new(None),
sort_cache: RefCell::new(None),
draw_depth_cache: RefCell::new(None),
hatch_cache: RefCell::new(None),
@@ -4791,107 +4792,137 @@ impl Scene {
const INTERACTION_INDEX_MIN_WORK: usize = 20_000;
fn interaction_index_worthwhile(&self, wires: &[WireModel]) -> bool {
if wires.len() >= Self::INTERACTION_INDEX_MIN_WIRES
wires.len() >= Self::INTERACTION_INDEX_MIN_WIRES
|| crate::scene::pick::interaction_index::InteractionIndex::estimated_work(wires)
>= Self::INTERACTION_INDEX_MIN_WORK
{
return true;
}
let space_key = self.interaction_space_key();
let aux_work = self
.interaction_aux_work_cache
.get()
.filter(|(epoch, space, _)| *epoch == self.geometry_epoch && *space == space_key)
.map(|(_, _, work)| work)
.unwrap_or_else(|| {
let hatch_work = self.hatches.iter().fold(0usize, |total, (&handle, hatch)| {
if self.hatch_visible_for_interaction(handle) {
total.saturating_add(hatch.boundary.len())
} else {
total
}
});
let insert_work = self
.insert_hatches_for_click()
.values()
.flatten()
.fold(0usize, |total, hatch| {
total.saturating_add(hatch.boundary.len())
});
let mesh_work = self
.interaction_meshes_arc()
.iter()
.fold(0usize, |total, set| {
total.saturating_add(set.lods.first().map_or(0, |mesh| mesh.verts.len()))
});
let work = hatch_work
.saturating_add(mesh_work)
.saturating_add(insert_work);
self.interaction_aux_work_cache
.set(Some((self.geometry_epoch, space_key, work)));
work
});
aux_work >= Self::INTERACTION_INDEX_MIN_WORK
|| self.hatches.len() >= Self::INTERACTION_INDEX_MIN_WIRES
|| self.meshes.len().saturating_add(self.block_meshes.len())
>= Self::INTERACTION_INDEX_MIN_WIRES
|| self
.hatches
.values()
.any(|hatch| hatch.boundary.len() >= Self::INTERACTION_INDEX_MIN_WORK)
|| self
.meshes
.values()
.chain(self.block_meshes.values())
.any(|set| {
set.lods
.first()
.is_some_and(|mesh| mesh.verts.len() >= Self::INTERACTION_INDEX_MIN_WORK)
})
}
fn interaction_index(
fn cached_interaction_index(
&self,
wires: &Arc<Vec<WireModel>>,
) -> Arc<crate::scene::pick::interaction_index::InteractionIndex> {
const MAX_CACHED_SOURCES: usize = 4;
) -> Option<Arc<crate::scene::pick::interaction_index::InteractionIndex>> {
let source = Arc::as_ptr(wires) as usize;
{
let mut cache = self.interaction_index_cache.borrow_mut();
cache.retain(|(epoch, _, weak, _)| {
*epoch == self.geometry_epoch && weak.strong_count() > 0
});
if let Some(position) = cache.iter().position(|(_, ptr, weak, _)| {
*ptr == source
&& weak
.upgrade()
.is_some_and(|cached| Arc::ptr_eq(&cached, wires))
}) {
let entry = cache.remove(position);
let index = Arc::clone(&entry.3);
cache.push(entry);
if self.current_layout == "Model" {
*self.interaction_base_index_cache.borrow_mut() = Some((
self.geometry_epoch,
self.interaction_space_key(),
Arc::clone(&index),
));
}
return index;
}
}
let started = iced::time::Instant::now();
let index = Arc::new(crate::scene::pick::interaction_index::InteractionIndex::build(wires));
let build_ms = started.elapsed().as_secs_f64() * 1000.0;
if std::env::var_os("OCS_PERF").is_some() && build_ms >= 50.0 {
eprintln!(
"[perf] interaction-index {:>7.1}ms wires={}",
build_ms,
wires.len(),
);
}
let mut cache = self.interaction_index_cache.borrow_mut();
cache.retain(|(epoch, _, weak, _)| {
*epoch == self.geometry_epoch && weak.strong_count() > 0
});
if let Some(position) = cache.iter().position(|(_, ptr, weak, _)| {
*ptr == source
&& weak
.upgrade()
.is_some_and(|cached| Arc::ptr_eq(&cached, wires))
}) {
let entry = cache.remove(position);
let index = Arc::clone(&entry.3);
cache.push(entry);
if self.current_layout == "Model" {
*self.interaction_base_index_cache.borrow_mut() = Some((
self.geometry_epoch,
self.interaction_space_key(),
Arc::clone(&index),
));
}
return Some(index);
}
None
}
fn cache_interaction_index(
&self,
epoch: u64,
wires: Arc<Vec<WireModel>>,
index: Arc<crate::scene::pick::interaction_index::InteractionIndex>,
) {
const MAX_CACHED_SOURCES: usize = 4;
let source = Arc::as_ptr(&wires) as usize;
let mut cache = self.interaction_index_cache.borrow_mut();
cache.retain(|(cached_epoch, ptr, weak, _)| {
*cached_epoch == self.geometry_epoch
&& weak.strong_count() > 0
&& !(*cached_epoch == epoch && *ptr == source)
});
cache.push((
self.geometry_epoch,
epoch,
source,
Arc::downgrade(wires),
Arc::downgrade(&wires),
Arc::clone(&index),
));
if cache.len() > MAX_CACHED_SOURCES {
cache.remove(0);
}
if self.current_layout == "Model" {
if epoch == self.geometry_epoch && self.current_layout == "Model" {
*self.interaction_base_index_cache.borrow_mut() = Some((
self.geometry_epoch,
epoch,
self.interaction_space_key(),
Arc::clone(&index),
index,
));
}
index
}
/// Return a stable build key when this source needs its large
/// interaction index. The app uses it to deduplicate background builds.
pub fn interaction_index_build_key(
&self,
wires: &Arc<Vec<WireModel>>,
screen_height_px: f32,
) -> Option<(u64, usize)> {
let key = (self.geometry_epoch, Arc::as_ptr(wires) as usize);
if !self.interaction_source_is_resident(wires, screen_height_px.max(1.0))
|| !self.interaction_index_worthwhile(wires)
|| self.interaction_overlay_base().is_some()
{
return None;
}
if self.cached_interaction_index(wires).is_some() {
if self.interaction_index_pending_key.get() == Some(key) {
self.interaction_index_pending_key.set(None);
}
return None;
}
Some(key)
}
pub fn mark_interaction_index_pending(&self, epoch: u64, source: usize) {
self.interaction_index_pending_key
.set(Some((epoch, source)));
}
/// Install a fully prepared background index only if its geometry/source
/// still belongs to this scene.
pub fn install_prepared_interaction_index(
&self,
epoch: u64,
source: usize,
wires: std::sync::Weak<Vec<WireModel>>,
index: Arc<crate::scene::pick::interaction_index::InteractionIndex>,
) -> bool {
if self.interaction_index_pending_key.get() == Some((epoch, source)) {
self.interaction_index_pending_key.set(None);
}
let Some(wires) = wires.upgrade() else {
return false;
};
if epoch != self.geometry_epoch || Arc::as_ptr(&wires) as usize != source {
return false;
}
self.cache_interaction_index(epoch, wires, index);
true
}
const INTERACTION_OVERLAY_MAX_HANDLES: usize = 2_048;
@@ -5018,6 +5049,7 @@ impl Scene {
&self,
wires: Arc<Vec<WireModel>>,
aabb: [f64; 4],
allow_pending_empty: bool,
) -> crate::scene::pick::interaction_index::InteractionCandidates {
if let Some((base_epoch, base, changes)) = self.interaction_overlay_base() {
let perf = std::env::var_os("OCS_PERF").is_some();
@@ -5057,7 +5089,16 @@ impl Scene {
}
return result;
}
self.interaction_index(&wires).query_xy(wires, aabb)
if let Some(index) = self.cached_interaction_index(&wires) {
index.query_xy(wires, aabb)
} else if allow_pending_empty
&& self.interaction_index_pending_key.get()
== Some((self.geometry_epoch, Arc::as_ptr(&wires) as usize))
{
crate::scene::pick::interaction_index::InteractionCandidates::pending(wires)
} else {
crate::scene::pick::interaction_index::InteractionCandidates::all(wires)
}
}
fn indexed_interaction_candidates_screen(
@@ -5067,6 +5108,7 @@ impl Scene {
view_rot: glam::Mat4,
eye: glam::DVec3,
bounds: iced::Rectangle,
allow_pending_empty: bool,
) -> crate::scene::pick::interaction_index::InteractionCandidates {
if let Some((base_epoch, base, changes)) = self.interaction_overlay_base() {
let perf = std::env::var_os("OCS_PERF").is_some();
@@ -5117,8 +5159,16 @@ impl Scene {
}
return result;
}
self.interaction_index(&wires)
.query_screen(wires, screen_rect, view_rot, eye, bounds)
if let Some(index) = self.cached_interaction_index(&wires) {
index.query_screen(wires, screen_rect, view_rot, eye, bounds)
} else if allow_pending_empty
&& self.interaction_index_pending_key.get()
== Some((self.geometry_epoch, Arc::as_ptr(&wires) as usize))
{
crate::scene::pick::interaction_index::InteractionCandidates::pending(wires)
} else {
crate::scene::pick::interaction_index::InteractionCandidates::all(wires)
}
}
fn indexed_interaction_pick_radius(
@@ -5131,7 +5181,8 @@ impl Scene {
self.interaction_overlay_changed_index(base_epoch, &changes);
base.pick_radius_px(changed.pick_radius_px(base_radius_px))
} else {
self.interaction_index(wires).pick_radius_px(base_radius_px)
self.cached_interaction_index(wires)
.map_or(base_radius_px, |index| index.pick_radius_px(base_radius_px))
}
}
@@ -5141,7 +5192,7 @@ impl Scene {
screen_height_px: f32,
) -> bool {
if self.current_layout == "Model" {
return true;
return Arc::ptr_eq(&self.entity_wires_arc(), wires);
}
match self.active_viewport {
Some(viewport) => {
@@ -5237,7 +5288,7 @@ impl Scene {
radius_px: f32,
) -> crate::scene::pick::interaction_index::InteractionCandidates {
self.interaction_candidates_near_impl(
wires, cursor, view_rot, eye, bounds, radius_px, false,
wires, cursor, view_rot, eye, bounds, radius_px, false, false,
)
}
@@ -5258,6 +5309,28 @@ impl Scene {
bounds,
radius_px,
self.document.header.lineweight_display,
false,
)
}
pub fn interaction_hover_candidates_near(
&self,
wires: Arc<Vec<WireModel>>,
cursor: glam::DVec3,
view_rot: glam::Mat4,
eye: glam::DVec3,
bounds: iced::Rectangle,
radius_px: f32,
) -> crate::scene::pick::interaction_index::InteractionCandidates {
self.interaction_candidates_near_impl(
wires,
cursor,
view_rot,
eye,
bounds,
radius_px,
self.document.header.lineweight_display,
true,
)
}
@@ -5270,6 +5343,7 @@ impl Scene {
bounds: iced::Rectangle,
radius_px: f32,
include_line_weight: bool,
allow_pending_empty: bool,
) -> crate::scene::pick::interaction_index::InteractionCandidates {
if !self.interaction_source_is_resident(&wires, bounds.height)
|| !self.interaction_index_worthwhile(&wires)
@@ -5306,6 +5380,7 @@ impl Scene {
view_rot,
eye,
bounds,
allow_pending_empty,
);
}
let world_x_px = ((view_rot.x_axis.x * bounds.width * 0.5).powi(2)
@@ -5325,7 +5400,7 @@ impl Scene {
cursor.x + radius,
cursor.y + radius,
];
self.indexed_interaction_candidates_xy(wires, query)
self.indexed_interaction_candidates_xy(wires, query, allow_pending_empty)
}
/// Shared rectangular broad phase for box/lasso/fence and command windows.
@@ -5349,9 +5424,16 @@ impl Scene {
return crate::scene::pick::interaction_index::InteractionCandidates::all(wires);
}
if flat_ortho {
self.indexed_interaction_candidates_xy(wires, aabb)
self.indexed_interaction_candidates_xy(wires, aabb, false)
} else {
self.indexed_interaction_candidates_screen(wires, screen_rect, view_rot, eye, bounds)
self.indexed_interaction_candidates_screen(
wires,
screen_rect,
view_rot,
eye,
bounds,
false,
)
}
}
@@ -5363,6 +5445,12 @@ impl Scene {
.iter()
.filter_map(|wire| Self::handle_from_wire_name(&wire.name))
.collect();
if candidates.is_indexed()
&& candidates.query_aabb().is_none()
&& candidates.screen_query().is_none()
{
return Some(handles);
}
if let Some(aabb) = candidates.query_aabb() {
let index = self.entity_index();
handles.extend(index.tree.query_rect(aabb));
@@ -5386,7 +5474,7 @@ impl Scene {
pub fn interaction_handles_in_world_aabb(&self, aabb: [f64; 4]) -> HashSet<Handle> {
let wires = self.hit_test_wires();
let candidates = self.indexed_interaction_candidates_xy(wires, aabb);
let candidates = self.indexed_interaction_candidates_xy(wires, aabb, false);
let mut handles: HashSet<Handle> = candidates
.iter()
.filter_map(|wire| Self::handle_from_wire_name(&wire.name))
@@ -5543,6 +5631,46 @@ impl Scene {
eye: glam::DVec3,
bounds: iced::Rectangle,
candidate_handles: Option<&HashSet<Handle>>,
) -> Option<Handle> {
self.solid_hit(
cursor,
view_rot,
eye,
bounds,
candidate_handles,
false,
)
}
/// Hover may use the coarsest cached solid LOD. Click selection keeps the
/// full-resolution mesh, while rollover only needs a stable parent handle
/// and must stay within an interactive frame budget.
pub fn solid_hover_hit(
&self,
cursor: iced::Point,
view_rot: glam::Mat4,
eye: glam::DVec3,
bounds: iced::Rectangle,
candidate_handles: Option<&HashSet<Handle>>,
) -> Option<Handle> {
self.solid_hit(
cursor,
view_rot,
eye,
bounds,
candidate_handles,
true,
)
}
fn solid_hit(
&self,
cursor: iced::Point,
view_rot: glam::Mat4,
eye: glam::DVec3,
bounds: iced::Rectangle,
candidate_handles: Option<&HashSet<Handle>>,
coarse_lod: bool,
) -> Option<Handle> {
// Reuse the renderer's expanded mesh set (top-level solids + per-INSERT
// block instances), cached per geometry epoch — so hover no longer
@@ -5581,10 +5709,24 @@ impl Scene {
.then_some((handle, set))
}));
}
let candidates = sets
.into_iter()
.filter_map(|(handle, set)| set.lods.first().map(|mesh| (handle, mesh)));
pick::hit_test::mesh_click_hit(cursor, candidates, view_rot, eye, bounds)
if coarse_lod {
return pick::hit_test::mesh_click_hit(
cursor,
sets.iter()
.filter_map(|(handle, set)| set.lods.last().map(|mesh| (*handle, mesh))),
view_rot,
eye,
bounds,
);
}
pick::hit_test::mesh_click_hit(
cursor,
sets.iter()
.filter_map(|(handle, set)| set.lods.first().map(|mesh| (*handle, mesh))),
view_rot,
eye,
bounds,
)
}
/// Parent INSERT handles whose block-internal solid meshes fall in a
+21 -6
View File
@@ -671,6 +671,24 @@ pub fn mesh_click_hit<'a>(
let v = &mesh.verts;
let idx = &mesh.indices;
let lo = &mesh.verts_low;
// Indexed meshes reuse most vertices across several triangles.
// Project once per vertex instead of three matrix transforms per
// triangle; dense solid misses are otherwise the worst-case hover.
let projected: Vec<(Point, f32)> = v
.iter()
.enumerate()
.map(|(i, &vertex)| {
let ndc =
view_rot.project_point3((mesh_vert(vertex, lo, i) - eye).as_vec3());
(
Point::new(
(ndc.x + 1.0) * 0.5 * bounds.width,
(1.0 - ndc.y) * 0.5 * bounds.height,
),
ndc.z,
)
})
.collect();
let mut t = 0;
while t + 2 < idx.len() {
let tri = [idx[t] as usize, idx[t + 1] as usize, idx[t + 2] as usize];
@@ -678,12 +696,9 @@ pub fn mesh_click_hit<'a>(
let mut sp = [Point::ORIGIN; 3];
let mut depth = 0.0f32;
for (j, &k) in tri.iter().enumerate() {
let ndc = view_rot.project_point3((mesh_vert(v[k], lo, k) - eye).as_vec3());
sp[j] = Point::new(
(ndc.x + 1.0) * 0.5 * bounds.width,
(1.0 - ndc.y) * 0.5 * bounds.height,
);
depth += ndc.z;
let (point, z) = projected[k];
sp[j] = point;
depth += z;
}
if point_in_polygon(cursor, &sp) {
let d = depth / 3.0;
+90 -4
View File
@@ -140,12 +140,12 @@ fn build_bvh2_node<T>(
return node_idx;
}
let axis = usize::from(bounds[3] - bounds[1] > bounds[2] - bounds[0]);
order.sort_unstable_by(|&a, &b| {
let mid = order.len() / 2;
order.select_nth_unstable_by(mid, |&a, &b| {
let aa = entry_aabb2(&entries[a as usize]);
let bb = entry_aabb2(&entries[b as usize]);
(aa[axis] + aa[axis + 2]).total_cmp(&(bb[axis] + bb[axis + 2]))
});
let mid = order.len() / 2;
let (left_order, right_order) = order.split_at_mut(mid);
let left = build_bvh2_node(entries, left_order, base, nodes);
let right = build_bvh2_node(entries, right_order, base + mid, nodes);
@@ -253,12 +253,12 @@ fn build_bvh3_node<T>(
axis = candidate;
}
}
order.sort_unstable_by(|&a, &b| {
let mid = order.len() / 2;
order.select_nth_unstable_by(mid, |&a, &b| {
let aa = entries[a as usize].aabb;
let bb = entries[b as usize].aabb;
(aa[axis] + aa[axis + 3]).total_cmp(&(bb[axis] + bb[axis + 3]))
});
let mid = order.len() / 2;
let (left_order, right_order) = order.split_at_mut(mid);
let left = build_bvh3_node(entries, left_order, base, nodes);
let right = build_bvh3_node(entries, right_order, base + mid, nodes);
@@ -520,6 +520,11 @@ impl<T: Copy + Ord + Sync> SpatialSet<T> {
self.xy.query(&self.entries, aabb)
}
fn prepare_screen(&self) {
self.xyz
.get_or_init(|| SpatialBvh3::build(&self.entries));
}
fn query_screen(
&self,
screen_rect: [f32; 4],
@@ -951,6 +956,52 @@ impl InteractionIndex {
}
}
/// Build every projected 3D broad phase before the index reaches the UI
/// thread. Perspective/orbit hover must never pay this one-time cost.
pub fn prepare_screen(&self) {
#[cfg(not(target_arch = "wasm32"))]
rayon::join(
|| {
rayon::join(
|| rayon::join(|| self.wires.prepare_screen(), || self.segments.prepare_screen()),
|| {
rayon::join(
|| self.snap_points.prepare_screen(),
|| self.key_vertices.prepare_screen(),
)
},
)
},
|| {
rayon::join(
|| {
rayon::join(
|| self.key_segments.prepare_screen(),
|| self.fill_triangles.prepare_screen(),
)
},
|| {
rayon::join(
|| self.pick_triangles.prepare_screen(),
|| self.glyphs.prepare_screen(),
)
},
)
},
);
#[cfg(target_arch = "wasm32")]
{
self.wires.prepare_screen();
self.segments.prepare_screen();
self.snap_points.prepare_screen();
self.key_vertices.prepare_screen();
self.key_segments.prepare_screen();
self.fill_triangles.prepare_screen();
self.pick_triangles.prepare_screen();
self.glyphs.prepare_screen();
}
}
pub fn pick_radius_px(&self, base_radius_px: f32) -> f32 {
base_radius_px.max(self.max_line_half_width_px)
}
@@ -1241,6 +1292,17 @@ impl InteractionIndex {
}
}
impl std::fmt::Debug for InteractionIndex {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("InteractionIndex")
.field("wires", &self.wire_handles.len())
.field("segments", &self.segments.entries.len())
.field("fill_triangles", &self.fill_triangles.entries.len())
.field("pick_triangles", &self.pick_triangles.entries.len())
.finish()
}
}
pub struct InteractionCandidates {
wires: Arc<Vec<WireModel>>,
wire_indices: Option<Vec<u32>>,
@@ -1302,6 +1364,30 @@ impl InteractionCandidates {
}
}
/// Empty but explicitly indexed candidate set. Used while a large source
/// is still being prepared off-thread so UI input never falls back to a
/// full-scene scan.
pub fn pending(wires: Arc<Vec<WireModel>>) -> Self {
Self {
wires,
wire_indices: Some(Vec::new()),
segments: Some(Vec::new()),
snap_points: Some(Vec::new()),
key_vertices: Some(Vec::new()),
key_segments: Some(Vec::new()),
fill_triangles: Some(Vec::new()),
pick_triangles: Some(Vec::new()),
glyphs: Some(Vec::new()),
query_aabb: None,
screen_rect: None,
screen_view: None,
}
}
pub fn is_indexed(&self) -> bool {
self.wire_indices.is_some()
}
pub fn iter(&self) -> WireIter<'_> {
match &self.wire_indices {
Some(indices) => WireIter::Indexed {