From 771a5b2c5c456066b22997f2af877d5c7f679951 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:14:09 +0300 Subject: [PATCH] fix: one VRAM-aware buffer budget for every GPU upload path max_buffer_size is an API validation limit, not physical VRAM. NVIDIA reports several GB of it on a 2 GB card, so sizing a chunk from it produces buffers larger than the whole device. That is the same bug as #203 (mesh_gpu), #358 (face3d_gpu) and the block-wire vertex buffer: rediscovered three times because each upload path spelled the budget out for itself. mesh_gpu.rs was the only site that clamped the result. Move that clamp into src/scene/pipeline/gpu_budget.rs and route every allocating path through it, so a new upload path cannot reintroduce the mistake by copying one of the unclamped expressions. Five sites had no clamp at all: text_gpu (block-text instances), wire_gpu (block-wire instances), and face3d_gpu (vertices, instances, and upload_chunks). Three behaviour changes worth noting: * Silhouette chunks were capped at a private 64 MiB constant and now use the shared 32 MiB budget. * The storage-hatch chunk limit was the minimum of two device limits, both of them validation figures; it is now also capped by the budget. The `*_fits` predicates next to it are genuine "would wgpu reject this" checks and are left alone. * mesh_gpu's instanced path used the unclamped ceiling to decide whether a mesh fits in a single buffer, which would still admit a multi-GB allocation. It now uses the clamped budget, so a few more meshes take the chunked path. OCS_GPU_CHUNK_MIB overrides the cap. Raising it above the real VRAM is the cheapest way to force an allocation failure and check that the renderer degrades instead of aborting. Co-Authored-By: Claude Opus 5 --- src/scene/pipeline/face3d_gpu.rs | 12 ++--- src/scene/pipeline/gpu_budget.rs | 62 +++++++++++++++++++++++++ src/scene/pipeline/hatch_gpu/storage.rs | 6 ++- src/scene/pipeline/mesh_gpu.rs | 14 +++--- src/scene/pipeline/mod.rs | 7 +-- src/scene/pipeline/text_gpu.rs | 5 +- src/scene/pipeline/wire_gpu.rs | 13 ++---- 7 files changed, 84 insertions(+), 35 deletions(-) create mode 100644 src/scene/pipeline/gpu_budget.rs diff --git a/src/scene/pipeline/face3d_gpu.rs b/src/scene/pipeline/face3d_gpu.rs index 929d83d73..15e58d1db 100644 --- a/src/scene/pipeline/face3d_gpu.rs +++ b/src/scene/pipeline/face3d_gpu.rs @@ -376,11 +376,9 @@ fn upload_block_chunks( if vertices.is_empty() { continue; } - let budget = (device.limits().max_buffer_size as usize / 10) * 9; - let max_vertices = ((budget / std::mem::size_of::()).max(3) / 3) * 3; - let max_instances = ((device.limits().max_buffer_size as usize / 10) * 9 - / std::mem::size_of::()) - .max(1); + // Triangle lists: chunks must stay a multiple of 3. + let max_vertices = super::gpu_budget::max_elements_grouped::(device, 3); + let max_instances = super::gpu_budget::max_elements::(device); let instances: Vec = group .iter() .filter_map(|wire| { @@ -459,10 +457,8 @@ fn upload_chunks( verts: &[Face3DVertex], label: &'static str, ) -> Vec { - let budget = (device.limits().max_buffer_size as usize / 10) * 9; // 10% headroom - let vsize = std::mem::size_of::(); // Round down to a multiple of 3 so triangles never straddle chunks. - let max_verts = ((budget / vsize).max(3) / 3) * 3; + let max_verts = super::gpu_budget::max_elements_grouped::(device, 3); verts .chunks(max_verts) .map(|c| Face3DChunk { diff --git a/src/scene/pipeline/gpu_budget.rs b/src/scene/pipeline/gpu_budget.rs new file mode 100644 index 000000000..0ea3c750f --- /dev/null +++ b/src/scene/pipeline/gpu_budget.rs @@ -0,0 +1,62 @@ +//! One VRAM-aware budget for every GPU buffer this renderer allocates. +//! +//! `wgpu::Limits::max_buffer_size` is an **API validation limit**, not a +//! statement about physical memory: NVIDIA reports several GB of it on a 2 GB +//! card. Sizing a chunk from that limit therefore produces buffers larger than +//! the whole device, the allocation fails, and the frame dies — issue #203 +//! (`mesh_gpu`), #358 (`face3d_gpu`) and the block-wire path were all the same +//! bug rediscovered. +//! +//! `mesh_gpu.rs` was the only site that clamped the result. This module makes +//! that clamp the single shared rule so a new upload path cannot reintroduce +//! the mistake by copying one of the unclamped expressions. + +use iced::wgpu; + +/// Upper bound on any single buffer, regardless of what the device claims it +/// would validate. Chosen to match the clamp `mesh_gpu` has used since #203: +/// large enough that chunk overhead stays negligible, small enough that a +/// failed allocation is recoverable on a low-VRAM card. +const HARD_CAP_BYTES: usize = 32 * 1024 * 1024; + +/// 10% headroom below the device's validation limit, matching the `/ 10 * 9` +/// the individual sites used before they shared this helper. +fn device_ceiling(device: &wgpu::Device) -> usize { + (device.limits().max_buffer_size as usize / 10) * 9 +} + +/// Test/debug override, in MiB. Raising it above the real VRAM is the +/// cheapest way to force an out-of-memory path and check that the renderer +/// degrades the frame instead of aborting. +fn cap_bytes() -> usize { + static CAP: std::sync::OnceLock = std::sync::OnceLock::new(); + *CAP.get_or_init(|| { + std::env::var("OCS_GPU_CHUNK_MIB") + .ok() + .and_then(|value| value.trim().parse::().ok()) + .filter(|mib| *mib > 0) + .map(|mib| mib * 1024 * 1024) + .unwrap_or(HARD_CAP_BYTES) + }) +} + +/// Largest byte size a single buffer built by this renderer may take. +pub fn buffer_budget(device: &wgpu::Device) -> usize { + device_ceiling(device).min(cap_bytes()) +} + +/// How many `T` fit in one buffer. Never returns 0, so callers can pass the +/// result straight to `slice::chunks`, which panics on a zero chunk size. +pub fn max_elements(device: &wgpu::Device) -> usize { + (buffer_budget(device) / std::mem::size_of::().max(1)).max(1) +} + +/// `max_elements` rounded down to a whole number of `group` elements, for +/// vertex data whose primitives must not straddle a chunk boundary: 3 for a +/// triangle list, 6 for the quad expansion the wire shaders use. +/// +/// Always returns at least one whole group. +pub fn max_elements_grouped(device: &wgpu::Device, group: usize) -> usize { + let group = group.max(1); + (max_elements::(device) / group).max(1) * group +} diff --git a/src/scene/pipeline/hatch_gpu/storage.rs b/src/scene/pipeline/hatch_gpu/storage.rs index 950a98929..e9ec6c4fc 100644 --- a/src/scene/pipeline/hatch_gpu/storage.rs +++ b/src/scene/pipeline/hatch_gpu/storage.rs @@ -196,9 +196,13 @@ impl StorageHatchBatch { } let limits = device.limits(); + // Both device figures are API validation limits, not physical VRAM, so + // the shared budget caps them (`storage.rs` chunks are allocations, not + // just size checks — the `*_fits` predicates below are the checks). let chunk_limit = limits .max_buffer_size - .min(limits.max_storage_buffer_binding_size as u64); + .min(limits.max_storage_buffer_binding_size as u64) + .min(crate::scene::pipeline::gpu_budget::buffer_budget(device) as u64); let mut ranges = Vec::new(); let mut start = 0; let mut used = [0u64; 7]; diff --git a/src/scene/pipeline/mesh_gpu.rs b/src/scene/pipeline/mesh_gpu.rs index f1c55b1ea..36bfa856c 100644 --- a/src/scene/pipeline/mesh_gpu.rs +++ b/src/scene/pipeline/mesh_gpu.rs @@ -1580,10 +1580,8 @@ pub fn build_mesh_batch_filtered( // Derive the caps from the real device limit and vertex size. The previous // fixed 6 M-vertex cap assumed 40 B/vertex, but `position_low` (RTE) grew // MeshVertex to 52 B, so 6 M × 52 B = 312 MB blew past the 256 MB cap. - let hard_budget = (device.limits().max_buffer_size as usize / 10) * 9; - let budget = hard_budget.min(32 * 1024 * 1024); - let vsize = std::mem::size_of::(); - let max_verts = (budget / vsize).max(3); + let budget = super::gpu_budget::buffer_budget(device); + let max_verts = super::gpu_budget::max_elements::(device).max(3); // A triangle contributes three line segments (six standalone edge // vertices). Bound chunks by the largest buffer produced for wire meshes. let max_tris = @@ -1800,9 +1798,9 @@ pub fn build_mesh_batch_filtered( .verts .len() .saturating_mul(std::mem::size_of::()) - <= hard_budget + <= budget && part.indices.len().saturating_mul(std::mem::size_of::()) - <= hard_budget + <= budget && part .set .instance_source @@ -1812,7 +1810,7 @@ pub fn build_mesh_batch_filtered( .edge_verts .len() .saturating_mul(std::mem::size_of::()) - <= hard_budget; + <= budget; let triangle_edges_fit = !part.include_edges || !source.edge_verts.is_empty() || part @@ -1820,7 +1818,7 @@ pub fn build_mesh_batch_filtered( .len() .saturating_mul(2) .saturating_mul(std::mem::size_of::()) - <= hard_budget; + <= budget; feature_edges_fit && triangle_edges_fit }) && part diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index bf04a427e..595876a13 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -1,5 +1,6 @@ mod device_capabilities; pub mod face3d_gpu; +pub mod gpu_budget; pub mod hatch_gpu; pub mod wipeout_gpu; pub mod image_gpu; @@ -2728,11 +2729,7 @@ impl Pipeline { .normalize_or(glam::DVec3::NEG_Z); use crate::scene::pipeline::mesh_gpu::{SilhouetteInstance, SilhouetteVertex}; use wgpu::util::DeviceExt; - const SILHOUETTE_CHUNK_BYTES: u64 = 64 * 1024 * 1024; - let chunk_bytes = device - .limits() - .max_buffer_size - .min(SILHOUETTE_CHUNK_BYTES) as usize; + let chunk_bytes = gpu_budget::buffer_budget(device); let max_vertices = chunk_bytes / std::mem::size_of::() / 2 * 2; if max_vertices < 2 { self.silhouette_chunks.clear(); diff --git a/src/scene/pipeline/text_gpu.rs b/src/scene/pipeline/text_gpu.rs index 14d2b3b53..2519fbe0a 100644 --- a/src/scene/pipeline/text_gpu.rs +++ b/src/scene/pipeline/text_gpu.rs @@ -541,9 +541,8 @@ pub fn upload_block_vertex_refs( }) }) .collect(); - let max_instances = ((device.limits().max_buffer_size as usize / 10) * 9 - / std::mem::size_of::()) - .max(1); + let max_instances = + super::gpu_budget::max_elements::(device); for chunk in instances.chunks(max_instances) { let instance_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("block_text.instances"), diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index 458bfe7c4..e5db97efc 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -745,15 +745,10 @@ impl BlockWireGpu { // `mesh_gpu.rs` (#203) and the fill chunking in `face3d_gpu.rs` // (#358); the block-wire path was never converted, so a // block-heavy drawing produced one multi-hundred-MB buffer. - // `max_buffer_size` is an API validation limit, not physical VRAM - // (NVIDIA reports several GB on a 2 GB card), so clamp it the way - // `mesh_gpu.rs` already does. - let vertex_budget = ((device.limits().max_buffer_size as usize / 10) * 9) - .min(32 * 1024 * 1024); - let vsize = std::mem::size_of::(); // Round down to a multiple of 6: each segment expands to six // vertices and must not straddle two chunks. - let max_verts = ((vertex_budget / vsize).max(6) / 6) * 6; + let max_verts = + super::gpu_budget::max_elements_grouped::(device, 6); let vertex_chunks: Vec<(wgpu::Buffer, u32)> = vertices .chunks(max_verts) .map(|chunk| { @@ -810,9 +805,7 @@ impl BlockWireGpu { }) }) .collect(); - let max_instances = ((device.limits().max_buffer_size as usize / 10) * 9 - / std::mem::size_of::()) - .max(1); + let max_instances = super::gpu_budget::max_elements::(device); // Build each instance buffer once and reuse it across vertex // chunks, rather than re-uploading it per chunk. let instance_chunks: Vec<(wgpu::Buffer, u32)> = instances