From d1ecaf46952e103d272643ba26147b0227811f86 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 26 Apr 2026 02:13:31 +0300 Subject: [PATCH] perf: eliminate per-frame re-tessellation and GPU re-upload in large files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two complementary render caches that make mouse navigation essentially free regardless of file size: Option A – Wire tessellation cache: entity_wires_arc() caches the tessellated Vec keyed by geometry_epoch and returns an Arc, so navigation frames (no preview wires) incur zero Vec clones and zero tessellation work. Primitive::wires changed to Arc>. Option B – GPU buffer cache: Pipeline stores cached_epoch; prepare() skips all upload_wires/hatches/images/meshes calls when the epoch is unchanged, writing only the 192-byte camera uniform instead. geometry_epoch is a process-wide AtomicU64 incremented by every Scene mutation (add/erase entity, selection, layer visibility, layout switch, populate_*, grip, transform, copy, clear, preview wires). Using a global counter rather than a per-scene counter prevents two tabs that happen to have the same local bump count from sharing a cached_epoch value, which was causing the first file's geometry to remain on screen after opening a second file. Co-Authored-By: Claude Sonnet 4.6 --- PERFORMANCE.md | 121 ++++++++++++++++++++++++++++++++++++++ src/app/update.rs | 4 ++ src/scene/mod.rs | 64 ++++++++++++++++++-- src/scene/pipeline/mod.rs | 4 ++ src/scene/render.rs | 52 +++++++++++----- 5 files changed, 225 insertions(+), 20 deletions(-) create mode 100644 PERFORMANCE.md diff --git a/PERFORMANCE.md b/PERFORMANCE.md new file mode 100644 index 00000000..0c94c148 --- /dev/null +++ b/PERFORMANCE.md @@ -0,0 +1,121 @@ +# Performance Optimization Plan + +## Problem + +The application lags severely when large files are open — even simple mouse movement causes noticeable frame drops. + +### Root Cause + +Every frame (triggered by any mouse event), Iced calls `shader::Program::draw()` → `build_primitive()`. +This function: + +1. **Re-tessellates every entity from scratch** — `wires_for_block()` iterates all document entities and calls `tessellate_one()` for each, every single frame. +2. **Recreates all GPU buffers** — `prepare()` calls `upload_wires/hatches/images/meshes()`, each of which allocates new `wgpu::Buffer` objects for every entity, every frame. +3. **Linear-scans all objects** to find `SortEntitiesTable` on every frame. + +During pure mouse navigation (pan/zoom), nothing in the geometry data changes — only the camera matrix (uniforms) changes. Despite this, all work above is repeated for every mouse-move event. + +--- + +## Option A — Wire Tessellation Cache ✅ Implemented + +**Status:** Done + +Added `wire_cache: RefCell>)>>` to `Scene`. +`entity_wires_arc()` checks the cache first; if `geometry_epoch` matches it returns the cached +`Arc` (O(1) refcount bump). On a cache miss it tessellates, wraps the result in `Arc`, stores it, +and returns a clone of the `Arc`. + +`build_primitive()` uses `entity_wires_arc()` directly: +- **No preview wires (navigation):** stores the Arc as-is — zero Vec clones, zero tessellation. +- **Command active (preview wires present):** clones the cached entity Vec once, appends + preview/interim wires, wraps in a new Arc — only one allocation instead of full tessellation. + +`Primitive::wires` changed from `Vec` to `Arc>`. + +**Impact:** During navigation in large files the per-frame cost of the wire step drops from +O(entities × tessellation) to O(1). Combined with Option B the GPU upload is also skipped, +making navigation frames nearly free regardless of file size. + +--- + +## Option B — GPU Buffer Cache ✅ Implemented + +**Status:** Done + +Add a `geometry_epoch: u64` counter to `Scene`. Bump it whenever geometry-affecting state changes. +Carry the epoch through `Primitive` to `Pipeline`, which stores `cached_epoch: u64`. + +In `prepare()`: +- **Always** upload uniforms (camera changes every frame). +- **Skip** geometry buffer uploads (`upload_wires/hatches/images/meshes`) when `geometry_epoch == cached_epoch`. +- After uploading, set `pipeline.cached_epoch = geometry_epoch`. + +**Impact:** During navigation, only a single 192-byte uniform write reaches the GPU instead of +re-creating thousands of buffers. Fixes mouse-movement lag in large files. + +**Difficulty:** Medium. Requires bumping the epoch in all geometry-mutation paths. + +### Epoch bump locations + +| Method | Location | +|--------|----------| +| `add_entity()` | `scene/mod.rs` | +| `erase_entities()` | `scene/mod.rs` | +| `populate_hatches_from_document()` | `scene/mod.rs` | +| `populate_images_from_document()` | `scene/mod.rs` | +| `populate_meshes_from_document()` | `scene/mod.rs` | +| `set_preview_wires()` | `scene/mod.rs` | +| `clear_preview_wire()` | `scene/mod.rs` | +| `set_interim_wire()` | `scene/mod.rs` | +| `select_entity()` | `scene/mod.rs` | +| `deselect_all()` | `scene/mod.rs` | +| `expand_selection_for_groups()` | `scene/mod.rs` | +| `toggle_layer_visibility()` | `scene/mod.rs` | +| `transform_entities()` | `scene/mod.rs` | +| `copy_entities()` | `scene/mod.rs` | +| `apply_grip()` | `scene/mod.rs` | +| `clear()` | `scene/mod.rs` | + +--- + +## Option C — Parallel Tessellation (Rayon) + +**Status:** Planned + +Replace the sequential `.flat_map(|e| self.tessellate_one(e))` chain in `wires_for_block()` with +`rayon::par_iter()`. + +**Impact:** Spreads tessellation across all CPU cores. Improves file-open time and post-edit +refresh. Does **not** fix per-frame navigation lag (Option A/B address that). + +**Difficulty:** Easy. Requires making `tessellate_one` free of interior mutability, or collecting +into a Vec first then parallel-processing. + +--- + +## Option D — Frustum / Viewport Culling + +**Status:** Planned + +Build a spatial index (R-tree or uniform grid) over entity bounding boxes. +During `wires_for_block()`, skip entities whose bounding box lies entirely outside the camera frustum. + +**Impact:** Dramatic speedup for large drawings when zoomed in — only visible entities are +tessellated and uploaded. No benefit when the full drawing is visible. + +**Difficulty:** Hard. Requires maintaining an up-to-date spatial index and computing per-entity +bounding boxes for all entity types. + +--- + +## Option E — SortEntitiesTable Scan Cache + +**Status:** Planned + +Cache the result of the `SortEntitiesTable` lookup that currently performs a linear scan over all +objects on every `wires_for_block()` call. + +**Impact:** Minor. Removes one O(objects) scan per frame. Low priority compared to A–D. + +**Difficulty:** Easy. diff --git a/src/app/update.rs b/src/app/update.rs index 599ff8ac..61f3321b 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -752,6 +752,7 @@ impl H7CAD { if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(&name) { if frozen { dl.freeze(); } else { dl.thaw(); } } + self.tabs[i].scene.bump_geometry(); self.tabs[i].dirty = true; } Task::none() @@ -788,6 +789,7 @@ impl H7CAD { let vp_info = self.tabs[i].scene.viewport_list(); let doc_layers = self.tabs[i].scene.document.layers.clone(); self.tabs[i].layers.sync_with_viewports(&doc_layers, vp_info); + self.tabs[i].scene.bump_geometry(); self.tabs[i].dirty = true; } } @@ -2470,6 +2472,7 @@ impl H7CAD { // Switch to Model if active layout was deleted. if self.tabs[i].scene.current_layout == name { self.tabs[i].scene.current_layout = "Model".to_string(); + self.tabs[i].scene.bump_geometry(); } self.layout_manager_selected = "Model".to_string(); self.layout_manager_rename_buf = String::new(); @@ -2516,6 +2519,7 @@ impl H7CAD { let i = self.active_tab; let name = self.layout_manager_selected.clone(); self.tabs[i].scene.current_layout = name.clone(); + self.tabs[i].scene.bump_geometry(); self.command_line.push_output(&format!("Switched to layout '{name}'.")); Task::none() } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 2bffd9d1..4fe652b1 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -47,6 +47,14 @@ use iced::time::Duration; use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::rc::Rc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +/// Global counter so every Scene and every geometry mutation gets a +/// process-wide unique epoch. This prevents two different tabs (Scenes) +/// from ever sharing the same epoch value, which would cause the shared +/// GPU Pipeline to skip re-uploading geometry when switching tabs. +static GEOMETRY_EPOCH: AtomicU64 = AtomicU64::new(1); pub struct Scene { pub camera: Rc>, @@ -60,6 +68,14 @@ pub struct Scene { /// Committed-segment wire drawn during multi-point commands (normal colour). pub interim_wire: Option, pub camera_generation: u64, + /// Incremented whenever geometry-affecting state changes (entities, selection, + /// preview wires, layer visibility, layout). The GPU pipeline uses this to + /// skip re-uploading unchanged geometry buffers every frame. + pub geometry_epoch: u64, + /// Cached tessellation of all visible entity wires for the current layout. + /// Keyed by `geometry_epoch`; invalidated automatically when the epoch changes. + /// Uses `Arc` so `build_primitive()` avoids a full Vec clone during navigation. + wire_cache: RefCell>)>>, /// Active layout name — "Model" or a paper space layout name. pub current_layout: String, /// GPU render data for hatch fills, keyed by the DXF entity Handle. @@ -89,6 +105,8 @@ impl Scene { preview_wires: vec![], interim_wire: None, camera_generation: 0, + geometry_epoch: GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed), + wire_cache: RefCell::new(None), current_layout: "Model".to_string(), hatches: HashMap::new(), meshes: HashMap::new(), @@ -99,6 +117,10 @@ impl Scene { } } + pub fn bump_geometry(&mut self) { + self.geometry_epoch = GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed); + } + /// Public accessor for the block-record handle of the current layout. /// Used by external callers (e.g. `commit_entity`) that need the handle /// without going through private API. @@ -343,14 +365,31 @@ impl Scene { .collect() } - /// Build WireModels from all document entities + optional preview wire. - pub fn entity_wires(&self) -> Vec { + /// Build WireModels from all document entities for the current layout. + /// Returns a shared `Arc` so `build_primitive()` can skip the clone during + /// navigation frames where no preview wires are active. + pub(super) fn entity_wires_arc(&self) -> Arc> { + { + let cache = self.wire_cache.borrow(); + if let Some((cached_epoch, ref arc)) = *cache { + if cached_epoch == self.geometry_epoch { + return Arc::clone(arc); + } + } + } let layout_block = self.current_layout_block_handle(); - let mut wires: Vec = self.wires_for_block(layout_block); + let mut wires = self.wires_for_block(layout_block); if self.current_layout != "Model" { wires.extend(self.viewport_content_wires(layout_block, None, None)); } - wires + let arc = Arc::new(wires); + *self.wire_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&arc))); + arc + } + + /// Build WireModels from all document entities + optional preview wire. + pub fn entity_wires(&self) -> Vec { + (*self.entity_wires_arc()).clone() } /// Wires that should participate in hit-testing, snapping, and selection. @@ -1126,6 +1165,7 @@ impl Scene { self.current_layout = "Model".to_string(); } + self.bump_geometry(); true } @@ -1224,6 +1264,7 @@ impl Scene { if let Some(model) = mesh_seed { self.meshes.insert(handle, model); } + self.bump_geometry(); } handle } @@ -1646,6 +1687,7 @@ impl Scene { self.images.insert(handle, model); } } + self.bump_geometry(); } pub fn populate_hatches_from_document(&mut self) { @@ -1677,6 +1719,7 @@ impl Scene { self.hatches.insert(handle, m); } } + self.bump_geometry(); } /// Tessellate all `Solid3D` entities in the current document into @@ -1712,6 +1755,7 @@ impl Scene { self.meshes.insert(handle, m); } } + self.bump_geometry(); } /// Rebuild hatch / image / mesh caches after the document is modified @@ -1783,17 +1827,20 @@ impl Scene { self.meshes = HashMap::new(); *self.camera.borrow_mut() = Camera::default(); self.camera_generation += 1; + self.bump_geometry(); } // ── Preview wire ────────────────────────────────────────────────────── pub fn set_preview_wires(&mut self, wires: Vec) { self.preview_wires = wires; + self.bump_geometry(); } pub fn clear_preview_wire(&mut self) { self.preview_wires = vec![]; self.interim_wire = None; + self.bump_geometry(); } pub fn wire_models_for(&self, handles: &[acadrust::Handle]) -> Vec { @@ -1820,6 +1867,7 @@ impl Scene { pub fn set_interim_wire(&mut self, w: WireModel) { self.interim_wire = Some(w); + self.bump_geometry(); } // ── Selection ───────────────────────────────────────────────────────── @@ -1829,10 +1877,12 @@ impl Scene { self.selected.clear(); } self.selected.insert(handle); + self.bump_geometry(); } pub fn deselect_all(&mut self) { self.selected.clear(); + self.bump_geometry(); } pub fn selected_entities(&self) -> Vec<(Handle, &EntityType)> { @@ -1873,6 +1923,7 @@ impl Scene { } self.document.objects.remove(gh); } + self.bump_geometry(); } // ── Group helpers ────────────────────────────────────────────────────── @@ -1955,6 +2006,7 @@ impl Scene { for h in to_add { self.selected.insert(h); } + self.bump_geometry(); } // ── Layer helpers ────────────────────────────────────────────────────── @@ -1963,6 +2015,7 @@ impl Scene { if let Some(layer) = self.document.layers.get_mut(name) { layer.flags.off = !layer.flags.off; } + self.bump_geometry(); } pub fn toggle_layer_lock(&mut self, name: &str) { @@ -1990,6 +2043,7 @@ impl Scene { } } } + self.bump_geometry(); } pub fn copy_entities(&mut self, handles: &[Handle], t: &EntityTransform) -> Vec { @@ -2015,6 +2069,7 @@ impl Scene { } new_handles.push(h); } + self.bump_geometry(); new_handles } @@ -2040,6 +2095,7 @@ impl Scene { } _ => {} } + self.bump_geometry(); } // ── Hit-test convenience: wire name → Handle ────────────────────────── diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 58232601..410871a0 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -52,6 +52,9 @@ pub struct Pipeline { gpu_images: Vec, gpu_meshes: Vec, pub viewcube: ViewCubePipeline, + /// Last geometry epoch for which GPU buffers were uploaded. + /// Initialized to u64::MAX so the first frame always uploads. + pub cached_epoch: u64, } impl Pipeline { @@ -476,6 +479,7 @@ impl Pipeline { gpu_images: vec![], gpu_meshes: vec![], viewcube, + cached_epoch: u64::MAX, } } diff --git a/src/scene/render.rs b/src/scene/render.rs index 3e6c4d4b..68d07765 100644 --- a/src/scene/render.rs +++ b/src/scene/render.rs @@ -9,6 +9,8 @@ use iced::mouse; use iced::widget::shader::{self, Viewport}; use iced::{Rectangle, Size}; +use std::sync::Arc; + use super::pipeline::viewcube::{hover_id, VIEWCUBE_PX}; use super::pipeline::Pipeline; use super::tessellate; @@ -75,7 +77,7 @@ pub struct CameraState { #[derive(Debug)] pub struct Primitive { - pub(super) wires: Vec, + pub(super) wires: Arc>, pub(super) hatches: Vec, /// Wipeout fills — rendered in a separate pass AFTER wires. pub(super) wipeout_hatches: Vec, @@ -89,6 +91,7 @@ pub struct Primitive { /// Background color used to clear the MSAA buffer at the start of each frame. pub(super) bg_color: [f32; 4], pub(super) show_viewcube: bool, + pub(super) geometry_epoch: u64, } // ── shader::Primitive impl ──────────────────────────────────────────────── @@ -116,11 +119,14 @@ impl shader::Primitive for Primitive { pipeline.ensure_depth_texture(device, clip_size); pipeline.viewcube.ensure_depth_texture(device, full_size); pipeline.upload_uniforms(queue, &self.uniforms); - pipeline.upload_hatches(device, &self.hatches); - pipeline.upload_wipeouts(device, &self.wipeout_hatches); - pipeline.upload_images(device, queue, &self.images); - pipeline.upload_meshes(device, &self.meshes); - pipeline.upload_wires(device, &self.wires); + if self.geometry_epoch != pipeline.cached_epoch { + pipeline.upload_hatches(device, &self.hatches); + pipeline.upload_wipeouts(device, &self.wipeout_hatches); + pipeline.upload_images(device, queue, &self.images); + pipeline.upload_meshes(device, &self.meshes); + pipeline.upload_wires(device, &self.wires[..]); + pipeline.cached_epoch = self.geometry_epoch; + } if self.show_viewcube { pipeline.viewcube.upload( queue, @@ -226,11 +232,17 @@ impl Scene { let cam = self.camera.borrow(); self.selection.borrow_mut().vp_size = (bounds.width, bounds.height); - let mut all_wires = self.entity_wires(); - if let Some(iw) = &self.interim_wire { - all_wires.push(iw.clone()); - } - all_wires.extend(self.preview_wires.iter().cloned()); + let entity_arc = self.entity_wires_arc(); + let all_wires = if self.interim_wire.is_none() && self.preview_wires.is_empty() { + entity_arc + } else { + let mut v = (*entity_arc).clone(); + if let Some(iw) = &self.interim_wire { + v.push(iw.clone()); + } + v.extend(self.preview_wires.iter().cloned()); + Arc::new(v) + }; let bg_color = if self.current_layout == "Model" { self.bg_color @@ -249,6 +261,7 @@ impl Scene { hover_region, bg_color, show_viewcube, + geometry_epoch: self.geometry_epoch, } } @@ -266,11 +279,17 @@ impl Scene { None => return self.build_primitive(hover_region, bounds, false), }; - let mut all_wires = self.model_wires_for_viewport(vp_handle); - if let Some(iw) = &self.interim_wire { - all_wires.push(iw.clone()); - } - all_wires.extend(self.preview_wires.iter().cloned()); + let base_wires = self.model_wires_for_viewport(vp_handle); + let all_wires = if self.interim_wire.is_none() && self.preview_wires.is_empty() { + Arc::new(base_wires) + } else { + let mut v = base_wires; + if let Some(iw) = &self.interim_wire { + v.push(iw.clone()); + } + v.extend(self.preview_wires.iter().cloned()); + Arc::new(v) + }; Primitive { wires: all_wires, @@ -283,6 +302,7 @@ impl Scene { hover_region, bg_color: self.bg_color, show_viewcube: false, + geometry_epoch: self.geometry_epoch, } }