perf(viewport): per-vp frustum + LOD cull in paper layout

`model_wires_for_viewport` previously passed `view_aabb: None` and
`wpp: None` to `tessellate_entity` — the assumption that paper-space
viewports were too small for culling to matter falls over the moment
the document carries 100k+ entities. Each frame re-tessellated the
full model through every floating viewport.

`wires_for_block_culled` grows two optional parameters
(`frozen_layers`, `anno_scale_override`) and now flips its
`world_offset` / `bg` / `anno` selection on `is_model_block` rather
than `current_layout`, so a paper-layout caller asking for the model
block still gets WCS-subtracted coordinates and the right annotation
scale.

`model_wires_for_viewport`:
- derives the viewport's frustum AABB from its (paper-zoom-invariant)
  camera + entity aspect — 25 % margin to match `view_world_aabb`,
- derives `wpp` from the on-paper pixel height the viewport currently
  occupies, so LOD stubs and zoom-adaptive curve sampling track the
  paper-zoom state,
- delegates everything else to `wires_for_block_culled` so block /
  layer / frozen-layer / curve-tolerance handling stays in one place.

`model_wires_for_viewport_arc` keys on `(geometry_epoch,
round(screen_height_px))` — sub-pixel jitter still hits the cache,
real paper-zoom steps invalidate. Hit-test / CPU-projection callers
pass `screen_height_px = 0.0` to get the no-LOD (full-fidelity) wire
list in their own cache slot.
This commit is contained in:
Hakan Seven committed 2026-05-28 20:32:16 +03:00
1 parent 0eaac008dd
commit 1eb54ee8a3
2 files changed
+109 -86

No files matched your search

+108 -85
View File
@@ -552,7 +552,7 @@ pub struct Scene {
mesh_cache: RefCell<Option<(u64, Arc<Vec<MeshLodSet>>)>>,
/// Per-viewport wire cache for paper-space rendering.
/// Maps vp_handle → (geometry_epoch, Arc<Vec<WireModel>>).
viewport_wire_cache: RefCell<HashMap<Handle, (u64, Arc<Vec<WireModel>>)>>,
viewport_wire_cache: RefCell<HashMap<Handle, ((u64, u32), Arc<Vec<WireModel>>)>>,
/// Cached tessellation of paper-space layout block entities (title block, annotations, etc.).
/// Separate from `wire_cache` so paper_canvas_wires() doesn't re-tessellate on every frame.
/// Keyed by `(geometry_epoch, camera_generation)` — paper view changes
@@ -1207,7 +1207,7 @@ impl Scene {
None
};
let block = self.model_space_block_handle();
let arc = Arc::new(self.wires_for_block_culled(block, view_aabb, wpp));
let arc = Arc::new(self.wires_for_block_culled(block, view_aabb, wpp, None, None));
self.model_tile_wire_cache
.borrow_mut()
.insert(tile_idx, (key, Arc::clone(&arc)));
@@ -1419,12 +1419,15 @@ impl Scene {
/// Tessellate all non-invisible entities owned by `block_handle`.
fn wires_for_block(&self, block_handle: Handle) -> Vec<WireModel> {
// Default culling is driven by the live `Scene::camera`. Multi-tile
// Model layouts call `wires_for_block_culled` directly with each
// tile's own camera so the per-tile LOD / frustum cull is independent.
// Model layouts and paper-space content viewports call
// `wires_for_block_culled` directly with their own per-view cull
// parameters so each pane culls independently.
self.wires_for_block_culled(
block_handle,
self.view_world_aabb(),
self.world_per_pixel(),
None,
None,
)
}
@@ -1433,6 +1436,15 @@ impl Scene {
block_handle: Handle,
view_aabb: Option<[f32; 4]>,
wpp: Option<f32>,
// Layers frozen specifically through the requesting viewport.
// Hidden in addition to the document-level off / frozen flags.
// `None` skips the per-viewport check (Model-space callers).
frozen_layers: Option<&HashSet<Handle>>,
// Paper-space content viewports compute their own annotation
// scale from `vp_effective_scale`; the Model-space and paper-
// sheet paths use `self.annotation_scale` / 1.0 respectively.
// `None` selects the default branch on `current_layout`.
anno_scale_override: Option<f32>,
) -> Vec<WireModel> {
use acadrust::objects::ObjectType;
@@ -1477,15 +1489,19 @@ impl Scene {
if matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_)) {
return false;
}
if self
.document
.layers
.get(&c.layer)
.map(|l| l.flags.off || l.flags.frozen)
.unwrap_or(false)
{
let layer = self.document.layers.get(&c.layer);
if layer.map(|l| l.flags.off || l.flags.frozen).unwrap_or(false) {
return false;
}
if let Some(frozen) = frozen_layers {
if !frozen.is_empty() {
if let Some(lh) = layer.map(|l| l.handle) {
if frozen.contains(&lh) {
return false;
}
}
}
}
self.belongs_to_visible_block(e.common().handle, c.owner_handle, block_handle)
};
@@ -1545,9 +1561,12 @@ impl Scene {
let doc = &self.document;
let sel = &self.selected;
let avp = self.active_viewport;
// Paper-space entities live in sheet coordinates (mm), not model-world
// coordinates, so world_offset must not be subtracted from them.
let woff = if self.current_layout == "Model" {
// A paper-space content viewport renders MODEL block entities while
// the user is sitting in a paper layout — that path expects
// `world_offset` subtraction even though `current_layout != "Model"`.
// Decide based on the block being tessellated, not the layout.
let is_model_block = block_handle == self.model_space_block_handle();
let woff = if is_model_block {
self.world_offset
} else {
[0.0; 3]
@@ -1557,7 +1576,9 @@ impl Scene {
} else {
self.paper_bg_color
};
let anno = if self.current_layout == "Model" {
let anno = if let Some(a) = anno_scale_override {
a
} else if self.current_layout == "Model" {
self.annotation_scale
} else {
1.0
@@ -2075,7 +2096,10 @@ impl Scene {
// ── Use cached tessellation (model_wires_for_viewport_arc) ────
// This eliminates the per-frame tessellate_one() loop that was here
// previously; tessellation is now O(1) on navigation frames.
let model_wires = self.model_wires_for_viewport_arc(vp_handle);
// Pass 0.0 for screen height — the CPU-projection / hit-test
// path wants the full-fidelity (no-LOD-stub) wire list,
// regardless of paper zoom.
let model_wires = self.model_wires_for_viewport_arc(vp_handle, 0.0);
// ── Project and clip wires into viewport ──────────────────────
let vp_x0 = pcx - hw;
@@ -5286,11 +5310,20 @@ impl Scene {
}
/// Collect model-space WireModels visible through `vp_handle`, respecting
/// global layer visibility and the viewport's per-viewport layer freeze list.
fn model_wires_for_viewport(&self, vp_handle: Handle) -> Vec<WireModel> {
/// global layer visibility, the viewport's per-viewport layer freeze list,
/// and the per-viewport frustum + LOD cull derived from
/// `screen_height_px` (the on-paper pixel height of this viewport).
fn model_wires_for_viewport(
&self,
vp_handle: Handle,
screen_height_px: f32,
) -> Vec<WireModel> {
use std::collections::HashSet as HSet;
let (frozen, vp_anno_scale) = match self.document.get_entity(vp_handle) {
let (frozen, vp_anno_scale, vp_aspect, vp_target, vp_ortho_h) = match self
.document
.get_entity(vp_handle)
{
Some(EntityType::Viewport(vp)) => {
let f: HSet<Handle> = vp.frozen_layers.iter().cloned().collect();
let vp_scale =
@@ -5300,88 +5333,78 @@ impl Scene {
} else {
1.0_f32
};
(f, anno)
let aspect = if vp.height > 1e-9 {
(vp.width / vp.height) as f32
} else {
1.0_f32
};
let half_h = (vp.view_height as f32) * 0.5;
let target = glam::Vec3::new(
(vp.view_target.x - self.world_offset[0]) as f32,
(vp.view_target.y - self.world_offset[1]) as f32,
(vp.view_target.z - self.world_offset[2]) as f32,
);
(f, anno, aspect, target, half_h)
}
_ => (HSet::new(), 1.0_f32),
_ => (HSet::new(), 1.0_f32, 1.0_f32, glam::Vec3::ZERO, 1.0_f32),
};
let model_block = self.model_space_block_handle();
let blk_cache = self.block_cache_arc();
// The visible background inside a paper-layout content viewport is
// the PaperCanvas sheet showing through the transparent shader,
// not the model bg. Adapt entity colours to the paper bg in that
// case so white wires on a light sheet turn black (and vice
// versa). In a Model layout this is unreachable in practice
// (active_viewport is a paper concept), but fall back to model bg
// just to stay consistent with the rest of the renderer.
let bg = if self.current_layout == "Model" {
self.bg_color
// Per-viewport frustum AABB in the same f32 wire space (already
// `world_offset`-subtracted). Matches the margin used by
// `view_world_aabb` so pan inertia doesn't pop edges.
let half_w = vp_ortho_h * vp_aspect.max(0.01);
let margin = 1.25_f32;
let view_aabb = Some([
vp_target.x - half_w * margin,
vp_target.y - vp_ortho_h * margin,
vp_target.x + half_w * margin,
vp_target.y + vp_ortho_h * margin,
]);
// World units per on-screen pixel for LOD substitution + curve
// tolerance. Tracks the paper-zoom-driven pixel height the
// viewport currently occupies.
let wpp = if screen_height_px > 1.0 {
Some((2.0 * vp_ortho_h) / screen_height_px)
} else {
self.paper_bg_color
None
};
self.document
.entities()
.filter(|e| {
let c = e.common();
if c.invisible || matches!(e, EntityType::Viewport(_)) {
return false;
}
if !self.belongs_to_visible_block(c.handle, c.owner_handle, model_block) {
return false;
}
if self
.document
.layers
.get(&c.layer)
.map(|l| l.flags.off || l.flags.frozen)
.unwrap_or(false)
{
return false;
}
if !frozen.is_empty() {
if let Some(lh) = self.document.layers.get(&c.layer).map(|l| l.handle) {
if frozen.contains(&lh) {
return false;
}
}
}
true
})
.flat_map(|e| {
// Per-viewport tessellation uses the viewport's own camera —
// not the model-space camera — so we don't pass a view_aabb
// here. (Viewports are typically small enough that culling
// them isn't worth the added complexity.)
tessellate_entity(
&self.document,
&self.selected,
self.active_viewport,
self.world_offset,
bg,
vp_anno_scale,
e,
Some(&blk_cache),
None,
None,
)
})
.collect()
self.wires_for_block_culled(
self.model_space_block_handle(),
view_aabb,
wpp,
Some(&frozen),
Some(vp_anno_scale),
)
}
pub(super) fn model_wires_for_viewport_arc(&self, vp_handle: Handle) -> Arc<Vec<WireModel>> {
/// Cached per-paper-viewport tessellation. Each viewport's wpp tracks
/// the on-paper pixel height (paper-zoom dependent), so the cache key
/// includes a quantized form of that height in addition to the
/// geometry epoch — every paper zoom step that actually changes the
/// LOD bucket invalidates this viewport's entry.
pub(super) fn model_wires_for_viewport_arc(
&self,
vp_handle: Handle,
screen_height_px: f32,
) -> Arc<Vec<WireModel>> {
// Drop sub-pixel noise so trivial paper-zoom jitter does not
// re-tessellate a 100k-entity drawing every frame; round to an
// integer pixel.
let height_key = screen_height_px.max(1.0).round() as u32;
let key = (self.geometry_epoch, height_key);
{
let cache = self.viewport_wire_cache.borrow();
if let Some((cached_epoch, ref arc)) = cache.get(&vp_handle) {
if *cached_epoch == self.geometry_epoch {
if let Some((cached_key, ref arc)) = cache.get(&vp_handle) {
if *cached_key == key {
return Arc::clone(arc);
}
}
}
let arc = Arc::new(self.model_wires_for_viewport(vp_handle));
let arc = Arc::new(self.model_wires_for_viewport(vp_handle, screen_height_px));
self.viewport_wire_cache
.borrow_mut()
.insert(vp_handle, (self.geometry_epoch, Arc::clone(&arc)));
.insert(vp_handle, (key, Arc::clone(&arc)));
arc
}
}
+1 -1
View File
@@ -594,7 +594,7 @@ impl Scene {
} else if inst.handle == acadrust::Handle::NULL {
self.entity_wires_arc()
} else {
self.model_wires_for_viewport_arc(inst.handle)
self.model_wires_for_viewport_arc(inst.handle, full.height)
};
let (face3d_wires, other_wires) = split_face3d_wires(&base_arc, &self.document);
let all_wires = if self.interim_wire.is_none() && self.preview_wires.is_empty() {