Merge PR #273: perf(render) scene-render cache + hatch interaction LOD

Skip re-rasterizing an unchanged frame (per-viewport render signature →
re-blit the cached resolve texture) and suppress the full-screen hatch
pass while navigating, rendering hatches once on settle. Reviewed:
signature covers every pixel-affecting input, per-viewport caches are
isolated, LOD settle timing renders + caches the full-quality frame, no
busy-loop. Targets #258.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-05 11:35:42 +03:00
commit d0639b3e38
4 files changed
+178 -5

No files matched your search

+11
View File
@@ -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,
+42
View File
@@ -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<u64>,
/// 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<u64>,
nav_changed_at: std::cell::Cell<Option<iced::time::Instant>>,
/// 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
+37 -5
View File
@@ -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<u64, Vec<u32>>,
/// 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
+88
View File
@@ -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,