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:
parent
0907d2912d
commit
38c8583d27
5 changed files with 398 additions and 6 deletions
|
|
@ -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
src/scene/pipeline/wire_arena.rs
Normal file
270
src/scene/pipeline/wire_arena.rs
Normal 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()),
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
|
@ -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>()
|
||||
|
|
|
|||
Loading…
Reference in a new issue