fix(paper): reset a reused viewport GPU slot so the survivor keeps its content

Pipeline slots are addressed by list index, but the renderer drops
off-canvas viewports from the list, so a slot can be reused by a
different viewport across frames — e.g. when the first paper viewport
scrolls off the canvas the second slides into its slot and inherits its
stale, differently frustum-culled GPU buffers. The text upload is gated
on a geometry-epoch change (unaffected by the index shift), so the
survivor rendered the gone viewport's text and its own vanished.

Give each viewport a stable identity, remember which one last used a
slot, and reset all cache keys when the occupant changes so wires, text,
hatches, meshes and Face3D re-upload for the new viewport. No cost in the
steady state — the reset fires only when a viewport enters/leaves view.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-07-13 22:38:13 +03:00
commit 31d3023167
2 changed files with 41 additions and 0 deletions

View file

@ -228,6 +228,12 @@ pub struct Pipeline {
/// `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,
/// Stable identity of the viewport that last used this (index-addressed)
/// pipeline slot. The renderer's viewport list drops off-canvas viewports,
/// so a slot can be reused by a *different* viewport across frames; when the
/// occupant changes, all cache keys above belong to the previous one and are
/// reset so every buffer re-uploads. `u64::MAX` = never used.
pub slot_id: u64,
}
impl Pipeline {
@ -1226,6 +1232,7 @@ impl Pipeline {
render_sig: u64::MAX,
skip_geometry: false,
skip_hatch_frame: false,
slot_id: u64::MAX,
}
}

View file

@ -31,6 +31,11 @@ pub struct CameraState {
/// own inner `Pipeline` instance drawn into its own rectangle.
#[derive(Debug)]
pub struct ViewportData {
/// Stable identity of the source viewport (its entity handle / tile index /
/// sheet role). The renderer addresses pipeline slots by list index but
/// drops off-canvas viewports, so this lets the slot detect when it has been
/// reused by a different viewport and reset its (index-addressed) caches.
pub(in crate::scene) instance_id: u64,
pub(in crate::scene) wires: Arc<Vec<WireModel>>,
/// Live command-preview / interim / grip-drag overlay wires. Kept out of
/// the main `wires` buffer so a drag re-uploads only this small set each
@ -224,6 +229,24 @@ impl shader::Primitive for Primitive {
for (i, vp) in self.viewports.iter().enumerate() {
let inner = &mut pipeline.inners[self.base_slot + i];
// Pipeline slots are addressed by list index, but off-canvas
// viewports are dropped from the list — so a slot can be reused by a
// DIFFERENT viewport across frames (e.g. the first viewport scrolls
// off the canvas and the second slides into its slot). When that
// happens every cache key below belongs to the previous occupant;
// reset them so wires, text, hatches, meshes and Face3D all
// re-upload for the new viewport instead of showing the previous
// one's (differently frustum-culled) content — which otherwise makes
// the surviving viewport's text/geometry vanish.
if inner.slot_id != vp.instance_id {
inner.slot_id = vp.instance_id;
inner.cached_epoch = (u64::MAX, u64::MAX, u64::MAX);
inner.cached_wire_id = u64::MAX;
inner.cached_selection = (u64::MAX, u64::MAX);
inner.cached_mesh_key = (u64::MAX, u64::MAX);
inner.cached_face3d_key = (u64::MAX, false);
inner.render_sig = u64::MAX;
}
// The MSAA / depth / resolve textures are always sized to the
// FULL viewport rectangle (not the on-canvas-visible portion)
// so the camera matrices render at consistent aspect / scale.
@ -1151,7 +1174,18 @@ impl Scene {
pv.extend_from_slice(&self.preview_text);
Arc::new(pv)
};
// Stable per-viewport identity (tagged so tile / sheet / content /
// implicit-model instances never collide), so a reused pipeline slot
// can tell it changed occupant and reset its caches.
let instance_id: u64 = if let Some(t) = inst.tile_idx {
0x1000_0000_0000_0000 | (t as u64)
} else if inst.paper_sheet {
0x2000_0000_0000_0000
} else {
0x3000_0000_0000_0000 | inst.handle.value()
};
Some(ViewportData {
instance_id,
wires: all_wires,
preview_wires,
face3d_wires,