diff --git a/src/ifcviewer-web/CMakeLists.txt b/src/ifcviewer-web/CMakeLists.txt index 357ee84d5d..6dfe663779 100644 --- a/src/ifcviewer-web/CMakeLists.txt +++ b/src/ifcviewer-web/CMakeLists.txt @@ -115,7 +115,7 @@ target_link_options(IfcViewerWeb PRIVATE # EMSCRIPTEN_KEEPALIVE alone keeps the symbols in the binary but doesn't # add them to Module. ccall lets the host page (web/ifcviewer.js) pass a JS string (the ?model # URL) to load_sidecar_from_url_c without manual heap marshalling. - "-sEXPORTED_FUNCTIONS=['_main','_malloc','_free','_raf_tick_c','_load_sidecar_from_source_c','_clear_scene_c','_ifcv_on_range_done','_ifcv_chunks_resident_c','_ifcv_chunks_total_c','_ifcv_model_count_c','_ifcv_model_resident_c','_ifcv_model_total_c','_ifcv_bytes_total_c','_ifcv_bytes_needed_c','_ifcv_bytes_loaded_c','_view_all_c','_frame_selection_c','_toggle_projection_c','_projection_is_ortho_c','_standard_view_c','_toggle_fly_c','_fly_is_active_c','_hide_selected_c','_isolate_selected_c','_show_all_c','_hide_all_c','_toggle_xray_c','_xray_is_active_c','_toggle_section_c','_clear_section_c','_section_is_active_c','_ifcv_get_camera_c','_ifcv_set_camera_c','_ifcv_set_ortho_c','_ifcv_set_nav_preset_c','_ifcv_set_background_c','_ifcv_get_selection_c','_ifcv_get_active_object_c','_ifcv_apply_selection_c','_ifcv_set_visible_c','_ifcv_get_hidden_c','_ifcv_set_color_c','_ifcv_clear_colors_c','_ifcv_request_objects_c','_ifcv_set_selection_outline_c','_ifcv_selection_outline_is_on_c','_ifcv_set_federation_unit_c','_ifcv_set_false_origin_c','_ifcv_get_false_origin_c','_ifcv_set_model_transform_c','_ifcv_clear_model_transform_c','_ifcv_set_model_name_c','_ifcv_get_model_georef_c']" + "-sEXPORTED_FUNCTIONS=['_main','_malloc','_free','_raf_tick_c','_load_sidecar_from_source_c','_clear_scene_c','_ifcv_on_range_done','_ifcv_chunks_resident_c','_ifcv_chunks_total_c','_ifcv_model_count_c','_ifcv_model_resident_c','_ifcv_model_total_c','_ifcv_bytes_total_c','_ifcv_bytes_needed_c','_ifcv_bytes_loaded_c','_view_all_c','_frame_selection_c','_toggle_projection_c','_projection_is_ortho_c','_standard_view_c','_toggle_fly_c','_fly_is_active_c','_hide_selected_c','_isolate_selected_c','_show_all_c','_hide_all_c','_toggle_xray_c','_xray_is_active_c','_toggle_section_c','_clear_section_c','_section_is_active_c','_ifcv_get_camera_c','_ifcv_set_camera_c','_ifcv_set_ortho_c','_ifcv_set_nav_preset_c','_ifcv_set_background_c','_ifcv_get_selection_c','_ifcv_get_active_object_c','_ifcv_apply_selection_c','_ifcv_set_visible_c','_ifcv_get_hidden_c','_ifcv_set_color_c','_ifcv_clear_colors_c','_ifcv_request_objects_c','_ifcv_set_selection_outline_c','_ifcv_selection_outline_is_on_c','_ifcv_set_federation_unit_c','_ifcv_set_false_origin_c','_ifcv_get_false_origin_c','_ifcv_set_model_transform_c','_ifcv_clear_model_transform_c','_ifcv_set_model_name_c','_ifcv_get_model_georef_c','_ifcv_get_frame_stats_c','_ifcv_unload_model_c','_ifcv_load_model_c','_ifcv_model_unloaded_c','_ifcv_model_vram_bytes_c']" # ccall: the host page (web/ifcviewer.js) passes the ?model URL string to load_sidecar_from_url_c, # and the nav-preset name to ifcv_set_nav_preset_c. # HEAPU8: lets tooling/tests read the wasm heap size (e.g. to verify a large diff --git a/src/ifcviewer-web/WebViewportHost.h b/src/ifcviewer-web/WebViewportHost.h index 633e93cf98..a9ce0454bd 100644 --- a/src/ifcviewer-web/WebViewportHost.h +++ b/src/ifcviewer-web/WebViewportHost.h @@ -54,9 +54,16 @@ public: // core.render(). bool consumeFrameRequest(); + // The most recent per-frame stats (fps, VRAM, working set). Latched + // 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; } + const FrameStats& lastFrameStats() const { return last_stats_; } + private: std::string canvas_selector_; bool request_frame_pending_ = true; // arm an initial frame + FrameStats last_stats_ = {}; }; #endif // WEBVIEWPORTHOST_H diff --git a/src/ifcviewer-web/main_web.cpp b/src/ifcviewer-web/main_web.cpp index 181d2c23d8..724b0400d3 100644 --- a/src/ifcviewer-web/main_web.cpp +++ b/src/ifcviewer-web/main_web.cpp @@ -926,6 +926,62 @@ extern "C" EMSCRIPTEN_KEEPALIVE double ifcv_bytes_loaded_c() { return double(loaded_bytes); } +// ---- Frame stats + GPU residency ------------------------------------------ + +// The latest FrameStats as doubles, in this order (see FrameStats.h): +// 0 fps, 1 frame_time_ms, 2 total_objects, 3 visible_objects, +// 4 total_triangles, 5 visible_triangles, 6 draw_calls, +// 7 vram_used_bytes, 8 vram_capacity_bytes, 9 vram_budget_bytes, +// 10 chunks_wanted, 11 chunks_wanted_missing, 12 wanted_missing_bytes. +// Device-wide VRAM is not included: there is no query for it on web. +// Returns the number of values written (0 before the first frame). +constexpr int kFrameStatsValues = 13; +extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_get_frame_stats_c(double* out, int capacity) { + if (!g_app || !out || capacity < kFrameStatsValues) return 0; + const FrameStats& s = g_app->host.lastFrameStats(); + out[0] = s.fps; + out[1] = s.frame_time_ms; + out[2] = s.total_objects; + out[3] = s.visible_objects; + out[4] = s.total_triangles; + out[5] = s.visible_triangles; + out[6] = s.gl_draw_calls; + out[7] = double(s.vram_used_bytes); + out[8] = double(s.vram_capacity_bytes); + out[9] = double(s.vram_budget_bytes); + out[10] = s.chunks_wanted; + out[11] = s.chunks_wanted_missing; + out[12] = double(s.wanted_missing_bytes); + return kFrameStatsValues; +} + +// Per-model GPU residency, keyed by source id like the other per-model +// exports. Unload frees everything the model holds on the GPU while it stays +// in the scene; load brings it back (0 if the device cannot fit its buffers). +// Neither touches visibility. +extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_unload_model_c(int source_id) { + if (!g_app) return; + const std::uint32_t session_model_id = g_app->federation.sessionModelId(source_id); + if (session_model_id == 0) return; + g_app->core.unloadModel(session_model_id); +} +extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_load_model_c(int source_id) { + if (!g_app) return 0; + const std::uint32_t session_model_id = g_app->federation.sessionModelId(source_id); + if (session_model_id == 0) return 0; + return g_app->core.loadModel(session_model_id) ? 1 : 0; +} +extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_model_unloaded_c(int source_id) { + if (!g_app) return 0; + const std::uint32_t session_model_id = g_app->federation.sessionModelId(source_id); + return session_model_id != 0 && g_app->core.isModelUnloaded(session_model_id) ? 1 : 0; +} +extern "C" EMSCRIPTEN_KEEPALIVE double ifcv_model_vram_bytes_c(int source_id) { + if (!g_app) return 0.0; + const std::uint32_t session_model_id = g_app->federation.sessionModelId(source_id); + return session_model_id == 0 ? 0.0 : double(g_app->core.modelVramBytes(session_model_id)); +} + int main(int /*argc*/, char** /*argv*/) { Log::info() << "ifcviewer-web: starting"; g_app = new AppState(); diff --git a/src/ifcviewer-web/tests/memory.spec.mjs b/src/ifcviewer-web/tests/memory.spec.mjs new file mode 100644 index 0000000000..7a7d7198fe --- /dev/null +++ b/src/ifcviewer-web/tests/memory.spec.mjs @@ -0,0 +1,115 @@ +import { test, expect } from '@playwright/test'; + +// GPU memory as the host page sees it: the per-frame stats (cache occupancy, +// working set) and the per-model unload/load lever. Mirrors what +// BonsaiViewer's status bar and Models panel show on desktop. + +async function open(page) { + const errors = []; + page.on('console', (msg) => { + const t = msg.text(); + if (/Uncaptured WebGPU error|is invalid|Not enough memory left/i.test(t)) errors.push(t); + }); + page.on('pageerror', (e) => errors.push('pageerror: ' + e.message)); + await page.goto('/scripting.html'); + await page.waitForFunction(() => !!(window.viewer && window.viewer.isLive()), null, + { timeout: 30_000 }); + return errors; +} + +// Add the sample as a real source (the embedded one has no source id) and +// wait until every chunk is resident. +async function addSampleAndSettle(page) { + const sid = await page.evaluate(async () => { + const v = window.viewer; + const loaded = new Promise((resolve) => { + const off = v.onModelLoaded((d) => { off(); resolve(d); }); + }); + const sid = await v.addUrl('/sample.ifcview', { replace: true, name: 'mem' }); + await loaded; + return sid; + }); + await settled(page); + return sid; +} + +async function settled(page) { + await page.waitForFunction(() => { + const v = window.viewer; + if (!v.modelCount()) return false; + for (let i = 0; i < v.modelCount(); ++i) { + const p = v.modelProgress(i); + if (!(p.total > 0 && p.resident === p.total)) return false; + } + return true; + }, null, { timeout: 30_000 }); +} + +test('stats() reports the frame, the cache and the working set', async ({ page }) => { + const errors = await open(page); + await addSampleAndSettle(page); + await page.waitForTimeout(300); + + const s = await page.evaluate(() => window.viewer.stats()); + expect(s).not.toBeNull(); + expect(s.fps).toBeGreaterThan(0); + expect(s.frameTimeMs).toBeGreaterThan(0); + expect(s.objects.total).toBeGreaterThan(0); + expect(s.triangles.total).toBeGreaterThan(0); + + // Resident geometry occupies the cache, within its capacity, and on web + // the cache is bounded from the start (the wasm heap cap). + expect(s.vram.usedBytes).toBeGreaterThan(0); + expect(s.vram.usedBytes).toBeLessThanOrEqual(s.vram.capacityBytes); + expect(s.vram.budgetBytes).toBeGreaterThan(0); + + // Everything the camera wants is resident once settled. + expect(s.workingSet.chunks).toBeGreaterThan(0); + expect(s.workingSet.chunksMissing).toBe(0); + expect(s.workingSet.missingBytes).toBe(0); + + expect(errors).toEqual([]); +}); + +test('unloadModel frees the model\'s GPU memory and loadModel streams it back', async ({ page }) => { + const errors = await open(page); + const sid = await addSampleAndSettle(page); + + const before = await page.evaluate((sid) => ({ + unloaded: window.viewer.modelUnloaded(sid), + bytes: window.viewer.modelVramBytes(sid), + used: window.viewer.stats().vram.usedBytes, + }), sid); + expect(before.unloaded).toBe(false); + expect(before.bytes).toBeGreaterThan(0); + + // Unload: the model's bytes go to zero immediately, and it stays listed. + const after = await page.evaluate((sid) => { + const v = window.viewer; + v.unloadModel(sid); + return { + unloaded: v.modelUnloaded(sid), + bytes: v.modelVramBytes(sid), + modelCount: v.modelCount(), + }; + }, sid); + expect(after.unloaded).toBe(true); + expect(after.bytes).toBe(0); + expect(after.modelCount).toBe(1); + + // The cache reflects the release on the next frame. + await page.waitForFunction((used) => { + const s = window.viewer.stats(); + return s && s.vram.usedBytes < used; + }, before.used, { timeout: 10_000 }); + + // Load: the buffers come back and the chunks stream in again. + const reloaded = await page.evaluate((sid) => window.viewer.loadModel(sid), sid); + expect(reloaded).toBe(true); + expect(await page.evaluate((sid) => window.viewer.modelUnloaded(sid), sid)).toBe(false); + await settled(page); + const restored = await page.evaluate((sid) => window.viewer.modelVramBytes(sid), sid); + expect(restored).toBe(before.bytes); + + expect(errors).toEqual([]); +}); diff --git a/src/ifcviewer-web/web/embedded.html b/src/ifcviewer-web/web/embedded.html index 9dee6e6454..c72ea0e0b3 100644 --- a/src/ifcviewer-web/web/embedded.html +++ b/src/ifcviewer-web/web/embedded.html @@ -42,6 +42,10 @@ ul#model-list .name b { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } ul#model-list .pct { color: #8a93a6; flex: 0 0 auto; } + ul#model-list .mem { color: #8a93a6; font-size: 11px; margin-left: 8px; flex: 0 0 auto; } + ul#model-list .mem button { font-size: 11px; padding: 1px 6px; margin-left: 6px; } + ul#model-list li.unloaded b { font-style: italic; color: #6f7988; } + #gpu-memory.full { color: #e0a040; } .bar { height: 4px; margin-top: 5px; border-radius: 2px; background: #232833; overflow: hidden; } .bar > i { display: block; height: 100%; width: 0%; background: #3182ce; } .empty { color: #6f7988; font-size: 12px; padding: 4px 0; } @@ -92,6 +96,7 @@

Models in scene

+
GPU memory: —
@@ -124,16 +129,61 @@ listEl.innerHTML = ''; models.forEach(function (m, i) { var li = document.createElement('li'); + if (m.unloaded) li.className = 'unloaded'; var pct = m.total > 0 ? Math.round(100 * m.resident / m.total) : 0; - var label = m.total > 0 ? pct + '%' : '…'; + var label = m.unloaded ? 'unloaded' : m.total > 0 ? pct + '%' : '…'; + var mem = m.unloaded ? '' : Math.round(m.vram / (1024 * 1024)) + ' MB'; li.innerHTML = '
' + m.name + '' + + '' + mem + '' + '' + label + '
' + - '
'; + '
'; listEl.appendChild(li); }); } + // Unload frees a model's GPU memory while it stays in the scene — the lever + // when the GPU memory line reports chunks that cannot be loaded. + listEl.addEventListener('click', function (ev) { + var btn = ev.target.closest('button[data-i]'); + if (!btn || !activeViewer) return; + var m = models[+btn.dataset.i]; + if (!m || m.sid === undefined) return; + if (m.unloaded) { + if (!activeViewer.loadModel(m.sid)) { hintEl.textContent = 'Not enough GPU memory to load ' + m.name; return; } + } else { + activeViewer.unloadModel(m.sid); + } + m.unloaded = activeViewer.modelUnloaded(m.sid); + renderList(); + }); + + var gpuMemEl = document.getElementById('gpu-memory'); + var shortfallSince = 0; + var activeViewer = null; // set once IfcViewer.create resolves + function renderGpuMemory(viewer) { + var s = viewer.stats(); + if (!s) return; + var mb = function (b) { return Math.round(b / (1024 * 1024)); }; + var text = 'GPU memory: ' + mb(s.vram.usedBytes) + ' / ' + mb(s.vram.capacityBytes) + ' MB'; + if (s.vram.budgetBytes && s.vram.budgetBytes !== s.vram.capacityBytes) { + text += ' (budget ' + mb(s.vram.budgetBytes) + ')'; + } + // A few missing chunks right after a camera move are normal; a shortfall + // that persists means the view does not fit — say so. + var now = performance.now(); + if (!s.workingSet.chunksMissing) shortfallSince = 0; + else if (!shortfallSince) shortfallSince = now; + var full = shortfallSince && now - shortfallSince > 3000; + if (full) { + text += ' — full: ' + s.workingSet.chunksMissing + ' of ' + s.workingSet.chunks + + ' visible chunks (' + mb(s.workingSet.missingBytes) + ' MB) not loaded. Unload a model to make room.'; + } + if (gpuMemEl.textContent !== text) gpuMemEl.textContent = text; + gpuMemEl.classList.toggle('full', !!full); + } + function setSelection(guid, modelName) { selModelEl.textContent = modelName || '—'; if (guid) { selGuidEl.textContent = guid; selGuidEl.classList.remove('none'); } @@ -154,16 +204,20 @@ // Keep clearing until it's gone; stop once the user adds their own model. if (!userAddedAny && viewer.modelCount() > 0) viewer.clearScene(); if (!models.length) return; + renderGpuMemory(viewer); var changed = false; for (var i = 0; i < models.length; i++) { var p = viewer.modelProgress(i); - if (p.resident !== models[i].resident || p.total !== models[i].total) { - models[i].resident = p.resident; models[i].total = p.total; changed = true; + var vram = models[i].sid !== undefined ? viewer.modelVramBytes(models[i].sid) : 0; + if (p.resident !== models[i].resident || p.total !== models[i].total + || Math.round(vram / (1024 * 1024)) !== Math.round(models[i].vram / (1024 * 1024))) { + models[i].resident = p.resident; models[i].total = p.total; models[i].vram = vram; changed = true; } } if (changed) renderList(); }, }).then(function (viewer) { + activeViewer = viewer; // Report the picked object's model + IFC GlobalId in our own DOM (empty on // deselect). sel.modelIndex indexes our JS model list (load order). viewer.onSelect(function (sel) { @@ -177,7 +231,10 @@ var urlInput = document.getElementById('url-input'); var urlBtn = document.getElementById('url-btn'); - function addModelEntry(name) { models.push({ name: name, resident: 0, total: 0 }); renderList(); } + function addModelEntry(name, sid) { + models.push({ name: name, sid: sid, resident: 0, total: 0, vram: 0, unloaded: false }); + renderList(); + } viewer.ready.then(function () { hintEl.textContent = 'Ready — add a .ifcview model.'; @@ -188,7 +245,7 @@ fileInput.addEventListener('change', function (ev) { if (ev.target.files.length) userAddedAny = true; Array.prototype.forEach.call(ev.target.files, function (file) { - viewer.addFile(file).then(function () { addModelEntry(file.name); }); + viewer.addFile(file).then(function (sid) { addModelEntry(file.name, sid); }); }); fileInput.value = ''; }); @@ -198,8 +255,8 @@ if (!url) return; userAddedAny = true; urlBtn.disabled = true; - viewer.addUrl(url).then(function () { - addModelEntry(url.split('/').pop() || url); + viewer.addUrl(url).then(function (sid) { + addModelEntry(url.split('/').pop() || url, sid); urlInput.value = ''; }).catch(function (e) { hintEl.textContent = 'URL load failed: ' + e.message; diff --git a/src/ifcviewer-web/web/ifcviewer.js b/src/ifcviewer-web/web/ifcviewer.js index 7fe800899d..dfa471fba0 100644 --- a/src/ifcviewer-web/web/ifcviewer.js +++ b/src/ifcviewer-web/web/ifcviewer.js @@ -530,6 +530,45 @@ }; }, + // The latest frame's statistics — what BonsaiViewer's status bar shows. + // `vram` is the streamed-geometry cache: bytes held by resident chunks, + // the pool's current capacity, and the budget it may grow to (0 while + // unbounded). `workingSet` is what the camera wants resident: chunks in + // view and large enough to draw, how many of those are not resident, + // and their size — transiently non-zero after a camera move, and + // persistently non-zero when the scene does not fit in GPU memory + // (unload a model to make room). Null before the first frame. + stats: function () { + const n = 13; + const ptr = Module._malloc(n * 8); + try { + if (!Module._ifcv_get_frame_stats_c(ptr, n)) return null; + const d = Module.HEAPF64.subarray(ptr >>> 3, (ptr >>> 3) + n); + return { + fps: d[0], + frameTimeMs: d[1], + objects: { visible: d[3], total: d[2] }, + triangles: { visible: d[5], total: d[4] }, + drawCalls: d[6], + vram: { usedBytes: d[7], capacityBytes: d[8], budgetBytes: d[9] }, + workingSet: { chunks: d[10], chunksMissing: d[11], missingBytes: d[12] }, + }; + } finally { + Module._free(ptr); + } + }, + + // GPU residency per model, by source id. Unloading frees everything + // the model holds on the GPU while it stays in the scene (its + // visibility untouched); loading brings it back, streaming the + // geometry in again on demand. loadModel resolves false when the + // device cannot fit the model's buffers. This is the lever when + // stats().workingSet.chunksMissing stays above zero. + unloadModel: function (sourceId) { Module._ifcv_unload_model_c(sourceId | 0); }, + loadModel: function (sourceId) { return Module._ifcv_load_model_c(sourceId | 0) !== 0; }, + modelUnloaded: function (sourceId) { return Module._ifcv_model_unloaded_c(sourceId | 0) !== 0; }, + modelVramBytes: function (sourceId) { return Module._ifcv_model_vram_bytes_c(sourceId | 0); }, + registerFileSource: registerFile, registerUrlSource: registerUrl,