perf(scene): batch the wire pass into per-run draw calls

Phase 3.3. upload_wires built one GPU instance buffer + one draw call per
WireModel — tens of thousands on a large drawing, a major per-frame CPU /
driver cost. Merge maximal runs of consecutive wires that share scissor +
mesh-edge state into one concatenated instance buffer each (WireGpu::
from_run); the existing draw loop then issues a single draw per run. A 2D
model (uniform None scissor, no mesh edges) collapses to one buffer + one
draw.

Runs are consecutive, never globally regrouped, so the sorted wire order
is preserved exactly — depth-biased overlap and alpha blending both
resolve in submission order, so the image is unchanged. from_run
guarantees instance order (index-ordered collect + sequential flatten).
The WireInstance layout, shader, scissor projection and draw loop are
untouched; only buffer packing changed. Selection overlay stays per-wire.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-05 09:35:18 +03:00
1 parent 65eba3887a
commit c6e17deb7e
3 files changed
+83 -6

No files matched your search

+12 -1
View File
@@ -212,7 +212,18 @@ Today every wire GPU buffer is re-uploaded when
pool — `HashMap<Handle, GpuSlot>` — uploads only the slots that actually
changed. Big win in CAD-edit scenarios.
### 3.3 Single-draw batched wire pipeline (Phase 4-B-style)
### 3.3 Single-draw batched wire pipeline (Phase 4-B-style) ✅ DONE
`upload_wires` made one GPU buffer + one draw call per `WireModel` (tens of
thousands on a large drawing). Now it merges maximal runs of *consecutive*
wires sharing scissor + mesh-edge state into one concatenated instance buffer
each (`WireGpu::from_run`), so the existing draw loop issues one draw per run
— a 2D model collapses to a single buffer + single draw. Runs stay
*consecutive* (not globally regrouped) so the sorted draw order is preserved
bit-for-bit; depth bias and alpha blending are unchanged. The `WireInstance`
layout, shader, scissor logic and draw loop are untouched — only the buffer
packing changed. (No iced widget-pipeline limits were hit: the change lives
entirely inside the existing custom wgpu pipeline.)
Every `WireModel` today costs one draw call plus a bind-group swap. Port
the batched hatch pipeline (`hatch_batched_gpu.rs`) to wires:
+27 -5
View File
@@ -907,13 +907,35 @@ impl Pipeline {
.and_then(|h| depth_map.get(&h).copied())
.unwrap_or(0.0)
};
self.gpu_wires = wires
.iter()
.map(|w| WireGpu::new(device, w, depth_of(w)))
.collect();
// Batch the wire pass: instead of one GPU buffer + one draw call per
// WireModel (tens of thousands on a large drawing), merge maximal runs
// of *consecutive* wires that share scissor + mesh-edge state into one
// concatenated instance buffer each. The draw loop then issues one draw
// per run. Runs must be consecutive (not regrouped) so the original
// wire order — already sorted by draw order — is preserved; depth bias
// and alpha blending both depend on it. Scissor and mesh-edge stay
// grouping keys because the draw loop sets one scissor per batch and
// skips whole mesh-edge batches in shaded modes.
let mut batches: Vec<WireGpu> = Vec::new();
let mut i = 0;
while i < wires.len() {
let scissor = wires[i].vp_scissor;
let mesh_edge = !wires[i].fill_tris.is_empty();
let mut j = i + 1;
while j < wires.len()
&& wires[j].vp_scissor == scissor
&& (!wires[j].fill_tris.is_empty()) == mesh_edge
{
j += 1;
}
batches.extend(WireGpu::from_run(device, &wires[i..j], depth_map, scissor, mesh_edge));
i = j;
}
self.gpu_wires = batches;
// Full-brightness copies of selected wires, drawn on top of everything
// in the selection overlay pass so they're always visible.
// in the selection overlay pass so they're always visible. Kept
// per-wire — the selection set is small and this overlay is separate.
self.gpu_selected_wires = wires
.iter()
.filter(|w| w.selected)
+44
View File
@@ -284,6 +284,50 @@ impl WireGpu {
g
}
/// Merge a run of WireModels that share scissor + mesh-edge state into one
/// (or, past the 256 MB GPU limit, a few) instance buffer(s), then stamp
/// the shared `scissor` / `mesh_edge` onto each so the draw loop treats the
/// whole run as a single batch.
///
/// Unlike [`from_batch`], instance order is **guaranteed** to follow wire
/// order (parallel `collect` is index-ordered; the flatten is sequential).
/// The main wire pass depends on that — depth-biased overlap *and* alpha
/// blending both resolve in submission order, so a reorder would change the
/// image for transparent / coincident wires.
pub fn from_run(
device: &wgpu::Device,
wires: &[WireModel],
depth_map: &rustc_hash::FxHashMap<u64, f32>,
scissor: Option<[f32; 4]>,
mesh_edge: bool,
) -> Vec<Self> {
use rayon::prelude::*;
const MAX_INSTANCES: usize = 268_435_456 / std::mem::size_of::<WireInstance>();
let per: Vec<Vec<WireInstance>> = wires
.par_iter()
.map(|w| emit_wire_instances(w, w.color, wire_draw_depth(w, depth_map)))
.collect();
let mut instances: Vec<WireInstance> = Vec::with_capacity(per.iter().map(Vec::len).sum());
for mut v in per {
instances.append(&mut v);
}
if instances.is_empty() {
return vec![];
}
instances
.chunks(MAX_INSTANCES)
.map(|chunk| {
let buf = instance_buffer_mapped(device, "wire.run.ibuf", chunk);
Self {
instance_buffer: buf,
instance_count: chunk.len() as u32,
vp_scissor: scissor,
is_3d_mesh_edge: mesh_edge,
}
})
.collect()
}
/// Merge multiple WireModels into GPU instance buffers, chunked to fit the
/// 256 MB GPU limit. Each wire keeps its own color and pattern — they live
/// per-instance.