From c3a55d7f7ba9f04eab1b645603cbbe0bfcbd02f8 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 28 May 2026 14:08:16 +1000 Subject: [PATCH] wgpu streaming: multi-pool growth, frustum-only residency, sorted convergence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/ifcviewer-wgpu/WgpuBufferPool.cpp | 238 ++++++++++----- src/ifcviewer-wgpu/WgpuBufferPool.h | 113 +++++-- src/ifcviewer-wgpu/WgpuModelGpuData.h | 29 +- src/ifcviewer-wgpu/WgpuViewportWindow.cpp | 349 ++++++++++++++-------- src/ifcviewer-wgpu/WgpuViewportWindow.h | 16 + 5 files changed, 507 insertions(+), 238 deletions(-) diff --git a/src/ifcviewer-wgpu/WgpuBufferPool.cpp b/src/ifcviewer-wgpu/WgpuBufferPool.cpp index 72d1cb5662..46233ab3fe 100644 --- a/src/ifcviewer-wgpu/WgpuBufferPool.cpp +++ b/src/ifcviewer-wgpu/WgpuBufferPool.cpp @@ -19,6 +19,8 @@ #include "WgpuBufferPool.h" +#include + #include #include @@ -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(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 (!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; } diff --git a/src/ifcviewer-wgpu/WgpuBufferPool.h b/src/ifcviewer-wgpu/WgpuBufferPool.h index 1b4499436b..d0979de0e1 100644 --- a/src/ifcviewer-wgpu/WgpuBufferPool.h +++ b/src/ifcviewer-wgpu/WgpuBufferPool.h @@ -23,64 +23,111 @@ #include #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. +// 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 free_ranges; + }; - // Sorted by offset, non-overlapping, non-adjacent (coalesced on - // every free). Empty when the pool is fully allocated. - std::vector 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 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 diff --git a/src/ifcviewer-wgpu/WgpuModelGpuData.h b/src/ifcviewer-wgpu/WgpuModelGpuData.h index bebd57d435..aff05c0835 100644 --- a/src/ifcviewer-wgpu/WgpuModelGpuData.h +++ b/src/ifcviewer-wgpu/WgpuModelGpuData.h @@ -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 visible_draws_scratch; std::vector 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. diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp index b5e40922af..d59de8101f 100644 --- a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp +++ b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp @@ -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 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; } // ----------------------------------------------------------------------------- diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.h b/src/ifcviewer-wgpu/WgpuViewportWindow.h index da3642e4a1..10d1bc7e5e 100644 --- a/src/ifcviewer-wgpu/WgpuViewportWindow.h +++ b/src/ifcviewer-wgpu/WgpuViewportWindow.h @@ -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; };