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
+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