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 <noreply@anthropic.com>
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

+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,