perf: cache hatch/wipeout/image/mesh models per geometry epoch (F+G+H)
Add Arc<Vec<...>>-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<Vec<u8>> 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 <noreply@anthropic.com>
This commit is contained in:
parent
2bf4a348bf
commit
81db3c37d0
5 changed files with 209 additions and 20 deletions
119
PERFORMANCE.md
Normal file
119
PERFORMANCE.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
# Performance Optimization Plan
|
||||
|
||||
## Implemented
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| A | Wire tessellation cache — `Arc<Vec<WireModel>>` 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<u8>` — 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<u32>`.
|
||||
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<Option<(u64, Arc<Vec<HatchModel>>)>>,
|
||||
wipeout_cache: RefCell<Option<(u64, Arc<Vec<HatchModel>>)>>,
|
||||
```
|
||||
|
||||
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<HatchModel>` to
|
||||
`Arc<Vec<HatchModel>>`.
|
||||
|
||||
**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<u8>` to `Arc<Vec<u8>>`.
|
||||
|
||||
`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<Vec<ImageModel>>` cache (same pattern as wire cache) so
|
||||
`build_primitive()` performs only an O(1) Arc bump per frame during navigation:
|
||||
|
||||
```rust
|
||||
image_cache: RefCell<Option<(u64, Arc<Vec<ImageModel>>)>>,
|
||||
```
|
||||
|
||||
**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<Vec<MeshModel>>` epoch cache to mesh models:
|
||||
|
||||
```rust
|
||||
mesh_cache: RefCell<Option<(u64, Arc<Vec<MeshModel>>)>>,
|
||||
```
|
||||
|
||||
`MeshModel` contains `verts: Vec<[f32;3]>` (vertex positions) and `indices: Vec<u32>`
|
||||
(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.
|
||||
|
|
@ -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<u8>,
|
||||
/// 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<Vec<u8>>,
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Option<(u64, HashMap<Handle, HashMap<u64, u64>>)>>,
|
||||
/// Cached hatch fill models, keyed by geometry_epoch.
|
||||
hatch_cache: RefCell<Option<(u64, Arc<Vec<HatchModel>>)>>,
|
||||
/// Cached wipeout fill models, keyed by geometry_epoch.
|
||||
wipeout_cache: RefCell<Option<(u64, Arc<Vec<HatchModel>>)>>,
|
||||
/// Cached image models, keyed by geometry_epoch.
|
||||
image_cache: RefCell<Option<(u64, Arc<Vec<ImageModel>>)>>,
|
||||
/// Cached mesh models, keyed by geometry_epoch.
|
||||
mesh_cache: RefCell<Option<(u64, Arc<Vec<MeshModel>>)>>,
|
||||
/// 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<Vec<HatchModel>> {
|
||||
{
|
||||
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<Vec<HatchModel>> {
|
||||
{
|
||||
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<Vec<ImageModel>> {
|
||||
{
|
||||
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<Vec<MeshModel>> {
|
||||
{
|
||||
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).
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -78,11 +78,11 @@ pub struct CameraState {
|
|||
#[derive(Debug)]
|
||||
pub struct Primitive {
|
||||
pub(super) wires: Arc<Vec<WireModel>>,
|
||||
pub(super) hatches: Vec<HatchModel>,
|
||||
pub(super) hatches: Arc<Vec<HatchModel>>,
|
||||
/// Wipeout fills — rendered in a separate pass AFTER wires.
|
||||
pub(super) wipeout_hatches: Vec<HatchModel>,
|
||||
pub(super) images: Vec<ImageModel>,
|
||||
pub(super) meshes: Vec<MeshModel>,
|
||||
pub(super) wipeout_hatches: Arc<Vec<HatchModel>>,
|
||||
pub(super) images: Arc<Vec<ImageModel>>,
|
||||
pub(super) meshes: Arc<Vec<MeshModel>>,
|
||||
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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue