diff --git a/src/scene/block_cache.rs b/src/scene/block_cache.rs index 66041389b..b015bf56d 100644 --- a/src/scene/block_cache.rs +++ b/src/scene/block_cache.rs @@ -25,6 +25,10 @@ use crate::scene::tessellate; use crate::scene::wire_model::{SnapHint, TangentGeom, WireModel}; 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)] pub struct LocalWire { @@ -401,6 +405,9 @@ pub fn expand_insert( // comparison is in the same f32 space as emitted wires). `None` disables // frustum culling — every cached sub is emitted. 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, ) -> Option> { let defn = cache.defn(&ins.block_name)?; let xform = ins.get_transform(); @@ -409,20 +416,27 @@ pub fn expand_insert( let mut visited: Vec = Vec::with_capacity(8); let [ox, oy, _] = world_offset; - // Whole-Insert cull: if the Insert's world AABB doesn't intersect the - // view, bail without doing any per-sub work. + 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, + ]; + + // Whole-Insert frustum cull. 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) { 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) { let base_xform = if offset == &[0.0; 3] { @@ -443,12 +457,19 @@ pub fn expand_insert( world_offset, pslt_factor, view_aabb, + world_per_pixel, }; expand_defn(defn, &base_xform, &ctx, &mut batches, &mut visited, 0); } 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> { cache: &'a BlockCache, ins_color: [f32; 4], @@ -460,6 +481,8 @@ struct ExpandCtx<'a> { pslt_factor: f32, // World-space XY view AABB (post world_offset). `None` = no culling. view_aabb: Option<[f32; 4]>, + // World units per screen pixel. `None` = no pixel-size LOD. + world_per_pixel: Option, } /// Style fingerprint used to group local wires into a single GPU buffer. @@ -605,19 +628,24 @@ fn expand_defn( for sub in &defn.subs { match sub { 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 { - 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) { 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); } LocalSub::Nested(nref) => { @@ -628,22 +656,27 @@ fn expand_defn( let Some(nested_defn) = ctx.cache.defn(&nref.block_name) else { continue; }; - // Nested-INSERT cull: union AABB of the nested defn, transformed - // by composed xform, vs view rect. + // Nested-INSERT cull: union AABB of the nested defn, + // 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 { - 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) { 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. let nested_color = if nref.color_is_byblock { ctx.ins_color @@ -670,6 +703,7 @@ fn expand_defn( world_offset: ctx.world_offset, pslt_factor: ctx.pslt_factor, view_aabb: ctx.view_aabb, + world_per_pixel: ctx.world_per_pixel, }; visited.push(nref.block_name.clone()); for offset in &nref.instance_offsets { diff --git a/src/scene/mod.rs b/src/scene/mod.rs index d6fb14bb5..d9069126f 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -293,6 +293,11 @@ pub struct Scene { /// Last viewport aspect ratio captured by the render pipeline. Used by /// `view_world_aabb` to compute the world-space view rect on demand. last_render_aspect: std::cell::Cell, + /// 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, } impl Scene { @@ -330,6 +335,7 @@ impl Scene { entity_block_map_cache: RefCell::new(None), block_defn_cache: RefCell::new(None), 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 { + 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. /// Built single-threaded — recursive nested expansion makes parallelization /// 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_ref: &block_cache::BlockCache = &blk_cache; let view_aabb = self.view_world_aabb(); + let wpp = self.world_per_pixel(); let mut wires: Vec = visible .into_par_iter() .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(); @@ -1119,6 +1164,7 @@ impl Scene { e, Some(&blk_cache), None, + None, ) } @@ -3636,6 +3682,7 @@ impl Scene { e, Some(&blk_cache), None, + None, ) }) .collect() @@ -3829,21 +3876,35 @@ fn tessellate_entity( // World-space XY view AABB (post `world_offset` subtraction). When // `Some`, entities whose AABB doesn't intersect this rect are skipped. view_aabb: Option<[f32; 4]>, + // World units per screen pixel for LOD culling. `None` = no LOD. + world_per_pixel: Option, ) -> Vec { let h = e.common().handle; let sel = selected.contains(&h); - // Frustum cull for non-Insert, non-Viewport entities. Insert is handled - // separately (its WCS bbox depends on the block defn AABB × Insert - // transform — done inside expand_insert). Viewports always emit so the - // viewport frame stays visible regardless of zoom. - if let Some(view) = view_aabb { + // Frustum + LOD cull for non-Insert, non-Viewport entities. Insert is + // handled separately (its WCS bbox depends on the block defn AABB × + // Insert transform — done inside expand_insert). Viewports always emit + // so the viewport frame stays visible regardless of zoom. + let needs_cull = view_aabb.is_some() || world_per_pixel.is_some(); + if needs_cull { match e { EntityType::Viewport(_) | EntityType::Insert(_) => {} _ => { let ab = entity_aabb(e, world_offset); - if ab != WireModel::UNBOUNDED_AABB && block_cache::aabb_disjoint_xy(ab, view) { - return vec![]; + if ab != WireModel::UNBOUNDED_AABB { + 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, pslt_factor, view_aabb, + world_per_pixel, ) { wires.push(marker); return wires; diff --git a/src/scene/render.rs b/src/scene/render.rs index 595b96ccf..c132465f8 100644 --- a/src/scene/render.rs +++ b/src/scene/render.rs @@ -321,6 +321,7 @@ impl Scene { // a correct culling rectangle before entity_wires_arc() runs. if bounds.height > 0.0 { self.set_render_aspect(bounds.width / bounds.height); + self.set_render_pixel_scale(bounds.width, bounds.height); } let entity_arc = self.entity_wires_arc();