From f99f1cd7a5fb0cd8b2ed6eb22b5f60a61c825218 Mon Sep 17 00:00:00 2001 From: sLuCHa Date: Sun, 5 Jul 2026 08:11:35 +0200 Subject: [PATCH 1/2] perf(render): cache rendered scene, skip re-raster on unchanged frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every cursor movement over the viewport re-rendered the entire GPU scene (all wires, hatches, meshes) even when nothing visible changed: iced redraws the whole window on each input message, and the shader's draw/prepare/render runs every time. On large drawings one such redraw exceeds the frame budget, so moving the mouse or panning stutters — and it gets worse the bigger the drawing. The pipeline already resolves each frame into a persistent, clip-sized resolve texture and blits that to the surface. This hashes the inputs that determine the rendered image (camera uniforms, geometry/selection generations, wire content id, render-mode flags, clip size, live preview coordinates) into a per-viewport signature. When a frame's signature matches the previous one the image is pixel-identical, so `prepare` skips the buffer uploads + per-frame scissor/LOD recompute and `render` skips passes 1-7 plus the MSAA resolve, re-blitting the resolve texture instead. A pure cursor move becomes a single fullscreen blit rather than an O(N) re-rasterization. The ViewCube is excluded from the signature (it renders in its own always-on pass) so cube hover still tracks while the scene is cached. Any real change — edit (geometry_epoch), select/hover (selection_generation), camera move or render-mode toggle — differs the signature and forces a full render, so nothing goes stale. Navigation still renders every frame (the view genuinely changes); this targets the wasted re-render on cursor-only movement. --- src/scene/pipeline/mod.rs | 23 ++++++++++++ src/scene/view/render.rs | 73 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 8f878824c..68cd426c4 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -178,6 +178,19 @@ 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, } impl Pipeline { @@ -1156,6 +1169,8 @@ 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, } } @@ -1553,6 +1568,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 { @@ -2101,6 +2123,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..d9f3bd194 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -235,6 +235,35 @@ 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; + 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 +461,50 @@ 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); + 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 From a55e1ac4f8232e96bcc0fc209fa60fce3f752a7f Mon Sep 17 00:00:00 2001 From: sLuCHa Date: Sun, 5 Jul 2026 09:48:49 +0200 Subject: [PATCH 2/2] perf(render): skip hatch pass while navigating (interaction LOD) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The batched hatch pass evaluates its fill procedurally per fragment — a point-in-polygon test over the whole boundary plus the pattern test, with a `discard` that disables early-Z. When the view sits inside a hatch its quad covers the screen, so panning / zooming re-runs that full-screen shader every frame (~40 ms/frame on a hatch-heavy plan) even though the rest of the scene is cheap — the wire pass and CPU prepare are negligible by comparison. Suppress the hatch pass while the view is actively panning / zooming / orbiting, detected from `camera_generation` plus a short settle timer (`Scene::navigating_lod`), and fold that state into the render signature so the moment the view settles it renders hatches once and the scene-render cache holds that full-quality frame. A brief `is_settling` subscription keeps requesting frames just past the settle point so the hatched frame renders even when no input event would otherwise fire. Mirrors AutoCAD's adaptive degradation. The paper sheet keeps its fills. --- src/app/view/mod.rs | 11 ++++++++++ src/scene/mod.rs | 42 +++++++++++++++++++++++++++++++++++++++ src/scene/pipeline/mod.rs | 19 +++++++++++++----- src/scene/view/render.rs | 15 ++++++++++++++ 4 files changed, 82 insertions(+), 5 deletions(-) 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,