ifcviewer-web: expose frame stats and per-model unload/load to the page

The memory work (cache budget, pressure handling, unloadModel) lives in
ViewportCore and so already ran in the wasm, but the page could not see
or use any of it: the web host had no onFrameStats, and there were no
bindings for residency.

- WebViewportHost latches the last FrameStats; ifcv_get_frame_stats_c
  hands them to JS as doubles, and viewer.stats() returns {fps,
  frameTimeMs, objects, triangles, drawCalls, vram{used, capacity,
  budget}, workingSet{chunks, chunksMissing, missingBytes}} — the same
  figures BonsaiViewer's status bar shows. Device-wide VRAM is omitted:
  there is no query for it on web.
- viewer.unloadModel / loadModel / modelUnloaded / modelVramBytes, keyed
  by source id like the other per-model calls.
- The demo page shows a GPU memory line that turns into a "full: N of M
  visible chunks not loaded" notice once a shortfall persists for 3 s,
  and each model's MB with an Unload/Load button.
- memory.spec.mjs covers stats() and the unload/load round trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-08-23 21:43:03 +10:00
parent 8074541057
commit b7f1f1728f
6 changed files with 283 additions and 9 deletions
+65 -8
View File
@@ -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 @@
<div class="card">
<h2>Models in scene</h2>
<ul id="model-list"><li class="empty">No models loaded.</li></ul>
<div class="hint" id="gpu-memory">GPU memory: —</div>
</div>
<div class="card">
@@ -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 =
'<div class="name"><b title="' + m.name + '">' + m.name + '</b>' +
'<span class="mem">' + mem + '<button data-i="' + i + '">' +
(m.unloaded ? 'Load' : 'Unload') + '</button></span>' +
'<span class="pct">' + label + '</span></div>' +
'<div class="bar"><i style="width:' + pct + '%"></i></div>';
'<div class="bar"><i style="width:' + (m.unloaded ? 0 : pct) + '%"></i></div>';
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;
+39
View File
@@ -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,