wgpu streaming (5/4): per-chunk indices + LRU/distance eviction (stopgap)

Defers index buffers per-chunk (alongside vertex bytes) so streaming
fully delivers on its "don't load until visible" contract — the previous
per-model index buffer was upfront-loaded and tipped scenes >~1.5 GB into
allocator OOM at frame 1.

Adds residency tracking + a two-phase evictor: (1) drop LRU non-visible
chunks first, (2) if everything resident is visible-this-frame, drop the
farthest-from-eye chunk only when the candidate to load is closer. This
gives monotonic convergence to "closest visible chunks fit the budget"
instead of "first 4 win, rest never load."

Default budget set to 1 GB — explicitly a stopgap, documented inline.
The per-machine OOM ceiling on wgpu-native (caused by allocator
fragmentation from one VkDeviceMemory per createBuffer call) cannot be
solved by tuning this knob. The proper fix is a probed single-pool
buffer with sub-allocation, tracked under task #16.

Caveat: LOD1 indices are now force-disabled when chunking — per-chunk
buffers only carry LOD0. Re-enabling needs LOD1 to participate in the
chunk plan.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-05-28 11:15:11 +10:00
parent 5a3e5167df
commit 71e61dd8a5
4 changed files with 350 additions and 75 deletions
+32 -1
View File
@@ -133,9 +133,15 @@ private:
// vertex_storage, build the chunk's bind group, flip is_resident=true.
// Returns true on success. 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,
// 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.
// per-frame budget. Triggers requestUpdate() if more remain. Evicts
// LRU non-visible chunks first when over streaming_vram_budget_bytes_.
void driveStreamingLoads();
void ensureDepthTexture(int w, int h);
void releaseDepthTexture();
@@ -393,6 +399,31 @@ 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;
private:
// Switch to LOD1 when an instance's projected bounding-sphere radius