From 602164c8176e70ccd6f4b9f8eeea45fc583cb623 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 11 Jul 2026 05:22:49 +0200 Subject: [PATCH] fix(web): tell the user why the game won't load instead of panicking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user on macOS Sequoia hit a wall of Rust stack traces and a misleading "bloom engine init timed out". The cause: Chrome exposes `navigator.gpu` (on by default since 113) but returns no adapter — graphics acceleration off, a blocklisted GPU, or a VM/remote session. wgpu decides its backend in Instance::new purely on whether `navigator.gpu` is defined, so it commits to WebGPU, finds no adapter, and panics inside the wasm. Probe requestAdapter() before booting the engine and, when it comes back empty, stop with a message that names the likely causes and points at chrome://gpu. The WebGL2 route is plumbed here too (hiding `navigator.gpu` is what routes wgpu to wgpu-core), but it is gated off: Bloom's renderer eagerly builds compute pipelines, which WebGL2 cannot run, so an automatic fallback would only swap one crash for another. Flip WEBGL2_FALLBACK_SUPPORTED once the engine has a downlevel path; `?renderer=gl` forces the attempt meanwhile. Also add `build-web.sh --engine-src`, which builds bloom_web from the ../engine checkout. The default path cannot carry an unpublished engine fix: it compiles from the npm tarball, whose bundled Rust source does not compile (its web crate calls 3D model APIs the shared crate it ships with doesn't expose) — only its prebuilt pkg/ works. --- build-web.sh | 28 ++++++++++++----- web/bloom_ffi.js | 79 ++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 98 insertions(+), 9 deletions(-) diff --git a/build-web.sh b/build-web.sh index a3b3dba..b2428d7 100755 --- a/build-web.sh +++ b/build-web.sh @@ -8,30 +8,44 @@ # assets/ game sprites, sounds, levels (copied from ./assets) # # Flags: -# --skip-bloom Reuse existing ../engine/native/web/pkg/ (skip wasm-pack) +# --skip-bloom Reuse the existing pkg/ (skip wasm-pack) +# --engine-src Build bloom_web from the ../engine checkout instead of node_modules # --serve After build, launch `python3 -m http.server 8080` in dist/web set -euo pipefail JUMP_DIR="$(cd "$(dirname "$0")" && pwd)" -# Build bloom_web from the SAME engine the game compiles against (node_modules), -# so the runtime glue and the game WASM's FFI ABI never skew. Falls back to a -# sibling engine source checkout (../engine) for local engine development. -BLOOM_WEB="$JUMP_DIR/node_modules/@bloomengine/engine/native/web" -[ -d "$BLOOM_WEB" ] || BLOOM_WEB="$JUMP_DIR/../engine/native/web" OUT="$JUMP_DIR/dist/web" skip_bloom=false +engine_src=false serve=false for arg in "$@"; do case "$arg" in --skip-bloom) skip_bloom=true ;; + --engine-src) engine_src=true ;; --serve) serve=true ;; - -h|--help) sed -n '2,15p' "$0"; exit 0 ;; + -h|--help) sed -n '2,16p' "$0"; exit 0 ;; *) echo "unknown flag: $arg" >&2; exit 2 ;; esac done +# Default: build bloom_web from the SAME engine the game compiles against +# (node_modules), so the runtime glue and the game WASM's FFI ABI never skew. +# +# --engine-src builds from the sibling ../engine checkout instead. Needed for any +# engine-side change that isn't in a published release yet: the npm tarball ships a +# prebuilt pkg/ that works, but its bundled Rust *source* does not currently compile +# (its web crate calls 3D model APIs the shared crate it ships with doesn't expose), +# so a from-source rebuild of node_modules fails. Verify the result end-to-end +# (tools/headless-check.js) — nothing else guards ABI skew on this path. +if $engine_src; then + BLOOM_WEB="$JUMP_DIR/../engine/native/web" +else + BLOOM_WEB="$JUMP_DIR/node_modules/@bloomengine/engine/native/web" + [ -d "$BLOOM_WEB" ] || BLOOM_WEB="$JUMP_DIR/../engine/native/web" +fi + if [ ! -d "$BLOOM_WEB" ]; then echo "error: bloom web crate not found at $BLOOM_WEB" >&2 exit 1 diff --git a/web/bloom_ffi.js b/web/bloom_ffi.js index 1dd65b5..45508b9 100644 --- a/web/bloom_ffi.js +++ b/web/bloom_ffi.js @@ -279,12 +279,81 @@ function buildFfiImports() { return ffi; } +// ----- GPU backend selection ----- +// The engine needs a WebGPU adapter. Two things make its absence far nastier than it +// should be, and this function handles both. +// +// 1. Having the WebGPU *API* is not the same as having an *adapter*. Chrome ships +// `navigator.gpu` on by default (since 113), but `requestAdapter()` still returns +// null when graphics acceleration is off, the GPU is blocklisted, or the session is +// a VM / remote desktop. Left alone, the engine hits that inside wgpu and panics +// ("No WebGPU/WebGL adapter found"), boot stalls, and the user sees a wall of Rust +// stack traces and a bogus "init timed out". So probe for a real adapter here and +// fail with something a human can act on. +// +// 2. wgpu picks its backend once, in `Instance::new`, on a single question: is +// `navigator.gpu` defined? (wgpu-29 src/api/instance.rs:74). If it is, wgpu commits +// to WebGPU and never looks at GL again — so the WebGL2 backend we compile in (the +// `webgl` feature) can never engage on its own. Hiding `navigator.gpu` from the wasm +// (an own `undefined` property shadows the Navigator.prototype getter; wgpu reads +// that as absent) is what routes wgpu to wgpu-core → WebGL2. +// +// That WebGL2 route is plumbed and reachable, but it cannot render yet: Bloom's +// Renderer::new eagerly builds 14 compute pipelines, storage buffers and a 64KB joint +// uniform, and WebGL2 (GLES 3.0) has no compute shaders or storage buffers at all — it +// dies during renderer init. Flipping this to true is only meaningful once the engine +// gains a downlevel/2D-only init path. Until then an automatic fallback would trade one +// crash for another, so we send the user a clear message instead. `?renderer=gl` still +// forces the attempt for engine development. +const WEBGL2_FALLBACK_SUPPORTED = false; + +async function selectGpuBackend() { + const forceGl = new URLSearchParams(location.search).get("renderer") === "gl"; + + if (!forceGl && navigator.gpu) { + let adapter = null; + try { + adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" }); + } catch (e) { + console.warn("navigator.gpu.requestAdapter() threw:", e); + } + if (adapter) return "webgpu"; + } + + if (!forceGl && !WEBGL2_FALLBACK_SUPPORTED) return "none"; + + // Route wgpu away from WebGPU and onto wgpu-core → WebGL2. + if ("gpu" in navigator) { + try { + Object.defineProperty(navigator, "gpu", { value: undefined, configurable: true }); + } catch (e) { + console.warn("could not hide navigator.gpu; wgpu will stay on WebGPU:", e); + return "none"; + } + } + return document.createElement("canvas").getContext("webgl2") ? "webgl2" : "none"; +} + // ----- Boot sequence ----- async function boot() { const loading = document.getElementById("loading"); if (loading) loading.textContent = "Initializing Bloom engine..."; await init(); // wasm-bindgen init + + const backend = await selectGpuBackend(); + if (backend === "none") { + throw new Error( + "This browser can't provide a WebGPU adapter, which Bloom Jump needs to render.\n\n" + + "WebGPU itself is enabled by default in Chrome 113+ — an adapter usually goes missing " + + "because graphics acceleration is switched off (chrome://settings/system), the GPU is " + + "blocklisted, or the browser is running in a VM or remote session. " + + "Open chrome://gpu and look at the WebGPU row for the specific reason." + ); + } + console.log(`[bloom] renderer: ${backend}`); + if (loading) loading.textContent = `Initializing Bloom engine (${backend})...`; + installInputListeners(); installAudioBridge(); @@ -299,7 +368,7 @@ async function boot() { bloom.bloom_init_window(w, h, 0, 0); const readyDeadline = Date.now() + 10_000; while (bloom.bloom_is_initialized() < 0.5) { - if (Date.now() > readyDeadline) throw new Error("bloom engine init timed out"); + if (Date.now() > readyDeadline) throw new Error(`bloom engine init timed out (${backend})`); await new Promise((r) => setTimeout(r, 16)); } @@ -319,5 +388,11 @@ async function boot() { boot().catch((err) => { console.error("Boot failed:", err, "\nstack:", err?.stack); const root = document.getElementById("loading") || document.body; - root.textContent = "Boot error: " + (err?.message || err); + root.textContent = err?.message || String(err); + // The no-GPU message is multi-line prose meant to be read, not a one-line status. + root.style.whiteSpace = "pre-wrap"; + root.style.maxWidth = "36rem"; + root.style.margin = "2rem auto"; + root.style.lineHeight = "1.5"; + root.style.textAlign = "left"; });