wgpu: probed-size pool replaces per-chunk createBuffer

Drops the per-machine "guess the OOM ceiling" budget knob in favour of
a single buffer pool whose capacity is *probed* at device-init time.
The runtime answers the question: descend from min(maxBufferSize, 4 GB)
through OOM error scopes, accept the largest size that allocates
cleanly. On a desktop wgpu-native v29 box this lands at 2 GB; on
browser-class platforms it'll land at 256 MB – 1 GB depending on the
implementation. Same code path either way.

Architecture:
- WgpuBufferPool (new): single WGPUBuffer + free-list sub-allocator
  with adjacent-range coalescing and first-fit. 256 B alignment for
  storage-binding offsets.
- Chunks now hold (pool_vertex_offset, pool_vertex_size) and
  (pool_index_offset, pool_index_size) instead of per-chunk WGPUBuffer
  handles. Load = pool.alloc + queueWriteBuffer. Unload = pool.free.
- Bind groups bind pool_.buffer() at the chunk's specific (offset, size)
  for both the vertex and index storage bindings.
- Eviction queries pool.largest_free_run_bytes() instead of a tracked
  budget; the two-phase LRU/distance evictor's policy is unchanged.

What this fixes:
- No more gpu-alloc-rs fragmentation OOM: one VkDeviceMemory block
  instead of N per-chunk blocks with rounding overhead. On the test
  dataset (~3 GB on disk, 562 k visible instances) the wgpu backend
  now runs through to render without OOM at any point.
- No --streaming-vram-mb knob, no hardcoded budget constant, no
  per-machine calibration. The pool size adapts to whatever the
  runtime grants.

Notes:
- Error scope probing: wgpu-native v29 classifies "Not enough memory
  left" as WGPUErrorType_Validation, not OutOfMemory. We push both
  filters (nested) and treat either firing as probe failure.
- The 4 GB probe cap is principled, not magic: above that, wgpu-native's
  advertised maxBufferSize is sometimes a sentinel (1 TB) that just
  forces wasteful halving steps. 4 GB is the largest buffer any
  realistic WebGPU implementation will grant a single allocation today.
- Pool destroy()/release happens after model release in shutdown() so
  the underlying buffer outlives every bind group that references it.

Follow-ups: spatial chunking (task #22) for finer eviction granularity;
cull perf needs work at 100+ models / 1M+ instances (separate from
streaming concerns).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-05-28 12:21:52 +10:00
parent 71e61dd8a5
commit 502c29fbc2
6 changed files with 503 additions and 148 deletions
+31 -33
View File
@@ -33,6 +33,7 @@
#include <unordered_map>
#include "SidecarCache.h"
#include "WgpuBufferPool.h"
#include "WgpuModelGpuData.h"
#include "WgpuSelectionState.h"
#include "WgpuVisibilityState.h"
@@ -72,10 +73,10 @@ public:
// Streaming variant: takes a StreamingSidecar (metadata only — no
// vertex / index bytes). Allocates per-chunk small buffers and the
// model-shared index / mesh / instance storage upfront, but leaves
// each chunk's vertex_storage null and is_resident=false. The per-
// frame loader (commit 4 of streaming) brings chunks resident on
// demand as cull flags them visible.
// model-shared mesh / instance storage upfront, but leaves each
// chunk's pool ranges unclaimed and is_resident=false. The per-frame
// loader (driveStreamingLoads) sub-allocates the chunk's vertex +
// index ranges from pool_ on demand as cull flags them visible.
void applyCachedModelStreaming(uint32_t model_id,
struct StreamingSidecar metadata);
@@ -129,20 +130,30 @@ private:
bool buildPipelines();
void buildModelBindGroup(WgpuModelGpuData& m);
void buildChunkBindGroup(WgpuModelGpuData& m, size_t chunk_idx);
// Streaming: read the chunk's vertex bytes from disk, allocate
// vertex_storage, build the chunk's bind group, flip is_resident=true.
// Returns true on success. No-op (returns true) when already resident.
// Streaming: read the chunk's vertex + index bytes from disk,
// sub-allocate ranges in pool_, queueWriteBuffer them in, build the
// chunk's bind group, flip is_resident=true. Returns true on success;
// false if either the disk read or a pool alloc fails (caller is
// expected to have already evicted enough). No-op (returns true)
// when already resident.
bool loadChunkBytesAndUploadGpu(WgpuModelGpuData& m, size_t chunk_idx);
// Release a resident chunk's vertex+index buffers and bind group;
// flip is_resident=false. The chunk's CPU metadata (offsets, AABB,
// Release a resident chunk's pool ranges + bind group; flip
// is_resident=false. The chunk's CPU metadata (offsets, AABB,
// visible-draw scratch) is retained so a subsequent
// loadChunkBytesAndUploadGpu can bring it back without re-planning.
void unloadChunk(WgpuModelGpuData& m, size_t chunk_idx);
// Called from render() after cull: find non-resident chunks with
// current visible draw counts > 0 and bring them resident, up to a
// per-frame budget. Triggers requestUpdate() if more remain. Evicts
// LRU non-visible chunks first when over streaming_vram_budget_bytes_.
// current visible draw counts > 0 and bring them resident. When the
// pool is full, evicts LRU non-visible chunks first, then falls back
// to evicting the farthest-from-camera visible chunks if a closer
// candidate needs the space. Triggers requestUpdate() if more remain.
void driveStreamingLoads();
// Discover the largest single buffer wgpu will give us by descending
// from the device's limits.maxBufferSize through OOM error scopes.
// Allocates pool_ at the discovered size. Returns false only when
// even a tiny pool can't be created (i.e. the device is unusable).
bool probeAndCreatePool();
void ensureDepthTexture(int w, int h);
void releaseDepthTexture();
void ensureMsaaColorTexture(int w, int h);
@@ -399,31 +410,18 @@ public:
// preserved; --streaming opts in.
bool streaming_enabled_ = false;
// Streaming residency budget (bytes). The per-frame loader will not
// bring a chunk resident if doing so would exceed this; instead it
// evicts the LRU non-visible chunks until headroom exists, then
// falls back to evicting the farthest-from-camera visible chunks
// when even visible-set residency would overshoot.
//
// STOPGAP: this is a hand-picked number — the wrong shape of fix.
// wgpu-native's per-allocation overhead (gpu-alloc-rs fragmentation +
// one VkDeviceMemory block per createBuffer) makes the true usable
// ceiling far below physical VRAM, by a margin that varies per
// GPU/driver/runtime. The proper fix is a single-pool buffer with
// sub-allocation (see WgpuBufferPool task), whose size is *probed*
// at startup via wgpuDevicePushErrorScope rather than guessed at
// compile time. Once the pool lands, this field disappears.
//
// For now: 1 GB is a portable-ish floor that won't OOM on any
// desktop GPU we care about, and is at least close to the web's
// common ceiling. Tune via --streaming-vram-mb on machines with
// more headroom.
uint64_t streaming_vram_budget_bytes_ = 1024ull * 1024 * 1024;
uint64_t streaming_vram_resident_bytes_ = 0;
// Monotonic frame counter, bumped at the top of driveStreamingLoads.
// Used as the LRU key for chunk eviction.
uint64_t streaming_frame_idx_ = 0;
// Sub-allocator for all chunk vertex + index bytes. Sized at startup
// by probeAndCreatePool() — the runtime tells us how big a single
// buffer it can actually deliver, eliminating the per-machine OOM
// ceiling that one-WGPUBuffer-per-chunk would otherwise hit. All
// chunk allocations land here; nothing else uses the pool. Replaces
// the old hand-picked streaming_vram_budget_bytes_ knob entirely.
WgpuBufferPool pool_;
private:
// Switch to LOD1 when an instance's projected bounding-sphere radius