BVH frustum culling, sidecar cache, per-model buffers, progressive upload

Phase 2 performance: BVH acceleration with median-split build, per-model
trees, and EBO re-sorting for GPU cache coherence. Raw binary .ifcview
sidecar stores full geometry + BVH for instant subsequent loads (skip
tessellation entirely).

Per-model GPU buffers (VAO/VBO/EBO per model) eliminate cross-model buffer
copies on growth. Sidecar reads happen on a background thread. Bulk GPU
uploads are progressive (48 MB/frame chunks) so the viewport stays
interactive while multi-GB models stream in.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-04-12 09:09:32 +10:00
parent 5b4c1089cf
commit 1dace18d26
9 changed files with 1541 additions and 327 deletions
+226
View File
@@ -0,0 +1,226 @@
/********************************************************************************
* *
* 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 "BvhAccel.h"
#include <algorithm>
#include <cassert>
#include <cmath>
#include <limits>
#include <numeric>
namespace {
struct Centroid {
float x, y, z;
};
Centroid computeCentroid(const ObjectDrawInfo& obj) {
return {
(obj.aabb_min[0] + obj.aabb_max[0]) * 0.5f,
(obj.aabb_min[1] + obj.aabb_max[1]) * 0.5f,
(obj.aabb_min[2] + obj.aabb_max[2]) * 0.5f
};
}
void computeAABB(const std::vector<ObjectDrawInfo>& draw_info,
const uint32_t* indices, uint32_t count,
float out_min[3], float out_max[3]) {
out_min[0] = out_min[1] = out_min[2] = std::numeric_limits<float>::max();
out_max[0] = out_max[1] = out_max[2] = -std::numeric_limits<float>::max();
for (uint32_t i = 0; i < count; ++i) {
const auto& obj = draw_info[indices[i]];
for (int a = 0; a < 3; ++a) {
if (obj.aabb_min[a] < out_min[a]) out_min[a] = obj.aabb_min[a];
if (obj.aabb_max[a] > out_max[a]) out_max[a] = obj.aabb_max[a];
}
}
}
// Recursive BVH builder. Writes nodes in pre-order DFS into mbvh.nodes.
// object_indices[start..start+count) are the indices to partition.
void buildRecursive(ModelBvh& mbvh,
const std::vector<ObjectDrawInfo>& draw_info,
uint32_t start, uint32_t count) {
uint32_t node_idx = static_cast<uint32_t>(mbvh.nodes.size());
mbvh.nodes.emplace_back();
BvhNode& node = mbvh.nodes[node_idx];
computeAABB(draw_info, &mbvh.object_indices[start], count,
node.aabb_min, node.aabb_max);
if (count <= BVH_MAX_LEAF_SIZE) {
node.right_or_first = start;
node.count = static_cast<uint16_t>(count);
node.axis = 0;
return;
}
// Find longest axis of node AABB.
float extent[3] = {
node.aabb_max[0] - node.aabb_min[0],
node.aabb_max[1] - node.aabb_min[1],
node.aabb_max[2] - node.aabb_min[2]
};
int axis = 0;
if (extent[1] > extent[axis]) axis = 1;
if (extent[2] > extent[axis]) axis = 2;
// Partition at median centroid on the chosen axis.
uint32_t mid = count / 2;
std::nth_element(
mbvh.object_indices.begin() + start,
mbvh.object_indices.begin() + start + mid,
mbvh.object_indices.begin() + start + count,
[&](uint32_t a, uint32_t b) {
Centroid ca = computeCentroid(draw_info[a]);
Centroid cb = computeCentroid(draw_info[b]);
return (&ca.x)[axis] < (&cb.x)[axis];
});
node.count = 0; // interior
node.axis = static_cast<uint16_t>(axis);
// Left child is always node_idx + 1 (implicit in pre-order DFS).
// Build left subtree first. Note: &node is invalidated after this call
// because the vector may reallocate.
buildRecursive(mbvh, draw_info, start, mid);
// Right child is the next node written after the entire left subtree.
uint32_t right_child_idx = static_cast<uint32_t>(mbvh.nodes.size());
buildRecursive(mbvh, draw_info, start + mid, count - mid);
// Patch the right child index (left is implicit = node_idx + 1).
mbvh.nodes[node_idx].right_or_first = right_child_idx;
}
} // anonymous namespace
ModelBvh buildModelBvh(const std::vector<ObjectDrawInfo>& draw_info,
const std::vector<uint32_t>& model_object_indices,
uint32_t model_id) {
ModelBvh mbvh;
mbvh.model_id = model_id;
mbvh.object_indices = model_object_indices;
uint32_t count = static_cast<uint32_t>(model_object_indices.size());
if (count == 0) return mbvh;
// Reserve a rough estimate: ~2*n nodes for a balanced binary tree.
mbvh.nodes.reserve(count * 2);
buildRecursive(mbvh, draw_info, 0, count);
// Verify: every object appears exactly once in the leaves.
assert(!mbvh.nodes.empty());
return mbvh;
}
std::shared_ptr<BvhSet> buildBvhSet(const std::vector<ObjectDrawInfo>& draw_info) {
auto bvh_set = std::make_shared<BvhSet>();
// Group object indices by model_id.
std::unordered_map<uint32_t, std::vector<uint32_t>> model_objects;
for (uint32_t i = 0; i < static_cast<uint32_t>(draw_info.size()); ++i) {
model_objects[draw_info[i].model_id].push_back(i);
}
// Build per-model BVHs.
for (auto& [model_id, obj_indices] : model_objects) {
if (obj_indices.size() < BVH_MIN_OBJECTS) continue;
ModelBvh mbvh = buildModelBvh(draw_info, obj_indices, model_id);
bvh_set->bvh_model_ids.insert(model_id);
bvh_set->models[model_id] = std::move(mbvh);
}
return bvh_set;
}
EboReorderResult reorderEbo(const BvhSet& bvh_set,
const std::vector<ObjectDrawInfo>& draw_info,
const std::vector<uint32_t>& original_ebo) {
EboReorderResult result;
result.reordered_draw_info = draw_info; // copy; we'll update offsets
result.reordered_ebo.reserve(original_ebo.size());
// Track which draw_info entries have been placed.
std::vector<bool> placed(draw_info.size(), false);
for (const auto& [model_id, mbvh] : bvh_set.models) {
// DFS traversal of BVH to visit leaves in order.
uint32_t stack[64];
int sp = 0;
stack[sp++] = 0;
while (sp > 0) {
uint32_t ni = stack[--sp];
const BvhNode& node = mbvh.nodes[ni];
if (node.count > 0) {
// Leaf: emit objects in order.
for (uint32_t i = 0; i < node.count; ++i) {
uint32_t oi = mbvh.object_indices[node.right_or_first + i];
if (placed[oi]) continue;
placed[oi] = true;
const auto& old_info = draw_info[oi];
uint32_t new_offset = static_cast<uint32_t>(
result.reordered_ebo.size() * sizeof(uint32_t));
// Copy indices from original EBO.
uint32_t idx_start = old_info.index_offset / sizeof(uint32_t);
uint32_t idx_count = old_info.index_count;
for (uint32_t j = 0; j < idx_count; ++j) {
result.reordered_ebo.push_back(original_ebo[idx_start + j]);
}
result.reordered_draw_info[oi].index_offset = new_offset;
}
} else {
// Interior: push left (=ni+1) last so it's processed first.
stack[sp++] = node.right_or_first; // right child
stack[sp++] = ni + 1; // left child
}
}
}
// Append non-BVH objects (models too small for BVH).
for (uint32_t oi = 0; oi < static_cast<uint32_t>(draw_info.size()); ++oi) {
if (placed[oi]) continue;
placed[oi] = true;
const auto& old_info = draw_info[oi];
uint32_t new_offset = static_cast<uint32_t>(
result.reordered_ebo.size() * sizeof(uint32_t));
uint32_t idx_start = old_info.index_offset / sizeof(uint32_t);
uint32_t idx_count = old_info.index_count;
for (uint32_t j = 0; j < idx_count; ++j) {
result.reordered_ebo.push_back(original_ebo[idx_start + j]);
}
result.reordered_draw_info[oi].index_offset = new_offset;
}
assert(result.reordered_ebo.size() == original_ebo.size());
return result;
}
+75
View File
@@ -0,0 +1,75 @@
/********************************************************************************
* *
* 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 BVHACCEL_H
#define BVHACCEL_H
#include <cstdint>
#include <vector>
#include <unordered_map>
#include <unordered_set>
#include <memory>
struct ObjectDrawInfo {
uint32_t index_offset; // byte offset into EBO
uint32_t index_count; // number of indices
uint32_t model_id; // which model this object belongs to
float aabb_min[3]; // world-space AABB
float aabb_max[3];
};
static constexpr uint32_t BVH_MAX_LEAF_SIZE = 8;
static constexpr uint32_t BVH_MIN_OBJECTS = 32;
struct BvhNode {
float aabb_min[3];
float aabb_max[3];
uint32_t right_or_first; // interior: right child index (left is always this_index+1); leaf: first object index
uint16_t count; // 0 = interior; >0 = leaf with this many objects
uint16_t axis; // split axis (0/1/2) for interior; unused for leaf
};
static_assert(sizeof(BvhNode) == 32, "BvhNode must be 32 bytes for cache alignment and sidecar format");
struct ModelBvh {
uint32_t model_id = 0;
std::vector<BvhNode> nodes;
std::vector<uint32_t> object_indices; // indices into object_draw_info_
};
struct BvhSet {
std::unordered_map<uint32_t, ModelBvh> models;
std::unordered_set<uint32_t> bvh_model_ids;
};
struct EboReorderResult {
std::vector<uint32_t> reordered_ebo;
std::vector<ObjectDrawInfo> reordered_draw_info;
};
// Build BVH trees for all models in the given draw info snapshot.
// Only builds the tree structure; does not touch EBO data.
std::shared_ptr<BvhSet> buildBvhSet(const std::vector<ObjectDrawInfo>& draw_info);
// Reorder the EBO so objects within each BVH leaf are contiguous.
// Must be called with the CURRENT run's EBO and draw_info (not cached).
EboReorderResult reorderEbo(const BvhSet& bvh_set,
const std::vector<ObjectDrawInfo>& draw_info,
const std::vector<uint32_t>& original_ebo);
#endif // BVHACCEL_H
+170 -10
View File
@@ -20,6 +20,7 @@
#include "MainWindow.h"
#include "AppSettings.h"
#include "SettingsWindow.h"
#include "SidecarCache.h"
#include <QApplication>
#include <QMenuBar>
@@ -61,7 +62,14 @@ MainWindow::MainWindow(QWidget* parent)
resize(1400, 900);
}
MainWindow::~MainWindow() {}
MainWindow::~MainWindow() {
joinSidecarThread();
}
void MainWindow::joinSidecarThread() {
if (sidecar_read_thread_.joinable())
sidecar_read_thread_.join();
}
void MainWindow::setupUi() {
// 3D Viewport as central widget
@@ -158,7 +166,7 @@ void MainWindow::addFiles(const QStringList& paths) {
}
if (loading_model_id_ == 0) {
startNextLoad();
QTimer::singleShot(0, this, &MainWindow::startNextLoad);
}
}
@@ -184,16 +192,133 @@ void MainWindow::startNextLoad() {
load_queue_.pop_front();
auto& model = models_[loading_model_id_];
connectStreamer(model.streamer);
progress_bar_->setValue(0);
progress_bar_->setVisible(true);
status_label_->setText("Loading: " + model.display_name);
load_timer_.restart();
element_poll_timer_.start();
model.streamer->loadFile(
model.file_path.toStdString(), next_object_id_, loading_model_id_);
status_label_->setText("Loading: " + model.display_name);
// Try sidecar on a background thread so the UI stays responsive.
std::string ifc_path = model.file_path.toStdString();
uint64_t file_size = static_cast<uint64_t>(QFileInfo(model.file_path).size());
ModelId mid = loading_model_id_;
joinSidecarThread();
sidecar_read_thread_ = std::thread([this, ifc_path, file_size, mid]() {
QElapsedTimer rt; rt.start();
auto cached = readSidecar(ifc_path, file_size);
qDebug(" Sidecar read: %lld ms (%s)", rt.elapsed(), ifc_path.c_str());
auto result = std::make_shared<std::optional<SidecarData>>(std::move(cached));
QMetaObject::invokeMethod(this, [this, mid, result]() {
if (*result && !(*result)->draw_info.empty()) {
applySidecarData(mid, std::move(**result));
} else {
// No sidecar — fall back to streaming from IFC.
auto it = models_.find(mid);
if (it == models_.end()) return;
auto& m = it->second;
connectStreamer(m.streamer);
progress_bar_->setValue(0);
progress_bar_->setVisible(true);
status_label_->setText("Loading: " + m.display_name);
element_poll_timer_.start();
m.streamer->loadFile(
m.file_path.toStdString(), next_object_id_, loading_model_id_);
}
}, Qt::QueuedConnection);
});
}
void MainWindow::applySidecarData(ModelId mid, SidecarData data) {
auto it = models_.find(mid);
if (it == models_.end()) return;
auto& model = it->second;
QElapsedTimer t;
qDebug("Sidecar hit: %s (%zu objects, %zu verts, %zu indices, %.1f MB)",
model.file_path.toStdString().c_str(), data.draw_info.size(),
data.vertices.size() / 8, data.indices.size(),
(data.vertices.size() * 4 + data.indices.size() * 4) / (1024.0 * 1024.0));
// GL upload — fast, single buffer copy.
t.start();
viewport_->uploadBulk(mid, data.vertices, data.indices,
data.draw_info, std::move(data.bvh_set));
qDebug(" GL upload: %lld ms", t.elapsed());
// Update next_object_id_ past all objects in this model.
for (const auto& elem : data.elements) {
if (elem.object_id >= next_object_id_)
next_object_id_ = elem.object_id + 1;
}
// Suppress per-item layout recalcs while building the tree.
t.restart();
element_tree_->setUpdatesEnabled(false);
populateTreeFromSidecar(model, data.elements, data.string_table);
element_tree_->setUpdatesEnabled(true);
qDebug(" Tree build: %lld ms (%zu elements)", t.elapsed(), data.elements.size());
progress_bar_->setVisible(false);
qint64 ms = load_timer_.elapsed();
QString elapsed = (ms >= 1000)
? QString::number(ms / 1000.0, 'f', 2) + " s"
: QString::number(ms) + " ms";
status_label_->setText(QString("%1 elements across %2 model(s) — loaded from cache in %3")
.arg(element_map_.size())
.arg(models_.size())
.arg(elapsed));
loading_model_id_ = 0;
QTimer::singleShot(0, this, &MainWindow::startNextLoad);
}
void MainWindow::populateTreeFromSidecar(ModelHandle& model,
const std::vector<PackedElementInfo>& elements,
const std::string& stbl) {
auto str = [&](uint32_t offset, uint32_t length) -> std::string {
if (length == 0 || offset + length > stbl.size()) return {};
return stbl.substr(offset, length);
};
for (const auto& pe : elements) {
ElementInfo info;
info.object_id = pe.object_id;
info.model_id = pe.model_id;
info.ifc_id = pe.ifc_id;
info.parent_id = pe.parent_id;
info.guid = str(pe.guid_offset, pe.guid_length);
info.name = str(pe.name_offset, pe.name_length);
info.type = str(pe.type_offset, pe.type_length);
element_map_[info.object_id] = info;
scoped_ifc_id_to_object_id_[scopedKey(info.model_id, info.ifc_id)] = info.object_id;
// Find parent tree item.
QTreeWidgetItem* parent_item = model.tree_root;
auto parent_obj_it = scoped_ifc_id_to_object_id_.find(
scopedKey(info.model_id, info.parent_id));
if (parent_obj_it != scoped_ifc_id_to_object_id_.end()) {
auto tree_it = tree_items_.find(parent_obj_it->second);
if (tree_it != tree_items_.end()) {
parent_item = tree_it->second;
}
}
QString display_name = QString::fromStdString(info.name);
if (display_name.isEmpty()) {
display_name = QString::fromStdString(info.type) + " #" + QString::number(info.ifc_id);
}
auto* item = new QTreeWidgetItem(parent_item);
item->setText(0, display_name);
item->setText(1, QString::fromStdString(info.type));
item->setText(2, QString::fromStdString(info.guid));
item->setData(0, Qt::UserRole, info.object_id);
tree_items_[info.object_id] = item;
}
}
void MainWindow::onProgressChanged(int percent) {
@@ -230,6 +355,41 @@ void MainWindow::onStreamingFinished() {
.arg(num_models)
.arg(elapsed));
// Build BVH and write sidecar (geometry + metadata + BVH).
if (loading_model_id_ != 0) {
auto it = models_.find(loading_model_id_);
if (it != models_.end()) {
std::string ifc_path = it->second.file_path.toStdString();
QFileInfo fi(it->second.file_path);
uint64_t file_size = static_cast<uint64_t>(fi.size());
// Pack element info for the sidecar (only this model's elements).
std::vector<PackedElementInfo> packed;
std::string stbl;
for (const auto& [oid, info] : element_map_) {
if (info.model_id != loading_model_id_) continue;
PackedElementInfo pe;
pe.object_id = info.object_id;
pe.model_id = info.model_id;
pe.ifc_id = info.ifc_id;
pe.parent_id = info.parent_id;
pe.guid_offset = static_cast<uint32_t>(stbl.size());
pe.guid_length = static_cast<uint32_t>(info.guid.size());
stbl += info.guid;
pe.name_offset = static_cast<uint32_t>(stbl.size());
pe.name_length = static_cast<uint32_t>(info.name.size());
stbl += info.name;
pe.type_offset = static_cast<uint32_t>(stbl.size());
pe.type_length = static_cast<uint32_t>(info.type.size());
stbl += info.type;
packed.push_back(pe);
}
viewport_->buildBvhAsync(loading_model_id_, ifc_path, file_size,
std::move(packed), std::move(stbl));
}
}
// Start next model if queued.
startNextLoad();
}
+7
View File
@@ -31,6 +31,7 @@
#include <map>
#include <deque>
#include <thread>
#include <unordered_map>
#include "ViewportWindow.h"
@@ -72,6 +73,11 @@ private:
void setupMenus();
void populateProperties(uint32_t object_id);
void startNextLoad();
void applySidecarData(ModelId mid, SidecarData data);
void joinSidecarThread();
void populateTreeFromSidecar(ModelHandle& model,
const std::vector<PackedElementInfo>& elements,
const std::string& string_table);
void connectStreamer(GeometryStreamer* streamer);
ViewportWindow* viewport_ = nullptr;
@@ -91,6 +97,7 @@ private:
uint32_t next_object_id_ = 1; // monotonically increasing across all models
std::deque<ModelId> load_queue_;
ModelId loading_model_id_ = 0;
std::thread sidecar_read_thread_;
// Map object_id -> tree item and element info
std::unordered_map<uint32_t, ElementInfo> element_map_;
+233 -124
View File
@@ -11,16 +11,16 @@ A high-performance native IFC viewer built on IfcOpenShell's C++ geometry engine
| | Element | | 3D Viewport ||
| | Tree | | (QWindow + OpenGL 4.5) ||
| | (per- | | ||
| | model) | | Single VBO/EBO ||
| | model) | | Per-model VAO/VBO/EBO ||
| +----------+ | glMultiDrawElements ||
| | Property | | frustum culling ||
| | Property | | BVH frustum culling ||
| | Table | | GPU pick pass ||
| +----------+ +--------------------------+|
| | Status / Progress / Stats |
+-------------------------------------------+
^ ^
| |
element metadata UploadChunks
element metadata UploadChunks / Sidecar
| |
+-------------------------------------------+
| GeometryStreamer (one per loaded model) |
@@ -32,11 +32,13 @@ A high-performance native IFC viewer built on IfcOpenShell's C++ geometry engine
### Key design decisions
- **QWindow viewport** embedded via `QWidget::createWindowContainer()`. This gives us a raw native surface for OpenGL, bypassing `QOpenGLWidget`'s compositor overhead.
- **One big vertex buffer + index buffer** (64 MB + 32 MB initial). Geometry is appended as it streams in. No per-object VBOs, no rebinding.
- **Per-model GPU buffers**: each loaded model gets its own VAO/VBO/EBO. No shared buffer, no cross-model copies on growth. Removing a model frees its GPU memory immediately.
- **Interleaved vertex format**: position (3 floats) + normal (3 floats) + object ID (1 float, bitcast uint32) + color (RGBA8 packed into 1 float) = 32 bytes per vertex.
- **Per-object frustum culling**: each object's AABB is tested against 6 frustum planes each frame. Only visible objects are drawn via `glMultiDrawElements`.
- **Progressive GPU upload**: bulk sidecar loads allocate empty GPU buffers, then stream data in 48 MB chunks per frame. VBO uploads first (no objects visible), then EBO (objects appear progressively as their index range lands). The viewport stays interactive throughout — you can orbit already-loaded models while new ones stream in.
- **Non-blocking sidecar loading**: sidecar files are read on a background thread. The heavy disk I/O (potentially gigabytes) never blocks the render loop. Only the final GPU upload and tree population happen on the main thread.
- **BVH frustum culling**: per-model BVH trees cull entire subtrees of objects in one frustum test, reducing per-frame cost from O(N) to O(log N). Falls back to linear scan during progressive upload; BVH activates once the model is fully loaded.
- **GPU object picking**: a second render pass writes object IDs to an R32UI framebuffer. Click reads back one pixel. No CPU-side raycasting.
- **Multi-model support**: multiple IFC files can be loaded simultaneously. Each model gets its own `GeometryStreamer` (owning the `ifcopenshell::file` for property lookup). Models are loaded sequentially; geometry from all models coexists in the shared VBO/EBO. Per-model visibility toggle and removal are supported.
- **Multi-model support**: multiple IFC files can be loaded simultaneously. Each model gets its own `GeometryStreamer` (owning the `ifcopenshell::file` for property lookup). Models are loaded sequentially. Per-model visibility toggle and removal are supported.
- **Multi-threaded tessellation**: `IfcGeom::Iterator` runs on a background thread and internally parallelizes geometry conversion across all CPU cores.
- **Non-blocking streaming**: the iterator emits `UploadChunk` signals via Qt's queued connection. The main thread uploads to the GPU without blocking iteration.
- **World coordinates**: geometry is emitted in world space (`use-world-coords=true`) so no per-object transform matrices are needed on the GPU.
@@ -47,8 +49,10 @@ A high-performance native IFC viewer built on IfcOpenShell's C++ geometry engine
|------|---------|
| `main.cpp` | Application entry point, GL 4.5 surface format, CLI argument parsing |
| `MainWindow.h/cpp` | Qt main window: multi-model project management, element tree, property table, status bar |
| `ViewportWindow.h/cpp` | OpenGL 4.5 Core renderer: shaders, buffer management, camera, frustum culling, picking |
| `ViewportWindow.h/cpp` | OpenGL 4.5 Core renderer: shaders, buffer management, camera, frustum culling, BVH traversal, picking |
| `GeometryStreamer.h/cpp` | Background geometry processing: loads IFC, runs iterator, emits chunks (one per model) |
| `BvhAccel.h/cpp` | BVH construction (median-split), per-model trees, EBO reordering |
| `SidecarCache.h/cpp` | Raw binary `.ifcview` sidecar read/write |
| `AppSettings.h/cpp` | Persisted application preferences (geometry library, show stats) |
| `SettingsWindow.h/cpp` | Settings dialog UI |
| `CMakeLists.txt` | Build configuration |
@@ -142,8 +146,9 @@ object that enters the GPU buffers:
```cpp
struct ObjectDrawInfo {
uint32_t index_offset; // byte offset into the shared EBO
uint32_t index_offset; // byte offset into the model's EBO
uint32_t index_count; // number of indices (triangles * 3)
uint32_t model_id; // which model this object belongs to
float aabb_min[3]; // world-space axis-aligned bounding box
float aabb_max[3]; // (computed from vertex positions at upload time)
};
@@ -211,108 +216,207 @@ Phase 1 is sufficient for models up to ~100k objects. Beyond that, the CPU-side
frustum test becomes a measurable fraction of the frame budget, motivating
phase 3.
### Phase 2: Spatial Tiling (optional, for large models)
### Phase 2: BVH Acceleration (optional, for large models)
For models exceeding ~10k objects, spatial tiling groups nearby objects into
tiles and culls at the tile level rather than per-object. This reduces the
number of frustum tests from N_objects to N_tiles (typically hundreds to low
thousands).
**Status:** Implemented.
#### When tiling activates
For models exceeding ~100 objects, a bounding volume hierarchy (BVH) groups
nearby objects into a binary tree and culls entire subtrees in one frustum
test. This reduces the number of AABB-frustum tests from O(N_objects) to
O(log N) in the best case (camera zoomed into a corner) and gives a constant
overhead for the common case where most of the model is on screen.
Tiling is **optional and non-disruptive**. The system treats a non-tiled model
as the degenerate case of "one tile containing everything" — the rendering loop
always iterates tiles, so no separate code path is needed.
A BVH was chosen over an octree because BIM data is spatially non-uniform —
dense MEP risers in one zone, sparse open atriums in another. An octree
subdivides space uniformly, wasting nodes on empty regions and creating deep
chains in dense ones. A BVH adapts its splits to the actual object
distribution, producing balanced trees regardless of density variation.
Tiling activates in one of three ways:
#### When the BVH activates
1. **Preprocessed cache exists**: If a `.ifcview` sidecar file is found next to
the `.ifc` file, the tile structure is loaded from it instantly. The model
uploads geometry in tile order.
2. **Automatic by size**: If the model has more than a configurable threshold of
objects (default 10k), a background task builds the spatial tree after
initial loading completes. Until it finishes, phase 1 culling handles
visibility.
3. **Explicit user action**: A "preprocess for performance" option builds the
spatial tree and saves the sidecar for future loads.
The BVH is **optional and non-disruptive**. Until it is built, phase 1's
linear scan handles all culling. The rendering loop checks for an active BVH
and falls back to the linear scan for any model that doesn't have one.
#### Spatial subdivision
The BVH activates in one of two ways:
The world-space bounding box of the entire model is subdivided using a
**loose octree**:
1. **Sidecar cache exists**: If a `.ifcview` file is found next to the `.ifc`
file, the BVH is loaded from it instantly (raw memory read, no parsing).
The model uses BVH culling from the first frame after loading.
2. **Automatic build**: After streaming finishes, a background thread builds
the BVH from the per-object AABBs already computed in phase 1. Until it
completes, phase 1 culling handles visibility. On completion, the render
thread picks up the BVH on the next frame. The sidecar is written for
future loads.
- The root node covers the scene AABB.
- Each node is split when it contains more than a threshold number of objects
(e.g. 256).
- Objects are assigned to the smallest node that fully contains their AABB.
- "Loose" bounds (inflated by 1.5x) reduce the number of objects that span
multiple nodes.
- Leaf nodes become tiles.
Models with fewer than 32 objects skip the BVH entirely — the overhead of tree
traversal is worse than a linear scan at that scale.
An octree adapts to non-uniform object density (common in buildings — lots of
detail in MEP risers, sparse in open atriums) better than a uniform grid.
#### BVH node layout
#### EBO re-sorting
For tile-level culling to translate into contiguous index ranges, the EBO must
be sorted so that all indices for objects in the same tile are adjacent.
This happens via **deferred compaction**:
1. During initial load, geometry uploads in iterator order (fast first frame,
phase 1 culling active).
2. After loading completes, a background thread:
a. Builds the octree from the per-object AABBs (already computed in phase 1).
b. Determines the tile for each object.
c. Computes the new index order (sorted by tile, then by object within tile).
d. Builds a new EBO on the CPU.
3. The main thread uploads the new EBO in one `glNamedBufferSubData` call and
swaps in the tile metadata. One frame of stutter, bounded by EBO upload
time.
The per-tile metadata:
Each node is 32 bytes, so two nodes fit in one 64-byte cache line:
```cpp
struct TileInfo {
float aabb_min[3]; // tile bounding box (union of contained AABBs)
float aabb_max[3];
uint32_t index_offset; // into the re-sorted EBO
uint32_t index_count; // sum of all contained objects' indices
uint32_t object_count; // for stats / debugging
struct BvhNode {
float aabb_min[3]; // world-space bounding box (12 bytes)
float aabb_max[3]; // (12 bytes)
uint32_t right_or_first; // interior: right child index; leaf: first object index (4 bytes)
uint16_t count; // 0 = interior node; >0 = leaf with this many objects (2 bytes)
uint16_t axis; // split axis for interior (0=x, 1=y, 2=z); unused for leaf (2 bytes)
};
```
#### Preprocessed sidecar format
Interior nodes store the right child index; the left child is always the
immediately next node in the array (implicit in pre-order DFS layout, no
pointer needed). Leaf nodes reference a contiguous range in a sorted
object-index array.
The `.ifcview` file stores:
The BVH is stored as a flat `std::vector<BvhNode>` in pre-order DFS layout.
This means a depth-first traversal (which is what frustum culling does) reads
memory sequentially, maximizing prefetch and cache-line utilization.
- Octree structure (node hierarchy, split planes).
- Per-object tile assignment (object_id → tile_id mapping).
- Per-tile index order (so the EBO can be built in tile order directly during
upload, skipping the compaction pass entirely).
- File hash of the source `.ifc` (invalidation check).
#### Build algorithm: object-median split
This makes second-and-subsequent loads of the same model significantly faster:
the spatial tree doesn't need to be rebuilt, and geometry uploads in tile order
from the start.
1. Compute the centroid of each object's AABB.
2. Find the longest axis of the current node's bounding box.
3. Use `std::nth_element` to partition objects at the median centroid on that
axis. This is O(n) — no full sort needed.
4. Recurse on each half. Terminate when the node contains ≤ 8 objects (leaf).
5. Write nodes into the flat array in pre-order DFS.
Total build time is O(n log n). For 100k objects this is well under 100 ms on
a single core.
SAH (Surface Area Heuristic) is the gold standard for ray-tracing BVHs, but
for frustum culling — where we test 6 planes and early-out entire subtrees —
the quality difference vs. median split is negligible. Median split is simpler
and produces reliably balanced trees.
#### Frustum traversal
The traversal uses an explicit stack on the C++ stack (no heap allocation,
no recursion):
```
stack[64] = {0} // start at root; depth 64 handles billions of objects
while stack not empty:
node = nodes[stack.pop()]
if node AABB outside frustum: continue // cull entire subtree
if leaf:
for each object in node:
if object AABB in frustum: emit to visible list
else:
push right child, push left child // left processed first (DFS)
```
When the camera is zoomed into a corner of the model, the traversal skips
large portions of the tree after testing only a handful of interior nodes.
When zoomed out to see everything, the traversal visits all leaves but the
overhead of the interior-node tests is small relative to the leaf work.
#### Per-model BVH
Each loaded model gets its own BVH. During frustum culling, the outer loop
iterates over models (skipping hidden/removed ones); the inner loop traverses
that model's BVH. This means hiding or removing a model is free — just skip
its BVH, no tree modification needed.
```cpp
struct ModelBvh {
uint32_t model_id;
std::vector<BvhNode> nodes; // flat BVH node array
std::vector<uint32_t> object_indices; // indices into object_draw_info_
};
```
#### EBO re-sorting
For BVH culling to maximise GPU cache performance, the EBO is re-sorted so
that objects in the same BVH leaf are contiguous. This happens via **deferred
compaction**:
1. During initial load, geometry uploads in iterator order (fast first frame,
phase 1 culling active).
2. After the BVH build completes on the background thread:
a. Walk the BVH leaves in DFS order.
b. For each object in each leaf, copy its index data to a new EBO buffer,
updating `ObjectDrawInfo::index_offset` accordingly.
c. Package the reordered EBO + updated draw info as a `BvhBuildResult`.
3. The render thread picks up the result on the next frame: one
`glNamedBufferSubData` call to re-upload the EBO, then swap in the new
draw info and activate the BVH. One frame of stutter, bounded by EBO
upload time (~5 ms for 32 MB).
#### Async build and render-thread handoff
The BVH build must not stall the render loop:
1. `buildBvhAsync()` snapshots `object_draw_info_` under the upload mutex,
then launches a `std::thread`.
2. The thread builds the BVH and reordered EBO, then stores the result in a
`pending_bvh_result_` pointer under a separate mutex.
3. At the top of each `render()` call, `applyBvhResult()` checks for a
pending result. If found, it re-uploads the EBO (requires GL context),
swaps the draw info, and activates the BVH.
4. Until the BVH is ready, phase 1's linear scan runs every frame as before.
#### Preprocessed sidecar format (`.ifcview`)
The sidecar is a raw memory dump (Blender `.blend`-style) — no serialization
format, no parsing. It stores everything needed to display the model without
re-tessellating: vertex data, index data, per-object metadata, element tree
info, and the BVH. Loading is just `fread` into vectors → GPU upload →
render. The expensive `IfcGeom::Iterator` tessellation is skipped entirely.
The IFC file is still parsed on demand (in background) for detailed property
lookup; the sidecar provides the basic properties (name, type, GUID)
immediately.
```
SidecarHeader (16 bytes: magic, version, endian, reserved)
uint64_t source_file_size
uint32_t + float[] vertex data (interleaved, 8 floats/vertex)
uint32_t + uint32_t[] index data (global indices, ready for EBO)
uint32_t + ObjectDrawInfo[] per-object draw metadata
uint32_t + PackedElementInfo[] element tree records (fixed-size)
uint32_t + char[] string table (concatenated UTF-8: guid, name, type)
uint32_t num_bvh_models
per model:
uint32_t model_id
uint32_t + BvhNode[] BVH node array
uint32_t + uint32_t[] object indices
```
Staleness check: `source_file_size` is compared against the actual IFC file
size. If mismatched, the sidecar is stale and is rebuilt. This is cheap and
sufficient for a local cache (no hash computation on multi-GB files).
Endianness: if the marker reads back as `0x01020304`, the file was written on
the same architecture — just `fread` the structs directly. Otherwise, reject
the sidecar and rebuild.
#### Performance characteristics
| Metric | Value |
|--------|-------|
| Tile count (typical) | 5005,000 for a large building |
| Per-frame frustum tests | N_tiles instead of N_objects |
| 500k objects, ~2k tiles | ~0.01 ms frustum testing |
| Memory overhead | ~64 bytes/tile + 32 bytes/object (phase 1 metadata retained) |
| Background compaction | 15 seconds for 1M objects (single-threaded) |
| Sidecar file size | ~1050 KB (indices + tree, no geometry) |
| BVH build time (100k objects) | < 100 ms (single-threaded, background) |
| Per-frame traversal (100k objects, 50% visible) | ~0.1 ms |
| Per-frame traversal (100k objects, 5% visible) | ~0.02 ms |
| Memory overhead | 32 bytes/node + 4 bytes/object index (~1.5× object count) |
| EBO reorder (one-time) | 15 ms upload for 32 MB EBO |
| Sidecar file size | ~same as geometry data (vertices + indices + metadata) |
| Sidecar read time | bounded by disk I/O (~500 ms for 640 MB, ~2 s for 2.8 GB from NVMe) |
| GPU upload time | progressive: ~48 MB/frame (~1 s for 2.8 GB at 60 fps, non-blocking) |
#### Spatial coherence bonus
Beyond culling, tile-sorted EBOs improve GPU cache performance. When the GPU
rasterizes a tile's triangles, the vertices are contiguous in the VBO, so the
post-transform vertex cache hits more often. This can yield 1020% rasterization
speedup even when nothing is culled (e.g. zoomed out to see the whole model).
Beyond culling, BVH-leaf-sorted EBOs improve GPU cache performance. When the
GPU rasterizes a leaf's triangles, the vertices are close together in the VBO,
so the post-transform vertex cache hits more often. This can yield 1020%
rasterization speedup even when nothing is culled (e.g. zoomed out to see the
whole model).
### Phase 3: GPU-Driven Indirect Draw
@@ -322,20 +426,20 @@ visibility decisions to the GPU via compute shaders and indirect draw commands.
#### How it works
Phase 3 is **approach 2 layered on top of approach 3**. It does not replace
tiling — it accelerates it.
Phase 3 builds on the BVH from phase 2. It does not replace the BVH — it
moves the per-frame traversal to the GPU.
1. **Upload phase** (once, at load time):
- Per-tile AABBs are uploaded to a GPU SSBO (`tile_aabbs`).
- One `DrawElementsIndirectCommand` per tile is written to an indirect draw
buffer:
- Per-leaf AABBs from the BVH are uploaded to a GPU SSBO (`leaf_aabbs`).
- One `DrawElementsIndirectCommand` per BVH leaf is written to an indirect
draw buffer:
```c
struct DrawElementsIndirectCommand {
uint count; // tile's total index count
uint count; // leaf's total index count
uint instanceCount; // 1
uint firstIndex; // offset into EBO
uint firstIndex; // offset into EBO (from BVH leaf order)
uint baseVertex; // 0 (indices are global)
uint baseInstance; // tile_id (available in shader via gl_DrawID)
uint baseInstance; // leaf_id (available in shader via gl_DrawID)
};
```
- A "template" copy of the indirect buffer is kept so the compute shader
@@ -343,20 +447,20 @@ tiling — it accelerates it.
2. **Cull phase** (every frame, on the GPU):
- The CPU uploads 6 frustum plane vec4s as a uniform or small UBO.
- A compute shader dispatches `ceil(N_tiles / 64)` workgroups:
- A compute shader dispatches `ceil(N_leaves / 64)` workgroups:
```glsl
layout(local_size_x = 64) in;
void main() {
uint tile_id = gl_GlobalInvocationID.x;
if (tile_id >= tile_count) return;
uint leaf_id = gl_GlobalInvocationID.x;
if (leaf_id >= leaf_count) return;
// Copy from template (resets any previously zeroed commands)
commands[tile_id] = template_commands[tile_id];
commands[leaf_id] = template_commands[leaf_id];
// Frustum test
if (!aabb_vs_frustum(tile_aabbs[tile_id], frustum_planes)) {
commands[tile_id].count = 0; // culled: GPU skips zero-count draws
if (!aabb_vs_frustum(leaf_aabbs[leaf_id], frustum_planes)) {
commands[leaf_id].count = 0; // culled: GPU skips zero-count draws
}
}
```
@@ -364,7 +468,7 @@ tiling — it accelerates it.
3. **Draw phase** (every frame):
- One call: `glMultiDrawElementsIndirect(GL_TRIANGLES, GL_UNSIGNED_INT,
nullptr, N_tiles, 0)`.
nullptr, N_leaves, 0)`.
- The GPU reads the indirect buffer, skips tiles with `count == 0`, and
draws the rest. Zero CPU-side per-object or per-tile work.
@@ -382,12 +486,12 @@ That's it. The CPU frame time is essentially constant regardless of model size.
Once the compute-based cull pass exists, it's straightforward to add:
- **Hierarchical-Z occlusion culling**: render a coarse depth buffer from the
previous frame, then test tile AABBs against it in the compute shader. Tiles
fully behind closer geometry get culled. This handles interior-heavy BIM
models well (most rooms are occluded from any given viewpoint).
previous frame, then test BVH leaf AABBs against it in the compute shader.
Leaves fully behind closer geometry get culled. This handles interior-heavy
BIM models well (most rooms are occluded from any given viewpoint).
- **Distance-based LOD**: the compute shader can select different index ranges
(coarse vs. fine tessellation) per tile based on distance to camera.
- **Contribution culling**: tiles whose screen-space projection is below a
(coarse vs. fine tessellation) per leaf based on distance to camera.
- **Contribution culling**: leaves whose screen-space projection is below a
pixel threshold get `count = 0`. Removes distant small objects.
#### Performance characteristics
@@ -395,10 +499,10 @@ Once the compute-based cull pass exists, it's straightforward to add:
| Metric | Value |
|--------|-------|
| CPU per-frame work | ~0.01 ms (constant, independent of model size) |
| GPU compute dispatch | ~0.02 ms for 2k tiles |
| GPU compute dispatch | ~0.02 ms for 2k leaves |
| Draw call overhead | 1 indirect multi-draw call |
| GPU memory overhead | ~48 bytes/tile (AABB SSBO) + 20 bytes/tile (indirect commands) × 2 (template + live) |
| Total for 2k tiles | ~176 KB GPU memory |
| GPU memory overhead | ~48 bytes/leaf (AABB SSBO) + 20 bytes/leaf (indirect commands) × 2 (template + live) |
| Total for 2k leaves | ~176 KB GPU memory |
| Implementation complexity | High (compute shaders, SSBOs, memory barriers, indirect draw) |
#### When to use
@@ -411,8 +515,8 @@ Phase 3 is worthwhile when:
the viewer requires 4.5).
For models under 100k objects, phase 1 alone is sufficient. For 100k500k,
phase 2 (tiling) keeps CPU culling under 1 ms. Phase 3 is the final step that
makes the CPU frame time constant.
phase 2 (BVH) keeps CPU culling well under 1 ms. Phase 3 is the final step
that makes the CPU frame time constant.
### Summary
@@ -429,28 +533,33 @@ The load path:
```
open(model.ifc):
├─ sidecar exists?
│ ├─ yes: load tile tree from .ifcview
│ │ upload geometry in tile order
│ │ (skip background compaction)
└─ no: upload geometry in iterator order (fast first frame)
phase 1 culling active immediately
if object_count > threshold:
background: build octree, re-sort EBO, save .ifcview
on completion: swap in tile structure
└─ rendering:
├─ phase 3 available? → compute cull + indirect multi-draw
└─ else → CPU frustum test + glMultiDrawElements
├─ sidecar exists (.ifcview)?
│ ├─ yes: background thread reads sidecar file (non-blocking I/O)
│ │ → allocate per-model VAO/VBO/EBO (empty, exact size)
│ │ → progressive GPU upload: 48 MB/frame VBO, then EBO
│ → objects appear as EBO chunks land
→ BVH activates once fully loaded
→ viewport interactive throughout
└─ no: stream from IFC via GeometryStreamer
→ uploadChunk() appends to per-model buffers (immediately drawable)
│ → phase 1 linear-scan culling active from first chunk
│ → on completion: background BVH build, re-sort EBO, save .ifcview
└─ rendering (per model, per frame):
├─ phase 3 available? → compute cull + indirect multi-draw
├─ BVH available? → BVH traversal + glMultiDrawElements
└─ else / progressive → linear scan of active objects + glMultiDrawElements
```
## Roadmap
- [x] Material color support (per-vertex RGBA8)
- [x] Buffer growth (dynamic VBO/EBO resizing up to 4 GB)
- [x] Per-model GPU buffers (VAO/VBO/EBO per model, no cross-model copies)
- [x] Per-object frustum culling (phase 1)
- [ ] Spatial tiling with octree (phase 2)
- [x] BVH acceleration with per-model trees (phase 2)
- [x] Raw binary `.ifcview` sidecar cache (full geometry + BVH, Blender-style)
- [x] Non-blocking sidecar loading (background thread I/O)
- [x] Progressive GPU upload (48 MB/frame chunked VBO/EBO transfer)
- [ ] GPU-driven indirect draw (phase 3)
- [ ] Preprocessed `.ifcview` sidecar for fast re-loads
- [ ] Hierarchical-Z occlusion culling
- [ ] Distance-based LOD selection
- [ ] Vulkan/MoltenVK backend for macOS
+196
View File
@@ -0,0 +1,196 @@
/********************************************************************************
* *
* 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 "SidecarCache.h"
#include <cstdio>
#include <cstring>
// Binary layout (all multi-byte fields native-endian):
//
// SidecarHeader (16 bytes)
// uint64_t source_file_size
//
// uint32_t num_vertices (count of floats)
// float[num_vertices] vertex data
//
// uint32_t num_indices
// uint32_t[num_indices] index data
//
// uint32_t num_draw_infos
// ObjectDrawInfo[N] draw info array
//
// uint32_t num_elements
// PackedElementInfo[N] element records
// uint32_t string_table_bytes
// char[string_table_bytes]
//
// uint32_t num_bvh_models
// for each model:
// uint32_t model_id
// uint32_t num_nodes
// BvhNode[num_nodes]
// uint32_t num_object_indices
// uint32_t[num_object_indices]
struct SidecarHeader {
uint32_t magic;
uint32_t version;
uint32_t endian;
uint32_t reserved;
};
static std::string sidecarPath(const std::string& ifc_path) {
return ifc_path + ".ifcview";
}
template<typename T>
static bool writeVec(FILE* f, const std::vector<T>& v) {
uint32_t n = static_cast<uint32_t>(v.size());
if (fwrite(&n, 4, 1, f) != 1) return false;
if (n > 0 && fwrite(v.data(), sizeof(T), n, f) != n) return false;
return true;
}
template<typename T>
static bool readVec(FILE* f, std::vector<T>& v) {
uint32_t n;
if (fread(&n, 4, 1, f) != 1) return false;
v.resize(n);
if (n > 0 && fread(v.data(), sizeof(T), n, f) != n) return false;
return true;
}
bool writeSidecar(const std::string& ifc_path,
const SidecarData& data,
uint64_t ifc_file_size) {
std::string path = sidecarPath(ifc_path);
FILE* f = fopen(path.c_str(), "wb");
if (!f) return false;
// Header
SidecarHeader hdr = { SIDECAR_MAGIC, SIDECAR_VERSION, SIDECAR_ENDIAN, 0 };
fwrite(&hdr, sizeof(hdr), 1, f);
fwrite(&ifc_file_size, 8, 1, f);
// Geometry
if (!writeVec(f, data.vertices)) { fclose(f); return false; }
if (!writeVec(f, data.indices)) { fclose(f); return false; }
// Draw info
if (!writeVec(f, data.draw_info)) { fclose(f); return false; }
// Elements + string table
if (!writeVec(f, data.elements)) { fclose(f); return false; }
uint32_t stbl_len = static_cast<uint32_t>(data.string_table.size());
fwrite(&stbl_len, 4, 1, f);
if (stbl_len > 0) fwrite(data.string_table.data(), 1, stbl_len, f);
// BVH
uint32_t num_bvh_models = data.bvh_set
? static_cast<uint32_t>(data.bvh_set->models.size()) : 0;
fwrite(&num_bvh_models, 4, 1, f);
if (data.bvh_set) {
for (const auto& [model_id, mbvh] : data.bvh_set->models) {
fwrite(&model_id, 4, 1, f);
uint32_t nn = static_cast<uint32_t>(mbvh.nodes.size());
fwrite(&nn, 4, 1, f);
if (nn > 0) fwrite(mbvh.nodes.data(), sizeof(BvhNode), nn, f);
uint32_t no = static_cast<uint32_t>(mbvh.object_indices.size());
fwrite(&no, 4, 1, f);
if (no > 0) fwrite(mbvh.object_indices.data(), 4, no, f);
}
}
fclose(f);
return true;
}
std::optional<SidecarData> readSidecar(const std::string& ifc_path,
uint64_t ifc_file_size) {
std::string path = sidecarPath(ifc_path);
FILE* f = fopen(path.c_str(), "rb");
if (!f) return std::nullopt;
auto fail = [&]() -> std::optional<SidecarData> { fclose(f); return std::nullopt; };
// Header
SidecarHeader hdr;
if (fread(&hdr, sizeof(hdr), 1, f) != 1) return fail();
if (hdr.magic != SIDECAR_MAGIC ||
hdr.version != SIDECAR_VERSION ||
hdr.endian != SIDECAR_ENDIAN) return fail();
uint64_t stored_size;
if (fread(&stored_size, 8, 1, f) != 1) return fail();
if (stored_size != ifc_file_size) return fail();
SidecarData data;
// Geometry
if (!readVec(f, data.vertices)) return fail();
if (!readVec(f, data.indices)) return fail();
// Draw info
if (!readVec(f, data.draw_info)) return fail();
// Elements + string table
if (!readVec(f, data.elements)) return fail();
uint32_t stbl_len;
if (fread(&stbl_len, 4, 1, f) != 1) return fail();
data.string_table.resize(stbl_len);
if (stbl_len > 0 && fread(data.string_table.data(), 1, stbl_len, f) != stbl_len)
return fail();
// BVH
uint32_t num_bvh_models;
if (fread(&num_bvh_models, 4, 1, f) != 1) return fail();
if (num_bvh_models > 0) {
data.bvh_set = std::make_shared<BvhSet>();
for (uint32_t m = 0; m < num_bvh_models; ++m) {
uint32_t model_id;
if (fread(&model_id, 4, 1, f) != 1) return fail();
ModelBvh mbvh;
mbvh.model_id = model_id;
uint32_t nn;
if (fread(&nn, 4, 1, f) != 1) return fail();
mbvh.nodes.resize(nn);
if (nn > 0 && fread(mbvh.nodes.data(), sizeof(BvhNode), nn, f) != nn)
return fail();
uint32_t no;
if (fread(&no, 4, 1, f) != 1) return fail();
mbvh.object_indices.resize(no);
if (no > 0 && fread(mbvh.object_indices.data(), 4, no, f) != no)
return fail();
data.bvh_set->bvh_model_ids.insert(model_id);
data.bvh_set->models[model_id] = std::move(mbvh);
}
}
fclose(f);
return data;
}
+76
View File
@@ -0,0 +1,76 @@
/********************************************************************************
* *
* 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 SIDECARCACHE_H
#define SIDECARCACHE_H
#include "BvhAccel.h"
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
static constexpr uint32_t SIDECAR_MAGIC = 0x49465657; // "IFVW"
static constexpr uint32_t SIDECAR_VERSION = 3;
static constexpr uint32_t SIDECAR_ENDIAN = 0x01020304;
// Fixed-size element record for the sidecar. Strings are stored as
// (offset, length) pairs into a separate string table.
struct PackedElementInfo {
uint32_t object_id;
uint32_t model_id;
int32_t ifc_id;
int32_t parent_id;
uint32_t guid_offset;
uint32_t guid_length;
uint32_t name_offset;
uint32_t name_length;
uint32_t type_offset;
uint32_t type_length;
};
// Everything the viewer needs to display a model without tessellating.
struct SidecarData {
// GPU geometry (ready to upload as-is)
std::vector<float> vertices; // interleaved, 8 floats per vertex
std::vector<uint32_t> indices; // global (already remapped)
// Per-object metadata
std::vector<ObjectDrawInfo> draw_info;
// Element tree metadata
std::vector<PackedElementInfo> elements;
std::string string_table; // concatenated UTF-8
// BVH acceleration
std::shared_ptr<BvhSet> bvh_set;
};
// Write a full sidecar next to the IFC file.
// Returns true on success.
bool writeSidecar(const std::string& ifc_path,
const SidecarData& data,
uint64_t ifc_file_size);
// Read a sidecar. Returns nullopt on any failure (missing, stale, corrupt).
std::optional<SidecarData> readSidecar(const std::string& ifc_path,
uint64_t ifc_file_size);
#endif // SIDECARCACHE_H
+471 -164
View File
@@ -18,6 +18,7 @@
********************************************************************************/
#include "ViewportWindow.h"
#include "SidecarCache.h"
#include <QMouseEvent>
#include <QWheelEvent>
@@ -32,7 +33,6 @@
static const size_t INITIAL_VBO_SIZE = 64 * 1024 * 1024; // 64 MB
static const size_t INITIAL_EBO_SIZE = 32 * 1024 * 1024; // 32 MB
// Cap buffer growth so a runaway upload can't try to allocate the world.
static const size_t MAX_BUFFER_SIZE = 4ull * 1024 * 1024 * 1024; // 4 GB
static const int VERTEX_STRIDE = 8; // pos(3) + normal(3) + object_id(1) + color(1 packed)
@@ -192,12 +192,16 @@ ViewportWindow::ViewportWindow(QWindow* parent)
}
ViewportWindow::~ViewportWindow() {
if (bvh_build_thread_.joinable())
bvh_build_thread_.join();
if (context_) {
context_->makeCurrent(this);
if (gl_) {
if (vao_) gl_->glDeleteVertexArrays(1, &vao_);
if (vbo_) gl_->glDeleteBuffers(1, &vbo_);
if (ebo_) gl_->glDeleteBuffers(1, &ebo_);
for (auto& [mid, m] : models_gpu_) {
if (m.vao) gl_->glDeleteVertexArrays(1, &m.vao);
if (m.vbo) gl_->glDeleteBuffers(1, &m.vbo);
if (m.ebo) gl_->glDeleteBuffers(1, &m.ebo);
}
if (axis_vao_) gl_->glDeleteVertexArrays(1, &axis_vao_);
if (axis_vbo_) gl_->glDeleteBuffers(1, &axis_vbo_);
if (main_program_) gl_->glDeleteProgram(main_program_);
@@ -231,46 +235,6 @@ void ViewportWindow::initGL() {
buildShaders();
buildAxisGizmo();
// Create VAO
gl_->glCreateVertexArrays(1, &vao_);
// Create VBO with initial capacity
vbo_capacity_ = INITIAL_VBO_SIZE;
gl_->glCreateBuffers(1, &vbo_);
gl_->glNamedBufferStorage(vbo_, vbo_capacity_, nullptr,
GL_DYNAMIC_STORAGE_BIT);
// Create EBO with initial capacity
ebo_capacity_ = INITIAL_EBO_SIZE;
gl_->glCreateBuffers(1, &ebo_);
gl_->glNamedBufferStorage(ebo_, ebo_capacity_, nullptr,
GL_DYNAMIC_STORAGE_BIT);
// Vertex layout: pos(3f) + normal(3f) + object_id(1f) + color(4 unorm bytes)
// = 8 floats = 32 bytes per vertex.
gl_->glVertexArrayVertexBuffer(vao_, 0, vbo_, 0, VERTEX_STRIDE * sizeof(float));
gl_->glVertexArrayElementBuffer(vao_, ebo_);
// position
gl_->glEnableVertexArrayAttrib(vao_, 0);
gl_->glVertexArrayAttribFormat(vao_, 0, 3, GL_FLOAT, GL_FALSE, 0);
gl_->glVertexArrayAttribBinding(vao_, 0, 0);
// normal
gl_->glEnableVertexArrayAttrib(vao_, 1);
gl_->glVertexArrayAttribFormat(vao_, 1, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float));
gl_->glVertexArrayAttribBinding(vao_, 1, 0);
// object_id (passed as float, decoded in shader via floatBitsToUint)
gl_->glEnableVertexArrayAttrib(vao_, 2);
gl_->glVertexArrayAttribFormat(vao_, 2, 1, GL_FLOAT, GL_FALSE, 6 * sizeof(float));
gl_->glVertexArrayAttribBinding(vao_, 2, 0);
// color (RGBA8 packed into the 4 bytes at offset 28; normalized to vec4)
gl_->glEnableVertexArrayAttrib(vao_, 3);
gl_->glVertexArrayAttribFormat(vao_, 3, 4, GL_UNSIGNED_BYTE, GL_TRUE, 7 * sizeof(float));
gl_->glVertexArrayAttribBinding(vao_, 3, 0);
gl_->glEnable(GL_DEPTH_TEST);
gl_->glEnable(GL_MULTISAMPLE);
gl_->glClearColor(0.18f, 0.20f, 0.22f, 1.0f);
@@ -282,6 +246,31 @@ void ViewportWindow::initGL() {
emit initialized();
}
void ViewportWindow::setupVaoLayout(GLuint vao, GLuint vbo, GLuint ebo) {
gl_->glVertexArrayVertexBuffer(vao, 0, vbo, 0, VERTEX_STRIDE * sizeof(float));
gl_->glVertexArrayElementBuffer(vao, ebo);
// position
gl_->glEnableVertexArrayAttrib(vao, 0);
gl_->glVertexArrayAttribFormat(vao, 0, 3, GL_FLOAT, GL_FALSE, 0);
gl_->glVertexArrayAttribBinding(vao, 0, 0);
// normal
gl_->glEnableVertexArrayAttrib(vao, 1);
gl_->glVertexArrayAttribFormat(vao, 1, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float));
gl_->glVertexArrayAttribBinding(vao, 1, 0);
// object_id (passed as float, decoded in shader via floatBitsToUint)
gl_->glEnableVertexArrayAttrib(vao, 2);
gl_->glVertexArrayAttribFormat(vao, 2, 1, GL_FLOAT, GL_FALSE, 6 * sizeof(float));
gl_->glVertexArrayAttribBinding(vao, 2, 0);
// color (RGBA8 packed into the 4 bytes at offset 28; normalized to vec4)
gl_->glEnableVertexArrayAttrib(vao, 3);
gl_->glVertexArrayAttribFormat(vao, 3, 4, GL_UNSIGNED_BYTE, GL_TRUE, 7 * sizeof(float));
gl_->glVertexArrayAttribBinding(vao, 3, 0);
}
void ViewportWindow::buildShaders() {
{
GLuint vs = compileShader(gl_, GL_VERTEX_SHADER, MAIN_VERTEX_SHADER);
@@ -301,15 +290,11 @@ void ViewportWindow::buildShaders() {
}
void ViewportWindow::buildAxisGizmo() {
// 3 line segments (X red, Y green, Z blue), 6 vertices, pos(3) + color(3).
static const float axis_data[] = {
// X axis - red
0.0f, 0.0f, 0.0f, 1.0f, 0.25f, 0.25f,
1.0f, 0.0f, 0.0f, 1.0f, 0.25f, 0.25f,
// Y axis - green
0.0f, 0.0f, 0.0f, 0.30f, 0.95f, 0.30f,
0.0f, 1.0f, 0.0f, 0.30f, 0.95f, 0.30f,
// Z axis - blue
0.0f, 0.0f, 0.0f, 0.30f, 0.55f, 1.0f,
0.0f, 0.0f, 1.0f, 0.30f, 0.55f, 1.0f,
};
@@ -329,15 +314,11 @@ void ViewportWindow::buildAxisGizmo() {
gl_->glVertexArrayAttribBinding(axis_vao_, 1, 0);
}
bool ViewportWindow::growVbo(size_t needed_total) {
// Double until it fits, but don't blow past the cap.
size_t new_capacity = vbo_capacity_;
while (new_capacity < needed_total) {
new_capacity *= 2;
}
bool ViewportWindow::growModelVbo(ModelGpuData& m, size_t needed_total) {
size_t new_capacity = m.vbo_capacity;
while (new_capacity < needed_total) new_capacity *= 2;
if (new_capacity > MAX_BUFFER_SIZE) {
qWarning("VBO grow request (%zu MB) exceeds cap (%zu MB)",
new_capacity / (1024 * 1024), MAX_BUFFER_SIZE / (1024 * 1024));
qWarning("VBO grow request (%zu MB) exceeds cap", new_capacity / (1024 * 1024));
return false;
}
@@ -345,29 +326,25 @@ bool ViewportWindow::growVbo(size_t needed_total) {
gl_->glCreateBuffers(1, &new_vbo);
gl_->glNamedBufferStorage(new_vbo, new_capacity, nullptr, GL_DYNAMIC_STORAGE_BIT);
if (vbo_used_ > 0) {
gl_->glCopyNamedBufferSubData(vbo_, new_vbo, 0, 0, vbo_used_);
if (m.vbo_used > 0) {
gl_->glCopyNamedBufferSubData(m.vbo, new_vbo, 0, 0, m.vbo_used);
}
gl_->glDeleteBuffers(1, &vbo_);
vbo_ = new_vbo;
vbo_capacity_ = new_capacity;
gl_->glDeleteBuffers(1, &m.vbo);
m.vbo = new_vbo;
m.vbo_capacity = new_capacity;
// Rebind on the VAO so subsequent draws see the new buffer.
gl_->glVertexArrayVertexBuffer(vao_, 0, vbo_, 0, VERTEX_STRIDE * sizeof(float));
gl_->glVertexArrayVertexBuffer(m.vao, 0, m.vbo, 0, VERTEX_STRIDE * sizeof(float));
qInfo("VBO grew to %zu MB", vbo_capacity_ / (1024 * 1024));
qInfo("Model VBO grew to %zu MB", m.vbo_capacity / (1024 * 1024));
return true;
}
bool ViewportWindow::growEbo(size_t needed_total) {
size_t new_capacity = ebo_capacity_;
while (new_capacity < needed_total) {
new_capacity *= 2;
}
bool ViewportWindow::growModelEbo(ModelGpuData& m, size_t needed_total) {
size_t new_capacity = m.ebo_capacity;
while (new_capacity < needed_total) new_capacity *= 2;
if (new_capacity > MAX_BUFFER_SIZE) {
qWarning("EBO grow request (%zu MB) exceeds cap (%zu MB)",
new_capacity / (1024 * 1024), MAX_BUFFER_SIZE / (1024 * 1024));
qWarning("EBO grow request (%zu MB) exceeds cap", new_capacity / (1024 * 1024));
return false;
}
@@ -375,17 +352,17 @@ bool ViewportWindow::growEbo(size_t needed_total) {
gl_->glCreateBuffers(1, &new_ebo);
gl_->glNamedBufferStorage(new_ebo, new_capacity, nullptr, GL_DYNAMIC_STORAGE_BIT);
if (ebo_used_ > 0) {
gl_->glCopyNamedBufferSubData(ebo_, new_ebo, 0, 0, ebo_used_);
if (m.ebo_used > 0) {
gl_->glCopyNamedBufferSubData(m.ebo, new_ebo, 0, 0, m.ebo_used);
}
gl_->glDeleteBuffers(1, &ebo_);
ebo_ = new_ebo;
ebo_capacity_ = new_capacity;
gl_->glDeleteBuffers(1, &m.ebo);
m.ebo = new_ebo;
m.ebo_capacity = new_capacity;
gl_->glVertexArrayElementBuffer(vao_, ebo_);
gl_->glVertexArrayElementBuffer(m.vao, m.ebo);
qInfo("EBO grew to %zu MB", ebo_capacity_ / (1024 * 1024));
qInfo("Model EBO grew to %zu MB", m.ebo_capacity / (1024 * 1024));
return true;
}
@@ -395,37 +372,55 @@ void ViewportWindow::uploadChunk(const UploadChunk& chunk) {
context_->makeCurrent(this);
// Get or create per-model GPU data.
auto it = models_gpu_.find(chunk.model_id);
if (it == models_gpu_.end()) {
ModelGpuData m;
gl_->glCreateVertexArrays(1, &m.vao);
gl_->glCreateBuffers(1, &m.vbo);
gl_->glCreateBuffers(1, &m.ebo);
m.vbo_capacity = INITIAL_VBO_SIZE;
m.ebo_capacity = INITIAL_EBO_SIZE;
gl_->glNamedBufferStorage(m.vbo, m.vbo_capacity, nullptr, GL_DYNAMIC_STORAGE_BIT);
gl_->glNamedBufferStorage(m.ebo, m.ebo_capacity, nullptr, GL_DYNAMIC_STORAGE_BIT);
setupVaoLayout(m.vao, m.vbo, m.ebo);
it = models_gpu_.emplace(chunk.model_id, std::move(m)).first;
}
auto& mgpu = it->second;
size_t vb_size = chunk.vertices.size() * sizeof(float);
size_t ib_size = chunk.indices.size() * sizeof(uint32_t);
if (vbo_used_ + vb_size > vbo_capacity_) {
if (!growVbo(vbo_used_ + vb_size)) {
if (mgpu.vbo_used + vb_size > mgpu.vbo_capacity) {
if (!growModelVbo(mgpu, mgpu.vbo_used + vb_size)) {
qWarning("VBO at cap, skipping chunk");
return;
}
}
if (ebo_used_ + ib_size > ebo_capacity_) {
if (!growEbo(ebo_used_ + ib_size)) {
if (mgpu.ebo_used + ib_size > mgpu.ebo_capacity) {
if (!growModelEbo(mgpu, mgpu.ebo_used + ib_size)) {
qWarning("EBO at cap, skipping chunk");
return;
}
}
uint32_t base_vertex = vertex_count_;
uint32_t base_vertex = mgpu.vertex_count;
gl_->glNamedBufferSubData(vbo_, vbo_used_, vb_size, chunk.vertices.data());
gl_->glNamedBufferSubData(mgpu.vbo, mgpu.vbo_used, vb_size, chunk.vertices.data());
// Remap chunk-local indices into global indices so the whole EBO can be
// drawn with a single glDrawElements call.
// Remap chunk-local indices into model-local global indices.
std::vector<uint32_t> global_indices(chunk.indices.size());
for (size_t i = 0; i < chunk.indices.size(); ++i) {
global_indices[i] = chunk.indices[i] + base_vertex;
}
gl_->glNamedBufferSubData(ebo_, ebo_used_, ib_size, global_indices.data());
gl_->glNamedBufferSubData(mgpu.ebo, mgpu.ebo_used, ib_size, global_indices.data());
// Compute AABB from vertex positions in this chunk.
ObjectDrawInfo info;
info.index_offset = static_cast<uint32_t>(ebo_used_);
info.index_offset = static_cast<uint32_t>(mgpu.ebo_used);
info.index_count = static_cast<uint32_t>(chunk.indices.size());
info.model_id = chunk.model_id;
@@ -445,46 +440,301 @@ void ViewportWindow::uploadChunk(const UploadChunk& chunk) {
info.aabb_max[0] = info.aabb_max[1] = info.aabb_max[2] = 0.0f;
}
{
std::lock_guard<std::mutex> lock(upload_mutex_);
total_index_count_ += static_cast<uint32_t>(chunk.indices.size());
object_draw_info_.push_back(info);
}
mgpu.draw_info.push_back(info);
mgpu.active_draw_count = static_cast<uint32_t>(mgpu.draw_info.size()); // immediately drawable
mgpu.vbo_used += vb_size;
mgpu.ebo_used += ib_size;
mgpu.vertex_count += static_cast<uint32_t>(num_verts);
mgpu.total_triangles += static_cast<uint32_t>(chunk.indices.size() / 3);
}
vbo_used_ += vb_size;
ebo_used_ += ib_size;
vertex_count_ += static_cast<uint32_t>(chunk.vertices.size() / VERTEX_STRIDE);
total_triangles_ += static_cast<uint32_t>(chunk.indices.size() / 3);
void ViewportWindow::uploadBulk(uint32_t model_id,
std::vector<float> vertices,
std::vector<uint32_t> indices,
const std::vector<ObjectDrawInfo>& draw_info,
std::shared_ptr<BvhSet> bvh_set) {
if (!gl_initialized_) return;
if (vertices.empty() || indices.empty()) return;
context_->makeCurrent(this);
size_t vb_size = vertices.size() * sizeof(float);
size_t ib_size = indices.size() * sizeof(uint32_t);
// Allocate empty buffers at exact size — no data uploaded yet.
ModelGpuData m;
gl_->glCreateVertexArrays(1, &m.vao);
gl_->glCreateBuffers(1, &m.vbo);
gl_->glCreateBuffers(1, &m.ebo);
m.vbo_capacity = vb_size;
m.ebo_capacity = ib_size;
gl_->glNamedBufferStorage(m.vbo, vb_size, nullptr, GL_DYNAMIC_STORAGE_BIT);
gl_->glNamedBufferStorage(m.ebo, ib_size, nullptr, GL_DYNAMIC_STORAGE_BIT);
setupVaoLayout(m.vao, m.vbo, m.ebo);
m.vbo_used = vb_size;
m.ebo_used = ib_size;
m.vertex_count = static_cast<uint32_t>(vertices.size() / VERTEX_STRIDE);
m.draw_info = draw_info;
m.active_draw_count = 0; // nothing drawable yet
uint32_t total_tri = 0;
for (const auto& di : draw_info) total_tri += di.index_count / 3;
m.total_triangles = total_tri;
// Delete old model data if re-uploading.
auto it = models_gpu_.find(model_id);
if (it != models_gpu_.end()) {
gl_->glDeleteVertexArrays(1, &it->second.vao);
gl_->glDeleteBuffers(1, &it->second.vbo);
gl_->glDeleteBuffers(1, &it->second.ebo);
}
models_gpu_[model_id] = std::move(m);
// Queue progressive upload — data will stream in over subsequent frames.
PendingUpload pu;
pu.model_id = model_id;
pu.vertices = std::move(vertices);
pu.indices = std::move(indices);
pu.bvh_set = std::move(bvh_set);
pending_uploads_.push_back(std::move(pu));
qDebug("Bulk upload queued: model %u, %zu vertices, %zu indices, %zu objects",
model_id, vertices.size() / VERTEX_STRIDE, indices.size(), draw_info.size());
}
void ViewportWindow::resetScene() {
if (!gl_initialized_) return;
std::lock_guard<std::mutex> lock(upload_mutex_);
total_index_count_ = 0;
vbo_used_ = 0;
ebo_used_ = 0;
vertex_count_ = 0;
total_triangles_ = 0;
if (bvh_build_thread_.joinable())
bvh_build_thread_.join();
context_->makeCurrent(this);
for (auto& [mid, m] : models_gpu_) {
if (m.vao) gl_->glDeleteVertexArrays(1, &m.vao);
if (m.vbo) gl_->glDeleteBuffers(1, &m.vbo);
if (m.ebo) gl_->glDeleteBuffers(1, &m.ebo);
}
models_gpu_.clear();
model_bvhs_.clear();
pending_uploads_.clear();
selected_object_id_ = 0;
object_draw_info_.clear();
hidden_models_.clear();
removed_models_.clear();
{
std::lock_guard<std::mutex> bvh_lock(bvh_result_mutex_);
pending_bvh_.reset();
}
}
static const size_t UPLOAD_CHUNK_BYTES = 48 * 1024 * 1024; // 48 MB per frame
void ViewportWindow::processPendingUploads() {
if (pending_uploads_.empty()) return;
auto& pu = pending_uploads_.front();
auto it = models_gpu_.find(pu.model_id);
if (it == models_gpu_.end()) {
pending_uploads_.pop_front();
return;
}
auto& mgpu = it->second;
size_t vbo_total = pu.vertices.size() * sizeof(float);
size_t ebo_total = pu.indices.size() * sizeof(uint32_t);
// Phase 1: Upload VBO in chunks.
if (pu.vbo_uploaded < vbo_total) {
size_t remaining = vbo_total - pu.vbo_uploaded;
size_t chunk = std::min(remaining, UPLOAD_CHUNK_BYTES);
gl_->glNamedBufferSubData(mgpu.vbo, pu.vbo_uploaded, chunk,
reinterpret_cast<const char*>(pu.vertices.data()) + pu.vbo_uploaded);
pu.vbo_uploaded += chunk;
if (pu.vbo_uploaded >= vbo_total) {
// VBO done — free CPU memory.
pu.vertices.clear();
pu.vertices.shrink_to_fit();
}
return; // yield to render loop
}
// Phase 2: Upload EBO in chunks. Objects become drawable as their range lands.
if (pu.ebo_uploaded < ebo_total) {
size_t remaining = ebo_total - pu.ebo_uploaded;
size_t chunk = std::min(remaining, UPLOAD_CHUNK_BYTES);
gl_->glNamedBufferSubData(mgpu.ebo, pu.ebo_uploaded, chunk,
reinterpret_cast<const char*>(pu.indices.data()) + pu.ebo_uploaded);
pu.ebo_uploaded += chunk;
// Advance active_draw_count: activate objects whose EBO range is fully uploaded.
while (mgpu.active_draw_count < mgpu.draw_info.size()) {
const auto& obj = mgpu.draw_info[mgpu.active_draw_count];
size_t obj_end = obj.index_offset + obj.index_count * sizeof(uint32_t);
if (obj_end <= pu.ebo_uploaded)
mgpu.active_draw_count++;
else
break;
}
if (pu.ebo_uploaded >= ebo_total) {
// EBO done — free CPU memory.
pu.indices.clear();
pu.indices.shrink_to_fit();
} else {
return; // yield to render loop
}
}
// Fully uploaded — activate BVH if present.
mgpu.active_draw_count = static_cast<uint32_t>(mgpu.draw_info.size());
if (pu.bvh_set) {
model_bvhs_[pu.model_id] = std::move(pu.bvh_set);
}
qDebug("Progressive upload complete: model %u", pu.model_id);
pending_uploads_.pop_front();
}
void ViewportWindow::hideModel(uint32_t model_id) {
std::lock_guard<std::mutex> lock(upload_mutex_);
hidden_models_.insert(model_id);
auto it = models_gpu_.find(model_id);
if (it != models_gpu_.end()) it->second.hidden = true;
}
void ViewportWindow::showModel(uint32_t model_id) {
std::lock_guard<std::mutex> lock(upload_mutex_);
hidden_models_.erase(model_id);
auto it = models_gpu_.find(model_id);
if (it != models_gpu_.end()) it->second.hidden = false;
}
void ViewportWindow::removeModel(uint32_t model_id) {
std::lock_guard<std::mutex> lock(upload_mutex_);
removed_models_.insert(model_id);
if (!gl_initialized_) return;
context_->makeCurrent(this);
// Cancel any pending upload for this model.
pending_uploads_.erase(
std::remove_if(pending_uploads_.begin(), pending_uploads_.end(),
[model_id](const PendingUpload& pu) { return pu.model_id == model_id; }),
pending_uploads_.end());
auto it = models_gpu_.find(model_id);
if (it != models_gpu_.end()) {
gl_->glDeleteVertexArrays(1, &it->second.vao);
gl_->glDeleteBuffers(1, &it->second.vbo);
gl_->glDeleteBuffers(1, &it->second.ebo);
models_gpu_.erase(it);
}
model_bvhs_.erase(model_id);
}
std::vector<uint32_t> ViewportWindow::readbackEbo(uint32_t model_id) const {
std::vector<uint32_t> ebo_data;
auto it = models_gpu_.find(model_id);
if (!gl_ || it == models_gpu_.end() || it->second.ebo_used == 0) return ebo_data;
const auto& m = it->second;
size_t num_indices = m.ebo_used / sizeof(uint32_t);
ebo_data.resize(num_indices);
gl_->glGetNamedBufferSubData(m.ebo, 0, m.ebo_used, ebo_data.data());
return ebo_data;
}
std::vector<float> ViewportWindow::readbackVbo(uint32_t model_id) const {
std::vector<float> vbo_data;
auto it = models_gpu_.find(model_id);
if (!gl_ || it == models_gpu_.end() || it->second.vbo_used == 0) return vbo_data;
const auto& m = it->second;
size_t num_floats = m.vbo_used / sizeof(float);
vbo_data.resize(num_floats);
gl_->glGetNamedBufferSubData(m.vbo, 0, m.vbo_used, vbo_data.data());
return vbo_data;
}
void ViewportWindow::buildBvhAsync(uint32_t model_id,
const std::string& ifc_path,
uint64_t ifc_file_size,
std::vector<PackedElementInfo> sidecar_elements,
std::string sidecar_string_table) {
if (bvh_build_thread_.joinable())
bvh_build_thread_.join();
auto it = models_gpu_.find(model_id);
if (it == models_gpu_.end()) return;
// Snapshot draw info; read back EBO + VBO on GL thread.
std::vector<ObjectDrawInfo> draw_snapshot = it->second.draw_info;
std::vector<uint32_t> ebo_snapshot = readbackEbo(model_id);
std::vector<float> vbo_snapshot;
if (!ifc_path.empty() && !sidecar_elements.empty()) {
vbo_snapshot = readbackVbo(model_id);
}
if (draw_snapshot.empty() || ebo_snapshot.empty()) return;
bvh_build_thread_ = std::thread([this,
model_id,
draw_info = std::move(draw_snapshot),
ebo_data = std::move(ebo_snapshot),
vbo_data = std::move(vbo_snapshot),
elements = std::move(sidecar_elements),
string_table = std::move(sidecar_string_table),
ifc_path, ifc_file_size]() {
auto bvh_set = buildBvhSet(draw_info);
EboReorderResult ebo_result = reorderEbo(*bvh_set, draw_info, ebo_data);
// Write full sidecar if requested.
if (!ifc_path.empty() && !elements.empty() && !vbo_data.empty()) {
SidecarData sd;
sd.vertices = vbo_data;
sd.indices = ebo_result.reordered_ebo;
sd.draw_info = ebo_result.reordered_draw_info;
sd.elements = std::move(elements);
sd.string_table = std::move(string_table);
sd.bvh_set = bvh_set;
writeSidecar(ifc_path, sd, ifc_file_size);
}
{
std::lock_guard<std::mutex> lock(bvh_result_mutex_);
pending_bvh_ = std::make_unique<PendingBvh>();
pending_bvh_->model_id = model_id;
pending_bvh_->bvh_set = std::move(bvh_set);
pending_bvh_->ebo_reorder = std::move(ebo_result);
}
});
}
void ViewportWindow::applyBvhResult() {
std::unique_ptr<PendingBvh> result;
{
std::lock_guard<std::mutex> lock(bvh_result_mutex_);
result = std::move(pending_bvh_);
}
if (!result) return;
auto it = models_gpu_.find(result->model_id);
if (it == models_gpu_.end()) return;
auto& mgpu = it->second;
// Re-upload the reordered EBO into this model's buffer.
if (!result->ebo_reorder.reordered_ebo.empty()) {
size_t ebo_bytes = result->ebo_reorder.reordered_ebo.size() * sizeof(uint32_t);
if (ebo_bytes <= mgpu.ebo_capacity) {
gl_->glNamedBufferSubData(mgpu.ebo, 0, ebo_bytes,
result->ebo_reorder.reordered_ebo.data());
}
}
// Swap draw info.
if (result->ebo_reorder.reordered_draw_info.size() == mgpu.draw_info.size()) {
mgpu.draw_info = std::move(result->ebo_reorder.reordered_draw_info);
}
model_bvhs_[result->model_id] = std::move(result->bvh_set);
qDebug("BVH activated for model %u", result->model_id);
}
void ViewportWindow::setSelectedObjectId(uint32_t id) {
@@ -499,7 +749,6 @@ uint32_t ViewportWindow::pickObjectAt(int x, int y) {
int w = width() * devicePixelRatio();
int h = height() * devicePixelRatio();
// Create/resize pick FBO if needed
if (pick_width_ != w || pick_height_ != h) {
if (pick_fbo_) gl_->glDeleteFramebuffers(1, &pick_fbo_);
if (pick_color_tex_) gl_->glDeleteTextures(1, &pick_color_tex_);
@@ -533,7 +782,6 @@ void ViewportWindow::updateCamera() {
float yaw_rad = qDegreesToRadians(camera_yaw_);
float pitch_rad = qDegreesToRadians(camera_pitch_);
// IFC / Blender convention: X right, Y forward, Z up.
QVector3D eye;
eye.setX(camera_target_.x() + camera_distance_ * cosf(pitch_rad) * cosf(yaw_rad));
eye.setY(camera_target_.y() + camera_distance_ * cosf(pitch_rad) * sinf(yaw_rad));
@@ -547,17 +795,61 @@ void ViewportWindow::updateCamera() {
proj_matrix_.perspective(45.0f, aspect, 0.1f, camera_distance_ * 10.0f);
}
bool ViewportWindow::aabbInFrustum(const float aabb_min[3], const float aabb_max[3],
const float planes[6][4]) {
for (int p = 0; p < 6; ++p) {
float px = planes[p][0] >= 0.0f ? aabb_max[0] : aabb_min[0];
float py = planes[p][1] >= 0.0f ? aabb_max[1] : aabb_min[1];
float pz = planes[p][2] >= 0.0f ? aabb_max[2] : aabb_min[2];
float dist = planes[p][0] * px + planes[p][1] * py + planes[p][2] * pz + planes[p][3];
if (dist < 0.0f) return false;
}
return true;
}
void ViewportWindow::traverseBvh(const ModelBvh& mbvh, const ModelGpuData& mgpu,
const float planes[6][4]) {
if (mbvh.nodes.empty()) return;
uint32_t stack[64];
int sp = 0;
stack[sp++] = 0; // root
// Get the current model's draw command being built.
auto& cmd = frame_draw_cmds_.back();
while (sp > 0) {
uint32_t ni = stack[--sp];
const BvhNode& node = mbvh.nodes[ni];
if (!aabbInFrustum(node.aabb_min, node.aabb_max, planes))
continue;
if (node.count > 0) {
for (uint32_t i = 0; i < node.count; ++i) {
uint32_t oi = mbvh.object_indices[node.right_or_first + i];
const auto& obj = mgpu.draw_info[oi];
if (aabbInFrustum(obj.aabb_min, obj.aabb_max, planes)) {
cmd.counts.push_back(static_cast<GLsizei>(obj.index_count));
cmd.offsets.push_back(reinterpret_cast<const void*>(
static_cast<uintptr_t>(obj.index_offset)));
visible_triangles_ += obj.index_count / 3;
}
}
} else {
if (sp < 63) {
stack[sp++] = node.right_or_first;
stack[sp++] = ni + 1;
}
}
}
}
void ViewportWindow::buildVisibleList(const QMatrix4x4& vp) {
visible_counts_.clear();
visible_offsets_.clear();
frame_draw_cmds_.clear();
visible_triangles_ = 0;
std::lock_guard<std::mutex> lock(upload_mutex_);
if (object_draw_info_.empty()) return;
// Extract 6 frustum planes from the view-projection matrix.
// Each plane is (a, b, c, d) where ax + by + cz + d >= 0 is inside.
// QMatrix4x4 is stored column-major; operator(row, col) gives element.
float planes[6][4];
for (int i = 0; i < 4; ++i) {
planes[0][i] = vp(3, i) + vp(0, i); // left
@@ -567,7 +859,6 @@ void ViewportWindow::buildVisibleList(const QMatrix4x4& vp) {
planes[4][i] = vp(3, i) + vp(2, i); // near
planes[5][i] = vp(3, i) - vp(2, i); // far
}
// Normalize planes.
for (int p = 0; p < 6; ++p) {
float len = std::sqrt(planes[p][0] * planes[p][0] +
planes[p][1] * planes[p][1] +
@@ -581,31 +872,40 @@ void ViewportWindow::buildVisibleList(const QMatrix4x4& vp) {
}
}
visible_counts_.reserve(object_draw_info_.size());
visible_offsets_.reserve(object_draw_info_.size());
for (auto& [model_id, mgpu] : models_gpu_) {
if (mgpu.hidden || mgpu.active_draw_count == 0) continue;
for (const auto& obj : object_draw_info_) {
// Skip hidden or removed models.
if (hidden_models_.count(obj.model_id) || removed_models_.count(obj.model_id))
continue;
frame_draw_cmds_.push_back({mgpu.vao, {}, {}});
auto& cmd = frame_draw_cmds_.back();
cmd.counts.reserve(mgpu.active_draw_count);
cmd.offsets.reserve(mgpu.active_draw_count);
bool visible = true;
for (int p = 0; p < 6; ++p) {
// p-vertex: the AABB corner most in the direction of the plane normal.
float px = planes[p][0] >= 0.0f ? obj.aabb_max[0] : obj.aabb_min[0];
float py = planes[p][1] >= 0.0f ? obj.aabb_max[1] : obj.aabb_min[1];
float pz = planes[p][2] >= 0.0f ? obj.aabb_max[2] : obj.aabb_min[2];
float dist = planes[p][0] * px + planes[p][1] * py + planes[p][2] * pz + planes[p][3];
if (dist < 0.0f) {
visible = false;
break;
bool fully_loaded = (mgpu.active_draw_count == mgpu.draw_info.size());
auto bvh_it = model_bvhs_.find(model_id);
// Only use BVH if model is fully uploaded; during progressive upload,
// fall back to linear scan of active objects.
if (fully_loaded && bvh_it != model_bvhs_.end() && bvh_it->second) {
const auto& bvh_set = *bvh_it->second;
auto mbvh_it = bvh_set.models.find(model_id);
if (mbvh_it != bvh_set.models.end()) {
traverseBvh(mbvh_it->second, mgpu, planes);
}
} else {
// Linear scan of active objects only.
for (uint32_t i = 0; i < mgpu.active_draw_count; ++i) {
const auto& obj = mgpu.draw_info[i];
if (aabbInFrustum(obj.aabb_min, obj.aabb_max, planes)) {
cmd.counts.push_back(static_cast<GLsizei>(obj.index_count));
cmd.offsets.push_back(reinterpret_cast<const void*>(
static_cast<uintptr_t>(obj.index_offset)));
visible_triangles_ += obj.index_count / 3;
}
}
}
if (visible) {
visible_counts_.push_back(static_cast<GLsizei>(obj.index_count));
visible_offsets_.push_back(reinterpret_cast<const void*>(
static_cast<uintptr_t>(obj.index_offset)));
visible_triangles_ += obj.index_count / 3;
if (cmd.counts.empty()) {
frame_draw_cmds_.pop_back();
}
}
}
@@ -614,6 +914,8 @@ void ViewportWindow::render() {
if (!gl_initialized_ || !isExposed()) return;
context_->makeCurrent(this);
applyBvhResult();
processPendingUploads();
updateCamera();
int w = width() * devicePixelRatio();
@@ -628,21 +930,20 @@ void ViewportWindow::render() {
gl_->glUniform3f(gl_->glGetUniformLocation(main_program_, "u_light_dir"), 0.3f, 0.5f, 0.8f);
gl_->glUniform1ui(gl_->glGetUniformLocation(main_program_, "u_selected_id"), selected_object_id_);
gl_->glBindVertexArray(vao_);
buildVisibleList(vp);
if (!visible_counts_.empty()) {
for (const auto& cmd : frame_draw_cmds_) {
gl_->glBindVertexArray(cmd.vao);
gl_->glMultiDrawElements(GL_TRIANGLES,
visible_counts_.data(), GL_UNSIGNED_INT,
visible_offsets_.data(),
static_cast<GLsizei>(visible_counts_.size()));
cmd.counts.data(), GL_UNSIGNED_INT,
cmd.offsets.data(),
static_cast<GLsizei>(cmd.counts.size()));
}
renderAxisGizmo();
context_->swapBuffers(this);
// Compute FPS (updated once per second to avoid flicker).
// Compute FPS.
float dt = frame_clock_.restart() / 1000.0f;
accumulated_time_ += dt;
frame_count_++;
@@ -651,12 +952,23 @@ void ViewportWindow::render() {
frame_count_ = 0;
accumulated_time_ = 0.0f;
uint32_t total_obj = 0, total_tri = 0, vis_obj = 0;
for (const auto& [mid, m] : models_gpu_) {
if (!m.hidden) {
total_obj += static_cast<uint32_t>(m.draw_info.size());
total_tri += m.total_triangles;
}
}
for (const auto& cmd : frame_draw_cmds_) {
vis_obj += static_cast<uint32_t>(cmd.counts.size());
}
FrameStats stats;
stats.fps = last_fps_;
stats.frame_time_ms = 1000.0f / last_fps_;
stats.total_objects = static_cast<uint32_t>(object_draw_info_.size());
stats.visible_objects = static_cast<uint32_t>(visible_counts_.size());
stats.total_triangles = total_triangles_;
stats.total_objects = total_obj;
stats.visible_objects = vis_obj;
stats.total_triangles = total_tri;
stats.visible_triangles = visible_triangles_;
emit frameStatsUpdated(stats);
}
@@ -672,8 +984,6 @@ void ViewportWindow::renderAxisGizmo() {
gl_->glViewport(margin, margin, gizmo_size, gizmo_size);
gl_->glDisable(GL_DEPTH_TEST);
// Build a view matrix from the same camera orientation but with a fixed
// close-up distance, so the gizmo rotates with the scene camera. Z-up.
float yaw_rad = qDegreesToRadians(camera_yaw_);
float pitch_rad = qDegreesToRadians(camera_pitch_);
@@ -693,7 +1003,7 @@ void ViewportWindow::renderAxisGizmo() {
gl_->glUseProgram(axis_program_);
gl_->glUniformMatrix4fv(gl_->glGetUniformLocation(axis_program_, "u_mvp"), 1, GL_FALSE, mvp.constData());
gl_->glLineWidth(2.5f); // ignored on some core-profile drivers, that's OK
gl_->glLineWidth(2.5f);
gl_->glBindVertexArray(axis_vao_);
gl_->glDrawArrays(GL_LINES, 0, 6);
@@ -712,14 +1022,13 @@ void ViewportWindow::renderPickPass() {
gl_->glUseProgram(pick_program_);
gl_->glUniformMatrix4fv(gl_->glGetUniformLocation(pick_program_, "u_view_projection"), 1, GL_FALSE, vp.constData());
gl_->glBindVertexArray(vao_);
// Reuse the visible list from the most recent render() call.
if (!visible_counts_.empty()) {
for (const auto& cmd : frame_draw_cmds_) {
gl_->glBindVertexArray(cmd.vao);
gl_->glMultiDrawElements(GL_TRIANGLES,
visible_counts_.data(), GL_UNSIGNED_INT,
visible_offsets_.data(),
static_cast<GLsizei>(visible_counts_.size()));
cmd.counts.data(), GL_UNSIGNED_INT,
cmd.offsets.data(),
static_cast<GLsizei>(cmd.counts.size()));
}
gl_->glBindFramebuffer(GL_FRAMEBUFFER, 0);
@@ -774,7 +1083,6 @@ void ViewportWindow::handleMouseMove(QMouseEvent* e) {
if (active_button_ == Qt::MiddleButton) {
if (e->modifiers() & Qt::ShiftModifier) {
// Pan in screen space, derived from the Z-up camera basis.
float pan_speed = camera_distance_ * 0.002f;
float yaw_rad = qDegreesToRadians(camera_yaw_);
float pitch_rad = qDegreesToRadians(camera_pitch_);
@@ -786,7 +1094,6 @@ void ViewportWindow::handleMouseMove(QMouseEvent* e) {
camera_target_ -= right * delta.x() * pan_speed;
camera_target_ += up * delta.y() * pan_speed;
} else {
// Orbit
camera_yaw_ -= delta.x() * 0.3f;
camera_pitch_ += delta.y() * 0.3f;
camera_pitch_ = qBound(-89.0f, camera_pitch_, 89.0f);
+87 -29
View File
@@ -28,23 +28,23 @@
#include <QMatrix4x4>
#include <QVector3D>
#include <deque>
#include <vector>
#include <unordered_set>
#include <unordered_map>
#include <cstdint>
#include <mutex>
#include <thread>
#include <memory>
#include <atomic>
#include "BvhAccel.h"
#include "SidecarCache.h"
struct MaterialInfo {
float r = 0.75f, g = 0.75f, b = 0.78f, a = 1.0f;
};
struct ObjectDrawInfo {
uint32_t index_offset; // byte offset into EBO
uint32_t index_count; // number of indices
uint32_t model_id; // which model this object belongs to
float aabb_min[3]; // world-space AABB
float aabb_max[3];
};
struct UploadChunk {
// Interleaved per-vertex layout (8 floats / 32 bytes per vertex):
// pos(3 float) + normal(3 float) + object_id(1 float bitcast from uint)
@@ -56,6 +56,32 @@ struct UploadChunk {
uint32_t model_id = 0;
};
// Per-model GPU state: own VAO, VBO, EBO, draw info, BVH.
struct ModelGpuData {
GLuint vao = 0;
GLuint vbo = 0;
GLuint ebo = 0;
size_t vbo_capacity = 0;
size_t ebo_capacity = 0;
size_t vbo_used = 0; // bytes
size_t ebo_used = 0; // bytes
uint32_t vertex_count = 0;
uint32_t total_triangles = 0;
std::vector<ObjectDrawInfo> draw_info;
uint32_t active_draw_count = 0; // how many objects are drawable (progressive upload)
bool hidden = false;
};
// Pending progressive upload — VBO first, then EBO.
struct PendingUpload {
uint32_t model_id = 0;
std::vector<float> vertices;
std::vector<uint32_t> indices;
std::shared_ptr<BvhSet> bvh_set;
size_t vbo_uploaded = 0; // bytes
size_t ebo_uploaded = 0; // bytes
};
class ViewportWindow : public QWindow {
Q_OBJECT
public:
@@ -65,10 +91,29 @@ public:
void uploadChunk(const UploadChunk& chunk);
void resetScene();
// Bulk upload pre-built geometry from a sidecar cache.
// Creates a perfectly-sized per-model buffer set. No copy.
void uploadBulk(uint32_t model_id,
std::vector<float> vertices,
std::vector<uint32_t> indices,
const std::vector<ObjectDrawInfo>& draw_info,
std::shared_ptr<BvhSet> bvh_set);
void hideModel(uint32_t model_id);
void showModel(uint32_t model_id);
void removeModel(uint32_t model_id);
// Build BVH and optionally write a sidecar cache.
void buildBvhAsync(uint32_t model_id,
const std::string& ifc_path = "",
uint64_t ifc_file_size = 0,
std::vector<PackedElementInfo> sidecar_elements = {},
std::string sidecar_string_table = {});
// Read snapshots of a model's GPU buffers into CPU vectors.
std::vector<uint32_t> readbackEbo(uint32_t model_id) const;
std::vector<float> readbackVbo(uint32_t model_id) const;
void setSelectedObjectId(uint32_t id);
uint32_t pickObjectAt(int x, int y);
@@ -99,9 +144,16 @@ private:
void updateCamera();
void buildShaders();
void buildAxisGizmo();
bool growVbo(size_t needed_total);
bool growEbo(size_t needed_total);
void setupVaoLayout(GLuint vao, GLuint vbo, GLuint ebo);
bool growModelVbo(ModelGpuData& m, size_t needed_total);
bool growModelEbo(ModelGpuData& m, size_t needed_total);
void buildVisibleList(const QMatrix4x4& vp);
void traverseBvh(const ModelBvh& mbvh, const ModelGpuData& mgpu,
const float planes[6][4]);
static bool aabbInFrustum(const float aabb_min[3], const float aabb_max[3],
const float planes[6][4]);
void applyBvhResult();
void processPendingUploads();
// Mouse interaction
void handleMousePress(QMouseEvent* event);
@@ -124,15 +176,9 @@ private:
GLuint axis_vao_ = 0;
GLuint axis_vbo_ = 0;
// Geometry buffers - one big buffer pair
GLuint vao_ = 0;
GLuint vbo_ = 0;
GLuint ebo_ = 0;
size_t vbo_capacity_ = 0;
size_t ebo_capacity_ = 0;
size_t vbo_used_ = 0; // in bytes
size_t ebo_used_ = 0; // in bytes
uint32_t vertex_count_ = 0;
// Per-model GPU data
std::unordered_map<uint32_t, ModelGpuData> models_gpu_;
std::mutex models_mutex_;
// Pick framebuffer
GLuint pick_fbo_ = 0;
@@ -141,16 +187,20 @@ private:
int pick_width_ = 0;
int pick_height_ = 0;
// Per-object draw metadata for frustum culling.
std::vector<ObjectDrawInfo> object_draw_info_;
std::unordered_set<uint32_t> hidden_models_;
std::unordered_set<uint32_t> removed_models_;
uint32_t total_index_count_ = 0;
std::mutex upload_mutex_;
// Per-model BVH
std::unordered_map<uint32_t, std::shared_ptr<const BvhSet>> model_bvhs_;
// Progressive upload queue
std::deque<PendingUpload> pending_uploads_;
// Scratch buffers reused each frame to avoid allocation.
std::vector<GLsizei> visible_counts_;
std::vector<const void*> visible_offsets_;
struct ModelDrawCmd {
GLuint vao;
std::vector<GLsizei> counts;
std::vector<const void*> offsets;
};
std::vector<ModelDrawCmd> frame_draw_cmds_;
uint32_t visible_triangles_ = 0;
// Camera
QVector3D camera_target_{0, 0, 0};
@@ -169,9 +219,17 @@ private:
bool pick_requested_ = false;
int pick_x_ = 0, pick_y_ = 0;
// BVH build (phase 2)
struct PendingBvh {
uint32_t model_id;
std::shared_ptr<BvhSet> bvh_set;
EboReorderResult ebo_reorder;
};
std::unique_ptr<PendingBvh> pending_bvh_;
std::mutex bvh_result_mutex_;
std::thread bvh_build_thread_;
// Stats
uint32_t total_triangles_ = 0;
uint32_t visible_triangles_ = 0;
int frame_count_ = 0;
float accumulated_time_ = 0.0f;
float last_fps_ = 0.0f;