perf(scene): pixel-size LOD cull at zoom-out
Frustum culling only helps at zoom-in (offscreen geometry); zoom-out
still tessellated and uploaded every wire even when an entire entity
projected to a single pixel. Now we also drop wires whose world AABB
projects smaller than 0.5px in the active view — a fit-all view of a
city plan skips sub-pixel symbols, lines, and curves before they ever
hit the GPU.
- `Scene` captures `world_per_pixel` each render from camera ortho_size
/ widget height.
- `expand_insert` / `tessellate_entity` take an optional
`world_per_pixel`; per-LocalWire / per-nested / whole-Insert /
top-level entity AABB → pixel-size test → skip if < MIN_PIXEL_SIZE.
- One-off paths (hit-test, viewport pane) pass `None` so they still
see full geometry regardless of zoom.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
0996b75bdd
commit
c5ed83c752
3 changed files with 133 additions and 36 deletions
|
|
@ -25,6 +25,10 @@ use crate::scene::tessellate;
|
||||||
use crate::scene::wire_model::{SnapHint, TangentGeom, WireModel};
|
use crate::scene::wire_model::{SnapHint, TangentGeom, WireModel};
|
||||||
|
|
||||||
const MAX_NESTING_DEPTH: usize = 32;
|
const MAX_NESTING_DEPTH: usize = 32;
|
||||||
|
/// Skip wires whose world-AABB projects to fewer than this many pixels in
|
||||||
|
/// the active view. Picks up sub-pixel detail at extreme zoom-out so the
|
||||||
|
/// tessellator doesn't waste time on geometry the user can't see anyway.
|
||||||
|
const MIN_PIXEL_SIZE: f32 = 0.5;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct LocalWire {
|
pub struct LocalWire {
|
||||||
|
|
@ -401,6 +405,9 @@ pub fn expand_insert(
|
||||||
// comparison is in the same f32 space as emitted wires). `None` disables
|
// comparison is in the same f32 space as emitted wires). `None` disables
|
||||||
// frustum culling — every cached sub is emitted.
|
// frustum culling — every cached sub is emitted.
|
||||||
view_aabb: Option<[f32; 4]>,
|
view_aabb: Option<[f32; 4]>,
|
||||||
|
// World units per screen pixel. When `Some`, wires whose AABB projects
|
||||||
|
// smaller than `MIN_PIXEL_SIZE` get skipped entirely (LOD).
|
||||||
|
world_per_pixel: Option<f32>,
|
||||||
) -> Option<Vec<WireModel>> {
|
) -> Option<Vec<WireModel>> {
|
||||||
let defn = cache.defn(&ins.block_name)?;
|
let defn = cache.defn(&ins.block_name)?;
|
||||||
let xform = ins.get_transform();
|
let xform = ins.get_transform();
|
||||||
|
|
@ -409,20 +416,27 @@ pub fn expand_insert(
|
||||||
let mut visited: Vec<String> = Vec::with_capacity(8);
|
let mut visited: Vec<String> = Vec::with_capacity(8);
|
||||||
let [ox, oy, _] = world_offset;
|
let [ox, oy, _] = world_offset;
|
||||||
|
|
||||||
// Whole-Insert cull: if the Insert's world AABB doesn't intersect the
|
let insert_world = transform_aabb_xy(defn.aabb_local, &xform);
|
||||||
// view, bail without doing any per-sub work.
|
let insert_local = [
|
||||||
|
insert_world[0] - ox as f32,
|
||||||
|
insert_world[1] - oy as f32,
|
||||||
|
insert_world[2] - ox as f32,
|
||||||
|
insert_world[3] - oy as f32,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Whole-Insert frustum cull.
|
||||||
if let Some(view) = view_aabb {
|
if let Some(view) = view_aabb {
|
||||||
let insert_world = transform_aabb_xy(defn.aabb_local, &xform);
|
|
||||||
let insert_local = [
|
|
||||||
(insert_world[0] - ox as f32),
|
|
||||||
(insert_world[1] - oy as f32),
|
|
||||||
(insert_world[2] - ox as f32),
|
|
||||||
(insert_world[3] - oy as f32),
|
|
||||||
];
|
|
||||||
if aabb_disjoint_xy(insert_local, view) {
|
if aabb_disjoint_xy(insert_local, view) {
|
||||||
return Some(vec![]);
|
return Some(vec![]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Whole-Insert pixel-size LOD: if the entire Insert footprint projects
|
||||||
|
// to sub-pixel size, skip it entirely.
|
||||||
|
if let Some(wpp) = world_per_pixel {
|
||||||
|
if aabb_pixel_size(insert_local, wpp) < MIN_PIXEL_SIZE {
|
||||||
|
return Some(vec![]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for offset in &array_offsets(ins) {
|
for offset in &array_offsets(ins) {
|
||||||
let base_xform = if offset == &[0.0; 3] {
|
let base_xform = if offset == &[0.0; 3] {
|
||||||
|
|
@ -443,12 +457,19 @@ pub fn expand_insert(
|
||||||
world_offset,
|
world_offset,
|
||||||
pslt_factor,
|
pslt_factor,
|
||||||
view_aabb,
|
view_aabb,
|
||||||
|
world_per_pixel,
|
||||||
};
|
};
|
||||||
expand_defn(defn, &base_xform, &ctx, &mut batches, &mut visited, 0);
|
expand_defn(defn, &base_xform, &ctx, &mut batches, &mut visited, 0);
|
||||||
}
|
}
|
||||||
Some(batches.finalize(&name, selected))
|
Some(batches.finalize(&name, selected))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn aabb_pixel_size(local_aabb: [f32; 4], world_per_pixel: f32) -> f32 {
|
||||||
|
let w = (local_aabb[2] - local_aabb[0]).abs();
|
||||||
|
let h = (local_aabb[3] - local_aabb[1]).abs();
|
||||||
|
w.max(h) / world_per_pixel
|
||||||
|
}
|
||||||
|
|
||||||
struct ExpandCtx<'a> {
|
struct ExpandCtx<'a> {
|
||||||
cache: &'a BlockCache,
|
cache: &'a BlockCache,
|
||||||
ins_color: [f32; 4],
|
ins_color: [f32; 4],
|
||||||
|
|
@ -460,6 +481,8 @@ struct ExpandCtx<'a> {
|
||||||
pslt_factor: f32,
|
pslt_factor: f32,
|
||||||
// World-space XY view AABB (post world_offset). `None` = no culling.
|
// World-space XY view AABB (post world_offset). `None` = no culling.
|
||||||
view_aabb: Option<[f32; 4]>,
|
view_aabb: Option<[f32; 4]>,
|
||||||
|
// World units per screen pixel. `None` = no pixel-size LOD.
|
||||||
|
world_per_pixel: Option<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Style fingerprint used to group local wires into a single GPU buffer.
|
/// Style fingerprint used to group local wires into a single GPU buffer.
|
||||||
|
|
@ -605,19 +628,24 @@ fn expand_defn(
|
||||||
for sub in &defn.subs {
|
for sub in &defn.subs {
|
||||||
match sub {
|
match sub {
|
||||||
LocalSub::Wire(lw) => {
|
LocalSub::Wire(lw) => {
|
||||||
|
let world = transform_aabb_xy(lw.aabb_local, accum_xform);
|
||||||
|
let [ox, oy, _] = ctx.world_offset;
|
||||||
|
let local = [
|
||||||
|
world[0] - ox as f32,
|
||||||
|
world[1] - oy as f32,
|
||||||
|
world[2] - ox as f32,
|
||||||
|
world[3] - oy as f32,
|
||||||
|
];
|
||||||
if let Some(view) = ctx.view_aabb {
|
if let Some(view) = ctx.view_aabb {
|
||||||
let world = transform_aabb_xy(lw.aabb_local, accum_xform);
|
|
||||||
let [ox, oy, _] = ctx.world_offset;
|
|
||||||
let local = [
|
|
||||||
world[0] - ox as f32,
|
|
||||||
world[1] - oy as f32,
|
|
||||||
world[2] - ox as f32,
|
|
||||||
world[3] - oy as f32,
|
|
||||||
];
|
|
||||||
if aabb_disjoint_xy(local, view) {
|
if aabb_disjoint_xy(local, view) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(wpp) = ctx.world_per_pixel {
|
||||||
|
if aabb_pixel_size(local, wpp) < MIN_PIXEL_SIZE {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
emit_wire(lw, accum_xform, ctx, out);
|
emit_wire(lw, accum_xform, ctx, out);
|
||||||
}
|
}
|
||||||
LocalSub::Nested(nref) => {
|
LocalSub::Nested(nref) => {
|
||||||
|
|
@ -628,22 +656,27 @@ fn expand_defn(
|
||||||
let Some(nested_defn) = ctx.cache.defn(&nref.block_name) else {
|
let Some(nested_defn) = ctx.cache.defn(&nref.block_name) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
// Nested-INSERT cull: union AABB of the nested defn, transformed
|
// Nested-INSERT cull: union AABB of the nested defn,
|
||||||
// by composed xform, vs view rect.
|
// transformed by composed xform, vs view rect + pixel size.
|
||||||
|
let composed = nref.xform.then(accum_xform);
|
||||||
|
let world = transform_aabb_xy(nested_defn.aabb_local, &composed);
|
||||||
|
let [ox, oy, _] = ctx.world_offset;
|
||||||
|
let local = [
|
||||||
|
world[0] - ox as f32,
|
||||||
|
world[1] - oy as f32,
|
||||||
|
world[2] - ox as f32,
|
||||||
|
world[3] - oy as f32,
|
||||||
|
];
|
||||||
if let Some(view) = ctx.view_aabb {
|
if let Some(view) = ctx.view_aabb {
|
||||||
let composed = nref.xform.then(accum_xform);
|
|
||||||
let world = transform_aabb_xy(nested_defn.aabb_local, &composed);
|
|
||||||
let [ox, oy, _] = ctx.world_offset;
|
|
||||||
let local = [
|
|
||||||
world[0] - ox as f32,
|
|
||||||
world[1] - oy as f32,
|
|
||||||
world[2] - ox as f32,
|
|
||||||
world[3] - oy as f32,
|
|
||||||
];
|
|
||||||
if aabb_disjoint_xy(local, view) {
|
if aabb_disjoint_xy(local, view) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(wpp) = ctx.world_per_pixel {
|
||||||
|
if aabb_pixel_size(local, wpp) < MIN_PIXEL_SIZE {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
// Resolve ByBlock for this nested ref against the outer ctx.
|
// Resolve ByBlock for this nested ref against the outer ctx.
|
||||||
let nested_color = if nref.color_is_byblock {
|
let nested_color = if nref.color_is_byblock {
|
||||||
ctx.ins_color
|
ctx.ins_color
|
||||||
|
|
@ -670,6 +703,7 @@ fn expand_defn(
|
||||||
world_offset: ctx.world_offset,
|
world_offset: ctx.world_offset,
|
||||||
pslt_factor: ctx.pslt_factor,
|
pslt_factor: ctx.pslt_factor,
|
||||||
view_aabb: ctx.view_aabb,
|
view_aabb: ctx.view_aabb,
|
||||||
|
world_per_pixel: ctx.world_per_pixel,
|
||||||
};
|
};
|
||||||
visited.push(nref.block_name.clone());
|
visited.push(nref.block_name.clone());
|
||||||
for offset in &nref.instance_offsets {
|
for offset in &nref.instance_offsets {
|
||||||
|
|
|
||||||
|
|
@ -293,6 +293,11 @@ pub struct Scene {
|
||||||
/// Last viewport aspect ratio captured by the render pipeline. Used by
|
/// Last viewport aspect ratio captured by the render pipeline. Used by
|
||||||
/// `view_world_aabb` to compute the world-space view rect on demand.
|
/// `view_world_aabb` to compute the world-space view rect on demand.
|
||||||
last_render_aspect: std::cell::Cell<f32>,
|
last_render_aspect: std::cell::Cell<f32>,
|
||||||
|
/// World units that map to one screen pixel at the current camera +
|
||||||
|
/// viewport size, captured each render. Drives the LOD pixel-size cull
|
||||||
|
/// in expand_insert / tessellate_entity. 0 means "not yet set" — culling
|
||||||
|
/// falls back to None.
|
||||||
|
last_world_per_pixel: std::cell::Cell<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Scene {
|
impl Scene {
|
||||||
|
|
@ -330,6 +335,7 @@ impl Scene {
|
||||||
entity_block_map_cache: RefCell::new(None),
|
entity_block_map_cache: RefCell::new(None),
|
||||||
block_defn_cache: RefCell::new(None),
|
block_defn_cache: RefCell::new(None),
|
||||||
last_render_aspect: std::cell::Cell::new(16.0 / 9.0),
|
last_render_aspect: std::cell::Cell::new(16.0 / 9.0),
|
||||||
|
last_world_per_pixel: std::cell::Cell::new(0.0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -376,6 +382,33 @@ impl Scene {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// World units per screen pixel at the current viewport size. Returns
|
||||||
|
/// `None` until the first render captures real bounds.
|
||||||
|
pub(super) fn world_per_pixel(&self) -> Option<f32> {
|
||||||
|
let v = self.last_world_per_pixel.get();
|
||||||
|
if v > 0.0 && v.is_finite() {
|
||||||
|
Some(v)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called from the render path with the current widget bounds so the
|
||||||
|
/// LOD pixel-size culler knows how big one world unit projects to.
|
||||||
|
pub fn set_render_pixel_scale(&self, width_px: f32, height_px: f32) {
|
||||||
|
if !width_px.is_finite() || !height_px.is_finite() || height_px <= 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cam = self.camera.borrow();
|
||||||
|
// Orthographic only. (Perspective varies with depth — we'd want a
|
||||||
|
// depth-aware scale per entity. Skipped for now.)
|
||||||
|
let h = cam.ortho_size();
|
||||||
|
let world_per_px = (2.0 * h) / height_px;
|
||||||
|
if world_per_px.is_finite() && world_per_px > 0.0 {
|
||||||
|
self.last_world_per_pixel.set(world_per_px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get (or build on miss) the block-definition cache for the current epoch.
|
/// Get (or build on miss) the block-definition cache for the current epoch.
|
||||||
/// Built single-threaded — recursive nested expansion makes parallelization
|
/// Built single-threaded — recursive nested expansion makes parallelization
|
||||||
/// fiddly and the cache only rebuilds when geometry actually changes.
|
/// fiddly and the cache only rebuilds when geometry actually changes.
|
||||||
|
|
@ -1006,10 +1039,22 @@ impl Scene {
|
||||||
let blk_cache = self.block_cache_arc();
|
let blk_cache = self.block_cache_arc();
|
||||||
let blk_ref: &block_cache::BlockCache = &blk_cache;
|
let blk_ref: &block_cache::BlockCache = &blk_cache;
|
||||||
let view_aabb = self.view_world_aabb();
|
let view_aabb = self.view_world_aabb();
|
||||||
|
let wpp = self.world_per_pixel();
|
||||||
let mut wires: Vec<WireModel> = visible
|
let mut wires: Vec<WireModel> = visible
|
||||||
.into_par_iter()
|
.into_par_iter()
|
||||||
.flat_map(|e| {
|
.flat_map(|e| {
|
||||||
tessellate_entity(doc, sel, avp, woff, bg, anno, e, Some(blk_ref), view_aabb)
|
tessellate_entity(
|
||||||
|
doc,
|
||||||
|
sel,
|
||||||
|
avp,
|
||||||
|
woff,
|
||||||
|
bg,
|
||||||
|
anno,
|
||||||
|
e,
|
||||||
|
Some(blk_ref),
|
||||||
|
view_aabb,
|
||||||
|
wpp,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|
@ -1119,6 +1164,7 @@ impl Scene {
|
||||||
e,
|
e,
|
||||||
Some(&blk_cache),
|
Some(&blk_cache),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3636,6 +3682,7 @@ impl Scene {
|
||||||
e,
|
e,
|
||||||
Some(&blk_cache),
|
Some(&blk_cache),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
|
|
@ -3829,21 +3876,35 @@ fn tessellate_entity(
|
||||||
// World-space XY view AABB (post `world_offset` subtraction). When
|
// World-space XY view AABB (post `world_offset` subtraction). When
|
||||||
// `Some`, entities whose AABB doesn't intersect this rect are skipped.
|
// `Some`, entities whose AABB doesn't intersect this rect are skipped.
|
||||||
view_aabb: Option<[f32; 4]>,
|
view_aabb: Option<[f32; 4]>,
|
||||||
|
// World units per screen pixel for LOD culling. `None` = no LOD.
|
||||||
|
world_per_pixel: Option<f32>,
|
||||||
) -> Vec<WireModel> {
|
) -> Vec<WireModel> {
|
||||||
let h = e.common().handle;
|
let h = e.common().handle;
|
||||||
let sel = selected.contains(&h);
|
let sel = selected.contains(&h);
|
||||||
|
|
||||||
// Frustum cull for non-Insert, non-Viewport entities. Insert is handled
|
// Frustum + LOD cull for non-Insert, non-Viewport entities. Insert is
|
||||||
// separately (its WCS bbox depends on the block defn AABB × Insert
|
// handled separately (its WCS bbox depends on the block defn AABB ×
|
||||||
// transform — done inside expand_insert). Viewports always emit so the
|
// Insert transform — done inside expand_insert). Viewports always emit
|
||||||
// viewport frame stays visible regardless of zoom.
|
// so the viewport frame stays visible regardless of zoom.
|
||||||
if let Some(view) = view_aabb {
|
let needs_cull = view_aabb.is_some() || world_per_pixel.is_some();
|
||||||
|
if needs_cull {
|
||||||
match e {
|
match e {
|
||||||
EntityType::Viewport(_) | EntityType::Insert(_) => {}
|
EntityType::Viewport(_) | EntityType::Insert(_) => {}
|
||||||
_ => {
|
_ => {
|
||||||
let ab = entity_aabb(e, world_offset);
|
let ab = entity_aabb(e, world_offset);
|
||||||
if ab != WireModel::UNBOUNDED_AABB && block_cache::aabb_disjoint_xy(ab, view) {
|
if ab != WireModel::UNBOUNDED_AABB {
|
||||||
return vec![];
|
if let Some(view) = view_aabb {
|
||||||
|
if block_cache::aabb_disjoint_xy(ab, view) {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(wpp) = world_per_pixel {
|
||||||
|
let w = (ab[2] - ab[0]).abs();
|
||||||
|
let h = (ab[3] - ab[1]).abs();
|
||||||
|
if w.max(h) / wpp < 0.5 {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3980,6 +4041,7 @@ fn tessellate_entity(
|
||||||
world_offset,
|
world_offset,
|
||||||
pslt_factor,
|
pslt_factor,
|
||||||
view_aabb,
|
view_aabb,
|
||||||
|
world_per_pixel,
|
||||||
) {
|
) {
|
||||||
wires.push(marker);
|
wires.push(marker);
|
||||||
return wires;
|
return wires;
|
||||||
|
|
|
||||||
|
|
@ -321,6 +321,7 @@ impl Scene {
|
||||||
// a correct culling rectangle before entity_wires_arc() runs.
|
// a correct culling rectangle before entity_wires_arc() runs.
|
||||||
if bounds.height > 0.0 {
|
if bounds.height > 0.0 {
|
||||||
self.set_render_aspect(bounds.width / bounds.height);
|
self.set_render_aspect(bounds.width / bounds.height);
|
||||||
|
self.set_render_pixel_scale(bounds.width, bounds.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
let entity_arc = self.entity_wires_arc();
|
let entity_arc = self.entity_wires_arc();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue