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 8f878824c..4f437f407 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -178,6 +178,23 @@ pub struct Pipeline { /// gather just the highlighted entity's wires (`O(highlighted)`) instead of /// scanning and string-parsing every wire on each hover change. wire_handle_index: rustc_hash::FxHashMap>, + /// Signature of the last frame's rendered scene (camera uniforms, geometry + /// / selection generations, wire content id, render-mode flags, clip size, + /// live preview). When the next frame's signature matches, the image is + /// pixel-identical, so `render` skips every geometry pass + the MSAA + /// resolve and only re-blits the resolve texture (which still holds it). + /// This turns a pure cursor move over a large drawing from an O(N) + /// re-rasterization into a single fullscreen blit. `u64::MAX` = nothing + /// rendered yet (forces the first frame to render fully). + pub render_sig: u64, + /// Set by `prepare` each frame: `true` when this frame's signature matched + /// `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 { @@ -1156,6 +1173,9 @@ impl Pipeline { cached_mesh_key: (u64::MAX, u64::MAX), cached_face3d_key: (u64::MAX, false), wire_handle_index: rustc_hash::FxHashMap::default(), + render_sig: u64::MAX, + skip_geometry: false, + skip_hatch_frame: false, } } @@ -1553,6 +1573,13 @@ impl Pipeline { a: a as f64, }; + // Scene-render cache: when `prepare` found this frame pixel-identical + // to the last (unchanged view / geometry / selection / preview), the + // resolve texture already holds the image. Skip every geometry pass + + // the MSAA resolve and fall straight through to the blit below. This + // is what makes a pure cursor move cost one fullscreen blit instead of + // re-rasterizing the whole drawing every frame. + if !self.skip_geometry { // ── Pass 1: hatch fills ──────────────────────────────────────────── { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { @@ -1589,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); + } } } @@ -2101,6 +2132,7 @@ impl Pipeline { }); // No draw calls — the pass itself triggers the MSAA resolve. } + } // end `if !self.skip_geometry` // ── Blit resolve texture → surface target at surface_dest position ── // The viewport maps the NDC quad to exactly `surface_dest` in the diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 59676a50f..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. @@ -235,6 +240,37 @@ impl shader::Primitive for Primitive { let (uo_y, us_y) = uv_crop_axis(sr.y, sr.height); inner.upload_blit_uv(queue, [uo_x, uo_y], [us_x, us_y]); inner.upload_uniforms(queue, &vp.uniforms); + + // ── Scene-render cache ──────────────────────────────────────── + // A pure cursor move — or any frame where the view, geometry, + // selection and live preview are all unchanged — produces a + // pixel-identical image. The resolve texture still holds it, so we + // skip every geometry pass + the MSAA resolve (in `Pipeline::render` + // via `skip_geometry`) and its per-frame O(N) scissor / LOD + // recompute below, letting the frame reduce to a single blit. This + // is the main fix for the per-mouse-move stall that scales with + // drawing size. The ViewCube is excluded from the signature and + // keeps updating in its own always-on pass, so cube hover still + // tracks while the scene is cached. + let sig = render_signature(vp, clip_size.width, clip_size.height); + 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( + queue, + vp.cam_rotation, + vp.compass_rotation, + (vp.screen_rect.width * bounds.width) as u32, + (vp.screen_rect.height * bounds.height) as u32, + vp.hover_region, + ); + } + continue; + } // Third component is the *selected-set* signature (not // selection_generation, which also bumps on hover) so a rollover // doesn't re-upload the static hatch / face3d buffers. @@ -432,6 +468,53 @@ impl shader::Primitive for Primitive { } } +/// Hash of everything that determines one viewport's rendered scene image. +/// Two consecutive frames with the same signature are pixel-identical, so the +/// second may skip the geometry passes and re-blit the resolve texture (see the +/// scene-render cache in `Primitive::prepare` / `Pipeline::render`). +/// +/// Deliberately EXCLUDES `hover_region` — the ViewCube highlight renders in its +/// own always-on pass, so cube hover must not force a full scene re-render. The +/// live preview IS included (its coordinates), so a rubber-band tracking the +/// cursor still renders, and the frame where the preview clears erases it +/// instead of freezing the last overlay on screen. +fn render_signature(vp: &ViewportData, clip_w: u32, clip_h: u32) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = rustc_hash::FxHasher::default(); + // Camera + per-view shading flags all live in the uniforms (view_rot, eye + // high/low, viewport size, lineweight, flat_shade, transparency) — hashing + // the raw POD bytes captures every pan / zoom / orbit / twist and toggle in + // one shot. Identical camera state recomputes to identical bits, so a still + // view never spuriously misses the cache. + bytemuck::bytes_of(&vp.uniforms).hash(&mut h); + vp.geometry_epoch.hash(&mut h); + vp.selection_generation.hash(&mut h); + vp.selected_sig.hash(&mut h); + vp.wire_content_id.hash(&mut h); + vp.fill_mode.hash(&mut h); + vp.view_wireframe.hash(&mut h); + 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 + // of wires — so hashing its coordinates is cheap and catches the endpoint + // moving with the cursor as well as the preview appearing / clearing. + for w in vp.preview_wires.iter() { + w.points.len().hash(&mut h); + for p in &w.points { + p[0].to_bits().hash(&mut h); + p[1].to_bits().hash(&mut h); + p[2].to_bits().hash(&mut h); + } + } + h.finish() +} + /// On-canvas-visible UV crop on one axis. `pos` and `size` are in the /// shader widget's normalized 0..1 coords. Returns `(uv_offset, uv_scale)` /// applied as `actual_uv = quad_uv * uv_scale + uv_offset` in the blit @@ -990,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,