diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 69483e3e7..84772aa0b 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1462,6 +1462,16 @@ impl OpenCADStudio { } else { Subscription::none() }; + // Interaction LOD: while the view is (or just was) navigating, keep + // requesting frames a little past the settle point. Panning itself is + // driven by input events, but once the cursor stops no event would fire + // the one full-quality frame that re-renders hatches — this tick does, + // then the scene-render cache holds it and the subscription auto-stops. + let nav_settle = if self.tabs[self.active_tab].scene.is_settling() { + window::frames().map(Message::Tick) + } else { + Subscription::none() + }; // Blink the MText preview caret while the editor is open. let caret_blink = if self.mtext_editor.is_some() { iced::time::every(std::time::Duration::from_millis(530)) @@ -1493,6 +1503,7 @@ impl OpenCADStudio { history_tick, grip_dwell, hover_dwell, + nav_settle, caret_blink, web_fonts, autosave, diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 86b1e7b04..14b8f7506 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -901,6 +901,13 @@ pub struct Scene { /// (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, + /// Interaction-LOD state: `camera_generation` seen on the previous frame and + /// the wall time it last changed. Used to detect "the view is actively being + /// panned / zoomed / orbited" so the expensive per-pixel hatch pass can be + /// suppressed while moving and rendered once on settle (held by the + /// scene-render cache). See [`Scene::navigating_lod`]. + nav_last_gen: std::cell::Cell, + nav_changed_at: 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)`; @@ -1002,6 +1009,8 @@ 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), + nav_last_gen: std::cell::Cell::new(0), + nav_changed_at: std::cell::Cell::new(None), model_static_wires: RefCell::new(None), wire_force_nonce: std::cell::Cell::new(0), split_cache: RefCell::new(None), @@ -1159,6 +1168,39 @@ impl Scene { self.selection_generation = self.selection_generation.wrapping_add(1); } + /// Milliseconds after the last camera change during which the view counts as + /// "actively navigating" for interaction-LOD purposes. + const NAV_SETTLE_MS: u128 = 130; + + /// Interaction LOD: true while the view is actively being panned / zoomed / + /// orbited. Detected purely from `camera_generation` (bumped by every camera + /// move) plus a short settle timer, so no navigation call site needs to opt + /// in. Called on the render path; it stamps the change time as a side effect. + /// + /// While this is true the per-pixel hatch pass is skipped (it dominates the + /// GPU frame — a full-screen procedural pattern/boundary test). When it flips + /// back to false on settle, the frame renders hatches once and the + /// scene-render cache holds that image, so a still view is full quality. + pub fn navigating_lod(&self) -> bool { + let gen = self.camera_generation; + if gen != self.nav_last_gen.get() { + self.nav_last_gen.set(gen); + self.nav_changed_at.set(Some(iced::time::Instant::now())); + } + self.nav_changed_at + .get() + .map_or(false, |t| t.elapsed().as_millis() < Self::NAV_SETTLE_MS) + } + + /// True for a short window around navigation — used by the subscription to + /// keep requesting frames just past the settle point so the one full-quality + /// (hatched) frame actually renders after the cursor stops, even when no + /// input event would otherwise trigger a redraw. Read-only (no side effect). + pub fn is_settling(&self) -> bool { + self.nav_changed_at + .get() + .map_or(false, |t| t.elapsed().as_millis() < Self::NAV_SETTLE_MS + 130) + } /// Re-evaluate every cached mesh's color through `render_style` so a /// Register a Model-tab solid: cache its truck B-rep (for boolean ops) and diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 68cd426c4..4f437f407 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -191,6 +191,10 @@ pub struct Pipeline { /// `render_sig`, so `render` may skip the geometry passes. Read (never /// written) by `render`, which always runs after `prepare` for the frame. pub skip_geometry: bool, + /// Interaction LOD: set by `prepare` when the view is actively navigating, so + /// `render` skips the (per-pixel, GPU-dominating) hatch pass this frame. The + /// scene-render cache holds the full-quality frame once the view settles. + pub skip_hatch_frame: bool, } impl Pipeline { @@ -1171,6 +1175,7 @@ impl Pipeline { wire_handle_index: rustc_hash::FxHashMap::default(), render_sig: u64::MAX, skip_geometry: false, + skip_hatch_frame: false, } } @@ -1611,11 +1616,15 @@ impl Pipeline { if let (Some(batch), Some(pipeline)) = (&self.gpu_hatch_batched, &self.hatch_batched_pipeline) { - pass.set_pipeline(pipeline); - pass.set_bind_group(0, &self.uniform_bind_group, &[]); - pass.set_bind_group(1, &batch.bind_group, &[]); - pass.set_vertex_buffer(0, batch.vertex_buffer.slice(..)); - pass.draw(0..batch.vertex_count, 0..1); + // Skipped while navigating (interaction LOD) — the per-pixel + // hatch pass dominates the GPU frame on hatch-heavy drawings. + if !self.skip_hatch_frame { + pass.set_pipeline(pipeline); + pass.set_bind_group(0, &self.uniform_bind_group, &[]); + pass.set_bind_group(1, &batch.bind_group, &[]); + pass.set_vertex_buffer(0, batch.vertex_buffer.slice(..)); + pass.draw(0..batch.vertex_count, 0..1); + } } } diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index d9f3bd194..d94a6f018 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -79,6 +79,11 @@ pub struct ViewportData { /// occluded by closer geometry are culled by the LessEqual depth /// test on the wire passes that follow. pub(in crate::scene) hidden_line: bool, + /// Interaction LOD: when true the (per-pixel, GPU-dominating) hatch pass is + /// skipped this frame because the view is actively being navigated. Folded + /// into the render signature so the settle frame re-renders hatches once and + /// the scene-render cache holds it. See [`Scene::navigating_lod`]. + pub(in crate::scene) skip_hatch: bool, pub(in crate::scene) geometry_epoch: u64, /// Camera generation captured when this Primitive was assembled. Paired /// with `geometry_epoch` so the per-frame scissor / LOD recompute runs. @@ -251,6 +256,8 @@ impl shader::Primitive for Primitive { let skip = inner.render_sig != u64::MAX && sig == inner.render_sig; inner.render_sig = sig; inner.skip_geometry = skip; + // Interaction LOD: skip the hatch draw this frame while navigating. + inner.skip_hatch_frame = vp.skip_hatch; if skip { if vp.show_viewcube { inner.viewcube.upload( @@ -489,6 +496,9 @@ fn render_signature(vp: &ViewportData, clip_w: u32, clip_h: u32) -> u64 { vp.mesh_fill.hash(&mut h); vp.show_3d_edges.hash(&mut h); vp.hidden_line.hash(&mut h); + // Interaction-LOD hatch suppression: differs the signature so the settle + // frame (skip_hatch flips false) re-renders with hatches and re-caches. + vp.skip_hatch.hash(&mut h); clip_w.hash(&mut h); clip_h.hash(&mut h); // Live overlay (command preview / interim / grip drag). Small — a handful @@ -1063,6 +1073,11 @@ impl Scene { mesh_fill: flags.mesh_fill, show_3d_edges: flags.show_3d_edges, hidden_line: flags.hidden_line, + // Interaction LOD: suppress the costly hatch pass while the view is + // actively moving; the scene-render cache holds the full-quality + // (hatched) frame once it settles. Only applied to the on-screen + // Model / paper content — the paper *sheet* keeps its fills. + skip_hatch: !inst.paper_sheet && self.navigating_lod(), geometry_epoch: self.geometry_epoch, camera_generation: self.camera_generation, wire_content_id,