mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-28 15:53:00 +00:00
ifcviewer: budget the geometry cache and make required allocations fallible
Loading enough models drove the chunk pool to the driver's refusal point, after which the first click aborted: the pick attachments are allocated lazily, wgpu-native reported their OOM as a validation error nobody observed, and the invalid views reached wgpuQueueSubmit, which panics across the FFI boundary. Two policy defects compounding: the cache was allowed to take the last byte, and nothing but the pool's own growth was treated as fallible. GPU memory is now two tiers. Required allocations (per-pixel attachments, a model's metadata buffers, readback staging) are eager, deterministic and fallible; the chunk pool is an elastic cache that grows only to a budget and yields whenever a required allocation fails. - GpuBudget (pure, unit-tested): desktop derives the budget from the driver's free-memory report minus a reserve for the attachments at 4K; web keeps the wasm-heap cap; either lowers it on pressure. The budget's source differs per platform, the mechanism does not. - GpuAllocScope: the OOM/Validation error-scope dance in one place, synchronous on wgpu-native, provisional on Dawn-web. BufferPool's inline copy now uses it. - BufferPool::shrinkToCapacity releases whole sub-buffers newest-first after the owner empties them; growth clamps to the budget instead of overshooting. - ViewportCore::allocateRequired runs any required creation under a scope and, on failure, lowers the budget, evicts and releases cache sub-buffers, waits for the device to reclaim them, and retries until it fits or the cache is at its floor. Pick attachments are created with the other attachments in configureSurface; render() skips a frame rather than submit invalid views; a model whose buffers cannot fit is not loaded instead of aborting. Verified on a 4 GB GeForce: the pool clamps itself at the derived budget (256+256+67 MB for a 579 MB budget) and, in a standalone check against the real device, a pool grown to the driver's refusal point observes a failed required allocation, releases 320 MB and succeeds on retry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -566,7 +566,11 @@ void MainWindow::setupLoader() {
|
||||
.arg(stats.total_triangles)
|
||||
.arg(stats.gl_draw_calls)
|
||||
.arg(double(stats.vram_used_bytes) * mb, 0, 'f', 0)
|
||||
.arg(double(stats.vram_capacity_bytes) * mb, 0, 'f', 0);
|
||||
// Used against what the cache may grow to; the pool's
|
||||
// momentary capacity only until the budget is known.
|
||||
.arg(double(stats.vram_budget_bytes > 0
|
||||
? stats.vram_budget_bytes
|
||||
: stats.vram_capacity_bytes) * mb, 0, 'f', 0);
|
||||
// Device total is only known when a driver backend answered.
|
||||
if (stats.device_vram_total_bytes > 0) {
|
||||
text += QString(" | Device %1/%2 MB")
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
********************************************************************************/
|
||||
|
||||
#include "BufferPool.h"
|
||||
#include "GpuAllocScope.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
@@ -59,17 +61,22 @@ bool BufferPool::addSubBuffer() {
|
||||
if (!device_ || per_sub_buffer_capacity_ == 0) return false;
|
||||
if (growth_disabled_) return false;
|
||||
|
||||
// 64 MB floor: smaller sub-buffers aren't worth the per-allocation
|
||||
// bookkeeping cost (one bind group per chunk, free-list overhead).
|
||||
// If the driver won't grant even 64 MB the pool is genuinely at
|
||||
// its ceiling; growth_disabled_ latches and future grow attempts
|
||||
// skip the doomed retry.
|
||||
constexpr uint64_t MIN_SUB_BUFFER_BYTES = 64ull * 1024 * 1024;
|
||||
uint64_t try_size = last_growth_size_ > 0
|
||||
? last_growth_size_
|
||||
: per_sub_buffer_capacity_;
|
||||
if (try_size < MIN_SUB_BUFFER_BYTES) try_size = MIN_SUB_BUFFER_BYTES;
|
||||
|
||||
// Never overshoot the budget: the cache's whole job is to stop short
|
||||
// of what the required tier needs, and a sub-buffer that straddles
|
||||
// the line would take exactly the bytes it was told to leave. A
|
||||
// budget refusal is not a driver refusal, so growth_disabled_ is not
|
||||
// latched — the budget is the (already lower) ceiling.
|
||||
if (max_total_capacity_bytes_ > 0) {
|
||||
const uint64_t total = total_capacity_bytes();
|
||||
if (total + MIN_SUB_BUFFER_BYTES > max_total_capacity_bytes_) return false;
|
||||
try_size = std::min(try_size, max_total_capacity_bytes_ - total);
|
||||
}
|
||||
|
||||
#if defined(__EMSCRIPTEN__)
|
||||
// Web can't synchronously learn whether createBuffer OOM'd: the
|
||||
// desktop spin-wait that drains PopErrorScope would block the JS
|
||||
@@ -94,7 +101,7 @@ bool BufferPool::addSubBuffer() {
|
||||
desc.label.data = label;
|
||||
desc.label.length = std::strlen(label);
|
||||
|
||||
wgpuDevicePushErrorScope(device_, WGPUErrorFilter_OutOfMemory);
|
||||
GpuAllocScope scope(instance_, device_);
|
||||
WGPUBuffer buf = wgpuDeviceCreateBuffer(device_, &desc);
|
||||
|
||||
SubPool sp;
|
||||
@@ -107,15 +114,7 @@ bool BufferPool::addSubBuffer() {
|
||||
last_growth_size_ = try_size;
|
||||
growth_pending_ = true;
|
||||
|
||||
WGPUPopErrorScopeCallbackInfo pcb = {};
|
||||
pcb.mode = WGPUCallbackMode_AllowSpontaneous;
|
||||
pcb.callback = [](WGPUPopErrorScopeStatus, WGPUErrorType type,
|
||||
WGPUStringView, void* ud1, void* /*ud2*/) {
|
||||
static_cast<BufferPool*>(ud1)->resolveProvisionalGrowth(
|
||||
type != WGPUErrorType_NoError);
|
||||
};
|
||||
pcb.userdata1 = this;
|
||||
wgpuDevicePopErrorScope(device_, pcb);
|
||||
scope.end([this](bool ok) { resolveProvisionalGrowth(!ok); });
|
||||
|
||||
// No usable space yet: the provisional sub-buffer isn't handed out
|
||||
// until validated. alloc fails this frame and retries on a later one.
|
||||
@@ -132,31 +131,10 @@ bool BufferPool::addSubBuffer() {
|
||||
desc.label.data = label;
|
||||
desc.label.length = std::strlen(label);
|
||||
|
||||
// wgpu-native classifies "Not enough memory left" as Validation,
|
||||
// not OutOfMemory. Nested scopes: OOM inner, Validation outer.
|
||||
wgpuDevicePushErrorScope(device_, WGPUErrorFilter_Validation);
|
||||
wgpuDevicePushErrorScope(device_, WGPUErrorFilter_OutOfMemory);
|
||||
|
||||
GpuAllocScope scope(instance_, device_);
|
||||
WGPUBuffer buf = wgpuDeviceCreateBuffer(device_, &desc);
|
||||
|
||||
struct PopResult { bool done = false; bool error = false; };
|
||||
auto pop = [&](PopResult& pop_result) {
|
||||
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 = &pop_result;
|
||||
wgpuDevicePopErrorScope(device_, pcb);
|
||||
while (!pop_result.done) wgpuInstanceProcessEvents(instance_);
|
||||
};
|
||||
PopResult oom_pop, validation_pop;
|
||||
pop(oom_pop);
|
||||
pop(validation_pop);
|
||||
const bool ok = buf && !oom_pop.error && !validation_pop.error;
|
||||
bool ok = false;
|
||||
scope.end([&](bool result) { ok = result && buf; });
|
||||
|
||||
if (ok) {
|
||||
SubPool sp;
|
||||
@@ -188,6 +166,39 @@ bool BufferPool::addSubBuffer() {
|
||||
#endif // __EMSCRIPTEN__
|
||||
}
|
||||
|
||||
uint64_t BufferPool::shrinkToCapacity(
|
||||
uint64_t target_bytes,
|
||||
const std::function<void(int sub_idx)>& evict_sub_buffer) {
|
||||
uint64_t released = 0;
|
||||
while (!sub_pools_.empty() && total_capacity_bytes() > target_bytes) {
|
||||
const int idx = int(sub_pools_.size()) - 1;
|
||||
if (sub_pools_[size_t(idx)].provisional) break;
|
||||
evict_sub_buffer(idx);
|
||||
SubPool& sub_pool = sub_pools_[size_t(idx)];
|
||||
assert(sub_pool.used == 0 && "owner must free every slice before a sub-buffer is released");
|
||||
if (sub_pool.buffer && sub_pool.owns_handle) {
|
||||
// Destroy, not just release: the handle may still be
|
||||
// referenced by in-flight work, and destroy tells the
|
||||
// backend to reclaim the memory as soon as that completes
|
||||
// instead of when the last reference goes away.
|
||||
wgpuBufferDestroy(sub_pool.buffer);
|
||||
wgpuBufferRelease(sub_pool.buffer);
|
||||
}
|
||||
released += sub_pool.capacity;
|
||||
sub_pools_.pop_back();
|
||||
}
|
||||
if (released > 0) {
|
||||
std::fprintf(stderr,
|
||||
"[wgpu pool] released %llu MB under memory pressure; pool now %llu MB "
|
||||
"across %zu sub-buffer(s), budget %llu MB\n",
|
||||
(unsigned long long)(released / (1024 * 1024)),
|
||||
(unsigned long long)(total_capacity_bytes() / (1024 * 1024)),
|
||||
sub_pools_.size(),
|
||||
(unsigned long long)(max_total_capacity_bytes_ / (1024 * 1024)));
|
||||
}
|
||||
return released;
|
||||
}
|
||||
|
||||
#if defined(__EMSCRIPTEN__)
|
||||
void BufferPool::resolveProvisionalGrowth(bool failed) {
|
||||
growth_pending_ = false;
|
||||
@@ -325,9 +336,10 @@ uint64_t BufferPool::largest_free_run_bytes() const {
|
||||
|
||||
void BufferPool::addSubBufferForTesting(WGPUBuffer fake_buffer, uint64_t capacity) {
|
||||
SubPool sp;
|
||||
sp.buffer = fake_buffer;
|
||||
sp.capacity = capacity;
|
||||
sp.used = 0;
|
||||
sp.buffer = fake_buffer;
|
||||
sp.capacity = capacity;
|
||||
sp.used = 0;
|
||||
sp.owns_handle = false;
|
||||
sp.free_ranges.push_back({0, capacity});
|
||||
sub_pools_.push_back(std::move(sp));
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
#include <webgpu/webgpu.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -105,6 +106,12 @@ public:
|
||||
uint64_t next_growth_size_bytes() const {
|
||||
return last_growth_size_ > 0 ? last_growth_size_ : per_sub_buffer_capacity_;
|
||||
}
|
||||
// Smallest sub-buffer worth adding: below this the per-allocation
|
||||
// bookkeeping (one bind group per chunk, free-list overhead) outweighs
|
||||
// the space. Growth that cannot reach the floor — the driver refusing,
|
||||
// or the budget leaving less than this — is not attempted.
|
||||
static constexpr uint64_t MIN_SUB_BUFFER_BYTES = 64ull * 1024 * 1024;
|
||||
|
||||
// Whether the pool can still attempt to add a sub-buffer. Flips to
|
||||
// false the first time addSubBuffer is refused even at the floor
|
||||
// size — eviction callers need this to know whether a future alloc
|
||||
@@ -112,7 +119,8 @@ public:
|
||||
bool can_grow() const {
|
||||
return !growth_disabled_ && per_sub_buffer_capacity_ > 0
|
||||
&& (max_total_capacity_bytes_ == 0
|
||||
|| total_capacity_bytes() < max_total_capacity_bytes_);
|
||||
|| total_capacity_bytes() + MIN_SUB_BUFFER_BYTES
|
||||
<= max_total_capacity_bytes_);
|
||||
}
|
||||
|
||||
// Whether a growth is in flight. On web that window is real time — a
|
||||
@@ -127,6 +135,20 @@ public:
|
||||
// is a bad_alloc that -fno-exceptions turns into an uncatchable abort, so
|
||||
// the async grow-OOM detection can't save us — we must stop first.
|
||||
void setMaxTotalCapacity(uint64_t max_bytes) { max_total_capacity_bytes_ = max_bytes; }
|
||||
uint64_t max_total_capacity_bytes() const { return max_total_capacity_bytes_; }
|
||||
|
||||
// Release whole sub-buffers, newest first, until total capacity is
|
||||
// ≤ target_bytes (or nothing is left). Before each sub-buffer is
|
||||
// dropped, `evict_sub_buffer(sub_idx)` is invoked so the owner can
|
||||
// free every slice that lives in it — the pool does not know what a
|
||||
// slice holds, and a sub-buffer is only released once it is empty.
|
||||
// Releasing from the back keeps every surviving Slice::sub_idx valid.
|
||||
// Returns the number of bytes released. This is how the cache yields
|
||||
// memory to the required tier (see GpuBudget); on web a provisional
|
||||
// sub-buffer that is still validating is left alone and the shrink is
|
||||
// retried once that resolves.
|
||||
uint64_t shrinkToCapacity(uint64_t target_bytes,
|
||||
const std::function<void(int sub_idx)>& evict_sub_buffer);
|
||||
|
||||
// Proactively add a sub-buffer (no allocation). On web this kicks off the
|
||||
// async provisional-validation cycle so validated free space appears a
|
||||
@@ -161,6 +183,9 @@ private:
|
||||
// capacity/free tallies skip provisional sub-pools so an
|
||||
// unvalidated (possibly invalid) buffer is never handed out.
|
||||
bool provisional = false;
|
||||
// False only for addSubBufferForTesting's fake handles: release
|
||||
// paths (shrinkToCapacity, destroy) then skip the wgpu calls.
|
||||
bool owns_handle = true;
|
||||
};
|
||||
|
||||
// Append a new sub-buffer to the pool. Starts at last_growth_size_
|
||||
|
||||
@@ -145,6 +145,8 @@ set(IFCVIEWER_CORE_SOURCES
|
||||
InstanceCompose.cpp
|
||||
LodBuilder.cpp
|
||||
FederationMath.cpp
|
||||
GpuAllocScope.cpp
|
||||
GpuBudget.cpp
|
||||
GpuMemory.cpp
|
||||
SidecarCache.cpp
|
||||
SidecarCompress.cpp
|
||||
|
||||
@@ -38,11 +38,12 @@ struct FrameStats {
|
||||
std::uint32_t unique_meshes;
|
||||
std::uint32_t gl_draw_calls; // wgpu draw-call count; name kept for bonsai parity
|
||||
std::uint32_t indirect_sub_draws; // sub-draws packed into the chunk-indirect lists
|
||||
// Chunk geometry pool occupancy (see BufferPool). wgpu exposes no
|
||||
// adapter-wide VRAM query, so this is the viewer's own allocation,
|
||||
// not the device total.
|
||||
// Chunk geometry pool occupancy (see BufferPool): bytes held by
|
||||
// resident chunks, the pool's current capacity, and the budget the
|
||||
// pool may grow to (GpuBudget; 0 when still unbounded).
|
||||
std::uint64_t vram_used_bytes;
|
||||
std::uint64_t vram_capacity_bytes;
|
||||
std::uint64_t vram_budget_bytes;
|
||||
// Whole-device VRAM from the driver (NVML / sysfs, see GpuMemory.h).
|
||||
// Desktop only; zero on web or when no backend could answer, so
|
||||
// consumers must treat 0 as "unknown" rather than as empty.
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/********************************************************************************
|
||||
* *
|
||||
* This file is part of IfcOpenShell. *
|
||||
* *
|
||||
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the Lesser GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* Lesser GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the Lesser GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
********************************************************************************/
|
||||
|
||||
#include "GpuAllocScope.h"
|
||||
|
||||
#include <cassert>
|
||||
|
||||
namespace {
|
||||
|
||||
// Shared between the two pop callbacks; freed by whichever fires last.
|
||||
struct PendingPop {
|
||||
GpuAllocScope::Callback on_result;
|
||||
int remaining = 2;
|
||||
bool error = false;
|
||||
// Desktop: points at a flag on end()'s stack frame so the spin-wait
|
||||
// can observe completion without touching this (freed) object.
|
||||
bool* done = nullptr;
|
||||
};
|
||||
|
||||
void onPopped(WGPUPopErrorScopeStatus, WGPUErrorType type, WGPUStringView,
|
||||
void* userdata1, void* /*userdata2*/) {
|
||||
auto* pending = static_cast<PendingPop*>(userdata1);
|
||||
if (type != WGPUErrorType_NoError) pending->error = true;
|
||||
if (--pending->remaining > 0) return;
|
||||
const bool ok = !pending->error;
|
||||
bool* done = pending->done;
|
||||
GpuAllocScope::Callback on_result = std::move(pending->on_result);
|
||||
delete pending;
|
||||
on_result(ok);
|
||||
if (done) *done = true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
GpuAllocScope::GpuAllocScope(WGPUInstance instance, WGPUDevice device)
|
||||
: instance_(instance), device_(device) {
|
||||
// Validation outer, OutOfMemory inner: each pop sees the errors of
|
||||
// its own filter, and an OOM reported under either classification
|
||||
// reaches one of the two.
|
||||
wgpuDevicePushErrorScope(device_, WGPUErrorFilter_Validation);
|
||||
wgpuDevicePushErrorScope(device_, WGPUErrorFilter_OutOfMemory);
|
||||
}
|
||||
|
||||
GpuAllocScope::~GpuAllocScope() {
|
||||
assert(ended_ && "GpuAllocScope::end() must be called exactly once");
|
||||
}
|
||||
|
||||
void GpuAllocScope::end(Callback on_result) {
|
||||
assert(!ended_);
|
||||
ended_ = true;
|
||||
|
||||
bool done = false;
|
||||
auto* pending = new PendingPop{std::move(on_result)};
|
||||
|
||||
WGPUPopErrorScopeCallbackInfo cb = {};
|
||||
#if defined(__EMSCRIPTEN__)
|
||||
// Dawn-web resolves pops from the JS event loop; the caller proceeds
|
||||
// provisionally and hears back in on_result.
|
||||
cb.mode = WGPUCallbackMode_AllowSpontaneous;
|
||||
#else
|
||||
// wgpu-native fires these from wgpuInstanceProcessEvents, which we
|
||||
// spin below so on_result has run by the time end() returns.
|
||||
cb.mode = WGPUCallbackMode_AllowProcessEvents;
|
||||
pending->done = &done;
|
||||
#endif
|
||||
cb.callback = onPopped;
|
||||
cb.userdata1 = pending;
|
||||
wgpuDevicePopErrorScope(device_, cb); // OutOfMemory (inner)
|
||||
wgpuDevicePopErrorScope(device_, cb); // Validation (outer)
|
||||
|
||||
#if !defined(__EMSCRIPTEN__)
|
||||
while (!done) wgpuInstanceProcessEvents(instance_);
|
||||
#else
|
||||
(void)done;
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/********************************************************************************
|
||||
* *
|
||||
* This file is part of IfcOpenShell. *
|
||||
* *
|
||||
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the Lesser GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* Lesser GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the Lesser GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
********************************************************************************/
|
||||
|
||||
#ifndef IFCVIEWER_GPUALLOCSCOPE_H
|
||||
#define IFCVIEWER_GPUALLOCSCOPE_H
|
||||
|
||||
#include <webgpu/webgpu.h>
|
||||
|
||||
#include <functional>
|
||||
|
||||
// Brackets one or more wgpu resource creations so an out-of-memory is
|
||||
// observed instead of silently producing an invalid resource.
|
||||
//
|
||||
// WebGPU never returns null from createBuffer / createTexture: a failed
|
||||
// allocation yields an *error* resource, and the failure is only reported
|
||||
// through an error scope. Left unobserved it surfaces later as a validation
|
||||
// error on the first use -- and on wgpu-native an invalid attachment in
|
||||
// wgpuQueueSubmit is a Rust panic across the FFI boundary, i.e. an abort
|
||||
// with no recovery path. So every allocation the renderer cannot do
|
||||
// without goes through one of these.
|
||||
//
|
||||
// Two filters are pushed, not one: wgpu-native classifies "Not enough
|
||||
// memory left" as a Validation error, Dawn as OutOfMemory.
|
||||
//
|
||||
// Desktop and web differ only in *when* the answer arrives. On wgpu-native
|
||||
// the scope pops synchronously (the instance is spun until the callback
|
||||
// fires) and `end` invokes the callback before returning. On Dawn-web the
|
||||
// pop is a promise and spinning would deadlock the JS event loop, so the
|
||||
// callback fires later from the event loop; callers use the resource
|
||||
// provisionally and correct course in the callback if it turns out bad.
|
||||
class GpuAllocScope {
|
||||
public:
|
||||
using Callback = std::function<void(bool ok)>;
|
||||
|
||||
GpuAllocScope(WGPUInstance instance, WGPUDevice device);
|
||||
~GpuAllocScope();
|
||||
|
||||
GpuAllocScope(const GpuAllocScope&) = delete;
|
||||
GpuAllocScope& operator=(const GpuAllocScope&) = delete;
|
||||
|
||||
// Pop the scopes and deliver the verdict: `ok` is true when no error
|
||||
// fired between construction and here. Must be called exactly once.
|
||||
void end(Callback on_result);
|
||||
|
||||
private:
|
||||
WGPUInstance instance_ = nullptr;
|
||||
WGPUDevice device_ = nullptr;
|
||||
bool ended_ = false;
|
||||
};
|
||||
|
||||
#endif // IFCVIEWER_GPUALLOCSCOPE_H
|
||||
@@ -0,0 +1,60 @@
|
||||
/********************************************************************************
|
||||
* *
|
||||
* This file is part of IfcOpenShell. *
|
||||
* *
|
||||
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the Lesser GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* Lesser GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the Lesser GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
********************************************************************************/
|
||||
|
||||
#include "GpuBudget.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
void GpuBudget::configure(std::uint64_t device_free_bytes,
|
||||
std::uint64_t reserve_bytes,
|
||||
std::uint64_t hard_cap_bytes) {
|
||||
bounded_ = false;
|
||||
budget_ = 0;
|
||||
if (device_free_bytes > 0) {
|
||||
bounded_ = true;
|
||||
budget_ = device_free_bytes > reserve_bytes
|
||||
? device_free_bytes - reserve_bytes
|
||||
: 0;
|
||||
}
|
||||
if (hard_cap_bytes > 0) {
|
||||
budget_ = bounded_ ? std::min(budget_, hard_cap_bytes) : hard_cap_bytes;
|
||||
bounded_ = true;
|
||||
}
|
||||
if (bounded_) budget_ = std::max(budget_, kMinCacheBudgetBytes);
|
||||
}
|
||||
|
||||
bool GpuBudget::onPressure(std::uint64_t cache_capacity_bytes,
|
||||
std::uint64_t bytes_needed) {
|
||||
++pressure_events_;
|
||||
// What the cache may keep once the failed allocation and its slack
|
||||
// have been carved out of what it holds right now. The pool's actual
|
||||
// capacity, not the previous budget, is the honest baseline: the
|
||||
// budget may never have been reached (unbounded, or growth refused
|
||||
// earlier by the driver), and lowering a number the pool never hit
|
||||
// would free nothing.
|
||||
const std::uint64_t carve = bytes_needed + kPressureSlackBytes;
|
||||
const std::uint64_t target = cache_capacity_bytes > carve
|
||||
? cache_capacity_bytes - carve
|
||||
: 0;
|
||||
const std::uint64_t lowered = std::max(target, kMinCacheBudgetBytes);
|
||||
if (bounded_ && lowered >= budget_) return false;
|
||||
bounded_ = true;
|
||||
budget_ = lowered;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/********************************************************************************
|
||||
* *
|
||||
* This file is part of IfcOpenShell. *
|
||||
* *
|
||||
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the Lesser GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* Lesser GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the Lesser GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
********************************************************************************/
|
||||
|
||||
#ifndef IFCVIEWER_GPUBUDGET_H
|
||||
#define IFCVIEWER_GPUBUDGET_H
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// How much device memory the elastic geometry cache (BufferPool) may hold.
|
||||
//
|
||||
// GPU memory in the viewer falls in two tiers. *Required* allocations --
|
||||
// render attachments, per-model metadata, readback staging -- are allocated
|
||||
// eagerly at deterministic moments (surface configure, model load) and the
|
||||
// frame cannot be drawn without them. The *cache* -- streamed chunk
|
||||
// geometry -- is elastic: a chunk that does not fit is simply not resident
|
||||
// this frame. The rule that keeps the two from colliding is that the cache
|
||||
// never takes the last byte: it grows only up to this budget, and yields
|
||||
// whenever a required allocation fails.
|
||||
//
|
||||
// The budget's *source* differs per platform, the mechanism does not:
|
||||
// - desktop: derived from the driver's free-memory report minus a reserve
|
||||
// for the attachments (GpuMemory.h);
|
||||
// - web: no memory query exists, so a fixed ceiling (the wasm heap) and
|
||||
// pressure feedback alone;
|
||||
// - either: when a required allocation still fails, onPressure() lowers
|
||||
// the budget so the pool releases enough for that allocation to succeed
|
||||
// on retry. Over a session the budget converges on what the device
|
||||
// actually allows, which is the only information WebGPU ever gives.
|
||||
//
|
||||
// Pure policy, no wgpu: the pool applies the number via
|
||||
// BufferPool::setMaxTotalCapacity / shrinkToCapacity.
|
||||
class GpuBudget {
|
||||
public:
|
||||
// Below this the viewer cannot keep even a handful of 4 MB chunks
|
||||
// resident, so there is no point lowering further: a required
|
||||
// allocation that still fails at the floor is a genuinely exhausted
|
||||
// device, and the caller degrades instead.
|
||||
static constexpr std::uint64_t kMinCacheBudgetBytes = 64ull * 1024 * 1024;
|
||||
// Headroom added on top of the failed allocation when lowering the
|
||||
// budget, so the very next small required allocation does not fail
|
||||
// again and trigger another shrink cycle.
|
||||
static constexpr std::uint64_t kPressureSlackBytes = 32ull * 1024 * 1024;
|
||||
|
||||
// `device_free_bytes` 0 = unknown (web, or an unsupported driver).
|
||||
// `reserve_bytes` is what the required tier is expected to need at its
|
||||
// largest (attachments at the biggest plausible surface, plus margin).
|
||||
// `hard_cap_bytes` 0 = none; otherwise an absolute ceiling regardless
|
||||
// of device memory (the wasm heap on web).
|
||||
void configure(std::uint64_t device_free_bytes,
|
||||
std::uint64_t reserve_bytes,
|
||||
std::uint64_t hard_cap_bytes);
|
||||
|
||||
// False when nothing bounds the cache yet (no device information, no
|
||||
// cap, no pressure so far). The pool then grows until the driver
|
||||
// refuses, exactly as before; the first pressure event bounds it.
|
||||
bool bounded() const { return bounded_; }
|
||||
// Meaningful only when bounded().
|
||||
std::uint64_t cache_budget_bytes() const { return budget_; }
|
||||
|
||||
// A required allocation of `bytes_needed` failed while the cache held
|
||||
// `cache_capacity_bytes`. Lowers the budget so that shrinking the cache
|
||||
// to it frees bytes_needed + slack. Returns false when the budget could
|
||||
// not be lowered any further (already at the floor): the device is
|
||||
// exhausted and the caller must degrade rather than retry.
|
||||
bool onPressure(std::uint64_t cache_capacity_bytes,
|
||||
std::uint64_t bytes_needed);
|
||||
|
||||
std::uint32_t pressure_events() const { return pressure_events_; }
|
||||
|
||||
private:
|
||||
bool bounded_ = false;
|
||||
std::uint64_t budget_ = 0;
|
||||
std::uint32_t pressure_events_ = 0;
|
||||
};
|
||||
|
||||
#endif // IFCVIEWER_GPUBUDGET_H
|
||||
@@ -24,7 +24,7 @@
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
#if defined(__linux__)
|
||||
#if defined(__linux__) && !defined(__EMSCRIPTEN__)
|
||||
#include <dirent.h>
|
||||
#include <dlfcn.h>
|
||||
#endif
|
||||
@@ -32,7 +32,7 @@
|
||||
namespace ifcviewer {
|
||||
namespace {
|
||||
|
||||
#if defined(__linux__)
|
||||
#if defined(__linux__) && !defined(__EMSCRIPTEN__)
|
||||
|
||||
// NVML, loaded at run time rather than linked: the viewer must run on machines
|
||||
// with no NVIDIA driver at all, so a link-time dependency is not an option.
|
||||
@@ -179,13 +179,13 @@ bool querySysfs(std::uint32_t vendor_id, std::uint32_t device_id, GpuMemoryInfo&
|
||||
return found;
|
||||
}
|
||||
|
||||
#endif // __linux__
|
||||
#endif // __linux__ && !__EMSCRIPTEN__
|
||||
|
||||
} // namespace
|
||||
|
||||
GpuMemoryInfo queryGpuMemory(std::uint32_t vendor_id, std::uint32_t device_id) {
|
||||
GpuMemoryInfo info;
|
||||
#if defined(__linux__)
|
||||
#if defined(__linux__) && !defined(__EMSCRIPTEN__)
|
||||
if (queryNvml(vendor_id, device_id, info)) return info;
|
||||
if (querySysfs(vendor_id, device_id, info)) return info;
|
||||
#else
|
||||
|
||||
@@ -475,5 +475,9 @@ struct ModelGpuData {
|
||||
// ranges via `pool.free()`) and clear its size mirrors. Safe to call
|
||||
// repeatedly; idempotent on already-released entries.
|
||||
void releaseWgpuModelGpuData(ModelGpuData& m, BufferPool& pool);
|
||||
// Just the model's own (non-pool) wgpu buffers: mesh + instance storage
|
||||
// and the per-chunk cull buffers. Chunk bookkeeping is left intact so the
|
||||
// buffers can be re-created — the undo step of a failed model load.
|
||||
void releaseModelBuffers(ModelGpuData& m);
|
||||
|
||||
#endif // WGPUMODELGPUDATA_H
|
||||
|
||||
+307
-94
@@ -28,6 +28,7 @@
|
||||
#endif
|
||||
|
||||
#include "CameraMath.h"
|
||||
#include "GpuAllocScope.h"
|
||||
#include "GpuMemory.h"
|
||||
#include "InstanceCompose.h"
|
||||
#include "Log.h"
|
||||
@@ -61,6 +62,17 @@ Eigen::Vector3f orbitEye(const float target[3], float dist,
|
||||
ViewportCore::ViewportCore(ViewportHost* host) : host_(host) {}
|
||||
ViewportCore::~ViewportCore() = default;
|
||||
|
||||
void releaseModelBuffers(ModelGpuData& m) {
|
||||
for (auto& c : m.chunks) {
|
||||
if (c.visible_draws_buffer) { wgpuBufferRelease(c.visible_draws_buffer); c.visible_draws_buffer = nullptr; }
|
||||
if (c.prefix_sums_buffer) { wgpuBufferRelease(c.prefix_sums_buffer); c.prefix_sums_buffer = nullptr; }
|
||||
if (c.per_chunk_uniform) { wgpuBufferRelease(c.per_chunk_uniform); c.per_chunk_uniform = nullptr; }
|
||||
}
|
||||
if (m.mesh_storage) { wgpuBufferRelease(m.mesh_storage); m.mesh_storage = nullptr; }
|
||||
if (m.instance_storage) { wgpuBufferRelease(m.instance_storage); m.instance_storage = nullptr; }
|
||||
m.vram_bytes_ssbo = 0;
|
||||
}
|
||||
|
||||
// Tear down a model's per-chunk GPU resources, free its pool slices,
|
||||
// and reset all the bookkeeping vectors so the slot can be reused.
|
||||
// Static because callers from outside this TU still live in
|
||||
@@ -77,10 +89,8 @@ void releaseWgpuModelGpuData(ModelGpuData& m, BufferPool& pool) {
|
||||
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; }
|
||||
if (c.per_chunk_uniform) { wgpuBufferRelease(c.per_chunk_uniform); c.per_chunk_uniform = nullptr; }
|
||||
}
|
||||
releaseModelBuffers(m);
|
||||
m.chunks.clear();
|
||||
m.mesh_chunk_idx.clear();
|
||||
m.mesh_chunk_local_base_vertex.clear();
|
||||
@@ -90,8 +100,6 @@ void releaseWgpuModelGpuData(ModelGpuData& m, BufferPool& pool) {
|
||||
m.instance_base_vertex.clear();
|
||||
m.instance_ebo_first_u32.clear();
|
||||
m.instance_lod1_first_u32.clear();
|
||||
if (m.mesh_storage) { wgpuBufferRelease(m.mesh_storage); m.mesh_storage = nullptr; }
|
||||
if (m.instance_storage) { wgpuBufferRelease(m.instance_storage); m.instance_storage = nullptr; }
|
||||
m.vertex_bytes = 0;
|
||||
m.index_count = 0;
|
||||
m.mesh_count = 0;
|
||||
@@ -1593,22 +1601,160 @@ bool ViewportCore::createPool() {
|
||||
std::max<uint64_t>(MIN_POOL_CAPACITY, INITIAL_SUB_BUFFER));
|
||||
pool_.configure(instance_, device_, pool_usage, per_sub,
|
||||
"ifcviewer-wgpu.pool");
|
||||
|
||||
// How far the pool may grow. The cache must stop short of what the
|
||||
// required tier (attachments, model metadata, staging) will need,
|
||||
// because those are allocated later and the frame cannot be drawn
|
||||
// without them; see GpuBudget.h for the model.
|
||||
std::uint64_t device_free = 0;
|
||||
std::uint64_t hard_cap = 0;
|
||||
#if defined(__EMSCRIPTEN__)
|
||||
// Cap total pool capacity below the wasm heap ceiling. On web a growth that
|
||||
// would push the heap past MAXIMUM_MEMORY is a bad_alloc → uncatchable
|
||||
// abort, so the pool must stop growing (and evict) before then. Leave
|
||||
// headroom for metadata (instances/maps), transient decompression buffers,
|
||||
// and wgpu overhead. Big federations then keep a bounded, highest-priority
|
||||
// resident set instead of aborting.
|
||||
pool_.setMaxTotalCapacity(3072ull * 1024 * 1024); // 3 GB (heap ceiling 4 GB)
|
||||
// No memory query on web. Cap total pool capacity below the wasm heap
|
||||
// ceiling: a growth that would push the heap past MAXIMUM_MEMORY is a
|
||||
// bad_alloc → uncatchable abort, so the pool must stop growing (and
|
||||
// evict) before then. Leave headroom for metadata (instances/maps),
|
||||
// transient decompression buffers, and wgpu overhead. Big federations
|
||||
// then keep a bounded, highest-priority resident set instead of
|
||||
// aborting. Device exhaustion below that is learnt through pressure.
|
||||
hard_cap = 3072ull * 1024 * 1024; // 3 GB (heap ceiling 4 GB)
|
||||
#else
|
||||
{
|
||||
WGPUAdapterInfo adapter_info = WGPU_ADAPTER_INFO_INIT;
|
||||
wgpuAdapterGetInfo(adapter_, &adapter_info);
|
||||
const ifcviewer::GpuMemoryInfo mem =
|
||||
ifcviewer::queryGpuMemory(adapter_info.vendorID, adapter_info.deviceID);
|
||||
wgpuAdapterInfoFreeMembers(adapter_info);
|
||||
if (mem.valid) device_free = mem.free_bytes();
|
||||
}
|
||||
#endif
|
||||
budget_.configure(device_free, requiredTierReserveBytes(), hard_cap);
|
||||
pool_.setMaxTotalCapacity(budget_.bounded() ? budget_.cache_budget_bytes() : 0);
|
||||
|
||||
Log::info() << "wgpu: pool per-sub-buffer capacity = "
|
||||
<< (per_sub / (1024 * 1024)) << " MB (grows lazily on "
|
||||
<< "demand; device maxBufferSize = "
|
||||
<< (device_limits.maxBufferSize / (1024 * 1024)) << " MB)";
|
||||
if (budget_.bounded()) {
|
||||
Log::info() << "wgpu: geometry cache budget = "
|
||||
<< (budget_.cache_budget_bytes() / (1024 * 1024)) << " MB"
|
||||
<< (device_free > 0
|
||||
? " (device free " + std::to_string(device_free / (1024 * 1024))
|
||||
+ " MB - reserve "
|
||||
+ std::to_string(requiredTierReserveBytes() / (1024 * 1024))
|
||||
+ " MB)"
|
||||
: " (fixed cap)");
|
||||
} else {
|
||||
Log::info() << "wgpu: geometry cache budget unknown (no device memory "
|
||||
"query); bounded on first memory-pressure event";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::uint64_t ViewportCore::attachmentBytesPerPixel() {
|
||||
// Sizes follow the formats in ensureDepthTexture / ensureMsaaColorTexture /
|
||||
// ensureSelectionOutlineTextures / createPickAttachments.
|
||||
constexpr std::uint64_t msaa = kViewportSampleCount;
|
||||
return 4 * msaa // msaa colour, 4 B/sample (any 8-bit RGBA surface)
|
||||
+ 4 * msaa // depth32 msaa
|
||||
+ 1 * msaa // selection mask msaa (R8)
|
||||
+ 1 // selection mask resolve (R8)
|
||||
+ 4 // selection scratch (RGBA8)
|
||||
+ 4 + 8 + 16 + 4; // pick: R32Uint id, RGBA16F normal, RGBA32F position, depth32
|
||||
}
|
||||
|
||||
std::uint64_t ViewportCore::requiredTierReserveBytes() {
|
||||
// Attachments at a 4K surface — a user can maximise onto a larger
|
||||
// monitor after load, and the cache must already have left room —
|
||||
// plus a fixed margin for per-model metadata buffers, readback
|
||||
// staging, and the driver's own bookkeeping.
|
||||
constexpr std::uint64_t kMaxPlausiblePixels = 3840ull * 2160;
|
||||
constexpr std::uint64_t kFixedMargin = 256ull * 1024 * 1024;
|
||||
return attachmentBytesPerPixel() * kMaxPlausiblePixels + kFixedMargin;
|
||||
}
|
||||
|
||||
bool ViewportCore::onRequiredAllocationFailed(const char* what, std::uint64_t bytes) {
|
||||
const std::uint64_t capacity_before = pool_.total_capacity_bytes();
|
||||
const bool lowered = budget_.onPressure(capacity_before, bytes);
|
||||
const double mb = 1.0 / (1024.0 * 1024.0);
|
||||
if (!lowered) {
|
||||
Log::warn() << "[wgpu] out of memory allocating " << what << " ("
|
||||
<< double(bytes) * mb << " MB) and the geometry cache ("
|
||||
<< double(capacity_before) * mb
|
||||
<< " MB) has nothing left to give -- device exhausted";
|
||||
return false;
|
||||
}
|
||||
pool_.setMaxTotalCapacity(budget_.cache_budget_bytes());
|
||||
const std::uint64_t released = pool_.shrinkToCapacity(
|
||||
budget_.cache_budget_bytes(),
|
||||
[this](int sub_idx) { evictChunksInSubBuffer(sub_idx); });
|
||||
Log::warn() << "[wgpu] out of memory allocating " << what << " ("
|
||||
<< double(bytes) * mb << " MB); geometry cache budget lowered to "
|
||||
<< double(budget_.cache_budget_bytes()) * mb << " MB, released "
|
||||
<< double(released) * mb << " MB (pressure event #"
|
||||
<< budget_.pressure_events() << ")";
|
||||
#if !defined(__EMSCRIPTEN__)
|
||||
// Released buffers are reclaimed once the GPU has finished with them;
|
||||
// wait for that so the retry that follows sees the memory. On web
|
||||
// there is no blocking wait — the next frame's attempt will.
|
||||
wgpuDevicePoll(device_, true, nullptr);
|
||||
#endif
|
||||
return released > 0;
|
||||
}
|
||||
|
||||
void ViewportCore::evictChunksInSubBuffer(int sub_idx) {
|
||||
for (auto& [session_model_id, m] : models_gpu_) {
|
||||
for (std::size_t ci = 0; ci < m.chunks.size(); ++ci) {
|
||||
const auto& c = m.chunks[ci];
|
||||
if (!c.is_resident) continue;
|
||||
if (c.vertex_slice.sub_idx == sub_idx || c.index_slice.sub_idx == sub_idx) {
|
||||
unloadChunk(m, ci);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool ViewportCore::allocateRequired(const char* what, std::uint64_t bytes,
|
||||
const std::function<void()>& create,
|
||||
const std::function<void()>& release,
|
||||
std::function<void()> on_web_failure) {
|
||||
#if defined(__EMSCRIPTEN__)
|
||||
(void)release;
|
||||
GpuAllocScope scope(instance_, device_);
|
||||
create();
|
||||
scope.end([this, what, bytes, on_web_failure = std::move(on_web_failure)](bool ok) {
|
||||
if (ok) return;
|
||||
onRequiredAllocationFailed(what, bytes);
|
||||
if (on_web_failure) on_web_failure();
|
||||
});
|
||||
return true;
|
||||
#else
|
||||
(void)on_web_failure;
|
||||
// Each failed attempt lowers the budget by the allocation plus slack
|
||||
// and releases at least one sub-buffer, so the loop strictly shrinks
|
||||
// the cache and ends at the floor — the driver may need more headroom
|
||||
// than one carve-out (its refusal threshold is not "free == 0").
|
||||
for (;;) {
|
||||
GpuAllocScope scope(instance_, device_);
|
||||
create();
|
||||
bool ok = false;
|
||||
scope.end([&](bool result) { ok = result; });
|
||||
if (ok) return true;
|
||||
release();
|
||||
if (!onRequiredAllocationFailed(what, bytes)) return false;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
WGPUBuffer ViewportCore::createRequiredBuffer(const WGPUBufferDescriptor& desc,
|
||||
const char* what) {
|
||||
WGPUBuffer buf = nullptr;
|
||||
const bool ok = allocateRequired(
|
||||
what, desc.size,
|
||||
[&]() { buf = wgpuDeviceCreateBuffer(device_, &desc); },
|
||||
[&]() { if (buf) { wgpuBufferRelease(buf); buf = nullptr; } });
|
||||
return ok ? buf : nullptr;
|
||||
}
|
||||
|
||||
bool ViewportCore::initWgpu(bool web_limits) {
|
||||
#if !defined(__EMSCRIPTEN__)
|
||||
wgpuSetLogCallback(onWgpuLog, nullptr);
|
||||
@@ -3129,6 +3275,64 @@ SidecarData& getOrCreateDirectStaging(
|
||||
|
||||
} // namespace
|
||||
|
||||
bool ViewportCore::createModelBuffers(std::uint32_t session_model_id,
|
||||
ModelGpuData& m,
|
||||
const std::vector<MeshGpu>& mesh_gpu,
|
||||
const std::vector<InstanceGpu>& inst_gpu) {
|
||||
const std::size_t mesh_storage_bytes = mesh_gpu.size() * sizeof(MeshGpu);
|
||||
const std::size_t inst_storage_bytes = inst_gpu.size() * sizeof(InstanceGpu);
|
||||
std::uint64_t total_bytes = mesh_storage_bytes + inst_storage_bytes;
|
||||
for (const auto& c : m.chunks) {
|
||||
total_bytes += c.visible_draws_capacity * sizeof(ModelGpuData::VisibleDrawGpu)
|
||||
+ c.prefix_sums_capacity * sizeof(std::uint32_t) + 16;
|
||||
}
|
||||
|
||||
return allocateRequired(
|
||||
"model buffers", total_bytes,
|
||||
[&]() {
|
||||
for (auto& chunk : m.chunks) {
|
||||
WGPUBufferDescriptor vd_desc = {};
|
||||
vd_desc.size = std::max<std::uint64_t>(
|
||||
chunk.visible_draws_capacity * sizeof(ModelGpuData::VisibleDrawGpu), 16);
|
||||
vd_desc.usage = WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst;
|
||||
vd_desc.label = svFromCStr("model.chunk.visible_draws");
|
||||
chunk.visible_draws_buffer = wgpuDeviceCreateBuffer(device_, &vd_desc);
|
||||
m.vram_bytes_ssbo += vd_desc.size;
|
||||
|
||||
WGPUBufferDescriptor ps_desc = {};
|
||||
ps_desc.size = std::max<std::uint64_t>(
|
||||
chunk.prefix_sums_capacity * sizeof(std::uint32_t), 16);
|
||||
ps_desc.usage = WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst;
|
||||
ps_desc.label = svFromCStr("model.chunk.prefix_sums");
|
||||
chunk.prefix_sums_buffer = wgpuDeviceCreateBuffer(device_, &ps_desc);
|
||||
m.vram_bytes_ssbo += ps_desc.size;
|
||||
|
||||
WGPUBufferDescriptor mu_desc = {};
|
||||
mu_desc.size = 16;
|
||||
mu_desc.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst;
|
||||
mu_desc.label = svFromCStr("model.chunk.uniform");
|
||||
chunk.per_chunk_uniform = wgpuDeviceCreateBuffer(device_, &mu_desc);
|
||||
m.vram_bytes_ssbo += 16;
|
||||
}
|
||||
m.mesh_storage = createBufferWithData(
|
||||
device_, queue_, mesh_gpu.data(), mesh_storage_bytes,
|
||||
WGPUBufferUsage_Storage, "model.mesh_storage");
|
||||
m.vram_bytes_ssbo += mesh_storage_bytes;
|
||||
m.instance_storage = createBufferWithData(
|
||||
device_, queue_, inst_gpu.data(), inst_storage_bytes,
|
||||
WGPUBufferUsage_Storage, "model.instance_storage");
|
||||
m.vram_bytes_ssbo += inst_storage_bytes;
|
||||
},
|
||||
[&]() { releaseModelBuffers(m); },
|
||||
// Web hears of the failure after the model is in the scene; its
|
||||
// buffers are error objects that would fail every bind, so drop it.
|
||||
[this, session_model_id]() {
|
||||
Log::warn() << "[wgpu] model " << session_model_id
|
||||
<< " removed: the device could not fit its metadata buffers";
|
||||
removeModel(session_model_id);
|
||||
});
|
||||
}
|
||||
|
||||
void ViewportCore::applyCachedModel(std::uint32_t session_model_id,
|
||||
StreamingSidecar metadata) {
|
||||
if (!device_ || !queue_) {
|
||||
@@ -3306,35 +3510,12 @@ void ViewportCore::applyCachedModel(std::uint32_t session_model_id,
|
||||
model_gpu_data.vertex_bytes += chunk.vertex_byte_size;
|
||||
model_gpu_data.index_count += std::uint32_t(chunk.index_count);
|
||||
|
||||
// Small per-chunk buffers, allocated upfront so cull can write into
|
||||
// them. visible_draws_buffer cap = chunk's instance count.
|
||||
// Per-chunk cull buffer capacities: visible_draws cap = the chunk's
|
||||
// instance count. The buffers themselves are created together with
|
||||
// the model's other buffers in createModelBuffers below.
|
||||
const std::size_t chunk_inst = std::max<std::size_t>(chunk_instance_count[chunk_index], 1);
|
||||
const std::size_t draws_bytes = chunk_inst * sizeof(ModelGpuData::VisibleDrawGpu);
|
||||
const std::size_t ps_bytes = (chunk_inst + 1) * sizeof(std::uint32_t);
|
||||
|
||||
WGPUBufferDescriptor vd_desc = {};
|
||||
vd_desc.size = std::max<std::uint64_t>(draws_bytes, 16);
|
||||
vd_desc.usage = WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst;
|
||||
vd_desc.label = svFromCStr("model.chunk.visible_draws");
|
||||
chunk.visible_draws_buffer = wgpuDeviceCreateBuffer(device_, &vd_desc);
|
||||
chunk.visible_draws_capacity = chunk_inst;
|
||||
model_gpu_data.vram_bytes_ssbo += vd_desc.size;
|
||||
|
||||
WGPUBufferDescriptor ps_desc = {};
|
||||
ps_desc.size = std::max<std::uint64_t>(ps_bytes, 16);
|
||||
ps_desc.usage = WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst;
|
||||
ps_desc.label = svFromCStr("model.chunk.prefix_sums");
|
||||
chunk.prefix_sums_buffer = wgpuDeviceCreateBuffer(device_, &ps_desc);
|
||||
chunk.prefix_sums_capacity = chunk_inst + 1;
|
||||
model_gpu_data.vram_bytes_ssbo += ps_desc.size;
|
||||
|
||||
WGPUBufferDescriptor mu_desc = {};
|
||||
mu_desc.size = 16;
|
||||
mu_desc.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst;
|
||||
mu_desc.label = svFromCStr("model.chunk.uniform");
|
||||
chunk.per_chunk_uniform = wgpuDeviceCreateBuffer(device_, &mu_desc);
|
||||
model_gpu_data.vram_bytes_ssbo += 16;
|
||||
|
||||
chunk.prefix_sums_capacity = chunk_inst + 1;
|
||||
chunk.visible_draws_scratch.reserve(chunk_inst);
|
||||
chunk.prefix_sums_scratch.reserve(chunk_inst + 1);
|
||||
}
|
||||
@@ -3355,13 +3536,6 @@ void ViewportCore::applyCachedModel(std::uint32_t session_model_id,
|
||||
mesh_gpu_record.aabb_max[2] = mesh_info.local_aabb_max[2];
|
||||
mesh_gpu.push_back(mesh_gpu_record);
|
||||
}
|
||||
const std::size_t mesh_storage_bytes = mesh_gpu.size() * sizeof(MeshGpu);
|
||||
model_gpu_data.mesh_storage = createBufferWithData(
|
||||
device_, queue_,
|
||||
mesh_gpu.data(), mesh_storage_bytes,
|
||||
WGPUBufferUsage_Storage,
|
||||
"model.mesh_storage");
|
||||
model_gpu_data.vram_bytes_ssbo += mesh_storage_bytes;
|
||||
|
||||
// InstanceGpu storage. Rebase object_ids globally.
|
||||
const std::uint32_t object_id_base = next_object_id_;
|
||||
@@ -3380,13 +3554,12 @@ void ViewportCore::applyCachedModel(std::uint32_t session_model_id,
|
||||
}
|
||||
next_object_id_ = object_id_base + max_local_id + 1;
|
||||
model_gpu_data.object_id_base = object_id_base; // element metadata records rebase to match
|
||||
const std::size_t inst_storage_bytes = inst_gpu.size() * sizeof(InstanceGpu);
|
||||
model_gpu_data.instance_storage = createBufferWithData(
|
||||
device_, queue_,
|
||||
inst_gpu.data(), inst_storage_bytes,
|
||||
WGPUBufferUsage_Storage,
|
||||
"model.instance_storage");
|
||||
model_gpu_data.vram_bytes_ssbo += inst_storage_bytes;
|
||||
|
||||
if (!createModelBuffers(session_model_id, model_gpu_data, mesh_gpu, inst_gpu)) {
|
||||
Log::warn() << "[wgpu] model " << session_model_id
|
||||
<< " not loaded: the device cannot fit its metadata buffers";
|
||||
return;
|
||||
}
|
||||
|
||||
// Hand off CPU mirrors.
|
||||
model_gpu_data.meshes = std::move(metadata.meta.meshes);
|
||||
@@ -4473,21 +4646,7 @@ void ViewportCore::ensureHizTextures(int viewport_w, int viewport_h) {
|
||||
|
||||
if (dst_w == hiz_resolve_w_ && dst_h == hiz_resolve_h_ && hiz_resolve_view_) return;
|
||||
|
||||
if (hiz_resolve_view_) { wgpuTextureViewRelease(hiz_resolve_view_); hiz_resolve_view_ = nullptr; }
|
||||
if (hiz_resolve_texture_) { wgpuTextureRelease(hiz_resolve_texture_); hiz_resolve_texture_ = nullptr; }
|
||||
for (int s = 0; s < HIZ_SLOTS; ++s) {
|
||||
if (hiz_staging_buffers_[s]) {
|
||||
if (hiz_slot_state_[s] == HizSlotState::Mapped) {
|
||||
wgpuBufferUnmap(hiz_staging_buffers_[s]);
|
||||
}
|
||||
wgpuBufferRelease(hiz_staging_buffers_[s]);
|
||||
hiz_staging_buffers_[s] = nullptr;
|
||||
}
|
||||
hiz_slot_state_[s] = HizSlotState::Idle;
|
||||
}
|
||||
hiz_write_idx_ = 0;
|
||||
hiz_valid_ = false;
|
||||
if (hiz_bind_group_) { wgpuBindGroupRelease(hiz_bind_group_); hiz_bind_group_ = nullptr; }
|
||||
releaseHizTextures();
|
||||
|
||||
WGPUTextureDescriptor desc = {};
|
||||
desc.usage = WGPUTextureUsage_RenderAttachment | WGPUTextureUsage_CopySrc;
|
||||
@@ -4530,9 +4689,8 @@ void ViewportCore::ensureHizTextures(int viewport_w, int viewport_h) {
|
||||
hiz_valid_ = false;
|
||||
}
|
||||
|
||||
void ViewportCore::releaseHizResources() {
|
||||
void ViewportCore::releaseHizTextures() {
|
||||
if (hiz_bind_group_) { wgpuBindGroupRelease(hiz_bind_group_); hiz_bind_group_ = nullptr; }
|
||||
if (hiz_uniform_buffer_) { wgpuBufferRelease(hiz_uniform_buffer_); hiz_uniform_buffer_ = nullptr; }
|
||||
if (hiz_resolve_view_) { wgpuTextureViewRelease(hiz_resolve_view_); hiz_resolve_view_ = nullptr; }
|
||||
if (hiz_resolve_texture_) { wgpuTextureRelease(hiz_resolve_texture_); hiz_resolve_texture_ = nullptr; }
|
||||
for (int s = 0; s < HIZ_SLOTS; ++s) {
|
||||
@@ -4546,12 +4704,17 @@ void ViewportCore::releaseHizResources() {
|
||||
hiz_slot_state_[s] = HizSlotState::Idle;
|
||||
}
|
||||
hiz_write_idx_ = 0;
|
||||
hiz_resolve_w_ = hiz_resolve_h_ = hiz_padded_bpr_ = 0;
|
||||
hiz_valid_ = false;
|
||||
}
|
||||
|
||||
void ViewportCore::releaseHizResources() {
|
||||
releaseHizTextures();
|
||||
if (hiz_uniform_buffer_) { wgpuBufferRelease(hiz_uniform_buffer_); hiz_uniform_buffer_ = nullptr; }
|
||||
if (hiz_pipeline_) { wgpuRenderPipelineRelease(hiz_pipeline_); hiz_pipeline_ = nullptr; }
|
||||
if (hiz_shader_module_) { wgpuShaderModuleRelease(hiz_shader_module_); hiz_shader_module_ = nullptr; }
|
||||
if (hiz_pipeline_layout_) { wgpuPipelineLayoutRelease(hiz_pipeline_layout_); hiz_pipeline_layout_ = nullptr; }
|
||||
if (hiz_bgl_) { wgpuBindGroupLayoutRelease(hiz_bgl_); hiz_bgl_ = nullptr; }
|
||||
hiz_resolve_w_ = hiz_resolve_h_ = hiz_padded_bpr_ = 0;
|
||||
hiz_valid_ = false;
|
||||
hiz_pyramid_.clear();
|
||||
hiz_mip_offset_.clear();
|
||||
hiz_mip_w_.clear();
|
||||
@@ -4839,6 +5002,44 @@ bool ViewportCore::aabbOccludedByHiz(const float mn[3], const float mx[3]) const
|
||||
return rejected;
|
||||
}
|
||||
|
||||
bool ViewportCore::ensureRenderAttachments(int width_px, int height_px) {
|
||||
const std::uint64_t bytes =
|
||||
attachmentBytesPerPixel() * std::uint64_t(width_px) * std::uint64_t(height_px);
|
||||
const bool ok = allocateRequired(
|
||||
"render attachments", bytes,
|
||||
[&]() {
|
||||
ensureDepthTexture(width_px, height_px);
|
||||
ensureMsaaColorTexture(width_px, height_px);
|
||||
ensureHizTextures(width_px, height_px);
|
||||
ensureSelectionOutlineTextures(width_px, height_px);
|
||||
createPickAttachments(width_px, height_px);
|
||||
},
|
||||
[&]() { releaseRenderAttachments(); },
|
||||
// Web learns of the failure after the views are already in use:
|
||||
// drop the set and reconfigure against the now-smaller cache.
|
||||
[this]() {
|
||||
releaseRenderAttachments();
|
||||
int w = 0, h = 0;
|
||||
host_->framebufferSize(w, h);
|
||||
if (w > 0 && h > 0) configureSurface(w, h);
|
||||
host_->requestFrame();
|
||||
});
|
||||
if (!ok && render_attachments_ok_) {
|
||||
Log::warn() << "[wgpu] render attachments unavailable at " << width_px
|
||||
<< "x" << height_px << "; frames are skipped until memory frees up";
|
||||
}
|
||||
render_attachments_ok_ = ok;
|
||||
return ok;
|
||||
}
|
||||
|
||||
void ViewportCore::releaseRenderAttachments() {
|
||||
releaseDepthTexture();
|
||||
releaseMsaaColorTexture();
|
||||
releaseHizTextures();
|
||||
releaseSelectionOutlineTextures();
|
||||
releasePickResources();
|
||||
}
|
||||
|
||||
void ViewportCore::ensureDepthTexture(int w, int h) {
|
||||
if (w == depth_w_ && h == depth_h_ && depth_view_) return;
|
||||
releaseDepthTexture();
|
||||
@@ -5736,8 +5937,18 @@ bool ViewportCore::buildBoxPickPipeline() {
|
||||
return true;
|
||||
}
|
||||
|
||||
void ViewportCore::ensurePickAttachments(int w, int h) {
|
||||
if (w <= 0 || h <= 0) return;
|
||||
bool ViewportCore::ensurePickAttachments(int w, int h) {
|
||||
if (w <= 0 || h <= 0) return false;
|
||||
if (w == pick_w_ && h == pick_h_ && pick_color_view_) return true;
|
||||
constexpr std::uint64_t kPickBytesPerPixel = 4 + 8 + 16 + 4;
|
||||
const std::uint64_t bytes = kPickBytesPerPixel * std::uint64_t(w) * std::uint64_t(h);
|
||||
return allocateRequired(
|
||||
"pick attachments", bytes,
|
||||
[&]() { createPickAttachments(w, h); },
|
||||
[&]() { releasePickResources(); });
|
||||
}
|
||||
|
||||
void ViewportCore::createPickAttachments(int w, int h) {
|
||||
if (w == pick_w_ && h == pick_h_ && pick_color_view_) return;
|
||||
|
||||
if (pick_color_view_) { wgpuTextureViewRelease(pick_color_view_); pick_color_view_ = nullptr; }
|
||||
@@ -5964,8 +6175,8 @@ std::uint32_t ViewportCore::pickObjectAt(int x_pixels, int y_pixels,
|
||||
if (x_pixels < 0 || y_pixels < 0 ||
|
||||
x_pixels >= configured_w_ || y_pixels >= configured_h_) return 0;
|
||||
|
||||
ensurePickAttachments(configured_w_, configured_h_);
|
||||
if (!pick_color_view_ || !pick_depth_view_ || !pick_staging_buffer_) return 0;
|
||||
if (!ensurePickAttachments(configured_w_, configured_h_)
|
||||
|| !pick_staging_buffer_) return 0;
|
||||
if (normal_out && !pick_normal_staging_buffer_) return 0;
|
||||
|
||||
encodePickReadbackToStaging(x_pixels, y_pixels, normal_out != nullptr);
|
||||
@@ -6193,8 +6404,8 @@ void ViewportCore::pickObjectAtAsync(int x_pixels, int y_pixels,
|
||||
if (x_pixels < 0 || y_pixels < 0 ||
|
||||
x_pixels >= configured_w_ || y_pixels >= configured_h_) { miss(0); return; }
|
||||
|
||||
ensurePickAttachments(configured_w_, configured_h_);
|
||||
if (!pick_color_view_ || !pick_depth_view_ || !pick_staging_buffer_) { miss(0); return; }
|
||||
if (!ensurePickAttachments(configured_w_, configured_h_)
|
||||
|| !pick_staging_buffer_) { miss(0); return; }
|
||||
|
||||
// One pick in flight at a time. Clicks are far slower than a readback, so
|
||||
// dropping a pick issued while another is mapping is acceptable (and
|
||||
@@ -6239,8 +6450,7 @@ bool ViewportCore::encodeBoxPickToStaging(int& x, int& y, int& w, int& h,
|
||||
if (y + h > configured_h_) h = configured_h_ - y;
|
||||
if (w <= 0 || h <= 0) return false;
|
||||
|
||||
ensurePickAttachments(configured_w_, configured_h_);
|
||||
if (!pick_color_view_ || !pick_depth_view_) return false;
|
||||
if (!ensurePickAttachments(configured_w_, configured_h_)) return false;
|
||||
|
||||
// Padded bytes-per-row. R32UInt = 4 B/texel; align to 256 B.
|
||||
constexpr std::uint64_t kWgpuBytesPerRowAlign = 256;
|
||||
@@ -6259,8 +6469,8 @@ bool ViewportCore::encodeBoxPickToStaging(int& x, int& y, int& w, int& h,
|
||||
sb.size = cap;
|
||||
sb.usage = WGPUBufferUsage_CopyDst | WGPUBufferUsage_MapRead;
|
||||
sb.label = svFromCStr("ifcviewer-wgpu.box_pick_staging");
|
||||
box_pick_staging_buffer_ = wgpuDeviceCreateBuffer(device_, &sb);
|
||||
box_pick_staging_capacity_ = cap;
|
||||
box_pick_staging_buffer_ = createRequiredBuffer(sb, "box pick staging");
|
||||
box_pick_staging_capacity_ = box_pick_staging_buffer_ ? cap : 0;
|
||||
}
|
||||
if (!box_pick_staging_buffer_) return false;
|
||||
|
||||
@@ -6374,8 +6584,7 @@ bool ViewportCore::encodeXrayBoxPickToStaging(int& x, int& y, int& w, int& h,
|
||||
if (y + h > configured_h_) h = configured_h_ - y;
|
||||
if (w <= 0 || h <= 0) return false;
|
||||
|
||||
ensurePickAttachments(configured_w_, configured_h_);
|
||||
if (!pick_depth_view_) return false;
|
||||
if (!ensurePickAttachments(configured_w_, configured_h_)) return false;
|
||||
|
||||
const std::uint64_t needed_bytes = std::uint64_t(hit_flags_words_) * sizeof(std::uint32_t);
|
||||
if (needed_bytes > hit_flags_staging_capacity_) {
|
||||
@@ -6388,8 +6597,8 @@ bool ViewportCore::encodeXrayBoxPickToStaging(int& x, int& y, int& w, int& h,
|
||||
sb.size = cap;
|
||||
sb.usage = WGPUBufferUsage_CopyDst | WGPUBufferUsage_MapRead;
|
||||
sb.label = svFromCStr("ifcviewer-wgpu.hit_flags_staging");
|
||||
hit_flags_staging_buffer_ = wgpuDeviceCreateBuffer(device_, &sb);
|
||||
hit_flags_staging_capacity_ = cap;
|
||||
hit_flags_staging_buffer_ = createRequiredBuffer(sb, "hit flags staging");
|
||||
hit_flags_staging_capacity_ = hit_flags_staging_buffer_ ? cap : 0;
|
||||
}
|
||||
if (!hit_flags_staging_buffer_) return false;
|
||||
|
||||
@@ -6701,9 +6910,8 @@ void ViewportCore::pickSurfaceAtAsync(int x_pixels, int y_pixels,
|
||||
if (configured_w_ <= 0 || configured_h_ <= 0) { miss(); return; }
|
||||
if (x_pixels < 0 || y_pixels < 0 ||
|
||||
x_pixels >= configured_w_ || y_pixels >= configured_h_) { miss(); return; }
|
||||
ensurePickAttachments(configured_w_, configured_h_);
|
||||
if (!pick_color_view_ || !pick_depth_view_ ||
|
||||
!pick_staging_buffer_ || !pick_normal_staging_buffer_) { miss(); return; }
|
||||
if (!ensurePickAttachments(configured_w_, configured_h_)
|
||||
|| !pick_staging_buffer_ || !pick_normal_staging_buffer_) { miss(); return; }
|
||||
// Shares the single-pick staging buffers → shares the in-flight guard.
|
||||
if (pick_async_in_flight_) { miss(); return; }
|
||||
pick_async_in_flight_ = true;
|
||||
@@ -7154,10 +7362,7 @@ void ViewportCore::configureSurface(int width_px, int height_px) {
|
||||
configured_w_ = width_px;
|
||||
configured_h_ = height_px;
|
||||
surface_configured_ = true;
|
||||
ensureDepthTexture(width_px, height_px);
|
||||
ensureMsaaColorTexture(width_px, height_px);
|
||||
ensureHizTextures(width_px, height_px);
|
||||
ensureSelectionOutlineTextures(width_px, height_px);
|
||||
ensureRenderAttachments(width_px, height_px);
|
||||
// depth_view_ was just replaced; force the HiZ + edge bind groups
|
||||
// to rebuild against the new view on next encode.
|
||||
if (hiz_bind_group_) {
|
||||
@@ -7210,7 +7415,8 @@ WGPUBuffer ViewportCore::encodeScreenshotCapture(
|
||||
bdesc.size = total_bytes;
|
||||
bdesc.usage = WGPUBufferUsage_CopyDst | WGPUBufferUsage_MapRead;
|
||||
bdesc.label = svFromCStr("ifcviewer-wgpu.capture");
|
||||
WGPUBuffer capture_buffer = wgpuDeviceCreateBuffer(device_, &bdesc);
|
||||
WGPUBuffer capture_buffer = createRequiredBuffer(bdesc, "screenshot capture");
|
||||
if (!capture_buffer) return nullptr;
|
||||
|
||||
WGPUTexelCopyTextureInfo src = {};
|
||||
src.texture = surface_texture;
|
||||
@@ -7342,6 +7548,12 @@ void ViewportCore::render() {
|
||||
// we don't busy-loop reconfiguring a dead surface — that retry storm is
|
||||
// what otherwise freezes the tab. The page logs guidance to reload.
|
||||
if (device_lost_) return;
|
||||
// configureSurface could not fit the per-pixel attachments even after
|
||||
// the geometry cache yielded. Drawing would submit invalid views (an
|
||||
// abort on wgpu-native), so try again — memory may have been freed by
|
||||
// another process since — and skip the frame if it still does not fit.
|
||||
if (!render_attachments_ok_
|
||||
&& !ensureRenderAttachments(configured_w_, configured_h_)) return;
|
||||
|
||||
Stopwatch frame_timer;
|
||||
frame_timer.start();
|
||||
@@ -7738,6 +7950,7 @@ void ViewportCore::render() {
|
||||
stats.indirect_sub_draws = last_sub_draws_;
|
||||
stats.vram_used_bytes = pool_.total_used_bytes();
|
||||
stats.vram_capacity_bytes = pool_.total_capacity_bytes();
|
||||
stats.vram_budget_bytes = budget_.bounded() ? budget_.cache_budget_bytes() : 0;
|
||||
#if !defined(__EMSCRIPTEN__)
|
||||
if (!device_vram_poll_timer_.isValid()
|
||||
|| device_vram_poll_timer_.elapsed() >= 1000) {
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
|
||||
#include "AxisIndicatorRenderer.h"
|
||||
#include "BufferPool.h"
|
||||
#include "GpuBudget.h"
|
||||
#include "InstanceCompose.h"
|
||||
#include "InstancedGeometry.h"
|
||||
#include "ModelGpuData.h"
|
||||
@@ -481,6 +482,12 @@ public:
|
||||
// brings them in. Triggers an auto-viewAll on the first model (so a
|
||||
// freshly-loaded scene frames itself).
|
||||
void applyCachedModel(std::uint32_t session_model_id, StreamingSidecar metadata);
|
||||
// The model's required-tier buffers (mesh + instance storage, per-chunk
|
||||
// cull buffers) as one allocation unit — see allocateRequired. False
|
||||
// when the device cannot fit them even after the cache yielded.
|
||||
bool createModelBuffers(std::uint32_t session_model_id, ModelGpuData& m,
|
||||
const std::vector<MeshGpu>& mesh_gpu,
|
||||
const std::vector<InstanceGpu>& inst_gpu);
|
||||
|
||||
// Qt-free sidecar load: readSidecarMetadata + applyCachedModel.
|
||||
// Used by the web build (and any other non-Qt embedder) so the
|
||||
@@ -705,6 +712,9 @@ public:
|
||||
// dimensions match. Resets ping-pong state so any in-flight map is
|
||||
// dropped (caller already ensured the surface resize blocked).
|
||||
void ensureHizTextures(int viewport_w, int viewport_h);
|
||||
// Drop just the resolve texture + staging buffers (pipeline stays),
|
||||
// resetting the ping-pong state. ensureHizTextures recreates them.
|
||||
void releaseHizTextures();
|
||||
|
||||
// Tear down every HiZ-owned wgpu resource (pipeline + textures +
|
||||
// staging buffers + pyramid). Called from shutdown() before
|
||||
@@ -800,8 +810,13 @@ public:
|
||||
bool buildPickPipeline();
|
||||
|
||||
// (Re)allocate the pick MRT attachments + readback staging buffers
|
||||
// to the supplied size. Idempotent when dimensions match.
|
||||
void ensurePickAttachments(int w, int h);
|
||||
// to the supplied size. Idempotent when dimensions match. Created
|
||||
// eagerly with the other attachments in configureSurface; the pick
|
||||
// entry points call it again only as the retry after a pressure
|
||||
// shrink, and bail when it returns false.
|
||||
bool ensurePickAttachments(int w, int h);
|
||||
// The raw (unscoped) creation ensurePickAttachments wraps.
|
||||
void createPickAttachments(int w, int h);
|
||||
|
||||
// Encode the one-shot pick pass + copy the (x, y) texel into the pick
|
||||
// staging buffer(s) and submit. Shared by the sync (pickObjectAt) and
|
||||
@@ -1052,6 +1067,63 @@ public:
|
||||
private:
|
||||
bool createPool();
|
||||
|
||||
// ---- Memory tiers (see GpuBudget.h) ------------------------------------
|
||||
//
|
||||
// Every allocation the frame cannot do without — the per-pixel
|
||||
// attachments, a model's metadata buffers, readback staging — is
|
||||
// "required" and goes through one of these so an out-of-memory is
|
||||
// observed and answered by shrinking the geometry cache, instead of
|
||||
// surfacing as an invalid resource that aborts in wgpuQueueSubmit.
|
||||
|
||||
// Reserve to hold back from the cache for the required tier: the
|
||||
// per-pixel attachments at the largest plausible surface plus margin.
|
||||
static std::uint64_t requiredTierReserveBytes();
|
||||
// Bytes every per-pixel attachment set costs (MSAA colour + depth,
|
||||
// selection mask trio, pick MRT + depth) — used to size both the
|
||||
// reserve and the pressure carve-out when an attachment set fails.
|
||||
static std::uint64_t attachmentBytesPerPixel();
|
||||
|
||||
// A required allocation of `bytes` (`what` names it for the log)
|
||||
// failed. Lowers the budget, evicts and releases cache sub-buffers
|
||||
// down to it, and on desktop waits for the device to actually reclaim
|
||||
// them so an immediate retry can succeed. Returns false when the
|
||||
// cache had nothing left to give: the device is exhausted and the
|
||||
// caller degrades (skips the operation) rather than retrying.
|
||||
bool onRequiredAllocationFailed(const char* what, std::uint64_t bytes);
|
||||
// Unload every resident chunk whose slices live in pool sub-buffer
|
||||
// `sub_idx`; the evictor BufferPool::shrinkToCapacity calls before it
|
||||
// releases that sub-buffer.
|
||||
void evictChunksInSubBuffer(int sub_idx);
|
||||
|
||||
// Run `create` (one or more wgpu allocations totalling ~`bytes`) under
|
||||
// an allocation scope. Desktop: verified synchronously; on failure
|
||||
// `release` undoes the attempt, the cache yields, and `create` runs
|
||||
// again, until it succeeds or the cache has nothing left to give
|
||||
// (false). Web: the resources are used
|
||||
// provisionally and true is returned; if the scope later reports a
|
||||
// failure the cache yields and `on_web_failure` (if any) corrects
|
||||
// course, since the caller has long since moved on.
|
||||
bool allocateRequired(const char* what, std::uint64_t bytes,
|
||||
const std::function<void()>& create,
|
||||
const std::function<void()>& release,
|
||||
std::function<void()> on_web_failure = {});
|
||||
// allocateRequired for a single buffer: the buffer, or null when the
|
||||
// device could not fit it even after the cache yielded.
|
||||
WGPUBuffer createRequiredBuffer(const WGPUBufferDescriptor& desc,
|
||||
const char* what);
|
||||
|
||||
// (Re)create every per-pixel attachment for a width_px × height_px
|
||||
// surface as one required allocation. False when they could not be
|
||||
// allocated even after the cache yielded; render() then skips the
|
||||
// frame rather than submitting with invalid views.
|
||||
bool ensureRenderAttachments(int width_px, int height_px);
|
||||
void releaseRenderAttachments();
|
||||
|
||||
GpuBudget budget_;
|
||||
// Latched false by ensureRenderAttachments when the device could not
|
||||
// fit the attachments; re-evaluated on the next configureSurface.
|
||||
bool render_attachments_ok_ = true;
|
||||
|
||||
// The scene's models in load order (ascending session_model_id, minted at
|
||||
// request time — see loadSidecarMetadataWeb). Every per-model API indexes
|
||||
// against this, so a model keeps a stable UI slot instead of hopping with
|
||||
|
||||
@@ -109,6 +109,12 @@ endif()
|
||||
add_ifcviewer_unit_test(test_selection)
|
||||
add_ifcviewer_unit_test(test_visibility)
|
||||
|
||||
# GpuBudget: the pure policy deciding how much device memory the geometry
|
||||
# cache may hold and how it yields under pressure. No wgpu at all.
|
||||
add_ifcviewer_unit_test(test_gpu_budget
|
||||
SOURCES ${IFCVIEWER_SRC}/GpuBudget.cpp
|
||||
)
|
||||
|
||||
# BufferPool sub-allocator invariants. The pool's wgpu calls live inside
|
||||
# addSubBuffer() (the growth path); tests use the addSubBufferForTesting
|
||||
# seam to preseed sub-pools with fake handles, so the only wgpu touchpoint
|
||||
@@ -117,7 +123,7 @@ add_ifcviewer_unit_test(test_visibility)
|
||||
# the pool go out of scope holding any. Linking wgpu_native satisfies the
|
||||
# symbol regardless.
|
||||
add_ifcviewer_unit_test(test_buffer_pool
|
||||
SOURCES ${IFCVIEWER_SRC}/BufferPool.cpp
|
||||
SOURCES ${IFCVIEWER_SRC}/BufferPool.cpp ${IFCVIEWER_SRC}/GpuAllocScope.cpp
|
||||
LIBS wgpu_native
|
||||
)
|
||||
if(UNIX AND NOT APPLE AND WGPU_NATIVE_LIB_DIR)
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -252,3 +253,62 @@ TEST_CASE("free with invalid slice is a no-op", "[buffer_pool]") {
|
||||
pool.free(a);
|
||||
REQUIRE(pool.total_used_bytes() == 0);
|
||||
}
|
||||
|
||||
// ---- Budget ceiling + shrink (the cache yielding to the required tier) ----
|
||||
|
||||
TEST_CASE("can_grow respects the total-capacity budget with sub-buffer granularity", "[buffer_pool]") {
|
||||
BufferPool pool;
|
||||
FakePoolGuard guard{pool};
|
||||
// No device configured, so can_grow is false regardless; the budget
|
||||
// arithmetic is what we check via max_total_capacity_bytes.
|
||||
pool.setMaxTotalCapacity(256ull * 1024 * 1024);
|
||||
REQUIRE(pool.max_total_capacity_bytes() == 256ull * 1024 * 1024);
|
||||
pool.addSubBufferForTesting(fake_handle(1), 200ull * 1024 * 1024);
|
||||
// 200 MB held + 64 MB floor > 256 MB budget: a grow could not fit.
|
||||
REQUIRE_FALSE(pool.can_grow());
|
||||
}
|
||||
|
||||
TEST_CASE("shrinkToCapacity releases sub-buffers newest-first after the owner empties them", "[buffer_pool]") {
|
||||
BufferPool pool;
|
||||
FakePoolGuard guard{pool};
|
||||
pool.addSubBufferForTesting(fake_handle(1), 1024);
|
||||
pool.addSubBufferForTesting(fake_handle(2), 1024);
|
||||
pool.addSubBufferForTesting(fake_handle(3), 1024);
|
||||
|
||||
auto a = pool.alloc(256, 16); // sub 0
|
||||
auto b = pool.alloc(1024, 16); // sub 1 (sub 0 has only 768 left)
|
||||
auto c = pool.alloc(512, 16); // sub 0 again (first fit)
|
||||
auto d = pool.alloc(512, 16); // sub 2
|
||||
REQUIRE(a.sub_idx == 0);
|
||||
REQUIRE(b.sub_idx == 1);
|
||||
REQUIRE(c.sub_idx == 0);
|
||||
REQUIRE(d.sub_idx == 2);
|
||||
|
||||
std::vector<int> evicted;
|
||||
auto evict = [&](int sub_idx) {
|
||||
evicted.push_back(sub_idx);
|
||||
if (sub_idx == 2) pool.free(d);
|
||||
if (sub_idx == 1) pool.free(b);
|
||||
if (sub_idx == 0) { pool.free(a); pool.free(c); }
|
||||
};
|
||||
|
||||
// Shrink to 1024: drops sub 2 then sub 1; sub 0 and its slices survive
|
||||
// with their sub_idx still valid.
|
||||
const uint64_t released = pool.shrinkToCapacity(1024, evict);
|
||||
REQUIRE(released == 2048);
|
||||
REQUIRE(evicted == std::vector<int>{2, 1});
|
||||
REQUIRE(pool.sub_buffer_count() == 1);
|
||||
REQUIRE(pool.total_capacity_bytes() == 1024);
|
||||
REQUIRE(pool.total_used_bytes() == 256 + 512);
|
||||
REQUIRE(pool.largest_free_run_bytes() == 256);
|
||||
|
||||
// Already at or below target: nothing happens, evictor not consulted.
|
||||
evicted.clear();
|
||||
REQUIRE(pool.shrinkToCapacity(1024, evict) == 0);
|
||||
REQUIRE(evicted.empty());
|
||||
|
||||
// Shrinking to zero empties the pool entirely.
|
||||
REQUIRE(pool.shrinkToCapacity(0, evict) == 1024);
|
||||
REQUIRE(pool.sub_buffer_count() == 0);
|
||||
REQUIRE(evicted == std::vector<int>{0});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/********************************************************************************
|
||||
* *
|
||||
* This file is part of IfcOpenShell. *
|
||||
* *
|
||||
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the Lesser GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* Lesser GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the Lesser GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
********************************************************************************/
|
||||
|
||||
// GpuBudget decides how much device memory the streamed-geometry cache may
|
||||
// hold. It is pure policy: a number derived from what the platform can
|
||||
// tell us (desktop: driver free-memory report; web: nothing but a heap
|
||||
// ceiling) and lowered by pressure events when a required allocation fails
|
||||
// anyway. These pin down the arithmetic and the floor behaviour.
|
||||
|
||||
#include "GpuBudget.h"
|
||||
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
namespace {
|
||||
constexpr std::uint64_t MB = 1024ull * 1024;
|
||||
}
|
||||
|
||||
TEST_CASE("unknown device memory and no cap leaves the cache unbounded", "[gpu_budget]") {
|
||||
GpuBudget b;
|
||||
b.configure(0, 512 * MB, 0);
|
||||
REQUIRE_FALSE(b.bounded());
|
||||
}
|
||||
|
||||
TEST_CASE("desktop budget is free memory minus the required-tier reserve", "[gpu_budget]") {
|
||||
GpuBudget b;
|
||||
b.configure(2800 * MB, 800 * MB, 0);
|
||||
REQUIRE(b.bounded());
|
||||
REQUIRE(b.cache_budget_bytes() == 2000 * MB);
|
||||
}
|
||||
|
||||
TEST_CASE("a reserve larger than free memory floors the budget, not zero", "[gpu_budget]") {
|
||||
GpuBudget b;
|
||||
b.configure(300 * MB, 800 * MB, 0);
|
||||
REQUIRE(b.bounded());
|
||||
REQUIRE(b.cache_budget_bytes() == GpuBudget::kMinCacheBudgetBytes);
|
||||
}
|
||||
|
||||
TEST_CASE("a hard cap bounds the cache on its own (web) and clamps a device-derived budget", "[gpu_budget]") {
|
||||
GpuBudget web;
|
||||
web.configure(0, 0, 3072 * MB);
|
||||
REQUIRE(web.bounded());
|
||||
REQUIRE(web.cache_budget_bytes() == 3072 * MB);
|
||||
|
||||
GpuBudget both;
|
||||
both.configure(8000 * MB, 800 * MB, 3072 * MB);
|
||||
REQUIRE(both.cache_budget_bytes() == 3072 * MB);
|
||||
|
||||
GpuBudget small_device;
|
||||
small_device.configure(2800 * MB, 800 * MB, 3072 * MB);
|
||||
REQUIRE(small_device.cache_budget_bytes() == 2000 * MB);
|
||||
}
|
||||
|
||||
TEST_CASE("pressure lowers the budget below what the cache currently holds", "[gpu_budget]") {
|
||||
GpuBudget b;
|
||||
b.configure(0, 0, 0);
|
||||
REQUIRE_FALSE(b.bounded());
|
||||
|
||||
// The pool grew to 2048 MB unbounded; a 120 MB attachment set then failed.
|
||||
REQUIRE(b.onPressure(2048 * MB, 120 * MB));
|
||||
REQUIRE(b.bounded());
|
||||
REQUIRE(b.cache_budget_bytes()
|
||||
== 2048 * MB - 120 * MB - GpuBudget::kPressureSlackBytes);
|
||||
REQUIRE(b.pressure_events() == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("pressure is measured against actual capacity, not the previous budget", "[gpu_budget]") {
|
||||
// Budget said 2000 MB but the driver only ever granted 1024 MB; a
|
||||
// failure must carve out of the 1024, else nothing would be released.
|
||||
GpuBudget b;
|
||||
b.configure(2800 * MB, 800 * MB, 0);
|
||||
REQUIRE(b.onPressure(1024 * MB, 100 * MB));
|
||||
REQUIRE(b.cache_budget_bytes()
|
||||
== 1024 * MB - 100 * MB - GpuBudget::kPressureSlackBytes);
|
||||
}
|
||||
|
||||
TEST_CASE("pressure never raises the budget", "[gpu_budget]") {
|
||||
GpuBudget b;
|
||||
b.configure(0, 0, 500 * MB);
|
||||
// Pool is at 256 MB (below budget) and a tiny allocation fails:
|
||||
// capacity - carve is 224 MB-ish, which IS lower, so it lowers.
|
||||
REQUIRE(b.onPressure(256 * MB, 0));
|
||||
REQUIRE(b.cache_budget_bytes() == 256 * MB - GpuBudget::kPressureSlackBytes);
|
||||
// A later event whose arithmetic lands above the current budget is a no-op.
|
||||
REQUIRE_FALSE(b.onPressure(4096 * MB, 0));
|
||||
REQUIRE(b.cache_budget_bytes() == 256 * MB - GpuBudget::kPressureSlackBytes);
|
||||
}
|
||||
|
||||
TEST_CASE("pressure bottoms out at the floor and then reports exhaustion", "[gpu_budget]") {
|
||||
GpuBudget b;
|
||||
b.configure(0, 0, 0);
|
||||
REQUIRE(b.onPressure(100 * MB, 90 * MB));
|
||||
REQUIRE(b.cache_budget_bytes() == GpuBudget::kMinCacheBudgetBytes);
|
||||
// Already at the floor: nothing more to give.
|
||||
REQUIRE_FALSE(b.onPressure(64 * MB, 90 * MB));
|
||||
REQUIRE(b.pressure_events() == 2);
|
||||
}
|
||||
Reference in New Issue
Block a user