diff --git a/src/ifcviewer-wgpu-minimal/main.cpp b/src/ifcviewer-wgpu-minimal/main.cpp index 3601611f3c..fbd0632c60 100644 --- a/src/ifcviewer-wgpu-minimal/main.cpp +++ b/src/ifcviewer-wgpu-minimal/main.cpp @@ -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); diff --git a/src/ifcviewer-wgpu/CMakeLists.txt b/src/ifcviewer-wgpu/CMakeLists.txt index 54d98fbfce..1fab6233e3 100644 --- a/src/ifcviewer-wgpu/CMakeLists.txt +++ b/src/ifcviewer-wgpu/CMakeLists.txt @@ -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}) diff --git a/src/ifcviewer-wgpu/WgpuModelGpuData.h b/src/ifcviewer-wgpu/WgpuModelGpuData.h index 76c5dbc04f..ef8a270d0f 100644 --- a/src/ifcviewer-wgpu/WgpuModelGpuData.h +++ b/src/ifcviewer-wgpu/WgpuModelGpuData.h @@ -28,7 +28,6 @@ #include #include -#include "BvhAccel.h" #include "InstancedGeometry.h" #include "WgpuBufferPool.h" @@ -157,6 +156,14 @@ struct WgpuModelGpuData { // point at the correct chunk-local offsets. std::vector 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 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 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; + // 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; }; diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp index a8e0225b4e..946dc2ba22 100644 --- a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp +++ b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp @@ -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 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 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 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) { diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.h b/src/ifcviewer-wgpu/WgpuViewportWindow.h index 10d1bc7e5e..36f6556663 100644 --- a/src/ifcviewer-wgpu/WgpuViewportWindow.h +++ b/src/ifcviewer-wgpu/WgpuViewportWindow.h @@ -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