From 31d30231672f1b558baf694b66a517a822de1779 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 13 Jul 2026 22:38:13 +0300 Subject: [PATCH] fix(paper): reset a reused viewport GPU slot so the survivor keeps its content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/scene/pipeline/mod.rs | 7 +++++++ src/scene/view/render.rs | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index f3ab2a67c..f12b1b799 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -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, } } diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 03eb9c308..5d39fa9b8 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -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>, /// 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,