diff --git a/ROADMAP.md b/ROADMAP.md index 25686367e..e27bd2536 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -212,7 +212,18 @@ Today every wire GPU buffer is re-uploaded when pool — `HashMap` — 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: diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index d4e65d1a9..2dfeb79a8 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -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 = 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) diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index 314dd185f..4bdb1c79d 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -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, + scissor: Option<[f32; 4]>, + mesh_edge: bool, + ) -> Vec { + use rayon::prelude::*; + const MAX_INSTANCES: usize = 268_435_456 / std::mem::size_of::(); + let per: Vec> = wires + .par_iter() + .map(|w| emit_wire_instances(w, w.color, wire_draw_depth(w, depth_map))) + .collect(); + let mut instances: Vec = 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.