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