wgpu backend: load .ifcview sidecars onto GPU buffers

Stage 2 of the wgpu port. WgpuViewportWindow gains a queueLoadSidecar
API (called from the minimal driver before init) and an applyCachedModel
that runs after init: reads via SidecarCache::readSidecar, allocates
four wgpu buffers per model (vertex storage, index, mesh-quant storage,
instance storage), uploads via wgpuQueueWriteBuffer, retains a CPU
mirror of the MeshInfo/InstanceCpu arrays for the cull and picking
paths that arrive in later stages.

MeshGpu (the per-mesh quantization basis) is derived from MeshInfo on
the fly; InstanceGpu (transform + ids) is derived from InstanceCpu and
uses the cached float transform — composing from placement_transformation
against federation-stage matrices lands when stage 5 wires those.

SidecarCache.cpp is compiled into IfcViewerWgpu directly: it's pure
C++ with no Qt/OCCT/IFC-parse deps, so dragging in the IfcViewer
static lib for one source file would be wasteful. This duplication
goes away once src/ifcviewer-core/ is extracted (task #12).

Verified on a synthesised v13 sidecar (4 verts, 6 indices, 1 mesh,
1 instance) and a multi-sidecar load that assigns successive model_ids.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-05-27 12:48:03 +10:00
parent 19a39a0413
commit 9daa5fe195
5 changed files with 325 additions and 5 deletions
+11 -2
View File
@@ -36,8 +36,11 @@ int main(int argc, char* argv[]) {
QCommandLineParser parser;
parser.setApplicationDescription(
"IfcOpenShell minimal wgpu IFC viewer (stage 1: clear-color smoke test)");
"IfcOpenShell minimal wgpu IFC viewer (stage 2: sidecar load, no draw)");
parser.addHelpOption();
parser.addPositionalArgument("files",
"Sidecar (.ifcview) files to load. Stem-based: foo.ifc resolves to foo.ifcview.",
"[files...]");
parser.process(app);
auto* viewport = new WgpuViewportWindow;
@@ -47,10 +50,16 @@ int main(int argc, char* argv[]) {
container->setMinimumSize(320, 240);
QMainWindow main_window;
main_window.setWindowTitle("IfcViewer (wgpu) — stage 1");
main_window.setWindowTitle("IfcViewer (wgpu) — stage 2");
main_window.setCentralWidget(container);
main_window.resize(1280, 800);
main_window.show();
// Queue sidecars; they're loaded after wgpu init completes in
// exposeEvent. Ordering matches the command line.
for (const QString& path : parser.positionalArguments()) {
viewport->queueLoadSidecar(path);
}
return app.exec();
}
+14 -1
View File
@@ -100,6 +100,16 @@ file(GLOB IFCVIEWER_WGPU_CPP_FILES ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp)
file(GLOB IFCVIEWER_WGPU_H_FILES ${CMAKE_CURRENT_SOURCE_DIR}/*.h)
set(IFCVIEWER_WGPU_FILES ${IFCVIEWER_WGPU_CPP_FILES} ${IFCVIEWER_WGPU_H_FILES})
# Intentional source-level borrowing from the GL backend until ifcviewer-core
# is extracted (task #12). SidecarCache + InstancedGeometry have zero Qt /
# OCCT / IFC-parse deps, so compiling them directly into IfcViewerWgpu is
# cheaper than dragging in the IfcViewer static lib (which would pull all
# of IfcGeom + IfcParse + OCCT + Qt OpenGL).
set(IFCVIEWER_SHARED_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../ifcviewer)
list(APPEND IFCVIEWER_WGPU_FILES
${IFCVIEWER_SHARED_DIR}/SidecarCache.cpp
)
add_library(IfcViewerWgpu STATIC ${IFCVIEWER_WGPU_FILES})
set_target_properties(IfcViewerWgpu PROPERTIES
@@ -108,7 +118,10 @@ set_target_properties(IfcViewerWgpu PROPERTIES
SOVERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}"
)
target_include_directories(IfcViewerWgpu PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_include_directories(IfcViewerWgpu
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
PUBLIC ${IFCVIEWER_SHARED_DIR} # SidecarCache.h is reachable via WgpuViewportWindow.h
)
target_link_libraries(IfcViewerWgpu PUBLIC
Qt${QT_VERSION}::Core
+71
View File
@@ -0,0 +1,71 @@
/********************************************************************************
* *
* 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 WGPUMODELGPUDATA_H
#define WGPUMODELGPUDATA_H
#include <webgpu/webgpu.h>
#include <cstddef>
#include <cstdint>
#include <vector>
#include "InstancedGeometry.h"
// Per-model wgpu state. Mirrors the GL backend's ModelGpuData but with
// wgpu handles. Stage 2 only allocates and uploads the four core buffers;
// bind groups, pipelines, BVH and cull scratch land in later stages.
//
// All vertex/index/mesh/instance bytes are uploaded once at load time via
// wgpuQueueWriteBuffer. The vertex storage buffer is read by the vertex
// shader (vertex pulling), not used as a classic vertex buffer — there is
// no input-assembler vertex layout to match.
struct WgpuModelGpuData {
// Raw VBO bytes at the INSTANCED_VERTEX_STRIDE_BYTES layout (12 B/vertex).
// Bound as a read-only storage buffer in the vertex shader.
WGPUBuffer vertex_storage = nullptr;
// Mesh-local u32 indices. base_vertex applied at draw time so a single
// index buffer per model is shared across meshes.
WGPUBuffer index_buffer = nullptr;
// MeshGpu[] — per-mesh quantization basis (aabb_min/max as vec4 pair).
// Derived from MeshInfo on upload.
WGPUBuffer mesh_storage = nullptr;
// InstanceGpu[] — per-instance transform + ids. Derived from InstanceCpu
// on upload. Stage 2 stores the cached `transform`; later stages will
// recompose from placement_transformation when stage matrices change.
WGPUBuffer instance_storage = nullptr;
// Size mirrors for stats / range checks.
size_t vertex_bytes = 0;
uint32_t index_count = 0;
uint32_t mesh_count = 0;
uint32_t instance_count = 0;
// CPU side, kept for cull / picking / federation recompose.
std::vector<MeshInfo> meshes;
std::vector<InstanceCpu> instances;
bool hidden = false;
};
// Release every wgpu handle in `m` and clear its size mirrors. Safe to call
// repeatedly; idempotent on already-released entries.
void releaseWgpuModelGpuData(WgpuModelGpuData& m);
#endif // WGPUMODELGPUDATA_H
+187
View File
@@ -22,10 +22,12 @@
#include <QGuiApplication>
#include <QResizeEvent>
#include <QDebug>
#include <QFileInfo>
#include <webgpu/wgpu.h> // wgpu-native extensions (logging, MULTI_DRAW_INDIRECT, …)
#include <cstring>
#include <utility>
// -----------------------------------------------------------------------------
// Small helpers
@@ -58,6 +60,43 @@ static void onUncapturedError(WGPUDevice const* /*device*/,
qWarning().noquote() << "[wgpu device error" << int(type) << "]" << sv(message);
}
// Allocate a wgpu buffer of `size_bytes` with the given usage, and upload
// `data` into it via the queue. Returns nullptr when size_bytes == 0 (wgpu
// rejects zero-sized buffer creation). `label` is informational; it shows up
// in validation messages when something goes wrong.
static WGPUBuffer createBufferWithData(WGPUDevice device, WGPUQueue queue,
const void* data, size_t size_bytes,
WGPUBufferUsage usage,
const char* label) {
if (size_bytes == 0) return nullptr;
WGPUBufferDescriptor desc = {};
desc.size = uint64_t(size_bytes);
desc.usage = usage | WGPUBufferUsage_CopyDst;
if (label) {
desc.label.data = label;
desc.label.length = std::strlen(label);
}
WGPUBuffer buf = wgpuDeviceCreateBuffer(device, &desc);
if (buf && data) {
wgpuQueueWriteBuffer(queue, buf, 0, data, size_bytes);
}
return buf;
}
void releaseWgpuModelGpuData(WgpuModelGpuData& m) {
if (m.vertex_storage) { wgpuBufferRelease(m.vertex_storage); m.vertex_storage = nullptr; }
if (m.index_buffer) { wgpuBufferRelease(m.index_buffer); m.index_buffer = nullptr; }
if (m.mesh_storage) { wgpuBufferRelease(m.mesh_storage); m.mesh_storage = nullptr; }
if (m.instance_storage) { wgpuBufferRelease(m.instance_storage); m.instance_storage = nullptr; }
m.vertex_bytes = 0;
m.index_count = 0;
m.mesh_count = 0;
m.instance_count = 0;
m.meshes.clear();
m.instances.clear();
}
// -----------------------------------------------------------------------------
// Construction / destruction
// -----------------------------------------------------------------------------
@@ -80,6 +119,147 @@ void WgpuViewportWindow::setBackgroundColor(const QColor& color) {
if (isExposed()) requestUpdate();
}
// -----------------------------------------------------------------------------
// Sidecar load + GPU upload
// -----------------------------------------------------------------------------
void WgpuViewportWindow::queueLoadSidecar(const QString& path) {
if (wgpu_initialized_) {
loadSidecar(path);
} else {
pending_sidecars_.push_back(path);
}
}
uint32_t WgpuViewportWindow::loadSidecar(const QString& path) {
if (!wgpu_initialized_) {
qWarning().noquote() << "loadSidecar called before wgpu init:" << path;
return 0;
}
auto data_opt = readSidecar(path.toStdString());
if (!data_opt) {
qWarning().noquote() << "Failed to read sidecar:" << path
<< "(file missing, wrong magic, or schema mismatch)";
return 0;
}
const uint32_t mid = next_model_id_++;
applyCachedModel(mid, std::move(*data_opt));
return mid;
}
void WgpuViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) {
if (!device_ || !queue_) {
qWarning() << "applyCachedModel without an initialised device";
return;
}
// Replace any existing state for this id.
auto it = models_gpu_.find(model_id);
if (it != models_gpu_.end()) {
releaseWgpuModelGpuData(it->second);
models_gpu_.erase(it);
}
WgpuModelGpuData m;
m.vertex_bytes = data.vertices.size();
m.index_count = uint32_t(data.indices.size());
m.mesh_count = uint32_t(data.meshes.size());
m.instance_count = uint32_t(data.instances.size());
// Vertex storage — raw bytes at INSTANCED_VERTEX_STRIDE_BYTES layout. The
// vertex shader will read this as a u8 storage buffer in stage 3.
m.vertex_storage = createBufferWithData(
device_, queue_,
data.vertices.data(), data.vertices.size(),
WGPUBufferUsage_Storage,
"model.vertex_storage");
// Index buffer — mesh-local u32 indices; baseVertex applied per-draw.
m.index_buffer = createBufferWithData(
device_, queue_,
data.indices.data(), data.indices.size() * sizeof(uint32_t),
WGPUBufferUsage_Index,
"model.index_buffer");
// Derive MeshGpu[] (vec4 aabb_min + vec4 aabb_max) from MeshInfo's
// local_aabb_*. Mirrors the GL backend's mesh_info_ssbo population.
std::vector<MeshGpu> mesh_gpu;
mesh_gpu.reserve(data.meshes.size());
for (const auto& mi : data.meshes) {
MeshGpu mg = {};
mg.aabb_min[0] = mi.local_aabb_min[0];
mg.aabb_min[1] = mi.local_aabb_min[1];
mg.aabb_min[2] = mi.local_aabb_min[2];
mg.aabb_min[3] = 0.0f;
mg.aabb_max[0] = mi.local_aabb_max[0];
mg.aabb_max[1] = mi.local_aabb_max[1];
mg.aabb_max[2] = mi.local_aabb_max[2];
mg.aabb_max[3] = 0.0f;
mesh_gpu.push_back(mg);
}
m.mesh_storage = createBufferWithData(
device_, queue_,
mesh_gpu.data(), mesh_gpu.size() * sizeof(MeshGpu),
WGPUBufferUsage_Storage,
"model.mesh_storage");
// Derive InstanceGpu[] from InstanceCpu[]. Stage 2 uses the cached
// `transform` directly (stage matrices are identity until stage 5+
// adds federation composition).
std::vector<InstanceGpu> inst_gpu;
inst_gpu.reserve(data.instances.size());
for (const auto& ic : data.instances) {
InstanceGpu ig = {};
std::memcpy(ig.transform, ic.transform, sizeof(ig.transform));
ig.object_id = ic.object_id;
ig.color_override_rgba8 = ic.color_override_rgba8;
ig.mesh_id = ic.mesh_id;
inst_gpu.push_back(ig);
}
m.instance_storage = createBufferWithData(
device_, queue_,
inst_gpu.data(), inst_gpu.size() * sizeof(InstanceGpu),
WGPUBufferUsage_Storage,
"model.instance_storage");
// Hand off CPU mirrors (cull / picking will need them later).
m.meshes = std::move(data.meshes);
m.instances = std::move(data.instances);
models_gpu_.emplace(model_id, std::move(m));
qInfo().noquote().nospace()
<< "[wgpu] applyCachedModel mid=" << model_id
<< " verts=" << m.vertex_bytes << "B"
<< " idx=" << m.index_count
<< " meshes=" << m.mesh_count
<< " instances=" << m.instance_count;
}
void WgpuViewportWindow::removeModel(uint32_t model_id) {
auto it = models_gpu_.find(model_id);
if (it == models_gpu_.end()) return;
releaseWgpuModelGpuData(it->second);
models_gpu_.erase(it);
if (isExposed()) requestUpdate();
}
void WgpuViewportWindow::resetScene() {
for (auto& [mid, m] : models_gpu_) releaseWgpuModelGpuData(m);
models_gpu_.clear();
if (isExposed()) requestUpdate();
}
void WgpuViewportWindow::flushPendingSidecarQueue() {
while (!pending_sidecars_.empty()) {
const QString p = pending_sidecars_.front();
pending_sidecars_.pop_front();
loadSidecar(p);
}
}
// -----------------------------------------------------------------------------
// Lifecycle
// -----------------------------------------------------------------------------
@@ -93,6 +273,9 @@ void WgpuViewportWindow::exposeEvent(QExposeEvent* /*event*/) {
return;
}
wgpu_initialized_ = true;
// Drain any sidecar paths queued before init; uploads run on the
// now-valid device.
flushPendingSidecarQueue();
}
const int w = int(width() * devicePixelRatio());
@@ -367,6 +550,10 @@ void WgpuViewportWindow::render() {
}
void WgpuViewportWindow::shutdown() {
// Release per-model buffers before the device they were created from.
for (auto& [mid, m] : models_gpu_) releaseWgpuModelGpuData(m);
models_gpu_.clear();
if (queue_) { wgpuQueueRelease(queue_); queue_ = nullptr; }
if (device_) { wgpuDeviceRelease(device_); device_ = nullptr; }
if (adapter_) { wgpuAdapterRelease(adapter_); adapter_ = nullptr; }
+42 -2
View File
@@ -22,12 +22,22 @@
#include <QWindow>
#include <QColor>
#include <QString>
#include <webgpu/webgpu.h>
// Stage-1 wgpu viewport: opens a native QWindow, brings up a wgpu instance/
#include <cstdint>
#include <deque>
#include <unordered_map>
#include "SidecarCache.h"
#include "WgpuModelGpuData.h"
// Stage-2 wgpu viewport: opens a native QWindow, brings up a wgpu instance/
// adapter/device, configures a surface against the platform-native window
// handle, and clears to background_color_ on every UpdateRequest.
// handle, and clears to background_color_ on every UpdateRequest. Models
// loaded from `.ifcview` sidecars are uploaded as wgpu buffers (no draw
// path yet — that's stage 3).
//
// Mirrors the lifecycle shape of the GL ViewportWindow so subsequent stages
// can grow this into a full IFC renderer without restructuring the host.
@@ -39,6 +49,27 @@ public:
void setBackgroundColor(const QColor& color);
// Queue a sidecar path to be loaded after wgpu init completes. Safe to
// call before the window is exposed. The path is resolved against the
// working directory and read via SidecarCache::readSidecar (which
// normalises stem → .ifcview).
void queueLoadSidecar(const QString& path);
// Synchronous load + GPU upload. Requires wgpu init to have completed
// (i.e. the window has been exposed at least once). Returns the
// assigned model_id, or 0 on failure.
uint32_t loadSidecar(const QString& path);
// Restore a finalised model from a SidecarData struct: allocate wgpu
// buffers, upload vertex/index/mesh/instance bytes, register in
// models_gpu_. Replaces any existing state for model_id.
void applyCachedModel(uint32_t model_id, SidecarData data);
void removeModel(uint32_t model_id);
void resetScene();
size_t modelCount() const { return models_gpu_.size(); }
protected:
void exposeEvent(QExposeEvent* event) override;
void resizeEvent(QResizeEvent* event) override;
@@ -51,6 +82,8 @@ private:
void render();
void shutdown();
void flushPendingSidecarQueue();
bool wgpu_initialized_ = false;
bool surface_configured_ = false;
int configured_w_ = 0;
@@ -64,6 +97,13 @@ private:
WGPUTextureFormat surface_format_ = WGPUTextureFormat_Undefined;
QColor background_color_ = QColor("#202329");
// Per-model state, keyed by viewport-assigned model_id.
std::unordered_map<uint32_t, WgpuModelGpuData> models_gpu_;
uint32_t next_model_id_ = 1;
// Sidecar paths queued before init completes.
std::deque<QString> pending_sidecars_;
};
#endif // WGPUVIEWPORTWINDOW_H