From 071b4b6a8785c2d946c4367ace88c0370f445e2f Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:16:12 +0300 Subject: [PATCH 1/2] 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 --- src/scene/pipeline/mod.rs | 9 +- src/scene/pipeline/wire_gpu.rs | 197 ++++++++++++++++++++++++--------- 2 files changed, 154 insertions(+), 52 deletions(-) diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 8e983d497..192ef6a16 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -213,6 +213,9 @@ pub struct Pipeline { /// shared resident buffer. pub(crate) gpu_wires: std::sync::Arc>, pub(crate) gpu_block_wires: std::sync::Arc>, + /// 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 { diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index 9d65d44a7..3fe96e415 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -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>, + std::sync::Arc, + ), +>; + impl BlockWireGpu { pub fn from_wires( device: &wgpu::Device, @@ -673,8 +693,8 @@ impl BlockWireGpu { depth_map: &rustc_hash::FxHashMap, color_override: Option<[f32; 4]>, const_bgl: &wgpu::BindGroupLayout, + mut cache: Option<&mut BlockGeometryCache>, ) -> Vec { - 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 = 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::(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, +) -> Vec { + 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, + device: &wgpu::Device, + queue: &wgpu::Queue, + vertex_chunks: &[(wgpu::Buffer, u32)], + instances: &[BlockWireInstance], + mesh_edge: bool, + const_bind_group: &std::sync::Arc, +) { + let max_instances = super::gpu_budget::max_elements::(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, From fb64e76f0405420bd79b48b8bf3b23e748969883 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:18:50 +0300 Subject: [PATCH 2/2] fix: let the wire arena serve drawings that contain blocks Clicking to end a segment froze the UI until the whole drawing had been re-uploaded. With block geometry cached the remaining cost is the regular half, and on two drawings it dominates: 113 k wires regular=96ms block=13ms index=12ms total 122ms 198 k wires regular=85ms block= 7ms index=25ms total 118ms It is rebuilt from scratch because `PersistentWireArena` -- the only thing that patches wire buffers in place -- was disabled outright by: && !vp_wires.iter().any(|wire| wire.render_instance.is_some()) One block reference was enough; the reproducer has 11 363. The scene already produces an incremental patch in 0.9 ms and the GPU side could not use it. Instanced wires are not something the arena has to handle: they are a different batch type with their own pipeline. Partition them out, let the arena own and patch the rest, and keep building `BlockWireGpu` for them. The handle index is unaffected -- it maps into `vp_wires`, not into arena slabs, and the patch path already maintains it. Measured on the 198 k-wire drawing, per drawn segment: before wire 350ms (shared-fullupload) after wire 12ms (arena-patch) with the whole click, including `bump_entities`, going from ~411 ms to ~20 ms. Verified visually on drawings with and without blocks, through repeated edits and undo/redo. Co-Authored-By: Claude Opus 5 --- src/scene/pipeline/mod.rs | 23 +++++++++++++++++++++++ src/scene/view/render.rs | 33 ++++++++++++++++++++++++++++++--- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 192ef6a16..12b9ab0ef 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -2340,6 +2340,29 @@ impl Pipeline { /// 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. + /// Batches for the instanced (block-reference) wires alone. + /// + /// The arena serves the non-instanced wires and patches them in place; + /// block references are a different batch type and still rebuild, but + /// their geometry is cached by `source_id`, so that is cheap. + pub fn upload_block_wires( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + wires: &[&WireModel], + depth_map: &rustc_hash::FxHashMap, + ) -> Vec { + BlockWireGpu::from_wires( + device, + queue, + wires, + depth_map, + None, + &self.block_wire_const_bgl, + Some(&mut self.block_geometry), + ) + } + pub fn build_wire_buffers( &mut self, device: &wgpu::Device, diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index bc0568231..81cea93e0 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -655,7 +655,14 @@ impl shader::Primitive for Primitive { || ((vp.wire_patch.is_some() || inner.wire_arena_id != u64::MAX) && packed_arena_owner)) - && !vp_wires.iter().any(|wire| wire.render_instance.is_some()); + ; + // Instanced wires used to disable the arena outright, and with + // it incremental patching: one block reference in the drawing + // meant every edit re-uploaded every wire, ~96 ms of the + // ~122 ms a click cost. They are simply a different batch type + // with their own pipeline, so the arena takes the rest and + // `BlockWireGpu` keeps them -- cheaply now that its geometry is + // cached by `source_id`. if use_wire_arena { use crate::scene::pipeline::wire_arena::{ self, PersistentWireArena as WireArena, @@ -767,12 +774,16 @@ impl shader::Primitive for Primitive { .iter() .filter(|w| { !w.points.is_empty() + && w.render_instance.is_none() && !wire_arena::is_mesh_edge(w, &mesh_names) }) .collect(); let mesh: Vec<&crate::scene::WireModel> = vp_wires .iter() - .filter(|w| wire_arena::is_mesh_edge(w, &mesh_names)) + .filter(|w| { + w.render_instance.is_none() + && wire_arena::is_mesh_edge(w, &mesh_names) + }) .collect(); if !reg_ok { inner.wire_arena = WireArena::build( @@ -874,7 +885,23 @@ impl shader::Primitive for Primitive { gpus.extend(arena.wire_gpus()); } inner.gpu_wires = std::sync::Arc::new(gpus); - inner.gpu_block_wires = std::sync::Arc::new(Vec::new()); + // The arena holds only the non-instanced wires; + // block references keep their own batch type. Their + // geometry is cached by `source_id`, so rebuilding them + // here is cheap against the ~96 ms of regular wires the + // arena now patches instead of re-uploading. + let instanced: Vec<&crate::scene::WireModel> = vp_wires + .iter() + .filter(|w| w.render_instance.is_some()) + .collect(); + inner.gpu_block_wires = std::sync::Arc::new( + inner.upload_block_wires( + device, + queue, + &instanced, + &draw_depths, + ), + ); if _patched { wire_arena::patch_handle_index( &mut inner.wire_handle_index,