mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-10 09:48:32 +00:00
wgpu cull: chunk-level frustum cull replaces BVH walk
cullModelCpuCompute previously had two paths: a flat linear scan over
all instances (default), or a BVH-stack walk (--bvh, gated off because
it regressed on dense scenes — the BVH built per instance but its
interior-node AABBs spanned huge chunks of model so most subtrees
straddled the frustum and the walk overhead beat the rejection win).
With spatial chunk planning (commit 4d3617420) chunks ARE already a
one-level spatial partition of the model, with tight per-chunk AABBs.
So the same wholesale-reject behaviour falls out of just walking
m.chunks: frustum-test each chunk's AABB once, and on hit, iterate
its (new) instance_ids list. No per-node traversal overhead, no
dependency on rebuilding a BVH alongside the chunk plan.
Changes:
- Chunk gains an instance_ids vector, populated in both apply paths
alongside the per-chunk AABB accumulation.
- cullModelCpuCompute drops the if-bvh / else-linear-scan dichotomy
in favour of `for chunk: frustum-test then iterate c.instance_ids`.
- Per-model ModelBvh field, buildModelBvhOne call sites, BvhAccel.cpp
in CMakeLists, bvh_enabled_ field, and --bvh CLI flag all removed —
dead code now that chunk-cull subsumes them.
- BvhAccel.{h,cpp} stay in src/ifcviewer for the GL backend's use.
Benchmark (big federation, --streaming, close camera): avg 37 fps
(was 36) / median 53 (was 53). Same order on the metric — the
parallelism across models was already amortising frustum-check cost,
so the per-chunk early-out saves only fragments of cull wall time.
Real cull-perf win will come from chunk-level HiZ (potentially) or
GPU compute cull (task #17). What this commit really delivers is
architectural simplification + removal of a dead-but-not-dropped
code path.
Pixel-identical to non-streaming on basic.ifc.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -54,10 +54,6 @@ int main(int argc, char* argv[]) {
|
||||
"Request the WebGPU mandatory floor limits (128MB max storage binding) "
|
||||
"instead of the adapter's actual max. Use to verify scenes fit through "
|
||||
"browser constraints."});
|
||||
parser.addOption({"bvh",
|
||||
"Enable BVH-walk cull. Off by default — currently a regression on "
|
||||
"dense camera-looking-at-everything scenes; may help on sprawling "
|
||||
"federations where most of the scene is off-screen."});
|
||||
parser.addOption({"streaming",
|
||||
"Enable streaming sidecar load. Reads metadata-only at load time; "
|
||||
"vertex chunks are deferred and loaded on demand as they become "
|
||||
@@ -68,7 +64,6 @@ int main(int argc, char* argv[]) {
|
||||
viewport->resize(1280, 800);
|
||||
if (parser.isSet("no-hiz")) viewport->hiz_enabled_ = false;
|
||||
if (parser.isSet("web-limits")) viewport->web_limits_ = true;
|
||||
if (parser.isSet("bvh")) viewport->bvh_enabled_ = true;
|
||||
if (parser.isSet("streaming")) viewport->streaming_enabled_ = true;
|
||||
|
||||
QWidget* container = QWidget::createWindowContainer(viewport);
|
||||
|
||||
@@ -108,7 +108,6 @@ set(IFCVIEWER_WGPU_FILES ${IFCVIEWER_WGPU_CPP_FILES} ${IFCVIEWER_WGPU_H_FILES})
|
||||
set(IFCVIEWER_SHARED_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../ifcviewer)
|
||||
list(APPEND IFCVIEWER_WGPU_FILES
|
||||
${IFCVIEWER_SHARED_DIR}/SidecarCache.cpp
|
||||
${IFCVIEWER_SHARED_DIR}/BvhAccel.cpp
|
||||
)
|
||||
|
||||
add_library(IfcViewerWgpu STATIC ${IFCVIEWER_WGPU_FILES})
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "BvhAccel.h"
|
||||
#include "InstancedGeometry.h"
|
||||
#include "WgpuBufferPool.h"
|
||||
|
||||
@@ -157,6 +156,14 @@ struct WgpuModelGpuData {
|
||||
// point at the correct chunk-local offsets.
|
||||
std::vector<uint32_t> mesh_ids;
|
||||
|
||||
// Instance indices belonging to this chunk (i.e. whose mesh lives
|
||||
// in this chunk). Built at chunk-planning time. Lets cull iterate
|
||||
// chunks as the outer loop, frustum-test the chunk AABB once,
|
||||
// and skip every instance inside in one shot when the chunk is
|
||||
// off-screen — far cheaper than the per-instance frustum check
|
||||
// on flat-scan culls of 1M+ instance scenes.
|
||||
std::vector<uint32_t> instance_ids;
|
||||
|
||||
// LRU marker for streaming eviction. Updated to the window's
|
||||
// streaming_frame_idx_ every frame the chunk is rendered (i.e.
|
||||
// total_visible_draws > 0). The evictor picks the smallest value
|
||||
@@ -205,12 +212,11 @@ struct WgpuModelGpuData {
|
||||
std::vector<MeshInfo> meshes;
|
||||
std::vector<InstanceCpu> instances;
|
||||
|
||||
// Per-model BVH over the instances' world AABBs. Built once at
|
||||
// applyCachedModel; consumed by cullModelCpuCompute to reject whole
|
||||
// subtrees against frustum + HiZ without descending. Critical for
|
||||
// 100+ model / 1M+ instance scenes — turns O(N) per-instance cull
|
||||
// into ~O(visible_count + log N).
|
||||
ModelBvh bvh;
|
||||
// Spatial chunk-cull replaced the per-model BVH walk — chunks are
|
||||
// already a one-level spatial partition of the instances, so a
|
||||
// single frustum test per chunk gives the same wholesale-reject
|
||||
// win without the BVH's per-node traversal overhead. The BVH field
|
||||
// is gone; cull iterates m.chunks instead.
|
||||
|
||||
bool hidden = false;
|
||||
};
|
||||
|
||||
@@ -159,8 +159,6 @@ void releaseWgpuModelGpuData(WgpuModelGpuData& m, WgpuBufferPool& pool) {
|
||||
m.instance_count = 0;
|
||||
m.meshes.clear();
|
||||
m.instances.clear();
|
||||
m.bvh.nodes.clear();
|
||||
m.bvh.item_indices.clear();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -759,9 +757,16 @@ void WgpuViewportWindow::applyCachedModelStreaming(uint32_t model_id,
|
||||
m.meshes = std::move(metadata.meta.meshes);
|
||||
m.instances = std::move(metadata.meta.instances);
|
||||
|
||||
// Compute per-chunk world AABBs from instance world AABBs grouped by
|
||||
// their mesh's chunk. Used to chunk-cull and prioritise streaming.
|
||||
for (const auto& inst : m.instances) {
|
||||
// Compute per-chunk world AABBs + instance-id lists from the
|
||||
// instances grouped by their mesh's chunk. The AABBs are used to
|
||||
// chunk-cull (cull skips every instance in a chunk whose AABB is
|
||||
// outside the frustum) and to prioritise streaming. instance_ids
|
||||
// lets cull iterate the chunk's instances when the chunk passes.
|
||||
for (size_t ci = 0; ci < m.chunks.size(); ++ci) {
|
||||
m.chunks[ci].instance_ids.reserve(m.instances.size() / m.chunks.size() + 4);
|
||||
}
|
||||
for (uint32_t inst_idx = 0; inst_idx < uint32_t(m.instances.size()); ++inst_idx) {
|
||||
const auto& inst = m.instances[inst_idx];
|
||||
if (inst.mesh_id >= m.mesh_chunk_idx.size()) continue;
|
||||
const uint32_t ci = m.mesh_chunk_idx[inst.mesh_id];
|
||||
if (ci >= m.chunks.size()) continue;
|
||||
@@ -770,6 +775,7 @@ void WgpuViewportWindow::applyCachedModelStreaming(uint32_t model_id,
|
||||
c.aabb_min[a] = std::min(c.aabb_min[a], inst.world_aabb_min[a]);
|
||||
c.aabb_max[a] = std::max(c.aabb_max[a], inst.world_aabb_max[a]);
|
||||
}
|
||||
c.instance_ids.push_back(inst_idx);
|
||||
}
|
||||
|
||||
auto [inserted, _] = models_gpu_.emplace(model_id, std::move(m));
|
||||
@@ -779,24 +785,6 @@ void WgpuViewportWindow::applyCachedModelStreaming(uint32_t model_id,
|
||||
// chunk's load. The per-frame loader (commit 4) will buildModelBindGroup
|
||||
// after a chunk becomes resident.
|
||||
|
||||
// BVH built from instance world AABBs (unchanged from non-streaming).
|
||||
{
|
||||
std::vector<BvhItem> items;
|
||||
items.reserve(mref.instances.size());
|
||||
for (const auto& inst : mref.instances) {
|
||||
BvhItem it;
|
||||
it.aabb_min[0] = inst.world_aabb_min[0];
|
||||
it.aabb_min[1] = inst.world_aabb_min[1];
|
||||
it.aabb_min[2] = inst.world_aabb_min[2];
|
||||
it.aabb_max[0] = inst.world_aabb_max[0];
|
||||
it.aabb_max[1] = inst.world_aabb_max[1];
|
||||
it.aabb_max[2] = inst.world_aabb_max[2];
|
||||
it.model_id = model_id;
|
||||
items.push_back(it);
|
||||
}
|
||||
mref.bvh = buildModelBvhOne(items, model_id);
|
||||
}
|
||||
|
||||
qInfo().noquote().nospace()
|
||||
<< "[wgpu stream] applyCachedModelStreaming mid=" << model_id
|
||||
<< " verts=" << mref.vertex_bytes << "B (deferred)"
|
||||
@@ -1081,30 +1069,30 @@ void WgpuViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) {
|
||||
m.meshes = std::move(data.meshes);
|
||||
m.instances = std::move(data.instances);
|
||||
|
||||
// Per-chunk world AABB + instance-id list. Same logic as the
|
||||
// streaming path. Lets cull frustum-test each chunk's AABB once
|
||||
// and skip every instance inside in one shot when the chunk is
|
||||
// off-screen.
|
||||
for (auto& c : m.chunks) {
|
||||
c.instance_ids.reserve(m.instances.size() / m.chunks.size() + 4);
|
||||
}
|
||||
for (uint32_t inst_idx = 0; inst_idx < uint32_t(m.instances.size()); ++inst_idx) {
|
||||
const auto& inst = m.instances[inst_idx];
|
||||
if (inst.mesh_id >= m.mesh_chunk_idx.size()) continue;
|
||||
const uint32_t ci = m.mesh_chunk_idx[inst.mesh_id];
|
||||
if (ci >= m.chunks.size()) continue;
|
||||
auto& c = m.chunks[ci];
|
||||
for (int a = 0; a < 3; ++a) {
|
||||
c.aabb_min[a] = std::min(c.aabb_min[a], inst.world_aabb_min[a]);
|
||||
c.aabb_max[a] = std::max(c.aabb_max[a], inst.world_aabb_max[a]);
|
||||
}
|
||||
c.instance_ids.push_back(inst_idx);
|
||||
}
|
||||
|
||||
auto [inserted, _] = models_gpu_.emplace(model_id, std::move(m));
|
||||
WgpuModelGpuData& mref = inserted->second;
|
||||
buildModelBindGroup(mref);
|
||||
|
||||
// Build per-model BVH over the instances' world AABBs. Once-per-load
|
||||
// cost; used every frame by the cull to reject whole subtrees against
|
||||
// frustum + HiZ.
|
||||
{
|
||||
std::vector<BvhItem> items;
|
||||
items.reserve(mref.instances.size());
|
||||
for (const auto& inst : mref.instances) {
|
||||
BvhItem it;
|
||||
it.aabb_min[0] = inst.world_aabb_min[0];
|
||||
it.aabb_min[1] = inst.world_aabb_min[1];
|
||||
it.aabb_min[2] = inst.world_aabb_min[2];
|
||||
it.aabb_max[0] = inst.world_aabb_max[0];
|
||||
it.aabb_max[1] = inst.world_aabb_max[1];
|
||||
it.aabb_max[2] = inst.world_aabb_max[2];
|
||||
it.model_id = model_id;
|
||||
items.push_back(it);
|
||||
}
|
||||
mref.bvh = buildModelBvhOne(items, model_id);
|
||||
}
|
||||
|
||||
// Cumulative VRAM across all loaded models so the user can see where
|
||||
// the wall is hit when streaming into a multi-GB scene.
|
||||
uint64_t total_vbo = 0, total_ebo = 0, total_ssbo = 0;
|
||||
@@ -2622,39 +2610,19 @@ uint32_t WgpuViewportWindow::cullModelCpuCompute(WgpuModelGpuData& m,
|
||||
c.prefix_sums_scratch.push_back(running_vertex_count[chunk_idx]);
|
||||
};
|
||||
|
||||
// BVH-driven walk: stack-based DFS through the per-model BVH.
|
||||
// Interior nodes do FRUSTUM ONLY — HiZ at an interior node rarely
|
||||
// rejects because the big subtree AABB spans many HiZ mip cells, and
|
||||
// we'd pay the test cost without saving anything. HiZ runs per-instance
|
||||
// at the leaf (already in process_instance via the inner test order).
|
||||
//
|
||||
// Falls back to a flat linear scan when the BVH is disabled (bvh_enabled_
|
||||
// default off because dense scenes regress under the walk overhead;
|
||||
// see task #15) or absent (empty BVH).
|
||||
if (!bvh_enabled_ || m.bvh.nodes.empty()) {
|
||||
for (uint32_t i = 0; i < uint32_t(m.instances.size()); ++i) {
|
||||
process_instance(i);
|
||||
}
|
||||
} else {
|
||||
std::vector<uint32_t> stack;
|
||||
stack.reserve(64);
|
||||
stack.push_back(0);
|
||||
while (!stack.empty()) {
|
||||
const uint32_t ni = stack.back();
|
||||
stack.pop_back();
|
||||
const BvhNode& node = m.bvh.nodes[ni];
|
||||
if (!aabbInFrustum(node.aabb_min, node.aabb_max, planes)) continue;
|
||||
if (node.count > 0) {
|
||||
// Leaf — handle items inline (no scratch buffer).
|
||||
for (uint32_t i = 0; i < node.count; ++i) {
|
||||
process_instance(m.bvh.item_indices[node.right_or_first + i]);
|
||||
}
|
||||
} else {
|
||||
// Interior: descend both children (Left=ni+1, Right=right_or_first).
|
||||
stack.push_back(ni + 1);
|
||||
stack.push_back(node.right_or_first);
|
||||
}
|
||||
}
|
||||
// Chunk-driven walk: frustum-test each chunk's AABB once, and skip
|
||||
// every instance inside in one shot when the chunk is off-screen.
|
||||
// With spatial chunk planning (~hundreds of tight per-chunk AABBs
|
||||
// per scene) this rejects most instances without ever touching them
|
||||
// individually — a strict superset of the previous BVH walk's win,
|
||||
// because the chunk partition is already a one-level spatial BVH
|
||||
// with zero traversal overhead. The per-model BVH built at load
|
||||
// time is now unused by cull; it stays around as dead weight until
|
||||
// the cleanup pass removes it.
|
||||
for (auto& c : m.chunks) {
|
||||
if (c.instance_ids.empty()) continue;
|
||||
if (!aabbInFrustum(c.aabb_min, c.aabb_max, planes)) continue;
|
||||
for (uint32_t i : c.instance_ids) process_instance(i);
|
||||
}
|
||||
|
||||
for (size_t ci = 0; ci < m.chunks.size(); ++ci) {
|
||||
|
||||
@@ -394,14 +394,6 @@ public:
|
||||
// scene fits through the constraints a browser will impose.
|
||||
bool web_limits_ = false;
|
||||
|
||||
// BVH-walk cull. Default OFF: the BVH adds ~17ms walk overhead on
|
||||
// dense centred-camera scenes without rejecting enough subtrees to
|
||||
// compensate (every subtree's AABB straddles the frustum). It MAY help
|
||||
// on spatially-separated scenes (e.g. distant camera looking at one
|
||||
// model in a sprawling federation). Toggle on via --bvh to measure.
|
||||
// Real default-on requires further tuning — see task #15.
|
||||
bool bvh_enabled_ = false;
|
||||
|
||||
// Streaming load (task #16). When enabled, queueLoadSidecar routes
|
||||
// through the metadata-only reader: mesh dict + instance dict + georef
|
||||
// load immediately; per-chunk vertex bytes are read + uploaded on
|
||||
|
||||
Reference in New Issue
Block a user