Surface VRAM shortfall to the user and let them unload models

When the geometry in view needs more GPU memory than the cache can
hold, the viewer keeps the largest on-screen chunks resident and streams
the rest as the camera moves. That is the right degradation, but it was
invisible: nothing told the user the scene did not fit, and the only
lever was removing or hiding models, neither of which is "keep it in the
federation but stop spending GPU memory on it".

Viewer core:
- ModelGpuData::unloaded, with drawable() = !hidden && !unloaded now the
  test every cull / draw / pick / streaming pass uses. unloadModel evicts
  every chunk and releases the model's own buffers; loadModel recreates
  them from the CPU mirrors (no disk read) and lets chunks stream back.
  Recompose keeps the CPU instances current while a model is unloaded so
  a reload sees up-to-date transforms. The MeshGpu/InstanceGpu record
  builders are factored out so load and reload share them.
- FrameStats reports the camera's working set: chunks wanted, how many
  of those are not resident, and their bytes.
- modelVramBytes / isModelUnloaded accessors, forwarded by ViewportWindow.

BonsaiViewer:
- Models tree gains a memory column (name | MB | eye) refreshed once a
  second and on load-state changes; unloaded models read "unloaded" in
  italics. The viewport stays the single authority for the state;
  SessionState only carries the modelLoadStateChanged notification.
- Context menu: "Unload Model" / "Load Model", distinct from hide and
  remove, reporting the MB freed in the status bar.
- Status bar notice, independent of the perf-stats toggle, once the
  shortfall has persisted for 3 s (a moment of missing chunks after any
  camera move is normal): "GPU memory full: N of M visible chunks (X MB)
  not loaded", with a tooltip pointing at Unload. The perf label also
  shows "N/M chunks waiting".

Verified on the GPU: unloading a 497 MB model frees it immediately with
the others still rendering; reloading streams all 180 chunks back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-08-23 20:09:22 +10:00
parent d755ca3a59
commit 8074541057
18 changed files with 360 additions and 72 deletions
+36 -2
View File
@@ -428,7 +428,8 @@ void MainWindow::setupPanels() {
spatial_panel_ = new modules::spatial_hierarchy::SpatialHierarchyPanel(this);
properties_panel_ = new modules::properties::PropertiesPanel(this);
models_view_ = new modules::models::ModelsPanelView(models_panel_, session_state_, this);
models_view_ = new modules::models::ModelsPanelView(
models_panel_, session_state_, viewport_widget_->viewport(), this);
spatial_view_ = new modules::spatial_hierarchy::SpatialHierarchyPanelView(spatial_panel_, session_state_, this);
properties_view_ = new modules::properties::PropertiesPanelView(properties_panel_, session_state_, this);
@@ -481,6 +482,13 @@ void MainWindow::setupStatus() {
status_mode_label_ = new QLabel("Ready", this);
status_selection_label_ = new QLabel("No selection", this);
status_perf_label_ = new QLabel(this);
status_memory_label_ = new QLabel(this);
status_memory_label_->setVisible(false);
status_memory_label_->setToolTip(
"The geometry in view needs more GPU memory than is available, so the "
"viewer keeps the largest on-screen parts resident and streams the rest "
"as you move. Right-click a model in the Models panel and choose "
"\"Unload Model\" to free its GPU memory for the others.");
status_progress_bar_ = new QProgressBar(this);
status_perf_label_->setVisible(AppSettings::instance().showStats());
status_progress_bar_->setMaximumWidth(200);
@@ -489,6 +497,7 @@ void MainWindow::setupStatus() {
statusBar()->setSizeGripEnabled(false);
statusBar()->addWidget(status_mode_label_);
statusBar()->addWidget(status_selection_label_, 1);
statusBar()->addPermanentWidget(status_memory_label_);
statusBar()->addPermanentWidget(status_perf_label_);
statusBar()->addPermanentWidget(status_progress_bar_);
@@ -554,8 +563,28 @@ void MainWindow::setupLoader() {
connect(viewport_widget_->viewport(), &ViewportWindow::frameStatsUpdated, this,
[this](const ViewportWindow::FrameStats& stats) {
if (!status_perf_label_->isVisible()) return;
const double mb = 1.0 / (1024.0 * 1024.0);
// Missing chunks are normal for a moment after every camera move
// while streaming catches up; only a shortfall that persists means
// the view does not fit, and only that is worth telling the user.
constexpr qint64 kShortfallNoticeMs = 3000;
if (stats.chunks_wanted_missing == 0) {
memory_shortfall_since_.invalidate();
status_memory_label_->setVisible(false);
} else {
if (!memory_shortfall_since_.isValid()) memory_shortfall_since_.start();
if (memory_shortfall_since_.elapsed() >= kShortfallNoticeMs) {
status_memory_label_->setText(
QString("GPU memory full: %1 of %2 visible chunks (%3 MB) not loaded")
.arg(stats.chunks_wanted_missing)
.arg(stats.chunks_wanted)
.arg(double(stats.wanted_missing_bytes) * mb, 0, 'f', 0));
status_memory_label_->setVisible(true);
}
}
if (!status_perf_label_->isVisible()) return;
QString text =
QString("%1 fps | %2 ms | %3/%4 obj | %5/%6 tri | %7 draws | VRAM %8/%9 MB")
.arg(stats.fps, 0, 'f', 1)
@@ -581,6 +610,11 @@ void MainWindow::setupLoader() {
.arg(double(stats.device_vram_used_bytes) * mb, 0, 'f', 0)
.arg(double(stats.device_vram_total_bytes) * mb, 0, 'f', 0);
}
if (stats.chunks_wanted_missing > 0) {
text += QString(" | %1/%2 chunks waiting")
.arg(stats.chunks_wanted_missing)
.arg(stats.chunks_wanted);
}
status_perf_label_->setText(text);
});
connect(viewport_widget_->viewport(), &ViewportWindow::objectPicked,
+5
View File
@@ -26,6 +26,7 @@
#include <QStringList>
class QLabel;
#include <QElapsedTimer>
class QDockWidget;
class QMenu;
class QProgressBar;
@@ -70,6 +71,10 @@ private:
QLabel* status_mode_label_ = nullptr;
QLabel* status_selection_label_ = nullptr;
QLabel* status_perf_label_ = nullptr;
// Shown while the visible geometry persistently exceeds what fits in
// GPU memory (see onFrameStats): the user's cue to unload models.
QLabel* status_memory_label_ = nullptr;
QElapsedTimer memory_shortfall_since_;
QProgressBar* status_progress_bar_ = nullptr;
bonsaiviewer::components::TabBar* ribbon_tabs_ = nullptr;
QStackedWidget* ribbon_pages_ = nullptr;
+4
View File
@@ -199,6 +199,10 @@ void SessionState::notifyModelGeometryReady(uint32_t session_model_id) {
emit modelGeometryReady(session_model_id);
}
void SessionState::notifyModelLoadStateChanged(const QString& model_id) {
emit modelLoadStateChanged(model_id);
}
void SessionState::notifyProjectOpened(const QString& path) {
emit projectOpened(path);
}
+5
View File
@@ -89,6 +89,7 @@ public:
void notifyFederationChanged();
void notifyVisibilityChanged();
void notifyModelGeometryReady(uint32_t session_model_id);
void notifyModelLoadStateChanged(const QString& model_id);
void notifyProjectOpened(const QString& path);
void notifyProjectSaved(const QString& path);
void notifyProjectReset();
@@ -107,6 +108,10 @@ signals:
// for both sidecar-cache and stream loads; subscribers that just need to
// re-derive view state (e.g. ViewportView::refresh) listen to this.
void modelGeometryReady(uint32_t session_model_id);
// Fires when a model was unloaded from, or loaded back onto, the GPU
// (commands::unloadModel / loadModel). The viewport is the authority
// for the state itself — ViewportWindow::isModelUnloaded.
void modelLoadStateChanged(const QString& model_id);
// Fires when a model's live IFC data source (the .ifc/.rdb, opened in the
// background after a sidecar-cache hit) becomes available for queries —
// e.g. so the spatial hierarchy can be built once the file is loaded.
@@ -262,6 +262,28 @@ void removeModel(SessionState& session, ViewportWindow& viewport, QWidget& host,
session.setStatusMessage("Models", "Model removed");
}
void unloadModel(SessionState& session, ViewportWindow& viewport, const QString& model_id) {
const uint32_t session_model_id = session.sessionModelIdForModelId(model_id);
if (session_model_id == 0) return;
if (session.loader()->isLoadingModel(session_model_id)) return;
const double freed_mb = double(viewport.modelVramBytes(session_model_id)) / (1024.0 * 1024.0);
viewport.unloadModel(session_model_id);
session.notifyModelLoadStateChanged(model_id);
session.setStatusMessage("Models", QString("Model unloaded (freed %1 MB of GPU memory)")
.arg(freed_mb, 0, 'f', 0));
}
void loadModel(SessionState& session, ViewportWindow& viewport, const QString& model_id) {
const uint32_t session_model_id = session.sessionModelIdForModelId(model_id);
if (session_model_id == 0) return;
if (!viewport.loadModel(session_model_id)) {
session.setStatusMessage("Models", "Not enough GPU memory to load this model");
return;
}
session.notifyModelLoadStateChanged(model_id);
session.setStatusMessage("Models", "Model loaded");
}
void viewModels(SessionState& session, ViewportWindow& viewport, const QStringList& model_ids) {
// Federation ids are the panel's currency; the viewport speaks session
// model ids. sessionModelIdForModelId returns 0 for a model the viewport
@@ -60,6 +60,12 @@ void moveGroup(SessionState& session, const QString& id, const QString& parent_g
void moveModels(SessionState& session, const QStringList& ids, const QString& parent_group_id);
void removeGroup(SessionState& session, QWidget& host, const QString& group_id);
void removeModel(SessionState& session, ViewportWindow& viewport, QWidget& host, const QString& model_id);
// GPU residency, distinct from visibility (hide) and from membership
// (remove): unloadModel frees everything the model holds on the device
// while it stays in the federation; loadModel brings it back. Both emit
// modelLoadStateChanged.
void unloadModel(SessionState& session, ViewportWindow& viewport, const QString& model_id);
void loadModel(SessionState& session, ViewportWindow& viewport, const QString& model_id);
// "View Selected Model" — frame the camera on just these models' geometry, the
// way View All frames the whole federation. Models that carry no loaded
// geometry (never loaded, or still streaming their metadata) contribute
@@ -25,16 +25,25 @@
#include "../../../ifcviewer/Federation.h"
#include <QBrush>
#include <QFont>
#include <QColor>
namespace bonsaiviewer::modules::models {
namespace {
QStandardItem* siblingVisibilityItem(QStandardItem* name_item) {
QStandardItem* siblingItem(QStandardItem* name_item, Column column) {
QStandardItem* parent = name_item->parent();
if (!parent) parent = name_item->model()->invisibleRootItem();
return parent->child(name_item->row(), 1);
return parent->child(name_item->row(), int(column));
}
QStandardItem* siblingVisibilityItem(QStandardItem* name_item) {
return siblingItem(name_item, VisibilityColumn);
}
QString formatMegabytes(quint64 bytes) {
return QString("%1 MB").arg(double(bytes) / (1024.0 * 1024.0), 0, 'f', 0);
}
template <typename F>
@@ -51,7 +60,7 @@ FederationItemModel::FederationItemModel(Federation* federation, QObject* parent
: QStandardItemModel(parent)
, federation_(federation)
{
setColumnCount(2);
setColumnCount(ColumnCount);
rebuildAll();
connect(federation_, &Federation::groupAdded, this, &FederationItemModel::onGroupAdded);
@@ -67,7 +76,7 @@ FederationItemModel::FederationItemModel(Federation* federation, QObject* parent
void FederationItemModel::rebuildAll() {
clear();
setColumnCount(2);
setColumnCount(ColumnCount);
id_to_name_item_.clear();
for (const auto& root_group : federation_->rootGroups()) {
@@ -121,6 +130,14 @@ QStandardItem* FederationItemModel::makeVisibilityItem(ItemKind kind, bool visib
return item;
}
QStandardItem* FederationItemModel::makeMemoryItem() const {
auto* item = new QStandardItem(QString());
item->setEditable(false);
item->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter);
item->setForeground(QBrush(QColor(bonsaiviewer::ViewerSettings::instance().color("disabled_text"))));
return item;
}
void FederationItemModel::styleRowVisibility(QStandardItem* name_item, bool visible) const {
QStandardItem* vis_item = siblingVisibilityItem(name_item);
if (visible) {
@@ -133,6 +150,22 @@ void FederationItemModel::styleRowVisibility(QStandardItem* name_item, bool visi
}
}
void FederationItemModel::setModelResidency(const QString& model_id, bool unloaded, quint64 vram_bytes) {
QStandardItem* name_item = findItem(model_id);
if (!name_item) return;
QStandardItem* memory_item = siblingItem(name_item, MemoryColumn);
if (!memory_item) return;
const QString text = unloaded ? QStringLiteral("unloaded")
: vram_bytes > 0 ? formatMegabytes(vram_bytes)
: QString();
if (memory_item->text() != text) memory_item->setText(text);
QFont font = name_item->font();
if (font.italic() != unloaded) {
font.setItalic(unloaded);
name_item->setFont(font);
}
}
QStandardItem* FederationItemModel::findItem(const QString& id) const {
return id_to_name_item_.value(id, nullptr);
}
@@ -148,7 +181,7 @@ void FederationItemModel::appendModelTo(QStandardItem* parent_item, const QStrin
if (!model) return;
auto* name_item = makeModelNameItem(model_id, model->display_name);
auto* vis_item = makeVisibilityItem(ItemKind::Model, federation_->isModelEffectivelyVisible(model_id));
parent_item->appendRow({name_item, vis_item});
parent_item->appendRow({name_item, makeMemoryItem(), vis_item});
id_to_name_item_.insert(model_id, name_item);
styleRowVisibility(name_item, federation_->isModelEffectivelyVisible(model_id));
}
@@ -158,7 +191,7 @@ void FederationItemModel::appendGroupSubtreeTo(QStandardItem* parent_item, const
if (!group) return;
auto* name_item = makeGroupNameItem(group_id, group->display_name);
auto* vis_item = makeVisibilityItem(ItemKind::Group, group->visible);
parent_item->appendRow({name_item, vis_item});
parent_item->appendRow({name_item, makeMemoryItem(), vis_item});
id_to_name_item_.insert(group_id, name_item);
styleRowVisibility(name_item, group->visible);
@@ -31,7 +31,7 @@ class Federation;
namespace bonsaiviewer::modules::models {
// QStandardItemModel that mirrors the Federation tree (groups + models in
// two columns: name + visibility icon). Subscribes directly to Federation's
// three columns: name, GPU memory, visibility icon). Subscribes directly to Federation's
// granular signals so each mutation only touches the affected rows — view
// state (expansion, selection, scroll) is preserved automatically.
//
@@ -57,6 +57,12 @@ public:
// previously- and newly-active model rows.
void setActiveModelId(const QString& model_id);
// GPU residency is viewport state, not Federation state, so it is pushed
// in by the owning View: the memory column shows `vram_bytes` for a
// loaded model and "unloaded" for one the user unloaded (which is also
// drawn in italics). Models the viewport knows nothing about show blank.
void setModelResidency(const QString& model_id, bool unloaded, quint64 vram_bytes);
private slots:
void onGroupAdded(const QString& group_id);
void onGroupRemoved(const QString& group_id);
@@ -72,6 +78,7 @@ private:
QStandardItem* makeGroupNameItem(const QString& group_id, const QString& display_name) const;
QStandardItem* makeModelNameItem(const QString& model_id, const QString& display_name) const;
QStandardItem* makeVisibilityItem(ItemKind kind, bool visible) const;
QStandardItem* makeMemoryItem() const;
void styleRowVisibility(QStandardItem* name_item, bool visible) const;
QStandardItem* findItem(const QString& id) const;
+28 -6
View File
@@ -28,6 +28,7 @@
#include "../../components/Section.h"
#include "../../components/SvgIcon.h"
#include "../../../ifcviewer/Federation.h"
#include "../../../ifcviewer/ViewportWindow.h"
#include <QDataStream>
#include <QDrag>
@@ -79,6 +80,7 @@ QStringList selectedModelIdsAt(QTreeView* tree, const QModelIndex& clicked_index
}
constexpr int kVisibilityColumnWidth = 28;
constexpr int kMemoryColumnWidth = 72; // "1234 MB" / "unloaded"
// QTreeView subclass that handles drag-and-drop. Drop logic dispatches
// through commands (not directly into the model) so notifications + status
@@ -250,7 +252,7 @@ ModelsPanel::ModelsPanel(bonsaiviewer::SessionState* session_state,
connect(tree_, &QTreeView::clicked, this, [this](const QModelIndex& index) {
if (!index.isValid()) return;
if (index.column() == 1) {
if (index.column() == VisibilityColumn) {
commands::toggleVisibility(*session_state_, kindOf(index), idOf(index));
return;
}
@@ -380,6 +382,24 @@ ModelsPanel::ModelsPanel(bonsaiviewer::SessionState* session_state,
commands::saveModelAsToCloud(*session_state_, *this, id);
});
// GPU residency. Unload keeps the model in the federation (and
// its visibility) but frees everything it holds on the GPU — the
// lever when the scene does not fit in VRAM. Load brings it back.
menu.addSeparator();
const uint32_t session_model_id = session_state_->sessionModelIdForModelId(id);
const bool unloaded = session_model_id != 0 && viewport_->isModelUnloaded(session_model_id);
QAction* residency = menu.addAction(
components::icons::makeSvgIcon(":/icons/cube.svg"),
unloaded ? "Load Model" : "Unload Model");
residency->setEnabled(session_model_id != 0);
residency->setToolTip(unloaded
? "Allocate GPU memory for this model again and stream its geometry back in."
: "Free this model's GPU memory while keeping it in the federation.");
connect(residency, &QAction::triggered, this, [this, id, unloaded]() {
if (unloaded) commands::loadModel(*session_state_, *viewport_, id);
else commands::unloadModel(*session_state_, *viewport_, id);
});
menu.addSeparator();
QAction* remove = menu.addAction(
components::icons::makeSvgIcon(":/icons/minus-square.svg"), "Remove Model");
@@ -409,14 +429,16 @@ void ModelsPanel::setModel(FederationItemModel* model) {
}
void ModelsPanel::applyColumnLayout() {
// Column 0 (name) stretches to fill; column 1 (visibility icon) is fixed.
// The name stretches to fill; memory and visibility are fixed.
QHeaderView* header = tree_->header();
if (header->count() < 2) return;
if (header->count() < ColumnCount) return;
header->setStretchLastSection(false);
header->setMinimumSectionSize(kVisibilityColumnWidth);
header->setSectionResizeMode(0, QHeaderView::Stretch);
header->setSectionResizeMode(1, QHeaderView::Fixed);
header->resizeSection(1, kVisibilityColumnWidth);
header->setSectionResizeMode(NameColumn, QHeaderView::Stretch);
header->setSectionResizeMode(MemoryColumn, QHeaderView::Fixed);
header->resizeSection(MemoryColumn, kMemoryColumnWidth);
header->setSectionResizeMode(VisibilityColumn, QHeaderView::Fixed);
header->resizeSection(VisibilityColumn, kVisibilityColumnWidth);
}
} // namespace bonsaiviewer::modules::models
+8
View File
@@ -31,6 +31,14 @@ enum class ItemKind {
Model,
};
// Columns of the models tree: name | GPU memory | visibility eye.
enum Column : int {
NameColumn = 0,
MemoryColumn = 1,
VisibilityColumn = 2,
ColumnCount = 3,
};
struct TreeNode {
QString id;
QString name;
+30 -1
View File
@@ -26,6 +26,9 @@
#include "../../ViewerSettings.h"
#include "../../SessionState.h"
#include "../../../ifcviewer/Federation.h"
#include "../../../ifcviewer/ViewportWindow.h"
#include <QTimer>
namespace bonsaiviewer::modules::models {
@@ -54,17 +57,19 @@ QList<GroupOption> validMoveTargets(const Federation& federation,
ModelsPanelView::ModelsPanelView(ModelsPanel* widget,
bonsaiviewer::SessionState* session_state,
ViewportWindow* viewport,
QObject* parent)
: QObject(parent)
, widget_(widget)
, session_state_(session_state)
, viewport_(viewport)
, model_(new FederationItemModel(session_state->federation(), this))
{
widget_->setModel(model_);
// Coarse signals: full rebuild + re-style. The granular Federation
// signals are handled inside FederationItemModel and don't reach here.
auto rebuild = [this]() { model_->rebuildAll(); };
auto rebuild = [this]() { model_->rebuildAll(); refreshResidency(); };
connect(session_state_, &SessionState::projectReset, this, rebuild);
connect(session_state_, &SessionState::projectOpened, this, rebuild);
connect(&bonsaiviewer::ViewerSettings::instance(),
@@ -73,6 +78,30 @@ ModelsPanelView::ModelsPanelView(ModelsPanel* widget,
connect(session_state_, &SessionState::activeModelChanged, this, [this](const QString& model_id) {
model_->setActiveModelId(model_id);
});
// Residency: immediately on the events that change it, and on a slow
// tick for the memory figures, which move as chunks stream.
auto refresh = [this]() { refreshResidency(); };
connect(session_state_, &SessionState::modelLoadStateChanged, this, refresh);
connect(session_state_, &SessionState::modelGeometryReady, this, refresh);
connect(session_state_, &SessionState::modelsChanged, this, refresh);
auto* tick = new QTimer(this);
tick->setInterval(1000);
connect(tick, &QTimer::timeout, this, refresh);
tick->start();
}
void ModelsPanelView::refreshResidency() {
for (const auto& model : session_state_->federation()->models()) {
const uint32_t session_model_id = session_state_->sessionModelIdForModelId(model.id);
if (session_model_id == 0) {
model_->setModelResidency(model.id, false, 0);
continue;
}
model_->setModelResidency(model.id,
viewport_->isModelUnloaded(session_model_id),
viewport_->modelVramBytes(session_model_id));
}
}
} // namespace bonsaiviewer::modules::models
+10
View File
@@ -26,6 +26,7 @@
#include <QObject>
class Federation;
class ViewportWindow;
namespace bonsaiviewer { class SessionState; }
namespace bonsaiviewer::modules::models {
@@ -45,16 +46,25 @@ QList<GroupOption> validMoveTargets(const Federation& federation,
// coarse session signals (project open/reset, theme change) — those are the
// "rebuild from scratch" cases the model itself doesn't subscribe to.
// Granular Federation events are handled inside the model.
//
// Also the bridge for the one thing the tree shows that is not Federation
// state: each model's GPU residency (memory column, unloaded styling). The
// viewport owns that state, so this view polls it once a second — the
// numbers move continuously while geometry streams — and pushes it in.
class ModelsPanelView : public QObject {
Q_OBJECT
public:
explicit ModelsPanelView(ModelsPanel* widget,
bonsaiviewer::SessionState* session_state,
ViewportWindow* viewport,
QObject* parent = nullptr);
private:
void refreshResidency();
ModelsPanel* widget_ = nullptr;
bonsaiviewer::SessionState* session_state_ = nullptr;
ViewportWindow* viewport_ = nullptr;
FederationItemModel* model_ = nullptr;
};
+9
View File
@@ -44,6 +44,15 @@ struct FrameStats {
std::uint64_t vram_used_bytes;
std::uint64_t vram_capacity_bytes;
std::uint64_t vram_budget_bytes;
// The camera's working set: chunks the streaming driver wants resident
// (in frustum and large enough on screen) and how many of those are
// not — i.e. geometry the user should be seeing but is not yet, or
// cannot be because it does not fit the cache. Transiently non-zero
// after any camera move; persistently non-zero means the scene does
// not fit in VRAM.
std::uint32_t chunks_wanted;
std::uint32_t chunks_wanted_missing;
std::uint64_t wanted_missing_bytes; // raw vertex + index bytes of the missing chunks
// 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.
+9
View File
@@ -447,6 +447,15 @@ struct ModelGpuData {
// is gone; cull iterates m.chunks instead.
bool hidden = false;
// Unloaded by the user: every chunk evicted and the model's own GPU
// buffers released, while the CPU mirrors (meshes, instances, chunk
// plan, element metadata) stay so the entry remains in the scene and
// loadModel can bring it back without touching the disk. Distinct
// from hidden (a viewing state; the geometry may stay resident) and
// from removal (the model leaves the scene).
bool unloaded = false;
// Whether cull / draw / pick / streaming should consider this model.
bool drawable() const { return !hidden && !unloaded; }
// Per-model federation matrices in metres. Default identity → no
// per-model contribution to the composed transform. See bonsai's
+119 -56
View File
@@ -109,6 +109,39 @@ void releaseWgpuModelGpuData(ModelGpuData& m, BufferPool& pool) {
// ---- Scene mutators -------------------------------------------------------
namespace {
// The GPU-side records the shaders read, built from the CPU mirrors.
std::vector<MeshGpu> meshGpuRecords(const std::vector<MeshInfo>& meshes) {
std::vector<MeshGpu> gpu;
gpu.reserve(meshes.size());
for (const auto& mesh_info : meshes) {
MeshGpu rec = {};
for (int a = 0; a < 3; ++a) {
rec.aabb_min[a] = mesh_info.local_aabb_min[a];
rec.aabb_max[a] = mesh_info.local_aabb_max[a];
}
gpu.push_back(rec);
}
return gpu;
}
std::vector<InstanceGpu> instanceGpuRecords(const std::vector<InstanceInfo>& instances) {
std::vector<InstanceGpu> gpu(instances.size());
for (size_t i = 0; i < instances.size(); ++i) {
const InstanceInfo& inst = instances[i];
InstanceGpu& dst = gpu[i];
std::memcpy(dst.transform, inst.transform, sizeof(dst.transform));
dst.object_id = inst.object_id;
dst.color_override_rgba8 = inst.color_override_rgba8;
dst.mesh_id = inst.mesh_id;
dst._pad1 = 0;
}
return gpu;
}
} // namespace
void ViewportCore::removeModel(uint32_t session_model_id) {
auto it = models_gpu_.find(session_model_id);
if (it == models_gpu_.end()) return;
@@ -142,6 +175,46 @@ void ViewportCore::showModel(uint32_t session_model_id) {
host_->requestFrame();
}
void ViewportCore::unloadModel(uint32_t session_model_id) {
auto it = models_gpu_.find(session_model_id);
if (it == models_gpu_.end() || it->second.unloaded) return;
ModelGpuData& m = it->second;
for (std::size_t ci = 0; ci < m.chunks.size(); ++ci) unloadChunk(m, ci);
releaseModelBuffers(m);
m.unloaded = true;
host_->requestFrame();
}
bool ViewportCore::loadModel(uint32_t session_model_id) {
auto it = models_gpu_.find(session_model_id);
if (it == models_gpu_.end()) return false;
ModelGpuData& m = it->second;
if (!m.unloaded) return true;
// Chunks stream back in on demand once the model is drawable again;
// only the model's own buffers have to be recreated here.
if (!createModelBuffers(session_model_id, m,
meshGpuRecords(m.meshes), instanceGpuRecords(m.instances))) {
Log::warn() << "[wgpu] model " << session_model_id
<< " not reloaded: the device cannot fit its metadata buffers";
return false;
}
m.unloaded = false;
host_->requestFrame();
return true;
}
bool ViewportCore::isModelUnloaded(uint32_t session_model_id) const {
auto it = models_gpu_.find(session_model_id);
return it != models_gpu_.end() && it->second.unloaded;
}
std::uint64_t ViewportCore::modelVramBytes(uint32_t session_model_id) const {
auto it = models_gpu_.find(session_model_id);
if (it == models_gpu_.end()) return 0;
const ModelGpuData& m = it->second;
return m.vram_bytes_vbo + m.vram_bytes_ebo + m.vram_bytes_ssbo;
}
void ViewportCore::setFederatedFalseOrigin(const Eigen::Matrix4d& matrix_meters) {
if (federated_false_origin_meters_ == matrix_meters) return;
federated_false_origin_meters_ = matrix_meters;
@@ -264,17 +337,7 @@ float ViewportCore::chunkScreenAreaPx(const ModelGpuData::Chunk& c,
void ViewportCore::uploadInstanceRecords(ModelGpuData& m) {
if (!wgpu_initialized_ || m.instances.empty() || m.instance_storage == nullptr) return;
std::vector<InstanceGpu> gpu(m.instances.size());
for (size_t i = 0; i < m.instances.size(); ++i) {
const InstanceInfo& inst = m.instances[i];
InstanceGpu& dst = gpu[i];
std::memcpy(dst.transform, inst.transform, sizeof(dst.transform));
dst.object_id = inst.object_id;
dst.color_override_rgba8 = inst.color_override_rgba8;
dst.mesh_id = inst.mesh_id;
dst._pad1 = 0;
}
const std::vector<InstanceGpu> gpu = instanceGpuRecords(m.instances);
wgpuQueueWriteBuffer(queue_, m.instance_storage, 0,
gpu.data(), gpu.size() * sizeof(InstanceGpu));
}
@@ -284,8 +347,11 @@ void ViewportCore::recomposeAndUploadModel(uint32_t session_model_id) {
auto it = models_gpu_.find(session_model_id);
if (it == models_gpu_.end()) return;
ModelGpuData& m = it->second;
if (m.instances.empty() || m.instance_storage == nullptr) return;
if (m.instances.empty()) return;
// The CPU mirrors are recomposed even while the model is unloaded (no
// instance_storage): cull and loadModel read them, and the upload
// below is skipped on its own.
for (auto& inst : m.instances) composeInstanceFromPlacement(inst, m);
uploadInstanceRecords(m);
@@ -2451,7 +2517,7 @@ void ViewportCore::driveStreamingLoads() {
// last ~30 frames.
constexpr float HISTORY_ALPHA = 1.0f / 30.0f;
for (auto& [session_model_id, m] : models_gpu_) {
if (m.hidden) continue;
if (!m.drawable()) continue;
for (auto& c : m.chunks) {
if (c.is_resident && c.frustum_visible_count > 0) {
c.last_visible_frame_idx = streaming_frame_idx_;
@@ -2656,7 +2722,7 @@ void ViewportCore::driveStreamingLoads() {
std::vector<Candidate> candidates;
candidates.reserve(64);
for (auto& [session_model_id, m] : models_gpu_) {
if (m.streaming_file_path.empty() || m.hidden) continue;
if (m.streaming_file_path.empty() || !m.drawable()) continue;
for (std::size_t ci = 0; ci < m.chunks.size(); ++ci) {
auto& c = m.chunks[ci];
if (c.is_resident) continue;
@@ -2872,7 +2938,7 @@ void ViewportCore::driveStreamingLoads() {
const bool growth_may_land = pool_.growth_pending() || pool_.can_grow();
bool visible_pending = false;
for (const auto& [session_model_id, m] : models_gpu_) {
if (m.streaming_file_path.empty() || m.hidden) continue;
if (m.streaming_file_path.empty() || !m.drawable()) continue;
for (const auto& c : m.chunks) {
if (c.is_resident) continue;
if (c.is_loading) { visible_pending = true; break; }
@@ -3557,38 +3623,18 @@ void ViewportCore::applyCachedModel(std::uint32_t session_model_id,
// Index section is NOT loaded upfront. Each chunk's index slice is
// range-read alongside its vertex bytes in loadChunkBytesAndUploadGpu.
// MeshGpu storage (per-mesh quant basis).
std::vector<MeshGpu> mesh_gpu;
mesh_gpu.reserve(metadata.meta.meshes.size());
for (const auto& mesh_info : metadata.meta.meshes) {
MeshGpu mesh_gpu_record = {};
mesh_gpu_record.aabb_min[0] = mesh_info.local_aabb_min[0];
mesh_gpu_record.aabb_min[1] = mesh_info.local_aabb_min[1];
mesh_gpu_record.aabb_min[2] = mesh_info.local_aabb_min[2];
mesh_gpu_record.aabb_max[0] = mesh_info.local_aabb_max[0];
mesh_gpu_record.aabb_max[1] = mesh_info.local_aabb_max[1];
mesh_gpu_record.aabb_max[2] = mesh_info.local_aabb_max[2];
mesh_gpu.push_back(mesh_gpu_record);
}
// InstanceGpu storage. Rebase object_ids globally.
// Rebase object_ids globally (element metadata records rebase to match).
const std::uint32_t object_id_base = next_object_id_;
std::uint32_t max_local_id = 0;
std::vector<InstanceGpu> inst_gpu;
inst_gpu.reserve(metadata.meta.instances.size());
for (auto& instance_cpu : metadata.meta.instances) {
if (instance_cpu.object_id > max_local_id) max_local_id = instance_cpu.object_id;
instance_cpu.object_id = object_id_base + instance_cpu.object_id;
InstanceGpu instance_gpu = {};
std::memcpy(instance_gpu.transform, instance_cpu.transform, sizeof(instance_gpu.transform));
instance_gpu.object_id = instance_cpu.object_id;
instance_gpu.color_override_rgba8 = instance_cpu.color_override_rgba8;
instance_gpu.mesh_id = instance_cpu.mesh_id;
inst_gpu.push_back(instance_gpu);
}
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
model_gpu_data.object_id_base = object_id_base;
const std::vector<MeshGpu> mesh_gpu = meshGpuRecords(metadata.meta.meshes);
const std::vector<InstanceGpu> inst_gpu = instanceGpuRecords(metadata.meta.instances);
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";
@@ -3980,7 +4026,7 @@ void ViewportCore::pumpWebChunkLoads() {
auto it = models_gpu_.find(next.session_model_id);
if (it == models_gpu_.end()) continue;
ModelGpuData& m = it->second;
if (m.hidden || next.ci >= m.chunks.size()) continue;
if (!m.drawable() || next.ci >= m.chunks.size()) continue;
auto& c = m.chunks[next.ci];
if (c.is_resident || c.is_loading) continue;
if (c.contribution_visible_count == 0) continue; // no longer worth drawing
@@ -4328,6 +4374,7 @@ void ViewportCore::streamingProgress(int& resident_chunks, int& total_chunks) co
resident_chunks = 0;
total_chunks = 0;
for (const auto& [session_model_id, m] : models_gpu_) {
if (m.unloaded) continue;
for (const auto& c : m.chunks) {
++total_chunks;
if (c.is_resident) ++resident_chunks;
@@ -4359,7 +4406,7 @@ void ViewportCore::streamingModelProgress(int idx, int& resident_chunks,
total_chunks = 0;
if (idx < 0 || idx >= int(models_gpu_.size())) return;
auto it = models_gpu_.find(modelIdsInLoadOrder()[std::size_t(idx)]);
if (it == models_gpu_.end()) return;
if (it == models_gpu_.end() || it->second.unloaded) return;
for (const auto& c : it->second.chunks) {
++total_chunks;
if (c.is_resident) ++resident_chunks;
@@ -4428,7 +4475,7 @@ void ViewportCore::streamingByteProgress(std::uint64_t& total_bytes,
// whole model this view even requires.
total_bytes = needed_bytes = loaded_bytes = 0;
for (const auto& [session_model_id, m] : models_gpu_) {
if (m.hidden) continue;
if (!m.drawable()) continue;
for (const auto& c : m.chunks) {
// Report COMPRESSED bytes — what actually crosses the network. Fall
// back to raw for direct (in-memory) loads that have no blobs.
@@ -5682,7 +5729,7 @@ void ViewportCore::encodeSelectionMaskPass(WGPUCommandEncoder enc) {
// Same draw stream as the main pass, opaque and transparent together —
// a selected element that happens to be translucent still gets a halo.
for (const auto& [session_model_id, m] : models_gpu_) {
if (m.hidden) continue;
if (!m.drawable()) continue;
for (const auto& c : m.chunks) {
if (!c.bind_group || c.total_visible_vertices == 0) continue;
wgpuRenderPassEncoderSetBindGroup(pass, 1, c.bind_group, 0, nullptr);
@@ -6144,7 +6191,7 @@ void ViewportCore::encodePickReadbackToStaging(int x_pixels, int y_pixels,
wgpuRenderPassEncoderSetPipeline(pass, pick_pipeline_);
wgpuRenderPassEncoderSetBindGroup(pass, 0, frame_bind_group_, 0, nullptr);
for (const auto& [session_model_id, m] : models_gpu_) {
if (m.hidden) continue;
if (!m.drawable()) continue;
for (const auto& c : m.chunks) {
if (!c.bind_group || c.total_visible_vertices == 0) continue;
wgpuRenderPassEncoderSetBindGroup(pass, 1, c.bind_group, 0, nullptr);
@@ -6343,7 +6390,7 @@ void ViewportCore::isolateSelected() {
// object_id 0 (unpickable) is skipped.
const auto& sel_ids = selection_.selectionIds();
for (const auto& [session_model_id, m] : models_gpu_) {
if (m.hidden) continue;
if (!m.drawable()) continue;
for (const InstanceInfo& inst : m.instances) {
if (inst.object_id == 0) continue;
if (sel_ids.find(inst.object_id) == sel_ids.end())
@@ -6366,7 +6413,7 @@ void ViewportCore::hideAll() {
// showAll, and isolateSelected with an empty selection. Model-hidden
// models are already gone from the cull, so they contribute nothing.
for (const auto& [session_model_id, m] : models_gpu_) {
if (m.hidden) continue;
if (!m.drawable()) continue;
for (const InstanceInfo& inst : m.instances) visibility_.hide(inst.object_id);
}
Log::info().noquote().nospace() << "[wgpu] hid all (" << visibility_.hiddenCount() << ")";
@@ -6546,7 +6593,7 @@ bool ViewportCore::encodeBoxPickToStaging(int& x, int& y, int& w, int& h,
wgpuRenderPassEncoderSetPipeline(pass, pick_pipeline_);
wgpuRenderPassEncoderSetBindGroup(pass, 0, frame_bind_group_, 0, nullptr);
for (const auto& [session_model_id, m] : models_gpu_) {
if (m.hidden) continue;
if (!m.drawable()) continue;
for (const auto& c : m.chunks) {
if (!c.bind_group || c.total_visible_vertices == 0) continue;
wgpuRenderPassEncoderSetBindGroup(pass, 1, c.bind_group, 0, nullptr);
@@ -6666,7 +6713,7 @@ bool ViewportCore::encodeXrayBoxPickToStaging(int& x, int& y, int& w, int& h,
wgpuRenderPassEncoderSetPipeline(pass, box_pick_pipeline_);
wgpuRenderPassEncoderSetBindGroup(pass, 0, frame_bind_group_, 0, nullptr);
for (const auto& [session_model_id, m] : models_gpu_) {
if (m.hidden) continue;
if (!m.drawable()) continue;
for (const auto& c : m.chunks) {
if (!c.bind_group || c.total_visible_vertices == 0) continue;
wgpuRenderPassEncoderSetBindGroup(pass, 1, c.bind_group, 0, nullptr);
@@ -6856,7 +6903,7 @@ bool ViewportCore::raycastSurfaceForObject(std::uint32_t object_id, int x_pixels
float best_radius = 0.0f;
bool found = false;
for (const auto& [session_model_id, m] : models_gpu_) {
if (m.hidden) continue;
if (!m.drawable()) continue;
for (const auto& inst : m.instances) {
if (inst.object_id != object_id) continue;
float t = 0.0f;
@@ -7171,7 +7218,7 @@ bool ViewportCore::raycast(const float origin[3], const float dir[3],
float best_normal[3] = {0, 0, 0};
for (const auto& [session_model_id, m] : models_gpu_) {
if (m.hidden) continue;
if (!m.drawable()) continue;
for (std::uint32_t inst_idx = 0; inst_idx < std::uint32_t(m.instances.size()); ++inst_idx) {
const InstanceInfo& inst = m.instances[inst_idx];
if (!rayAabbSlab(origin, inv_d, inst.world_aabb_min, inst.world_aabb_max)) {
@@ -7732,7 +7779,7 @@ void ViewportCore::render() {
std::vector<std::pair<std::uint32_t, std::future<std::uint32_t>>> futures;
futures.reserve(models_gpu_.size());
for (auto& [session_model_id, m] : models_gpu_) {
if (m.hidden) continue;
if (!m.drawable()) continue;
auto& m_ref = m;
futures.emplace_back(session_model_id, std::async(std::launch::async,
[this, &m_ref, &planes, &eye_a, &fwd_a, &right_a, &up_a,
@@ -7749,7 +7796,7 @@ void ViewportCore::render() {
}
} else {
for (auto& [session_model_id, m] : models_gpu_) {
if (m.hidden) continue;
if (!m.drawable()) continue;
hiz_reject_count_ += cullModelCpuCompute(
m, planes, eye_a, fwd_a, right_a, up_a, focal_px,
effective_min_px, lod1_pixel_threshold_,
@@ -7761,7 +7808,7 @@ void ViewportCore::render() {
Stopwatch upload_timer;
upload_timer.start();
for (auto& [session_model_id, m] : models_gpu_) {
if (m.hidden) continue;
if (!m.drawable()) continue;
cullModelCpuUpload(m);
for (const auto& c : m.chunks) {
last_visible_objects_ += c.total_visible_draws;
@@ -7842,7 +7889,7 @@ void ViewportCore::render() {
wgpuRenderPassEncoderSetBindGroup(pass, 0, frame_bind_group_, 0, nullptr);
for (const auto& [session_model_id, m] : models_gpu_) {
if (m.hidden) continue;
if (!m.drawable()) continue;
for (const auto& c : m.chunks) {
if (!c.bind_group || c.opaque_visible_vertices == 0) continue;
wgpuRenderPassEncoderSetBindGroup(pass, 1, c.bind_group, 0, nullptr);
@@ -7853,7 +7900,7 @@ void ViewportCore::render() {
wgpuRenderPassEncoderSetPipeline(pass, main_pipeline_transparent_);
for (const auto& [session_model_id, m] : models_gpu_) {
if (m.hidden) continue;
if (!m.drawable()) continue;
for (const auto& c : m.chunks) {
if (!c.bind_group) continue;
const std::uint32_t transparent_verts =
@@ -7975,13 +8022,29 @@ void ViewportCore::render() {
stats.unique_meshes = total_meshes;
std::uint32_t draw_calls = 0;
for (const auto& [session_model_id, mm] : models_gpu_) {
if (mm.hidden) continue;
if (!mm.drawable()) continue;
for (const auto& c : mm.chunks) {
if (c.is_resident && c.total_visible_draws > 0) ++draw_calls;
}
}
stats.gl_draw_calls = draw_calls;
stats.indirect_sub_draws = last_sub_draws_;
std::uint32_t wanted = 0, wanted_missing = 0;
std::uint64_t wanted_missing_bytes = 0;
for (const auto& [session_model_id, mm] : models_gpu_) {
if (!mm.drawable()) continue;
for (const auto& c : mm.chunks) {
if (c.contribution_visible_count == 0) continue;
++wanted;
if (c.is_resident) continue;
++wanted_missing;
wanted_missing_bytes += c.vertex_byte_size
+ c.index_count * sizeof(std::uint32_t);
}
}
stats.chunks_wanted = wanted;
stats.chunks_wanted_missing = wanted_missing;
stats.wanted_missing_bytes = wanted_missing_bytes;
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;
+11
View File
@@ -157,6 +157,17 @@ public:
void resetScene();
void hideModel(uint32_t session_model_id);
void showModel(uint32_t session_model_id);
// Release a model's GPU memory (every chunk + its own buffers) while
// keeping it in the scene; loadModel recreates the buffers from the
// CPU mirrors and lets chunks stream back. Neither touches hidden.
// loadModel returns false when the device cannot fit the model's
// buffers even after the cache yielded (it stays unloaded).
void unloadModel(uint32_t session_model_id);
bool loadModel(uint32_t session_model_id);
bool isModelUnloaded(uint32_t session_model_id) const;
// Bytes this model currently holds on the GPU: resident chunk
// geometry plus its mesh/instance/cull buffers. 0 when unloaded.
std::uint64_t modelVramBytes(uint32_t session_model_id) const;
// Federation matrix setters. Each writes to model state and posts
// a recompose so per-instance world matrices stay consistent with
+4
View File
@@ -594,6 +594,10 @@ void ViewportWindow::removeModel(uint32_t session_model_id) { core_.removeMode
void ViewportWindow::resetScene() { core_.resetScene(); }
void ViewportWindow::hideModel(uint32_t session_model_id) { core_.hideModel(session_model_id); }
void ViewportWindow::showModel(uint32_t session_model_id) { core_.showModel(session_model_id); }
void ViewportWindow::unloadModel(uint32_t session_model_id) { core_.unloadModel(session_model_id); }
bool ViewportWindow::loadModel(uint32_t session_model_id) { return core_.loadModel(session_model_id); }
bool ViewportWindow::isModelUnloaded(uint32_t session_model_id) const { return core_.isModelUnloaded(session_model_id); }
std::uint64_t ViewportWindow::modelVramBytes(uint32_t session_model_id) const { return core_.modelVramBytes(session_model_id); }
void ViewportWindow::setFederatedFalseOrigin(const Eigen::Matrix4d& m) {
core_.setFederatedFalseOrigin(m);
+7
View File
@@ -142,6 +142,13 @@ public:
// consults. requestUpdate() so the change is visible immediately.
void hideModel(uint32_t session_model_id);
void showModel(uint32_t session_model_id);
// GPU residency of a model, independent of visibility: unloadModel
// frees everything it holds on the device while it stays in the
// scene; loadModel brings it back (false if the device cannot fit it).
void unloadModel(uint32_t session_model_id);
bool loadModel(uint32_t session_model_id);
bool isModelUnloaded(uint32_t session_model_id) const;
std::uint64_t modelVramBytes(uint32_t session_model_id) const;
// Federation pipeline: composed instance transform =
// FederatedFalseOrigin · ModelTransformation · CoordinateOperation