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:
Hakan Seven 2026-04-26 11:34:52 +03:00
commit 81db3c37d0
5 changed files with 209 additions and 20 deletions

View file

@ -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 })
}
}

View file

@ -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).

View file

@ -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),

View file

@ -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,