mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-10 01:41:57 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 = ≺
|
||||
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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user