From 0d4257bb3df5f9b80c9fc1daacbba6ef2b3fecf9 Mon Sep 17 00:00:00 2001 From: Hesperus Date: Wed, 26 Aug 2026 00:03:23 -0700 Subject: [PATCH 1/3] Web: report engine boot phase before callMain blocks the main thread After the download completes the loading bar sits at 100% while callMain runs the engine's synchronous start-up (module/physics init and, on WebGPU, shader/pipeline compilation). The page looks frozen and Chrome raises 'Page Unresponsive'. Add an opt-in onStatusChange config callback, fired with a boot-phase message and given a paint yield (double rAF) before the blocking call, and wire it into both bundled HTML shells. --- misc/dist/html/full-size.html | 25 +++++++++++++- misc/dist/html/webgpu-full-size.html | 27 ++++++++++++++- platform/web/js/engine/config.js | 18 ++++++++++ platform/web/js/engine/engine.js | 49 +++++++++++++++++++++++++--- 4 files changed, 113 insertions(+), 6 deletions(-) diff --git a/misc/dist/html/full-size.html b/misc/dist/html/full-size.html index d7bfbcdb0a5f7..0d24ee4f0a1e2 100644 --- a/misc/dist/html/full-size.html +++ b/misc/dist/html/full-size.html @@ -66,7 +66,17 @@ image-rendering: pixelated; } -#status-progress, #status-notice { +#status-progress, #status-phase { + color: #b0b0b0; + font-family: 'Noto Sans', 'Droid Sans', Arial, sans-serif; + font-size: 0.85em; + line-height: 1.4em; + margin-top: 0.5em; + text-align: center; + display: none; +} + +#status-notice { display: none; } @@ -104,6 +114,7 @@
+
@@ -218,7 +229,19 @@ statusProgress.removeAttribute('max'); } }, + // Reported after the download finishes, just before the engine's + // synchronous start-up blocks the main thread. Swap the full + // progress bar for a legible phase message so the loading screen + // does not look frozen while the engine initializes. + 'onStatusChange': function (phase) { + // A quiet line under the (full) progress bar — enough to say the + // silence is expected, without an alarming notice box. + const el = document.getElementById('status-phase'); + el.textContent = phase === 'shader-compile' ? 'Compiling shaders…' : 'Initializing engine…'; + el.style.display = 'block'; + }, }).then(() => { + document.getElementById('status-phase').style.display = 'none'; setStatusMode('hidden'); }, displayFailureNotice); } diff --git a/misc/dist/html/webgpu-full-size.html b/misc/dist/html/webgpu-full-size.html index e257d44d7c5cc..b8df90442d786 100644 --- a/misc/dist/html/webgpu-full-size.html +++ b/misc/dist/html/webgpu-full-size.html @@ -66,7 +66,17 @@ image-rendering: pixelated; } -#status-progress, #status-notice { +#status-progress, #status-phase { + color: #b0b0b0; + font-family: 'Noto Sans', 'Droid Sans', Arial, sans-serif; + font-size: 0.85em; + line-height: 1.4em; + margin-top: 0.5em; + text-align: center; + display: none; +} + +#status-notice { display: none; } @@ -104,6 +114,7 @@
+
@@ -289,7 +300,10 @@ } else { // No WebGPU device — the engine will fall back to gl_compatibility // if opengl3=yes was also compiled in. Otherwise it will fail at init. + // Correct the config so downstream consumers (boot-phase reporting, + // diagnostics) see the driver that will actually run. console.warn('WebGPU unavailable, engine will attempt fallback.'); + engineConfig['renderingDriver'] = 'opengl3'; } const engine = new Engine(engineConfig); @@ -303,6 +317,17 @@ statusProgress.removeAttribute('max'); } }, + // Reported after the download finishes, just before the engine's + // synchronous start-up blocks the main thread. Swap the full + // progress bar for a legible phase message so the loading screen + // does not look frozen while shaders/pipelines compile. + 'onStatusChange': function (phase) { + // A quiet line under the (full) progress bar — enough to say the + // silence is expected, without an alarming notice box. + const el = document.getElementById('status-phase'); + el.textContent = phase === 'shader-compile' ? 'Compiling shaders…' : 'Initializing engine…'; + el.style.display = 'block'; + }, }).then(() => { setStatusMode('hidden'); }, displayFailureNotice); diff --git a/platform/web/js/engine/config.js b/platform/web/js/engine/config.js index 85ee6fd3bc294..9fbe202c99887 100644 --- a/platform/web/js/engine/config.js +++ b/platform/web/js/engine/config.js @@ -215,6 +215,23 @@ const InternalConfig = function (initConfig) { // eslint-disable-line no-unused- * @type {?function(number, number)} */ onProgress: null, + /** + * A callback function for reporting the engine boot phase after the + * download completes. Called with a machine-readable boot-phase token + * (``'shader-compile'`` when a pre-initialized WebGPU device will run + * shader/pipeline compilation, ``'engine-init'`` otherwise) immediately + * before the synchronous engine start-up runs, so the shell can show a + * localized message and keep the loading screen legible while the main + * thread is blocked. + * + * @callback EngineConfig.onStatusChange + * @param {string} phase The boot-phase token. + */ + /** + * @ignore + * @type {?function(string)} + */ + onStatusChange: null, /** * A callback function for handling the standard output stream. This method should usually only be used in debug pages. * @@ -278,6 +295,7 @@ const InternalConfig = function (initConfig) { // eslint-disable-line no-unused- this.onPrintError = parse('onPrintError', this.onPrintError); this.onPrint = parse('onPrint', this.onPrint); this.onProgress = parse('onProgress', this.onProgress); + this.onStatusChange = parse('onStatusChange', this.onStatusChange); // Godot config this.canvas = parse('canvas', this.canvas); diff --git a/platform/web/js/engine/engine.js b/platform/web/js/engine/engine.js index 4a3d8b7757b87..3387598af7b50 100644 --- a/platform/web/js/engine/engine.js +++ b/platform/web/js/engine/engine.js @@ -172,10 +172,51 @@ const Engine = (function () { me.rtenv['copyToFS'](file.path, file.buffer); } preloader.preloadedFiles.length = 0; // Clear memory - me.rtenv['callMain'](me.config.args); - initPromise = null; - me.installServiceWorker(); - resolve(); + // Once the download completes the progress bar sits at 100% + // while `callMain` runs the engine's synchronous start-up + // (module/physics init and, on WebGPU, shader/pipeline + // compilation). That work blocks the main thread with no + // progress reporting, so the page can look frozen. Surface + // a boot-phase message and force it to paint *before* the + // blocking call, so the last thing drawn is legible text + // instead of a stalled bar. + function runMain() { + me.rtenv['callMain'](me.config.args); + initPromise = null; + me.installServiceWorker(); + resolve(); + } + if (typeof me.config.onStatusChange === 'function') { + // Machine-readable phase token; the shell owns the (localized) + // display text. 'shader-compile' only when a WebGPU device was + // actually pre-initialized — with renderingDriver=webgpu but no + // device, the engine falls back to GL and 'engine-init' is the + // honest phase. + const phase = (me.config.renderingDriver === 'webgpu' && me.config.preinitializedWebGPUDevice) + ? 'shader-compile' + : 'engine-init'; + me.config.onStatusChange(phase); + // Yield to the browser so the message actually paints before + // `callMain` blocks the thread. A double rAF waits for a real + // composite — but rAF is suspended entirely in background + // tabs, so race it against a short timeout (run-once latch) + // or a backgrounded page would not boot until focused. + let mainStarted = false; + const runMainOnce = function () { + if (!mainStarted) { + mainStarted = true; + runMain(); + } + }; + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(function () { + requestAnimationFrame(runMainOnce); + }); + } + setTimeout(runMainOnce, 250); + } else { + runMain(); + } }); }); }, From 0bb46a62b266e833b80fb3fffc666e30f9ecf7ec Mon Sep 17 00:00:00 2001 From: Hesperus Date: Fri, 28 Aug 2026 23:43:37 -0700 Subject: [PATCH 2/3] Web: guarantee a composite of the boot-phase message before callMain blocks The double-rAF yield was measured (CDP screencast, two runs) failing under post-download main-thread load: both rAF callbacks fired 2-8ms apart with no compositor frame between them, so the phase message never reached the screen once. Align to a frame boundary with a single rAF, then allow the compositor 100ms to present the committed change; keep an absolute 1s fallback for background tabs where rAF is suspended. --- platform/web/js/engine/engine.js | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/platform/web/js/engine/engine.js b/platform/web/js/engine/engine.js index 3387598af7b50..253fb73781cbf 100644 --- a/platform/web/js/engine/engine.js +++ b/platform/web/js/engine/engine.js @@ -197,10 +197,16 @@ const Engine = (function () { : 'engine-init'; me.config.onStatusChange(phase); // Yield to the browser so the message actually paints before - // `callMain` blocks the thread. A double rAF waits for a real - // composite — but rAF is suspended entirely in background - // tabs, so race it against a short timeout (run-once latch) - // or a backgrounded page would not boot until focused. + // `callMain` blocks the thread. A double rAF is NOT a + // composite guarantee: measured under post-download load + // (CDP screencast), the two rAF callbacks fired 2-8ms apart + // with no frame between them, and the message never reached + // a single compositor frame. Instead, align to a frame + // boundary with one rAF, then give the compositor real time + // to present the committed change before blocking. rAF is + // suspended entirely in background tabs, so keep an absolute + // fallback timer (run-once latch) or a backgrounded page + // would not boot until focused. let mainStarted = false; const runMainOnce = function () { if (!mainStarted) { @@ -210,10 +216,10 @@ const Engine = (function () { }; if (typeof requestAnimationFrame === 'function') { requestAnimationFrame(function () { - requestAnimationFrame(runMainOnce); + setTimeout(runMainOnce, 100); }); } - setTimeout(runMainOnce, 250); + setTimeout(runMainOnce, 1000); } else { runMain(); } From bbb01215417c2b103e925406dd8569c15bd7ca21 Mon Sep 17 00:00:00 2001 From: Hesperus Date: Fri, 28 Aug 2026 23:49:42 -0700 Subject: [PATCH 3/3] Web: position the boot-phase line above the splash image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The splash is absolutely positioned, and positioned elements paint above normal-flow content regardless of DOM order — so the phase line rendered underneath the opaque splash (measured: elementFromPoint at the text's center hit #status-splash; compositor frames pixel-identical across the text mutation). Position it like the progress bar, just beneath it. --- misc/dist/html/full-size.html | 10 ++++++++++ misc/dist/html/webgpu-full-size.html | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/misc/dist/html/full-size.html b/misc/dist/html/full-size.html index 0d24ee4f0a1e2..a7fce39545aee 100644 --- a/misc/dist/html/full-size.html +++ b/misc/dist/html/full-size.html @@ -86,6 +86,16 @@ margin: 0 auto; } +#status-phase { + /* Positioned like the bar itself: the absolutely-positioned splash paints + above normal-flow content, so an unpositioned phase line renders + underneath it (measured: elementFromPoint at the text hit the splash). */ + position: absolute; + left: 0; + right: 0; + bottom: calc(10% - 2.2em); +} + #status-notice { background-color: #5b3943; border-radius: 0.5rem; diff --git a/misc/dist/html/webgpu-full-size.html b/misc/dist/html/webgpu-full-size.html index b8df90442d786..e1c6d008372af 100644 --- a/misc/dist/html/webgpu-full-size.html +++ b/misc/dist/html/webgpu-full-size.html @@ -86,6 +86,16 @@ margin: 0 auto; } +#status-phase { + /* Positioned like the bar itself: the absolutely-positioned splash paints + above normal-flow content, so an unpositioned phase line renders + underneath it (measured: elementFromPoint at the text hit the splash). */ + position: absolute; + left: 0; + right: 0; + bottom: calc(10% - 2.2em); +} + #status-notice { background-color: #5b3943; border-radius: 0.5rem;