mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-14 19:34:34 +00:00
ifcviewer-web: end-to-end rendering on Chrome + Firefox
Wire up wgpu init + scene load + RAF render so the embedded sample sidecar paints on the canvas in both browsers. Root-cause fix: BufferPool::addSubBuffer spin-waited on PopErrorScope, which resolves via JS microtask on Dawn-web. The spin blocked the JS event loop, so the microtask never fired and the first allocation hung the page indefinitely. Skip the error-scope dance on Emscripten; trust the buffer pointer. ViewportCore: add initWgpuAsyncWeb (nested-callback adapter→device chain with AllowSpontaneous mode, no spin) and loadSidecarFromPath (Qt-free entry point). waitTickInstance becomes a no-op shim on web; cull-threads / streaming_thread_ / wgpuSurfacePresent gated off; chunk I/O runs inline. main_web.cpp: AppState + initWgpuAsyncWeb → buildPipelines (+ HiZ/edge/pick) → loadSidecarFromPath → ready flag → Module._app_ptr handoff. The RAF loop lives in shell.html (NOT here) because any RAF helper called from inside Dawn-web's wgpu Promise.then chain stalls the device callback. CMakeLists.txt: EXIT_RUNTIME=0 + Module.noExitRuntime=true (shell) keeps wasm alive past main() so the device promise lands; no Asyncify; export _raf_tick_c so shell.html's RAF can call it. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -80,12 +80,26 @@ add_executable(IfcViewerWeb
|
||||
WebViewportHost.h
|
||||
)
|
||||
target_link_libraries(IfcViewerWeb PRIVATE IfcViewerCore)
|
||||
|
||||
target_link_options(IfcViewerWeb PRIVATE
|
||||
# Asyncify lets us await wgpu's RequestAdapter/RequestDevice/MapAsync
|
||||
# as if they were synchronous. Adds ~30-50% wasm size and a small
|
||||
# per-await runtime cost; acceptable for the spike, revisit when
|
||||
# we ship.
|
||||
"-sASYNCIFY"
|
||||
# No -sASYNCIFY. The web init path is callback-driven
|
||||
# (initWgpuAsyncWeb chains RequestAdapter → RequestDevice via
|
||||
# WGPU's AllowSpontaneous mode + the JS event loop), so we don't
|
||||
# need to await wgpu calls as if they were sync. Asyncify would
|
||||
# also instrument every function reachable from emscripten_sleep,
|
||||
# adding ~30% to wasm size for no win here.
|
||||
#
|
||||
# EXIT_RUNTIME=0 + Module.noExitRuntime=true (set in shell.html)
|
||||
# keeps wasm alive after main() returns so Dawn-web's
|
||||
# RequestAdapter/RequestDevice promise callbacks land. The
|
||||
# alternative — calling emscripten_set_main_loop_arg early in
|
||||
# main() to set noExitRuntime as a side effect — registers a RAF
|
||||
# that starves the device promise (observed: ~10s delay in Firefox).
|
||||
"-sEXIT_RUNTIME=0"
|
||||
# Expose the C tick entry point to JS so shell.html's RAF loop
|
||||
# can call Module._raf_tick_c. EMSCRIPTEN_KEEPALIVE alone keeps
|
||||
# the symbol in the binary but doesn't add it to Module.
|
||||
"-sEXPORTED_FUNCTIONS=['_main','_raf_tick_c']"
|
||||
# Streaming + chunked geometry want a heap that can grow as buffers
|
||||
# arrive. 256 MB initial, 2 GB ceiling (matches the wasm32 pointer
|
||||
# cap; --shared64 / MEMORY64 would lift this later if we need it).
|
||||
@@ -96,6 +110,12 @@ target_link_options(IfcViewerWeb PRIVATE
|
||||
# sidecar byte-range loader. Not used yet by the scaffold but
|
||||
# needed by the upcoming #27 web streaming I/O backend.
|
||||
"-sFETCH=1"
|
||||
# Bundle a small sample sidecar into Emscripten's MEMFS so the
|
||||
# scaffold can prove the load path end-to-end without needing
|
||||
# emscripten_fetch + COOP/COEP wiring. The @ separator mounts the
|
||||
# file at the virtual path the wasm uses to fopen() it. Replaced
|
||||
# by an emscripten_fetch + Range backend in #88.
|
||||
"--embed-file=${CMAKE_CURRENT_SOURCE_DIR}/sample.ifcview@/sample.ifcview"
|
||||
# Shell template wraps the JS output in our canvas page.
|
||||
"--shell-file=${CMAKE_CURRENT_SOURCE_DIR}/shell.html"
|
||||
)
|
||||
|
||||
@@ -18,42 +18,48 @@
|
||||
********************************************************************************/
|
||||
|
||||
// Web entry point. Wires a WebViewportHost to a ViewportCore, brings up
|
||||
// wgpu through emdawnwebgpu (the spec-compatible WebGPU header set that
|
||||
// shipped with Dawn), then drives a render() per requestAnimationFrame
|
||||
// tick. No sidecar load yet — that lands with the emscripten_fetch
|
||||
// streaming backend (#88). For this scaffold we render an empty scene
|
||||
// with the configured background so init + present is verified
|
||||
// end-to-end through the same ViewportCore code path the desktop build
|
||||
// uses.
|
||||
// wgpu via emdawnwebgpu (the spec-compatible WebGPU header set that
|
||||
// shipped with Dawn), loads the embedded sample sidecar, and drives
|
||||
// render() per requestAnimationFrame from JS (shell.html).
|
||||
//
|
||||
// The RAF loop lives in shell.html — NOT here — because any call into
|
||||
// Emscripten's main-loop / RAF helpers (or even raw
|
||||
// requestAnimationFrame via EM_ASM) made from inside Dawn-web's wgpu
|
||||
// promise-resolution chain stalls the device callback. Having JS drive
|
||||
// the tick keeps the wasm init path callback-only.
|
||||
|
||||
#include "ViewportCore.h"
|
||||
#include "WebViewportHost.h"
|
||||
#include "Log.h"
|
||||
|
||||
#include <emscripten/emscripten.h>
|
||||
#include <emscripten/html5.h>
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
namespace {
|
||||
|
||||
// Shared by the main_loop trampoline + the cleanup path. Allocated on
|
||||
// the heap so Emscripten's set_main_loop callback (which is C-style)
|
||||
// can recover state through a void*.
|
||||
struct AppState {
|
||||
WebViewportHost host{ "#viewer-canvas" };
|
||||
ViewportCore core{ &host };
|
||||
int last_w = 0;
|
||||
int last_h = 0;
|
||||
// Set true by the init callback once the device + pipelines are up.
|
||||
// raf_tick_c skips render() until then; before that point the wgpu
|
||||
// state pointers inside core are still null and any draw would crash.
|
||||
bool ready = false;
|
||||
};
|
||||
|
||||
// The main_loop is a free function (Emscripten signature em_callback_func)
|
||||
// so we can hand it directly to emscripten_set_main_loop_arg.
|
||||
void main_loop(void* user) {
|
||||
auto* app = static_cast<AppState*>(user);
|
||||
// One global so the JS-side RAF loop can recover state through a
|
||||
// pointer round-trip (set into Module._app_ptr from on_complete).
|
||||
AppState* g_app = nullptr;
|
||||
|
||||
} // namespace
|
||||
|
||||
// Called from shell.html's RAF tick (via Module._raf_tick_c). Exported
|
||||
// to JS by EXPORTED_FUNCTIONS in CMakeLists.txt; EMSCRIPTEN_KEEPALIVE
|
||||
// also keeps the symbol alive under -O*.
|
||||
extern "C" EMSCRIPTEN_KEEPALIVE void raf_tick_c(void* user) {
|
||||
auto* app = static_cast<AppState*>(user);
|
||||
if (!app->ready) return;
|
||||
|
||||
// Reconfigure when the canvas resizes. The first tick also lands
|
||||
// here because last_w / last_h start at 0.
|
||||
int w = 0, h = 0;
|
||||
app->host.framebufferSize(w, h);
|
||||
if (w != app->last_w || h != app->last_h) {
|
||||
@@ -62,54 +68,44 @@ void main_loop(void* user) {
|
||||
app->last_h = h;
|
||||
}
|
||||
|
||||
// Only render when something has requested a frame — CPU stays
|
||||
// idle while the scene is static. WebViewportHost's ctor arms one
|
||||
// initial frame request so the canvas always paints at startup;
|
||||
// subsequent paints come from core_.render() rearming itself
|
||||
// (motion settle, streaming in flight, bench mode) and, once
|
||||
// input is wired (#85), from mouse / key events flagging the host.
|
||||
if (app->host.consumeFrameRequest()) {
|
||||
app->core.render();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int /*argc*/, char** /*argv*/) {
|
||||
Log::info() << "ifcviewer-web: starting";
|
||||
g_app = new AppState();
|
||||
g_app->core.initWgpuAsyncWeb([](bool ok) {
|
||||
if (!ok) {
|
||||
Log::warn() << "ifcviewer-web: wgpu init failed";
|
||||
return;
|
||||
}
|
||||
if (!g_app->core.buildPipelines()) {
|
||||
Log::warn() << "ifcviewer-web: buildPipelines failed";
|
||||
return;
|
||||
}
|
||||
// HiZ + edge + pick pipelines: built up-front to match the
|
||||
// desktop path. ViewportCore::shutdown expects each resource
|
||||
// to be either constructed or null, so building them all here
|
||||
// keeps teardown symmetric.
|
||||
g_app->core.buildHizPipeline();
|
||||
g_app->core.buildEdgePipeline();
|
||||
g_app->core.buildPickPipeline();
|
||||
|
||||
auto* app = new AppState();
|
||||
// Load the embedded sample sidecar (mounted into MEMFS via
|
||||
// --embed-file in CMakeLists.txt). Replaced by an
|
||||
// emscripten_fetch + Range backend in #88.
|
||||
if (!g_app->core.loadSidecarFromPath("/sample.ifcview")) {
|
||||
Log::warn() << "ifcviewer-web: sample sidecar load failed";
|
||||
}
|
||||
|
||||
// Web limits floor: requestDevice the WebGPU spec's mandatory floor
|
||||
// (maxStorageBufferBindingSize=128MB, maxBufferSize=256MB) so the
|
||||
// chunking + pool probe see the same constraints they would hit in
|
||||
// any browser. Desktop --web-limits did this opt-in; on web it's
|
||||
// the only sensible default.
|
||||
if (!app->core.initWgpu(/*web_limits=*/true)) {
|
||||
std::fprintf(stderr, "[viewer-web] initWgpu failed\n");
|
||||
delete app;
|
||||
return 1;
|
||||
}
|
||||
if (!app->core.buildPipelines()) {
|
||||
std::fprintf(stderr, "[viewer-web] buildPipelines failed\n");
|
||||
delete app;
|
||||
return 1;
|
||||
}
|
||||
// HiZ + edge + pick pipelines: built up-front to match the desktop
|
||||
// path's lifetime. shutdown() in ViewportCore expects each
|
||||
// resource to be either constructed or null, so building them all
|
||||
// here keeps teardown symmetric.
|
||||
app->core.buildHizPipeline();
|
||||
app->core.buildEdgePipeline();
|
||||
app->core.buildPickPipeline();
|
||||
g_app->ready = true;
|
||||
|
||||
// fps = 0 means "use the browser's natural rate (RAF)" — Emscripten
|
||||
// schedules the callback once per requestAnimationFrame tick.
|
||||
// simulate_infinite_loop = false because we want main() to return
|
||||
// so the runtime + JS event loop keep ticking. The AppState leaks
|
||||
// on tab close, which is fine — emscripten_force_exit (called
|
||||
// from host_->quit()) is the only formal shutdown path on web.
|
||||
emscripten_set_main_loop_arg(main_loop, app, /*fps=*/0,
|
||||
/*simulate_infinite_loop=*/0);
|
||||
// Hand the app pointer to the JS-side RAF loop (set up in
|
||||
// shell.html's onRuntimeInitialized). The loop polls for
|
||||
// Module._app_ptr before invoking _raf_tick_c.
|
||||
EM_ASM({ Module._app_ptr = $0; }, (void*)g_app);
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -8,9 +8,11 @@
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
||||
#viewer-canvas { display: block; width: 100vw; height: 100vh; outline: none;
|
||||
background: #1a1d24; }
|
||||
#status { position: fixed; top: 8px; left: 12px; font-size: 12px;
|
||||
background: rgba(20,22,28,.72); padding: 4px 8px; border-radius: 4px;
|
||||
pointer-events: none; }
|
||||
#status { position: fixed; top: 8px; left: 12px; right: 12px;
|
||||
max-height: 80vh; overflow-y: auto; font-size: 11px;
|
||||
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
|
||||
background: rgba(20,22,28,.78); padding: 6px 10px; border-radius: 4px;
|
||||
white-space: pre-wrap; pointer-events: auto; }
|
||||
#status.error { background: rgba(120,30,30,.85); color: #fff; }
|
||||
</style>
|
||||
</head>
|
||||
@@ -24,17 +26,43 @@
|
||||
var statusEl = document.getElementById('status');
|
||||
var Module = {
|
||||
canvas: document.getElementById('viewer-canvas'),
|
||||
// Keep the wasm runtime alive after main() returns so the
|
||||
// Dawn-web RequestAdapter/RequestDevice promise callbacks
|
||||
// (queued from main) actually land. Without this flag the
|
||||
// runtime tears down at end-of-main and the callbacks never
|
||||
// fire — symptom: adapter cb fires (synchronous-ish on the
|
||||
// first JS tick) but device cb does not. EXIT_RUNTIME=0 in
|
||||
// CMakeLists is the build-time half; this is the runtime half.
|
||||
noExitRuntime: true,
|
||||
print: function(t) { console.log(t); },
|
||||
printErr: function(t) {
|
||||
console.warn(t);
|
||||
// First non-empty stderr line replaces the "Starting…" tag; subsequent
|
||||
// lines append. Anything containing "fail" / "error" flips us into
|
||||
// the red error chip.
|
||||
statusEl.textContent = t;
|
||||
// Accumulate every stderr line so the init sequence is visible
|
||||
// even when the wasm hangs partway. The first time something is
|
||||
// printed we drop the "Starting…" placeholder.
|
||||
if (statusEl.textContent === 'Starting…' ||
|
||||
statusEl.textContent === 'wasm loaded — waiting for WebGPU') {
|
||||
statusEl.textContent = '';
|
||||
}
|
||||
statusEl.textContent += t + '\n';
|
||||
statusEl.scrollTop = statusEl.scrollHeight;
|
||||
if (/fail|error|null/i.test(t)) statusEl.classList.add('error');
|
||||
},
|
||||
onRuntimeInitialized: function() {
|
||||
statusEl.textContent = 'wasm loaded — waiting for WebGPU';
|
||||
// RAF loop. Polls for Module._app_ptr (set by C once the wgpu
|
||||
// device callback completes init) and only then drives the C
|
||||
// tick. Living in shell.html means the loop is set up from a
|
||||
// clean JS top-level, NOT nested inside Dawn-web's Promise.then
|
||||
// chain — which is the configuration that stalls device-callback
|
||||
// delivery (verified during web bring-up).
|
||||
function shellTick() {
|
||||
if (Module._app_ptr && Module._raf_tick_c) {
|
||||
Module._raf_tick_c(Module._app_ptr);
|
||||
}
|
||||
requestAnimationFrame(shellTick);
|
||||
}
|
||||
requestAnimationFrame(shellTick);
|
||||
}
|
||||
};
|
||||
if (!navigator.gpu) {
|
||||
|
||||
Reference in New Issue
Block a user