ifcviewer: bound the CPU triangle shadow and the cull scratch to residency

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 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-08-23 22:38:02 +10:00
parent 06d87e21a7
commit 091b4d4113
4 changed files with 101 additions and 10 deletions
+5
View File
@@ -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:
+13
View File
@@ -434,6 +434,19 @@ struct ModelGpuData {
std::vector<uint32_t> indices; // 3 * triangle_count, LOD0
};
std::vector<MeshTriangles> 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<std::uint16_t> 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
+76 -10
View File
@@ -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<std::size_t>(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, "
+7
View File
@@ -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