perf: keep block geometry on the GPU across edits

Adding one entity to a drawing rebuilt every block group's geometry from
scratch. Measured on a 1.17 M-entity drawing with 11 380 block wires, the
per-edit upload split as:

    upload-split regular=80.4ms block=230.3ms

The block half is three quarters of it, and every byte of it was
identical to the previous frame: a new line is not a block reference.
`BlockWireGpu::from_wires` re-emitted each group's segments, re-expanded
them six-fold and re-uploaded the vertex buffers anyway.

That work depends only on the block definition being expanded, which
already has a stable identity: `render_instance.source_id`, minted once
when the block cache expands a definition and unchanged while that
expansion stays cached. Cache the vertex chunks and constants under
`(source_id, mesh_edge, colour)` -- colour is in the key because a
highlight tint changes the constants while leaving the vertices alone.

Only the per-instance placements are rebuilt, and they must be: each
carries its own draw depth and the depth map shifts on every add or
remove. They are also small, a few thousand placements against millions
of vertices.

Entries not seen in a build are dropped, so a block-cache rebuild that
mints fresh source ids does not strand old buffers on the GPU.

Measured: the block half falls from 230 ms to 7.4 ms, and the whole
per-edit upload from ~350 ms to ~118 ms.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
PanosandClaude Opus 5 committed 2026-09-05 09:16:12 +03:00
1 parent 13afc1479f
commit 071b4b6a87
2 files changed
+154 -52

No files matched your search

+8 -1
View File
@@ -213,6 +213,9 @@ pub struct Pipeline {
/// shared resident buffer.
pub(crate) gpu_wires: std::sync::Arc<Vec<WireGpu>>,
pub(crate) gpu_block_wires: std::sync::Arc<Vec<BlockWireGpu>>,
/// Block geometry that survives an edit, keyed by the definition it
/// expands. See `wire_gpu::BlockGeometryCache`.
pub(crate) block_geometry: wire_gpu::BlockGeometryCache,
/// Persistent per-entity wire instance arena (capability-selected format).
/// When active, `gpu_wires` is a thin wrapper over this arena's buffers and an
/// edit patches one entity's slab in place instead of rebuilding every wire.
@@ -2279,6 +2282,7 @@ impl Pipeline {
surface_format: format,
gpu_wires: std::sync::Arc::new(vec![]),
gpu_block_wires: std::sync::Arc::new(vec![]),
block_geometry: Default::default(),
wire_arena: None,
wire_arena_mesh: None,
wire_arena_fallback: std::sync::Arc::new(Vec::new()),
@@ -2337,7 +2341,7 @@ impl Pipeline {
/// `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,
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
wires: &[WireModel],
@@ -2397,6 +2401,7 @@ impl Pipeline {
depth_map,
None,
&self.block_wire_const_bgl,
Some(&mut self.block_geometry),
);
// Index handle → wire slots once, here, so the per-hover selection
@@ -2523,6 +2528,7 @@ impl Pipeline {
depth_map,
Some(tint),
&self.block_wire_const_bgl,
None,
)
} else {
Vec::new()
@@ -2534,6 +2540,7 @@ impl Pipeline {
depth_map,
Some(WireModel::HOVER),
&self.block_wire_const_bgl,
None,
));
self.gpu_selected_block_wires = block_gpu;
if let Some(started) = perf_started {
+146 -51
View File
@@ -665,6 +665,26 @@ pub(crate) fn wire_draw_depth(
}
}
/// Per-definition block geometry that survives an edit.
///
/// A block group's vertex buffers and constants depend only on the definition
/// it expands, identified by `source_id` — minted once when the block cache
/// expands a definition, and unchanged for as long as that expansion is
/// cached. Adding an unrelated entity changes neither, yet the whole block
/// half of the upload was rebuilt every time: 240 ms of the 350 ms an edit
/// cost on a drawing with 11 380 block wires, all of it identical to the
/// previous frame.
///
/// Keyed by `(source_id, mesh_edge, colour)` because a highlight tint changes
/// the constants while leaving the vertices alone.
pub type BlockGeometryCache = rustc_hash::FxHashMap<
(u64, bool, u32),
(
std::sync::Arc<Vec<(wgpu::Buffer, u32)>>,
std::sync::Arc<wgpu::BindGroup>,
),
>;
impl BlockWireGpu {
pub fn from_wires(
device: &wgpu::Device,
@@ -673,8 +693,8 @@ impl BlockWireGpu {
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
color_override: Option<[f32; 4]>,
const_bgl: &wgpu::BindGroupLayout,
mut cache: Option<&mut BlockGeometryCache>,
) -> Vec<Self> {
let mut slots: rustc_hash::FxHashMap<(u64, bool), usize> =
rustc_hash::FxHashMap::default();
let mut groups: Vec<(bool, Vec<&WireModel>)> = Vec::new();
@@ -699,6 +719,8 @@ impl BlockWireGpu {
}
let mut out = Vec::new();
let mut live_keys: rustc_hash::FxHashSet<(u64, bool, u32)> =
rustc_hash::FxHashSet::default();
for (mesh_edge, group) in groups {
let Some(&source) = group.first() else {
continue;
@@ -707,6 +729,30 @@ impl BlockWireGpu {
continue;
};
let color = color_override.unwrap_or(source.color);
let cache_key = (
base.source_id,
mesh_edge,
color.iter().fold(0u32, |acc, c| acc.rotate_left(8) ^ c.to_bits()),
);
live_keys.insert(cache_key);
// Reuse this definition's geometry when it is already on the GPU.
if let Some((chunks, bind_group)) = cache
.as_deref()
.and_then(|c| c.get(&cache_key))
.map(|(v, b)| (std::sync::Arc::clone(v), std::sync::Arc::clone(b)))
{
let instances = block_instances(&group, base, mesh_edge, depth_map);
push_block_batches(
&mut out,
device,
queue,
&chunks,
&instances,
mesh_edge,
&bind_group,
);
continue;
}
let (segments, mut constant) = emit_wire_native(source, 0, color, 0.0);
if segments.is_empty() {
continue;
@@ -770,63 +816,112 @@ impl BlockWireGpu {
}],
},
));
let instances: Vec<BlockWireInstance> = group
.iter()
.filter_map(|wire| {
let instance = wire.render_instance?;
let delta = [
instance.translation[0] - base.translation[0],
instance.translation[1] - base.translation[1],
instance.translation[2] - base.translation[2],
];
let high = delta.map(|value| value as f32);
Some(BlockWireInstance {
translation: high,
translation_low: [
(delta[0] - high[0] as f64) as f32,
(delta[1] - high[1] as f64) as f32,
(delta[2] - high[2] as f64) as f32,
],
depth: [
if mesh_edge {
0.0
} else {
wire_draw_depth(wire, depth_map)
},
0.0,
],
})
})
.collect();
let max_instances = super::gpu_budget::max_elements::<BlockWireInstance>(device);
// Build each instance buffer once and reuse it across vertex
// chunks, rather than re-uploading it per chunk.
let instance_chunks: Vec<(wgpu::Buffer, u32)> = instances
.chunks(max_instances)
.map(|chunk| {
let vertex_chunks = std::sync::Arc::new(vertex_chunks);
if let Some(cache) = cache.as_deref_mut() {
cache.insert(
cache_key,
(
instance_buffer(device, queue, "block_wire.instances", chunk),
chunk.len() as u32,
)
})
.collect();
for (vertex_buffer, vertex_count) in &vertex_chunks {
for (instance_buffer, instance_count) in &instance_chunks {
out.push(Self {
vertex_buffer: vertex_buffer.clone(),
instance_buffer: instance_buffer.clone(),
vertex_count: *vertex_count,
instance_count: *instance_count,
is_3d_mesh_edge: mesh_edge,
const_bind_group: const_bind_group.clone(),
});
}
std::sync::Arc::clone(&vertex_chunks),
std::sync::Arc::clone(&const_bind_group),
),
);
}
let instances = block_instances(&group, base, mesh_edge, depth_map);
push_block_batches(
&mut out,
device,
queue,
&vertex_chunks,
&instances,
mesh_edge,
&const_bind_group,
);
}
// Definitions that are gone — the block cache was rebuilt and minted
// fresh source ids — would otherwise hold their vertex buffers on the
// GPU forever.
if let Some(cache) = cache {
cache.retain(|key, _| live_keys.contains(key));
}
out
}
}
/// Per-instance placements for one block group. Rebuilt even when the geometry
/// is reused: each instance carries its own draw depth, and the draw-depth map
/// shifts whenever anything is added or removed. They are also small — a few
/// thousand placements against millions of vertices.
fn block_instances(
group: &[&WireModel],
base: crate::scene::model::instance_model::RenderInstance,
mesh_edge: bool,
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
) -> Vec<BlockWireInstance> {
group
.iter()
.filter_map(|wire| {
let instance = wire.render_instance?;
let delta = [
instance.translation[0] - base.translation[0],
instance.translation[1] - base.translation[1],
instance.translation[2] - base.translation[2],
];
let high = delta.map(|value| value as f32);
Some(BlockWireInstance {
translation: high,
translation_low: [
(delta[0] - high[0] as f64) as f32,
(delta[1] - high[1] as f64) as f32,
(delta[2] - high[2] as f64) as f32,
],
depth: [
if mesh_edge {
0.0
} else {
wire_draw_depth(wire, depth_map)
},
0.0,
],
})
})
.collect()
}
/// One batch per (vertex chunk × instance chunk). Instance buffers are built
/// once and shared across vertex chunks rather than re-uploaded per chunk.
fn push_block_batches(
out: &mut Vec<BlockWireGpu>,
device: &wgpu::Device,
queue: &wgpu::Queue,
vertex_chunks: &[(wgpu::Buffer, u32)],
instances: &[BlockWireInstance],
mesh_edge: bool,
const_bind_group: &std::sync::Arc<wgpu::BindGroup>,
) {
let max_instances = super::gpu_budget::max_elements::<BlockWireInstance>(device);
let instance_chunks: Vec<(wgpu::Buffer, u32)> = instances
.chunks(max_instances)
.map(|chunk| {
(
instance_buffer(device, queue, "block_wire.instances", chunk),
chunk.len() as u32,
)
})
.collect();
for (vertex_buffer, vertex_count) in vertex_chunks {
for (instance_buffer, instance_count) in &instance_chunks {
out.push(BlockWireGpu {
vertex_buffer: vertex_buffer.clone(),
instance_buffer: instance_buffer.clone(),
vertex_count: *vertex_count,
instance_count: *instance_count,
is_3d_mesh_edge: mesh_edge,
const_bind_group: const_bind_group.clone(),
});
}
}
}
/// Upload one constant chunk; its instances use chunk-local wire IDs.
fn build_const_bind_group(
device: &wgpu::Device,