forked from 1b-agent/opencadstudio-upstream
perf(scene): per-entity tessellation memo for the render path
Phase 2.2. wires_for_block_culled re-tessellated every visible entity on any geometry bump, so a single-entity edit cost a full model re-tess (~30ms baseline after the block_epoch fix). Add a per-entity memo (tess_memo: Handle -> Arc<Vec<WireModel>>) on the culled Model render path. It's guarded by a hash of the tessellation parameters (tol / view / anno / world_offset / bg / entered viewport); a mismatch (zoom, layout switch) clears it. The render path now classifies the visible set against the memo, materializes hits and tessellates only the misses in parallel, then stores the misses back. - bump_geometry clears the memo (structural change — safe default); - incremental edits drop just the changed handle via mark_entity_dirty and bump with bump_geometry_no_blocks: line commit (new handle is a natural miss) and grip commit / Esc-cancel (mark the dragged handle). The hit-test (view_aabb == None), paper and per-viewport paths bypass the memo so their differing cull parameters don't thrash it. A single-entity edit now skips the per-entity geometry math for the entire unchanged set; the Vec assembly + batched-buffer upload stay O(N). Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
7f94a9d66a
commit
eb89717b47
3 files changed
+125
-18
No files matched your search
+17
-1
@@ -119,7 +119,23 @@ read-only over the finished `defns` map and re-walks shared nested defns
|
||||
(no memo), so the per-name resolves fan out across rayon (read phase), then a
|
||||
serial phase stores each AABB back.
|
||||
|
||||
### 2.2 Incremental wire cache (delta tessellation)
|
||||
### 2.2 Incremental wire cache (delta tessellation) ✅ DONE (render path)
|
||||
|
||||
A per-entity tessellation memo (`tess_memo`, `Handle → Arc<Vec<WireModel>>`)
|
||||
sits inside `wires_for_block_culled` on the culled Model render path. It's
|
||||
keyed by a guard hash of the tessellation parameters (tol / view / anno /
|
||||
offset / bg / entered viewport); a mismatch (zoom, layout) clears it.
|
||||
`bump_geometry` clears it (structural change); incremental edits drop just the
|
||||
changed handle via `mark_entity_dirty` and bump with `bump_geometry_no_blocks`.
|
||||
So a single-entity edit (line commit, grip commit/cancel) re-tessellates only
|
||||
that entity and reuses every other — the per-entity geometry math is skipped
|
||||
for the unchanged set. The hit-test (`view_aabb == None`), paper and
|
||||
per-viewport paths bypass the memo so their cull params don't thrash it.
|
||||
|
||||
Remaining: the assembly still concatenates into one `Vec` and rebuilds the
|
||||
batched GPU buffer (O(N) clone + upload), and the uncalled hit-test
|
||||
(`entity_wires_arc`) still rebuilds on edit — driving those to true O(1)
|
||||
needs per-handle GPU slots, which trade against the single-draw batch (3.3).
|
||||
|
||||
`bump_geometry()` invalidates the whole wire cache today
|
||||
([`scene/mod.rs:650`](src/scene/mod.rs#L650)). Edits usually touch 1-2
|
||||
|
||||
@@ -1325,6 +1325,8 @@ impl OpenCADStudio {
|
||||
}
|
||||
self.tabs[i].scene.hidden.remove(&h);
|
||||
self.tabs[i].scene.clear_preview_wire();
|
||||
// Geometry restored to the backup — re-tessellate just it.
|
||||
self.tabs[i].scene.mark_entity_dirty(h);
|
||||
self.tabs[i].scene.bump_geometry_no_blocks();
|
||||
self.refresh_selected_grips();
|
||||
}
|
||||
@@ -2652,6 +2654,8 @@ impl OpenCADStudio {
|
||||
self.grip_original = None;
|
||||
self.tabs[i].scene.hidden.remove(&h);
|
||||
self.tabs[i].scene.clear_preview_wire();
|
||||
// Only the dragged entity changed — re-tessellate just it.
|
||||
self.tabs[i].scene.mark_entity_dirty(h);
|
||||
self.tabs[i].scene.bump_geometry_no_blocks();
|
||||
}
|
||||
// Placement confirmed — keep the just-added leader.
|
||||
|
||||
+104
-17
@@ -775,6 +775,17 @@ pub struct Scene {
|
||||
/// `selection_generation`. Rebuilt only when the selection changes so
|
||||
/// `build_primitive` doesn't clone the set every frame.
|
||||
highlight_set_cache: RefCell<Option<(u64, Arc<HashSet<Handle>>)>>,
|
||||
/// Per-entity tessellation memo for the culled Model render path (Phase
|
||||
/// 2.2). Maps a top-level handle to its already-tessellated wires so a
|
||||
/// single-entity edit re-tessellates only the changed entity and reuses the
|
||||
/// rest, instead of re-running the whole model. Keyed implicitly by
|
||||
/// `tess_memo_guard` (tol / view / anno / offset / bg); a guard mismatch
|
||||
/// (zoom, layout, …) clears it. `bump_geometry` clears it (structural
|
||||
/// change); `mark_entity_dirty` drops one handle (incremental edit).
|
||||
tess_memo: RefCell<HashMap<Handle, Arc<Vec<WireModel>>>>,
|
||||
/// Hash of the tessellation parameters `tess_memo` was built under. When
|
||||
/// the current call's parameters differ, the memo is stale and cleared.
|
||||
tess_memo_guard: std::cell::Cell<u64>,
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
@@ -840,6 +851,8 @@ impl Scene {
|
||||
wire_force_nonce: std::cell::Cell::new(0),
|
||||
split_cache: RefCell::new(None),
|
||||
highlight_set_cache: RefCell::new(None),
|
||||
tess_memo: RefCell::new(HashMap::default()),
|
||||
tess_memo_guard: std::cell::Cell::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -954,6 +967,15 @@ impl Scene {
|
||||
// Default: also invalidate block definitions. Safe for every caller;
|
||||
// operations that know blocks are untouched use `bump_geometry_no_blocks`.
|
||||
self.block_epoch = GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed);
|
||||
// Structural change — drop the whole per-entity tessellation memo.
|
||||
self.tess_memo.borrow_mut().clear();
|
||||
}
|
||||
|
||||
/// Drop a single entity from the tessellation memo so the next render
|
||||
/// re-tessellates just that entity while reusing every other. Pair with
|
||||
/// [`bump_geometry_no_blocks`] for an incremental single-entity edit.
|
||||
pub fn mark_entity_dirty(&mut self, handle: Handle) {
|
||||
self.tess_memo.borrow_mut().remove(&handle);
|
||||
}
|
||||
|
||||
/// Invalidate the visible-wire tessellation but KEEP the cached block
|
||||
@@ -2243,23 +2265,88 @@ impl Scene {
|
||||
crate::scene::truck_tess::set_curve_tol_override(Some((w * 0.5) as f64));
|
||||
CurveTolGuard
|
||||
});
|
||||
let mut wires: Vec<WireModel> = visible
|
||||
.into_par_iter()
|
||||
.flat_map(|e| {
|
||||
tessellate_entity(
|
||||
doc,
|
||||
sel,
|
||||
avp,
|
||||
woff,
|
||||
bg,
|
||||
anno,
|
||||
e,
|
||||
Some(blk_ref),
|
||||
view_aabb,
|
||||
wpp,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
// Per-entity tessellation memo (Phase 2.2) — only on the culled Model
|
||||
// render path. A single-entity edit re-tessellates just the changed
|
||||
// entity (dropped from the memo via `mark_entity_dirty`) and reuses the
|
||||
// rest, instead of re-running every visible entity. The hit-test path
|
||||
// (`view_aabb == None`) and paper / per-viewport paths bypass it so
|
||||
// their different cull parameters don't thrash the memo.
|
||||
let memo_active = view_aabb.is_some()
|
||||
&& is_model_block
|
||||
&& frozen_layers.is_none()
|
||||
&& anno_scale_override.is_none();
|
||||
let mut wires: Vec<WireModel> = if memo_active {
|
||||
// Guard hash of everything tessellate_entity output depends on
|
||||
// besides the entity itself. A mismatch (zoom/tol, view, anno,
|
||||
// offset, bg, entered viewport) means the memo is stale.
|
||||
let guard = {
|
||||
let mut g: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
let mut mix = |x: u64| g = g.rotate_left(13) ^ x;
|
||||
mix(wpp.map(|w| w.to_bits() as u64).unwrap_or(u64::MAX));
|
||||
if let Some(v) = view_aabb {
|
||||
for c in v {
|
||||
mix(c.to_bits() as u64);
|
||||
}
|
||||
}
|
||||
mix(anno.to_bits() as u64);
|
||||
for c in woff {
|
||||
mix(c.to_bits());
|
||||
}
|
||||
for c in bg {
|
||||
mix(c.to_bits() as u64);
|
||||
}
|
||||
mix(avp.map(|h| h.value()).unwrap_or(0));
|
||||
g
|
||||
};
|
||||
if self.tess_memo_guard.get() != guard {
|
||||
self.tess_memo.borrow_mut().clear();
|
||||
self.tess_memo_guard.set(guard);
|
||||
}
|
||||
// Classify (serial, cheap): reuse memoized Arcs, collect misses.
|
||||
let mut hit_arcs: Vec<Arc<Vec<WireModel>>> = Vec::new();
|
||||
let mut misses: Vec<&EntityType> = Vec::new();
|
||||
{
|
||||
let memo = self.tess_memo.borrow();
|
||||
for e in &visible {
|
||||
let h = e.common().handle;
|
||||
match memo.get(&h) {
|
||||
Some(a) => hit_arcs.push(Arc::clone(a)),
|
||||
None => misses.push(*e),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Materialize hits + tessellate misses, both in parallel.
|
||||
let hit_wires: Vec<WireModel> =
|
||||
hit_arcs.par_iter().flat_map_iter(|a| a.iter().cloned()).collect();
|
||||
let miss_pairs: Vec<(Handle, Arc<Vec<WireModel>>)> = misses
|
||||
.par_iter()
|
||||
.map(|e| {
|
||||
let e: &EntityType = e;
|
||||
let w = tessellate_entity(
|
||||
doc, sel, avp, woff, bg, anno, e, Some(blk_ref), view_aabb, wpp,
|
||||
);
|
||||
(e.common().handle, Arc::new(w))
|
||||
})
|
||||
.collect();
|
||||
let mut out = hit_wires;
|
||||
{
|
||||
let mut memo = self.tess_memo.borrow_mut();
|
||||
for (h, a) in &miss_pairs {
|
||||
out.extend(a.iter().cloned());
|
||||
memo.insert(*h, Arc::clone(a));
|
||||
}
|
||||
}
|
||||
out
|
||||
} else {
|
||||
visible
|
||||
.into_par_iter()
|
||||
.flat_map(|e| {
|
||||
tessellate_entity(
|
||||
doc, sel, avp, woff, bg, anno, e, Some(blk_ref), view_aabb, wpp,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Apply draw order via the cached index (O(1) block lookup).
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user