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
-14
View File
@@ -62,10 +62,6 @@ int main(int argc, char* argv[]) {
"Enable streaming sidecar load. Reads metadata-only at load time; "
"vertex chunks are deferred and loaded on demand as they become "
"frustum-visible. Required for scenes that exceed GPU memory."});
parser.addOption({"streaming-vram-mb",
"Set the streaming residency budget in MB (default 1900). Tune "
"down to test browser-class memory ceilings; tune up if your GPU "
"+ driver are happy with larger allocations.", "mb"});
parser.process(app);
auto* viewport = new WgpuViewportWindow;
@@ -74,16 +70,6 @@ int main(int argc, char* argv[]) {
if (parser.isSet("web-limits")) viewport->web_limits_ = true;
if (parser.isSet("bvh")) viewport->bvh_enabled_ = true;
if (parser.isSet("streaming")) viewport->streaming_enabled_ = true;
if (parser.isSet("streaming-vram-mb")) {
bool ok = false;
const uint64_t mb = parser.value("streaming-vram-mb").toULongLong(&ok);
if (ok && mb > 0) {
viewport->streaming_vram_budget_bytes_ = mb * 1024ull * 1024ull;
} else {
qWarning() << "--streaming-vram-mb: failed to parse"
<< parser.value("streaming-vram-mb");
}
}
QWidget* container = QWidget::createWindowContainer(viewport);
container->setMinimumSize(320, 240);
+130
View File
@@ -0,0 +1,130 @@
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "WgpuBufferPool.h"
#include <cassert>
#include <cstring>
WgpuBufferPool::~WgpuBufferPool() {
destroy();
}
bool WgpuBufferPool::init(WGPUDevice device, uint64_t capacity_bytes,
WGPUBufferUsage usage, const char* label) {
destroy();
if (capacity_bytes == 0) return false;
WGPUBufferDescriptor desc = {};
desc.usage = usage;
desc.size = capacity_bytes;
if (label) {
desc.label.data = label;
desc.label.length = std::strlen(label);
}
buffer_ = wgpuDeviceCreateBuffer(device, &desc);
if (!buffer_) return false;
capacity_ = capacity_bytes;
used_ = 0;
free_ranges_.clear();
free_ranges_.push_back({0, capacity_bytes});
return true;
}
void WgpuBufferPool::destroy() {
if (buffer_) {
wgpuBufferRelease(buffer_);
buffer_ = nullptr;
}
capacity_ = 0;
used_ = 0;
free_ranges_.clear();
}
bool WgpuBufferPool::alloc(uint64_t size, uint64_t align, uint64_t* out_offset) {
if (size == 0 || align == 0) return false;
// First-fit: scan free ranges, pick the first that fits with alignment.
for (size_t i = 0; i < free_ranges_.size(); ++i) {
const FreeRange& r = free_ranges_[i];
const uint64_t aligned = (r.offset + (align - 1)) & ~(align - 1);
const uint64_t pad = aligned - r.offset;
if (pad >= r.size) continue; // alignment alone won't fit
if (size > r.size - pad) continue; // payload won't fit
// Split the range. Three resulting pieces:
// [r.offset, aligned) -> pre-pad, returned to free list
// [aligned, aligned + size) -> the allocation (claimed)
// [aligned + size, r.offset + r.size) -> post-pad, returned to free list
const uint64_t post_off = aligned + size;
const uint64_t post_size = (r.offset + r.size) - post_off;
// Mutate in place: replace the matched range with the pre-pad
// (or erase it if there's no pre-pad), then optionally insert
// the post-pad immediately after.
if (pad == 0 && post_size == 0) {
free_ranges_.erase(free_ranges_.begin() + i);
} else if (pad == 0) {
free_ranges_[i] = {post_off, post_size};
} else if (post_size == 0) {
free_ranges_[i] = {r.offset, pad};
} else {
free_ranges_[i] = {r.offset, pad};
free_ranges_.insert(free_ranges_.begin() + i + 1, {post_off, post_size});
}
used_ += size; // pre-/post-pad remain in free_ranges_, not used_
*out_offset = aligned;
return true;
}
return false;
}
void WgpuBufferPool::free(uint64_t offset, uint64_t size) {
if (size == 0) return;
assert(offset + size <= capacity_);
// Find insertion point: first range whose offset > released offset.
size_t i = 0;
while (i < free_ranges_.size() && free_ranges_[i].offset < offset) ++i;
free_ranges_.insert(free_ranges_.begin() + i, {offset, size});
used_ -= size;
// Coalesce with right neighbour first (so subsequent left-coalesce
// sees the merged range).
if (i + 1 < free_ranges_.size()
&& free_ranges_[i].offset + free_ranges_[i].size == free_ranges_[i + 1].offset) {
free_ranges_[i].size += free_ranges_[i + 1].size;
free_ranges_.erase(free_ranges_.begin() + i + 1);
}
// Coalesce with left neighbour.
if (i > 0
&& free_ranges_[i - 1].offset + free_ranges_[i - 1].size == free_ranges_[i].offset) {
free_ranges_[i - 1].size += free_ranges_[i].size;
free_ranges_.erase(free_ranges_.begin() + i);
}
}
uint64_t WgpuBufferPool::largest_free_run_bytes() const {
uint64_t m = 0;
for (const auto& r : free_ranges_) {
if (r.size > m) m = r.size;
}
return m;
}
+86
View File
@@ -0,0 +1,86 @@
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef WGPUBUFFERPOOL_H
#define WGPUBUFFERPOOL_H
#include <webgpu/webgpu.h>
#include <cstdint>
#include <vector>
// Single-buffer sub-allocator. Owns one WGPUBuffer of fixed capacity and
// hands out byte ranges within it. Replaces the per-chunk
// wgpuDeviceCreateBuffer/Release pattern, which on wgpu-native triggers
// gpu-alloc-rs fragmentation (one VkDeviceMemory per buffer with
// rounding overhead) and OOMs the device well below physical VRAM.
//
// Lifetime model: alloc/free are immediate. WebGPU guarantees that
// queue.writeBuffer to a just-freed range is correctly serialised against
// any prior submitted GPU reads — we never need to fence frees ourselves.
//
// Allocator: sorted free list with adjacent-range coalescing, first-fit.
// Adequate for the chunk workload (a few hundred allocations of broadly
// similar size); revisit if a workload demonstrates worst-case behaviour.
class WgpuBufferPool {
public:
WgpuBufferPool() = default;
~WgpuBufferPool();
WgpuBufferPool(const WgpuBufferPool&) = delete;
WgpuBufferPool& operator=(const WgpuBufferPool&) = delete;
// Allocate the underlying buffer at the given capacity. `usage` must
// include CopyDst (alloc'd ranges are populated via queueWriteBuffer).
// Returns false if creation failed (caller can retry at smaller size).
bool init(WGPUDevice device, uint64_t capacity_bytes,
WGPUBufferUsage usage, const char* label);
void destroy();
// Sub-allocate a range of `size` bytes, aligned to `align` (must be
// a power of two; typical: 256 for storage-buffer binding offsets).
// On success returns true and writes the byte offset to *out_offset.
// On failure (no free range fits) returns false; *out_offset is
// unchanged.
bool alloc(uint64_t size, uint64_t align, uint64_t* out_offset);
// Free a previously-allocated range. (offset, size) must exactly
// match a prior alloc(); freeing a partial range is unsupported.
void free(uint64_t offset, uint64_t size);
WGPUBuffer buffer() const { return buffer_; }
uint64_t capacity_bytes() const { return capacity_; }
uint64_t used_bytes() const { return used_; }
uint64_t free_bytes() const { return capacity_ - used_; }
// Largest contiguous free run. Useful for evictor heuristics ("can
// this allocation even fit, ever, without eviction?").
uint64_t largest_free_run_bytes() const;
private:
struct FreeRange { uint64_t offset; uint64_t size; };
// Sorted by offset, non-overlapping, non-adjacent (coalesced on
// every free). Empty when the pool is fully allocated.
std::vector<FreeRange> free_ranges_;
WGPUBuffer buffer_ = nullptr;
uint64_t capacity_ = 0;
uint64_t used_ = 0;
};
#endif // WGPUBUFFERPOOL_H
+27 -17
View File
@@ -63,24 +63,31 @@ struct WgpuModelGpuData {
};
static_assert(sizeof(VisibleDrawGpu) == 16, "VisibleDrawGpu must be 16 bytes");
// Per-chunk state. Each chunk owns its vertex_storage (sized ≤ 128 MB)
// plus a small set of per-frame buffers (visible_draws, prefix_sums,
// uniform) and a bind group that pulls in the chunk's vertex_storage
// alongside the model-shared index/mesh/instance buffers. Rendering
// issues one drawcall per non-empty chunk.
// Per-chunk state. Each chunk references a vertex range and an
// index range inside WgpuViewportWindow::pool_, plus a small set of
// per-frame buffers (visible_draws, prefix_sums, uniform) and a bind
// group that binds the pool ranges alongside the model-shared
// mesh/instance storage. Rendering issues one drawcall per non-empty
// chunk.
//
// Streaming (task #16): a chunk may be marked is_resident=false; its
// vertex_storage + bind_group are then null until the streaming loader
// brings it in. Other per-chunk buffers (visible_draws etc.) stay
// allocated regardless because cull still needs them. Non-streaming
// path always sets is_resident=true so existing code is unchanged.
// pool ranges (pool_*_size == 0) and bind_group are then unclaimed
// until the streaming loader brings it in. Other per-chunk buffers
// (visible_draws etc.) stay allocated regardless because cull still
// needs them. Non-streaming path always sets is_resident=true and
// populates pool ranges at applyCachedModel time.
struct Chunk {
WGPUBuffer vertex_storage = nullptr;
// Per-chunk index buffer (was previously per-model). Each chunk
// covers a contiguous range of mesh ids, so its indices form a
// contiguous slice of the model's overall index data. Storing
// per-chunk lets streaming defer index loading alongside vertices.
WGPUBuffer index_buffer = nullptr;
// Pool-allocated vertex bytes. When resident, pool_vertex_size > 0
// and the range [pool_vertex_offset, pool_vertex_offset + pool_vertex_size)
// in WgpuViewportWindow::pool_ holds this chunk's vertex_storage.
// When non-resident, both are 0.
uint64_t pool_vertex_offset = 0;
uint64_t pool_vertex_size = 0;
// Pool-allocated index bytes. Same lifetime as the vertex range —
// either both resident or both freed.
uint64_t pool_index_offset = 0;
uint64_t pool_index_size = 0;
WGPUBuffer visible_draws_buffer = nullptr;
WGPUBuffer prefix_sums_buffer = nullptr;
WGPUBuffer per_chunk_uniform = nullptr;
@@ -183,8 +190,11 @@ struct WgpuModelGpuData {
bool hidden = false;
};
// Release every wgpu handle in `m` and clear its size mirrors. Safe to call
class WgpuBufferPool;
// Release every wgpu handle in `m` (including per-chunk and per-model pool
// ranges via `pool.free()`) and clear its size mirrors. Safe to call
// repeatedly; idempotent on already-released entries.
void releaseWgpuModelGpuData(WgpuModelGpuData& m);
void releaseWgpuModelGpuData(WgpuModelGpuData& m, WgpuBufferPool& pool);
#endif // WGPUMODELGPUDATA_H
+229 -84
View File
@@ -132,11 +132,19 @@ static WGPUBuffer createBufferWithData(WGPUDevice device, WGPUQueue queue,
return buf;
}
void releaseWgpuModelGpuData(WgpuModelGpuData& m) {
void releaseWgpuModelGpuData(WgpuModelGpuData& m, WgpuBufferPool& pool) {
for (auto& c : m.chunks) {
if (c.bind_group) { wgpuBindGroupRelease(c.bind_group); c.bind_group = nullptr; }
if (c.vertex_storage) { wgpuBufferRelease(c.vertex_storage); c.vertex_storage = nullptr; }
if (c.index_buffer) { wgpuBufferRelease(c.index_buffer); c.index_buffer = nullptr; }
if (c.pool_vertex_size > 0) {
pool.free(c.pool_vertex_offset, c.pool_vertex_size);
c.pool_vertex_offset = 0;
c.pool_vertex_size = 0;
}
if (c.pool_index_size > 0) {
pool.free(c.pool_index_offset, c.pool_index_size);
c.pool_index_offset = 0;
c.pool_index_size = 0;
}
if (c.visible_draws_buffer) { wgpuBufferRelease(c.visible_draws_buffer); c.visible_draws_buffer = nullptr; }
if (c.prefix_sums_buffer) { wgpuBufferRelease(c.prefix_sums_buffer); c.prefix_sums_buffer = nullptr; }
if (c.per_chunk_uniform) { wgpuBufferRelease(c.per_chunk_uniform); c.per_chunk_uniform = nullptr; }
@@ -545,7 +553,7 @@ void WgpuViewportWindow::applyCachedModelStreaming(uint32_t model_id,
// Replace any existing state for this id.
auto it = models_gpu_.find(model_id);
if (it != models_gpu_.end()) {
releaseWgpuModelGpuData(it->second);
releaseWgpuModelGpuData(it->second, pool_);
models_gpu_.erase(it);
}
@@ -795,7 +803,7 @@ void WgpuViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) {
// Replace any existing state for this id.
auto it = models_gpu_.find(model_id);
if (it != models_gpu_.end()) {
releaseWgpuModelGpuData(it->second);
releaseWgpuModelGpuData(it->second, pool_);
models_gpu_.erase(it);
}
@@ -912,26 +920,43 @@ void WgpuViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) {
c.index_first_u32 = plan.index_first_u32;
c.index_count = plan.index_count;
const char* vs_label = (ci == 0) ? "model.chunk0.vertex_storage"
: "model.chunkN.vertex_storage";
c.vertex_storage = createBufferWithData(
device_, queue_,
data.vertices.data() + plan.source_byte_offset,
plan.byte_count,
WGPUBufferUsage_Storage,
vs_label);
// Vertex bytes: claim a pool range, upload via queueWriteBuffer.
// Storage binding offsets must align to 256 B (WebGPU spec floor
// — minStorageBufferOffsetAlignment); WgpuBufferPool inserts the
// necessary pre-pad. Failure here is fatal for the model: a fresh
// applyCachedModel can't proceed without VRAM, so we bail with
// a clear log and let the caller see it as a load failure.
if (!pool_.alloc(plan.byte_count, 256, &c.pool_vertex_offset)) {
qWarning().noquote().nospace()
<< "[wgpu] pool OOM: chunk " << ci << " needed "
<< plan.byte_count << " B for vertices, pool free="
<< pool_.free_bytes() << " B; aborting model load";
releaseWgpuModelGpuData(m, pool_);
return;
}
c.pool_vertex_size = plan.byte_count;
wgpuQueueWriteBuffer(queue_, pool_.buffer(),
c.pool_vertex_offset,
data.vertices.data() + plan.source_byte_offset,
plan.byte_count);
m.vram_bytes_vbo += plan.byte_count;
// Per-chunk index slice. base mesh's ebo_byte_offset gives us the
// chunk's start; chunk's index_count tells us how far the slice runs.
// Per-chunk index slice — same dance, from the model's indices[].
const size_t chunk_index_bytes = size_t(plan.index_count) * sizeof(uint32_t);
if (chunk_index_bytes > 0) {
c.index_buffer = createBufferWithData(
device_, queue_,
data.indices.data() + plan.index_first_u32,
chunk_index_bytes,
WGPUBufferUsage_Storage | WGPUBufferUsage_Index,
"model.chunk.index_buffer");
if (!pool_.alloc(chunk_index_bytes, 256, &c.pool_index_offset)) {
qWarning().noquote().nospace()
<< "[wgpu] pool OOM: chunk " << ci << " needed "
<< chunk_index_bytes << " B for indices, pool free="
<< pool_.free_bytes() << " B; aborting model load";
releaseWgpuModelGpuData(m, pool_);
return;
}
c.pool_index_size = chunk_index_bytes;
wgpuQueueWriteBuffer(queue_, pool_.buffer(),
c.pool_index_offset,
data.indices.data() + plan.index_first_u32,
chunk_index_bytes);
m.vram_bytes_ebo += chunk_index_bytes;
}
}
@@ -1094,13 +1119,13 @@ void WgpuViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) {
void WgpuViewportWindow::removeModel(uint32_t model_id) {
auto it = models_gpu_.find(model_id);
if (it == models_gpu_.end()) return;
releaseWgpuModelGpuData(it->second);
releaseWgpuModelGpuData(it->second, pool_);
models_gpu_.erase(it);
if (isExposed()) requestUpdate();
}
void WgpuViewportWindow::resetScene() {
for (auto& [mid, m] : models_gpu_) releaseWgpuModelGpuData(m);
for (auto& [mid, m] : models_gpu_) releaseWgpuModelGpuData(m, pool_);
models_gpu_.clear();
if (isExposed()) requestUpdate();
}
@@ -1257,6 +1282,16 @@ bool WgpuViewportWindow::initWgpu() {
device_ = dreq.device;
queue_ = wgpuDeviceGetQueue(device_);
// ---- Probe streaming pool capacity ----------------------------------
// Ask the device for the largest single buffer it'll actually give us.
// Replaces the per-machine "guess the OOM ceiling" knob: now the
// runtime answers the question. Failure here is fatal — without any
// pool we can't load chunks.
if (!probeAndCreatePool()) {
qWarning() << "wgpu: streaming pool probe failed; cannot start";
return false;
}
// ---- Pick a surface format -------------------------------------------
WGPUSurfaceCapabilities caps = {};
if (wgpuSurfaceGetCapabilities(surface_, adapter_, &caps) != WGPUStatus_Success
@@ -1276,6 +1311,86 @@ bool WgpuViewportWindow::initWgpu() {
return true;
}
bool WgpuViewportWindow::probeAndCreatePool() {
// Discover the largest single buffer the runtime will grant. We
// descend from the device's advertised maxBufferSize because the
// adapter promises that much per binding, but the underlying
// allocator (gpu-alloc-rs on Vulkan, Metal heap manager, browser
// internals) may refuse anything above an undocumented per-system
// ceiling. The probe answers the question honestly.
//
// Each attempt is wrapped in an OOM error scope so a failed
// allocation doesn't surface to onUncapturedError as a noisy
// validation warning — the scope captures the OOM cleanly and we
// simply halve and retry.
WGPULimits device_limits = {};
wgpuDeviceGetLimits(device_, &device_limits);
// 64 MB lower bound: below this the viewer is unusable for any real
// dataset, so we'd rather fail init than limp along.
constexpr uint64_t MIN_POOL_CAPACITY = 64ull * 1024 * 1024;
// 4 GB starting cap: this is the largest single buffer the WebGPU
// ecosystem realistically supports today (browsers stay well below;
// desktop drivers vary). Asking for the device's full advertised
// maxBufferSize first is wasteful — on wgpu-native it can be 1 TB
// (a sentinel meaning "no spec floor"), which always fails and
// forces ~10 halving steps before we land somewhere sensible.
constexpr uint64_t MAX_PROBE_START = 4ull * 1024 * 1024 * 1024;
uint64_t try_size = std::min<uint64_t>(device_limits.maxBufferSize,
MAX_PROBE_START);
if (try_size < MIN_POOL_CAPACITY) try_size = MIN_POOL_CAPACITY;
const WGPUBufferUsage pool_usage = WGPUBufferUsage_Storage
| WGPUBufferUsage_CopyDst;
while (try_size >= MIN_POOL_CAPACITY) {
// wgpu-native classifies "Not enough memory left" as Validation,
// not OutOfMemory — so we need both filters. Nested scopes: OOM
// inner (matches first), Validation outer (catches the rest).
wgpuDevicePushErrorScope(device_, WGPUErrorFilter_Validation);
wgpuDevicePushErrorScope(device_, WGPUErrorFilter_OutOfMemory);
const bool init_ok = pool_.init(device_, try_size, pool_usage,
"ifcviewer-wgpu.streaming_pool");
struct PopResult { bool done = false; bool error = false; };
auto pop = [&](PopResult& pr) {
WGPUPopErrorScopeCallbackInfo pcb = {};
pcb.mode = WGPUCallbackMode_AllowProcessEvents;
pcb.callback = [](WGPUPopErrorScopeStatus, WGPUErrorType type,
WGPUStringView, void* ud1, void* /*ud2*/) {
auto* p = static_cast<PopResult*>(ud1);
p->done = true;
p->error = (type != WGPUErrorType_NoError);
};
pcb.userdata1 = &pr;
wgpuDevicePopErrorScope(device_, pcb);
while (!pr.done) wgpuInstanceProcessEvents(instance_);
};
PopResult oom_pop, validation_pop;
pop(oom_pop);
pop(validation_pop);
if (init_ok && !oom_pop.error && !validation_pop.error) {
qInfo().noquote()
<< "wgpu: streaming pool capacity ="
<< (try_size / (1024 * 1024)) << "MB"
<< "(device maxBufferSize ="
<< (device_limits.maxBufferSize / (1024 * 1024)) << "MB)";
return true;
}
// Tear down the failed pool and halve. The init may have left
// the buffer handle in an invalid state — destroy() releases it.
pool_.destroy();
try_size /= 2;
}
qWarning() << "wgpu: pool probe found no allocatable size >="
<< (MIN_POOL_CAPACITY / (1024 * 1024)) << "MB";
return false;
}
// -----------------------------------------------------------------------------
// Surface creation — platform-specific native handle plumbing.
// -----------------------------------------------------------------------------
@@ -3169,16 +3284,20 @@ void WgpuViewportWindow::buildChunkBindGroup(WgpuModelGpuData& m, size_t chunk_i
wgpuBindGroupRelease(c.bind_group);
c.bind_group = nullptr;
}
if (!c.vertex_storage || !c.index_buffer || !c.visible_draws_buffer
|| !c.prefix_sums_buffer || !c.per_chunk_uniform
if (c.pool_vertex_size == 0 || c.pool_index_size == 0
|| !c.visible_draws_buffer || !c.prefix_sums_buffer || !c.per_chunk_uniform
|| !m.mesh_storage || !m.instance_storage) {
return;
}
WGPUBindGroupEntry entries[7] = {};
// vertices and indices live in the shared pool buffer at chunk-specific
// (offset, size) ranges; the other entries are still per-chunk small
// buffers (visible_draws/prefix_sums/uniform) or per-model (mesh/instance).
entries[0].binding = 0;
entries[0].buffer = c.vertex_storage;
entries[0].size = WGPU_WHOLE_SIZE;
entries[0].buffer = pool_.buffer();
entries[0].offset = c.pool_vertex_offset;
entries[0].size = c.pool_vertex_size;
entries[1].binding = 1;
entries[1].buffer = m.mesh_storage;
entries[1].size = WGPU_WHOLE_SIZE;
@@ -3186,8 +3305,9 @@ void WgpuViewportWindow::buildChunkBindGroup(WgpuModelGpuData& m, size_t chunk_i
entries[2].buffer = m.instance_storage;
entries[2].size = WGPU_WHOLE_SIZE;
entries[3].binding = 3;
entries[3].buffer = c.index_buffer;
entries[3].size = WGPU_WHOLE_SIZE;
entries[3].buffer = pool_.buffer();
entries[3].offset = c.pool_index_offset;
entries[3].size = c.pool_index_size;
entries[4].binding = 4;
entries[4].buffer = c.visible_draws_buffer;
entries[4].size = WGPU_WHOLE_SIZE;
@@ -3226,14 +3346,18 @@ bool WgpuViewportWindow::loadChunkBytesAndUploadGpu(WgpuModelGpuData& m, size_t
<< " size=" << c.vertex_byte_size << ")";
return false;
}
c.vertex_storage = createBufferWithData(
device_, queue_,
vbytes.data(), vbytes.size(),
WGPUBufferUsage_Storage,
"model.chunk.vertex_storage_streamed");
if (!c.vertex_storage) return false;
m.vram_bytes_vbo += vbytes.size();
streaming_vram_resident_bytes_ += vbytes.size();
// Claim a pool range for the vertex bytes and upload.
if (!pool_.alloc(vbytes.size(), 256, &c.pool_vertex_offset)) {
// No room — caller (driveStreamingLoads) should have evicted
// first. This branch is a safety net for the very-first-frame
// case where pool eviction may not have caught up.
return false;
}
c.pool_vertex_size = vbytes.size();
wgpuQueueWriteBuffer(queue_, pool_.buffer(),
c.pool_vertex_offset,
vbytes.data(), vbytes.size());
m.vram_bytes_vbo += vbytes.size();
// Index slice. The byte-range read comes from
// streaming_index_section_offset + index_first_u32 * 4 (set by
@@ -3249,21 +3373,26 @@ bool WgpuViewportWindow::loadChunkBytesAndUploadGpu(WgpuModelGpuData& m, size_t
<< "[wgpu stream] failed to read index chunk " << chunk_idx
<< " (first=" << c.index_first_u32
<< " count=" << c.index_count << ")";
// Release the vertex buffer we just allocated so we don't leak.
wgpuBufferRelease(c.vertex_storage);
c.vertex_storage = nullptr;
m.vram_bytes_vbo -= vbytes.size();
streaming_vram_resident_bytes_ -= vbytes.size();
// Return the vertex slice to the pool so we don't leak.
pool_.free(c.pool_vertex_offset, c.pool_vertex_size);
c.pool_vertex_offset = 0;
c.pool_vertex_size = 0;
m.vram_bytes_vbo -= vbytes.size();
return false;
}
const size_t ibytes = idx.size() * sizeof(uint32_t);
c.index_buffer = createBufferWithData(
device_, queue_,
idx.data(), ibytes,
WGPUBufferUsage_Storage | WGPUBufferUsage_Index,
"model.chunk.index_buffer_streamed");
m.vram_bytes_ebo += ibytes;
streaming_vram_resident_bytes_ += ibytes;
if (!pool_.alloc(ibytes, 256, &c.pool_index_offset)) {
pool_.free(c.pool_vertex_offset, c.pool_vertex_size);
c.pool_vertex_offset = 0;
c.pool_vertex_size = 0;
m.vram_bytes_vbo -= vbytes.size();
return false;
}
c.pool_index_size = ibytes;
wgpuQueueWriteBuffer(queue_, pool_.buffer(),
c.pool_index_offset,
idx.data(), ibytes);
m.vram_bytes_ebo += ibytes;
}
buildChunkBindGroup(m, chunk_idx);
@@ -3280,19 +3409,17 @@ void WgpuViewportWindow::unloadChunk(WgpuModelGpuData& m, size_t chunk_idx) {
wgpuBindGroupRelease(c.bind_group);
c.bind_group = nullptr;
}
if (c.vertex_storage) {
const uint64_t vsz = c.vertex_byte_size;
wgpuBufferRelease(c.vertex_storage);
c.vertex_storage = nullptr;
m.vram_bytes_vbo -= vsz;
streaming_vram_resident_bytes_ -= vsz;
if (c.pool_vertex_size > 0) {
m.vram_bytes_vbo -= c.pool_vertex_size;
pool_.free(c.pool_vertex_offset, c.pool_vertex_size);
c.pool_vertex_offset = 0;
c.pool_vertex_size = 0;
}
if (c.index_buffer) {
const uint64_t isz = c.index_count * sizeof(uint32_t);
wgpuBufferRelease(c.index_buffer);
c.index_buffer = nullptr;
m.vram_bytes_ebo -= isz;
streaming_vram_resident_bytes_ -= isz;
if (c.pool_index_size > 0) {
m.vram_bytes_ebo -= c.pool_index_size;
pool_.free(c.pool_index_offset, c.pool_index_size);
c.pool_index_offset = 0;
c.pool_index_size = 0;
}
// Clear per-frame visibility so the chunk doesn't get re-rendered or
// re-evicted on the same frame; cull will set it again next time
@@ -3340,18 +3467,27 @@ void WgpuViewportWindow::driveStreamingLoads() {
// Per-frame load budget. Caps first-frame stall on a fresh load — at
// 4 chunks/frame × 60fps we ingest 240 chunks/sec, fast enough that
// a 100-model scene fully resides in ~1s. Bigger scenes are bounded
// by the VRAM budget below: the residency set never grows past
// streaming_vram_budget_bytes_, so we stay clear of the wgpu-native
// allocator's OOM ceiling (~2.4 GB on this box, default budget 1.9).
// a 100-model scene fully resides in ~1s. The hard ceiling on total
// residency is the pool capacity (probed at startup); when the pool
// can't fit a candidate, the evictors below free closer-fitting
// ranges until it does.
constexpr int MAX_STREAMING_LOADS_PER_FRAME = 4;
int loads = 0;
bool more_pending = false;
// The pool needs `need` contiguous bytes free for both the vertex and
// index allocations a load requires. Fragmentation matters: a chunk
// may fit total-free-bytes but not largest_free_run_bytes(). We don't
// try to predict fragmentation perfectly — the load will simply fail
// and trigger another eviction round next frame.
auto pool_can_fit = [&](uint64_t bytes) -> bool {
return pool_.largest_free_run_bytes() >= bytes;
};
// Phase-1 evictor: drop the LRU non-visible resident chunk. Skips
// chunks stamped on streaming_frame_idx_ to avoid yanking what cull
// just marked visible. Returns bytes freed (0 ⇒ no candidate).
auto evict_one_lru = [&]() -> uint64_t {
// just marked visible. Returns true iff a chunk was evicted.
auto evict_one_lru = [&]() -> bool {
WgpuModelGpuData* victim_m = nullptr;
size_t victim_ci = 0;
uint64_t victim_lru = std::numeric_limits<uint64_t>::max();
@@ -3367,10 +3503,9 @@ void WgpuViewportWindow::driveStreamingLoads() {
}
}
}
if (!victim_m) return 0;
const uint64_t before = streaming_vram_resident_bytes_;
if (!victim_m) return false;
unloadChunk(*victim_m, victim_ci);
return before - streaming_vram_resident_bytes_;
return true;
};
// Phase-2 evictor: when every resident chunk is visible-this-frame
@@ -3378,8 +3513,8 @@ void WgpuViewportWindow::driveStreamingLoads() {
// eye visible chunk — provided it is farther than the candidate we
// want to load. Without the distance check this would loop forever
// swapping pairs; with it, residency monotonically converges to the
// closest visible chunks that fit the budget.
auto evict_farthest_than = [&](float candidate_dist2) -> uint64_t {
// closest visible chunks that fit the pool.
auto evict_farthest_than = [&](float candidate_dist2) -> bool {
WgpuModelGpuData* victim_m = nullptr;
size_t victim_ci = 0;
float victim_dist2 = candidate_dist2;
@@ -3395,10 +3530,9 @@ void WgpuViewportWindow::driveStreamingLoads() {
}
}
}
if (!victim_m) return 0;
const uint64_t before = streaming_vram_resident_bytes_;
if (!victim_m) return false;
unloadChunk(*victim_m, victim_ci);
return before - streaming_vram_resident_bytes_;
return true;
};
for (auto& [mid, m] : models_gpu_) {
@@ -3415,18 +3549,23 @@ void WgpuViewportWindow::driveStreamingLoads() {
}
// Make room. Phase 1: drop LRU non-visible. Phase 2: if still
// over budget, drop the farthest-from-eye visible chunk that
// is strictly farther than the candidate we want to load.
// pool-tight, drop the farthest-from-eye visible chunk that is
// strictly farther than the candidate we want to load. The
// largest-free-run check is conservative — pool may have N MB
// free across many small holes that no chunk can use.
const uint64_t need = c.vertex_byte_size
+ c.index_count * sizeof(uint32_t);
while (streaming_vram_resident_bytes_ + need
> streaming_vram_budget_bytes_) {
if (evict_one_lru() > 0) continue;
if (evict_farthest_than(chunk_center_dist2(c)) > 0) continue;
while (!pool_can_fit(c.vertex_byte_size)
|| (c.index_count > 0
&& !pool_can_fit(c.index_count * sizeof(uint32_t)))
|| pool_.free_bytes() < need) {
if (evict_one_lru()) continue;
if (evict_farthest_than(chunk_center_dist2(c))) continue;
break; // nothing left we're willing to evict
}
if (streaming_vram_resident_bytes_ + need
> streaming_vram_budget_bytes_) {
if (!pool_can_fit(c.vertex_byte_size)
|| (c.index_count > 0
&& !pool_can_fit(c.index_count * sizeof(uint32_t)))) {
// Candidate is farther than every resident — skip it.
// We'll come back to it if it gets closer.
more_pending = true;
@@ -3840,7 +3979,7 @@ void WgpuViewportWindow::wheelEvent(QWheelEvent* event) {
void WgpuViewportWindow::shutdown() {
// Release per-model buffers before the device they were created from.
for (auto& [mid, m] : models_gpu_) releaseWgpuModelGpuData(m);
for (auto& [mid, m] : models_gpu_) releaseWgpuModelGpuData(m, pool_);
models_gpu_.clear();
releaseDepthTexture();
@@ -3859,6 +3998,12 @@ void WgpuViewportWindow::shutdown() {
if (model_bgl_) { wgpuBindGroupLayoutRelease(model_bgl_); model_bgl_ = nullptr; }
if (frame_bgl_) { wgpuBindGroupLayoutRelease(frame_bgl_); frame_bgl_ = nullptr; }
// Destroy the streaming pool while device_ is still alive (it owns
// the underlying WGPUBuffer). All chunks have already returned their
// ranges via releaseWgpuModelGpuData above; pool's free-list count
// should equal capacity at this point.
pool_.destroy();
if (queue_) { wgpuQueueRelease(queue_); queue_ = nullptr; }
if (device_) { wgpuDeviceRelease(device_); device_ = nullptr; }
if (adapter_) { wgpuAdapterRelease(adapter_); adapter_ = nullptr; }
+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