diff --git a/misc/dist/html/full-size.html b/misc/dist/html/full-size.html
index d7bfbcdb0a5f7..a7fce39545aee 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;
}
@@ -76,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;
@@ -104,6 +124,7 @@
+
@@ -218,7 +239,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..e1c6d008372af 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;
}
@@ -76,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;
@@ -104,6 +124,7 @@
+
@@ -289,7 +310,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 +327,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..253fb73781cbf 100644
--- a/platform/web/js/engine/engine.js
+++ b/platform/web/js/engine/engine.js
@@ -172,10 +172,57 @@ 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 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) {
+ mainStarted = true;
+ runMain();
+ }
+ };
+ if (typeof requestAnimationFrame === 'function') {
+ requestAnimationFrame(function () {
+ setTimeout(runMainOnce, 100);
+ });
+ }
+ setTimeout(runMainOnce, 1000);
+ } else {
+ runMain();
+ }
});
});
},