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 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-07-15 06:02:25 +03:00
commit 38c8583d27
5 changed files with 398 additions and 6 deletions

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![],

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()),
}]
}
}

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>()