diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 2e0a5ca6c..b71239da1 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -408,14 +408,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, camera_generation). - /// The camera term is needed because `synced_hatch_models` now view-culls - /// via the quadtree — a key that ignored camera would return stale culled - /// lists across pan/zoom. - hatch_cache: RefCell>)>>, - /// Cached wipeout fill models, keyed by (geometry_epoch, camera_generation). - /// Same reasoning as `hatch_cache`. - wipeout_cache: RefCell>)>>, + /// Cached hatch fill models, keyed by geometry_epoch. View culling + /// is handled at draw time via `hatch_skip_flags` in the pipeline, + /// not at build time — that lets the GPU buffer stay stable across + /// pan/zoom while still skipping out-of-view hatches. + hatch_cache: RefCell>)>>, + /// Cached wipeout fill models, keyed by geometry_epoch. Same + /// reasoning as `hatch_cache`. + wipeout_cache: RefCell>)>>, /// Cached image models, keyed by geometry_epoch. Images do their own /// per-frame culling in the GPU pipeline (vp_scissor); no camera key /// needed here. @@ -1061,32 +1061,30 @@ impl Scene { } pub(super) fn hatch_models_arc(&self) -> Arc> { - let key = (self.geometry_epoch, self.camera_generation); { let cache = self.hatch_cache.borrow(); - if let Some((cached_key, ref arc)) = *cache { - if cached_key == key { + 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((key, Arc::clone(&arc))); + *self.hatch_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&arc))); arc } pub(super) fn wipeout_models_arc(&self) -> Arc> { - let key = (self.geometry_epoch, self.camera_generation); { let cache = self.wipeout_cache.borrow(); - if let Some((cached_key, ref arc)) = *cache { - if cached_key == key { + 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((key, Arc::clone(&arc))); + *self.wipeout_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&arc))); arc } @@ -2482,33 +2480,16 @@ impl Scene { .unwrap_or(false) }; - // Phase 2.1 — narrow the per-hatch visibility scan to candidates - // the quadtree says intersect the view. The map below still does - // the heavy lifting (model clone + render_style + selection tint) - // so cutting the pre-filter from O(N_hatches) → O(visible_hatches) - // is the main win. - let view_candidates: Option> = - self.view_world_aabb().map(|local_view| { - let [ox, oy, _] = self.world_offset; - let view_wcs: [f64; 4] = [ - local_view[0] as f64 + ox, - local_view[1] as f64 + oy, - local_view[2] as f64 + ox, - local_view[3] as f64 + oy, - ]; - let tree = self.entity_index(); - tree.query_rect(view_wcs).into_iter().collect() - }); - + // synced_hatch_models is cached on geometry_epoch and the GPU + // upload is keyed on geometry_epoch only (see render.rs — hatch + // buffers are "static"). Don't view-cull here; the per-frame + // skip flag in compute_hatch_lod handles frustum + sub-pixel + // culling at draw time, which keeps the GPU upload set stable + // across pan/zoom. let mut models: Vec = self .hatches .iter() .filter(|(&handle, _)| { - if let Some(set) = view_candidates.as_ref() { - if !set.contains(&handle) { - return false; - } - } let Some(entity) = self.document.get_entity(handle) else { return true; }; @@ -2593,11 +2574,6 @@ impl Scene { if fills.is_empty() { continue; } - if let Some(set) = view_candidates.as_ref() { - if !set.contains(&common.handle) { - continue; - } - } if common.invisible || layer_hidden(&common.layer) { continue; } @@ -2643,29 +2619,17 @@ impl Scene { } else { self.world_offset }; - // Phase 2.1 — quadtree pre-filter for wipeouts in Model layout. - let view_candidates: Option> = - self.view_world_aabb().map(|local_view| { - let [ox, oy, _] = self.world_offset; - let view_wcs: [f64; 4] = [ - local_view[0] as f64 + ox, - local_view[1] as f64 + oy, - local_view[2] as f64 + ox, - local_view[3] as f64 + oy, - ]; - let tree = self.entity_index(); - tree.query_rect(view_wcs).into_iter().collect() - }); + // No per-frame view-cull here: GPU wipeout buffer upload is + // gated on geometry_epoch only (see render.rs), so any cull at + // build time would freeze the visible subset at the geometry + // epoch boundary and never re-evaluate as the user pans. The + // pipeline's `wipeout_skip_flags` (compute_wipeout_lod) does + // the per-frame skip at draw time instead. let mut models = Vec::new(); for entity in self.document.entities() { let EntityType::Wipeout(wo) = entity else { continue; }; - if let Some(set) = view_candidates.as_ref() { - if !set.contains(&wo.common.handle) { - continue; - } - } if entity.common().invisible { continue; } diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 8bc00cd9e..358352a12 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -65,6 +65,10 @@ pub struct Pipeline { hatch_skip_flags: Vec, /// Wipeout fills — rendered after wires in a separate pass. gpu_wipeouts: Vec, + /// Per-wipeout draw-time skip flag (Phase 2.3 frustum cull). `true` + /// when the wipeout's projected AABB sits entirely outside the + /// viewport rect. Recomputed by `compute_wipeout_lod`. + wipeout_skip_flags: Vec, /// Pixel scissor rects [x, y, w, h] for viewport-clipped wipeouts. Recomputed each frame. wipeout_pixel_scissors: Vec>, gpu_images: Vec, @@ -622,6 +626,7 @@ impl Pipeline { hatch_pixel_scissors: vec![], hatch_skip_flags: vec![], gpu_wipeouts: vec![], + wipeout_skip_flags: vec![], wipeout_pixel_scissors: vec![], gpu_images: vec![], image_pixel_scissors: vec![], @@ -676,7 +681,11 @@ impl Pipeline { self.hatch_skip_flags = self .gpu_hatches .iter() - .map(|h| aabb_below_pixel(h.world_aabb, view_proj, clip_w, clip_h, 2.0)) + .map(|h| { + // Phase 3.3 sub-pixel LOD skip OR Phase 2.3 frustum skip. + aabb_below_pixel(h.world_aabb, view_proj, clip_w, clip_h, 2.0) + || aabb_offscreen(h.world_aabb, view_proj, clip_w, clip_h) + }) .collect(); } @@ -689,6 +698,18 @@ impl Pipeline { .collect(); } + /// Per-frame wipeout frustum-skip flag (Phase 2.3). Mirrors + /// `compute_hatch_lod`'s frustum branch. No sub-pixel skip: + /// wipeouts mask, so dropping a sub-pixel one wouldn't be wrong + /// but also wouldn't pay off — they're usually few. + pub fn compute_wipeout_lod(&mut self, view_proj: glam::Mat4, clip_w: u32, clip_h: u32) { + self.wipeout_skip_flags = self + .gpu_wipeouts + .iter() + .map(|h| aabb_offscreen(h.world_aabb, view_proj, clip_w, clip_h)) + .collect(); + } + /// Recompute pixel scissor rects for viewport-clipped raster images. pub fn compute_image_scissors(&mut self, view_proj: glam::Mat4, clip_w: u32, clip_h: u32) { self.image_pixel_scissors = self @@ -1098,6 +1119,9 @@ impl Pipeline { pass.set_bind_group(0, &self.uniform_bind_group, &[]); let mut scissor_active = false; for (i, wipeout) in self.gpu_wipeouts.iter().enumerate() { + if self.wipeout_skip_flags.get(i).copied().unwrap_or(false) { + continue; + } match self.wipeout_pixel_scissors.get(i) { Some(Some([x, y, w, h])) => { pass.set_scissor_rect(*x, *y, *w, *h); diff --git a/src/scene/render.rs b/src/scene/render.rs index 571086860..48fb97fcd 100644 --- a/src/scene/render.rs +++ b/src/scene/render.rs @@ -148,6 +148,7 @@ impl shader::Primitive for Primitive { pipeline.compute_wipeout_scissors(self.uniforms.view_proj, clip_size.width, clip_size.height); pipeline.compute_image_scissors(self.uniforms.view_proj, clip_size.width, clip_size.height); pipeline.compute_hatch_lod(self.uniforms.view_proj, clip_size.width, clip_size.height); + pipeline.compute_wipeout_lod(self.uniforms.view_proj, clip_size.width, clip_size.height); pipeline.compute_mesh_lod(self.uniforms.view_proj, clip_size.width, clip_size.height); if self.show_viewcube { pipeline.viewcube.upload(