From 1f8f6bffc437f919baeffefb9ef0edaec5c3d67e Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 29 May 2026 16:06:58 +1000 Subject: [PATCH] wgpu cull: refactor per-mesh chunk lookups to per-instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preparatory refactor for spatial instance bucketing (#55). Cull previously routed through per-mesh tables (mesh_chunk_idx, mesh_chunk_local_base_vertex, _ebo_first_u32, _lod1_first_u32) to find the chunk and chunk-local offsets for each instance. That assumes a mesh lives in EXACTLY ONE chunk — the assumption holds under the current mesh-keyed planner but breaks under spatial bucketing, where the same mesh can be duplicated across multiple buckets if its instances are scattered. Adds four per-instance arrays (instance_chunk_idx, instance_base_vertex, instance_ebo_first_u32, instance_lod1_first_u32) populated at planning time. The current mesh-keyed planner derives them by translation: instance_chunk_idx[i] = mesh_chunk_idx[instances[i].mesh_id] The spatial-bucket planner (next commit) will populate them directly, allowing the same mesh_id to map to different chunks for different instances. cullModelCpuCompute now reads the per-instance arrays: - frustum_visible_count uses chunks[instance_chunk_idx[i]] - VisibleDrawGpu uses instance_base_vertex / instance_ebo_first_u32 / instance_lod1_first_u32 - LOD-select branch and use_lod1 logic unchanged. Per-mesh tables stay (used by makeChunkRequest, debug logs, the planner itself). Memory cost: 16 bytes × N instances ≈ 16 MB on a 1M-instance scene. Pixel-identical on basic.ifc in both non-streaming and streaming modes. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer-wgpu/WgpuModelGpuData.h | 15 +++++ src/ifcviewer-wgpu/WgpuViewportWindow.cpp | 72 +++++++++++++++++++---- 2 files changed, 74 insertions(+), 13 deletions(-) diff --git a/src/ifcviewer-wgpu/WgpuModelGpuData.h b/src/ifcviewer-wgpu/WgpuModelGpuData.h index 9989ba34ab..6021357b60 100644 --- a/src/ifcviewer-wgpu/WgpuModelGpuData.h +++ b/src/ifcviewer-wgpu/WgpuModelGpuData.h @@ -232,6 +232,21 @@ struct WgpuModelGpuData { // entries for meshes without LOD1 are 0 and unused. std::vector mesh_chunk_local_lod1_first_u32; + // Per-INSTANCE chunk lookup tables. Mirror the per-mesh arrays above, + // but resolved at planning time so cull can read them directly without + // routing through mesh_id. The split exists because the spatial- + // bucketing planner (#55) can place the same mesh in multiple chunks + // (mesh data duplicated when its instances live in different buckets) + // — under that scheme `mesh_chunk_idx[mesh_id]` is ambiguous, but + // `instance_chunk_idx[instance_id]` is always exactly one chunk. + // The mesh-keyed planner populates these by translation + // (instance_chunk_idx[i] = mesh_chunk_idx[instances[i].mesh_id]); + // the spatial-bucket planner populates them directly. + std::vector instance_chunk_idx; + std::vector instance_base_vertex; + std::vector instance_ebo_first_u32; + std::vector instance_lod1_first_u32; + // Model-shared buffers. Mesh + instance storage are small (<10 MB on // any real scene we've seen); the chunked index buffer lives in Chunk // alongside vertex_storage so streaming can defer both together. diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp index c3b22026e8..5cbf092b9f 100644 --- a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp +++ b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp @@ -235,6 +235,10 @@ void releaseWgpuModelGpuData(WgpuModelGpuData& m, WgpuBufferPool& pool) { m.mesh_chunk_local_base_vertex.clear(); m.mesh_chunk_local_ebo_first_u32.clear(); m.mesh_chunk_local_lod1_first_u32.clear(); + m.instance_chunk_idx.clear(); + m.instance_base_vertex.clear(); + m.instance_ebo_first_u32.clear(); + m.instance_lod1_first_u32.clear(); 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; @@ -863,6 +867,26 @@ void WgpuViewportWindow::applyCachedModelStreaming(uint32_t model_id, c.instance_ids.push_back(inst_idx); } + // Resolve per-instance chunk lookups by translating from the per-mesh + // arrays. Cull reads these directly, so the spatial-bucket planner + // (which can place the same mesh in multiple chunks under #55) will + // populate them without going through mesh_chunk_idx[]. + { + const size_t n_inst = m.instances.size(); + m.instance_chunk_idx.assign(n_inst, 0); + m.instance_base_vertex.assign(n_inst, 0); + m.instance_ebo_first_u32.assign(n_inst, 0); + m.instance_lod1_first_u32.assign(n_inst, 0); + for (size_t i = 0; i < n_inst; ++i) { + const uint32_t mi = m.instances[i].mesh_id; + if (mi >= m.mesh_chunk_idx.size()) continue; + m.instance_chunk_idx[i] = m.mesh_chunk_idx[mi]; + m.instance_base_vertex[i] = m.mesh_chunk_local_base_vertex[mi]; + m.instance_ebo_first_u32[i] = m.mesh_chunk_local_ebo_first_u32[mi]; + m.instance_lod1_first_u32[i] = m.mesh_chunk_local_lod1_first_u32[mi]; + } + } + auto [inserted, _] = models_gpu_.emplace(model_id, std::move(m)); WgpuModelGpuData& mref = inserted->second; @@ -1189,6 +1213,26 @@ void WgpuViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { c.instance_ids.push_back(inst_idx); } + // Resolve per-instance chunk lookups by translating from the per-mesh + // arrays. Cull reads these directly, so the spatial-bucket planner + // (which can place the same mesh in multiple chunks under #55) will + // populate them without going through mesh_chunk_idx[]. + { + const size_t n_inst = m.instances.size(); + m.instance_chunk_idx.assign(n_inst, 0); + m.instance_base_vertex.assign(n_inst, 0); + m.instance_ebo_first_u32.assign(n_inst, 0); + m.instance_lod1_first_u32.assign(n_inst, 0); + for (size_t i = 0; i < n_inst; ++i) { + const uint32_t mi = m.instances[i].mesh_id; + if (mi >= m.mesh_chunk_idx.size()) continue; + m.instance_chunk_idx[i] = m.mesh_chunk_idx[mi]; + m.instance_base_vertex[i] = m.mesh_chunk_local_base_vertex[mi]; + m.instance_ebo_first_u32[i] = m.mesh_chunk_local_ebo_first_u32[mi]; + m.instance_lod1_first_u32[i] = m.mesh_chunk_local_lod1_first_u32[mi]; + } + } + auto [inserted, _] = models_gpu_.emplace(model_id, std::move(m)); WgpuModelGpuData& mref = inserted->second; buildModelBindGroup(mref); @@ -2738,7 +2782,7 @@ uint32_t WgpuViewportWindow::cullModelCpuCompute(WgpuModelGpuData& m, // This is the signal driveStreamingLoads keys residency on — stable // across frames when the camera doesn't move, so the loader doesn't // thrash on HiZ visibility flicker. - ++m.chunks[m.mesh_chunk_idx[inst.mesh_id]].frustum_visible_count; + ++m.chunks[m.instance_chunk_idx[i]].frustum_visible_count; const MeshInfo& mesh = m.meshes[inst.mesh_id]; @@ -2779,23 +2823,25 @@ uint32_t WgpuViewportWindow::cullModelCpuCompute(WgpuModelGpuData& m, && mesh.lod1_index_count > 0 && projected_px < lod1_threshold_px; - // Emit one VisibleDraw entry into the chunk that owns this mesh's - // vertex range. base_vertex AND ebo_first_u32 are both CHUNK-LOCAL - // — the chunk's bind group points at its own vertex_storage and - // index_buffer slices so the shader indexes them directly. When - // use_lod1, ebo_first_u32 routes into the LOD1 section of the - // chunk's index slice (which is packed after the LOD0 section at - // chunk-build time); the shader is oblivious to the LOD split. - const uint32_t chunk_idx = m.mesh_chunk_idx[inst.mesh_id]; + // Emit one VisibleDraw entry into the chunk that owns this + // instance's vertex range. base_vertex AND ebo_first_u32 are both + // CHUNK-LOCAL — the chunk's bind group points at its own + // vertex_storage and index_buffer slices so the shader indexes + // them directly. When use_lod1, ebo_first_u32 routes into the LOD1 + // section of the chunk's index slice (which is packed after the + // LOD0 section at chunk-build time); the shader is oblivious to + // the LOD split. Lookups are per-INSTANCE (not per-mesh) so the + // spatial-bucket planner (#55) can duplicate a mesh into multiple + // chunks; each instance still resolves to exactly one chunk. + const uint32_t chunk_idx = m.instance_chunk_idx[i]; WgpuModelGpuData::Chunk& c = m.chunks[chunk_idx]; WgpuModelGpuData::VisibleDrawGpu d; d.mesh_id = inst.mesh_id; d.instance_idx = i; - d.ebo_first_u32 = use_lod1 - ? m.mesh_chunk_local_lod1_first_u32[inst.mesh_id] - : m.mesh_chunk_local_ebo_first_u32[inst.mesh_id]; - d.base_vertex = m.mesh_chunk_local_base_vertex[inst.mesh_id]; + d.ebo_first_u32 = use_lod1 ? m.instance_lod1_first_u32[i] + : m.instance_ebo_first_u32[i]; + d.base_vertex = m.instance_base_vertex[i]; c.visible_draws_scratch.push_back(d); const uint32_t entry_vert_count = use_lod1 ? mesh.lod1_index_count