mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-19 14:41:25 +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)
|
// 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.
|
// instead of being called back every frame across the wasm boundary.
|
||||||
void onFrameStats(const FrameStats& stats) override { last_stats_ = stats; }
|
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_; }
|
const FrameStats& lastFrameStats() const { return last_stats_; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|||||||
@@ -434,6 +434,19 @@ struct ModelGpuData {
|
|||||||
std::vector<uint32_t> indices; // 3 * triangle_count, LOD0
|
std::vector<uint32_t> indices; // 3 * triangle_count, LOD0
|
||||||
};
|
};
|
||||||
std::vector<MeshTriangles> mesh_triangles_cache;
|
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`.
|
// object_id (globally rebased) → instance index in `instances`.
|
||||||
// Populated alongside the instance vector so the Volume tool can do
|
// Populated alongside the instance vector so the Volume tool can do
|
||||||
|
|||||||
@@ -2353,6 +2353,16 @@ bool ViewportCore::applyStreamedChunk(
|
|||||||
c.is_loading = false;
|
c.is_loading = false;
|
||||||
c.loaded_frame_idx = streaming_frame_idx_;
|
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
|
// 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
|
// for any alpha byte < 255 — fires the mesh_has_alpha flag the cull
|
||||||
// classifier reads to route instances of this mesh to the transparent
|
// 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.
|
// chunk to deliver each mesh fills it in.
|
||||||
bool filled_volume = false;
|
bool filled_volume = false;
|
||||||
if (!m.mesh_local_volumes.empty() && !idx.empty()) {
|
if (!m.mesh_local_volumes.empty() && !idx.empty()) {
|
||||||
|
const bool want_tris = host_->wantsCpuMeshTriangles();
|
||||||
for (std::uint32_t mi : c.mesh_ids) {
|
for (std::uint32_t mi : c.mesh_ids) {
|
||||||
if (mi >= m.meshes.size() || mi >= m.mesh_local_volumes.size()) continue;
|
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];
|
const MeshInfo& mesh = m.meshes[mi];
|
||||||
if (mesh.vertex_count == 0 || mesh.index_count < 3) continue;
|
if (mesh.vertex_count == 0 || mesh.index_count < 3) continue;
|
||||||
const std::size_t v_off =
|
const std::size_t v_off =
|
||||||
@@ -2402,14 +2420,18 @@ bool ViewportCore::applyStreamedChunk(
|
|||||||
+ std::size_t(mesh.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES;
|
+ std::size_t(mesh.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES;
|
||||||
if (v_end > vbytes.size()) continue;
|
if (v_end > vbytes.size()) continue;
|
||||||
if (i_off + mesh.index_count > idx.size()) continue;
|
if (i_off + mesh.index_count > idx.size()) continue;
|
||||||
ModelGpuData::MeshTriangles* tris =
|
const double volume = computeMeshLocalVolumeQuantised(
|
||||||
(mi < m.mesh_triangles_cache.size())
|
|
||||||
? &m.mesh_triangles_cache[mi]
|
|
||||||
: nullptr;
|
|
||||||
m.mesh_local_volumes[mi] = computeMeshLocalVolumeQuantised(
|
|
||||||
mesh, vbytes.data() + v_off, idx.data() + i_off, mesh.index_count,
|
mesh, vbytes.data() + v_off, idx.data() + i_off, mesh.index_count,
|
||||||
tris);
|
need_tris ? &m.mesh_triangles_cache[mi] : nullptr);
|
||||||
filled_volume = true;
|
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
|
// 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_draws = 0;
|
||||||
c.total_visible_vertices = 0;
|
c.total_visible_vertices = 0;
|
||||||
c.is_resident = false;
|
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.
|
// decide what's worth fetching for the current view.
|
||||||
++c.contribution_visible_count;
|
++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
|
if (hiz_active
|
||||||
&& hiz_occluded(inst.world_aabb_min, inst.world_aabb_max)) {
|
&& hiz_occluded(inst.world_aabb_min, inst.world_aabb_max)) {
|
||||||
++hiz_rejects;
|
++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);
|
const std::size_t chunk_inst = std::max<std::size_t>(chunk_instance_count[chunk_index], 1);
|
||||||
chunk.visible_draws_capacity = chunk_inst;
|
chunk.visible_draws_capacity = chunk_inst;
|
||||||
chunk.prefix_sums_capacity = chunk_inst + 1;
|
chunk.prefix_sums_capacity = chunk_inst + 1;
|
||||||
chunk.visible_draws_scratch.reserve(chunk_inst);
|
// The CPU scratch is NOT reserved here: it grows on the chunk's
|
||||||
chunk.prefix_sums_scratch.reserve(chunk_inst + 1);
|
// 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
|
// 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.
|
// inside applyStreamedChunk as the bytes arrive.
|
||||||
model_gpu_data.mesh_local_volumes.assign(model_gpu_data.meshes.size(), 0.0);
|
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_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));
|
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
|
// object_id → instance index lookup. Volume tool reads it on every
|
||||||
@@ -8082,6 +8145,7 @@ void ViewportCore::render() {
|
|||||||
++interactive_frame_count_;
|
++interactive_frame_count_;
|
||||||
const float ms = float(frame_timer.nsecsElapsed()) / 1e6f;
|
const float ms = float(frame_timer.nsecsElapsed()) / 1e6f;
|
||||||
std::uint64_t total_vbo = 0, total_ebo = 0, total_ssbo = 0;
|
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::uint32_t total_instances = 0;
|
||||||
std::size_t chunks_total = 0, chunks_resident = 0;
|
std::size_t chunks_total = 0, chunks_resident = 0;
|
||||||
std::size_t chunks_frustum_vis = 0, chunks_missing = 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_vbo += mo.vram_bytes_vbo;
|
||||||
total_ebo += mo.vram_bytes_ebo;
|
total_ebo += mo.vram_bytes_ebo;
|
||||||
total_ssbo += mo.vram_bytes_ssbo;
|
total_ssbo += mo.vram_bytes_ssbo;
|
||||||
|
total_cpu_shadow += mo.cpu_shadow_bytes;
|
||||||
total_instances += mo.instance_count;
|
total_instances += mo.instance_count;
|
||||||
for (const auto& c : mo.chunks) {
|
for (const auto& c : mo.chunks) {
|
||||||
++chunks_total;
|
++chunks_total;
|
||||||
@@ -8119,6 +8184,7 @@ void ViewportCore::render() {
|
|||||||
<< " chunks " << chunks_resident << "/" << chunks_frustum_vis
|
<< " chunks " << chunks_resident << "/" << chunks_frustum_vis
|
||||||
<< "/" << chunks_total << " (missing " << chunks_missing << ")"
|
<< "/" << chunks_total << " (missing " << chunks_missing << ")"
|
||||||
<< " vram " << fmtF(double(total_vbo + total_ebo + total_ssbo) * mb, 1) << "MB"
|
<< " 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()
|
<< " models " << models_gpu_.size()
|
||||||
<< " lod1 " << lod1_dbg_count_ << "/" << (lod1_dbg_count_ + lod0_dbg_eligible_count_)
|
<< " lod1 " << lod1_dbg_count_ << "/" << (lod1_dbg_count_ + lod0_dbg_eligible_count_)
|
||||||
<< " (saved " << lod1_dbg_tris_saved_ << " tris, "
|
<< " (saved " << lod1_dbg_tris_saved_ << " tris, "
|
||||||
|
|||||||
@@ -99,6 +99,13 @@ public:
|
|||||||
// is encoded; QtViewportHost forwards to `emit frameStatsUpdated(...)`.
|
// is encoded; QtViewportHost forwards to `emit frameStatsUpdated(...)`.
|
||||||
virtual void onFrameStats(const FrameStats& /*stats*/) {}
|
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-
|
// Overlay encode hooks. ViewportCore::render() calls these mid-
|
||||||
// frame so the Qt-bound OverlayRenderer (which carries QString
|
// frame so the Qt-bound OverlayRenderer (which carries QString
|
||||||
// labels for the HUD) can encode its passes without core having
|
// labels for the HUD) can encode its passes without core having
|
||||||
|
|||||||
Reference in New Issue
Block a user