Fix layout cache correctness and GPU resource recovery

This commit is contained in:
Hakan Seven committed 2026-09-05 19:56:49 +03:00
1 parent 1a4b389b0e
commit ac8a545669
11 files changed
+352 -326

No files matched your search

+1
View File
@@ -165,6 +165,7 @@ clipboard =
.paste-to-original-coordinates = Özgün Koordinatlara Yapıştır
viewport =
.preparing-layout = Yerleşim hazırlanıyor…
.split-vertical = Görünüm Penceresini Dikey Böl
.split-horizontal = Görünüm Penceresini Yatay Böl
.move = Görünüm Penceresini Taşı
+30
View File
@@ -653,6 +653,36 @@ impl OpenCADStudio {
mod tests {
use crate::app::OpenCADStudio;
#[test]
fn layout_notice_skips_grid_camera_and_scene_builds() {
let mut app = OpenCADStudio::new_for_test();
assert_eq!(app.automation_op(r#"{"op":"new"}"#)["ok"], true);
assert_eq!(app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#)["ok"], true);
let i = app.active_tab;
let scene = &mut app.tabs[i].scene;
scene.document.add_layout("Review").unwrap();
scene.set_current_layout("Review".to_string());
let mut viewport = acadrust::entities::Viewport::new();
viewport.id = 2;
viewport.width = 100.0;
viewport.height = 50.0;
viewport.status.is_on = true;
scene.add_entity(acadrust::EntityType::Viewport(viewport));
for entity in scene.document.entities_mut() {
if let acadrust::EntityType::Viewport(viewport) = entity {
viewport.status.grid_on = true;
}
}
let before = scene.last_tess_wires.get();
app.layout_settling = true;
drop(app.view_main());
assert_eq!(app.tabs[i].scene.last_tess_wires.get(), before);
let _ = app.update(crate::app::Message::LayoutSettled);
assert!(!app.layout_settling);
drop(app.view_main());
assert!(app.tabs[i].scene.last_tess_wires.get() > before);
}
#[test]
fn automation_ops_round_trip() {
let mut app = OpenCADStudio::new_for_test();
+2 -10
View File
@@ -1055,14 +1055,7 @@ pub(super) struct OpenCADStudio {
/// `Some` while a CAD file is loading — drives the modal overlay.
/// Cleared when the load finishes, errors, or the user cancels.
pub(super) opening: Option<OpenProgress>,
/// Set the moment a layout switch changes `current_layout`, cleared on the
/// next presented frame.
///
/// The switch itself costs about 10 ms; the frame after it rebuilds the
/// whole wire set and can block the main thread for seconds. While this is
/// set the viewport is replaced by a notice, so that frame is cheap — which
/// gives the compositor an answer, and the user something other than a
/// stale window, immediately before the expensive one.
/// Show a lightweight notice until the next layout redraw.
pub(super) layout_settling: bool,
open_job_serial: u64,
/// Last repair or failed-open report shown in the recovery modal.
@@ -2292,8 +2285,7 @@ pub enum Message {
/// Timer pulse while the cursor is dwelling on a grip; drives the
/// dwell-to-popup transition without requiring further mouse motion.
GripDwellTick,
/// One frame has been presented since a layout switch; the next may do the
/// expensive rebuild.
/// The notice redraw allows the next frame to build the scene.
LayoutSettled,
/// Timer pulse while a rollover hit-test is queued; fires when the
/// cursor has been still long enough to safely run the pick.
+1 -2
View File
@@ -3046,8 +3046,7 @@ impl OpenCADStudio {
}
Message::LayoutSettled => {
// The notice frame has been presented, so the compositor has
// had its answer. Let the next frame build the real thing.
// Restore the scene after the notice redraw.
self.layout_settling = false;
Task::none()
}
+1 -3
View File
@@ -5535,9 +5535,7 @@ impl OpenCADStudio {
.layers
.sync_with_viewports(&doc_layers, vp_info);
let layers_ms = perf_phase.elapsed().as_secs_f64() * 1000.0;
// The switch is cheap; the frame that follows it is not. Draw a notice
// for one frame first, so the compositor gets an answer before the main
// thread disappears into rebuilding the wire set.
// Give the layout rebuild a visible notice first.
self.layout_settling = true;
if perf {
crate::perf_record!(
+12 -18
View File
@@ -36,11 +36,7 @@ pub(in crate::app) const VIEWPORT_CAPTURE_BOUNDS_ID: &str = "viewport-capture-bo
const VIEWCUBE_HIT_SIZE: f32 = VIEWCUBE_REGION_PX;
/// Base surface directly under the crosshair, selection marquee, and grid overlay.
/// In paper space, drafting surface context is unconditionally the white sheet (`tab.scene.paper_bg_color`),
/// matching AutoCAD's model (where crosshairs and overlays adapt per context — model vs. sheet/layout —
/// rather than per-pixel cursor sampling). This avoids flicker across sheet edges and provides a clean
/// dark crosshair that maintains ~5.85:1 contrast against the default medium-gray desk margin (#8A8A8A).
/// Background used by drafting overlays in model or paper space.
fn crosshair_background(tab: &DocumentTab, is_paper: bool) -> [f32; 4] {
if !is_paper {
return tab.scene.bg_color;
@@ -273,10 +269,7 @@ impl OpenCADStudio {
// borders) and the floating content viewports blit on top.
mark("viewport_3d");
let viewport_3d: Element<'_, Message> = if self.layout_settling && !tab.is_start {
// One cheap frame between the switch and the rebuild. No shader
// widget means no scene primitive, so `prepare` has nothing to do
// and the event loop turns — which is what stops the compositor
// deciding the application has stopped responding.
// Omit the shader and drafting overlays for the notice frame.
let bg = crosshair_background(tab, is_paper);
let lum = 0.299 * bg[0] + 0.587 * bg[1] + 0.114 * bg[2];
let ink = if lum > 0.5 {
@@ -376,7 +369,7 @@ impl OpenCADStudio {
// above the crosshair overlay so it actually receives mouse events, and
// it owns the divider resize. Only built for the Model layout.
mark("model_input");
let model_input_layer: Option<Element<'_, Message>> = if is_paper || tab.is_start {
let model_input_layer: Option<Element<'_, Message>> = if is_paper || tab.is_start || self.layout_settling {
None
} else {
let scene = &tab.scene;
@@ -394,7 +387,9 @@ impl OpenCADStudio {
};
mark("grid");
let grid_overlay = {
let grid_overlay = if self.layout_settling || tab.is_start {
Space::new().into()
} else {
let (vw, vh) = tab.scene.selection.borrow().vp_size;
let model_basis = {
let (o, ux, uy, uz) = tab.ucs_xform().axes();
@@ -479,7 +474,9 @@ bg={bg_ms:.1}ms n={view_count}"
};
mark("selection_overlay");
let selection_overlay = {
let selection_overlay = if self.layout_settling || tab.is_start {
Space::new().into()
} else {
// Hold a single `Ref<'_, SelectionState>` for the whole overlay
// block. The `vp_size` and `last_move_pos` reads below become
// field accesses on `sel_ref` (no redundant `borrow()`s), and
@@ -996,7 +993,7 @@ bg={bg_ms:.1}ms n={view_count}"
};
mark("viewport_stack");
let mut viewport_stack = if tab.is_start {
let mut viewport_stack = if tab.is_start || self.layout_settling {
// Start tab: only the welcome widget over a flat background.
// Skip every drawing-only overlay (selection markers, snap info,
// mouse-area capturing draw clicks, viewcube, nav toolbar, …).
@@ -1057,7 +1054,7 @@ bg={bg_ms:.1}ms n={view_count}"
.height(Fill)
};
if !thumbnail_capture_clean {
if !thumbnail_capture_clean && !self.layout_settling {
// Per-pane input pane_grid goes ABOVE the crosshair overlay so it
// receives mouse events (the overlay's `Hidden` cursor would otherwise
// starve any layer beneath it). The controls bar is pushed on top of it.
@@ -2279,10 +2276,7 @@ impl OpenCADStudio {
// the mouse perfectly still — `ViewportMove` alone would never
// fire again. Auto-stops once the hover clears or the popup is
// already open.
// One frame after a layout switch, then stop. `window::frames` fires
// once a frame has actually been presented, which is the guarantee this
// needs: the notice is on screen and the compositor has been answered
// before the expensive frame starts.
// Resume scene construction after the notice redraw.
let layout_settle = if self.layout_settling {
window::frames().map(|_| Message::LayoutSettled)
} else {
+134 -229
View File
@@ -1495,8 +1495,8 @@ pub enum ViewportRefreshScope {
All,
}
/// `(geometry_epoch, entities per block, entities no block claims)`.
type BlockMembers = (u64, HashMap<Handle, Vec<Handle>>, Vec<Handle>);
/// Entity membership in document order, keyed by geometry epoch.
type BlockMembers = (u64, HashMap<Handle, Vec<Handle>>);
pub struct Scene {
pub camera: Rc<RefCell<Camera>>,
@@ -1836,24 +1836,9 @@ pub struct Scene {
/// Reverse map: entity_handle → block_record_handle, built from entity_handles lists.
/// Keyed by geometry_epoch. Eliminates the O(B) fallback scan in belongs_to_visible_block.
entity_block_map_cache: RefCell<Option<(u64, HashMap<Handle, Handle>)>>,
/// Candidate entities per block, keyed by `geometry_epoch`.
///
/// Selecting a block's contents used to walk every entity in the document
/// and ask `belongs_to_visible_block` about each: 313 ms of a 334 ms
/// resident build for a paper sheet holding 43 entities, on a drawing with
/// 1.17 M. The membership rules only depend on owner handles and the block
/// records, so one pass answers them for every block at once.
///
/// The second field holds entities no block claims, which
/// `belongs_to_visible_block` treats as belonging to everything — but only
/// when no block record enumerates its contents at all.
/// Candidate handles per block, in document order and keyed by geometry epoch.
block_members_cache: RefCell<Option<BlockMembers>>,
/// Handles of entities the quadtree cannot index — Insert, Viewport, Block,
/// BlockEnd — in document order, keyed by `geometry_epoch`.
///
/// The quadtree path has to append these regardless of the view, and did so
/// by walking every entity in the document: ~300 ms of every resident
/// rebuild on a 1.17 M-entity drawing, to find a few thousand.
/// Insert/Viewport/Block/BlockEnd handles omitted by the spatial index.
unindexable_cache: RefCell<Option<(u64, Vec<Handle>)>>,
/// Distinct entity type names present in a layout, keyed by
/// (geometry_epoch, layout block). The status bar asks for this on every
@@ -1958,17 +1943,9 @@ pub struct Scene {
/// Hash of the tessellation parameters `tess_memo` was built under. When
/// the current call's parameters differ, the memo is stale and cleared.
tess_memo_guard: std::cell::Cell<u64>,
/// Per-entity memo for the **resident** model wire set (`model_tile_wires_arc`,
/// the one the main GPU render holds). Kept separate from `tess_memo` because
/// the resident set is camera-INDEPENDENT (no view cull, no zoom LOD), so its
/// guard depends only on anno-scale — it survives pan/zoom and layout switches, and a
/// single-entity edit re-tessellates just the changed entity instead of the
/// whole model. Sharing `tess_memo` would let the camera-dependent culled path
/// thrash it on every zoom. (#perf)
/// Camera-independent model tessellation, separate from the culled memo.
resident_tess_memo: RefCell<HashMap<Handle, Arc<Vec<WireModel>>>>,
/// Guard hash for `resident_tess_memo` (anno-scale only). The background
/// is not part of it: it changes colours, not geometry, and
/// `resolve_colors_for_bg` reapplies it to the assembled set.
/// Annotation, background, viewport and glyph-atlas signature.
resident_tess_guard: std::cell::Cell<u64>,
/// Per-mutation delta journal: which handles changed on each `geometry_epoch`
/// bump. Bounded ring ([`GEOMETRY_JOURNAL_CAP`]); a derived cache replays the
@@ -8916,7 +8893,7 @@ impl Scene {
// the compiler enforces that. A default here is what made the first
// version of this diagnostic report a constant.
let visible_path: &str;
let mut visible_candidates = 0usize;
let mut visible_candidates: usize;
// Phase 2.1 — quadtree-driven candidate selection. When a view
// AABB exists (Model layout with a settled camera), only iterate
@@ -8978,6 +8955,7 @@ impl Scene {
// — the legacy-DXF case. Both need the full walk, and both must
// keep document order, which is draw order for the wire pass.
visible_path = "full-scan";
visible_candidates = self.document.entity_count();
self.document
.entities()
.filter(|e| visibility_ok(e))
@@ -8993,7 +8971,7 @@ impl Scene {
// in exactly the order the full scan produced.
visible_path = "block-index";
let members = self.block_members();
let (_, by_block, _) = &*members;
let (_, by_block) = &*members;
let claimed = by_block.get(&block_handle).map(Vec::as_slice).unwrap_or(&[]);
visible_candidates = claimed.len();
let mut out: Vec<&EntityType> = Vec::with_capacity(claimed.len());
@@ -9089,11 +9067,7 @@ impl Scene {
let mut guard_was_stale = false;
let t_build = perf.then(iced::time::Instant::now);
let mut wires: Vec<WireModel> = if memo_active {
// Guard hash of everything tessellate_entity's GEOMETRY depends on
// besides the entity itself. A mismatch (zoom/tol, view, anno,
// offset, entered viewport) means the memo is stale. For the
// resident path wpp/view_aabb are None, so this collapses to anno.
// The background is excluded on purpose — see the note below.
// Invalidate when any baked display input changes.
let guard = {
let mut g: u64 = 0xcbf2_9ce4_8422_2325;
let mut mix = |x: u64| g = g.rotate_left(13) ^ x;
@@ -9106,13 +9080,11 @@ impl Scene {
mix(anno.to_bits() as u64);
mix(annotation_scale_handle.map(|handle| handle.value()).unwrap_or(0));
mix(all_visible as u64);
// The background is deliberately NOT mixed in. It changes
// colours, never geometry — `local_wires_for` discards it with
// `let _ = bg_color;` and `Batches::finalize` applies it last —
// so a memo entry built under one background serves any other
// once `resolve_colors_for_bg` has run over the assembled set
// below. Keying on it made a layout switch re-tessellate the
// whole model to arrive at identical points.
// Direct entities and inherited/faded block colours still bake
// the background before Batches::finalize records its inputs.
for channel in bg {
mix(channel.to_bits() as u64);
}
mix(avp.map(|h| h.value()).unwrap_or(0));
// SDF glyph quads bake the atlas UV of each tile, so a growth or
// a re-bake (which rescale / rewind every UV) makes memoized text
@@ -9283,21 +9255,7 @@ guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}",
wires
}
/// Decide whether an entity should be drawn as direct content of `block_handle`.
/// Entities each block may contain, and the ones no block claims.
///
/// Mirrors `belongs_to_visible_block` exactly, resolved once per
/// `geometry_epoch` for every block instead of per entity per query:
///
/// * a non-null owner *is* the block, so the entity is that block's;
/// * a null owner falls back to the reverse map built from the block
/// records' `entity_handles`;
/// * an entity neither names nor is named by any block is "unowned", and
/// counts towards every block only when no block record enumerates its
/// contents at all — the legacy-DXF case that predicate allows.
/// Entities the quadtree cannot index, in document order, resolved once per
/// `geometry_epoch`. Membership depends only on the entity's type, so an
/// edit that does not add or remove one leaves the list unchanged.
/// Unindexable entity handles in document order, cached per geometry epoch.
fn unindexable_handles(&self) -> std::cell::Ref<'_, (u64, Vec<Handle>)> {
{
let cache = self.unindexable_cache.borrow();
@@ -9318,10 +9276,11 @@ guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}",
std::cell::Ref::map(self.unindexable_cache.borrow(), |c| c.as_ref().unwrap())
}
/// Resolve owner handles and block-record membership once per geometry epoch.
fn block_members(&self) -> std::cell::Ref<'_, BlockMembers> {
{
let cache = self.block_members_cache.borrow();
if cache.as_ref().is_some_and(|(epoch, _, _)| *epoch == self.geometry_epoch) {
if cache.as_ref().is_some_and(|(epoch, _)| *epoch == self.geometry_epoch) {
drop(cache);
return std::cell::Ref::map(self.block_members_cache.borrow(), |c| {
c.as_ref().unwrap()
@@ -9329,7 +9288,6 @@ guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}",
}
}
let mut members: HashMap<Handle, Vec<Handle>> = HashMap::default();
let mut unowned: Vec<Handle> = Vec::new();
{
let map = self.entity_block_map();
for entity in self.document.entities() {
@@ -9339,16 +9297,15 @@ guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}",
members.entry(owner).or_default().push(common.handle);
} else if let Some(&block) = map.get(&common.handle) {
members.entry(block).or_default().push(common.handle);
} else {
unowned.push(common.handle);
}
}
}
*self.block_members_cache.borrow_mut() =
Some((self.geometry_epoch, members, unowned));
Some((self.geometry_epoch, members));
std::cell::Ref::map(self.block_members_cache.borrow(), |c| c.as_ref().unwrap())
}
/// Whether the entity is direct content of this block.
fn belongs_to_visible_block(
&self,
entity_handle: Handle,
@@ -10044,15 +10001,7 @@ guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}",
std::cell::Ref::map(self.entity_index_cache.borrow(), |c| &c.as_ref().unwrap().1)
}
/// Full tessellation pipeline for one entity.
/// The document-wide inputs `tessellate_entity` takes, resolved from the
/// current layout and viewport.
///
/// Every entity in a walk gets the same four, and resolving them per entity
/// is not free — `block_cache_arc_for` takes a `RefCell` borrow and a cache
/// lookup. Callers that tessellate more than one entity resolve them once
/// and pass them down; sharing the resolution keeps those callers from
/// drifting away from `tessellate_one`.
/// Resolve the active layout and annotation context for tessellation.
fn tessellation_context(
&self,
) -> ([f32; 4], f32, Option<Handle>, Arc<cache::block_cache::BlockCache>, bool) {
@@ -10227,167 +10176,43 @@ guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}",
if model_block.is_null() {
return None;
}
let mut min = glam::Vec3::splat(f32::INFINITY);
let mut max = glam::Vec3::splat(f32::NEG_INFINITY);
let mut any = false;
// Prefer the already-computed wire AABB cache when available — avoids re-tessellating.
if self.current_layout == "Model" {
let cache = self.wire_cache.borrow();
if let Some(((epoch, _cam_gen), _gen, ref arc)) = *cache {
if epoch == self.geometry_epoch {
for wire in arc.iter() {
let [ax, ay, bx, by] = wire.aabb;
let lo = glam::Vec3::new(ax, ay, 0.0);
let hi = glam::Vec3::new(bx, by, 0.0);
// Reject the whole AABB unless every component is finite:
// rays/xlines carry an unbounded AABB, and checking only
// x let a vertical ray's infinite y poison the extents.
if lo.is_finite() && hi.is_finite() {
min = min.min(lo);
max = max.max(hi);
any = true;
}
}
// 3D solids render as meshes, not wires, so fold their
// complete 3D boxes in too — otherwise view fitting loses
// both solid-only drawings and their depth.
for (&handle, set) in &self.meshes {
if !self.mesh_entity_visible(handle)
|| !self.document.get_entity(handle).is_some_and(|entity| {
self.belongs_to_visible_block(
handle,
entity.common().owner_handle,
model_block,
)
})
{
continue;
}
let [ax, ay, bx, by] = set.world_aabb;
let [az, bz] = set.z_aabb;
let lo = glam::Vec3::new(ax, ay, az);
let hi = glam::Vec3::new(bx, by, bz);
if lo.is_finite() && hi.is_finite() {
min = min.min(lo);
max = max.max(hi);
any = true;
}
}
return if any { Some((min, max)) } else { None };
}
}
}
// Fallback: tessellate (first call or paper-space context).
// wire.key_vertices live in offset-rel coords (world_offset
// already subtracted at tessellation time). Add it back so the
// result matches Path 1 above and the caller's expectation —
// callers (auto_fit_viewport) write the centroid directly to
// `Viewport.view_target`, which is a WCS field; storing
// offset-rel coords there silently double-subtracts world_offset
// inside `camera_for_viewport` and points the viewport at the
// wrong location on UTM-scale drawings.
//
// `tessellate_one` resolves the background, annotation scale and block
// cache from `&self` on every call, and this walk calls it once per
// model entity — 186 k times on a large drawing, each one taking a
// `RefCell` borrow for a block cache that is the same every time. Those
// inputs do not vary across the loop, so they are resolved once and the
// shared tessellator is called directly, which also lets the walk run
// in parallel the way the wire builder's own tessellation pass does.
// Same arguments, same output: only who computes them changed.
let (bg_for_tess, anno_for_tess, anno_handle_for_tess, blk_for_tess, paper_for_tess) =
self.tessellation_context();
let subjects: Vec<&EntityType> = self
.document
.entities()
.filter(|entity| {
let c = entity.common();
c.owner_handle == model_block
&& !c.invisible
&& !self.entity_temporarily_hidden(c.handle)
})
.collect();
let doc = &self.document;
let sel = &self.selected;
let avp = self.active_viewport;
let blk_ref: &cache::block_cache::BlockCache = &blk_for_tess;
use rayon::iter::{IntoParallelIterator, ParallelIterator};
let reduced = subjects
.into_par_iter()
.map(|entity| {
let mut lo = glam::Vec3::splat(f32::INFINITY);
let mut hi = glam::Vec3::splat(f32::NEG_INFINITY);
let mut hit = false;
for wire in tessellate_entity(
doc,
sel,
avp,
bg_for_tess,
anno_for_tess,
anno_handle_for_tess,
entity,
Some(blk_ref),
None,
None,
paper_for_tess,
) {
for &[x, y, z] in &wire.key_vertices {
let v = glam::Vec3::new(x as f32, y as f32, z as f32);
// Check finiteness *after* the f32 cast: a ray/xline
// endpoint is a huge-but-finite f64 that overflows to
// inf in f32, which the f64 `is_finite` test would have
// let through.
if v.is_finite() {
lo = lo.min(v);
hi = hi.max(v);
hit = true;
}
}
}
(lo, hi, hit)
})
.reduce(
|| {
(
glam::Vec3::splat(f32::INFINITY),
glam::Vec3::splat(f32::NEG_INFINITY),
false,
)
},
|a, b| (a.0.min(b.0), a.1.max(b.1), a.2 || b.2),
);
if reduced.2 {
min = min.min(reduced.0);
max = max.max(reduced.1);
any = true;
}
// Same mesh inclusion for the tessellate fallback path.
for (&handle, set) in &self.meshes {
// Reuse the full resident source in every layout. The kernel bounds
// its world-space key vertices; camera fitting never tessellates a
// second copy or reads the current paper sheet's wire cache.
let scale = crate::scene::annotative::scale_handle_by_name(
&self.document,
&self.document.header.current_annotation_scale,
);
let wires = self.resident_wires_for(
model_block,
Some(self.annotation_scale),
scale,
None,
None,
);
let wire_points = wires.iter().flat_map(|wire| wire.key_vertices.iter().copied());
let mesh_points = self.meshes.iter().filter_map(|(&handle, set)| {
let entity = self.document.get_entity(handle)?;
if !self.mesh_entity_visible(handle)
|| !self.document.get_entity(handle).is_some_and(|entity| {
self.belongs_to_visible_block(
handle,
entity.common().owner_handle,
model_block,
)
})
|| !self.belongs_to_visible_block(handle, entity.common().owner_handle, model_block)
{
continue;
return None;
}
let [ax, ay, bx, by] = set.world_aabb;
let [az, bz] = set.z_aabb;
let lo = glam::Vec3::new(ax, ay, az);
let hi = glam::Vec3::new(bx, by, bz);
if lo.is_finite() && hi.is_finite() {
min = min.min(lo);
max = max.max(hi);
any = true;
}
}
if any {
return Some((min, max));
Some([
[ax as f64, ay as f64, az as f64],
[bx as f64, by as f64, bz as f64],
])
}).flatten();
let points = wire_points.chain(mesh_points).filter(|point| {
point.iter().all(|coordinate| (*coordinate as f32).is_finite())
});
if let Some(bounds) = cadkernel::brep::bounds::Aabb::around(points) {
return Some((
glam::Vec3::from_array(bounds.min.map(|v| v as f32)),
glam::Vec3::from_array(bounds.max.map(|v| v as f32)),
));
}
// Last resort: saved EXTMIN/EXTMAX before the wire cache is built.
const SANE_EXTENT: f64 = 1.0e16;
@@ -10649,7 +10474,7 @@ mod journal_tests {
// What the membership index reports for `block`, in order.
fn indexed(s: &Scene, block: Handle) -> Vec<Handle> {
let members = s.block_members();
let (_, by_block, _) = &*members;
let (_, by_block) = &*members;
by_block.get(&block).cloned().unwrap_or_default()
}
@@ -11311,3 +11136,83 @@ mod selection_arc_tests {
assert!(true);
}
}
#[cfg(test)]
mod layout_cache_tests {
use super::*;
#[test]
fn memoized_plain_line_matches_fresh_background() {
let mut s = Scene::new();
s.bg_color = [0.1, 0.1, 0.1, 1.0];
let handle = s.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
acadrust::types::Vector3::new(0.0, 0.0, 0.0),
acadrust::types::Vector3::new(10.0, 0.0, 0.0),
)));
let build = |s: &Scene| {
s.wires_for_block_culled(
s.model_space_block_handle(),
None,
None,
None,
None,
None,
true,
None,
)
.into_iter()
.find(|wire| wire.name == handle.value().to_string())
.unwrap()
};
let original = build(&s);
assert!(s.resident_tess_memo.borrow().contains_key(&handle));
s.paper_bg_color = [1.0; 4];
s.document.add_layout("Review").unwrap();
s.set_current_layout("Review".to_string());
let cached = build(&s);
s.resident_tess_memo.borrow_mut().clear();
let fresh = build(&s);
assert_ne!(
original.color, fresh.color,
"fixture must depend on background"
);
assert_eq!(
cached.color, fresh.color,
"memo hit should match fresh tessellation on a white background"
);
}
#[test]
fn model_extents_reuse_resident_geometry_in_every_layout() {
let mut s = Scene::new();
s.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
acadrust::types::Vector3::new(1_000_000.0, 2_000_000.0, 30.0),
acadrust::types::Vector3::new(1_000_010.0, 2_000_020.0, 40.0),
)));
s.document.add_layout("Review").unwrap();
for layout in ["Model", "Review"] {
s.set_current_layout(layout.to_string());
*s.model_extents_cache.borrow_mut() = None;
let scale = crate::scene::annotative::scale_handle_by_name(
&s.document,
&s.document.header.current_annotation_scale,
);
let source = s.resident_wires_for(
s.model_space_block_handle(),
Some(s.annotation_scale),
scale,
None,
None,
);
let generation = s.last_model_wire_gen.get();
let bounds = s.model_space_extents().unwrap();
assert_eq!(bounds.0, glam::Vec3::new(1_000_000.0, 2_000_000.0, 30.0));
assert_eq!(bounds.1, glam::Vec3::new(1_000_010.0, 2_000_020.0, 40.0));
assert_eq!(
s.last_model_wire_gen.get(),
generation,
"fitting must reuse the resident source"
);
assert!(!source.is_empty());
}
}
}
+89
View File
@@ -319,3 +319,92 @@ fn toggling_shadows_reallocates_without_upsetting_the_device() {
"GPU validation failed"
);
}
#[test]
#[ignore = "requires a GPU adapter"]
fn urgent_release_preserves_visible_panes_and_frees_cold_slots() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter =
block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())).unwrap();
let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
}))
.unwrap();
let mut pipeline = <MultiPipeline as iced::widget::shader::Pipeline>::new(
&device,
&queue,
wgpu::TextureFormat::Bgra8UnormSrgb,
);
let first = pipeline.resolve_slots(&device, &queue, &[10])[0];
pipeline.inners[first].slot_id = 10;
pipeline.inners[first].ensure_depth_texture(&device, Size::new(512, 512));
let second = pipeline.resolve_slots(&device, &queue, &[20])[0];
pipeline.inners[second].slot_id = 20;
pipeline.inners[second].ensure_depth_texture(&device, Size::new(512, 512));
pipeline.release_idle_slots(&device, &[second], true);
assert_eq!(
pipeline.inners[first].alloc_size,
Size::new(512, 512),
"a sibling pane prepared immediately before this pane is still visible"
);
pipeline
.frame_rendered
.store(true, std::sync::atomic::Ordering::Relaxed);
let next = pipeline.resolve_slots(&device, &queue, &[20]);
assert_eq!(
pipeline.release_idle_slots(&device, &next, true),
0,
"a previous-frame pane may still be prepared later in this frame"
);
pipeline
.frame_rendered
.store(true, std::sync::atomic::Ordering::Relaxed);
let next = pipeline.resolve_slots(&device, &queue, &[20]);
assert_eq!(pipeline.release_idle_slots(&device, &next, true), 1);
assert_eq!(pipeline.inners[first].alloc_size, Size::new(0, 0));
assert_eq!(pipeline.inners[second].alloc_size, Size::new(512, 512));
}
#[test]
#[ignore = "requires a GPU adapter"]
fn late_device_errors_invalidate_uploaded_content_and_targets() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter =
block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())).unwrap();
let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
}))
.unwrap();
let mut pipeline = <MultiPipeline as iced::widget::shader::Pipeline>::new(
&device,
&queue,
wgpu::TextureFormat::Bgra8UnormSrgb,
);
let inner = &mut pipeline.inners[0];
inner.cached_wire_id = 7;
inner.wire_arena_id = 7;
inner.ensure_depth_texture(&device, Size::new(512, 512));
let before = gpu_errors_seen();
let _rejected = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("rejected test allocation"),
size: device.limits().max_buffer_size + 4,
usage: wgpu::BufferUsages::VERTEX,
mapped_at_creation: false,
});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
assert!(gpu_errors_seen() > before);
inner.sync_gpu_error_epoch();
assert_eq!(inner.cached_wire_id, u64::MAX);
assert_eq!(inner.wire_arena_id, u64::MAX);
assert_eq!(inner.alloc_size, Size::new(0, 0));
assert_eq!(inner.background_source_id, usize::MAX);
inner.cached_wire_id = 8;
inner.sync_gpu_error_epoch();
assert_eq!(
inner.cached_wire_id, 8,
"the same error must not invalidate twice"
);
}
+13
View File
@@ -159,6 +159,19 @@ impl HatchGpu {
}
}
pub(super) fn clear(&mut self) {
match &mut self.backend {
HatchBackend::Storage { resident, preview } => {
resident.clear();
preview.clear();
}
HatchBackend::Texture { resident, preview } => {
resident.clear();
preview.clear();
}
}
}
pub fn upload(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, hatches: &[HatchModel]) {
match &mut self.backend {
HatchBackend::Storage { resident, .. } => {
+57 -49
View File
@@ -74,6 +74,7 @@ struct MeshHighlightDraw {
}
pub struct Pipeline {
gpu_error_epoch: usize,
background_pipeline: wgpu::RenderPipeline,
shadow_pipeline: wgpu::RenderPipeline,
shadow_plain_pipeline: wgpu::RenderPipeline,
@@ -2226,6 +2227,7 @@ impl Pipeline {
wire_const_bgl,
block_wire_const_bgl,
block_wire_mode: wire_mode,
gpu_error_epoch: gpu_errors_seen(),
wipeout_pipeline,
hatch_gpu,
image_pipeline,
@@ -3557,9 +3559,12 @@ impl Pipeline {
fn sync_shadow_target(&mut self, device: &wgpu::Device) {
match (self.shadow_enabled, self.shadow_full.is_some()) {
(true, false) => {
let errors_before = gpu_errors_seen();
let texture = create_shadow_texture(device, SHADOW_MAP_SIZE);
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
self.shadow_full = Some((texture, view));
if gpu_errors_seen() == errors_before {
self.shadow_full = Some((texture, view));
}
}
(false, true) => self.shadow_full = None,
_ => return,
@@ -3575,7 +3580,11 @@ impl Pipeline {
) {
self.shadow_enabled = uniforms.shadow_params[0] > 0.5;
self.sync_shadow_target(device);
queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(uniforms));
let mut uniforms = *uniforms;
if self.shadow_full.is_none() {
uniforms.shadow_params[0] = 0.0;
}
queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&uniforms));
}
pub fn upload_background_images(
@@ -3710,9 +3719,7 @@ impl Pipeline {
// render rectangle does the clipping).
let stencil_ref: u32 = if self.clip_boundary.is_some() { 0xFF } else { 0 };
// `shadow_enabled` says the style wants shadows; the target existing
// says the device could give us one. Without the second test a failed
// 16 MiB allocation would render the pass into the 1x1 placeholder.
// Render shadows only when a full target was allocated.
if let Some(shadow_target) = self
.shadow_full
.as_ref()
@@ -4671,6 +4678,23 @@ impl Pipeline {
self.render_sig = u64::MAX;
}
/// Catch errors delivered after the previous upload/submit completed.
pub(crate) fn sync_gpu_error_epoch(&mut self) {
let errors = gpu_errors_seen();
if errors == self.gpu_error_epoch {
return;
}
self.gpu_error_epoch = errors;
self.forget_cached_keys();
self.wire_arena = None;
self.wire_arena_mesh = None;
self.wire_arena_id = u64::MAX;
self.alloc_size = Size::new(0, 0);
self.shadow_full = None;
self.background_source_id = usize::MAX;
self.environment_source_id = usize::MAX;
}
/// Give this slot's device memory back and forget what it held.
///
/// A slot nobody is drawing still owns its wire arena, its render targets,
@@ -4698,6 +4722,7 @@ impl Pipeline {
self.gpu_selected_wires.clear();
self.gpu_selected_block_wires.clear();
self.gpu_preview_wires.clear();
self.hatch_gpu.clear();
self.gpu_wipeouts.clear();
self.wipeout_skip_flags.clear();
self.gpu_images.clear();
@@ -4928,19 +4953,8 @@ impl Pipeline {
}
impl MultiPipeline {
/// Hand back the device memory of slots nobody is drawing.
///
/// `reserved` are the slots this frame is about to use; they are never
/// touched. Everything else is released once it has been idle for
/// `idle_frames` prepare calls — gradually, so an ordinary session pays
/// nothing and a layout the user keeps switching back to stays warm.
///
/// `urgent` drops that patience: the device has just refused an allocation,
/// and holding a viewport nobody can see is what turns a degraded frame
/// into a session that never recovers. Releasing early costs one slow frame
/// if the slot is wanted again; not releasing costs the drawing.
///
/// Returns how many slots were released.
/// Release cold slots after 64 frames, or two frames under memory pressure.
/// Current and previous frame slots remain reserved for sibling widgets.
pub(crate) fn release_idle_slots(
&mut self,
device: &wgpu::Device,
@@ -4948,7 +4962,9 @@ impl MultiPipeline {
urgent: bool,
) -> usize {
const IDLE_FRAMES: u64 = 64;
let idle_frames = if urgent { 0 } else { IDLE_FRAMES };
// Other shader widgets may prepare later in this frame. Keep every
// slot used in this or the previous frame, even under memory pressure.
let idle_frames = if urgent { 2 } else { IDLE_FRAMES };
let mut released = 0;
for index in 0..self.inners.len() {
if reserved.contains(&index) {
@@ -4970,6 +4986,15 @@ impl MultiPipeline {
self.inners[index].release_heavy_resources(device);
released += 1;
}
if released > 0 {
self.wire_buffer_cache.retain(|_, entry| {
std::sync::Arc::strong_count(&entry.0) > 1
|| std::sync::Arc::strong_count(&entry.1) > 1
});
self.block_geometry.retain(|_, chunks| {
chunks.iter().any(|chunk| std::sync::Arc::strong_count(&chunk.bind_group) > 1)
});
}
released
}
@@ -5104,25 +5129,11 @@ pub struct MultiPipeline {
pub(crate) slot_by_instance: rustc_hash::FxHashMap<u64, usize>,
slot_last_used: Vec<u64>,
slot_clock: u64,
/// The resident wire batches, keyed by `wire_content_id` and shared across
/// every slot (and every pane — one `MultiPipeline` backs all of them) that
/// renders the same content. `prepare` builds an entry once on a cache miss
/// then hands `Arc` clones to each slot, so N paper viewports / Model tiles
/// showing one identical resident set upload the wire vertex buffers exactly
/// once between them instead of once per slot. Kept trim by dropping entries
/// no slot still references (`Arc::strong_count == 1`) once it grows past a
/// small bound.
/// Block geometry that survives an edit, keyed by the definition it
/// expands rather than by anything a viewport contributes. See
/// `wire_gpu::BlockGeometryCache`.
///
/// Shared across slots for the same reason `wire_buffer_cache` is: a
/// definition's segments are identical in every viewport that draws it, and
/// this is the largest upload the renderer makes — 59.5 MiB on the
/// reproducer. Held per slot, zooming a paper layout out until four or five
/// viewports were on screen uploaded it once each and ran a 2 GB card out
/// of memory.
pub(crate) frame_rendered: std::sync::atomic::AtomicBool,
pub(crate) gpu_error_epoch: usize,
/// Definition geometry shared across viewport slots.
pub(crate) block_geometry: wire_gpu::BlockGeometryCache,
/// Shared resident batches, keyed by wire content identity.
pub(crate) wire_buffer_cache: rustc_hash::FxHashMap<
u64,
(
@@ -5134,11 +5145,7 @@ pub struct MultiPipeline {
}
impl MultiPipeline {
/// Ensure at least `n` (≥1) inner pipelines exist, creating any missing
/// ones. Grow-only: extra pipelines are NOT dropped, because per-pane Model
/// shader widgets share this storage and each only touches its own slot —
/// truncating mid-frame would destroy another pane's slot. Stale inners (a
/// closed pane, or fewer paper viewports) are harmless idle GPU resources.
/// Grow slot storage without disturbing sibling shader widgets.
pub(crate) fn ensure_len(
&mut self,
device: &wgpu::Device,
@@ -5152,12 +5159,7 @@ impl MultiPipeline {
}
}
/// Resolve stable slots for the viewport identities in one primitive.
/// Thirty-two hot slots cover ordinary tiled/paper drawings. A cold slot
/// is recycled only after several other prepare calls, which prevents
/// sibling Model panes prepared in the same frame from evicting each
/// other. If every slot is still hot, growing is safer than a visible
/// rebuild hitch.
/// Preserve viewport slots across frames; recycle only cold slots above the limit.
pub(crate) fn resolve_slots(
&mut self,
device: &wgpu::Device,
@@ -5167,7 +5169,11 @@ impl MultiPipeline {
const SOFT_LIMIT: usize = 32;
const HOT_WINDOW: u64 = 8;
self.slot_clock = self.slot_clock.wrapping_add(1).max(1);
if self.frame_rendered.swap(false, std::sync::atomic::Ordering::Relaxed)
|| self.slot_clock == 0
{
self.slot_clock = self.slot_clock.wrapping_add(1).max(1);
}
let now = self.slot_clock;
let reserved: rustc_hash::FxHashSet<u64> = instance_ids.iter().copied().collect();
// `inner.slot_id` is updated later in `prepare`, after this whole
@@ -5284,6 +5290,8 @@ impl iced::widget::shader::Pipeline for MultiPipeline {
slot_by_instance: rustc_hash::FxHashMap::default(),
slot_last_used: vec![0],
slot_clock: 0,
frame_rendered: std::sync::atomic::AtomicBool::new(false),
gpu_error_epoch: gpu_errors_seen(),
wire_buffer_cache: rustc_hash::FxHashMap::default(),
}
}
+12 -15
View File
@@ -364,20 +364,20 @@ impl shader::Primitive for Primitive {
viewport: &Viewport,
) {
let nav_prepare_started = iced::time::Instant::now();
let errors_at_entry = crate::scene::pipeline::gpu_errors_seen();
let recovering = pipeline.gpu_error_epoch != errors_at_entry;
if recovering {
pipeline.wire_buffer_cache.clear();
pipeline.block_geometry.clear();
pipeline.gpu_error_epoch = errors_at_entry;
}
let scale = viewport.scale_factor() as f32;
let instance_ids: Vec<u64> = self.viewports.iter().map(|vp| vp.instance_id).collect();
let slots = pipeline.resolve_slots(device, queue, &instance_ids);
// Compared after the frame's uploads: a move means the device refused
// something, which changes how patient the slot release below can be.
let errors_at_entry = crate::scene::pipeline::gpu_errors_seen();
for (i, vp) in self.viewports.iter().enumerate() {
let inner = &mut pipeline.inners[slots[i]];
// Compared at the end of this viewport's turn. Every "we hold this
// now" latch below is stamped after its upload, whether or not the
// device accepted it — and the wire latch gates the whole upload
// block, so one rejected allocation could leave a slotpermanently empty with
// nothing that would ever rebuild it.
inner.sync_gpu_error_epoch();
// Do not latch rejected uploads as current content.
let slot_errors_before = crate::scene::pipeline::gpu_errors_seen();
// Pipeline slots are addressed by list index, but off-canvas
// viewports are dropped from the list — so a slot can be reused by a
@@ -1216,13 +1216,9 @@ impl shader::Primitive for Primitive {
self.viewports.len(),
);
}
// Give back what nobody is drawing. Gradual while the device is
// healthy; immediate once it has refused an allocation, because the
// memory a hidden viewport is holding is exactly what the visible one
// could not get — and freeing it is what lets the operator close a
// viewport or two and carry on instead of restarting.
// Memory pressure shortens cold-slot retention without evicting siblings.
let errors_now = crate::scene::pipeline::gpu_errors_seen();
let urgent = errors_now != errors_at_entry;
let urgent = recovering || errors_now != errors_at_entry;
let released = pipeline.release_idle_slots(device, &slots, urgent);
if released > 0 && crate::perf::enabled() {
crate::perf_record!(
@@ -1241,6 +1237,7 @@ impl shader::Primitive for Primitive {
clip: &Rectangle<u32>,
) {
let nav_render_started = iced::time::Instant::now();
pipeline.frame_rendered.store(true, std::sync::atomic::Ordering::Relaxed);
let cw = clip.width as f32;
let ch = clip.height as f32;
let clip_right = clip.x + clip.width;