From 95ef3b3890c3ef58d2ee0a8ea6a63ea39e1d6ea1 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 15 Jul 2026 09:48:20 +0300 Subject: [PATCH] perf(render): split the wire arena into regular + mesh-edge batches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-batch arena required a set with no mesh/solid edges, so any drawing with a 3D solid or mesh (this one: 1693 mesh-edge wires of 12200) disqualified the WHOLE set — every edit fell back to build_wire_buffers re-uploading ~265 MB (4.4M instances), stalling ~1s per edit on a shared-memory GPU. Split the resident set into a regular-2D arena and a mesh-edge arena (the wires that need the draw loops mesh-edge skip/black treatment), one WireArena each, passed as &[&WireModel] subsets (no clone). Both patch incrementally; the mesh arena carries is_3d_mesh_edge so the existing draw loop handles it. A changed handle absent from an arenas subset is a no-op for it, so each edit touches only the batch it belongs to. Result: the wire block drops from ~300ms + a 265 MB GPU upload to ~2ms. Two correctness points folded in from adversarial review: - mesh-edge wires are stamped draw_depth 0 (matching WireGpu::from_run) so their hidden back edges dont peek through the shaded fill; the mesh arena also always bails on append (all its wires share depth 0, so relocation would reorder coincident edges). - buffers are capped at wgpus 256 MB limit — a batch larger than one buffer returns None so the caller chunks it via the batched path (fixes a create_buffer panic on the 355 MB regular batch). Still behind OCS_WIRE_GPU_PATCH (default off). OCS_PERF prints the per-edit wire timing + outcome. Co-Authored-By: Claude Opus 4.8 --- src/scene/pipeline/mod.rs | 9 ++- src/scene/pipeline/wire_arena.rs | 94 +++++++++++++++++--------- src/scene/view/render.rs | 109 ++++++++++++++++++++++++------- 3 files changed, 157 insertions(+), 55 deletions(-) diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 628e03332..e892e5422 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -154,7 +154,12 @@ pub struct Pipeline { /// edit patches one entity's slab in place instead of rebuilding every wire. #[cfg(not(target_arch = "wasm32"))] pub(crate) wire_arena: Option, - /// The Model content id `wire_arena` currently mirrors (`u64::MAX` = none). + /// Second arena for the mesh/solid EDGE wires (drawn with the mesh-edge skip / + /// black treatment); the resident set is split into this + `wire_arena` so + /// both patch incrementally. Shares `wire_arena_id`. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) wire_arena_mesh: Option, + /// The Model content id both arenas currently mirror (`u64::MAX` = none). #[cfg(not(target_arch = "wasm32"))] pub(crate) wire_arena_id: u64, /// Pixel scissor rects [x, y, w, h] for viewport-clipped wires. Recomputed each frame. @@ -1320,6 +1325,8 @@ impl Pipeline { #[cfg(not(target_arch = "wasm32"))] wire_arena: None, #[cfg(not(target_arch = "wasm32"))] + wire_arena_mesh: None, + #[cfg(not(target_arch = "wasm32"))] wire_arena_id: u64::MAX, wire_pixel_scissors: vec![], gpu_selected_wires: vec![], diff --git a/src/scene/pipeline/wire_arena.rs b/src/scene/pipeline/wire_arena.rs index 89de70d2f..54a3150c9 100644 --- a/src/scene/pipeline/wire_arena.rs +++ b/src/scene/pipeline/wire_arena.rs @@ -50,6 +50,12 @@ const HEADROOM_NUM: u64 = 3; const HEADROOM_DEN: u64 = 2; const MIN_INST_CAP: u64 = 4096; const MIN_CONST_CAP: u64 = 1024; +/// wgpu caps a single buffer at 256 MB. An arena keeps ONE instance buffer per +/// batch, so a batch whose instances exceed this can't be an arena — build +/// returns None and the caller falls back to the chunked batched path. The +/// buffer (with headroom) is also clamped here so it never exceeds the limit. +const MAX_INSTANCES: u64 = 268_435_456 / std::mem::size_of::() as u64; +const MAX_CONSTS: u64 = 268_435_456 / std::mem::size_of::() as u64; struct Slab { inst_off: u32, @@ -73,18 +79,29 @@ pub struct WireArena { /// Tombstoned instances (blanked, not reclaimed) — past half the tail a patch /// bails so the caller compacts with a full rebuild. tombstoned: u32, + /// Whether this arena's wires are 3D mesh/solid edges (`is_3d_mesh_edge` on + /// the draw batch): the draw loop hides them in clean-shaded modes and draws + /// them black in filled-with-edges modes. The regular and mesh-edge subsets + /// of the resident set each get their own arena so both patch incrementally. + mesh_edge: bool, } fn handle_of(w: &WireModel) -> Option { crate::scene::Scene::handle_from_wire_name(&w.name) } -/// True if the whole set is arena-eligible: a single batch that draws with no -/// viewport scissor — no mesh/solid fill and no per-wire scissor. +/// True if the set can use the arena: no per-wire scissor (paper viewports). Mesh +/// fills no longer disqualify it — the render layer splits the set into a regular +/// and a mesh-edge subset, one arena each. pub fn is_arena_eligible(wires: &[WireModel]) -> bool { - wires - .iter() - .all(|w| w.fill_tris.is_empty() && w.vp_scissor.is_none()) + wires.iter().all(|w| w.vp_scissor.is_none()) +} + +/// True when `w`'s edge segments belong to a mesh/solid whose fill is drawn in a +/// separate pass — such wires need the draw loop's mesh-edge treatment, so they +/// go in their own arena. `mesh_names` are the entities that emit a fill. +pub fn is_mesh_edge(w: &WireModel, mesh_names: &rustc_hash::FxHashSet) -> bool { + !w.points.is_empty() && handle_of(w).map_or(false, |h| mesh_names.contains(&h.value())) } /// True when appending a new entity at the tail could change the image, so the @@ -95,7 +112,7 @@ pub fn is_arena_eligible(wires: &[WireModel]) -> bool { /// Surface) are excluded from `draw_depth_map`, so their fallback edge wires /// get draw_depth 0.0. Two coincident opaque such wires share a z-bias and /// resolve by submission order, which a tail relocation would flip. -fn append_unsafe(wires: &[WireModel], depth_map: &FxHashMap) -> bool { +fn append_unsafe(wires: &[&WireModel], depth_map: &FxHashMap) -> bool { wires.iter().any(|w| { w.color[3] < 0.999 || handle_of(w).map_or(true, |h| !depth_map.contains_key(&h.value())) @@ -116,13 +133,13 @@ pub fn build_handle_index(wires: &[WireModel]) -> std::sync::Arc Option> { +fn handle_ranges(wires: &[&WireModel]) -> Option> { let mut out: Vec<(Handle, usize, usize)> = Vec::new(); let mut i = 0; while i < wires.len() { - let h = handle_of(&wires[i])?; + let h = handle_of(wires[i])?; let mut j = i + 1; - while j < wires.len() && handle_of(&wires[j]) == Some(h) { + while j < wires.len() && handle_of(wires[j]) == Some(h) { j += 1; } out.push((h, i, j)); @@ -189,13 +206,11 @@ impl WireArena { pub fn build( device: &wgpu::Device, queue: &wgpu::Queue, - wires: &[WireModel], + wires: &[&WireModel], depth_map: &FxHashMap, const_bgl: &wgpu::BindGroupLayout, + mesh_edge: bool, ) -> Option { - if !is_arena_eligible(wires) { - return None; - } let ranges = handle_ranges(wires)?; // const slot 0 = blank tombstone target. @@ -205,9 +220,12 @@ impl WireArena { for (h, i, j) in ranges { let inst_off = instances.len() as u32; let const_off = consts_cpu.len() as u32; - for w in &wires[i..j] { + for &w in &wires[i..j] { let wire_id = consts_cpu.len() as u32; - let dd = wire_draw_depth(w, depth_map); + // 3D mesh outline edges are occluded by true depth and must NOT + // take the draw-order z-bias (or hidden back edges peek through + // the shaded fill) — matching WireGpu::from_run. + let dd = if mesh_edge { 0.0 } else { wire_draw_depth(w, depth_map) }; let (mut insts, cst) = emit_wire_native(w, wire_id, w.color, dd); instances.append(&mut insts); consts_cpu.push(cst); @@ -225,9 +243,17 @@ impl WireArena { let inst_tail = instances.len() as u32; let const_tail = consts_cpu.len() as u32; - let inst_cap = ((inst_tail as u64 * HEADROOM_NUM / HEADROOM_DEN).max(MIN_INST_CAP)) as u32; - let const_cap = - ((const_tail as u64 * HEADROOM_NUM / HEADROOM_DEN).max(MIN_CONST_CAP)) as u32; + // A batch bigger than one buffer can't be an arena — let the caller chunk + // it via the batched path. + if inst_tail as u64 > MAX_INSTANCES || const_tail as u64 > MAX_CONSTS { + return None; + } + let inst_cap = ((inst_tail as u64 * HEADROOM_NUM / HEADROOM_DEN) + .max(MIN_INST_CAP) + .min(MAX_INSTANCES)) as u32; + let const_cap = ((const_tail as u64 * HEADROOM_NUM / HEADROOM_DEN) + .max(MIN_CONST_CAP) + .min(MAX_CONSTS)) as u32; let inst_buf = alloc_inst(device, inst_cap as u64); let const_buf = alloc_const(device, const_cap as u64); if inst_tail > 0 { @@ -247,6 +273,7 @@ impl WireArena { consts_cpu, slabs, tombstoned: 0, + mesh_edge, }) } @@ -265,12 +292,9 @@ impl WireArena { &mut self, queue: &wgpu::Queue, changes: &[(Handle, ChangeKind)], - wires: &[WireModel], + wires: &[&WireModel], depth_map: &FxHashMap, ) -> bool { - if !is_arena_eligible(wires) { - return false; - } let Some(ranges) = handle_ranges(wires) else { return false; }; @@ -282,7 +306,9 @@ impl WireArena { for &(h, kind) in changes { let new_range = range_of.get(&h).copied(); - // Removed / now-hidden ⇒ tombstone the slab. + // Removed / now-hidden ⇒ tombstone the slab. A handle not in THIS + // arena's subset (it belongs to the other batch) simply isn't in its + // slabs, so this is a no-op for it. if matches!(kind, ChangeKind::Removed) || new_range.is_none() { if let Some(slab) = self.slabs.remove(&h) { let blanks = vec![blank_instance(); slab.inst_len as usize]; @@ -298,9 +324,9 @@ impl WireArena { // Emit into fresh, run-local const slots (patched to absolute below). let mut insts: Vec = Vec::new(); let mut csts: Vec = Vec::new(); - for w in run { + for &w in run { let wire_id = csts.len() as u32; - let dd = wire_draw_depth(w, depth_map); + let dd = if self.mesh_edge { 0.0 } else { wire_draw_depth(w, depth_map) }; let (mut wi, c) = emit_wire_native(w, wire_id, w.color, dd); insts.append(&mut wi); csts.push(c); @@ -339,9 +365,11 @@ impl WireArena { } // Layout changed ⇒ append at the tail. Unsafe to relocate when the set - // resolves overlap by submission order (transparency, or a wire with - // no draw-order depth); fall back to a full rebuild instead. - if append_unsafe { + // resolves overlap by submission order: transparency, a wire with no + // draw-order depth, or the mesh-edge arena (all its wires are forced + // to depth 0, so coincident edges resolve by submission order). Fall + // back to a full rebuild instead. + if append_unsafe || self.mesh_edge { return false; } if self.inst_tail + inst_len > self.inst_cap @@ -383,7 +411,13 @@ impl WireArena { // depth map and re-upload the whole (small) const buffer; the instance // buffer is untouched. for (h, slab) in &self.slabs { - let dd = depth_map.get(&h.value()).copied().unwrap_or(0.0); + // Mesh-edge wires keep depth 0 (no draw-order bias); regular wires + // take the re-normalised map value. + let dd = if self.mesh_edge { + 0.0 + } else { + depth_map.get(&h.value()).copied().unwrap_or(0.0) + }; for k in 0..slab.const_len { self.consts_cpu[(slab.const_off + k) as usize].draw_depth = dd; } @@ -406,7 +440,7 @@ impl WireArena { instance_buffer: self.inst_buf.clone(), instance_count: self.inst_tail, vp_scissor: None, - is_3d_mesh_edge: false, + is_3d_mesh_edge: self.mesh_edge, const_bind_group: Some(self.const_bind_group.clone()), }] } diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 3ffcf16b5..72c7be2da 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -369,38 +369,81 @@ impl shader::Primitive for Primitive { // 2D/3D sets fall through to the shared batched path below. let mut arena_served = false; #[cfg(not(target_arch = "wasm32"))] + let _perf = std::env::var_os("OCS_PERF").is_some(); + #[cfg(not(target_arch = "wasm32"))] + let _t0 = std::time::Instant::now(); + #[cfg(not(target_arch = "wasm32"))] + let mut _patched = false; + #[cfg(not(target_arch = "wasm32"))] if crate::scene::wire_gpu_patch_enabled() && inner.wire_const_bgl.is_some() && crate::scene::pipeline::wire_arena::is_arena_eligible(&vp.wires) { - use crate::scene::pipeline::wire_arena; - // In-place patch from the base the GPU holds, else rebuild. - let patched = if let (Some(arena), Some((base, changes))) = - (inner.wire_arena.as_mut(), vp.wire_patch.as_ref()) - { - inner.wire_arena_id == *base - && !changes.is_empty() - && arena.patch(queue, changes, &vp.wires[..], &vp.draw_depths) - } else { - false - }; - if !patched { - let bgl = inner.wire_const_bgl.as_ref().unwrap(); - inner.wire_arena = wire_arena::WireArena::build( - device, - queue, - &vp.wires[..], - &vp.draw_depths, - bgl, - ); + use crate::scene::pipeline::wire_arena::{self, WireArena}; + // Split the resident set into the regular 2D wires and the + // mesh/solid EDGE wires (which need the mesh-edge draw + // treatment), one arena each, so both patch incrementally. + // Fill-only wires (no segments) are drawn in the face3d pass, + // not here, so they go in neither. + let mesh_names: rustc_hash::FxHashSet = vp + .wires + .iter() + .filter(|w| !w.fill_tris.is_empty() && !w.fill_tris_low.is_empty()) + .filter_map(|w| w.name.parse::().ok()) + .collect(); + let regular: Vec<&crate::scene::WireModel> = vp + .wires + .iter() + .filter(|w| { + !w.points.is_empty() && !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)) + .collect(); + let bgl = inner.wire_const_bgl.as_ref().unwrap(); + let base_ok = vp + .wire_patch + .as_ref() + .map_or(false, |(base, changes)| { + inner.wire_arena_id == *base && !changes.is_empty() + }); + let changes = vp.wire_patch.as_ref().map(|(_, c)| c); + + // Each batch: patch from the shared base if possible, else + // rebuild just that batch. They advance together. + let reg_ok = base_ok + && inner.wire_arena.as_mut().map_or(false, |a| { + a.patch(queue, changes.unwrap(), ®ular, &vp.draw_depths) + }); + if !reg_ok { + inner.wire_arena = + WireArena::build(device, queue, ®ular, &vp.draw_depths, bgl, false); } - if let Some(arena) = inner.wire_arena.as_ref() { - inner.gpu_wires = std::sync::Arc::new(arena.wire_gpus()); - inner.wire_handle_index = - wire_arena::build_handle_index(&vp.wires[..]); + let mesh_ok = base_ok + && inner.wire_arena_mesh.as_mut().map_or(false, |a| { + a.patch(queue, changes.unwrap(), &mesh, &vp.draw_depths) + }); + if !mesh_ok { + inner.wire_arena_mesh = + WireArena::build(device, queue, &mesh, &vp.draw_depths, bgl, true); + } + _patched = reg_ok && mesh_ok; + + if let (Some(reg), Some(me)) = + (inner.wire_arena.as_ref(), inner.wire_arena_mesh.as_ref()) + { + let mut gpus = reg.wire_gpus(); + gpus.extend(me.wire_gpus()); + inner.gpu_wires = std::sync::Arc::new(gpus); + inner.wire_handle_index = wire_arena::build_handle_index(&vp.wires[..]); inner.wire_arena_id = vp.wire_content_id; arena_served = true; } else { + inner.wire_arena = None; + inner.wire_arena_mesh = None; inner.wire_arena_id = u64::MAX; } } @@ -436,6 +479,24 @@ impl shader::Primitive for Primitive { inner.wire_handle_index = built.1; } // end !arena_served inner.cached_wire_id = vp.wire_content_id; + #[cfg(not(target_arch = "wasm32"))] + if _perf { + let gi: u32 = inner.gpu_wires.iter().map(|w| w.instance_count).sum(); + let outcome = if !arena_served { + "shared-fullupload" + } else if _patched { + "arena-patch" + } else { + "arena-build" + }; + eprintln!( + "[perf] wire {:>7.1}ms {:<18} wires={} gpu_instances={}", + _t0.elapsed().as_secs_f64() * 1000.0, + outcome, + vp.wires.len(), + gi, + ); + } } // Selection xray overlay — rebuilt when the selection changes or the // underlying wires changed. A pick bumps only selection_generation,