forked from 1b-agent/opencadstudio-upstream
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:
commit
d0639b3e38
4 files changed
+178
-5
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user