perf(pipeline): per-frame hatch+wipeout frustum cull at draw time (Phase 2.3)

The Phase 2.1 step-4 attempt at culling hatches inside
`synced_hatch_models` was wrong: GPU hatch/wipeout buffers are
uploaded on geometry_epoch only (see render.rs — they're "static
buffers"), so any CPU-side cull at build time would freeze the
visible set at the geometry epoch boundary and never re-evaluate on
pan. Reverted that approach; the build-time function now returns the
full visible set again and the cache key is back to geometry_epoch.

The actual per-frame frustum cull moves into the existing draw-time
skip-flag machinery:

- `compute_hatch_lod` now also folds in `aabb_offscreen` (ORed with
  the existing Phase 3.3 sub-pixel skip), so out-of-view hatches are
  skipped at the draw call.
- New `compute_wipeout_lod` + `wipeout_skip_flags` mirror the hatch
  path. The wipeout draw loop honors the flag.
- `Primitive::prepare` calls the new compute step alongside the rest.

Per-frame projection + skip is a few hundred microseconds on dense
docs; far cheaper than re-uploading GPU buffers per pan tick.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.7 committed 2026-05-17 15:02:07 +03:00
1 parent 49fddea1ef
commit b70c9f9262
3 files changed
+52 -63

No files matched your search

+26 -62
View File
@@ -408,14 +408,14 @@ pub struct Scene {
/// Maps block_handle → (entity_handle.value() → sort_handle.value()). /// Maps block_handle → (entity_handle.value() → sort_handle.value()).
/// Replaces the O(objects) linear scan inside `wires_for_block()` with an O(1) lookup. /// 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>>)>>, sort_cache: RefCell<Option<(u64, HashMap<Handle, HashMap<u64, u64>>)>>,
/// Cached hatch fill models, keyed by (geometry_epoch, camera_generation). /// Cached hatch fill models, keyed by geometry_epoch. View culling
/// The camera term is needed because `synced_hatch_models` now view-culls /// is handled at draw time via `hatch_skip_flags` in the pipeline,
/// via the quadtree — a key that ignored camera would return stale culled /// not at build time — that lets the GPU buffer stay stable across
/// lists across pan/zoom. /// pan/zoom while still skipping out-of-view hatches.
hatch_cache: RefCell<Option<((u64, u64), Arc<Vec<HatchModel>>)>>, hatch_cache: RefCell<Option<(u64, Arc<Vec<HatchModel>>)>>,
/// Cached wipeout fill models, keyed by (geometry_epoch, camera_generation). /// Cached wipeout fill models, keyed by geometry_epoch. Same
/// Same reasoning as `hatch_cache`. /// reasoning as `hatch_cache`.
wipeout_cache: RefCell<Option<((u64, u64), Arc<Vec<HatchModel>>)>>, wipeout_cache: RefCell<Option<(u64, Arc<Vec<HatchModel>>)>>,
/// Cached image models, keyed by geometry_epoch. Images do their own /// Cached image models, keyed by geometry_epoch. Images do their own
/// per-frame culling in the GPU pipeline (vp_scissor); no camera key /// per-frame culling in the GPU pipeline (vp_scissor); no camera key
/// needed here. /// needed here.
@@ -1061,32 +1061,30 @@ impl Scene {
} }
pub(super) fn hatch_models_arc(&self) -> Arc<Vec<HatchModel>> { pub(super) fn hatch_models_arc(&self) -> Arc<Vec<HatchModel>> {
let key = (self.geometry_epoch, self.camera_generation);
{ {
let cache = self.hatch_cache.borrow(); let cache = self.hatch_cache.borrow();
if let Some((cached_key, ref arc)) = *cache { if let Some((cached_epoch, ref arc)) = *cache {
if cached_key == key { if cached_epoch == self.geometry_epoch {
return Arc::clone(arc); return Arc::clone(arc);
} }
} }
} }
let arc = Arc::new(self.synced_hatch_models()); 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 arc
} }
pub(super) fn wipeout_models_arc(&self) -> Arc<Vec<HatchModel>> { pub(super) fn wipeout_models_arc(&self) -> Arc<Vec<HatchModel>> {
let key = (self.geometry_epoch, self.camera_generation);
{ {
let cache = self.wipeout_cache.borrow(); let cache = self.wipeout_cache.borrow();
if let Some((cached_key, ref arc)) = *cache { if let Some((cached_epoch, ref arc)) = *cache {
if cached_key == key { if cached_epoch == self.geometry_epoch {
return Arc::clone(arc); return Arc::clone(arc);
} }
} }
} }
let arc = Arc::new(self.wipeout_models()); 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 arc
} }
@@ -2482,33 +2480,16 @@ impl Scene {
.unwrap_or(false) .unwrap_or(false)
}; };
// Phase 2.1 — narrow the per-hatch visibility scan to candidates // synced_hatch_models is cached on geometry_epoch and the GPU
// the quadtree says intersect the view. The map below still does // upload is keyed on geometry_epoch only (see render.rs — hatch
// the heavy lifting (model clone + render_style + selection tint) // buffers are "static"). Don't view-cull here; the per-frame
// so cutting the pre-filter from O(N_hatches) → O(visible_hatches) // skip flag in compute_hatch_lod handles frustum + sub-pixel
// is the main win. // culling at draw time, which keeps the GPU upload set stable
let view_candidates: Option<std::collections::HashSet<Handle>> = // across pan/zoom.
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()
});
let mut models: Vec<HatchModel> = self let mut models: Vec<HatchModel> = self
.hatches .hatches
.iter() .iter()
.filter(|(&handle, _)| { .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 { let Some(entity) = self.document.get_entity(handle) else {
return true; return true;
}; };
@@ -2593,11 +2574,6 @@ impl Scene {
if fills.is_empty() { if fills.is_empty() {
continue; continue;
} }
if let Some(set) = view_candidates.as_ref() {
if !set.contains(&common.handle) {
continue;
}
}
if common.invisible || layer_hidden(&common.layer) { if common.invisible || layer_hidden(&common.layer) {
continue; continue;
} }
@@ -2643,29 +2619,17 @@ impl Scene {
} else { } else {
self.world_offset self.world_offset
}; };
// Phase 2.1 — quadtree pre-filter for wipeouts in Model layout. // No per-frame view-cull here: GPU wipeout buffer upload is
let view_candidates: Option<std::collections::HashSet<Handle>> = // gated on geometry_epoch only (see render.rs), so any cull at
self.view_world_aabb().map(|local_view| { // build time would freeze the visible subset at the geometry
let [ox, oy, _] = self.world_offset; // epoch boundary and never re-evaluate as the user pans. The
let view_wcs: [f64; 4] = [ // pipeline's `wipeout_skip_flags` (compute_wipeout_lod) does
local_view[0] as f64 + ox, // the per-frame skip at draw time instead.
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()
});
let mut models = Vec::new(); let mut models = Vec::new();
for entity in self.document.entities() { for entity in self.document.entities() {
let EntityType::Wipeout(wo) = entity else { let EntityType::Wipeout(wo) = entity else {
continue; continue;
}; };
if let Some(set) = view_candidates.as_ref() {
if !set.contains(&wo.common.handle) {
continue;
}
}
if entity.common().invisible { if entity.common().invisible {
continue; continue;
} }
+25 -1
View File
@@ -65,6 +65,10 @@ pub struct Pipeline {
hatch_skip_flags: Vec<bool>, hatch_skip_flags: Vec<bool>,
/// Wipeout fills — rendered after wires in a separate pass. /// Wipeout fills — rendered after wires in a separate pass.
gpu_wipeouts: Vec<HatchGpu>, gpu_wipeouts: Vec<HatchGpu>,
/// 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<bool>,
/// Pixel scissor rects [x, y, w, h] for viewport-clipped wipeouts. Recomputed each frame. /// Pixel scissor rects [x, y, w, h] for viewport-clipped wipeouts. Recomputed each frame.
wipeout_pixel_scissors: Vec<Option<[u32; 4]>>, wipeout_pixel_scissors: Vec<Option<[u32; 4]>>,
gpu_images: Vec<ImageGpu>, gpu_images: Vec<ImageGpu>,
@@ -622,6 +626,7 @@ impl Pipeline {
hatch_pixel_scissors: vec![], hatch_pixel_scissors: vec![],
hatch_skip_flags: vec![], hatch_skip_flags: vec![],
gpu_wipeouts: vec![], gpu_wipeouts: vec![],
wipeout_skip_flags: vec![],
wipeout_pixel_scissors: vec![], wipeout_pixel_scissors: vec![],
gpu_images: vec![], gpu_images: vec![],
image_pixel_scissors: vec![], image_pixel_scissors: vec![],
@@ -676,7 +681,11 @@ impl Pipeline {
self.hatch_skip_flags = self self.hatch_skip_flags = self
.gpu_hatches .gpu_hatches
.iter() .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(); .collect();
} }
@@ -689,6 +698,18 @@ impl Pipeline {
.collect(); .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. /// 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) { pub fn compute_image_scissors(&mut self, view_proj: glam::Mat4, clip_w: u32, clip_h: u32) {
self.image_pixel_scissors = self self.image_pixel_scissors = self
@@ -1098,6 +1119,9 @@ impl Pipeline {
pass.set_bind_group(0, &self.uniform_bind_group, &[]); pass.set_bind_group(0, &self.uniform_bind_group, &[]);
let mut scissor_active = false; let mut scissor_active = false;
for (i, wipeout) in self.gpu_wipeouts.iter().enumerate() { 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) { match self.wipeout_pixel_scissors.get(i) {
Some(Some([x, y, w, h])) => { Some(Some([x, y, w, h])) => {
pass.set_scissor_rect(*x, *y, *w, *h); pass.set_scissor_rect(*x, *y, *w, *h);
+1
View File
@@ -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_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_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_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); pipeline.compute_mesh_lod(self.uniforms.view_proj, clip_size.width, clip_size.height);
if self.show_viewcube { if self.show_viewcube {
pipeline.viewcube.upload( pipeline.viewcube.upload(