diff --git a/src/ifcviewer-wgpu-minimal/main.cpp b/src/ifcviewer-wgpu-minimal/main.cpp
index 324be20e61..3601611f3c 100644
--- a/src/ifcviewer-wgpu-minimal/main.cpp
+++ b/src/ifcviewer-wgpu-minimal/main.cpp
@@ -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);
diff --git a/src/ifcviewer-wgpu/WgpuBufferPool.cpp b/src/ifcviewer-wgpu/WgpuBufferPool.cpp
new file mode 100644
index 0000000000..72d1cb5662
--- /dev/null
+++ b/src/ifcviewer-wgpu/WgpuBufferPool.cpp
@@ -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 . *
+ * *
+ ********************************************************************************/
+
+#include "WgpuBufferPool.h"
+
+#include
+#include
+
+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;
+}
diff --git a/src/ifcviewer-wgpu/WgpuBufferPool.h b/src/ifcviewer-wgpu/WgpuBufferPool.h
new file mode 100644
index 0000000000..1b4499436b
--- /dev/null
+++ b/src/ifcviewer-wgpu/WgpuBufferPool.h
@@ -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 . *
+ * *
+ ********************************************************************************/
+
+#ifndef WGPUBUFFERPOOL_H
+#define WGPUBUFFERPOOL_H
+
+#include
+
+#include
+#include
+
+// 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 free_ranges_;
+
+ WGPUBuffer buffer_ = nullptr;
+ uint64_t capacity_ = 0;
+ uint64_t used_ = 0;
+};
+
+#endif // WGPUBUFFERPOOL_H
diff --git a/src/ifcviewer-wgpu/WgpuModelGpuData.h b/src/ifcviewer-wgpu/WgpuModelGpuData.h
index 124fda14c9..bebd57d435 100644
--- a/src/ifcviewer-wgpu/WgpuModelGpuData.h
+++ b/src/ifcviewer-wgpu/WgpuModelGpuData.h
@@ -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
diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp
index 1a269c6a82..b5e40922af 100644
--- a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp
+++ b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp
@@ -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(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(ud1);
+ p->done = true;
+ p->error = (type != WGPUErrorType_NoError);
+ };
+ pcb.userdata1 = ≺
+ 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::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; }
diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.h b/src/ifcviewer-wgpu/WgpuViewportWindow.h
index 3db05d9262..da3642e4a1 100644
--- a/src/ifcviewer-wgpu/WgpuViewportWindow.h
+++ b/src/ifcviewer-wgpu/WgpuViewportWindow.h
@@ -33,6 +33,7 @@
#include
#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