diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 91ea7a2c9..657def8d2 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -5553,19 +5553,19 @@ impl MultiPipeline { || std::sync::Arc::strong_count(&entry.1) > 1 || std::sync::Arc::strong_count(&entry.3) > 1 }); - self.block_geometry.retain(|_, chunks| { - chunks.iter().any(|chunk| std::sync::Arc::strong_count(&chunk.bind_group) > 1) - }); + // Block records are host memory a released slot no longer needs; + // the next build re-emits what it still draws. + self.block_geometry.clear(); } released } /// Sum across slots, plus the caches held once for all of them. pub(crate) fn gpu_live_bytes(&self) -> GpuLiveBytes { - let mut total = GpuLiveBytes { - block_geometry: wire_gpu::block_geometry_bytes(&self.block_geometry), - ..Default::default() - }; + let mut total = GpuLiveBytes::default(); + // Block arenas belong to the batches, and a cached build hands the same + // arenas to several slots, so each one is counted once. + let mut counted = rustc_hash::FxHashSet::default(); for inner in &self.inners { let slot = inner.gpu_live_bytes(); total.slots += slot.slots; @@ -5573,6 +5573,15 @@ impl MultiPipeline { total.render_targets += slot.render_targets; total.text_atlas += slot.text_atlas; total.wire_arena += slot.wire_arena; + total.block_geometry += + wire_gpu::block_arena_bytes(&inner.gpu_block_wires, &mut counted) + + wire_gpu::block_arena_bytes( + &inner.gpu_selected_block_wires, + &mut counted, + ); + } + for entry in self.wire_buffer_cache.values() { + total.block_geometry += wire_gpu::block_arena_bytes(&entry.1, &mut counted); } total } @@ -5584,16 +5593,23 @@ impl MultiPipeline { /// the six-per-segment geometry in slot 0 and the placements in slot 1, while /// storage has no geometry vertex buffer at all and the placements become slot /// 0. Both draw loops go through here so they cannot drift apart. +/// Many definitions share one arena, so the batch addresses its own slice of +/// it: a dynamic offset for its constants, `first_vertex` for its segments and +/// `first_instance` for its placements. fn bind_and_draw_block_wire(pass: &mut wgpu::RenderPass<'_>, wire: &wire_gpu::BlockWireGpu) { - pass.set_bind_group(1, wire.const_bind_group.as_ref(), &[]); - match &wire.vertex_buffer { + let arena = wire.arena.as_ref(); + pass.set_bind_group(1, arena.bind_group.as_ref(), &[wire.const_offset]); + match &arena.vertex_buffer { Some(vertices) => { pass.set_vertex_buffer(0, vertices.slice(..)); - pass.set_vertex_buffer(1, wire.instance_buffer.slice(..)); + pass.set_vertex_buffer(1, arena.instance_buffer.slice(..)); } - None => pass.set_vertex_buffer(0, wire.instance_buffer.slice(..)), + None => pass.set_vertex_buffer(0, arena.instance_buffer.slice(..)), } - pass.draw(0..wire.vertex_count, 0..wire.instance_count); + pass.draw( + wire.first_vertex..wire.first_vertex + wire.vertex_count, + wire.first_instance..wire.first_instance + wire.instance_count, + ); } /// A square `Depth32Float` render target for the shadow pass. `side` is 1 for diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index 38681c578..fc92863f3 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -335,21 +335,49 @@ impl BlockWireInstance { } } -#[derive(Clone)] -pub struct BlockWireGpu { +/// One packed upload shared by many block definitions. +/// +/// A drawing of block-heavy plan content reaches thousands of definitions, and +/// a buffer per definition is what runs a device out of memory: the payloads +/// are a few hundred bytes each, but every allocation costs a page of its own — +/// on D3D12 enough of them to exhaust a 6 GB card at 12 MB of actual geometry. +/// So the definitions share one buffer set: their constants in one uniform +/// buffer addressed by dynamic offset, their segments in one geometry buffer +/// addressed by `first_vertex`, and their placements in one instance buffer +/// addressed by `first_instance`. +pub struct BlockArena { /// Packed mode only; `None` in storage mode, where the segments reach the - /// shader through `const_bind_group` instead. + /// shader through `bind_group` instead. pub vertex_buffer: Option, pub instance_buffer: wgpu::Buffer, - pub vertex_count: u32, - pub instance_count: u32, - pub is_3d_mesh_edge: bool, - pub const_bind_group: std::sync::Arc, + pub bind_group: std::sync::Arc, + /// Device bytes of the three buffers, for the live-memory report. + pub bytes: u64, } -/// Binding 0 is the definition's constants. In storage mode binding 1 carries -/// that chunk's segments, which is why the bind group is per-chunk there and -/// shared across chunks in packed mode. +#[derive(Clone)] +pub struct BlockWireGpu { + pub arena: std::sync::Arc, + /// First vertex of this definition's slice of the arena. The shader reads + /// `vertex_index / 6` as the segment index and `vertex_index % 6` as the + /// quad corner, so a slice that starts a whole number of segments in needs + /// no shader change — the arithmetic lands on the right record either way. + pub first_vertex: u32, + pub vertex_count: u32, + pub first_instance: u32, + pub instance_count: u32, + /// Byte offset of this definition's `WireConst` in the arena's uniform + /// buffer, passed as the bind group's dynamic offset. + pub const_offset: u32, + pub is_3d_mesh_edge: bool, + /// The records this batch was packed from. The buffers are rebuilt every + /// build, so this is what tells a cache hit from a re-emission. + pub geometry: std::sync::Arc, +} + +/// Binding 0 is the definition's constants, one dynamic-offset slice of the +/// arena's uniform buffer. In storage mode binding 1 carries the arena's +/// segments, which every definition in it indexes through `first_vertex`. pub fn block_const_bind_group_layout( device: &wgpu::Device, uses_storage: bool, @@ -359,8 +387,10 @@ pub fn block_const_bind_group_layout( visibility: wgpu::ShaderStages::VERTEX, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, + has_dynamic_offset: true, + min_binding_size: std::num::NonZeroU64::new( + std::mem::size_of::() as u64, + ), }, count: None, }]; @@ -865,10 +895,13 @@ pub(crate) fn wire_draw_depth( /// Cached block geometry keyed by source, edge mode, colour and base translation. /// Vertices use the first instance's world coordinates, so changing that base /// must invalidate the geometry as well as rebuild the relative placements. -pub type BlockGeometryCache = rustc_hash::FxHashMap< - BlockGeometryKey, - std::sync::Arc>, ->; +/// +/// The cache holds the records rather than device buffers: the buffers are +/// packed arenas shared by every definition of a build, so they cannot be kept +/// per definition, and re-packing them is a handful of writes while re-emitting +/// the records is the expensive half. +pub type BlockGeometryCache = + rustc_hash::FxHashMap>; /// How this device draws block wires. The layout and the mode are decided /// together when the pipeline is built and are never chosen independently, so @@ -879,51 +912,47 @@ pub struct BlockWireTarget<'a> { pub mode: WirePipelineMode, } -/// Device bytes held by a whole block-geometry cache. -pub fn block_geometry_bytes(cache: &BlockGeometryCache) -> u64 { - cache - .values() - .flat_map(|chunks| chunks.iter()) - .map(|chunk| chunk.gpu_bytes) +/// Device bytes the arenas behind these batches hold, counting each arena once +/// however many batches draw out of it. `seen` carries the arenas already +/// counted, so slots that share a cached build are not counted twice. +pub fn block_arena_bytes( + batches: &[BlockWireGpu], + seen: &mut rustc_hash::FxHashSet, +) -> u64 { + batches + .iter() + .filter(|batch| seen.insert(std::sync::Arc::as_ptr(&batch.arena) as usize)) + .map(|batch| batch.arena.bytes) .sum() } /// `(source_id, mesh_edge, colour bits, base translation bits)`. pub type BlockGeometryKey = (u64, bool, [u32; 4], [u64; 3]); -/// One drawable slice of a block definition's geometry. +/// One block definition's geometry on the host: the constants its every +/// segment shares, and one record per segment. /// -/// The two pipeline modes put the segments in different places, so the chunk -/// carries whichever the mode produced: -/// -/// * packed — `vertex_buffer` holds six copies of each segment, one per corner -/// of its quad, and `bind_group` is the definition's constants alone; -/// * storage — the segments are in a read-only storage buffer inside -/// `bind_group`, one copy each, and `vertex_buffer` is `None`. -/// -/// `vertex_count` is six per segment either way: in storage mode the shader -/// divides `vertex_index` by six to find its segment. -pub struct BlockGeometryChunk { - pub vertex_buffer: Option, - pub vertex_count: u32, - pub bind_group: std::sync::Arc, - /// Device bytes this chunk holds. Recorded at build because in storage mode - /// the buffer is owned by `bind_group` and cannot be measured from here. - pub gpu_bytes: u64, +/// Both pipeline modes pack from this. Packed mode writes six copies of each +/// record, one per corner of its quad; storage mode writes one and lets the +/// shader divide `vertex_index` by six. `vertex_count` is six per segment +/// either way. +pub struct BlockGeometryRecords { + pub constant: WireConst, + pub records: Vec, } impl BlockWireGpu { /// Identity of the geometry this batch draws, comparable across pipeline /// modes. /// - /// Packed mode keeps the segments in `vertex_buffer`; storage mode keeps - /// them inside `const_bind_group`, which is per-chunk there, and leaves - /// `vertex_buffer` `None`. Both are built on the same cache miss and - /// reused together on a hit, so the pair answers "is this the same - /// geometry as before?" in either mode — which `vertex_buffer` alone - /// cannot once it is always `None`. - pub fn geometry_id(&self) -> (Option<&wgpu::Buffer>, &wgpu::BindGroup) { - (self.vertex_buffer.as_ref(), self.const_bind_group.as_ref()) + /// Identity of the geometry this batch draws, comparable across builds. + /// + /// The arena buffers are packed fresh every build, so they say nothing + /// about whether the geometry changed. The records behind them do: a cache + /// hit hands out the same `Arc`, and anything that invalidates the key — + /// a new base instance, a colour change — emits new records. + pub fn geometry_id(&self) -> *const BlockGeometryRecords { + std::sync::Arc::as_ptr(&self.geometry) } pub fn from_wires( @@ -959,15 +988,12 @@ impl BlockWireGpu { groups[slot].1.push(wire); } - let mut out = Vec::new(); + let mut arena = BlockArenaBuilder::new(device, mode); let mut live_keys: rustc_hash::FxHashSet = rustc_hash::FxHashSet::default(); - // Geometry bytes actually sent to the device this call, so the storage - // path's saving is visible rather than assumed. - let mut uploaded_bytes = 0usize; - let mut uploaded_segments = 0usize; - // Definitions whose upload the device rejected, so they were not cached. - let mut poisoned = 0usize; + // Records emitted this call, so a cache that is earning its keep is + // visible rather than assumed. + let mut emitted_segments = 0usize; for (mesh_edge, group) in groups { let Some(&source) = group.first() else { continue; @@ -983,162 +1009,293 @@ impl BlockWireGpu { base.translation.map(f64::to_bits), ); live_keys.insert(cache_key); - // Reuse this definition's geometry when it is already on the GPU. - if let Some(chunks) = cache + // Reuse this definition's records when they are already emitted. + let geometry = match cache .as_deref() .and_then(|c| c.get(&cache_key)) .map(std::sync::Arc::clone) { - let instances = block_instances(&group, base, mesh_edge, depth_map); - push_block_batches(&mut out, device, queue, &chunks, &instances, mesh_edge); - continue; - } - // A failed allocation still produces a `Buffer`; it is simply - // invalid, and every later use of it reports again. Cached, it - // freezes the viewport for the rest of the session because the key - // keeps hitting and nothing ever rebuilds. Watch the device's error - // count across the build and refuse to cache what it condemns. - let errors_before = super::gpu_errors_seen(); - let (segments, mut constant) = emit_wire_native(source, 0, color, 0.0); - if segments.is_empty() { - continue; - } - constant.draw_depth = 0.0; - let records: Vec = segments - .iter() - .map(|segment| BlockWireVertex { - pos_a: segment.pos_a, - pos_a_low: segment.pos_a_low, - pos_b: segment.pos_b, - pos_b_low: segment.pos_b_low, - distances: [segment.distance_a, segment.distance_b], - taper_ratio: segment.taper_ratio, - }) - .collect(); - // Queue uploads avoid mapping a failed allocation. - let const_buffer = super::gpu_upload::upload_buffer( - device, - queue, - "block_wire.const", - std::slice::from_ref(&constant), - wgpu::BufferUsages::UNIFORM, - ); - // Bound uploads so a block-heavy drawing cannot ask a low-VRAM - // device for one multi-hundred-MB buffer. - let chunks: Vec = if mode.uses_storage() { - // One record per segment. A storage chunk is bounded by the - // binding size as well as the buffer budget, and needs no - // grouping: the shader indexes segments directly. - let max_records = - super::gpu_budget::max_storage_elements::(device); - records - .chunks(max_records) - .map(|chunk| { - let segments = super::gpu_upload::upload_buffer( - device, - queue, - "block_wire.segments", - chunk, - wgpu::BufferUsages::STORAGE, - ); - BlockGeometryChunk { - vertex_buffer: None, - vertex_count: chunk.len() as u32 * 6, - gpu_bytes: std::mem::size_of_val(chunk) as u64, - bind_group: std::sync::Arc::new(device.create_bind_group( - &wgpu::BindGroupDescriptor { - label: Some("block_wire.const.bg"), - layout: const_bgl, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: const_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: segments.as_entire_binding(), - }, - ], - }, - )), - } - }) - .collect() - } else { - // Six copies per segment, one per corner of its quad, without - // splitting a segment's six vertices across two buffers. - let mut vertices = Vec::with_capacity(records.len() * 6); - for record in &records { - vertices.extend_from_slice(&[*record; 6]); + Some(hit) => hit, + None => { + let (segments, mut constant) = emit_wire_native(source, 0, color, 0.0); + if segments.is_empty() { + continue; + } + constant.draw_depth = 0.0; + let records: Vec = segments + .iter() + .map(|segment| BlockWireVertex { + pos_a: segment.pos_a, + pos_a_low: segment.pos_a_low, + pos_b: segment.pos_b, + pos_b_low: segment.pos_b_low, + distances: [segment.distance_a, segment.distance_b], + taper_ratio: segment.taper_ratio, + }) + .collect(); + emitted_segments += records.len(); + let built = + std::sync::Arc::new(BlockGeometryRecords { constant, records }); + // Records are device-independent, so a build the device + // rejected poisons nothing: the next one packs these same + // records into fresh buffers instead of caching a buffer + // the device condemned under a key that keeps hitting it. + if let Some(cache) = cache.as_deref_mut() { + cache.insert(cache_key, std::sync::Arc::clone(&built)); + } + built } - let max_verts = - super::gpu_budget::max_elements_grouped::(device, 6); - let shared = std::sync::Arc::new(device.create_bind_group( - &wgpu::BindGroupDescriptor { - label: Some("block_wire.const.bg"), - layout: const_bgl, - entries: &[wgpu::BindGroupEntry { - binding: 0, - resource: const_buffer.as_entire_binding(), - }], - }, - )); - vertices - .chunks(max_verts) - .map(|chunk| BlockGeometryChunk { - gpu_bytes: std::mem::size_of_val(chunk) as u64, - vertex_buffer: Some(super::gpu_upload::upload_buffer( - device, - queue, - "block_wire.vertices", - chunk, - wgpu::BufferUsages::VERTEX, - )), - vertex_count: chunk.len() as u32, - bind_group: std::sync::Arc::clone(&shared), - }) - .collect() }; - uploaded_segments += records.len(); - uploaded_bytes += records.len() - * std::mem::size_of::() - * if mode.uses_storage() { 1 } else { 6 }; - let chunks = std::sync::Arc::new(chunks); - let built_clean = super::gpu_errors_seen() == errors_before; - if built_clean { - if let Some(cache) = cache.as_deref_mut() { - cache.insert(cache_key, std::sync::Arc::clone(&chunks)); - } - } else { - // Draw this frame with what we have — a degraded frame beats a - // blank one — but leave the cache clean so the next frame tries - // again. `live_keys` still holds the key, so the retain below - // does not evict a good entry from an earlier frame. - poisoned += 1; - } let instances = block_instances(&group, base, mesh_edge, depth_map); - push_block_batches(&mut out, device, queue, &chunks, &instances, mesh_edge); + if instances.is_empty() { + continue; + } + arena.push(&geometry, &instances, mesh_edge); } // Release geometry that this build no longer uses. if let Some(cache) = cache { cache.retain(|key, _| live_keys.contains(key)); } - if crate::perf::enabled() && uploaded_segments > 0 { + let out = arena.finish(device, queue, const_bgl); + if crate::perf::enabled() && arena.segments > 0 { crate::perf_record!( - "[perf] block-wire-geometry mode={} definitions={} segments={} bytes={} \ -bytes_if_packed={} poisoned={}", + "[perf] block-wire-geometry mode={} definitions={} arenas={} batches={} \ +segments={} bytes={} bytes_if_per_definition={} emitted={}", if mode.uses_storage() { "storage" } else { "packed" }, live_keys.len(), - uploaded_segments, - uploaded_bytes, - uploaded_segments * std::mem::size_of::() * 6, - poisoned, + arena.arenas, + out.len(), + arena.segments, + arena.bytes, + BlockArenaBuilder::per_definition_bytes(out.len()), + emitted_segments, ); } out } } +/// Packs the definitions of one build into shared buffers, starting another +/// arena whenever constants, geometry or placements would outgrow the +/// per-buffer budget. +struct BlockArenaBuilder { + mode: WirePipelineMode, + /// Vertices written per segment: one in storage mode, six in packed mode. + per_segment: usize, + max_verts: usize, + max_consts: usize, + max_instances: usize, + const_stride: usize, + chunks: Vec, + /// Totals for the perf line. + arenas: usize, + segments: usize, + bytes: u64, +} + +#[derive(Default)] +struct BlockArenaChunk { + verts: Vec, + consts: Vec, + instances: Vec, + batches: Vec, +} + +/// One definition's slice of an arena, waiting for the arena's buffers. +struct PendingBlockBatch { + first_vertex: u32, + vertex_count: u32, + first_instance: u32, + instance_count: u32, + const_offset: u32, + is_3d_mesh_edge: bool, + geometry: std::sync::Arc, +} + +impl BlockArenaBuilder { + fn new(device: &wgpu::Device, mode: WirePipelineMode) -> Self { + let storage = mode.uses_storage(); + // A uniform binding addressed by dynamic offset must start on the + // device's alignment, so the constants are written on that stride. + let align = (device.limits().min_uniform_buffer_offset_alignment as usize).max(1); + let const_stride = std::mem::size_of::().max(1).div_ceil(align) * align; + Self { + mode, + per_segment: if storage { 1 } else { 6 }, + max_verts: if storage { + super::gpu_budget::max_storage_elements::(device) + } else { + super::gpu_budget::max_elements_grouped::(device, 6) + }, + max_consts: (super::gpu_budget::buffer_budget(device) / const_stride).max(1), + max_instances: super::gpu_budget::max_elements::(device), + const_stride, + chunks: Vec::new(), + arenas: 0, + segments: 0, + bytes: 0, + } + } + + /// Append one definition and its placements. + fn push( + &mut self, + geometry: &std::sync::Arc, + instances: &[BlockWireInstance], + mesh_edge: bool, + ) { + self.segments += geometry.records.len(); + // A definition's geometry, constants and placements must land in the + // same arena — one bind group and one instance buffer draw them — so a + // definition too large for one arena is split into pieces that each + // carry their own copy of the constants. + for run in instances.chunks(self.max_instances) { + let mut placed = 0usize; + while placed < geometry.records.len() { + let index = self.chunk_with_room(run.len()); + let chunk = &mut self.chunks[index]; + let room = (self.max_verts - chunk.verts.len()) / self.per_segment; + let take = room.min(geometry.records.len() - placed); + // Segments already in this arena, times the six vertices the + // shader spends on each: storage mode divides `vertex_index` by + // six to index the record, packed mode finds the six copies. + let first_vertex = (chunk.verts.len() / self.per_segment) as u32 * 6; + let records = &geometry.records[placed..placed + take]; + if self.per_segment == 1 { + chunk.verts.extend_from_slice(records); + } else { + for record in records { + chunk.verts.extend_from_slice(&[*record; 6]); + } + } + let const_offset = (chunk.consts.len() * self.const_stride) as u32; + chunk.consts.push(geometry.constant); + let first_instance = chunk.instances.len() as u32; + chunk.instances.extend_from_slice(run); + chunk.batches.push(PendingBlockBatch { + first_vertex, + vertex_count: take as u32 * 6, + first_instance, + instance_count: run.len() as u32, + const_offset, + is_3d_mesh_edge: mesh_edge, + geometry: std::sync::Arc::clone(geometry), + }); + placed += take; + } + } + } + + /// The open arena when it has room for one more segment, one more set of + /// constants and `instances` placements; a fresh one otherwise. `instances` + /// never exceeds one arena's budget, so a fresh arena always has room. + fn chunk_with_room(&mut self, instances: usize) -> usize { + let fits = self.chunks.last().is_some_and(|chunk| { + self.max_verts - chunk.verts.len() >= self.per_segment + && chunk.consts.len() < self.max_consts + && self.max_instances - chunk.instances.len() >= instances + }); + if !fits { + self.chunks.push(BlockArenaChunk::default()); + } + self.chunks.len() - 1 + } + + /// Upload each arena and turn its pending slices into draw batches. + fn finish( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + const_bgl: &wgpu::BindGroupLayout, + ) -> Vec { + let storage = self.mode.uses_storage(); + let mut out = Vec::new(); + for chunk in std::mem::take(&mut self.chunks) { + let mut const_bytes = vec![0u8; chunk.consts.len() * self.const_stride]; + for (slot, cst) in chunk.consts.iter().enumerate() { + let at = slot * self.const_stride; + const_bytes[at..at + std::mem::size_of::()] + .copy_from_slice(bytemuck::bytes_of(cst)); + } + let consts = super::gpu_upload::upload_bytes( + device, + queue, + "block_wire.consts", + &const_bytes, + std::mem::size_of::(), + wgpu::BufferUsages::UNIFORM, + ); + let geometry = super::gpu_upload::upload_buffer( + device, + queue, + if storage { + "block_wire.segments" + } else { + "block_wire.vertices" + }, + &chunk.verts, + if storage { + wgpu::BufferUsages::STORAGE + } else { + wgpu::BufferUsages::VERTEX + }, + ); + let instance_buffer = + instance_buffer(device, queue, "block_wire.instances", &chunk.instances); + let mut entries = vec![wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &consts, + offset: 0, + size: std::num::NonZeroU64::new(std::mem::size_of::() as u64), + }), + }]; + if storage { + entries.push(wgpu::BindGroupEntry { + binding: 1, + resource: geometry.as_entire_binding(), + }); + } + let bind_group = + std::sync::Arc::new(device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("block_wire.const.bg"), + layout: const_bgl, + entries: &entries, + })); + let bytes = consts.size() + geometry.size() + instance_buffer.size(); + self.arenas += 1; + self.bytes += bytes; + // In storage mode the bind group owns the segments; in packed mode + // the draw binds them as vertex buffer 0. + let arena = std::sync::Arc::new(BlockArena { + vertex_buffer: (!storage).then(|| geometry.clone()), + instance_buffer, + bind_group, + bytes, + }); + out.extend(chunk.batches.into_iter().map(|pending| BlockWireGpu { + arena: std::sync::Arc::clone(&arena), + first_vertex: pending.first_vertex, + vertex_count: pending.vertex_count, + first_instance: pending.first_instance, + instance_count: pending.instance_count, + const_offset: pending.const_offset, + is_3d_mesh_edge: pending.is_3d_mesh_edge, + geometry: pending.geometry, + })); + } + out + } + + /// What a buffer per definition would have cost: payloads of a few hundred + /// bytes, but a device page each, which is the footprint that ran a 6 GB + /// card out of memory on a drawing holding 12 MB of block geometry. + fn per_definition_bytes(definitions: usize) -> u64 { + const PAGE: u64 = 64 * 1024; + definitions as u64 * 2 * PAGE + } +} + /// Placements and draw depths relative to the group's first instance. fn block_instances( group: &[&WireModel], @@ -1176,40 +1333,6 @@ fn block_instances( .collect() } -/// One batch per (geometry chunk × instance chunk). Instance buffers are built -/// once and shared across geometry chunks rather than re-uploaded per chunk. -fn push_block_batches( - out: &mut Vec, - device: &wgpu::Device, - queue: &wgpu::Queue, - chunks: &[BlockGeometryChunk], - instances: &[BlockWireInstance], - mesh_edge: bool, -) { - let max_instances = super::gpu_budget::max_elements::(device); - let instance_chunks: Vec<(wgpu::Buffer, u32)> = instances - .chunks(max_instances) - .map(|chunk| { - ( - instance_buffer(device, queue, "block_wire.instances", chunk), - chunk.len() as u32, - ) - }) - .collect(); - for chunk in chunks { - for (instance_buffer, instance_count) in &instance_chunks { - out.push(BlockWireGpu { - vertex_buffer: chunk.vertex_buffer.clone(), - instance_buffer: instance_buffer.clone(), - vertex_count: chunk.vertex_count, - instance_count: *instance_count, - is_3d_mesh_edge: mesh_edge, - const_bind_group: std::sync::Arc::clone(&chunk.bind_group), - }); - } - } -} - /// Upload one constant chunk; its instances use chunk-local wire IDs. fn build_const_bind_group( device: &wgpu::Device,