From de6e27304d66b5c49f79f1695018e0b0d9ab5fab Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 5 Oct 2026 15:53:23 +0300 Subject: [PATCH] perf(layers): toggle layer visibility without re-tessellating Turning a 141k-entity layer off/on took 1.5-5 s; it now takes ~0.6-0.9 s. - Off/freeze toggles go through invalidate_layer_visibility: entities on the layer keep their tessellation memo (visibility gates them before tessellation); only block users re-expand. No mesh recolor. - The resident patch reuses a surviving memo entry instead of re-tessellating, guarded by the same display-context hash as the full build. - The patch looked up `layout.order` linearly per re-shown entity, which made turning a big layer back on quadratic. - recolor_meshes_for_handles resolved materials for handles with no mesh. - Wire arena: a hidden slab keeps its instances and sets a `hidden` flag in its consts (collapsed in the vertex shader); an unchanged re-show only clears the flag. Const writes are batched into merged spans. - The `layer_visible` automation action indexed the document's layer table instead of the sortable panel rows the Layer* messages use. Co-Authored-By: Claude Opus 5.5 --- src/app/commands/layers.rs | 12 +-- src/app/control/actions.rs | 4 +- src/app/update/mod.rs | 4 +- src/scene/mod.rs | 157 ++++++++++++++++++++++++------- src/scene/pipeline/wire_arena.rs | 101 ++++++++++++++++---- src/scene/pipeline/wire_gpu.rs | 6 +- src/shaders/wire_indexed.wgsl | 8 +- 7 files changed, 228 insertions(+), 64 deletions(-) diff --git a/src/app/commands/layers.rs b/src/app/commands/layers.rs index 565180c8d..22ce5eb3a 100644 --- a/src/app/commands/layers.rs +++ b/src/app/commands/layers.rs @@ -33,7 +33,7 @@ impl OpenCADStudio { dl.turn_off(); } } - self.tabs[i].scene.invalidate_layer_dependencies(&names); + self.tabs[i].scene.invalidate_layer_visibility(&names); self.tabs[i].dirty = true; self.commit_layer_undo(i, undo); self.refresh_layer_panel(); @@ -70,7 +70,7 @@ impl OpenCADStudio { dl.freeze(); } } - self.tabs[i].scene.invalidate_layer_dependencies(&names); + self.tabs[i].scene.invalidate_layer_visibility(&names); self.tabs[i].dirty = true; self.commit_layer_undo(i, undo); self.refresh_layer_panel(); @@ -440,7 +440,7 @@ impl OpenCADStudio { dl.turn_on(); } } - self.tabs[i].scene.invalidate_layer_dependencies(&names); + self.tabs[i].scene.invalidate_layer_visibility(&names); self.tabs[i].dirty = true; self.commit_layer_undo(i, undo); self.refresh_layer_panel(); @@ -461,7 +461,7 @@ impl OpenCADStudio { dl.thaw(); } } - self.tabs[i].scene.invalidate_layer_dependencies(&names); + self.tabs[i].scene.invalidate_layer_visibility(&names); self.tabs[i].dirty = true; self.commit_layer_undo(i, undo); self.refresh_layer_panel(); @@ -530,7 +530,7 @@ impl OpenCADStudio { } } } - self.tabs[i].scene.invalidate_layer_dependencies(&names); + self.tabs[i].scene.invalidate_layer_visibility(&names); self.tabs[i].dirty = true; self.commit_layer_undo(i, undo); self.refresh_layer_panel(); @@ -620,7 +620,7 @@ impl OpenCADStudio { dl.turn_on(); } } - self.tabs[i].scene.invalidate_layer_dependencies(&names); + self.tabs[i].scene.invalidate_layer_visibility(&names); self.tabs[i].dirty = true; self.commit_layer_undo(i, undo); self.refresh_layer_panel(); diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index a6724bbd5..0cfbf624c 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -1227,9 +1227,9 @@ impl OpenCADStudio { "redo" => Message::Redo, "layer_visible" | "layer_locked" | "layer_frozen" | "layer_current" => { let layer = string(req, "layer")?; + // The Layer* messages index the panel's (sortable) row list. let index = self.tabs[self.active_tab] - .scene - .document + .layers .layers .iter() .position(|l| l.name == layer) diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 70c160a1b..597c77c7c 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -3338,7 +3338,7 @@ impl OpenCADStudio { pl.visible = on; } } - self.tabs[i].scene.invalidate_layer_dependencies(&targets); + self.tabs[i].scene.invalidate_layer_visibility(&targets); self.tabs[i].dirty = true; self.commit_layer_undo(i, undo); self.command_line.push_output( @@ -3426,7 +3426,7 @@ impl OpenCADStudio { pl.frozen = frozen; } } - self.tabs[i].scene.invalidate_layer_dependencies(&targets); + self.tabs[i].scene.invalidate_layer_visibility(&targets); self.tabs[i].dirty = true; self.commit_layer_undo(i, undo); self.command_line.push_output( diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 2a6b45ee7..67335f4bb 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -500,10 +500,53 @@ pub(super) struct EntityIndex { pub unbounded_handles: Vec, } +/// Hash of every baked display input a memoized tessellation depends on; a +/// memo entry is reusable only under the guard it was built with. +fn tess_memo_guard( + view_aabb: Option<[f32; 4]>, + anno: f32, + annotation_scale_handle: Option, + all_visible: bool, + bg: [f32; 4], + avp: Option, +) -> u64 { + let mut g: u64 = 0xcbf2_9ce4_8422_2325; + let mut mix = |x: u64| g = g.rotate_left(13) ^ x; + // wpp removed from guard: GPU analytical rendering handles + // circles/arcs/ellipses, Point ignores wpp, and Light (the + // only remaining wpp consumer) is rare enough that its stale + // glyphs don't justify clearing every memoized entity on zoom. + if let Some(v) = view_aabb { + for c in v { + mix(c.to_bits() as u64); + } + } + mix(anno.to_bits() as u64); + mix(annotation_scale_handle + .map(|handle| handle.value()) + .unwrap_or(0)); + mix(all_visible as u64); + // Direct entities and inherited/faded block colours still bake + // the background before Batches::finalize records its inputs. + for channel in bg { + mix(channel.to_bits() as u64); + } + mix(avp.map(|h| h.value()).unwrap_or(0)); + // SDF glyph quads bake the atlas UV of each tile, so a growth or + // a re-bake (which rescale / rewind every UV) makes memoized text + // address the wrong tile — garbage on screen, and a silent miss in + // the PDF export's glyph lookup (#385 under #347's conditions). + mix(crate::scene::text::sdf_atlas::generation()); + g +} + #[derive(Clone, Default)] struct DependencyTargets { render_handles: HashSet, source_handles: HashSet, + /// Render handles reached through a block definition (INSERTs and other + /// block users), as opposed to entities that carry the name themselves. + block_users: HashSet, touches_block_definition: bool, } @@ -4020,6 +4063,9 @@ impl Scene { let bg = self.bg_color; let materials: HashMap = handles .iter() + .filter(|handle| { + self.meshes.contains_key(handle) || self.block_meshes.contains_key(handle) + }) .filter_map(|&handle| { self.document.get_entity(handle).map(|entity| { let color = self.render_style(entity).0; @@ -6706,6 +6752,12 @@ impl Scene { let mut new_runs: HashMap> = HashMap::default(); let mut memo_updates: Vec<(Handle, Arc>)> = Vec::new(); let mut visible_changed: HashSet = HashSet::default(); + // Every geometry edit drops its handle's memo entry, so an entry that + // survived its delta marks a visibility-only change: its wires come + // back from the memo, provided it was built under this display context. + let avp = style_viewport.or(self.active_viewport); + let memo_ok = self.resident_tess_guard.get() + == tess_memo_guard(None, anno, annotation_scale_handle, all_visible, bg, avp); for (h, kind) in &deltas { if matches!(kind, ChangeKind::Removed) { continue; @@ -6723,10 +6775,19 @@ impl Scene { continue; } visible_changed.insert(*h); + let memoized = memo_ok + .then(|| self.resident_tess_memo.borrow().get(h).cloned()) + .flatten(); + if let Some(raw) = memoized { + let mut faded = raw.as_ref().clone(); + self.apply_refedit_fade(&mut faded, bg); + new_runs.insert(*h, faded); + continue; + } let mut raw = tessellate_entity( &self.document, &empty_sel, - style_viewport.or(self.active_viewport), + avp, bg, anno, annotation_scale_handle, @@ -6777,13 +6838,20 @@ impl Scene { .copied() .unwrap_or(handle.value()) }; + // Hashed once: a linear `order.contains` per re-shown entity made + // turning a big layer back on quadratic. + let ordered: HashSet = if visible_changed.is_empty() { + HashSet::default() + } else { + layout.order.iter().copied().collect() + }; for &(handle, kind) in &deltas { if matches!(kind, ChangeKind::Removed) { // Keep the order entry as a tombstone. Undo/Redo can then // restore the exact physical slot and submission position. continue; } - if visible_changed.contains(&handle) && !layout.order.contains(&handle) { + if visible_changed.contains(&handle) && !ordered.contains(&handle) { let key = effective(handle); let position = layout .order @@ -10748,36 +10816,8 @@ impl Scene { let t_build = perf.then(iced::time::Instant::now); let mut wires: Vec = if memo_active { // Invalidate when any baked display input changes. - let guard = { - let mut g: u64 = 0xcbf2_9ce4_8422_2325; - let mut mix = |x: u64| g = g.rotate_left(13) ^ x; - // wpp removed from guard: GPU analytical rendering handles - // circles/arcs/ellipses, Point ignores wpp, and Light (the - // only remaining wpp consumer) is rare enough that its stale - // glyphs don't justify clearing every memoized entity on zoom. - if let Some(v) = view_aabb { - for c in v { - mix(c.to_bits() as u64); - } - } - mix(anno.to_bits() as u64); - mix(annotation_scale_handle - .map(|handle| handle.value()) - .unwrap_or(0)); - mix(all_visible as u64); - // Direct entities and inherited/faded block colours still bake - // the background before Batches::finalize records its inputs. - for channel in bg { - mix(channel.to_bits() as u64); - } - mix(avp.map(|h| h.value()).unwrap_or(0)); - // SDF glyph quads bake the atlas UV of each tile, so a growth or - // a re-bake (which rescale / rewind every UV) makes memoized text - // address the wrong tile — garbage on screen, and a silent miss in - // the PDF export's glyph lookup (#385 under #347's conditions). - mix(crate::scene::text::sdf_atlas::generation()); - g - }; + let guard = + tess_memo_guard(view_aabb, anno, annotation_scale_handle, all_visible, bg, avp); let (memo_cell, guard_cell) = if resident { (&self.resident_tess_memo, &self.resident_tess_guard) } else { @@ -11369,6 +11409,9 @@ vis_index={:.1} visible_probe={:.1}", target.render_handles.extend(render_handles.iter().copied()); target.source_handles.insert(common.handle); target.touches_block_definition |= inside_block; + if inside_block { + target.block_users.extend(render_handles.iter().copied()); + } }; if matches!(entity, EntityType::Point(_)) { extend_category(&mut index.points); @@ -11399,6 +11442,9 @@ vis_index={:.1} visible_probe={:.1}", target.render_handles.extend(render_handles.iter().copied()); target.source_handles.insert(common.handle); target.touches_block_definition |= inside_block; + if inside_block { + target.block_users.extend(render_handles.iter().copied()); + } }; let add_handle = |map: &mut HashMap, handle: Option| { @@ -11409,6 +11455,9 @@ vis_index={:.1} visible_probe={:.1}", target.render_handles.extend(render_handles.iter().copied()); target.source_handles.insert(common.handle); target.touches_block_definition |= inside_block; + if inside_block { + target.block_users.extend(render_handles.iter().copied()); + } }; add(&mut index.layers, &common.layer); match entity { @@ -11541,6 +11590,9 @@ vis_index={:.1} visible_probe={:.1}", combined .source_handles .extend(target.source_handles.iter().copied()); + combined + .block_users + .extend(target.block_users.iter().copied()); combined.touches_block_definition |= target.touches_block_definition; } combined @@ -11563,6 +11615,47 @@ vis_index={:.1} visible_probe={:.1}", self.bump_entities_restyled(&changes); } + /// Off/freeze toggle on `names`, nothing else changed. Visibility gates an + /// entity before it is tessellated, so entities carrying these layers keep + /// their tessellation memo and only leave or re-enter the resident set; + /// block users re-expand, since their definitions bake the children's + /// visibility. Nothing is recoloured. + pub fn invalidate_layer_visibility(&mut self, names: &[String]) { + let targets = self.dependency_targets(DependencyKind::Layer, names); + if targets.render_handles.is_empty() { + return; + } + if targets.touches_block_definition { + self.block_epoch = GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed); + } + let kept: Vec<_> = { + let mut tm = self.tess_memo.borrow_mut(); + let mut rm = self.resident_tess_memo.borrow_mut(); + targets + .render_handles + .iter() + .filter(|handle| !targets.block_users.contains(handle)) + .map(|&handle| (handle, tm.remove(&handle), rm.remove(&handle))) + .collect() + }; + let changes: Vec<(Handle, ChangeKind)> = targets + .render_handles + .iter() + .map(|&handle| (handle, ChangeKind::Modified)) + .collect(); + self.bump_entities_restyled(&changes); + let mut tm = self.tess_memo.borrow_mut(); + let mut rm = self.resident_tess_memo.borrow_mut(); + for (handle, culled, resident) in kept { + if let Some(wires) = culled { + tm.insert(handle, wires); + } + if let Some(wires) = resident { + rm.insert(handle, wires); + } + } + } + pub fn invalidate_layer_dependencies(&mut self, names: &[String]) { let targets = self.dependency_targets(DependencyKind::Layer, names); self.invalidate_dependency_targets(targets); diff --git a/src/scene/pipeline/wire_arena.rs b/src/scene/pipeline/wire_arena.rs index cdd9c7f36..563047eee 100644 --- a/src/scene/pipeline/wire_arena.rs +++ b/src/scene/pipeline/wire_arena.rs @@ -63,11 +63,16 @@ struct Slab { /// the whole slab by the base-depth delta instead of flattening those /// offsets. base_depth: f32, + /// `slab_content` of what the slab holds, so re-showing a hidden entity + /// with unchanged geometry only clears its `hidden` flag. Unused (0) by + /// the packed arena. + content: u64, } struct PreparedPatchRun { insts: Vec, csts: Vec, + content: u64, base_depth: f32, aabb: [f32; 4], order_sensitive: bool, @@ -431,6 +436,38 @@ fn blank_const() -> WireConst { blank } +/// Position-independent id of an emitted slab: its instances with slab-local +/// wire ids, then its consts with the visibility flag cleared. +fn slab_content(insts: &[WireInstance], const_off: u32, csts: &[WireConst]) -> u64 { + use std::hash::Hasher; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + for inst in insts { + let mut local = *inst; + local.wire_id -= const_off; + hasher.write(bytemuck::bytes_of(&local)); + } + for cst in csts { + let mut shown = *cst; + shown.hidden = 0.0; + hasher.write(bytemuck::bytes_of(&shown)); + } + hasher.finish() +} + +/// Merge const index ranges whose gap is small enough that one write beats two. +fn merge_const_ranges(mut ranges: Vec<(u32, u32)>) -> Vec<(u32, u32)> { + const GAP: u32 = 256; + ranges.sort_unstable(); + let mut merged: Vec<(u32, u32)> = Vec::with_capacity(ranges.len()); + for (start, end) in ranges { + match merged.last_mut() { + Some(last) if start <= last.1 + GAP => last.1 = last.1.max(end), + _ => merged.push((start, end)), + } + } + merged +} + /// A tombstoned instance. Const slot 0 carries the shader discard sentinel. fn blank_instance() -> WireInstance { WireInstance { @@ -485,6 +522,7 @@ impl WireArena { const_off: u32, base_depth: f32, aabb: [f32; 4], + content: u64, instances: Vec, consts: Vec, } @@ -540,6 +578,7 @@ impl WireArena { const_off: plan.const_off, base_depth: plan.base_depth, aabb: run_aabb(run), + content: slab_content(&instances, plan.const_off, &consts), instances, consts, } @@ -574,6 +613,7 @@ impl WireArena { const_len, aabb: packed_slab.aabb, base_depth: packed_slab.base_depth, + content: packed_slab.content, }, ); } @@ -698,11 +738,13 @@ impl WireArena { } } + let content = slab_content(&insts, 0, &csts); prepared.insert( h, PreparedPatchRun { insts, csts, + content, base_depth: if self.mesh_edge { 0.0 } else { @@ -714,16 +756,25 @@ impl WireArena { ); } + // Const edits are mirrored on the CPU and uploaded once at the end in + // merged spans: a layer toggle touches ~10^5 slabs, and a write per + // slab cost more than the change itself. + let mut dirty_consts: Vec<(u32, u32)> = Vec::new(); for &(h, kind) in changes { let run = runs.get(&h).map(Vec::as_slice).unwrap_or(&[]); - // Removed / now-hidden ⇒ tombstone the slab. A handle not in THIS - // arena's subset (it belongs to the other batch) simply isn't in its - // slabs, so this is a no-op for it. + // Removed / now-hidden ⇒ tombstone the slab by flagging its consts + // hidden; the instances stay put for an unchanged re-show. A handle + // not in THIS arena's subset (it belongs to the other batch) simply + // isn't in its slabs, so this is a no-op for it. if matches!(kind, ChangeKind::Removed) || run.is_empty() { if let Some(slab) = self.slabs.remove(&h) { - let blanks = vec![blank_instance(); slab.inst_len as usize]; - self.write_insts(queue, slab.inst_off, &blanks); + let start = slab.const_off as usize; + let end = start + slab.const_len as usize; + for cst in &mut self.consts_cpu[start..end] { + cst.hidden = 1.0; + } + dirty_consts.push((slab.const_off, slab.const_off + slab.const_len)); self.tombstoned += slab.inst_len; if matches!(kind, ChangeKind::Modified) { self.vacant.insert(h, slab); @@ -738,6 +789,7 @@ impl WireArena { let PreparedPatchRun { mut insts, csts, + content, base_depth, aabb, order_sensitive: run_order_sensitive, @@ -764,29 +816,27 @@ impl WireArena { .unwrap_or(false); if in_place { - let (inst_off, const_off) = { + let (inst_off, const_off, same_content) = { let s = self.slabs.get(&h).unwrap(); - (s.inst_off, s.const_off) + (s.inst_off, s.const_off, s.content == content) }; - for w in insts.iter_mut() { - w.wire_id += const_off; + if !same_content { + for w in insts.iter_mut() { + w.wire_id += const_off; + } + self.write_insts(queue, inst_off, &insts); } - self.write_insts(queue, inst_off, &insts); for (k, c) in csts.iter().enumerate() { self.consts_cpu[const_off as usize + k] = *c; } - // Push the entity's consts to the GPU too — an in-place edit is - // NOT structural, so the whole-buffer refresh below won't run and - // a colour change would otherwise never reach the shader. - let csz = std::mem::size_of::() as u64; - queue.write_buffer( - &self.const_buf, - const_off as u64 * csz, - bytemuck::cast_slice(&csts), - ); + // The consts reach the GPU in the merged upload below — an + // in-place edit is NOT structural, so without it a colour change + // or a re-show would never reach the shader. + dirty_consts.push((const_off, const_off + const_len)); let slab = self.slabs.get_mut(&h).unwrap(); slab.base_depth = base_depth; slab.aabb = aabb; + slab.content = content; continue; } @@ -830,6 +880,7 @@ impl WireArena { slab.const_len = const_len; slab.base_depth = base_depth; slab.aabb = aabb; + slab.content = content; self.order_sensitive |= run_order_sensitive; continue; } @@ -887,11 +938,20 @@ impl WireArena { const_len, aabb, base_depth, + content, }, ); self.order_sensitive |= run_order_sensitive; } + for (start, end) in merge_const_ranges(dirty_consts) { + let size = std::mem::size_of::() as u64; + queue.write_buffer( + &self.const_buf, + start as u64 * size, + bytemuck::cast_slice(&self.consts_cpu[start as usize..end as usize]), + ); + } if self.tombstoned > self.inst_tail / 2 { return false; } @@ -1187,6 +1247,7 @@ impl PackedWireArena { const_len: 0, aabb: packed_slab.aabb, base_depth: packed_slab.base_depth, + content: 0, }, ); } @@ -1386,6 +1447,7 @@ impl PackedWireArena { const_len: 0, aabb, base_depth, + content: 0, }, ); self.order_sensitive |= run_order_sensitive; @@ -1588,6 +1650,7 @@ mod tests { const_len: 2, aabb: [0.0; 4], base_depth: 0.0, + content: 0, } } diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index ee360adc4..38681c578 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -118,7 +118,9 @@ pub struct WireConst { /// in drawing units. pub world_half_width: f32, pub is_tapered: f32, - pub _pad2: f32, + /// Non-zero hides every segment of the wire: arena slabs of hidden + /// entities stay resident and are collapsed by the vertex shader. + pub hidden: f32, /// Point-marker origin as a double-single pair. `marker_normal_scale.w` /// stores the viewport-height percentage; zero disables marker scaling. pub marker_origin_high: [f32; 4], @@ -776,7 +778,7 @@ pub(crate) fn emit_wire_native( align_total, world_half_width: wire.world_width * 0.5, is_tapered: if is_tapered { 1.0 } else { 0.0 }, - _pad2: 0.0, + hidden: 0.0, marker_origin_high, marker_origin_low, marker_normal_scale, diff --git a/src/shaders/wire_indexed.wgsl b/src/shaders/wire_indexed.wgsl index 680e0bcd4..162fdbcd2 100644 --- a/src/shaders/wire_indexed.wgsl +++ b/src/shaders/wire_indexed.wgsl @@ -33,7 +33,7 @@ struct WireConst { align_total: f32, world_half_width: f32, is_tapered: f32, - _pad2: f32, + hidden: f32, marker_origin_high: vec4, marker_origin_low: vec4, marker_normal_scale: vec4, @@ -244,6 +244,12 @@ fn marker_relative(position_high: vec3, position_low: vec3, c: WireCon out.min_elem = min_elem; out.align_end = c.align_end * lt_scale; out.align_total = c.align_total; + // A hidden entity's arena slab stays resident; collapse it outside the + // clip volume and take the tombstone discard. + if c.hidden != 0.0 { + out.clip_pos = vec4(0.0, 0.0, -2.0, 1.0); + out.pattern_length = -1.0; + } return out; }