diff --git a/src/ifcviewer-wgpu-minimal/main.cpp b/src/ifcviewer-wgpu-minimal/main.cpp index b9c4f4e71d..757442e64d 100644 --- a/src/ifcviewer-wgpu-minimal/main.cpp +++ b/src/ifcviewer-wgpu-minimal/main.cpp @@ -54,12 +54,17 @@ 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.process(app); auto* viewport = new WgpuViewportWindow; 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; QWidget* container = QWidget::createWindowContainer(viewport); container->setMinimumSize(320, 240); diff --git a/src/ifcviewer-wgpu/CMakeLists.txt b/src/ifcviewer-wgpu/CMakeLists.txt index 1fab6233e3..54d98fbfce 100644 --- a/src/ifcviewer-wgpu/CMakeLists.txt +++ b/src/ifcviewer-wgpu/CMakeLists.txt @@ -108,6 +108,7 @@ 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}) diff --git a/src/ifcviewer-wgpu/WgpuModelGpuData.h b/src/ifcviewer-wgpu/WgpuModelGpuData.h index 5507e6f66a..74e088f8ae 100644 --- a/src/ifcviewer-wgpu/WgpuModelGpuData.h +++ b/src/ifcviewer-wgpu/WgpuModelGpuData.h @@ -26,6 +26,7 @@ #include #include +#include "BvhAccel.h" #include "InstancedGeometry.h" // Per-model wgpu state. Mirrors the GL backend's ModelGpuData but with @@ -109,6 +110,13 @@ struct WgpuModelGpuData { std::vector meshes; std::vector 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; + bool hidden = false; }; diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp index a7695d2630..bd2c94f5ad 100644 --- a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp +++ b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp @@ -151,6 +151,8 @@ void releaseWgpuModelGpuData(WgpuModelGpuData& m) { m.instance_count = 0; m.meshes.clear(); m.instances.clear(); + m.bvh.nodes.clear(); + m.bvh.item_indices.clear(); } // ----------------------------------------------------------------------------- @@ -726,6 +728,26 @@ void WgpuViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { 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 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] applyCachedModel mid=" << model_id << " verts=" << mref.vertex_bytes << "B" @@ -2039,13 +2061,17 @@ uint32_t WgpuViewportWindow::cullModelCpuCompute(WgpuModelGpuData& m, // chunk counts. std::vector running_vertex_count(m.chunks.size(), 0); - for (uint32_t i = 0; i < uint32_t(m.instances.size()); ++i) { + // Per-instance work as a lambda — same logic regardless of how we + // reached the instance (BVH walk leaf vs. flat linear scan). Keeps the + // BVH path single-pass (no scratch buffer / no second iteration). + auto process_instance = [&](uint32_t i) { const auto& inst = m.instances[i]; - if (inst.mesh_id >= m.meshes.size()) continue; - // Cheapest possible cull first: explicit user-hidden flag. Skips - // every downstream cost (frustum / HiZ / draw / pick). - if (visibility_.isHidden(inst.object_id)) continue; - if (!aabbInFrustum(inst.world_aabb_min, inst.world_aabb_max, planes)) continue; + if (inst.mesh_id >= m.meshes.size()) return; + if (visibility_.isHidden(inst.object_id)) return; + // Per-instance frustum still needed: a partially-covered subtree + // descended this far means *some* leaves are visible, but not + // necessarily this one. + if (!aabbInFrustum(inst.world_aabb_min, inst.world_aabb_max, planes)) return; const MeshInfo& mesh = m.meshes[inst.mesh_id]; @@ -2074,12 +2100,12 @@ uint32_t WgpuViewportWindow::cullModelCpuCompute(WgpuModelGpuData& m, // per-instance test (8-corner projection + mip pyramid sample), so // letting cheap contribution drops happen first cuts the HiZ-tested // population by ~5× on real scenes. - if (contrib_enabled && projected_px < min_radius_px) continue; + if (contrib_enabled && projected_px < min_radius_px) return; if (hiz_enabled && aabbOccludedByHiz(inst.world_aabb_min, inst.world_aabb_max)) { ++hiz_rejects; - continue; + return; } const bool use_lod1 = lod_enabled @@ -2104,6 +2130,41 @@ uint32_t WgpuViewportWindow::cullModelCpuCompute(WgpuModelGpuData& m, : mesh.index_count; running_vertex_count[chunk_idx] += entry_vert_count; 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 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); + } + } } for (size_t ci = 0; ci < m.chunks.size(); ++ci) { diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.h b/src/ifcviewer-wgpu/WgpuViewportWindow.h index 7b7dffe8e8..fa019af410 100644 --- a/src/ifcviewer-wgpu/WgpuViewportWindow.h +++ b/src/ifcviewer-wgpu/WgpuViewportWindow.h @@ -358,6 +358,14 @@ 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; + private: // Switch to LOD1 when an instance's projected bounding-sphere radius