perf(render): arena on by default; trim per-edit resident + text work

Three cuts to the per-edit cost, on top of the incremental wire arena:

- Enable OCS_WIRE_GPU_PATCH by default (kill-switch OCS_WIRE_GPU_PATCH=0), so the
  in-place / append-tombstone arena — validated on the reference drawing (wire
  block ~300ms + 265MB upload -> ~2ms) — is on for everyone; a bail always falls
  back to the identical batched path.

- Resident patch: an all-Modified edit never reorders, so reassemble by walking
  the recorded assembled order (~10k handles) instead of scanning the whole
  document (~800k) to re-derive draw order. Add/Remove still scan (positions
  change). ~14ms -> <1ms on a move.

- SDF text: a new content id misses the glyph cache every edit, but if no
  text-bearing entity (Text/MText/Dimension/MLeader/Leader/Table/attribute/INSERT)
  changed since the last build the glyphs are identical, so reuse them. ~23ms -> 0
  on a plain-geometry edit.

A move/edit on this 800k-entity drawing now spends ~50ms (dominated by the undo
document clone) instead of ~85ms, and the wire GPU upload is gone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-15 10:14:44 +03:00
1 parent f12100d783
commit aea1fd2caa
2 files changed
+104 -19

No files matched your search

+77 -14
View File
@@ -129,7 +129,14 @@ const GEOMETRY_JOURNAL_CAP: usize = 256;
pub(crate) fn wire_gpu_patch_enabled() -> bool {
use std::sync::OnceLock;
static ON: OnceLock<bool> = OnceLock::new();
*ON.get_or_init(|| std::env::var_os("OCS_WIRE_GPU_PATCH").is_some())
// On by default; `OCS_WIRE_GPU_PATCH=0` (or off / false / no) reverts to the
// batched full-upload path as a kill-switch.
*ON.get_or_init(|| {
!matches!(
std::env::var("OCS_WIRE_GPU_PATCH").ok().as_deref(),
Some("0") | Some("off") | Some("false") | Some("no")
)
})
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn wire_gpu_patch_enabled() -> bool {
@@ -726,6 +733,13 @@ pub struct Scene {
sdf_text_cache: RefCell<
HashMap<u64, std::sync::Arc<Vec<crate::scene::pipeline::text_gpu::TextVertex>>>,
>,
/// `(geometry_epoch, gathered text)` of the most recent SDF-text build. When a
/// new content id misses the cache but the journal shows no text-bearing
/// entity changed since this epoch, the same glyphs are reused instead of
/// re-walking every wire — an edit on plain geometry never rebuilds the text.
last_sdf_text: RefCell<
Option<(u64, std::sync::Arc<Vec<crate::scene::pipeline::text_gpu::TextVertex>>)>,
>,
/// pane_grid layout tree for the Model tab — the source of truth for the
/// tile split layout, resize and focus. `model_tiles` (the renderer's
/// per-pane data: camera / render-mode / grid) is kept in lock-step with
@@ -1025,6 +1039,7 @@ impl Scene {
}]),
active_model_tile: std::cell::Cell::new(0),
sdf_text_cache: RefCell::new(HashMap::default()),
last_sdf_text: RefCell::new(None),
// One pane mapped to tile 0 — matches the single default tile above.
model_panes: iced::widget::pane_grid::State::new(0).0,
selection: Rc::new(RefCell::new(SelectionState::default())),
@@ -1295,6 +1310,33 @@ impl Scene {
}
}
/// True when no text-bearing entity changed since `last_epoch`, so cached SDF
/// glyphs stay valid. Text comes from Text / MText / Dimension / MultiLeader /
/// Leader / Table / attributes and from block references (their baked text
/// moves with the instance) — an edit to any of those, or any removal,
/// invalidates it; a plain line / arc / polyline edit does not.
fn text_unchanged(&self, last_epoch: u64) -> bool {
match self.replay_since(last_epoch) {
Some(deltas) => deltas.iter().all(|&(h, k)| {
k != ChangeKind::Removed
&& !matches!(
self.document.get_entity(h),
Some(
EntityType::Text(_)
| EntityType::MText(_)
| EntityType::Dimension(_)
| EntityType::MultiLeader(_)
| EntityType::Leader(_)
| EntityType::Table(_)
| EntityType::AttributeEntity(_)
| EntityType::Insert(_)
)
)
}),
None => false,
}
}
/// Report the exact handles a mutation changed. Folds the memo drop and the
/// epoch bump so a call site can't bump geometry without recording what
/// changed — the delta lets every derived cache patch per-handle instead of
@@ -2761,6 +2803,10 @@ impl Scene {
// each entity's wires in one contiguous run, so a violation means our
// move-and-reassemble model doesn't hold and we must full-rebuild.
let mut old_runs: HashMap<Handle, Vec<WireModel>> = HashMap::default();
// The assembled draw order (one entry per entity, in the order they were
// laid out). Lets an all-Modified edit — which never reorders — reassemble
// by walking just these (~10k) handles instead of the whole document.
let mut old_order: Vec<Handle> = Vec::new();
{
let mut cur: Option<Handle> = None;
let mut run: Vec<WireModel> = Vec::new();
@@ -2773,6 +2819,7 @@ impl Scene {
}
}
cur = Some(h);
old_order.push(h);
}
run.push(w);
}
@@ -2782,6 +2829,11 @@ impl Scene {
}
}
}
// An all-Modified edit keeps the layout (no entity added / removed), so
// the draw order is exactly `old_order` — skip the whole-document scan.
let structural = deltas
.iter()
.any(|(_, k)| !matches!(k, ChangeKind::Modified));
// Re-tessellate the changed entities, replicating the full build's exact
// context (anno derivation, empty selection, no cull / no LOD). Faded
@@ -2834,22 +2886,33 @@ impl Scene {
}
}
// Reassemble in document order, then apply the identical draw-order sort
// wires_for_block_culled uses. With a fully-populated memo the full build
// also emits in document order (every entity is a hit), so a stable sort
// by rank yields byte-identical output — this is what the oracle test
// pins.
// Reassemble, then apply the identical draw-order sort
// wires_for_block_culled uses. Emission order IS the final order when the
// block has no SortEntitiesTable (the sort below no-ops), so it must match
// a from-scratch build: document order. An all-Modified edit never reorders
// — reuse the recorded `old_order` (~10k handles) instead of walking the
// whole document (~800k). Add/Remove change positions, so fall back to the
// document scan. Either way a leftover run (a kept handle we couldn't
// place) means an inconsistency we don't patch.
let mut new_vec: Vec<WireModel> = Vec::new();
for e in self.document.entities() {
let h = e.common().handle;
if let Some(run) = new_runs.remove(&h) {
new_vec.extend(run);
} else if let Some(run) = old_runs.remove(&h) {
new_vec.extend(run);
if structural {
for e in self.document.entities() {
let h = e.common().handle;
if let Some(run) = new_runs.remove(&h) {
new_vec.extend(run);
} else if let Some(run) = old_runs.remove(&h) {
new_vec.extend(run);
}
}
} else {
for h in old_order {
if let Some(run) = new_runs.remove(&h) {
new_vec.extend(run);
} else if let Some(run) = old_runs.remove(&h) {
new_vec.extend(run);
}
}
}
// A leftover run means its entity vanished from the document without a
// Removed delta — an inconsistency we don't try to patch.
if !new_runs.is_empty() || !old_runs.is_empty() {
return None;
}
+27 -5
View File
@@ -990,6 +990,25 @@ impl Scene {
return verts.clone();
}
}
// A new content id (every geometry edit) misses the cache — but if no
// text-bearing entity changed since the last build, the glyphs are
// identical, so reuse them instead of re-walking every wire.
{
let reuse = {
let last = self.last_sdf_text.borrow();
match &*last {
Some((epoch, arc)) if self.text_unchanged(*epoch) => Some(arc.clone()),
_ => None,
}
};
if let Some(arc) = reuse {
self.sdf_text_cache
.borrow_mut()
.insert(wire_content_id, arc.clone());
*self.last_sdf_text.borrow_mut() = Some((self.geometry_epoch, arc.clone()));
return arc;
}
}
let mut out: Vec<crate::scene::pipeline::text_gpu::TextVertex> = Vec::new();
for w in wires {
if !w.text_verts.is_empty() {
@@ -997,12 +1016,15 @@ impl Scene {
}
}
let verts = Arc::new(out);
let mut cache = self.sdf_text_cache.borrow_mut();
// Ids change on rebuild, so old keys die naturally; cap bounds churn.
if cache.len() > 8 {
cache.clear();
{
let mut cache = self.sdf_text_cache.borrow_mut();
// Ids change on rebuild, so old keys die naturally; cap bounds churn.
if cache.len() > 8 {
cache.clear();
}
cache.insert(wire_content_id, verts.clone());
}
cache.insert(wire_content_id, verts.clone());
*self.last_sdf_text.borrow_mut() = Some((self.geometry_epoch, verts.clone()));
verts
}