mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-28 15:53:00 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, "
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user