diff --git a/src/bonsaiviewer/MainWindow.cpp b/src/bonsaiviewer/MainWindow.cpp index e832d635ee..35f9cec5e9 100644 --- a/src/bonsaiviewer/MainWindow.cpp +++ b/src/bonsaiviewer/MainWindow.cpp @@ -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, diff --git a/src/bonsaiviewer/MainWindow.h b/src/bonsaiviewer/MainWindow.h index 9a8a2ed43e..17c0c0ce3f 100644 --- a/src/bonsaiviewer/MainWindow.h +++ b/src/bonsaiviewer/MainWindow.h @@ -26,6 +26,7 @@ #include class QLabel; +#include 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; diff --git a/src/bonsaiviewer/SessionState.cpp b/src/bonsaiviewer/SessionState.cpp index 203c184583..08cd9577c3 100644 --- a/src/bonsaiviewer/SessionState.cpp +++ b/src/bonsaiviewer/SessionState.cpp @@ -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); } diff --git a/src/bonsaiviewer/SessionState.h b/src/bonsaiviewer/SessionState.h index 672d5dc088..791dc36436 100644 --- a/src/bonsaiviewer/SessionState.h +++ b/src/bonsaiviewer/SessionState.h @@ -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. diff --git a/src/bonsaiviewer/modules/models/Commands.cpp b/src/bonsaiviewer/modules/models/Commands.cpp index 243cd6a305..2cff196506 100644 --- a/src/bonsaiviewer/modules/models/Commands.cpp +++ b/src/bonsaiviewer/modules/models/Commands.cpp @@ -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 diff --git a/src/bonsaiviewer/modules/models/Commands.h b/src/bonsaiviewer/modules/models/Commands.h index 788f4aa1a7..84a0a54eb2 100644 --- a/src/bonsaiviewer/modules/models/Commands.h +++ b/src/bonsaiviewer/modules/models/Commands.h @@ -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 diff --git a/src/bonsaiviewer/modules/models/FederationItemModel.cpp b/src/bonsaiviewer/modules/models/FederationItemModel.cpp index 9b30a654c1..0295afaf35 100644 --- a/src/bonsaiviewer/modules/models/FederationItemModel.cpp +++ b/src/bonsaiviewer/modules/models/FederationItemModel.cpp @@ -25,16 +25,25 @@ #include "../../../ifcviewer/Federation.h" #include +#include #include 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 @@ -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); diff --git a/src/bonsaiviewer/modules/models/FederationItemModel.h b/src/bonsaiviewer/modules/models/FederationItemModel.h index 5e6f00c534..c779fdbf52 100644 --- a/src/bonsaiviewer/modules/models/FederationItemModel.h +++ b/src/bonsaiviewer/modules/models/FederationItemModel.h @@ -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; diff --git a/src/bonsaiviewer/modules/models/Panel.cpp b/src/bonsaiviewer/modules/models/Panel.cpp index ca19970fd7..306089c2bb 100644 --- a/src/bonsaiviewer/modules/models/Panel.cpp +++ b/src/bonsaiviewer/modules/models/Panel.cpp @@ -28,6 +28,7 @@ #include "../../components/Section.h" #include "../../components/SvgIcon.h" #include "../../../ifcviewer/Federation.h" +#include "../../../ifcviewer/ViewportWindow.h" #include #include @@ -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 diff --git a/src/bonsaiviewer/modules/models/Types.h b/src/bonsaiviewer/modules/models/Types.h index c496e16687..f4fd6decf3 100644 --- a/src/bonsaiviewer/modules/models/Types.h +++ b/src/bonsaiviewer/modules/models/Types.h @@ -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; diff --git a/src/bonsaiviewer/modules/models/View.cpp b/src/bonsaiviewer/modules/models/View.cpp index 27ace0bbf6..adcc1dc3dd 100644 --- a/src/bonsaiviewer/modules/models/View.cpp +++ b/src/bonsaiviewer/modules/models/View.cpp @@ -26,6 +26,9 @@ #include "../../ViewerSettings.h" #include "../../SessionState.h" #include "../../../ifcviewer/Federation.h" +#include "../../../ifcviewer/ViewportWindow.h" + +#include namespace bonsaiviewer::modules::models { @@ -54,17 +57,19 @@ QList 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 diff --git a/src/bonsaiviewer/modules/models/View.h b/src/bonsaiviewer/modules/models/View.h index 7ae862b94b..e9eb834b83 100644 --- a/src/bonsaiviewer/modules/models/View.h +++ b/src/bonsaiviewer/modules/models/View.h @@ -26,6 +26,7 @@ #include class Federation; +class ViewportWindow; namespace bonsaiviewer { class SessionState; } namespace bonsaiviewer::modules::models { @@ -45,16 +46,25 @@ QList 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; }; diff --git a/src/ifcviewer/FrameStats.h b/src/ifcviewer/FrameStats.h index 19aa458ba4..3fc9ce0b61 100644 --- a/src/ifcviewer/FrameStats.h +++ b/src/ifcviewer/FrameStats.h @@ -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. diff --git a/src/ifcviewer/ModelGpuData.h b/src/ifcviewer/ModelGpuData.h index 7558d7e2f8..27be9530f2 100644 --- a/src/ifcviewer/ModelGpuData.h +++ b/src/ifcviewer/ModelGpuData.h @@ -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 diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp index c6660c3fd8..b417912eb2 100644 --- a/src/ifcviewer/ViewportCore.cpp +++ b/src/ifcviewer/ViewportCore.cpp @@ -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 meshGpuRecords(const std::vector& meshes) { + std::vector 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 instanceGpuRecords(const std::vector& instances) { + std::vector 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 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 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 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 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 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 mesh_gpu = meshGpuRecords(metadata.meta.meshes); + const std::vector 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>> 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; diff --git a/src/ifcviewer/ViewportCore.h b/src/ifcviewer/ViewportCore.h index 5b29a8ddde..e5015e99df 100644 --- a/src/ifcviewer/ViewportCore.h +++ b/src/ifcviewer/ViewportCore.h @@ -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 diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 3caf7b49eb..a179a52a32 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -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); diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 18a019db7b..1820ece43d 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -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