From 091b4d41138eb5d6abdc2811509d00cf5fc50716 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 23 Aug 2026 22:38:02 +1000 Subject: [PATCH] ifcviewer: bound the CPU triangle shadow and the cull scratch to residency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wasm heap grew past 2 GB on a 66-model session (surfacing first as the setBindGroup 2 GB TypeError, fixed separately) because CPU memory attached to loaded geometry never shrank while the GPU pool did: - mesh_triangles_cache — the dequantised positions + LOD0 indices the surface raycasts and measurement tools read — was filled once per mesh on first residency (gated on mesh_local_volumes == 0) and never released, converging over a session to the whole federation's geometry on the heap: 12 B/vertex + 4 B/index, 400 MB - 1 GB at this scale. And on web nothing reads it at all (no measurement tools yet). - Every chunk's cull scratch was reserved at model load (20 B/instance scene-wide) and the scratch + uploaded mirrors survived eviction. - Cull ran the HiZ test and emitted VisibleDrawGpu entries — then uploaded them — for non-resident chunks render() cannot draw. Now the shadow follows GPU residency: a per-mesh resident-chunk refcount (the spatial planner may duplicate a mesh into several chunks) is counted up in applyStreamedChunk and down in unloadChunk, releasing the mesh's entry at zero and refilling from the chunk bytes on the next residency. mesh_local_volumes (8 B/mesh) is kept across eviction so the Volume tool still covers evicted meshes. Hosts opt in via ViewportHost::wantsCpuMeshTriangles(): Qt yes, web no until the tools are ported — so on web the shadow costs nothing. Cull stops at the streaming counters for non-resident chunks, the eager scratch reserve is gone, and unloadChunk releases the scratch and uploaded mirrors. Clearing the mirrors also fixes a real staleness bug in unload/load: the model's cull buffers are recreated on load, and a stale mirror would make the memcmp dirty-check skip the first upload into the fresh (garbage) buffer. The heartbeat log reports the shadow (cpuTris). Measured on a 3-model / 990 MB scene: shadow tracks residency (493 MB at a 530 MB resident set, flat over minutes of streaming churn; previously monotonic), unload drops it to zero, reload refills it (verified via readbackMeshTriangles round trip). Co-Authored-By: Claude Fable 5 --- src/ifcviewer-web/WebViewportHost.h | 5 ++ src/ifcviewer/ModelGpuData.h | 13 +++++ src/ifcviewer/ViewportCore.cpp | 86 +++++++++++++++++++++++++---- src/ifcviewer/ViewportHost.h | 7 +++ 4 files changed, 101 insertions(+), 10 deletions(-) diff --git a/src/ifcviewer-web/WebViewportHost.h b/src/ifcviewer-web/WebViewportHost.h index a9ce0454bd..3a2d6af0f7 100644 --- a/src/ifcviewer-web/WebViewportHost.h +++ b/src/ifcviewer-web/WebViewportHost.h @@ -58,6 +58,11 @@ public: // here so the page can read them whenever it likes (ifcv_get_frame_stats_c) // instead of being called back every frame across the wasm boundary. void onFrameStats(const FrameStats& stats) override { last_stats_ = stats; } + + // No measurement tools on web yet, so nothing reads the CPU triangle + // shadow — and at 12 B/vertex it is a large slice of a 4 GB-capped + // wasm heap. Flip when the tools are ported. + bool wantsCpuMeshTriangles() const override { return false; } const FrameStats& lastFrameStats() const { return last_stats_; } private: diff --git a/src/ifcviewer/ModelGpuData.h b/src/ifcviewer/ModelGpuData.h index 27be9530f2..dc8f89c946 100644 --- a/src/ifcviewer/ModelGpuData.h +++ b/src/ifcviewer/ModelGpuData.h @@ -434,6 +434,19 @@ struct ModelGpuData { std::vector indices; // 3 * triangle_count, LOD0 }; std::vector mesh_triangles_cache; + // How many RESIDENT chunks currently contain each mesh (the spatial + // planner may duplicate a mesh into several chunks). Maintained by + // applyStreamedChunk / unloadChunk; when it drops to zero the mesh's + // mesh_triangles_cache entry is released — the shadow follows GPU + // residency instead of accumulating every mesh ever loaded, which on + // a large federation grew monotonically toward the whole scene's + // geometry on the CPU heap. mesh_local_volumes is NOT released: the + // Volume tool needs it for evicted meshes too, and it is 8 B/mesh. + std::vector mesh_resident_chunk_refs; + // Bytes currently held by mesh_triangles_cache, maintained at the fill + // (applyStreamedChunk) and release (unloadChunk) sites so the heartbeat + // log can report the shadow without walking every mesh per frame. + std::uint64_t cpu_shadow_bytes = 0; // object_id (globally rebased) → instance index in `instances`. // Populated alongside the instance vector so the Volume tool can do diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp index b417912eb2..3bb80a2ef2 100644 --- a/src/ifcviewer/ViewportCore.cpp +++ b/src/ifcviewer/ViewportCore.cpp @@ -2353,6 +2353,16 @@ bool ViewportCore::applyStreamedChunk( c.is_loading = false; c.loaded_frame_idx = streaming_frame_idx_; + // The CPU triangle shadow follows residency: count this chunk into each + // of its meshes (a mesh can live in several chunks under the spatial + // planner); unloadChunk counts it back out and releases the shadow of + // any mesh with no resident chunk left. + if (m.mesh_resident_chunk_refs.size() == m.meshes.size()) { + for (std::uint32_t mi : c.mesh_ids) { + if (mi < m.mesh_resident_chunk_refs.size()) ++m.mesh_resident_chunk_refs[mi]; + } + } + // Per-mesh alpha probe. Scan every vertex of every mesh in this chunk // for any alpha byte < 255 — fires the mesh_has_alpha flag the cull // classifier reads to route instances of this mesh to the transparent @@ -2389,9 +2399,17 @@ bool ViewportCore::applyStreamedChunk( // chunk to deliver each mesh fills it in. bool filled_volume = false; if (!m.mesh_local_volumes.empty() && !idx.empty()) { + const bool want_tris = host_->wantsCpuMeshTriangles(); for (std::uint32_t mi : c.mesh_ids) { if (mi >= m.meshes.size() || mi >= m.mesh_local_volumes.size()) continue; - if (m.mesh_local_volumes[mi] != 0.0) continue; + // Volume is computed once per mesh (8 B, kept across eviction); + // the triangle shadow is refilled whenever this mesh returns to + // residency after its shadow was released. + const bool need_volume = m.mesh_local_volumes[mi] == 0.0; + const bool need_tris = want_tris + && mi < m.mesh_triangles_cache.size() + && m.mesh_triangles_cache[mi].indices.empty(); + if (!need_volume && !need_tris) continue; const MeshInfo& mesh = m.meshes[mi]; if (mesh.vertex_count == 0 || mesh.index_count < 3) continue; const std::size_t v_off = @@ -2402,14 +2420,18 @@ bool ViewportCore::applyStreamedChunk( + std::size_t(mesh.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES; if (v_end > vbytes.size()) continue; if (i_off + mesh.index_count > idx.size()) continue; - ModelGpuData::MeshTriangles* tris = - (mi < m.mesh_triangles_cache.size()) - ? &m.mesh_triangles_cache[mi] - : nullptr; - m.mesh_local_volumes[mi] = computeMeshLocalVolumeQuantised( + const double volume = computeMeshLocalVolumeQuantised( mesh, vbytes.data() + v_off, idx.data() + i_off, mesh.index_count, - tris); - filled_volume = true; + need_tris ? &m.mesh_triangles_cache[mi] : nullptr); + if (need_tris) { + const auto& tris = m.mesh_triangles_cache[mi]; + m.cpu_shadow_bytes += tris.positions.size() * sizeof(float) + + tris.indices.size() * sizeof(std::uint32_t); + } + if (need_volume) { + m.mesh_local_volumes[mi] = volume; + filled_volume = true; + } } } // Fire the tool-refresh callback once per apply if anything new filled @@ -2486,6 +2508,38 @@ void ViewportCore::unloadChunk(ModelGpuData& m, std::size_t chunk_idx) { c.total_visible_draws = 0; c.total_visible_vertices = 0; c.is_resident = false; + + // CPU side of the eviction. The cull scratch and the uploaded mirrors + // are only meaningful for a resident chunk (cull no longer emits for + // non-resident ones); releasing them here also keeps the memcmp + // dirty-check honest — after a model unload/load cycle the GPU cull + // buffers are fresh, and a stale mirror would wrongly skip the first + // upload into them. Assignment, not clear(): capacity must go too. + c.visible_draws_scratch = {}; + c.prefix_sums_scratch = {}; + c.visible_draws_scratch_transparent = {}; + c.transparent_per_draw_vertex_counts = {}; + c.visible_draws_uploaded = {}; + c.prefix_sums_uploaded = {}; + + // Count this chunk out of its meshes' residency; a mesh with no + // resident chunk left releases its triangle shadow (refilled from the + // chunk bytes on the next residency — see applyStreamedChunk). + if (m.mesh_resident_chunk_refs.size() == m.meshes.size()) { + for (std::uint32_t mi : c.mesh_ids) { + if (mi >= m.mesh_resident_chunk_refs.size()) continue; + if (m.mesh_resident_chunk_refs[mi] > 0) --m.mesh_resident_chunk_refs[mi]; + if (m.mesh_resident_chunk_refs[mi] == 0 + && mi < m.mesh_triangles_cache.size()) { + auto& tris = m.mesh_triangles_cache[mi]; + const std::uint64_t bytes = tris.positions.size() * sizeof(float) + + tris.indices.size() * sizeof(std::uint32_t); + m.cpu_shadow_bytes = m.cpu_shadow_bytes > bytes + ? m.cpu_shadow_bytes - bytes : 0; + tris = {}; + } + } + } } // =========================================================================== @@ -3168,6 +3222,13 @@ std::uint32_t ViewportCore::cullModelCpuCompute( // decide what's worth fetching for the current view. ++c.contribution_visible_count; + // Streaming has everything it needs. The HiZ test and the draw + // emission below only matter for a chunk that can actually draw; + // for a non-resident one they were pure waste — scratch heap that + // eviction never reclaimed and per-frame buffer uploads that + // render() skipped anyway. + if (!c.is_resident) return; + if (hiz_active && hiz_occluded(inst.world_aabb_min, inst.world_aabb_max)) { ++hiz_rejects; @@ -3616,8 +3677,9 @@ void ViewportCore::applyCachedModel(std::uint32_t session_model_id, const std::size_t chunk_inst = std::max(chunk_instance_count[chunk_index], 1); chunk.visible_draws_capacity = chunk_inst; chunk.prefix_sums_capacity = chunk_inst + 1; - chunk.visible_draws_scratch.reserve(chunk_inst); - chunk.prefix_sums_scratch.reserve(chunk_inst + 1); + // The CPU scratch is NOT reserved here: it grows on the chunk's + // first resident cull and is released again on eviction, so only + // resident chunks pay for it. } // Index section is NOT loaded upfront. Each chunk's index slice is @@ -3672,6 +3734,7 @@ void ViewportCore::applyCachedModel(std::uint32_t session_model_id, // inside applyStreamedChunk as the bytes arrive. model_gpu_data.mesh_local_volumes.assign(model_gpu_data.meshes.size(), 0.0); model_gpu_data.mesh_triangles_cache.assign(model_gpu_data.meshes.size(), ModelGpuData::MeshTriangles{}); + model_gpu_data.mesh_resident_chunk_refs.assign(model_gpu_data.meshes.size(), 0); model_gpu_data.mesh_has_alpha.assign(model_gpu_data.meshes.size(), std::uint8_t(0)); // object_id → instance index lookup. Volume tool reads it on every @@ -8082,6 +8145,7 @@ void ViewportCore::render() { ++interactive_frame_count_; const float ms = float(frame_timer.nsecsElapsed()) / 1e6f; std::uint64_t total_vbo = 0, total_ebo = 0, total_ssbo = 0; + std::uint64_t total_cpu_shadow = 0; std::uint32_t total_instances = 0; std::size_t chunks_total = 0, chunks_resident = 0; std::size_t chunks_frustum_vis = 0, chunks_missing = 0; @@ -8089,6 +8153,7 @@ void ViewportCore::render() { total_vbo += mo.vram_bytes_vbo; total_ebo += mo.vram_bytes_ebo; total_ssbo += mo.vram_bytes_ssbo; + total_cpu_shadow += mo.cpu_shadow_bytes; total_instances += mo.instance_count; for (const auto& c : mo.chunks) { ++chunks_total; @@ -8119,6 +8184,7 @@ void ViewportCore::render() { << " chunks " << chunks_resident << "/" << chunks_frustum_vis << "/" << chunks_total << " (missing " << chunks_missing << ")" << " vram " << fmtF(double(total_vbo + total_ebo + total_ssbo) * mb, 1) << "MB" + << " cpuTris " << fmtF(float(double(total_cpu_shadow) * mb), 1) << "MB" << " models " << models_gpu_.size() << " lod1 " << lod1_dbg_count_ << "/" << (lod1_dbg_count_ + lod0_dbg_eligible_count_) << " (saved " << lod1_dbg_tris_saved_ << " tris, " diff --git a/src/ifcviewer/ViewportHost.h b/src/ifcviewer/ViewportHost.h index 8a82ac7588..99f23cf9c2 100644 --- a/src/ifcviewer/ViewportHost.h +++ b/src/ifcviewer/ViewportHost.h @@ -99,6 +99,13 @@ public: // is encoded; QtViewportHost forwards to `emit frameStatsUpdated(...)`. virtual void onFrameStats(const FrameStats& /*stats*/) {} + // Whether this host's tools need the CPU-side triangle shadow + // (ModelGpuData::mesh_triangles_cache) that surface raycasts and the + // measurement tools read. It costs 12 B/vertex + 4 B/index of heap for + // every resident mesh, so hosts without those tools (the web viewer, + // for now) skip populating it entirely. + virtual bool wantsCpuMeshTriangles() const { return true; } + // Overlay encode hooks. ViewportCore::render() calls these mid- // frame so the Qt-bound OverlayRenderer (which carries QString // labels for the HUD) can encode its passes without core having