From 14ebdd36d1fe22d484ec930b7080a6aa08dd39a1 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 26 Jun 2026 16:06:11 +0300 Subject: [PATCH] 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 --- src/scene/mod.rs | 166 +++++++++++---------------------------- src/scene/view/render.rs | 38 +++++---- 2 files changed, 65 insertions(+), 139 deletions(-) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 114b3e7c5..e3022dcab 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -62,13 +62,13 @@ use std::sync::Arc; /// GPU Pipeline to skip re-uploading geometry when switching tabs. static GEOMETRY_EPOCH: AtomicU64 = AtomicU64::new(1); -/// Process-wide monotonic id stamped on each Model-tile wire re-tessellation. -/// Reused (not re-stamped) when a pan reuses the cached tessellation, so it -/// uniquely identifies a wire-buffer's *content* across frames. The GPU -/// pipeline gates wire re-upload on it (Phase 3.2): a pan that reuses the -/// tessellation keeps the same id, so the world-space wire buffer is not -/// re-sent. Monotonic (never reused) → free of the ABA hazard a raw `Arc` -/// pointer would carry when an address is freed and reallocated. +/// Process-wide monotonic id stamped each time the Model wire set is built. +/// The set is held static across camera moves, so the id stays the same every +/// frame until the geometry epoch changes — it uniquely identifies a wire +/// buffer's *content* across frames. The GPU pipeline gates wire re-upload on +/// it: an unchanged id means the world-space wire buffer is not re-sent. +/// Monotonic (never reused) → free of the ABA hazard a raw `Arc` pointer would +/// carry when an address is freed and reallocated. static WIRE_CONTENT_GEN: AtomicU64 = AtomicU64::new(1); /// Resolve a viewport's paper-to-model scale ratio from its two @@ -636,35 +636,6 @@ fn transform_block_mesh_lod_set( 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) -> 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` /// (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. /// Uses `Arc` so `build_primitive()` avoids a full Vec clone during navigation. wire_cache: RefCell>)>>, - /// 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>)>>, /// Index built from every SortEntitiesTable in the document. /// Maps block_handle → (entity_handle.value() → sort_handle.value()). /// 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, /// Wire count produced by that most recent re-tessellation. pub(crate) last_tess_wires: std::cell::Cell, - /// Content id ([`WIRE_CONTENT_GEN`]) of the Model-tile wire set returned by - /// the most recent `model_tile_wires_arc` call — stamped on a miss, reused - /// on a pan-hit. `build_primitive` reads it right after the call to gate - /// GPU wire re-upload (Phase 3.2). 0 = none yet. + /// Content id ([`WIRE_CONTENT_GEN`]) of the Model wire set returned by the + /// most recent `model_tile_wires_arc` call — stamped when the static set is + /// (re)built, otherwise the held value. `build_primitive` reads it right + /// after the call to gate GPU wire re-upload. 0 = none yet. pub(crate) last_model_wire_gen: std::cell::Cell, + /// 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>)>>, /// 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 /// never collide with a real [`WIRE_CONTENT_GEN`] id; incremented every /// 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), selection_generation: 0, wire_cache: RefCell::new(None), - model_tile_wire_cache: RefCell::new(HashMap::default()), sort_cache: RefCell::new(None), draw_depth_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_wires: 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), split_cache: RefCell::new(None), tess_memo: RefCell::new(HashMap::default()), @@ -2005,83 +1968,44 @@ impl Scene { .collect() } - /// Per-tile cached tessellation for the Model layout. 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 `entity_wires_arc` cache would cull every tile against - /// whichever camera was current when it was last built. + /// Wire set for the Model layout, shared by every tile. /// - /// `cam_aspect` is the tile's pixel `width / height`; together with the - /// camera's `ortho_size` it determines the world-XY rectangle culled - /// against. Returns a clone of the cached `Arc` on a key match. + /// The model wire geometry is **camera-independent**, so it is tessellated + /// in full (un-culled, fixed detail) once per geometry epoch, held resident + /// (`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( &self, - tile_idx: usize, - cam: &Camera, - cam_aspect: f32, - tile_pixel_height: f32, + _tile_idx: usize, + _cam: &Camera, + _cam_aspect: f32, + _tile_pixel_height: f32, ) -> Arc> { - let wpp = if tile_pixel_height > 0.0 { - 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) - }; + // Reuse the resident full set if it's already built for this epoch. { - let cache = self.model_tile_wire_cache.borrow(); - if let Some(((epoch, sig, region), gen, arc)) = cache.get(&tile_idx) { - 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). + let held = self.model_static_wires.borrow(); + if let Some((epoch, gen, arc)) = held.as_ref() { + if *epoch == self.geometry_epoch { self.last_model_wire_gen.set(*gen); return Arc::clone(arc); } } } - // Miss → cull/tessellate to the 1.25×-margin region (unchanged cost) - // and remember that region for the pan-reuse test above. - // 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) - }; + // Build once: full tessellation, no cull (region = None), no zoom LOD + // (wpp = None). Held for the life of this geometry epoch. let block = self.model_space_block_handle(); 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); - let arc = Arc::new(wires); self.last_tess_ms.set(t_tess.elapsed().as_secs_f32() * 1000.0); - self.last_tess_wires.set(arc.len()); - let stored_region = tess_region - .unwrap_or([f32::NEG_INFINITY, f32::NEG_INFINITY, f32::INFINITY, f32::INFINITY]); - // New tessellation → fresh content id; the GPU will re-upload. + self.last_tess_wires.set(wires.len()); + let arc = Arc::new(wires); let gen = WIRE_CONTENT_GEN.fetch_add(1, Ordering::Relaxed); self.last_model_wire_gen.set(gen); - self.model_tile_wire_cache.borrow_mut().insert( - tile_idx, - ((self.geometry_epoch, pan_sig, stored_region), gen, Arc::clone(&arc)), - ); + *self.model_static_wires.borrow_mut() = Some((self.geometry_epoch, gen, Arc::clone(&arc))); arc } @@ -8345,10 +8269,14 @@ fn tessellate_entity( // (baseline-line / greek / full) and must reach it // even when projected size is sub-5 px. 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!( e, - EntityType::Face3D(_) - | EntityType::Solid3D(_) + EntityType::Solid3D(_) | EntityType::Mesh(_) | EntityType::PolyfaceMesh(_) | EntityType::PolygonMesh(_) @@ -8356,7 +8284,7 @@ fn tessellate_entity( | EntityType::Region(_) | 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 // nothing so it stays visible / selectable / // hit-test'able at any zoom. 2-D entities diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index ba67795dc..dc2dc04ae 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -80,10 +80,10 @@ pub struct ViewportData { /// Camera generation captured when this Primitive was assembled. Paired /// with `geometry_epoch` so the per-frame scissor / LOD recompute runs. pub(in crate::scene) camera_generation: u64, - /// Content id of `wires` (Phase 3.2). Stable across a pure pan that reuses - /// the Model-tile tessellation, so `prepare` skips re-uploading the - /// world-space wire buffer when only the camera moved. Non-tile and - /// preview/interim frames carry a fresh id each time → always re-upload. + /// Content id of `wires`. Stable across camera moves (the Model wire set is + /// held static), so `prepare` skips re-uploading the world-space wire buffer + /// when only the camera moved. Non-tile and preview/interim frames carry a + /// fresh id each time → always re-upload. pub(in crate::scene) wire_content_id: u64, /// Selected handles only (no hover) — solid meshes tint these blue. pub(in crate::scene) selected_handles: Arc>, @@ -264,11 +264,11 @@ impl shader::Primitive for Primitive { } // Wire buffers are world-space, so a camera move alone doesn't // change them — only the view_proj uniform (uploaded every frame). - // 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 id, skipping the vertex re-pack + GPU write. Kept independent - // of the `cur_key` block so a preview/interim wire change still - // uploads even when the camera didn't move. + // Gate the upload on the wire content id instead of the camera tick: + // the Model wire set is held static, so its id is unchanged across + // camera moves and the vertex re-pack + GPU write is skipped. Kept + // independent of the `cur_key` block so a preview/interim wire change + // still uploads even when the camera didn't move. if vp.wire_content_id != inner.cached_wire_id { inner.upload_wires(device, &vp.wires[..], &vp.draw_depths); 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 vs = (visible_h / full.height).clamp(0.0, 1.0); - // Model tiles each get their own tessellation cache keyed off the - // tile's camera — culling/LOD depend on per-tile zoom, not the - // active tile's. Paper-space content viewports already had a - // per-viewport cache. - // `tile_wire_gen` is the Model-tile content id (Phase 3.2): present - // only on the tiled Model path, where a pan reuses the tessellation - // and its id so the GPU wire upload can be skipped. The other wire - // sources don't participate and force a re-upload below. + // Model tiles all share one resident, camera-independent wire set + // (`model_tile_wires_arc` holds it static). `tile_wire_gen` is that + // set's content id: stable across camera moves, so the GPU wire upload + // and the Face3D split below are skipped every frame the geometry is + // unchanged. The paper-space sources have no stable id and force a + // re-upload. let (base_arc, tile_wire_gen) = if let Some(tile_idx) = inst.tile_idx { let aspect = if full.height > 0.0 { full.width / full.height @@ -703,9 +701,9 @@ impl Scene { } }; // 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 - // re-walking every wire (handle lookup + clone) each frame. Non-tile - // paths have no stable id and split inline as before. + // by the wire-set content id), so while the geometry is unchanged it's + // memoized rather than re-walking every wire (handle lookup + clone) + // each frame. Non-tile paths have no stable id and split inline. let (face3d_wires, other_arc) = match tile_wire_gen { Some(gen) => { let cached = {