perf(render): hold model wire set static instead of re-tessellating on camera moves

The model-space wire geometry is camera-independent, yet every pan/zoom
re-tessellated the whole model and re-uploaded it to the GPU. On a
block/text-heavy drawing that is millions of segments, costing a ~100ms
re-tessellation plus a ~150ms GPU re-upload per camera change (~258ms
freeze) while the GPU itself draws the full set comfortably in real time.

Tessellate the full, un-culled model once per geometry epoch, hold it
resident, and return it for every camera and tile. A pan/zoom now only
changes the view uniform; no frustum cull, no zoom LOD, no re-tess or
re-upload on camera moves. Pan/zoom frame time drops to ~7ms (~140fps)
with no stutter.

Removes the now-dead per-tile cull cache, pan-invariant hash, AABB
containment helper, and the reuse-during-motion / settle-regen machinery
that an earlier iteration introduced. Also exempts Face3D from the
sub-pixel stub LOD: a flat face is trivially cheap to tessellate, so it
is drawn full at any zoom and no longer pops a cube stub at the threshold.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-26 16:06:11 +03:00
commit 14ebdd36d1
2 changed files with 65 additions and 139 deletions

View file

@ -62,13 +62,13 @@ use std::sync::Arc;
/// GPU Pipeline to skip re-uploading geometry when switching tabs. /// GPU Pipeline to skip re-uploading geometry when switching tabs.
static GEOMETRY_EPOCH: AtomicU64 = AtomicU64::new(1); static GEOMETRY_EPOCH: AtomicU64 = AtomicU64::new(1);
/// Process-wide monotonic id stamped on each Model-tile wire re-tessellation. /// Process-wide monotonic id stamped each time the Model wire set is built.
/// Reused (not re-stamped) when a pan reuses the cached tessellation, so it /// The set is held static across camera moves, so the id stays the same every
/// uniquely identifies a wire-buffer's *content* across frames. The GPU /// frame until the geometry epoch changes — it uniquely identifies a wire
/// pipeline gates wire re-upload on it (Phase 3.2): a pan that reuses the /// buffer's *content* across frames. The GPU pipeline gates wire re-upload on
/// tessellation keeps the same id, so the world-space wire buffer is not /// it: an unchanged id means the world-space wire buffer is not re-sent.
/// re-sent. Monotonic (never reused) → free of the ABA hazard a raw `Arc` /// Monotonic (never reused) → free of the ABA hazard a raw `Arc` pointer would
/// pointer would carry when an address is freed and reallocated. /// carry when an address is freed and reallocated.
static WIRE_CONTENT_GEN: AtomicU64 = AtomicU64::new(1); static WIRE_CONTENT_GEN: AtomicU64 = AtomicU64::new(1);
/// Resolve a viewport's paper-to-model scale ratio from its two /// Resolve a viewport's paper-to-model scale ratio from its two
@ -636,35 +636,6 @@ fn transform_block_mesh_lod_set(
out out
} }
/// Stable hash of a `Camera`'s pose for use as a per-tile cache key.
/// Two cameras with bit-identical target / rotation / distance hash the
/// same; any orbit / pan / zoom on a tile bumps it.
/// Hash of the camera state that affects *tessellation output* on the Model
/// tile, deliberately EXCLUDING the pan target. Rotation governs the
/// projection; `wpp` (world-per-pixel) governs the zoom-adaptive curve tol
/// and the sub-pixel LOD cull. Two cameras with the same value differ only by
/// pan, so the same tessellation is valid for both as long as the new view
/// still fits inside the region the wires were culled to (see
/// `model_tile_wires_arc`). Target is the one varying input on a pure pan.
fn camera_pan_invariant_hash(c: &Camera, wpp: Option<f32>) -> u64 {
fn h(state: u64, x: f32) -> u64 {
state.rotate_left(13) ^ x.to_bits() as u64
}
let mut s: u64 = 0x9e37_79b9_7f4a_7c15;
s = h(s, c.rotation.x);
s = h(s, c.rotation.y);
s = h(s, c.rotation.z);
s = h(s, c.rotation.w);
s = h(s, wpp.unwrap_or(-1.0));
s
}
/// `outer` fully contains `inner` (both `[min_x, min_y, max_x, max_y]`).
#[inline]
fn aabb_contains(outer: [f32; 4], inner: [f32; 4]) -> bool {
outer[0] <= inner[0] && outer[1] <= inner[1] && outer[2] >= inner[2] && outer[3] >= inner[3]
}
/// World-XY rectangle the model camera currently sees, expanded by `margin` /// World-XY rectangle the model camera currently sees, expanded by `margin`
/// (1.0 = tight), as `[min_x, min_y, max_x, max_y]` for the entity R-tree cull. /// (1.0 = tight), as `[min_x, min_y, max_x, max_y]` for the entity R-tree cull.
/// ///
@ -761,19 +732,6 @@ pub struct Scene {
/// invalidates the cull-dependent wire list as well as a geometry change. /// invalidates the cull-dependent wire list as well as a geometry change.
/// Uses `Arc` so `build_primitive()` avoids a full Vec clone during navigation. /// Uses `Arc` so `build_primitive()` avoids a full Vec clone during navigation.
wire_cache: RefCell<Option<((u64, u64), Arc<Vec<WireModel>>)>>, wire_cache: RefCell<Option<((u64, u64), Arc<Vec<WireModel>>)>>,
/// Per-Model-tile cached tessellation. Each tile has its own camera
/// (live for the active tile, stored snapshot for the others), so
/// LOD / frustum culling has to run independently — the shared
/// `wire_cache` would cull every tile against whichever camera was
/// current when it was last built. Keyed by tile index; the value
/// Carries `(geometry_epoch, pan_invariant_hash, tessellated_region)`.
/// A hit needs the epoch + the rotation/tol signature to match AND the
/// tile's current visible view to still fit inside the region the wires
/// were culled to — so a pure pan within that 1.25×-margin region reuses
/// the tessellation instead of rebuilding it. Zoom / orbit / edits change
/// the epoch or signature and rebuild as before.
model_tile_wire_cache:
RefCell<HashMap<usize, ((u64, u64, [f32; 4]), u64, Arc<Vec<WireModel>>)>>,
/// Index built from every SortEntitiesTable in the document. /// Index built from every SortEntitiesTable in the document.
/// 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.
@ -897,13 +855,18 @@ pub struct Scene {
pub(crate) last_tess_ms: std::cell::Cell<f32>, pub(crate) last_tess_ms: std::cell::Cell<f32>,
/// Wire count produced by that most recent re-tessellation. /// Wire count produced by that most recent re-tessellation.
pub(crate) last_tess_wires: std::cell::Cell<usize>, pub(crate) last_tess_wires: std::cell::Cell<usize>,
/// Content id ([`WIRE_CONTENT_GEN`]) of the Model-tile wire set returned by /// Content id ([`WIRE_CONTENT_GEN`]) of the Model wire set returned by the
/// the most recent `model_tile_wires_arc` call — stamped on a miss, reused /// most recent `model_tile_wires_arc` call — stamped when the static set is
/// on a pan-hit. `build_primitive` reads it right after the call to gate /// (re)built, otherwise the held value. `build_primitive` reads it right
/// GPU wire re-upload (Phase 3.2). 0 = none yet. /// after the call to gate GPU wire re-upload. 0 = none yet.
pub(crate) last_model_wire_gen: std::cell::Cell<u64>, pub(crate) last_model_wire_gen: std::cell::Cell<u64>,
/// Static-hold cache for the Model layout: the FULL, un-culled tessellation
/// held resident and reused for every camera (the geometry is
/// camera-independent — see `model_tile_wires_arc`). `(epoch, gen, wires)`;
/// rebuilt when `geometry_epoch` changes.
model_static_wires: RefCell<Option<(u64, u64, Arc<Vec<WireModel>>)>>,
/// Monotonic per-build nonce for wire sources that must NOT be skipped by /// Monotonic per-build nonce for wire sources that must NOT be skipped by
/// the Phase 3.2 upload gate — the paper / per-viewport wire paths and any /// the upload gate — the paper / per-viewport wire paths and any
/// frame carrying live preview / interim wires. High bit set so it can /// frame carrying live preview / interim wires. High bit set so it can
/// never collide with a real [`WIRE_CONTENT_GEN`] id; incremented every /// never collide with a real [`WIRE_CONTENT_GEN`] id; incremented every
/// use so the GPU always sees a fresh id and re-uploads. /// use so the GPU always sees a fresh id and re-uploads.
@ -963,7 +926,6 @@ impl Scene {
block_epoch: GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed), block_epoch: GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed),
selection_generation: 0, selection_generation: 0,
wire_cache: RefCell::new(None), wire_cache: RefCell::new(None),
model_tile_wire_cache: RefCell::new(HashMap::default()),
sort_cache: RefCell::new(None), sort_cache: RefCell::new(None),
draw_depth_cache: RefCell::new(None), draw_depth_cache: RefCell::new(None),
hatch_cache: RefCell::new(None), hatch_cache: RefCell::new(None),
@ -996,6 +958,7 @@ impl Scene {
last_tess_ms: std::cell::Cell::new(0.0), last_tess_ms: std::cell::Cell::new(0.0),
last_tess_wires: std::cell::Cell::new(0), last_tess_wires: std::cell::Cell::new(0),
last_model_wire_gen: std::cell::Cell::new(0), last_model_wire_gen: std::cell::Cell::new(0),
model_static_wires: RefCell::new(None),
wire_force_nonce: std::cell::Cell::new(0), wire_force_nonce: std::cell::Cell::new(0),
split_cache: RefCell::new(None), split_cache: RefCell::new(None),
tess_memo: RefCell::new(HashMap::default()), tess_memo: RefCell::new(HashMap::default()),
@ -2005,83 +1968,44 @@ impl Scene {
.collect() .collect()
} }
/// Per-tile cached tessellation for the Model layout. Each tile has /// Wire set for the Model layout, shared by every tile.
/// its own camera (live for the active tile, stored snapshot for the
/// others), so LOD / frustum culling has to run independently — the
/// shared `entity_wires_arc` cache would cull every tile against
/// whichever camera was current when it was last built.
/// ///
/// `cam_aspect` is the tile's pixel `width / height`; together with the /// The model wire geometry is **camera-independent**, so it is tessellated
/// camera's `ortho_size` it determines the world-XY rectangle culled /// in full (un-culled, fixed detail) once per geometry epoch, held resident
/// against. Returns a clone of the cached `Arc` on a key match. /// (`model_static_wires`), and returned for any camera/tile. A pan/zoom only
/// changes the view uniform — the GPU re-draws the same buffer, with no
/// frustum cull, no zoom LOD, and no re-tessellation/re-upload on camera
/// moves. The per-tile camera args are unused (kept for call-site symmetry
/// with the paper-space wire sources).
pub(super) fn model_tile_wires_arc( pub(super) fn model_tile_wires_arc(
&self, &self,
tile_idx: usize, _tile_idx: usize,
cam: &Camera, _cam: &Camera,
cam_aspect: f32, _cam_aspect: f32,
tile_pixel_height: f32, _tile_pixel_height: f32,
) -> Arc<Vec<WireModel>> { ) -> Arc<Vec<WireModel>> {
let wpp = if tile_pixel_height > 0.0 { // Reuse the resident full set if it's already built for this epoch.
Some((2.0 * cam.ortho_size()) / tile_pixel_height)
} else {
None
};
// Cache reuse is split from the exact-camera key: a pure pan keeps the
// geometry epoch and the pan-invariant signature (rotation + tol) but
// shifts the view. The cached wires were culled to a 1.25×-margin
// region; if the new *visible* view still sits inside that region they
// remain complete, so we can skip re-tessellation entirely — only the
// GPU re-uploads (camera_generation still bumps). No margin widening,
// so zoom (which changes the signature) costs exactly as before.
let pan_sig = camera_pan_invariant_hash(cam, wpp);
// Exact visible rect (margin 1.0) the reused wires must still cover.
// A tilted view returns no XY box (cull disabled) → require the cached
// region to be the full plane, so we only reuse a full tessellation.
let full = [f32::NEG_INFINITY, f32::NEG_INFINITY, f32::INFINITY, f32::INFINITY];
let need_region = if self.camera_generation == 0 {
full
} else {
view_cull_aabb(cam, cam_aspect, 1.0).unwrap_or(full)
};
{ {
let cache = self.model_tile_wire_cache.borrow(); let held = self.model_static_wires.borrow();
if let Some(((epoch, sig, region), gen, arc)) = cache.get(&tile_idx) { if let Some((epoch, gen, arc)) = held.as_ref() {
if *epoch == self.geometry_epoch if *epoch == self.geometry_epoch {
&& *sig == pan_sig
&& aabb_contains(*region, need_region)
{
// Pan-hit: reuse the tessellation AND its content id, so
// the GPU wire upload is skipped too (Phase 3.2).
self.last_model_wire_gen.set(*gen); self.last_model_wire_gen.set(*gen);
return Arc::clone(arc); return Arc::clone(arc);
} }
} }
} }
// Miss → cull/tessellate to the 1.25×-margin region (unchanged cost) // Build once: full tessellation, no cull (region = None), no zoom LOD
// and remember that region for the pan-reuse test above. // (wpp = None). Held for the life of this geometry epoch.
// A tilted view yields no XY box → `None` = cull nothing (full tess),
// which is correct, just heavier, for the less common 3D/elevation case.
let tess_region = if self.camera_generation == 0 {
None
} else {
view_cull_aabb(cam, cam_aspect, 1.25)
};
let block = self.model_space_block_handle(); let block = self.model_space_block_handle();
let t_tess = iced::time::Instant::now(); let t_tess = iced::time::Instant::now();
let mut wires = self.wires_for_block_culled(block, tess_region, wpp, None, None); let mut wires = self.wires_for_block_culled(block, None, None, None, None);
self.apply_refedit_fade(&mut wires, self.bg_color); self.apply_refedit_fade(&mut wires, self.bg_color);
let arc = Arc::new(wires);
self.last_tess_ms.set(t_tess.elapsed().as_secs_f32() * 1000.0); self.last_tess_ms.set(t_tess.elapsed().as_secs_f32() * 1000.0);
self.last_tess_wires.set(arc.len()); self.last_tess_wires.set(wires.len());
let stored_region = tess_region let arc = Arc::new(wires);
.unwrap_or([f32::NEG_INFINITY, f32::NEG_INFINITY, f32::INFINITY, f32::INFINITY]);
// New tessellation → fresh content id; the GPU will re-upload.
let gen = WIRE_CONTENT_GEN.fetch_add(1, Ordering::Relaxed); let gen = WIRE_CONTENT_GEN.fetch_add(1, Ordering::Relaxed);
self.last_model_wire_gen.set(gen); self.last_model_wire_gen.set(gen);
self.model_tile_wire_cache.borrow_mut().insert( *self.model_static_wires.borrow_mut() = Some((self.geometry_epoch, gen, Arc::clone(&arc)));
tile_idx,
((self.geometry_epoch, pan_sig, stored_region), gen, Arc::clone(&arc)),
);
arc arc
} }
@ -8345,10 +8269,14 @@ fn tessellate_entity(
// (baseline-line / greek / full) and must reach it // (baseline-line / greek / full) and must reach it
// even when projected size is sub-5 px. // even when projected size is sub-5 px.
let is_text = matches!(e, EntityType::Text(_) | EntityType::MText(_)); let is_text = matches!(e, EntityType::Text(_) | EntityType::MText(_));
// Face3D is exempt from the sub-pixel stub: it is trivially
// cheap to tessellate (4 corners → 2 tris), so there is no
// cost to draw it full at any zoom, and the cube-stub
// otherwise pops/coarsens flat faces across the threshold.
let is_face3d = matches!(e, EntityType::Face3D(_));
let is_3d_entity = matches!( let is_3d_entity = matches!(
e, e,
EntityType::Face3D(_) EntityType::Solid3D(_)
| EntityType::Solid3D(_)
| EntityType::Mesh(_) | EntityType::Mesh(_)
| EntityType::PolyfaceMesh(_) | EntityType::PolyfaceMesh(_)
| EntityType::PolygonMesh(_) | EntityType::PolygonMesh(_)
@ -8356,7 +8284,7 @@ fn tessellate_entity(
| EntityType::Region(_) | EntityType::Region(_)
| EntityType::Surface(_) | EntityType::Surface(_)
); );
if !is_text && w_px.max(h_px) / wpp < 5.0 { if !is_text && !is_face3d && w_px.max(h_px) / wpp < 5.0 {
// Sub-pixel entity: emit a stub instead of // Sub-pixel entity: emit a stub instead of
// nothing so it stays visible / selectable / // nothing so it stays visible / selectable /
// hit-test'able at any zoom. 2-D entities // hit-test'able at any zoom. 2-D entities

View file

@ -80,10 +80,10 @@ pub struct ViewportData {
/// Camera generation captured when this Primitive was assembled. Paired /// Camera generation captured when this Primitive was assembled. Paired
/// with `geometry_epoch` so the per-frame scissor / LOD recompute runs. /// with `geometry_epoch` so the per-frame scissor / LOD recompute runs.
pub(in crate::scene) camera_generation: u64, pub(in crate::scene) camera_generation: u64,
/// Content id of `wires` (Phase 3.2). Stable across a pure pan that reuses /// Content id of `wires`. Stable across camera moves (the Model wire set is
/// the Model-tile tessellation, so `prepare` skips re-uploading the /// held static), so `prepare` skips re-uploading the world-space wire buffer
/// world-space wire buffer when only the camera moved. Non-tile and /// when only the camera moved. Non-tile and preview/interim frames carry a
/// preview/interim frames carry a fresh id each time → always re-upload. /// fresh id each time → always re-upload.
pub(in crate::scene) wire_content_id: u64, pub(in crate::scene) wire_content_id: u64,
/// Selected handles only (no hover) — solid meshes tint these blue. /// Selected handles only (no hover) — solid meshes tint these blue.
pub(in crate::scene) selected_handles: Arc<rustc_hash::FxHashSet<acadrust::Handle>>, pub(in crate::scene) selected_handles: Arc<rustc_hash::FxHashSet<acadrust::Handle>>,
@ -264,11 +264,11 @@ impl shader::Primitive for Primitive {
} }
// Wire buffers are world-space, so a camera move alone doesn't // Wire buffers are world-space, so a camera move alone doesn't
// change them — only the view_proj uniform (uploaded every frame). // change them — only the view_proj uniform (uploaded every frame).
// Gate the upload on the wire content id instead of the camera tick // Gate the upload on the wire content id instead of the camera tick:
// (Phase 3.2): a pan that reused the Model-tile tessellation keeps // the Model wire set is held static, so its id is unchanged across
// the id, skipping the vertex re-pack + GPU write. Kept independent // camera moves and the vertex re-pack + GPU write is skipped. Kept
// of the `cur_key` block so a preview/interim wire change still // independent of the `cur_key` block so a preview/interim wire change
// uploads even when the camera didn't move. // still uploads even when the camera didn't move.
if vp.wire_content_id != inner.cached_wire_id { if vp.wire_content_id != inner.cached_wire_id {
inner.upload_wires(device, &vp.wires[..], &vp.draw_depths); inner.upload_wires(device, &vp.wires[..], &vp.draw_depths);
inner.cached_wire_id = vp.wire_content_id; inner.cached_wire_id = vp.wire_content_id;
@ -663,14 +663,12 @@ impl Scene {
let us = (visible_w / full.width).clamp(0.0, 1.0); let us = (visible_w / full.width).clamp(0.0, 1.0);
let vs = (visible_h / full.height).clamp(0.0, 1.0); let vs = (visible_h / full.height).clamp(0.0, 1.0);
// Model tiles each get their own tessellation cache keyed off the // Model tiles all share one resident, camera-independent wire set
// tile's camera — culling/LOD depend on per-tile zoom, not the // (`model_tile_wires_arc` holds it static). `tile_wire_gen` is that
// active tile's. Paper-space content viewports already had a // set's content id: stable across camera moves, so the GPU wire upload
// per-viewport cache. // and the Face3D split below are skipped every frame the geometry is
// `tile_wire_gen` is the Model-tile content id (Phase 3.2): present // unchanged. The paper-space sources have no stable id and force a
// only on the tiled Model path, where a pan reuses the tessellation // re-upload.
// and its id so the GPU wire upload can be skipped. The other wire
// sources don't participate and force a re-upload below.
let (base_arc, tile_wire_gen) = if let Some(tile_idx) = inst.tile_idx { let (base_arc, tile_wire_gen) = if let Some(tile_idx) = inst.tile_idx {
let aspect = if full.height > 0.0 { let aspect = if full.height > 0.0 {
full.width / full.height full.width / full.height
@ -703,9 +701,9 @@ impl Scene {
} }
}; };
// Split Face3D wires from the rest. The split is content-only (keyed // Split Face3D wires from the rest. The split is content-only (keyed
// by the tile's wire id), so on a pan-reuse it's memoized rather than // by the wire-set content id), so while the geometry is unchanged it's
// re-walking every wire (handle lookup + clone) each frame. Non-tile // memoized rather than re-walking every wire (handle lookup + clone)
// paths have no stable id and split inline as before. // each frame. Non-tile paths have no stable id and split inline.
let (face3d_wires, other_arc) = match tile_wire_gen { let (face3d_wires, other_arc) = match tile_wire_gen {
Some(gen) => { Some(gen) => {
let cached = { let cached = {