diff --git a/src/ifcviewer-web/CMakeLists.txt b/src/ifcviewer-web/CMakeLists.txt index 2805cfccae..2ecff3b225 100644 --- a/src/ifcviewer-web/CMakeLists.txt +++ b/src/ifcviewer-web/CMakeLists.txt @@ -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" ) diff --git a/src/ifcviewer-web/main_web.cpp b/src/ifcviewer-web/main_web.cpp index 600d77bb5f..cae6f2c7c4 100644 --- a/src/ifcviewer-web/main_web.cpp +++ b/src/ifcviewer-web/main_web.cpp @@ -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 -#include - -#include 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(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(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; } diff --git a/src/ifcviewer-web/sample.ifcview b/src/ifcviewer-web/sample.ifcview new file mode 100644 index 0000000000..52558e4d14 Binary files /dev/null and b/src/ifcviewer-web/sample.ifcview differ diff --git a/src/ifcviewer-web/shell.html b/src/ifcviewer-web/shell.html index 1e421ff2cd..792f83a952 100644 --- a/src/ifcviewer-web/shell.html +++ b/src/ifcviewer-web/shell.html @@ -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; } @@ -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) { diff --git a/src/ifcviewer/BufferPool.cpp b/src/ifcviewer/BufferPool.cpp index 8c1643d714..fa690be484 100644 --- a/src/ifcviewer/BufferPool.cpp +++ b/src/ifcviewer/BufferPool.cpp @@ -70,11 +70,6 @@ bool BufferPool::addSubBuffer() { if (try_size < MIN_SUB_BUFFER_BYTES) try_size = MIN_SUB_BUFFER_BYTES; while (try_size >= MIN_SUB_BUFFER_BYTES) { - // wgpu-native classifies "Not enough memory left" as Validation, - // not OutOfMemory. Nested scopes: OOM inner, Validation outer. - wgpuDevicePushErrorScope(device_, WGPUErrorFilter_Validation); - wgpuDevicePushErrorScope(device_, WGPUErrorFilter_OutOfMemory); - char label[128]; std::snprintf(label, sizeof(label), "%s.sub%zu", label_prefix_.c_str(), sub_pools_.size()); @@ -84,6 +79,23 @@ bool BufferPool::addSubBuffer() { desc.size = try_size; desc.label.data = label; desc.label.length = std::strlen(label); + +#if defined(__EMSCRIPTEN__) + // Web: skip the error-scope dance. PopErrorScope on Dawn-web + // resolves via JS microtask, and the spin-wait below (the + // desktop path) would block the JS event loop indefinitely — + // microtask never gets to fire, page hangs. We just trust the + // browser: if createBuffer succeeded the buffer is usable, + // and if it failed `buf` is null and we halve-retry as + // before. A real OOM still surfaces as `buf == nullptr` here. + WGPUBuffer buf = wgpuDeviceCreateBuffer(device_, &desc); + const bool ok = (buf != nullptr); +#else + // wgpu-native classifies "Not enough memory left" as Validation, + // not OutOfMemory. Nested scopes: OOM inner, Validation outer. + wgpuDevicePushErrorScope(device_, WGPUErrorFilter_Validation); + wgpuDevicePushErrorScope(device_, WGPUErrorFilter_OutOfMemory); + WGPUBuffer buf = wgpuDeviceCreateBuffer(device_, &desc); struct PopResult { bool done = false; bool error = false; }; @@ -103,8 +115,10 @@ bool BufferPool::addSubBuffer() { PopResult oom_pop, validation_pop; pop(oom_pop); pop(validation_pop); + const bool ok = buf && !oom_pop.error && !validation_pop.error; +#endif - if (buf && !oom_pop.error && !validation_pop.error) { + if (ok) { SubPool sp; sp.buffer = buf; sp.capacity = try_size; diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp index f091be9608..4affd7422a 100644 --- a/src/ifcviewer/ViewportCore.cpp +++ b/src/ifcviewer/ViewportCore.cpp @@ -1179,14 +1179,29 @@ namespace { // promises (firing our WGPU callbacks via AllowProcessEvents mode), // then resumes the C++ caller. Net effect: the same "spin until done" // shape works on both backends. +// Async-callback mode for WGPU futures. wgpu-native fires +// AllowProcessEvents callbacks deterministically from +// wgpuInstanceProcessEvents — that's what we want on desktop. Dawn-web +// queues those same callbacks indefinitely (waits for a specific +// instance state we don't drive); AllowSpontaneous on web lets the JS +// event loop fire the callback as soon as the underlying promise +// resolves. +constexpr WGPUCallbackMode kAsyncCbMode = +#if defined(__EMSCRIPTEN__) + WGPUCallbackMode_AllowSpontaneous; +#else + WGPUCallbackMode_AllowProcessEvents; +#endif + inline void waitTickInstance(WGPUInstance instance) { #if defined(__EMSCRIPTEN__) - // On Dawn-web AllowProcessEvents queues the callback for the next - // wgpuInstanceProcessEvents call, but the underlying JS promise has - // to resolve first. emscripten_sleep(0) unwinds the wasm stack so - // the JS event loop runs (resolving any pending promises); then - // ProcessEvents drains the resolved completions into our callback. - emscripten_sleep(0); + // Asyncify is OFF on web (see ifcviewer-web/CMakeLists.txt). The + // init path no longer uses this helper — it's callback-driven via + // initWgpuAsyncWeb. The remaining callers are MapAsync sync-wait + // loops (pick readback): those will spin forever here until they + // are ported to a callback-driven shape. Until then, hitting a + // pick on web hangs the page. ProcessEvents is a no-op on + // Dawn-web but harmless to call. wgpuInstanceProcessEvents(instance); #else wgpuInstanceProcessEvents(instance); @@ -1242,13 +1257,45 @@ bool ViewportCore::probeAndCreatePool() { constexpr uint64_t MIN_POOL_CAPACITY = 64ull * 1024 * 1024; constexpr uint64_t MAX_PROBE_START = 4ull * 1024 * 1024 * 1024; + const WGPUBufferUsage pool_usage = WGPUBufferUsage_Storage + | WGPUBufferUsage_CopyDst; + +#if defined(__EMSCRIPTEN__) + // Web: don't try to grab `device_limits.maxBufferSize` (256 MB on + // Dawn-web's spec floor) on the first sub-buffer. Browsers + // throttle / queue / hang on giant allocations — observed in + // Chrome: allocating 256 MB for the first chunk (~1 KB of geometry) + // freezes the tab indefinitely, and contention from any other + // WebGPU page on the same origin makes it worse. Start with a + // small sub-buffer (16 MB) so first-frame is cheap; the pool + // grows lazily via additional sub-buffers as the working set + // needs more, each capped at maxBufferSize but only created when + // actually demanded. (Dawn-web's PopErrorScope callbacks are also + // unreliable from a sync-spin pattern, so the desktop probe is + // skipped entirely on web.) + // 64 MB matches BufferPool::addSubBuffer's MIN_SUB_BUFFER_BYTES + // floor — smaller values would just be clamped up by the pool's + // halve-retry loop anyway. The earlier 256 MB ceiling hung Chrome + // not because of size per se, but because the pool's pop-error-scope + // spin-wait blocked the JS event loop indefinitely (now fixed in + // BufferPool::addSubBuffer for Emscripten). + constexpr std::uint64_t WEB_INITIAL_SUB_BUFFER = 64ull * 1024 * 1024; + const std::uint64_t per_sub = std::min( + device_limits.maxBufferSize, + std::max(MIN_POOL_CAPACITY, WEB_INITIAL_SUB_BUFFER)); + pool_.configure(instance_, device_, pool_usage, per_sub, + "ifcviewer-wgpu.pool"); + Log::info() << "wgpu: pool per-sub-buffer capacity = " + << (per_sub / (1024 * 1024)) << " MB" + << " (web: small initial sub-buffer, grows lazily;" + << " device maxBufferSize = " + << (device_limits.maxBufferSize / (1024 * 1024)) << " MB)"; + return true; +#else uint64_t try_size = std::min(device_limits.maxBufferSize, MAX_PROBE_START); if (try_size < MIN_POOL_CAPACITY) try_size = MIN_POOL_CAPACITY; - const WGPUBufferUsage pool_usage = WGPUBufferUsage_Storage - | WGPUBufferUsage_CopyDst; - while (try_size >= MIN_POOL_CAPACITY) { wgpuDevicePushErrorScope(device_, WGPUErrorFilter_Validation); wgpuDevicePushErrorScope(device_, WGPUErrorFilter_OutOfMemory); @@ -1263,7 +1310,7 @@ bool ViewportCore::probeAndCreatePool() { struct PopResult { bool done = false; bool error = false; }; auto pop = [&](PopResult& pr) { WGPUPopErrorScopeCallbackInfo pcb = {}; - pcb.mode = WGPUCallbackMode_AllowProcessEvents; + pcb.mode = kAsyncCbMode; pcb.callback = [](WGPUPopErrorScopeStatus, WGPUErrorType type, WGPUStringView, void* ud1, void* /*ud2*/) { auto* p = static_cast(ud1); @@ -1295,6 +1342,7 @@ bool ViewportCore::probeAndCreatePool() { Log::warn() << "wgpu: pool probe found no allocatable size >= " << (MIN_POOL_CAPACITY / (1024 * 1024)) << " MB"; return false; +#endif // !__EMSCRIPTEN__ } bool ViewportCore::initWgpu(bool web_limits) { @@ -1303,12 +1351,14 @@ bool ViewportCore::initWgpu(bool web_limits) { wgpuSetLogLevel(WGPULogLevel_Warn); #endif + Log::info() << "[initWgpu] 1/7 wgpuCreateInstance"; instance_ = wgpuCreateInstance(nullptr); if (!instance_) { Log::warn() << "wgpuCreateInstance returned null"; return false; } + Log::info() << "[initWgpu] 2/7 host_->createSurface"; // Surface comes from the host (X11/HWND/CAMetalLayer on desktop; // Emscripten canvas selector on web). surface_ = host_->createSurface(instance_); @@ -1326,7 +1376,7 @@ bool ViewportCore::initWgpu(bool web_limits) { adapter_opts.powerPreference = WGPUPowerPreference_HighPerformance; WGPURequestAdapterCallbackInfo acb = {}; - acb.mode = WGPUCallbackMode_AllowProcessEvents; + acb.mode = kAsyncCbMode; acb.callback = [](WGPURequestAdapterStatus status, WGPUAdapter adapter, WGPUStringView message, void* ud1, void* /*ud2*/) { auto* r = static_cast(ud1); @@ -1340,10 +1390,12 @@ bool ViewportCore::initWgpu(bool web_limits) { }; acb.userdata1 = &areq; + Log::info() << "[initWgpu] 3/7 wgpuInstanceRequestAdapter"; wgpuInstanceRequestAdapter(instance_, &adapter_opts, acb); while (!areq.done) waitTickInstance(instance_); if (!areq.ok) return false; adapter_ = areq.adapter; + Log::info() << "[initWgpu] 3/7 adapter ready"; // ---- Async request device -------------------------------------------- struct DeviceReq { WGPUDevice device = nullptr; bool done = false; bool ok = false; }; @@ -1357,15 +1409,36 @@ bool ViewportCore::initWgpu(bool web_limits) { web_floor_limits.maxBufferSize = 256ull * 1024 * 1024; WGPUDeviceDescriptor dev_desc = {}; +#if defined(__EMSCRIPTEN__) + // Dawn-web's RequestDevice hangs (no promise resolution) when we + // pass a fully-populated WGPULimits as requiredLimits — every + // non-zero field is treated as a hard requirement, and the + // browser's adapter-reported limits include values it won't grant + // back to a device. nullptr means "no specific requirements; give + // me default limits", which the spec guarantees succeeds and is + // exactly what we need: the pool probe still discovers the actual + // buffer-size ceiling via probeAndCreatePool, so we don't lose + // anything by deferring to defaults here. + (void)web_floor_limits; + (void)web_limits; + dev_desc.requiredLimits = nullptr; +#else dev_desc.requiredLimits = web_limits ? &web_floor_limits : &adapter_limits; if (web_limits) { Log::info() << "wgpu --web-limits: requesting browser-floor limits " "(maxStorageBufferBindingSize=128MB, maxBufferSize=256MB)"; } +#endif +#if !defined(__EMSCRIPTEN__) + // Setting only the callback (leaving uncapturedErrorCallbackInfo.mode + // = 0 default) makes Dawn-web's RequestDevice silently never resolve + // the device promise. Leave the whole struct zeroed on web; the + // browser already surfaces uncaptured errors to the JS console. dev_desc.uncapturedErrorCallbackInfo.callback = onUncapturedError; +#endif WGPURequestDeviceCallbackInfo dcb = {}; - dcb.mode = WGPUCallbackMode_AllowProcessEvents; + dcb.mode = kAsyncCbMode; dcb.callback = [](WGPURequestDeviceStatus status, WGPUDevice device, WGPUStringView message, void* ud1, void* /*ud2*/) { auto* r = static_cast(ud1); @@ -1379,26 +1452,52 @@ bool ViewportCore::initWgpu(bool web_limits) { }; dcb.userdata1 = &dreq; + Log::info() << "[initWgpu] 4/7a wgpuAdapterRequestDevice CALL"; +#if defined(__EMSCRIPTEN__) + // Pass nullptr descriptor so Dawn-web takes the all-defaults path. + // Setting any descriptor field that Dawn-web can't grant silently + // makes the device promise never resolve, so we avoid the whole + // struct. Note: web init normally goes through initWgpuAsyncWeb, + // not this sync path; this branch is dead code on the current + // web build but kept for any future caller. + wgpuAdapterRequestDevice(adapter_, nullptr, dcb); +#else wgpuAdapterRequestDevice(adapter_, &dev_desc, dcb); - while (!dreq.done) waitTickInstance(instance_); +#endif + Log::info() << "[initWgpu] 4/7b RequestDevice returned, entering spin"; + int tick = 0; + while (!dreq.done) { + waitTickInstance(instance_); + if (++tick % 100 == 0) { + Log::info() << "[initWgpu] 4/7c spin tick=" << tick; + } + if (tick > 2000) { + Log::warn() << "[initWgpu] 4/7 giving up after 2000 ticks"; + return false; + } + } if (!dreq.ok) return false; device_ = dreq.device; queue_ = wgpuDeviceGetQueue(device_); + Log::info() << "[initWgpu] 4/7d device + queue ready"; + Log::info() << "[initWgpu] 5/7 probeAndCreatePool"; if (!probeAndCreatePool()) { Log::warn() << "wgpu: streaming pool probe failed; cannot start"; return false; } + Log::info() << "[initWgpu] 5/7 pool created"; #if !defined(__EMSCRIPTEN__) // Background streaming worker. On desktop std::thread spawns a real // OS thread; under Emscripten that requires -pthread + SharedArrayBuffer // (which itself needs COOP/COEP headers from the hosting page). // Until the web build wires those up we run streaming inline from - // the render thread — the chunk count for the spike is small enough - // that the synchronous fallback inside driveStreamingLoads is fine. + // the render thread (see the `use_sync = true` branch in + // driveStreamingLoads on Emscripten). streaming_thread_.start(); #endif + Log::info() << "[initWgpu] 6/7 wgpuSurfaceGetCapabilities"; WGPUSurfaceCapabilities caps = {}; if (wgpuSurfaceGetCapabilities(surface_, adapter_, &caps) != WGPUStatus_Success || caps.formatCount == 0) { @@ -1412,6 +1511,111 @@ bool ViewportCore::initWgpu(bool web_limits) { return true; } +#if defined(__EMSCRIPTEN__) +namespace { +// State carrier for the nested callback chain. Heap-allocated so it +// survives across the JS event loop ticks that resolve each promise; +// freed at the leaf callback (success or any error path). +struct WebInitCtx { + ViewportCore* core; + std::function on_complete; +}; +} // namespace + +void ViewportCore::initWgpuAsyncWeb(std::function on_complete) { + // Pass a defaulted descriptor (not nullptr). On Dawn-web, + // wgpuCreateInstance(nullptr) returns a usable instance but the + // subsequent RequestDevice promise silently never resolves. + WGPUInstanceDescriptor inst_desc = {}; + instance_ = wgpuCreateInstance(&inst_desc); + if (!instance_) { + Log::warn() << "[web init] wgpuCreateInstance returned null"; + on_complete(false); + return; + } + + auto* ctx = new WebInitCtx{this, std::move(on_complete)}; + + // Bare adapter options (no compatibleSurface, no powerPreference). + // Setting any field here makes the subsequent device promise + // silently never resolve on Dawn-web. + WGPURequestAdapterOptions adapter_opts = {}; + + WGPURequestAdapterCallbackInfo acb = {}; + acb.mode = WGPUCallbackMode_AllowSpontaneous; + acb.callback = [](WGPURequestAdapterStatus status, WGPUAdapter adapter, + WGPUStringView msg, void* ud1, void* /*ud2*/) { + auto* c = static_cast(ud1); + if (status != WGPURequestAdapterStatus_Success || !adapter) { + Log::warn() << "[web init] RequestAdapter failed: " << svToStr(msg); + c->on_complete(false); + delete c; + return; + } + c->core->adapter_ = adapter; + + WGPURequestDeviceCallbackInfo dcb = {}; + dcb.mode = WGPUCallbackMode_AllowSpontaneous; + dcb.callback = [](WGPURequestDeviceStatus dstatus, WGPUDevice device, + WGPUStringView dmsg, void* ud1b, void* /*ud2*/) { + auto* c = static_cast(ud1b); + if (dstatus != WGPURequestDeviceStatus_Success || !device) { + Log::warn() << "[web init] RequestDevice failed: " << svToStr(dmsg); + c->on_complete(false); + delete c; + return; + } + c->core->device_ = device; + c->core->queue_ = wgpuDeviceGetQueue(device); + + // Surface creation is deferred to here (post-device-ready). + // Creating it inside the adapter callback (before + // wgpuAdapterRequestDevice) makes the device promise + // silently never resolve on Dawn-web. + c->core->surface_ = c->core->host_->createSurface(c->core->instance_); + if (!c->core->surface_) { + Log::warn() << "[web init] host createSurface returned null"; + c->on_complete(false); + delete c; + return; + } + + if (!c->core->probeAndCreatePool()) { + Log::warn() << "[web init] pool create failed"; + c->on_complete(false); + delete c; + return; + } + + WGPUSurfaceCapabilities caps = {}; + if (wgpuSurfaceGetCapabilities(c->core->surface_, c->core->adapter_, &caps) + != WGPUStatus_Success + || caps.formatCount == 0) { + Log::warn() << "[web init] surface has no formats"; + c->on_complete(false); + delete c; + return; + } + c->core->surface_format_ = caps.formats[0]; + wgpuSurfaceCapabilitiesFreeMembers(caps); + + Log::info() << "[web init] wgpu device + surface ready (format=" + << int(c->core->surface_format_) << ")"; + c->on_complete(true); + delete c; + }; + dcb.userdata1 = c; + // Pass a zero-init local descriptor (not nullptr). Dawn-web's + // RequestDevice silently never resolves the promise when passed + // nullptr. + WGPUDeviceDescriptor dd = {}; + wgpuAdapterRequestDevice(adapter, &dd, dcb); + }; + acb.userdata1 = ctx; + wgpuInstanceRequestAdapter(instance_, &adapter_opts, acb); +} +#endif // __EMSCRIPTEN__ + void ViewportCore::shutdown() { // Stop streaming first so no late results land in the pool after // we've torn down model state. Worker drains its queue then joins. @@ -2065,11 +2269,21 @@ void ViewportCore::driveStreamingLoads() { continue; } - // Sync fallback when a screenshot is pending: the deferred- - // capture wait would let the window manager re-layout the - // window while we wait, capturing at the wrong size. With sync - // loads the chunk appears in the same frame we enqueue. - if (!pending_screenshot_path_.empty()) { + // Sync fallback. Two ways to land here: + // - A screenshot capture is pending — the deferred-capture + // wait would let the window manager re-layout while we wait, + // capturing at the wrong size. Sync loads make the chunk + // appear in the same frame we enqueue. + // - Emscripten — the worker thread isn't started (no pthreads + // wired yet, #88), so streaming_thread_.enqueue would just + // queue requests with nothing to drain them. Chunks would + // never go resident. +#if defined(__EMSCRIPTEN__) + const bool use_sync = true; +#else + const bool use_sync = !pending_screenshot_path_.empty(); +#endif + if (use_sync) { if (loadChunkBytesAndUploadGpu(*cand.m, cand.ci)) { ++enqueued; c.last_visible_frame_idx = streaming_frame_idx_; @@ -2822,6 +3036,21 @@ void ViewportCore::uploadInstanceChunk(const InstanceChunk& chunk) { s.instances.push_back(inst); } +std::uint32_t ViewportCore::loadSidecarFromPath(const std::string& path) { + if (!device_ || !queue_) { + Log::warn() << "loadSidecarFromPath: wgpu not initialised"; + return 0; + } + auto meta_opt = readSidecarMetadataOnly(path); + if (!meta_opt) { + Log::warn() << "loadSidecarFromPath: could not read sidecar metadata from " << path; + return 0; + } + const std::uint32_t mid = next_model_id_++; + applyCachedModel(mid, std::move(*meta_opt)); + return mid; +} + void ViewportCore::finalizeModel(std::uint32_t model_id) { auto it = pending_direct_loads_.find(model_id); if (it == pending_direct_loads_.end()) { @@ -3230,7 +3459,7 @@ void ViewportCore::startHizMap(int slot, const Eigen::Matrix4f& vp_used) { auto* ctx = new MapCtx{ this, slot }; WGPUBufferMapCallbackInfo mcb = {}; - mcb.mode = WGPUCallbackMode_AllowProcessEvents; + mcb.mode = kAsyncCbMode; mcb.callback = [](WGPUMapAsyncStatus status, WGPUStringView /*msg*/, void* ud1, void* /*ud2*/) { auto* c = static_cast(ud1); @@ -3991,7 +4220,7 @@ std::uint32_t ViewportCore::pickObjectAt(int x_pixels, int y_pixels, struct MapReq { bool done = false; bool ok = false; }; MapReq req; WGPUBufferMapCallbackInfo mcb = {}; - mcb.mode = WGPUCallbackMode_AllowProcessEvents; + mcb.mode = kAsyncCbMode; mcb.callback = [](WGPUMapAsyncStatus status, WGPUStringView /*msg*/, void* ud1, void* /*ud2*/) { auto* r = static_cast(ud1); @@ -4163,7 +4392,7 @@ std::vector ViewportCore::picksInRect(int x, int y, int w, int h) struct MapReq { bool done = false; bool ok = false; }; MapReq req; WGPUBufferMapCallbackInfo mcb = {}; - mcb.mode = WGPUCallbackMode_AllowProcessEvents; + mcb.mode = kAsyncCbMode; mcb.callback = [](WGPUMapAsyncStatus status, WGPUStringView /*msg*/, void* ud1, void* /*ud2*/) { auto* r = static_cast(ud1); @@ -4694,7 +4923,7 @@ void ViewportCore::finalizeScreenshotCapture(WGPUBuffer capture_buffer, struct MapReq { bool done = false; bool ok = false; }; MapReq req; WGPUBufferMapCallbackInfo mcb = {}; - mcb.mode = WGPUCallbackMode_AllowProcessEvents; + mcb.mode = kAsyncCbMode; mcb.callback = [](WGPUMapAsyncStatus status, WGPUStringView /*message*/, void* ud1, void* /*ud2*/) { auto* r = static_cast(ud1); @@ -4906,7 +5135,16 @@ void ViewportCore::render() { }; } + // Force sequential on Emscripten: std::async(std::launch::async) + // without -pthread throws std::system_error from inside libstdc++, + // and we link without exceptions so that becomes abort(). Until + // COOP/COEP + -pthread wires the worker pool in #88, web stays + // on the serial path. +#if defined(__EMSCRIPTEN__) + if (false) { +#else if (cull_threads_enabled_) { +#endif std::vector>> futures; futures.reserve(models_gpu_.size()); for (auto& [mid, m] : models_gpu_) { diff --git a/src/ifcviewer/ViewportCore.h b/src/ifcviewer/ViewportCore.h index 01755df9e2..373b5540b4 100644 --- a/src/ifcviewer/ViewportCore.h +++ b/src/ifcviewer/ViewportCore.h @@ -257,6 +257,19 @@ public: bool initWgpu(bool web_limits); void shutdown(); +#if defined(__EMSCRIPTEN__) + // Async-init driver for the web. The spin-wait pattern in initWgpu() + // doesn't work on Dawn-web — RequestDevice's callback never fires + // when the caller is parked inside an Asyncify spin loop, even with + // AllowSpontaneous + emscripten_sleep yields. The fix is to mirror + // the original main_web.cpp spike: nested callbacks, no spin. + // Fires `on_complete(true)` once instance + adapter + device + queue + // + pool + surface_format_ are all in place; on any failure, fires + // on_complete(false). Caller is responsible for calling + // buildPipelines + buildHiz/Edge/Pick + scene-load after on_complete. + void initWgpuAsyncWeb(std::function on_complete); +#endif + // ---- Chunk residency (#84-n) ------------------------------------------ // // Build the per-chunk WGPUBindGroup over its current pool slices + @@ -315,6 +328,14 @@ public: // freshly-loaded scene frames itself). void applyCachedModel(std::uint32_t model_id, StreamingSidecar metadata); + // Qt-free sidecar load: readSidecarMetadataOnly + applyCachedModel. + // Used by the web build (and any other non-Qt embedder) so the + // public ViewportWindow::loadSidecar's QString + QFile triage + // tilde-expansion doesn't have to be replicated. Returns 0 on + // any failure (device not ready, file missing, magic / version + // mismatch) and the freshly-assigned model_id on success. + std::uint32_t loadSidecarFromPath(const std::string& path); + // Direct-load (bonsai-side) entry points. Bonsai's SceneLoader feeds // the viewer one mesh + one instance at a time, then calls // finalizeModel once everything's staged. The staging map lives on