perf(render): in-place GPU wire arena for move/edit (phase 4, OCS_WIRE_GPU_PATCH)

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 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-15 06:02:25 +03:00
1 parent 0907d2912d
commit 38c8583d27
5 files changed
+398 -6

No files matched your search

+55 -3
View File
@@ -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<bool> = 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<u64>,
/// 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<Option<(u64, u64, Arc<Vec<(Handle, ChangeKind)>>)>>,
}
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<Vec<(Handle, ChangeKind)>>)> {
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<WireModel> = {
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);
+18 -1
View File
@@ -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<wgpu::BindGroupLayout>,
pub(crate) wire_const_bgl: Option<wgpu::BindGroupLayout>,
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<Vec<WireGpu>>,
/// 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<wire_arena::WireArena>,
/// 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<Option<[u32; 4]>>,
/// 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![],
+270
View File
@@ -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<wgpu::BindGroup>,
slabs: FxHashMap<Handle, Slab>,
}
fn handle_of(w: &WireModel) -> Option<Handle> {
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<FxHashMap<u64, Vec<u32>>> {
let mut index: FxHashMap<u64, Vec<u32>> = FxHashMap::default();
index.reserve(wires.len());
for (idx, w) in wires.iter().enumerate() {
if let Ok(h) = w.name.parse::<u64>() {
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<Vec<(Handle, usize, usize)>> {
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<wgpu::BindGroup> {
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::<WireInstance>() 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::<WireConst>() 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<u64, f32>,
const_bgl: &wgpu::BindGroupLayout,
) -> Option<Self> {
if !is_arena_eligible(wires) {
return None;
}
let ranges = handle_ranges(wires)?;
let mut instances: Vec<WireInstance> = Vec::new();
let mut consts: Vec<WireConst> = Vec::new();
let mut slabs: FxHashMap<Handle, Slab> = 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<u64, f32>,
) -> bool {
if !is_arena_eligible(wires) {
return false;
}
let Some(ranges) = handle_ranges(wires) else {
return false;
};
let range_of: FxHashMap<Handle, (usize, usize)> =
ranges.into_iter().map(|(h, i, j)| (h, (i, j))).collect();
let inst_sz = std::mem::size_of::<WireInstance>() as u64;
let const_sz = std::mem::size_of::<WireConst>() 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<WireInstance> = Vec::new();
let mut csts: Vec<WireConst> = 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<WireGpu> {
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()),
}]
}
}
+5 -2
View File
@@ -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<u64, f32>) -> f32 {
pub(crate) fn wire_draw_depth(
wire: &WireModel,
depth_map: &rustc_hash::FxHashMap<u64, f32>,
) -> f32 {
wire
.name
.parse::<u64>()
+50
View File
@@ -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<Vec<(acadrust::Handle, crate::scene::ChangeKind)>>)>,
/// Selected handles only (no hover) — solid meshes tint these blue.
pub(in crate::scene) selected_handles: Arc<rustc_hash::FxHashSet<acadrust::Handle>>,
/// 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,