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