forked from 1b-agent/opencadstudio-upstream
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 <[email protected]>
This commit is contained in:
1 parent
f12100d783
commit
aea1fd2caa
2 files changed
+104
-19
No files matched your search
+77
-14
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user