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 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 5.5 committed 2026-10-05 15:53:27 +03:00
1 parent c35b902f3a
commit de6e27304d
7 files changed
+228 -64

No files matched your search

+6 -6
View File
@@ -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();
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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(
+125 -32
View File
@@ -500,10 +500,53 @@ pub(super) struct EntityIndex {
pub unbounded_handles: Vec<Handle>,
}
/// 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<Handle>,
all_visible: bool,
bg: [f32; 4],
avp: Option<Handle>,
) -> 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<Handle>,
source_handles: HashSet<Handle>,
/// Render handles reached through a block definition (INSERTs and other
/// block users), as opposed to entities that carry the name themselves.
block_users: HashSet<Handle>,
touches_block_definition: bool,
}
@@ -4020,6 +4063,9 @@ impl Scene {
let bg = self.bg_color;
let materials: HashMap<Handle, crate::scene::model::material_model::MeshMaterial> = 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<Handle, Vec<WireModel>> = HashMap::default();
let mut memo_updates: Vec<(Handle, Arc<Vec<WireModel>>)> = Vec::new();
let mut visible_changed: HashSet<Handle> = 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<Handle> = 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<WireModel> = 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, DependencyTargets>,
handle: Option<Handle>| {
@@ -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);
+82 -19
View File
@@ -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<WireInstance>,
csts: Vec<WireConst>,
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<WireInstance>,
consts: Vec<WireConst>,
}
@@ -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::<WireConst>() 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::<WireConst>() 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,
}
}
+4 -2
View File
@@ -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,
+7 -1
View File
@@ -33,7 +33,7 @@ struct WireConst {
align_total: f32,
world_half_width: f32,
is_tapered: f32,
_pad2: f32,
hidden: f32,
marker_origin_high: vec4<f32>,
marker_origin_low: vec4<f32>,
marker_normal_scale: vec4<f32>,
@@ -244,6 +244,12 @@ fn marker_relative(position_high: vec3<f32>, position_low: vec3<f32>, 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<f32>(0.0, 0.0, -2.0, 1.0);
out.pattern_length = -1.0;
}
return out;
}