wgpu streaming: multi-pool growth, frustum-only residency, sorted convergence

Five interlocking fixes that take --streaming on the big federation
scene from "5 fps + endless flicker + infinite cold-load" to a
stable 35-49 fps with a converged working set.

1. Multi-sub-buffer WgpuBufferPool. Pool now grows lazily by adding
   sub-buffers of per_sub_buffer_capacity_ when alloc demand exceeds
   existing free runs. Each Slice carries (buffer, offset, size,
   sub_idx). On driver refusal of addSubBuffer, growth_disabled_
   latches so subsequent allocs don't keep retrying and log-spamming.
   pool_can_fit consults can_grow() to know when growth could rescue
   a candidate vs when eviction is the only path.

2. Split cull / stream benchmark timers. The previous "cull[wall]"
   metric was actually cull + driveStreamingLoads, blaming the wrong
   subsystem (~170 ms of "cull" was synchronous disk I/O).

3. frustum_visible_count on Chunk, populated in cullModelCpuCompute
   right after the per-instance aabbInFrustum check. driveStreamingLoads
   now keys residency on this instead of total_visible_draws (which
   includes contribution + HiZ). HiZ visibility flips frame-to-frame
   as occluders shift; using it for residency caused chunks to be
   evicted then immediately re-loaded, every frame, even with a
   stationary camera — both the perf cliff and the visible flicker.

4. Distance-sorted candidates in driveStreamingLoads. Walk the
   non-resident frustum-visible chunks in distance order (closest
   first). With sorted processing, evict_farthest_than converges
   monotonically: each swap replaces a far resident with a closer
   candidate; once the next candidate is farther than every
   remaining resident, the loop exits. Without sorting the loader
   visited candidates in model/chunk-id order, swapping random
   chunks every frame without ever converging.

5. 10% eviction hysteresis (EVICT_DIST2_RATIO = 1.21). On scenes
   where many chunks are clustered at similar distance from the
   camera (e.g. several chunks all ~370 m away), naive
   "evict any resident strictly farther than candidate" triggers
   sub-meter swaps every frame, never resting. Requiring the victim
   to be 10% farther in linear distance kills these cycles while
   still allowing genuine "much closer" candidates to evict.

Plus: latched bench_warm_done_ on the cold-load gate, with a
5-frames-of-zero-loads convergence test (default-camera big scene
converges in 20 frames) and a 600-frame timeout fallback that prints
exactly once.

Measured on the test federation (111 sidecars, ~3 GB raw, 1 M
instances) with the user's close-in camera:
- avg 35 fps (was 5), median 49 fps (was 7)
- cull 19 ms (now the bottleneck), stream 5-8 ms (was 172)
- p99 184 ms — occasional big-chunk load on the render thread;
  background-thread I/O would smooth that out as a follow-up.

With the default wide camera:
- avg 40 fps, converges in 20 frames, residency grows naturally
  from 59 → 76 chunks as orbit shifts the frustum.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-05-28 14:08:16 +10:00
parent 502c29fbc2
commit c3a55d7f7b
5 changed files with 507 additions and 238 deletions
+162 -76
View File
@@ -19,6 +19,8 @@
#include "WgpuBufferPool.h"
#include <QtDebug>
#include <cassert>
#include <cstring>
@@ -26,105 +28,189 @@ WgpuBufferPool::~WgpuBufferPool() {
destroy();
}
bool WgpuBufferPool::init(WGPUDevice device, uint64_t capacity_bytes,
WGPUBufferUsage usage, const char* label) {
void WgpuBufferPool::configure(WGPUInstance instance, WGPUDevice device,
WGPUBufferUsage usage,
uint64_t per_sub_buffer_capacity,
const char* label_prefix) {
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;
instance_ = instance;
device_ = device;
usage_ = usage;
per_sub_buffer_capacity_ = per_sub_buffer_capacity;
label_prefix_ = label_prefix ? label_prefix : "";
}
void WgpuBufferPool::destroy() {
if (buffer_) {
wgpuBufferRelease(buffer_);
buffer_ = nullptr;
for (auto& sp : sub_pools_) {
if (sp.buffer) wgpuBufferRelease(sp.buffer);
}
capacity_ = 0;
used_ = 0;
free_ranges_.clear();
sub_pools_.clear();
device_ = nullptr;
instance_ = nullptr;
usage_ = 0;
per_sub_buffer_capacity_ = 0;
growth_disabled_ = false;
label_prefix_.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
bool WgpuBufferPool::addSubBuffer() {
if (!device_ || per_sub_buffer_capacity_ == 0) return false;
// A previous addSubBuffer at this capacity was refused — don't retry
// every alloc and re-log. The driver's per-allocation cap won't move
// without something freeing first, which only destroy() represents.
if (growth_disabled_) return false;
// 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;
// wgpu-native classifies "Not enough memory left" as Validation, not
// OutOfMemory — so we push both filters (nested: OOM inner, Validation
// outer). Either firing means the driver refused the allocation.
wgpuDevicePushErrorScope(device_, WGPUErrorFilter_Validation);
wgpuDevicePushErrorScope(device_, WGPUErrorFilter_OutOfMemory);
// 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});
char label[128];
std::snprintf(label, sizeof(label), "%s.sub%zu",
label_prefix_.c_str(), sub_pools_.size());
WGPUBufferDescriptor desc = {};
desc.usage = usage_;
desc.size = per_sub_buffer_capacity_;
desc.label.data = label;
desc.label.length = std::strlen(label);
WGPUBuffer buf = wgpuDeviceCreateBuffer(device_, &desc);
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 (!buf || oom_pop.error || validation_pop.error) {
if (buf) wgpuBufferRelease(buf);
// Log once — set growth_disabled_ so subsequent allocs don't
// re-try at this size. The pool runs at its hardware-limited
// ceiling from here; eviction handles the rest.
qInfo().noquote().nospace()
<< "[wgpu pool] driver refused sub-buffer " << sub_pools_.size()
<< " at " << (per_sub_buffer_capacity_ / (1024 * 1024))
<< " MB; pool capped at " << (total_capacity_bytes() / (1024 * 1024))
<< " MB across " << sub_pools_.size() << " sub-buffer(s) — growth disabled";
growth_disabled_ = true;
return false;
}
SubPool sp;
sp.buffer = buf;
sp.capacity = per_sub_buffer_capacity_;
sp.used = 0;
sp.free_ranges.push_back({0, per_sub_buffer_capacity_});
sub_pools_.push_back(std::move(sp));
qInfo().noquote().nospace()
<< "[wgpu pool] added sub-buffer " << (sub_pools_.size() - 1)
<< " (" << (per_sub_buffer_capacity_ / (1024 * 1024)) << " MB); pool total now "
<< (total_capacity_bytes() / (1024 * 1024)) << " MB";
return true;
}
WgpuBufferPool::Slice WgpuBufferPool::alloc(uint64_t size, uint64_t align) {
Slice out;
if (size == 0 || align == 0) return out;
// First-fit across all sub-buffers. When none fits, try to grow by
// adding another sub-buffer and retry once.
for (int attempt = 0; attempt < 2; ++attempt) {
for (size_t sp_idx = 0; sp_idx < sub_pools_.size(); ++sp_idx) {
SubPool& sp = sub_pools_[sp_idx];
for (size_t i = 0; i < sp.free_ranges.size(); ++i) {
const FreeRange& r = sp.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;
if (size > r.size - pad) continue;
const uint64_t post_off = aligned + size;
const uint64_t post_size = (r.offset + r.size) - post_off;
if (pad == 0 && post_size == 0) {
sp.free_ranges.erase(sp.free_ranges.begin() + i);
} else if (pad == 0) {
sp.free_ranges[i] = {post_off, post_size};
} else if (post_size == 0) {
sp.free_ranges[i] = {r.offset, pad};
} else {
sp.free_ranges[i] = {r.offset, pad};
sp.free_ranges.insert(sp.free_ranges.begin() + i + 1,
{post_off, post_size});
}
sp.used += size;
out.buffer = sp.buffer;
out.offset = aligned;
out.size = size;
out.sub_idx = int(sp_idx);
return out;
}
}
// Existing sub-buffers can't fit. Grow once before giving up.
if (attempt == 0) {
if (!addSubBuffer()) break;
}
used_ += size; // pre-/post-pad remain in free_ranges_, not used_
*out_offset = aligned;
return true;
}
return false;
return out;
}
void WgpuBufferPool::free(uint64_t offset, uint64_t size) {
if (size == 0) return;
assert(offset + size <= capacity_);
void WgpuBufferPool::free(const Slice& s) {
if (!s.valid()) return;
if (s.sub_idx < 0 || size_t(s.sub_idx) >= sub_pools_.size()) return;
SubPool& sp = sub_pools_[size_t(s.sub_idx)];
assert(s.offset + s.size <= sp.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;
while (i < sp.free_ranges.size() && sp.free_ranges[i].offset < s.offset) ++i;
sp.free_ranges.insert(sp.free_ranges.begin() + i, {s.offset, s.size});
sp.used -= s.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);
if (i + 1 < sp.free_ranges.size()
&& sp.free_ranges[i].offset + sp.free_ranges[i].size == sp.free_ranges[i + 1].offset) {
sp.free_ranges[i].size += sp.free_ranges[i + 1].size;
sp.free_ranges.erase(sp.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);
&& sp.free_ranges[i - 1].offset + sp.free_ranges[i - 1].size == sp.free_ranges[i].offset) {
sp.free_ranges[i - 1].size += sp.free_ranges[i].size;
sp.free_ranges.erase(sp.free_ranges.begin() + i);
}
}
uint64_t WgpuBufferPool::total_capacity_bytes() const {
uint64_t s = 0;
for (const auto& sp : sub_pools_) s += sp.capacity;
return s;
}
uint64_t WgpuBufferPool::total_used_bytes() const {
uint64_t s = 0;
for (const auto& sp : sub_pools_) s += sp.used;
return s;
}
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;
for (const auto& sp : sub_pools_) {
for (const auto& r : sp.free_ranges) {
if (r.size > m) m = r.size;
}
}
return m;
}
+80 -33
View File
@@ -23,64 +23,111 @@
#include <webgpu/webgpu.h>
#include <cstdint>
#include <string>
#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.
// Multi-sub-buffer sub-allocator. Owns one or more fixed-size WGPUBuffers
// and hands out byte ranges within them.
//
// Why multiple sub-buffers: WebGPU caps any single buffer at
// `limits.maxBufferSize`, which on wgpu-native + Vulkan tops out
// around 2 GB regardless of how much GPU memory exists. The GL backend
// reaches 4+ GB by letting the driver sub-allocate across many
// VkDeviceMemory blocks behind one logical GL buffer; here we do the
// same explicitly — `per_sub_buffer_capacity` (set from a probe) is the
// largest single buffer that allocates cleanly, and the pool grows
// lazily by adding more sub-buffers of that size when alloc demand
// exceeds what existing sub-buffers can fit.
//
// 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.
// Allocator: per-sub-buffer sorted free list with adjacent-range
// coalescing, first-fit across sub-buffers. Adequate for the chunk
// workload (a few hundred allocations of broadly similar size).
class WgpuBufferPool {
public:
// A handle to a previously-allocated range. Includes the underlying
// sub-buffer so callers (bind-group builders, queueWriteBuffer) can
// address the correct buffer; includes sub_idx so free() knows which
// sub-pool's bookkeeping to update.
struct Slice {
WGPUBuffer buffer = nullptr;
uint64_t offset = 0;
uint64_t size = 0;
int sub_idx = -1;
bool valid() const { return size > 0 && buffer != nullptr; }
};
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);
// Record the device + usage + sub-buffer size. Does NOT allocate any
// sub-buffer here — that happens lazily on first alloc(). `instance`
// is needed so the pool can drain async PopErrorScope events when
// probing whether a new sub-buffer can be created.
void configure(WGPUInstance instance, WGPUDevice device,
WGPUBufferUsage usage,
uint64_t per_sub_buffer_capacity,
const char* label_prefix);
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);
// Tries every existing sub-buffer; if none can fit, attempts to add
// a new sub-buffer at per_sub_buffer_capacity. Returns an invalid
// Slice (size == 0) if no sub-buffer fits and growth fails.
Slice alloc(uint64_t size, uint64_t align);
// Return a slice to the free list. Coalesces with adjacent free
// ranges in the same sub-buffer.
void free(const Slice& s);
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;
// Tally summed across every sub-buffer.
uint64_t total_capacity_bytes() const;
uint64_t total_used_bytes() const;
uint64_t total_free_bytes() const { return total_capacity_bytes() - total_used_bytes(); }
// Largest contiguous free run across all sub-buffers. Useful for
// evictor heuristics ("can this allocation even fit, ever, without
// eviction or growth?").
uint64_t largest_free_run_bytes() const;
// Per-sub-buffer count, for diagnostics / logging.
size_t sub_buffer_count() const { return sub_pools_.size(); }
uint64_t per_sub_buffer_capacity_bytes() const { return per_sub_buffer_capacity_; }
// Whether the pool can still attempt to add a sub-buffer. Flips to
// false the first time addSubBuffer is refused — eviction callers
// need this to know whether a future alloc could rescue them by
// growing, or whether eviction is the only path.
bool can_grow() const { return !growth_disabled_ && per_sub_buffer_capacity_ > 0; }
private:
struct FreeRange { uint64_t offset; uint64_t size; };
struct SubPool {
WGPUBuffer buffer = nullptr;
uint64_t capacity = 0;
uint64_t used = 0;
std::vector<FreeRange> free_ranges;
};
// Sorted by offset, non-overlapping, non-adjacent (coalesced on
// every free). Empty when the pool is fully allocated.
std::vector<FreeRange> free_ranges_;
// Append a new sub-buffer at per_sub_buffer_capacity_, wrapped in an
// OOM/Validation error scope so a failed allocation doesn't take the
// device down. Returns false on driver OOM (caller should treat as
// "pool is at its hardware-limited maximum"). After a failure, sets
// growth_disabled_ so subsequent allocs don't keep retrying (and
// log-spamming) at the same size that just refused.
bool addSubBuffer();
WGPUBuffer buffer_ = nullptr;
uint64_t capacity_ = 0;
uint64_t used_ = 0;
std::vector<SubPool> sub_pools_;
WGPUInstance instance_ = nullptr;
WGPUDevice device_ = nullptr;
WGPUBufferUsage usage_ = 0;
uint64_t per_sub_buffer_capacity_ = 0;
bool growth_disabled_ = false;
std::string label_prefix_;
};
#endif // WGPUBUFFERPOOL_H
+17 -12
View File
@@ -30,6 +30,7 @@
#include "BvhAccel.h"
#include "InstancedGeometry.h"
#include "WgpuBufferPool.h"
// Per-model wgpu state. Mirrors the GL backend's ModelGpuData but with
// wgpu handles. Stage 2 only allocates and uploads the four core buffers;
@@ -77,16 +78,13 @@ struct WgpuModelGpuData {
// needs them. Non-streaming path always sets is_resident=true and
// populates pool ranges at applyCachedModel time.
struct Chunk {
// 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;
// Pool-allocated vertex + index bytes. Both slices land in the
// shared WgpuViewportWindow::pool_; the slice tells us which
// sub-buffer they live in (the pool may span multiple sub-buffers
// when scenes exceed wgpu's single-buffer cap). When non-resident,
// both .size are 0.
WgpuBufferPool::Slice vertex_slice;
WgpuBufferPool::Slice index_slice;
WGPUBuffer visible_draws_buffer = nullptr;
WGPUBuffer prefix_sums_buffer = nullptr;
@@ -98,8 +96,17 @@ struct WgpuModelGpuData {
size_t prefix_sums_capacity = 0;
// Per-frame, populated by cullModelCpuCompute and consumed by render().
// total_visible_* are post-frustum + contribution + HiZ — used to size
// the actual draw call. frustum_visible_count is bumped immediately
// after the frustum check (before contribution / HiZ), and is what
// driveStreamingLoads keys on for residency decisions. Streaming
// must NOT use the HiZ-post counters: HiZ visibility flips
// frame-to-frame as occluders shift, which would otherwise thrash
// the loader (evict-then-reload every frame even with the camera
// stationary, killing FPS and producing visible flicker).
uint32_t total_visible_vertices = 0;
uint32_t total_visible_draws = 0;
uint32_t frustum_visible_count = 0;
std::vector<VisibleDrawGpu> visible_draws_scratch;
std::vector<uint32_t> prefix_sums_scratch;
@@ -190,8 +197,6 @@ struct WgpuModelGpuData {
bool hidden = false;
};
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.
+232 -117
View File
@@ -135,15 +135,13 @@ static WGPUBuffer createBufferWithData(WGPUDevice device, WGPUQueue queue,
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.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.vertex_slice.valid()) {
pool.free(c.vertex_slice);
c.vertex_slice = {};
}
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.index_slice.valid()) {
pool.free(c.index_slice);
c.index_slice = {};
}
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; }
@@ -926,17 +924,18 @@ void WgpuViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) {
// 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)) {
c.vertex_slice = pool_.alloc(plan.byte_count, 256);
if (!c.vertex_slice.valid()) {
qWarning().noquote().nospace()
<< "[wgpu] pool OOM: chunk " << ci << " needed "
<< plan.byte_count << " B for vertices, pool free="
<< pool_.free_bytes() << " B; aborting model load";
<< pool_.total_free_bytes() << " B across "
<< pool_.sub_buffer_count() << " sub-buffer(s); aborting model load";
releaseWgpuModelGpuData(m, pool_);
return;
}
c.pool_vertex_size = plan.byte_count;
wgpuQueueWriteBuffer(queue_, pool_.buffer(),
c.pool_vertex_offset,
wgpuQueueWriteBuffer(queue_, c.vertex_slice.buffer,
c.vertex_slice.offset,
data.vertices.data() + plan.source_byte_offset,
plan.byte_count);
m.vram_bytes_vbo += plan.byte_count;
@@ -944,17 +943,18 @@ void WgpuViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) {
// 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) {
if (!pool_.alloc(chunk_index_bytes, 256, &c.pool_index_offset)) {
c.index_slice = pool_.alloc(chunk_index_bytes, 256);
if (!c.index_slice.valid()) {
qWarning().noquote().nospace()
<< "[wgpu] pool OOM: chunk " << ci << " needed "
<< chunk_index_bytes << " B for indices, pool free="
<< pool_.free_bytes() << " B; aborting model load";
<< pool_.total_free_bytes() << " B across "
<< pool_.sub_buffer_count() << " sub-buffer(s); aborting model load";
releaseWgpuModelGpuData(m, pool_);
return;
}
c.pool_index_size = chunk_index_bytes;
wgpuQueueWriteBuffer(queue_, pool_.buffer(),
c.pool_index_offset,
wgpuQueueWriteBuffer(queue_, c.index_slice.buffer,
c.index_slice.offset,
data.indices.data() + plan.index_first_u32,
chunk_index_bytes);
m.vram_bytes_ebo += chunk_index_bytes;
@@ -1351,8 +1351,14 @@ bool WgpuViewportWindow::probeAndCreatePool() {
wgpuDevicePushErrorScope(device_, WGPUErrorFilter_Validation);
wgpuDevicePushErrorScope(device_, WGPUErrorFilter_OutOfMemory);
const bool init_ok = pool_.init(device_, try_size, pool_usage,
"ifcviewer-wgpu.streaming_pool");
// Test allocation. If it survives both scopes, this size works
// and becomes the pool's per-sub-buffer capacity.
WGPUBufferDescriptor desc = {};
desc.usage = pool_usage;
desc.size = try_size;
desc.label.data = "ifcviewer-wgpu.pool_probe";
desc.label.length = std::strlen("ifcviewer-wgpu.pool_probe");
WGPUBuffer probe_buf = wgpuDeviceCreateBuffer(device_, &desc);
struct PopResult { bool done = false; bool error = false; };
auto pop = [&](PopResult& pr) {
@@ -1372,17 +1378,21 @@ bool WgpuViewportWindow::probeAndCreatePool() {
pop(oom_pop);
pop(validation_pop);
if (init_ok && !oom_pop.error && !validation_pop.error) {
if (probe_buf) wgpuBufferRelease(probe_buf);
if (probe_buf && !oom_pop.error && !validation_pop.error) {
// Per-sub-buffer capacity locked in; the pool can grow
// beyond this by allocating more sub-buffers of the same
// size on demand (up to whatever the driver lets us total).
pool_.configure(instance_, device_, pool_usage, try_size,
"ifcviewer-wgpu.pool");
qInfo().noquote()
<< "wgpu: streaming pool capacity ="
<< "wgpu: pool per-sub-buffer capacity ="
<< (try_size / (1024 * 1024)) << "MB"
<< "(device maxBufferSize ="
<< (device_limits.maxBufferSize / (1024 * 1024)) << "MB)";
<< (device_limits.maxBufferSize / (1024 * 1024))
<< "MB); pool will grow on demand";
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;
}
@@ -2467,6 +2477,8 @@ void WgpuViewportWindow::setBenchmarkFrames(int frames) {
bench_total_ = std::max(0, frames);
bench_count_ = 0;
bench_yaw_start_ = camera_yaw_deg_;
bench_warm_streak_ = 0;
bench_warm_frames_total_ = 0;
bench_frame_ms_.clear();
bench_frame_ms_.reserve(size_t(bench_total_));
if (isExposed() && bench_total_ > 0) requestUpdate();
@@ -2495,6 +2507,7 @@ uint32_t WgpuViewportWindow::cullModelCpuCompute(WgpuModelGpuData& m,
c.prefix_sums_scratch.push_back(0);
c.total_visible_vertices = 0;
c.total_visible_draws = 0;
c.frustum_visible_count = 0;
}
// Per-chunk running vertex count (used to populate that chunk's prefix
@@ -2514,6 +2527,12 @@ uint32_t WgpuViewportWindow::cullModelCpuCompute(WgpuModelGpuData& m,
// necessarily this one.
if (!aabbInFrustum(inst.world_aabb_min, inst.world_aabb_max, planes)) return;
// Bump the chunk's frustum-only counter before contribution / HiZ.
// This is the signal driveStreamingLoads keys residency on — stable
// across frames when the camera doesn't move, so the loader doesn't
// thrash on HiZ visibility flicker.
++m.chunks[m.mesh_chunk_idx[inst.mesh_id]].frustum_visible_count;
const MeshInfo& mesh = m.meshes[inst.mesh_id];
// Projected bounding-sphere radius in pixels — shared between the
@@ -2778,10 +2797,19 @@ void WgpuViewportWindow::render() {
}
}
// Stop the cull-only timer before streaming, so the benchmark
// attribution doesn't lump disk I/O into "cull".
const double cull_only_ms = double(cull_timer.nsecsElapsed()) / 1e6;
// Streaming: bring non-resident chunks that the cull just flagged
// visible into residency. Runs before draw encoding so newly-loaded
// chunks render the same frame.
// chunks render the same frame. Timed separately because synchronous
// disk reads here can dwarf the cull itself on big scenes.
QElapsedTimer stream_timer;
if (bench_total_ > 0) stream_timer.start();
driveStreamingLoads();
const double stream_ms = (bench_total_ > 0)
? double(stream_timer.nsecsElapsed()) / 1e6 : 0.0;
// Snapshot camera state for next frame's motion detection.
prev_camera_target_[0] = camera_target_[0];
@@ -2792,7 +2820,8 @@ void WgpuViewportWindow::render() {
prev_camera_pitch_deg_ = camera_pitch_deg_;
has_prev_camera_ = true;
if (bench_total_ > 0 && bench_count_ >= bench_warmup_) {
bench_cull_ms_total_ += double(cull_timer.nsecsElapsed()) / 1e6;
bench_cull_ms_total_ += cull_only_ms;
bench_stream_ms_total_ += stream_ms;
}
WGPUCommandEncoder enc = wgpuDeviceCreateCommandEncoder(device_, nullptr);
@@ -2992,6 +3021,39 @@ void WgpuViewportWindow::render() {
// ---- Benchmark integration + auto-quit -------------------------------
if (bench_total_ > 0) {
// Cold-load gate: don't start the orbit sweep until streaming has
// converged for a few consecutive frames. Converged = 0 loads.
// bench_warm_done_ latches on first satisfaction so the gate is
// evaluated only during warmup, not every frame after.
if (!bench_warm_done_) {
constexpr int CONVERGE_FRAMES_REQUIRED = 5;
constexpr int MAX_WARM_FRAMES = 600;
if (streaming_loads_this_frame_ > 0) {
bench_warm_streak_ = 0;
} else {
++bench_warm_streak_;
}
++bench_warm_frames_total_;
const bool converged = bench_warm_streak_ >= CONVERGE_FRAMES_REQUIRED;
const bool timed_out = bench_warm_frames_total_ >= MAX_WARM_FRAMES;
if (converged) {
qInfo().noquote().nospace()
<< "[bench warm] converged after "
<< bench_warm_frames_total_ << " frames";
bench_warm_done_ = true;
} else if (timed_out) {
qWarning().noquote().nospace()
<< "[bench warm] timed out after " << bench_warm_frames_total_
<< " frames without convergence (last loads="
<< streaming_loads_this_frame_
<< "); starting bench anyway";
bench_warm_done_ = true;
} else {
requestUpdate();
return;
}
}
const float ms = float(frame_timer.nsecsElapsed()) / 1e6f;
// Warm-up frames are dropped from the sample. The yaw advance starts
@@ -3014,8 +3076,9 @@ void WgpuViewportWindow::render() {
total_meshes += mo.mesh_count;
}
const double mb = 1.0 / (1024.0 * 1024.0);
const double cull_ms = bench_cull_ms_total_
/ double(std::max(1, bench_count_ - bench_warmup_ + 1));
const double avg_n = double(std::max(1, bench_count_ - bench_warmup_ + 1));
const double cull_ms = bench_cull_ms_total_ / avg_n;
const double stream_ms = bench_stream_ms_total_ / avg_n;
qInfo().noquote().nospace()
<< "[frame] " << QString::number(ms > 0 ? 1000.0f / ms : 0.0f, 'f', 1) << " fps"
<< " " << QString::number(ms, 'f', 2) << " ms"
@@ -3025,6 +3088,7 @@ void WgpuViewportWindow::render() {
<< " sub_draws " << last_sub_draws_
<< " hiz_rej " << hiz_reject_count_
<< " cull[wall " << QString::number(cull_ms, 'f', 2) << "]ms"
<< " stream[" << QString::number(stream_ms, 'f', 2) << "]ms"
<< " vram " << QString::number(double(total_vbo + total_ebo + total_ssbo) * mb, 'f', 1) << "MB"
<< " (vbo " << QString::number(double(total_vbo) * mb, 'f', 1)
<< " + ebo " << QString::number(double(total_ebo) * mb, 'f', 1)
@@ -3071,6 +3135,7 @@ void WgpuViewportWindow::render() {
const double n = double(std::max(1, bench_total_));
qInfo().noquote().nospace()
<< " per-frame avg ms: cull=" << bench_cull_ms_total_ / n
<< " stream=" << bench_stream_ms_total_ / n
<< " hiz_readback=" << bench_hiz_readback_ms_total_ / n
<< " hiz=" << (hiz_enabled_ ? "on" : "off");
qInfo().noquote() << "=== END BENCHMARK ===\n";
@@ -3284,20 +3349,22 @@ void WgpuViewportWindow::buildChunkBindGroup(WgpuModelGpuData& m, size_t chunk_i
wgpuBindGroupRelease(c.bind_group);
c.bind_group = nullptr;
}
if (c.pool_vertex_size == 0 || c.pool_index_size == 0
if (!c.vertex_slice.valid() || !c.index_slice.valid()
|| !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).
// vertices and indices live in the shared pool. Each slice carries
// the specific sub-buffer it landed in (the pool may span several
// when scenes exceed wgpu's single-buffer cap). 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 = pool_.buffer();
entries[0].offset = c.pool_vertex_offset;
entries[0].size = c.pool_vertex_size;
entries[0].buffer = c.vertex_slice.buffer;
entries[0].offset = c.vertex_slice.offset;
entries[0].size = c.vertex_slice.size;
entries[1].binding = 1;
entries[1].buffer = m.mesh_storage;
entries[1].size = WGPU_WHOLE_SIZE;
@@ -3305,9 +3372,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 = pool_.buffer();
entries[3].offset = c.pool_index_offset;
entries[3].size = c.pool_index_size;
entries[3].buffer = c.index_slice.buffer;
entries[3].offset = c.index_slice.offset;
entries[3].size = c.index_slice.size;
entries[4].binding = 4;
entries[4].buffer = c.visible_draws_buffer;
entries[4].size = WGPU_WHOLE_SIZE;
@@ -3347,15 +3414,15 @@ bool WgpuViewportWindow::loadChunkBytesAndUploadGpu(WgpuModelGpuData& m, size_t
return false;
}
// Claim a pool range for the vertex bytes and upload.
if (!pool_.alloc(vbytes.size(), 256, &c.pool_vertex_offset)) {
c.vertex_slice = pool_.alloc(vbytes.size(), 256);
if (!c.vertex_slice.valid()) {
// 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,
wgpuQueueWriteBuffer(queue_, c.vertex_slice.buffer,
c.vertex_slice.offset,
vbytes.data(), vbytes.size());
m.vram_bytes_vbo += vbytes.size();
@@ -3374,23 +3441,21 @@ bool WgpuViewportWindow::loadChunkBytesAndUploadGpu(WgpuModelGpuData& m, size_t
<< " (first=" << c.index_first_u32
<< " count=" << c.index_count << ")";
// 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();
pool_.free(c.vertex_slice);
m.vram_bytes_vbo -= c.vertex_slice.size;
c.vertex_slice = {};
return false;
}
const size_t ibytes = idx.size() * sizeof(uint32_t);
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();
c.index_slice = pool_.alloc(ibytes, 256);
if (!c.index_slice.valid()) {
pool_.free(c.vertex_slice);
m.vram_bytes_vbo -= c.vertex_slice.size;
c.vertex_slice = {};
return false;
}
c.pool_index_size = ibytes;
wgpuQueueWriteBuffer(queue_, pool_.buffer(),
c.pool_index_offset,
wgpuQueueWriteBuffer(queue_, c.index_slice.buffer,
c.index_slice.offset,
idx.data(), ibytes);
m.vram_bytes_ebo += ibytes;
}
@@ -3409,17 +3474,15 @@ void WgpuViewportWindow::unloadChunk(WgpuModelGpuData& m, size_t chunk_idx) {
wgpuBindGroupRelease(c.bind_group);
c.bind_group = nullptr;
}
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.vertex_slice.valid()) {
m.vram_bytes_vbo -= c.vertex_slice.size;
pool_.free(c.vertex_slice);
c.vertex_slice = {};
}
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;
if (c.index_slice.valid()) {
m.vram_bytes_ebo -= c.index_slice.size;
pool_.free(c.index_slice);
c.index_slice = {};
}
// 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
@@ -3438,11 +3501,14 @@ void WgpuViewportWindow::driveStreamingLoads() {
// Refresh LRU stamps for every resident chunk the cull just touched.
// Doing this before the load loop means newly-loaded chunks (which
// get stamped inside the load path) and already-resident-visible
// chunks share a single coherent timeline.
// chunks share a single coherent timeline. We stamp on FRUSTUM
// visibility, not the HiZ-post total_visible_draws — same reason as
// residency: HiZ flicker would otherwise un-stamp chunks that should
// stay resident.
for (auto& [mid, m] : models_gpu_) {
if (m.hidden) continue;
for (auto& c : m.chunks) {
if (c.is_resident && c.total_visible_draws > 0) {
if (c.is_resident && c.frustum_visible_count > 0) {
c.last_visible_frame_idx = streaming_frame_idx_;
}
}
@@ -3477,11 +3543,20 @@ void WgpuViewportWindow::driveStreamingLoads() {
// 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.
// may fit total-free-bytes but not largest_free_run_bytes(). With
// multi-sub-buffer pools, an alloc can also succeed by growing the
// pool (adding a new sub-buffer at per_sub_buffer_capacity_bytes()),
// so a chunk also "fits" if it's smaller than one fresh sub-buffer.
// The actual alloc handles the growth attempt; this predicate only
// avoids wasted evict-then-fail loops.
auto pool_can_fit = [&](uint64_t bytes) -> bool {
return pool_.largest_free_run_bytes() >= bytes;
if (pool_.largest_free_run_bytes() >= bytes) return true;
// Growth might still rescue us — but only if growth hasn't been
// refused at this size already. After a refusal, eviction is the
// sole path; the eviction loop must run until largest_free_run
// catches up.
if (pool_.can_grow() && pool_.per_sub_buffer_capacity_bytes() >= bytes) return true;
return false;
};
// Phase-1 evictor: drop the LRU non-visible resident chunk. Skips
@@ -3514,10 +3589,19 @@ void WgpuViewportWindow::driveStreamingLoads() {
// 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 pool.
// Require a meaningful distance gap before evicting. Without this,
// chunks clustered at similar distances (e.g. three chunks all
// ~370 m from the camera) oscillate forever: each frame the "closest
// candidate" is fractionally closer than some resident, triggering a
// swap that doesn't actually improve the picture. 21% in dist² ≈
// 10% in linear distance — a 370 m chunk only evicts a >407 m
// resident, not a 371 m one.
constexpr float EVICT_DIST2_RATIO = 1.21f;
auto evict_farthest_than = [&](float candidate_dist2) -> bool {
const float threshold_dist2 = candidate_dist2 * EVICT_DIST2_RATIO;
WgpuModelGpuData* victim_m = nullptr;
size_t victim_ci = 0;
float victim_dist2 = candidate_dist2;
float victim_dist2 = threshold_dist2;
for (auto& [mid, m] : models_gpu_) {
for (size_t ci = 0; ci < m.chunks.size(); ++ci) {
auto& c = m.chunks[ci];
@@ -3535,56 +3619,87 @@ void WgpuViewportWindow::driveStreamingLoads() {
return true;
};
// Gather candidates: every non-resident frustum-visible chunk. Sort
// by distance (closest first) so processing converges monotonically —
// each successful swap replaces a far resident with a closer
// candidate, and when the next candidate is farther than every
// remaining resident, we stop. Without sorting, the load loop
// visits candidates in arbitrary (model/chunk-id) order, which
// creates an infinite swap cycle on scenes where the frustum-visible
// set exceeds pool capacity: each frame loads 4 random candidates
// and evicts 4 random residents, getting nowhere.
struct Candidate { WgpuModelGpuData* m; size_t ci; float dist2; };
std::vector<Candidate> candidates;
candidates.reserve(64);
for (auto& [mid, m] : models_gpu_) {
if (m.streaming_file_path.empty() || m.hidden) continue;
for (size_t ci = 0; ci < m.chunks.size(); ++ci) {
auto& c = m.chunks[ci];
if (c.is_resident) continue;
// Only load chunks the cull just marked visible — keeps fetch
// priority aligned with what the camera actually sees.
if (c.total_visible_draws == 0) continue;
if (loads >= MAX_STREAMING_LOADS_PER_FRAME) {
more_pending = true;
break;
}
// Make room. Phase 1: drop LRU non-visible. Phase 2: if still
// 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 (!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 (!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;
continue;
}
if (loadChunkBytesAndUploadGpu(m, ci)) {
++loads;
// Stamp the just-loaded chunk so eviction this frame
// can't immediately yank it back out.
c.last_visible_frame_idx = streaming_frame_idx_;
}
if (c.is_resident) continue;
if (c.frustum_visible_count == 0) continue;
candidates.push_back({&m, ci, chunk_center_dist2(c)});
}
if (more_pending && loads >= MAX_STREAMING_LOADS_PER_FRAME) break;
}
// Keep the frame loop running until all visible chunks are resident
// (or the budget definitively prevents that, in which case residency
// converges to the closest visible chunks that fit).
if (more_pending) requestUpdate();
std::sort(candidates.begin(), candidates.end(),
[](const Candidate& a, const Candidate& b) {
return a.dist2 < b.dist2;
});
for (const Candidate& cand : candidates) {
if (loads >= MAX_STREAMING_LOADS_PER_FRAME) {
more_pending = true;
break;
}
auto& c = cand.m->chunks[cand.ci];
// Make room. Phase 1: drop LRU non-visible (chunks resident from
// a previous viewpoint that aren't frustum-visible now). Phase 2:
// drop the farthest-from-eye resident that's strictly farther
// than this candidate. With distance-sorted candidates, phase 2
// monotonically converges — once the next candidate is farther
// than every resident, evict_farthest_than fails for it and all
// subsequent (even farther) candidates, and we stop.
const uint64_t need = c.vertex_byte_size
+ c.index_count * sizeof(uint32_t);
while (!pool_can_fit(c.vertex_byte_size)
|| (c.index_count > 0
&& !pool_can_fit(c.index_count * sizeof(uint32_t)))
|| pool_.total_free_bytes() < need) {
if (evict_one_lru()) continue;
if (evict_farthest_than(cand.dist2)) continue;
break;
}
if (!pool_can_fit(c.vertex_byte_size)
|| (c.index_count > 0
&& !pool_can_fit(c.index_count * sizeof(uint32_t)))) {
// This candidate doesn't fit. Sorted-by-distance means every
// remaining candidate is farther, so none of them will fit
// either — bail out of the whole loop rather than waste
// iterations probing each one.
more_pending = true;
break;
}
if (loadChunkBytesAndUploadGpu(*cand.m, cand.ci)) {
++loads;
c.last_visible_frame_idx = streaming_frame_idx_;
}
}
// Keep the frame loop running only while we're making progress.
// When loads == 0 (whether because everything fits or because the
// pool is at its hardware cap and the rest of the visible set
// can't fit), the loader has converged — let the renderer go idle
// until something actually changes (camera move, model add/remove
// triggers their own requestUpdate). Spinning here would burn the
// CPU forever on scenes whose visible set exceeds the pool.
if (loads > 0) requestUpdate();
// Surface per-frame activity for the bench harness to gate the
// orbit sweep against cold-load. We only export loads — more_pending
// can stay true forever in the can't-fit case and is not a "done"
// signal.
streaming_loads_this_frame_ = loads;
streaming_more_pending_ = more_pending;
}
// -----------------------------------------------------------------------------
+16
View File
@@ -422,6 +422,21 @@ public:
// the old hand-picked streaming_vram_budget_bytes_ knob entirely.
WgpuBufferPool pool_;
// Per-frame streaming activity, written by driveStreamingLoads,
// consumed by the benchmark harness to delay the orbit sweep until
// the initial cold-load settles. `loads` = chunks brought resident
// this frame; `more_pending` = the loader wants to keep going.
int streaming_loads_this_frame_ = 0;
bool streaming_more_pending_ = false;
// Bench warm-phase counters. We wait until N consecutive frames with
// 0 loads (convergence) before starting the orbit sweep, capped by
// MAX_WARM_FRAMES so chronically thrashing scenes still produce
// numbers. Both reset implicitly per bench run via setBenchmarkFrames.
int bench_warm_streak_ = 0;
int bench_warm_frames_total_ = 0;
bool bench_warm_done_ = false; // latch: once true, gate is open for this run
private:
// Switch to LOD1 when an instance's projected bounding-sphere radius
@@ -495,6 +510,7 @@ private:
// distinct slice of render() so we can attribute frame cost. Totals
// across the timed window are divided by bench_total_ on print.
double bench_cull_ms_total_ = 0.0;
double bench_stream_ms_total_ = 0.0; // driveStreamingLoads only
double bench_hiz_readback_ms_total_ = 0.0;
double bench_submit_ms_total_ = 0.0;
};