From 38c8583d275aa0e346c4ec5f353984d7514cdd7a Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 15 Jul 2026 06:02:25 +0300 Subject: [PATCH] perf(render): in-place GPU wire arena for move/edit (phase 4, OCS_WIRE_GPU_PATCH) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a geometry edit the resident set gets a fresh content id, so the batched wire path (build_wire_buffers) re-emits and re-uploads EVERY wire instance — the whole buffer even when one line moved. Behind the OCS_WIRE_GPU_PATCH flag (default off, native only), add a persistent per-entity instance arena: one long-lived VERTEX|COPY_DST instance buffer + its WireConst storage, with a per-entity slab table. An in-place edit (move / rotate / scale / colour — same segment count) becomes a queue.write_buffer of just that entity`s slab. Scope is deliberately IN-PLACE ONLY. draw_depth_map normalises each entity`s draw-order z-bias by the block`s entity count, so an Add or Remove re-scales EVERY entity`s z-bias — which a per-slab patch can`t honour — and a segment-count change relocates the entity. patch() returns false for any of those (and for mesh-edge / scissored sets), and the caller falls back to the identical full batched rebuild, so correctness never rides on the fast path. An adversarial review of an earlier append/tombstone design surfaced exactly these draw-order hazards; restricting to in-place removes them while keeping the common interactive-edit win. The Scene hands the changed handles to the arena via model_wire_gpu_patch, recorded by try_resident_patch for the Model block and matched by content id in prepare(); scissored paper viewports and mixed 2D/3D sets keep the batched path. Co-Authored-By: Claude Opus 4.8 --- src/scene/mod.rs | 58 ++++++- src/scene/pipeline/mod.rs | 19 ++- src/scene/pipeline/wire_arena.rs | 270 +++++++++++++++++++++++++++++++ src/scene/pipeline/wire_gpu.rs | 7 +- src/scene/view/render.rs | 50 ++++++ 5 files changed, 398 insertions(+), 6 deletions(-) create mode 100644 src/scene/pipeline/wire_arena.rs diff --git a/src/scene/mod.rs b/src/scene/mod.rs index e11174c2c..70c09307d 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -121,6 +121,21 @@ struct GeometryDelta { /// and does a one-time full rebuild — the safe fallback, not a correctness hole. const GEOMETRY_JOURNAL_CAP: usize = 256; +/// Whether the persistent per-entity GPU wire arena (`OCS_WIRE_GPU_PATCH`) is +/// enabled — patches one entity's instance slab on an edit instead of rebuilding +/// the whole wire buffer. Opt-in while it is validated visually. Always off on +/// wasm (WebGL2 has the fat self-contained instance, no arena). +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn wire_gpu_patch_enabled() -> bool { + use std::sync::OnceLock; + static ON: OnceLock = OnceLock::new(); + *ON.get_or_init(|| std::env::var_os("OCS_WIRE_GPU_PATCH").is_some()) +} +#[cfg(target_arch = "wasm32")] +pub(crate) fn wire_gpu_patch_enabled() -> bool { + false +} + /// Resolve a viewport's paper-to-model scale ratio from its two /// DXF-derived sources. /// @@ -983,6 +998,13 @@ pub struct Scene { /// a full re-tessellation. Diagnostics + a hook for the oracle test to prove /// the fast path is actually exercised (not silently always falling back). resident_patch_hits: std::cell::Cell, + /// Handoff for the GPU wire arena (`OCS_WIRE_GPU_PATCH`): when the MODEL + /// resident set was brought up to date by an incremental patch, this records + /// `(prev_gen, new_gen, changed handles)` so the render layer can patch just + /// those entities' instance slabs instead of re-uploading every wire. The + /// render layer matches `new_gen` against the viewport's content id and + /// `prev_gen` against what the GPU currently holds; a mismatch just rebuilds. + model_wire_gpu_patch: RefCell>)>>, } impl Scene { @@ -1067,6 +1089,7 @@ impl Scene { geometry_deltas: RefCell::new(std::collections::VecDeque::new()), geometry_journal_floor: std::cell::Cell::new(0), resident_patch_hits: std::cell::Cell::new(0), + model_wire_gpu_patch: RefCell::new(None), } } @@ -2654,6 +2677,11 @@ impl Scene { let arc = Arc::new(wires); let gen = WIRE_CONTENT_GEN.fetch_add(1, Ordering::Relaxed); self.last_model_wire_gen.set(gen); + // A full rebuild of the Model set — the GPU arena must rebuild too, not + // apply a stale patch, so clear any pending handoff. + if block == self.model_space_block_handle() { + *self.model_wire_gpu_patch.borrow_mut() = None; + } let mut sets = self.resident_wire_sets.borrow_mut(); // Evict stale entries (older epochs / abandoned keys) so switching // spaces or re-scaling a viewport can't accumulate dead full sets. @@ -2666,6 +2694,21 @@ impl Scene { arc } + /// The GPU wire-arena handoff for a viewport whose content id is `gen`: + /// `(prev_gen, changed handles)` when the Model set reached `gen` via an + /// incremental resident patch, else `None`. Read (not consumed) so every + /// Model tile applies the same patch; gen-matching keeps it from applying to + /// a viewport that rebuilt independently. + pub(crate) fn model_wire_patch_for( + &self, + gen: u64, + ) -> Option<(u64, Arc>)> { + match &*self.model_wire_gpu_patch.borrow() { + Some((prev, new, changes)) if *new == gen => Some((*prev, Arc::clone(changes))), + _ => None, + } + } + /// Bring the resident set for `key` up to the current epoch by replaying only /// the changed entities into the cached assembly. Returns the patched `Arc`, /// or `None` to fall back to a full rebuild — the safe default whenever the @@ -2696,9 +2739,11 @@ impl Scene { let deltas = self.replay_since(cached_epoch)?; // Take ownership of the cached assembly (guaranteed unique above). - let owned: Vec = { - let arc = self.resident_wire_sets.borrow_mut().remove(&key)?.2; - Arc::try_unwrap(arc).ok()? + // `prev_gen` is the content id the GPU currently holds for this set — the + // base the wire-arena patch replays from. + let (prev_gen, owned) = { + let removed = self.resident_wire_sets.borrow_mut().remove(&key)?; + (removed.1, Arc::try_unwrap(removed.2).ok()?) }; // Group into per-entity runs. Bail if any wire isn't named with a handle @@ -2818,6 +2863,13 @@ impl Scene { .set(self.resident_patch_hits.get() + 1); let gen = WIRE_CONTENT_GEN.fetch_add(1, Ordering::Relaxed); self.last_model_wire_gen.set(gen); + // Hand the exact changed handles to the GPU wire arena so it patches just + // those entities' slabs. Only for the Model set (the arena is model-only); + // the render layer verifies prev_gen against what the GPU actually holds. + if wire_gpu_patch_enabled() && block == self.model_space_block_handle() { + *self.model_wire_gpu_patch.borrow_mut() = + Some((prev_gen, gen, Arc::new(deltas.clone()))); + } let cur_epoch = self.geometry_epoch; let mut sets = self.resident_wire_sets.borrow_mut(); sets.retain(|_, (e, ..)| *e == cur_epoch); diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 164e75b48..628e03332 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -10,6 +10,11 @@ pub mod mesh_gpu; pub mod text_gpu; pub mod uniforms; pub mod viewcube; +/// Persistent per-entity wire instance arena (native), enabled by +/// `OCS_WIRE_GPU_PATCH` — patches one entity's slab via `write_buffer` instead +/// of re-uploading the whole wire set on an edit. +#[cfg(not(target_arch = "wasm32"))] +pub mod wire_arena; pub mod wire_gpu; use iced::wgpu; @@ -44,7 +49,7 @@ pub struct Pipeline { /// xray pipelines). `Some` on native; `None` on web (fat instance, no /// storage). Passed to `WireGpu::from_run` / `from_batch` so they can build /// each batch's per-wire bind group. - wire_const_bgl: Option, + pub(crate) wire_const_bgl: Option, wipeout_pipeline: wgpu::RenderPipeline, /// Phase 4-B — single-draw batched hatch pipeline. Per-instance /// data lives in storage buffers; one draw call covers every @@ -144,6 +149,14 @@ pub struct Pipeline { /// their camera uniforms + scissor stay per-slot, only the geometry is /// deduplicated. Never mutated in place, only reassigned on content change. pub(crate) gpu_wires: std::sync::Arc>, + /// Persistent per-entity wire instance arena (native, `OCS_WIRE_GPU_PATCH`). + /// 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. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) wire_arena: Option, + /// The Model content id `wire_arena` currently mirrors (`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. wire_pixel_scissors: Vec>, /// Ghost copies (25% alpha) of selected wires for the X-ray depth pass. @@ -1304,6 +1317,10 @@ impl Pipeline { blit_uniform_buffer, surface_format: format, gpu_wires: std::sync::Arc::new(vec![]), + #[cfg(not(target_arch = "wasm32"))] + wire_arena: None, + #[cfg(not(target_arch = "wasm32"))] + wire_arena_id: u64::MAX, wire_pixel_scissors: vec![], gpu_selected_wires: vec![], gpu_preview_wires: vec![], diff --git a/src/scene/pipeline/wire_arena.rs b/src/scene/pipeline/wire_arena.rs new file mode 100644 index 000000000..fb3f2d981 --- /dev/null +++ b/src/scene/pipeline/wire_arena.rs @@ -0,0 +1,270 @@ +// Persistent per-entity wire instance arena (native, behind OCS_WIRE_GPU_PATCH). +// +// The normal wire path re-emits EVERY wire into a fresh, tightly-packed instance +// buffer whenever the resident set's content id changes — so moving one line on a +// million-wire drawing re-uploads all million instances. This arena instead keeps +// one persistent instance buffer (plus its shared WireConst storage) with per- +// entity *slabs*, so an in-place edit is a `queue.write_buffer` of just that +// entity's slab. +// +// Scope (v1): IN-PLACE patches only. A move / rotate / scale / colour change keeps +// an entity's slab, its instance count, and — crucially — the block's entity count, +// so `draw_depth_map`'s per-entity z-bias (normalised by that count) is unchanged +// and every other slab stays correct. `patch` returns false (⇒ the caller does a +// full batched rebuild) for anything else: an Add or Remove (which re-scales every +// entity's z-bias), a segment-count change, or a set that isn't a single scissor- +// free batch (mesh-edge fills, paper viewport scissor). Because a full rebuild is +// always the fallback, correctness never rides on the fast path. + +#![cfg(not(target_arch = "wasm32"))] + +use super::wire_gpu::{emit_wire_native, wire_draw_depth, WireConst, WireGpu, WireInstance}; +use crate::scene::model::wire_model::WireModel; +use crate::scene::ChangeKind; +use acadrust::Handle; +use iced::wgpu; +use rustc_hash::FxHashMap; + +struct Slab { + inst_off: u32, + inst_len: u32, + const_off: u32, + const_len: u32, +} + +pub struct WireArena { + inst_buf: wgpu::Buffer, + inst_tail: u32, + const_buf: wgpu::Buffer, + const_bind_group: std::sync::Arc, + slabs: FxHashMap, +} + +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 (which forces the multi-batch draw-order +/// split) and no per-wire scissor (paper content viewports, which the arena's +/// scissor-free wrapper would fail to clip). Model tiles qualify; scissored paper +/// viewports fall back to the normal batched path. +pub fn is_arena_eligible(wires: &[WireModel]) -> bool { + wires + .iter() + .all(|w| w.fill_tris.is_empty() && w.vp_scissor.is_none()) +} + +/// handle → wire-slot index for the selection / text-highlight overlays, built +/// from the resident Vec (independent of the arena's slab layout, exactly like +/// the batched path's index). +pub fn build_handle_index(wires: &[WireModel]) -> std::sync::Arc>> { + let mut index: FxHashMap> = FxHashMap::default(); + index.reserve(wires.len()); + for (idx, w) in wires.iter().enumerate() { + if let Ok(h) = w.name.parse::() { + index.entry(h).or_default().push(idx as u32); + } + } + std::sync::Arc::new(index) +} + +/// Group `wires` (draw-order sorted, entity-contiguous) into per-handle ranges. +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 mut j = i + 1; + while j < wires.len() && handle_of(&wires[j]) == Some(h) { + j += 1; + } + out.push((h, i, j)); + i = j; + } + Some(out) +} + +fn build_const_bind_group( + device: &wgpu::Device, + bgl: &wgpu::BindGroupLayout, + buf: &wgpu::Buffer, +) -> std::sync::Arc { + std::sync::Arc::new(device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("wire_arena.const.bg"), + layout: bgl, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: buf.as_entire_binding(), + }], + })) +} + +fn alloc_inst(device: &wgpu::Device, cap: u32) -> wgpu::Buffer { + device.create_buffer(&wgpu::BufferDescriptor { + label: Some("wire_arena.ibuf"), + size: (cap as u64) * std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }) +} + +fn alloc_const(device: &wgpu::Device, cap: u32) -> wgpu::Buffer { + device.create_buffer(&wgpu::BufferDescriptor { + label: Some("wire_arena.cbuf"), + size: (cap as u64) * std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }) +} + +impl WireArena { + /// Build a fresh arena from the full resident set, or `None` if it isn't + /// single-batch (has mesh-edge fills) or a wire is unnamed — in which case + /// the caller keeps using the normal batched path. + pub fn build( + device: &wgpu::Device, + queue: &wgpu::Queue, + wires: &[WireModel], + depth_map: &FxHashMap, + const_bgl: &wgpu::BindGroupLayout, + ) -> Option { + if !is_arena_eligible(wires) { + return None; + } + let ranges = handle_ranges(wires)?; + + let mut instances: Vec = Vec::new(); + let mut consts: Vec = Vec::new(); + let mut slabs: FxHashMap = FxHashMap::default(); + for (h, i, j) in ranges { + let inst_off = instances.len() as u32; + let const_off = consts.len() as u32; + for w in &wires[i..j] { + let wire_id = consts.len() as u32; + let dd = wire_draw_depth(w, depth_map); + let (mut insts, cst) = emit_wire_native(w, wire_id, w.color, dd); + instances.append(&mut insts); + consts.push(cst); + } + slabs.insert( + h, + Slab { + inst_off, + inst_len: instances.len() as u32 - inst_off, + const_off, + const_len: consts.len() as u32 - const_off, + }, + ); + } + + // In-place-only: allocate exactly the set's size (no append headroom). + let inst_tail = instances.len() as u32; + let inst_buf = alloc_inst(device, inst_tail.max(1)); + let const_buf = alloc_const(device, (consts.len() as u32).max(1)); + if inst_tail > 0 { + queue.write_buffer(&inst_buf, 0, bytemuck::cast_slice(&instances)); + } + queue.write_buffer(&const_buf, 0, bytemuck::cast_slice(&consts)); + let const_bind_group = build_const_bind_group(device, const_bgl, &const_buf); + + Some(Self { + inst_buf, + inst_tail, + const_buf, + const_bind_group, + slabs, + }) + } + + /// Apply the changed handles to the arena in place. Returns false (⇒ the + /// caller does a full batched rebuild) for any change that isn't an in-place + /// slab overwrite — see the module header. + pub fn patch( + &mut self, + queue: &wgpu::Queue, + changes: &[(Handle, ChangeKind)], + wires: &[WireModel], + depth_map: &FxHashMap, + ) -> bool { + if !is_arena_eligible(wires) { + return false; + } + let Some(ranges) = handle_ranges(wires) else { + return false; + }; + let range_of: FxHashMap = + ranges.into_iter().map(|(h, i, j)| (h, (i, j))).collect(); + + let inst_sz = std::mem::size_of::() as u64; + let const_sz = std::mem::size_of::() as u64; + + // Only IN-PLACE updates are safe to patch: an entity keeps its slab and + // its instance/const count (a move / rotate / scale / colour change). + // Add / Remove / any count-changed edit alter the layout AND — because + // draw_depth_map normalises each entity's z-bias by the block's entity + // count — re-scale EVERY entity's draw order, which we can't patch slab- + // by-slab. Those fall back to a full rebuild (return false), where the + // batched path re-emits everything with the current depth map. + for &(h, kind) in changes { + if matches!(kind, ChangeKind::Added | ChangeKind::Removed) { + return false; + } + let Some(&(i, j)) = range_of.get(&h) else { + return false; // Modified but now absent (hidden / not in set) + }; + let Some(&Slab { + inst_off, + inst_len, + const_off, + const_len, + }) = self.slabs.get(&h) + else { + return false; // no existing slab to overwrite + }; + let run = &wires[i..j]; + + // Emit into the entity's existing const slots (wire_id = const_off + k). + let mut insts: Vec = Vec::new(); + let mut csts: Vec = Vec::new(); + for w in run { + let wire_id = const_off + csts.len() as u32; + let dd = 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); + } + if insts.len() as u32 != inst_len || csts.len() as u32 != const_len { + return false; // segment count changed ⇒ not in place ⇒ rebuild + } + if inst_len > 0 { + queue.write_buffer( + &self.inst_buf, + inst_off as u64 * inst_sz, + bytemuck::cast_slice(&insts), + ); + } + queue.write_buffer( + &self.const_buf, + const_off as u64 * const_sz, + bytemuck::cast_slice(&csts), + ); + } + true + } + + /// One draw batch wrapping the persistent instance buffer. `instance_count` + /// is the whole tail (tombstones included — they draw nothing). + pub fn wire_gpus(&self) -> Vec { + if self.inst_tail == 0 { + return vec![]; + } + vec![WireGpu { + instance_buffer: self.inst_buf.clone(), + instance_count: self.inst_tail, + vp_scissor: None, + is_3d_mesh_edge: false, + const_bind_group: Some(self.const_bind_group.clone()), + }] + } +} diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index ad5034242..37a145704 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -383,7 +383,7 @@ fn emit_wire_instances(wire: &WireModel, color: [f32; 4], draw_depth: f32) -> Ve /// Native: emit slim per-segment instances (positions + distances + `wire_id`) /// plus the one `WireConst` record every segment of this wire shares. #[cfg(not(target_arch = "wasm32"))] -fn emit_wire_native( +pub(crate) fn emit_wire_native( wire: &WireModel, wire_id: u32, color: [f32; 4], @@ -432,7 +432,10 @@ fn emit_wire_native( /// Looks up a wire's draw-order depth from the per-entity map using the /// handle encoded in its `name`. Falls back to 0.0 (transient / preview /// wires that carry no document handle). -fn wire_draw_depth(wire: &WireModel, depth_map: &rustc_hash::FxHashMap) -> f32 { +pub(crate) fn wire_draw_depth( + wire: &WireModel, + depth_map: &rustc_hash::FxHashMap, +) -> f32 { wire .name .parse::() diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 78e449bbf..3ffcf16b5 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -105,6 +105,11 @@ pub struct ViewportData { /// when only the camera moved. Non-tile and preview/interim frames carry a /// fresh id each time → always re-upload. pub(in crate::scene) wire_content_id: u64, + /// GPU wire-arena handoff (`OCS_WIRE_GPU_PATCH`): `(prev_gen, changed)` when + /// this Model set reached `wire_content_id` by an incremental resident patch, + /// so `prepare` can patch just those entities' slabs. `None` ⇒ full build. + pub(in crate::scene) wire_patch: + Option<(u64, Arc>)>, /// Selected handles only (no hover) — solid meshes tint these blue. pub(in crate::scene) selected_handles: Arc>, /// Currently hovered handle — solid meshes tint it orange. @@ -357,6 +362,48 @@ impl shader::Primitive for Primitive { // independent of the `cur_key` block so a preview/interim wire change // still uploads even when the camera didn't move. if vp.wire_content_id != inner.cached_wire_id { + // Persistent per-entity wire arena (OCS_WIRE_GPU_PATCH): patch + // just the changed entities' instance slabs instead of rebuilding + // the whole wire buffer. Only for the scissor-free, mesh-free + // (single-batch) Model set; scissored paper viewports and mixed + // 2D/3D sets fall through to the shared batched path below. + let mut arena_served = 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, + ); + } + 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[..]); + inner.wire_arena_id = vp.wire_content_id; + arena_served = true; + } else { + inner.wire_arena_id = u64::MAX; + } + } // Share one copy of the resident wire buffers across every slot // (and every pane — one MultiPipeline backs them all) rendering // this content id: build on a cache miss, then hand out Arc @@ -364,6 +411,7 @@ impl shader::Primitive for Primitive { // Model tiles, upload the wire vertices once between them. // `.cloned()` releases the immutable cache borrow before the // miss branch takes a mutable one. + if !arena_served { let cached = pipeline.wire_buffer_cache.get(&vp.wire_content_id).cloned(); let built = match cached { Some(entry) => entry, @@ -386,6 +434,7 @@ impl shader::Primitive for Primitive { }; inner.gpu_wires = built.0; inner.wire_handle_index = built.1; + } // end !arena_served inner.cached_wire_id = vp.wire_content_id; } // Selection xray overlay — rebuilt when the selection changes or the @@ -1239,6 +1288,7 @@ impl Scene { geometry_epoch: self.geometry_epoch, camera_generation: self.camera_generation, wire_content_id, + wire_patch: self.model_wire_patch_for(wire_content_id), selected_handles: Arc::new(self.selected.iter().copied().collect()), hover_handle: self.hover_highlight, selection_generation: self.selection_generation,