perf(render): share one GPU wire buffer across slots by content id

Each pipeline slot owned its own gpu_wires: every paper viewport and every
Model tile rebuilt an identical resident wire set into its own GPU vertex
buffers and its own handle index. With N content viewports drawing one shared
tessellation that is N copies of the (potentially huge) wire buffers in VRAM
plus N batch+index builds on every content change — the issue-131 memory
exposure multiplied by the viewport count.

A wgpu::Buffer is internally ref-counted, so sharing a WireGpu by Arc shares
its GPU allocation. Make Pipeline.gpu_wires an Arc<Vec<WireGpu>> (and
wire_handle_index an Arc<map>), turn upload_wires into build_wire_buffers(&self)
that returns those Arcs, and add MultiPipeline.wire_buffer_cache keyed by
wire_content_id. prepare() builds an entry once on a cache miss and hands Arc
clones to every slot with that id — so N viewports (and, since one MultiPipeline
backs all panes, N panes) upload the wire vertices exactly once between them.
Camera uniforms and scissor stay per-slot, so the draw still issues one call per
viewport with the right view; only the geometry is deduplicated. The shared Vec
is never mutated in place — only reassigned on a content change — so the sharing
is sound.

The cache drops entries no slot still references (Arc::strong_count == 1) once it
passes a small bound, so a live buffer (held by any pane) is never evicted.
Model multi-tile gets the same dedup for free, and two panes showing the same
model now share buffers.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-14 21:19:18 +03:00
1 parent f39a2d3126
commit 0f61a2e582
2 files changed
+73 -12

No files matched your search

+44 -11
View File
@@ -136,7 +136,14 @@ pub struct Pipeline {
blit_uniform_buffer: wgpu::Buffer,
/// Cached texture format (needed to recreate MSAA / depth textures on resize).
surface_format: wgpu::TextureFormat,
gpu_wires: Vec<WireGpu>,
/// The resident wire batches, shared by `std::sync::Arc` with every other
/// pipeline slot rendering the *same* `wire_content_id` (see
/// `MultiPipeline::wire_buffer_cache`). A `wgpu::Buffer` is internally
/// ref-counted, so N paper viewports (or Model tiles) drawing one identical
/// resident set hold one copy of the GPU vertex buffers between them —
/// their camera uniforms + scissor stay per-slot, only the geometry is
/// deduplicated. Never mutated in place, only reassigned on content change.
pub(crate) gpu_wires: std::sync::Arc<Vec<WireGpu>>,
/// Pixel scissor rects [x, y, w, h] for viewport-clipped wires. Recomputed each frame.
wire_pixel_scissors: Vec<Option<[u32; 4]>>,
/// Ghost copies (25% alpha) of selected wires for the X-ray depth pass.
@@ -225,8 +232,10 @@ pub struct Pipeline {
/// Handle → indices into the resident wire set, built once per wire upload
/// (when `cached_wire_id` changes). Lets the selection/hover xray overlay
/// 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>>,
/// scanning and string-parsing every wire on each hover change. Shared by
/// `std::sync::Arc` alongside `gpu_wires` — built once per `wire_content_id`
/// and cloned into every slot that renders it.
pub(crate) wire_handle_index: std::sync::Arc<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
@@ -1294,7 +1303,7 @@ impl Pipeline {
blit_bind_group,
blit_uniform_buffer,
surface_format: format,
gpu_wires: vec![],
gpu_wires: std::sync::Arc::new(vec![]),
wire_pixel_scissors: vec![],
gpu_selected_wires: vec![],
gpu_preview_wires: vec![],
@@ -1321,7 +1330,7 @@ impl Pipeline {
cached_selection: (u64::MAX, u64::MAX),
cached_mesh_key: (u64::MAX, u64::MAX),
cached_face3d_key: (u64::MAX, false),
wire_handle_index: rustc_hash::FxHashMap::default(),
wire_handle_index: std::sync::Arc::new(rustc_hash::FxHashMap::default()),
render_sig: u64::MAX,
skip_geometry: false,
skip_hatch_frame: false,
@@ -1329,11 +1338,19 @@ impl Pipeline {
}
}
pub fn upload_wires(
&mut self,
/// Build the resident wire batches + handle index for `wires`, wrapped in
/// `Arc` so the caller can cache them by `wire_content_id` and share one
/// copy across every slot that renders that content (see
/// `MultiPipeline::wire_buffer_cache`). Takes `&self` (reads only the const
/// bind-group layout) so the shared cache — not the slot — owns the result.
pub fn build_wire_buffers(
&self,
device: &wgpu::Device,
wires: &[WireModel],
depth_map: &rustc_hash::FxHashMap<u64, f32>,
) -> (
std::sync::Arc<Vec<WireGpu>>,
std::sync::Arc<rustc_hash::FxHashMap<u64, Vec<u32>>>,
) {
// Batch the wire pass: instead of one GPU buffer + one draw call per
// WireModel (tens of thousands on a large drawing), merge maximal runs
@@ -1372,18 +1389,18 @@ impl Pipeline {
batches.extend(WireGpu::from_run(device, &wires[i..j], depth_map, scissor, mesh_edge, self.wire_const_bgl.as_ref()));
i = j;
}
self.gpu_wires = batches;
// Index handle → wire slots once, here, so the per-hover selection
// overlay can gather just the highlighted wires instead of scanning +
// string-parsing the whole set every time the hovered entity changes.
self.wire_handle_index.clear();
self.wire_handle_index.reserve(wires.len());
let mut index: rustc_hash::FxHashMap<u64, Vec<u32>> = rustc_hash::FxHashMap::default();
index.reserve(wires.len());
for (idx, w) in wires.iter().enumerate() {
if let Ok(h) = w.name.parse::<u64>() {
self.wire_handle_index.entry(h).or_default().push(idx as u32);
index.entry(h).or_default().push(idx as u32);
}
}
(std::sync::Arc::new(batches), std::sync::Arc::new(index))
}
/// Build the selection xray overlay: full-brightness copies of the wires
@@ -2820,6 +2837,21 @@ fn create_msaa_texture(
pub struct MultiPipeline {
pub(crate) inners: Vec<Pipeline>,
format: wgpu::TextureFormat,
/// 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.
pub(crate) wire_buffer_cache: rustc_hash::FxHashMap<
u64,
(
std::sync::Arc<Vec<WireGpu>>,
std::sync::Arc<rustc_hash::FxHashMap<u64, Vec<u32>>>,
),
>,
}
impl MultiPipeline {
@@ -2846,6 +2878,7 @@ impl iced::widget::shader::Pipeline for MultiPipeline {
Self {
inners: vec![Pipeline::new(device, queue, format)],
format,
wire_buffer_cache: rustc_hash::FxHashMap::default(),
}
}
}
+29 -1
View File
@@ -357,7 +357,35 @@ impl shader::Primitive for Primitive {
// independent of the `cur_key` block so a preview/interim wire change
// still uploads even when the camera didn't move.
if vp.wire_content_id != inner.cached_wire_id {
inner.upload_wires(device, &vp.wires[..], &vp.draw_depths);
// Share one copy of the resident wire buffers across every slot
// (and every pane — one MultiPipeline backs them all) rendering
// this content id: build on a cache miss, then hand out Arc
// clones. Two paper viewports showing the same model, or four
// Model tiles, upload the wire vertices once between them.
// `.cloned()` releases the immutable cache borrow before the
// miss branch takes a mutable one.
let cached = pipeline.wire_buffer_cache.get(&vp.wire_content_id).cloned();
let built = match cached {
Some(entry) => entry,
None => {
let entry =
inner.build_wire_buffers(device, &vp.wires[..], &vp.draw_depths);
pipeline
.wire_buffer_cache
.insert(vp.wire_content_id, entry.clone());
// Evict entries no slot still holds (only the cache
// references them). An entry drawn by any pane keeps a
// strong count ≥ 2, so this never drops live geometry.
if pipeline.wire_buffer_cache.len() > 16 {
pipeline
.wire_buffer_cache
.retain(|_, (w, _)| std::sync::Arc::strong_count(w) > 1);
}
entry
}
};
inner.gpu_wires = built.0;
inner.wire_handle_index = built.1;
inner.cached_wire_id = vp.wire_content_id;
}
// Selection xray overlay — rebuilt when the selection changes or the