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:
Dion Moult
2026-05-28 15:20:30 +10:00
parent 4d36174200
commit 6f66d08bee
5 changed files with 57 additions and 97 deletions
-5
View File
@@ -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);
-1
View File
@@ -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})
+13 -7
View File
@@ -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;
};
+44 -76
View File
@@ -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) {
-8
View File
@@ -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