From 81db3c37d0cce08f1436a982603a854a341788ef Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 26 Apr 2026 11:34:52 +0300 Subject: [PATCH] perf: cache hatch/wipeout/image/mesh models per geometry epoch (F+G+H) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Arc>-backed epoch caches for hatches, wipeouts, images, and meshes — the same pattern used for wires. build_primitive() now returns an O(1) Arc refcount bump on navigation frames instead of rebuilding every collection from scratch. Also wrap ImageModel.pixels in Arc> so cloning an ImageModel is O(1) regardless of image resolution. Navigation frames no longer copy pixel data, tessellated hatch boundaries, or mesh geometry — all per-frame CPU work on unchanged geometry is now eliminated. Co-Authored-By: Claude Sonnet 4.6 --- PERFORMANCE.md | 119 ++++++++++++++++++++++++++++++++ src/scene/image_model.rs | 8 ++- src/scene/mod.rs | 68 ++++++++++++++++++ src/scene/pipeline/image_gpu.rs | 2 +- src/scene/render.rs | 32 ++++----- 5 files changed, 209 insertions(+), 20 deletions(-) create mode 100644 PERFORMANCE.md diff --git a/PERFORMANCE.md b/PERFORMANCE.md new file mode 100644 index 000000000..feb61e7c2 --- /dev/null +++ b/PERFORMANCE.md @@ -0,0 +1,119 @@ +# Performance Optimization Plan + +## Implemented + +| Option | Description | +|--------|-------------| +| A | Wire tessellation cache — `Arc>` keyed by `geometry_epoch`; O(1) on navigation | +| B | GPU buffer cache — skip `upload_wires/hatches/images/meshes` when epoch unchanged | +| C | Rayon parallel tessellation — `tessellate_entity` free function + `par_iter()` in `wires_for_block()` | +| E | SortEntitiesTable scan cache — O(objects) linear scan replaced with O(1) HashMap lookup | + +--- + +## Remaining per-frame CPU work (navigation path) + +With Options A/B implemented, `build_primitive()` still runs every frame during navigation +(pan/zoom), even though the geometry epoch has not changed. The following work happens on every +frame and should be eliminated: + +### 1. `ImageModel.pixels` cloned every frame — highest impact + +`build_primitive()` calls `self.images.values().cloned().collect()`. +`ImageModel` contains `pixels: Vec` — raw RGBA pixel data for each image. +A single 2000×1500 image is ~12 MB. With three images, every mouse-move event copies ~36 MB. + +### 2. `synced_hatch_models()` — full document scan + Insert explosion every frame + +The inner loop `for entity in self.document.entities()` iterates every entity in the document, +and for each `Insert` calls `explode_from_document()` to find embedded hatches. +In drawings with many block references this is O(inserts × avg_block_size) per frame. + +### 3. `meshes.values().cloned().collect()` — 3D geometry cloned every frame + +`MeshModel` contains `verts: Vec<[f32;3]>` and `indices: Vec`. +A tessellated 3D solid can hold tens of thousands of triangles (several MB). +All mesh data is copied on every frame even during pure navigation. + +### 4. `wipeout_models()` — full entity scan every frame + +Iterates `self.document.entities()` to find `Wipeout` entities and rebuild their boundary +polygons. No caching; O(entities) on every frame. + +--- + +## Option F — Cache hatch and wipeout models + +**Status:** Done + +Add epoch-keyed `Arc` caches for hatches and wipeouts, identical in structure to the wire cache: + +```rust +hatch_cache: RefCell>)>>, +wipeout_cache: RefCell>)>>, +``` + +Introduce `hatch_models_arc()` and `wipeout_models_arc()` helpers that return the cached `Arc` +on an epoch hit (O(1) refcount bump) and rebuild on a miss. + +`build_primitive()` stores the `Arc` directly in `Primitive`, removing the per-frame +`synced_hatch_models()` / `wipeout_models()` calls on navigation frames. + +`Primitive.hatches` and `Primitive.wipeout_hatches` change from `Vec` to +`Arc>`. + +**Impact:** Eliminates O(inserts × block_size) work per frame in drawings with block references +containing hatch entities. Navigation becomes free for hatch-heavy files. + +**Difficulty:** Easy. Same pattern as the wire cache. + +--- + +## Option G — Arc-wrap image pixels + +**Status:** Done + +Change `ImageModel.pixels` from `Vec` to `Arc>`. + +`ImageModel::clone()` then copies only the pointer (8 bytes) instead of megabytes of pixel data. +No other code needs to change — the GPU upload path reads `pixels` by reference. + +Add an epoch-keyed `Arc>` cache (same pattern as wire cache) so +`build_primitive()` performs only an O(1) Arc bump per frame during navigation: + +```rust +image_cache: RefCell>)>>, +``` + +**Impact:** Eliminates MB-scale copies per frame in files that contain raster images. +With a 4K image (~32 MB raw), this alone can cut per-frame CPU time by tens of milliseconds. + +**Difficulty:** Easy. Change one field type + add cache field + wire up in `build_primitive()`. + +--- + +## Option H — Arc-wrap mesh models + +**Status:** Done + +Apply the same `Arc>` epoch cache to mesh models: + +```rust +mesh_cache: RefCell>)>>, +``` + +`MeshModel` contains `verts: Vec<[f32;3]>` (vertex positions) and `indices: Vec` +(triangle list). A complex 3D solid can have hundreds of thousands of triangles. + +**Impact:** Eliminates per-frame mesh data copies in files that contain 3D solids (ACIS bodies, +extruded polylines). Navigation stays free regardless of model complexity. + +**Difficulty:** Easy. Same pattern as Options F and G. + +--- + +## Implementation order + +F → G → H. Each is independent; all three can be done in a single pass since the pattern +is identical. F has the most complex rebuild logic (Insert explosion); G has the highest +raw byte impact; H is the simplest but matters for 3D-heavy files. diff --git a/src/scene/image_model.rs b/src/scene/image_model.rs index 312017718..32fec2355 100644 --- a/src/scene/image_model.rs +++ b/src/scene/image_model.rs @@ -4,13 +4,15 @@ // from the RasterImage entity's insertion point, u/v vectors, and pixel size. use std::path::Path; +use std::sync::Arc; #[derive(Clone, Debug)] pub struct ImageModel { /// Original file path (used for reload / display in properties). pub file_path: String, - /// RGBA8 pixel data in row-major order. - pub pixels: Vec, + /// RGBA8 pixel data in row-major order. Arc-wrapped so cloning ImageModel + /// is O(1) — the pixel bytes are shared, not copied. + pub pixels: Arc>, pub width: u32, pub height: u32, /// Opacity: 1.0 = opaque, 0.0 = transparent. @@ -47,7 +49,7 @@ impl ImageModel { let opacity = 1.0 - img.fade as f32 / 100.0; let (pixels, width, height) = load_pixels(&img.file_path)?; - Some(Self { file_path: img.file_path.clone(), pixels, width, height, opacity, corners }) + Some(Self { file_path: img.file_path.clone(), pixels: Arc::new(pixels), width, height, opacity, corners }) } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 84167c4bb..25520b555 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -80,6 +80,14 @@ pub struct Scene { /// Maps block_handle → (entity_handle.value() → sort_handle.value()). /// Replaces the O(objects) linear scan inside `wires_for_block()` with an O(1) lookup. sort_cache: RefCell>)>>, + /// Cached hatch fill models, keyed by geometry_epoch. + hatch_cache: RefCell>)>>, + /// Cached wipeout fill models, keyed by geometry_epoch. + wipeout_cache: RefCell>)>>, + /// Cached image models, keyed by geometry_epoch. + image_cache: RefCell>)>>, + /// Cached mesh models, keyed by geometry_epoch. + mesh_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. @@ -112,6 +120,10 @@ impl Scene { geometry_epoch: GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed), wire_cache: RefCell::new(None), sort_cache: RefCell::new(None), + hatch_cache: RefCell::new(None), + wipeout_cache: RefCell::new(None), + image_cache: RefCell::new(None), + mesh_cache: RefCell::new(None), current_layout: "Model".to_string(), hatches: HashMap::new(), meshes: HashMap::new(), @@ -397,6 +409,62 @@ impl Scene { (*self.entity_wires_arc()).clone() } + pub(super) fn hatch_models_arc(&self) -> Arc> { + { + let cache = self.hatch_cache.borrow(); + if let Some((cached_epoch, ref arc)) = *cache { + if cached_epoch == self.geometry_epoch { + return Arc::clone(arc); + } + } + } + let arc = Arc::new(self.synced_hatch_models()); + *self.hatch_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&arc))); + arc + } + + pub(super) fn wipeout_models_arc(&self) -> Arc> { + { + let cache = self.wipeout_cache.borrow(); + if let Some((cached_epoch, ref arc)) = *cache { + if cached_epoch == self.geometry_epoch { + return Arc::clone(arc); + } + } + } + let arc = Arc::new(self.wipeout_models()); + *self.wipeout_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&arc))); + arc + } + + pub(super) fn images_arc(&self) -> Arc> { + { + let cache = self.image_cache.borrow(); + if let Some((cached_epoch, ref arc)) = *cache { + if cached_epoch == self.geometry_epoch { + return Arc::clone(arc); + } + } + } + let arc = Arc::new(self.images.values().cloned().collect()); + *self.image_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&arc))); + arc + } + + pub(super) fn meshes_arc(&self) -> Arc> { + { + let cache = self.mesh_cache.borrow(); + if let Some((cached_epoch, ref arc)) = *cache { + if cached_epoch == self.geometry_epoch { + return Arc::clone(arc); + } + } + } + let arc = Arc::new(self.meshes.values().cloned().collect()); + *self.mesh_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&arc))); + arc + } + /// Wires that should participate in hit-testing, snapping, and selection. /// /// - Model layout: all entity wires (same as entity_wires). diff --git a/src/scene/pipeline/image_gpu.rs b/src/scene/pipeline/image_gpu.rs index 7445be659..a595d0b1d 100644 --- a/src/scene/pipeline/image_gpu.rs +++ b/src/scene/pipeline/image_gpu.rs @@ -87,7 +87,7 @@ impl ImageGpu { }); queue.write_texture( texture.as_image_copy(), - &model.pixels, + &model.pixels[..], wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(4 * model.width), diff --git a/src/scene/render.rs b/src/scene/render.rs index e7faa11a1..85824c4fb 100644 --- a/src/scene/render.rs +++ b/src/scene/render.rs @@ -78,11 +78,11 @@ pub struct CameraState { #[derive(Debug)] pub struct Primitive { pub(super) wires: Arc>, - pub(super) hatches: Vec, + pub(super) hatches: Arc>, /// Wipeout fills — rendered in a separate pass AFTER wires. - pub(super) wipeout_hatches: Vec, - pub(super) images: Vec, - pub(super) meshes: Vec, + pub(super) wipeout_hatches: Arc>, + pub(super) images: Arc>, + pub(super) meshes: Arc>, pub(super) uniforms: Uniforms, /// Camera rotation matrix derived from the quaternion. /// Used by the ViewCube pipeline — no gimbal lock. @@ -120,10 +120,10 @@ impl shader::Primitive for Primitive { pipeline.viewcube.ensure_depth_texture(device, full_size); pipeline.upload_uniforms(queue, &self.uniforms); 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_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; } @@ -264,10 +264,10 @@ impl Scene { Primitive { wires: all_wires, - hatches: self.synced_hatch_models(), - wipeout_hatches: self.wipeout_models(), - images: self.images.values().cloned().collect(), - meshes: self.meshes.values().cloned().collect(), + hatches: self.hatch_models_arc(), + wipeout_hatches: self.wipeout_models_arc(), + images: self.images_arc(), + meshes: self.meshes_arc(), uniforms: Uniforms::new(&cam, bounds), cam_rotation: cam.view_rotation_mat(), hover_region, @@ -305,10 +305,10 @@ impl Scene { Primitive { wires: all_wires, - hatches: self.synced_hatch_models(), - wipeout_hatches: self.wipeout_models(), - images: self.images.values().cloned().collect(), - meshes: self.meshes.values().cloned().collect(), + hatches: self.hatch_models_arc(), + wipeout_hatches: self.wipeout_models_arc(), + images: self.images_arc(), + meshes: self.meshes_arc(), uniforms: Uniforms::new(&cam, bounds), cam_rotation: cam.view_rotation_mat(), hover_region,