perf(render): cache rendered scene, skip re-raster on unchanged frames

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.
This commit is contained in:
sLuCHa committed 2026-07-05 08:11:35 +02:00
1 parent aa63634d95
commit f99f1cd7a5
2 files changed
+96

No files matched your search

+23
View File
@@ -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<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,
}
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
+73
View File
@@ -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