diff --git a/client/js/app.js b/client/js/app.js index c8289db..ee0c922 100644 --- a/client/js/app.js +++ b/client/js/app.js @@ -278,10 +278,14 @@ function startGameScreen(isAdmin) { $(window).off("keydown.game"); } $("#disconnect-banner").addClass("hidden"); + // Start the boot log before the screen is built, so the WebGL context, shader + // compile and texture load are timed and shown alongside the world generation. + bootLog.bind($("#loading-overlay")).begin(); gameScreen = new GameScreen(network, config); + gameScreen.beginLoading(); showScreen("screen-game"); gameScreen.enter(); - gameScreen.beginLoading(); + bootLog.step("Opened the game screen"); if (isAdmin) { gameScreen.sendSetup(); bootLog.step("Requested the world from the server"); diff --git a/client/js/game_screen.js b/client/js/game_screen.js index b827fdc..a7250ff 100644 --- a/client/js/game_screen.js +++ b/client/js/game_screen.js @@ -43,6 +43,9 @@ export class GameScreen { this._gdpExpanded = false; this._battles = new Set(); this._loading = false; + // Whether the first snapshot has been mirrored while the loading overlay is + // up; the frame loop waits for this before checking map readiness. + this._worldMirrored = false; this._researched = new Set(); this._wars = new Set(); this._conflicts = []; @@ -94,11 +97,16 @@ export class GameScreen { requestAnimationFrame((t) => this._frame(t)); } - // Shows the boot-style log while the first snapshot's world is generated and - // uploaded. The real app calls this; tests never do, so they pay nothing. + // Marks the screen as loading and makes sure the boot log is running. The real + // app begins the log before building the screen (so the WebGL context, shader + // and texture setup is logged too); this only starts it if that was skipped. beginLoading() { this._loading = true; - bootLog.bind($("#loading-overlay")).begin(); + // The frame loop must not consider the map loaded before the first snapshot + // has been mirrored, or the textures finishing first would lift the overlay. + this._worldMirrored = false; + bootLog.bind($("#loading-overlay")); + if (!bootLog.active) bootLog.begin(); } leave() { @@ -202,12 +210,38 @@ export class GameScreen { this._updateNationModal(); this._updateNewsModal(); if (this._loading) { - this._loading = false; + this._worldMirrored = true; bootLog.step("Updated the HUD"); - bootLog.finish(() => this._reportCapabilities(state)); + // The world is mirrored, but the GPU renderer may still be uploading its + // textures. Only lift the loading overlay once the map is actually ready; + // otherwise wait for the uploads, polling each frame. + if (this.map.isReady()) this._finishLoading(); + else this._awaitMapReady(); } } + _finishLoading() { + this._loading = false; + bootLog.finish(() => this._reportCapabilities(this.snapshot)); + } + + // The map is not ready yet: give the frame loop a deadline so a stuck texture + // cannot leave the loading overlay up forever. + _awaitMapReady() { + const now = typeof performance !== "undefined" ? performance.now() : Date.now(); + this._loadingDeadline = now + 20000; + bootLog.step("Waiting for the terrain textures"); + } + + _pollLoading() { + if (!this._loading || !this._worldMirrored) return; + const ready = this.map.isReady(); + const now = typeof performance !== "undefined" ? performance.now() : Date.now(); + if (!ready && now < this._loadingDeadline) return; + if (!ready) bootLog.step("Gave up waiting for the terrain textures"); + this._finishLoading(); + } + // When the GPU map renderer was not used, log and show the detailed list of // WebGL/WASM features this client is missing. The report is built only on the // fallback path (it probes a throwaway context) and shown at most once. @@ -423,6 +457,7 @@ export class GameScreen { const dt = Math.min(0.1, (now - this._lastFrame) / 1000); this._lastFrame = now; this.map.animate(dt); + if (this._loading) this._pollLoading(); this._fpsFrames += 1; this._fpsElapsed += dt; if (this._fpsElapsed >= 0.25) { diff --git a/client/js/map_view.js b/client/js/map_view.js index a3daa58..83268c3 100644 --- a/client/js/map_view.js +++ b/client/js/map_view.js @@ -249,6 +249,20 @@ export class MapView { if (!this.glRenderer) return; this.glRenderer.render(this, this.$viewport.width(), this.$viewport.height()); } + + // Whether the map is fully loaded and safe to reveal. The DOM renderer is + // ready as soon as its chunks are synced; the WebGL renderer waits until its + // terrain textures have finished uploading, so the loading screen does not + // lift onto a half-drawn map. + isReady() { + return !this.glRenderer || this.glRenderer.isReady(); + } + + // Texture-upload progress for the loading log, or null on the DOM renderer. + loadingProgress() { + if (!this.glRenderer) return null; + return { loaded: this.glRenderer.texLoaded, total: this.glRenderer.texTotal }; + } } Object.assign( diff --git a/client/js/map_view/webgl.js b/client/js/map_view/webgl.js index 08a5597..ceaaab9 100644 --- a/client/js/map_view/webgl.js +++ b/client/js/map_view/webgl.js @@ -371,6 +371,10 @@ export class GLMapRenderer { // One GL texture per source: the legacy atlas plus the land textures. this.textures = {}; this.texReady = false; + // How many terrain textures have finished uploading, out of the total the + // terrain shader samples. The loading log and the readiness gate read these. + this.texLoaded = 0; + this.texTotal = 0; // The per-tile distance-to-land field the water shader samples for its // coastal wavelets, built with the world. this.coastTexture = null; @@ -403,17 +407,19 @@ export class GLMapRenderer { this.fullscreenQuad = null; this.fogBlur = null; - this._initContext(); - this._initProgram(); - try { - this._initFullscreenProgram(); - } catch { - // Without the blur program the fog is still drawn, just without the - // post-process, so a picky driver does not cost us the whole map. - this.fullscreenProgram = null; - this.fullscreenQuad = null; - } - this._initTexture(); + bootLog.measure("Created the WebGL context", () => this._initContext()); + bootLog.measure("Compiled the map shaders", () => this._initProgram()); + bootLog.measure("Compiled the fog shaders", () => { + try { + this._initFullscreenProgram(); + } catch { + // Without the blur program the fog is still drawn, just without the + // post-process, so a picky driver does not cost us the whole map. + this.fullscreenProgram = null; + this.fullscreenQuad = null; + } + }); + bootLog.measure("Started loading the terrain textures", () => this._initTexture()); this._listenForContextLoss(); } @@ -684,24 +690,35 @@ export class GLMapRenderer { const gl = this.gl; this.textures = {}; this.texReady = false; + this.texLoaded = 0; + this.texTotal = 0; let pending = 0; - const loaded = () => { + const loaded = (name) => { pending -= 1; - if (pending > 0 || this.lost) return; + this.texLoaded += 1; + // A lost context is rebuilt wholesale, so these counters are thrown away; + // still count down so an onload racing the loss cannot wedge `pending`. + if (this.lost) return; + bootLog.step(`Uploaded the ${name} texture (${this.texLoaded}/${this.texTotal})`); + if (pending > 0) return; this.texReady = true; this.version += 1; this._sig = null; + bootLog.step("Terrain textures ready"); }; const load = (name, src, repeat) => { const texture = this._createTexture(repeat); this.textures[name] = texture; pending += 1; + this.texTotal += 1; const image = new Image(); image.onload = () => { - if (this.lost) return; - this._uploadImage(texture, image); - loaded(); + if (!this.lost) this._uploadImage(texture, image); + loaded(name); }; + // A texture that fails to load must not hold the loading screen open: the + // placeholder texel stays and the map still draws, just without its art. + image.onerror = () => loaded(name); image.src = src; }; load("atlas", "assets/terrain.png", false); @@ -723,6 +740,13 @@ export class GLMapRenderer { gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); } + // True once every terrain texture has been uploaded (or failed), so the map + // can be revealed instead of a blank canvas. A lost context is treated as + // ready so a crash cannot leave the loading screen open forever. + isReady() { + return this.texReady || this.lost; + } + _createTexture(repeat) { const gl = this.gl; const texture = gl.createTexture(); diff --git a/tests/loading_test.js b/tests/loading_test.js index 2e6329f..d4070b1 100644 --- a/tests/loading_test.js +++ b/tests/loading_test.js @@ -1,6 +1,9 @@ import { TestCase } from "./framework/test_case.js"; import { setupDom, teardownDom } from "./framework/dom.js"; import { LoadingLog } from "../client/js/loading.js"; +import { GameScreen } from "../client/js/game_screen.js"; +import { CIVILISATIONS } from "../shared/data.js"; +import { smallState } from "./framework/helpers.js"; export class LoadingTest extends TestCase { async test_steps_are_logged_with_their_timings() { @@ -43,4 +46,80 @@ export class LoadingTest extends TestCase { teardownDom(env); } } + + // The GPU renderer uploads its textures asynchronously, so the overlay must + // stay up until the map is actually ready instead of lifting on a blank map. + async test_loading_waits_for_the_map_before_finishing() { + const env = await setupDom(); + try { + const screen = new GameScreen(stubNetwork(), stubConfig()); + screen.enter(); + screen.beginLoading(); + this.assertTrue(screen.map.isReady(), "the DOM map is ready at once"); + this.assertNull(screen.map.loadingProgress(), "the DOM map reports no texture progress"); + + // Pretend the GPU renderer still has textures to upload. + screen.map.isReady = () => false; + screen.onState(smallState().snapshot(0)); + this.assertTrue(screen._loading, "the screen stays in the loading state"); + this.assertFalse( + env.$("#loading-overlay").hasClass("done"), + "the overlay has not started hiding" + ); + + // Once the map is ready, the next frame finishes the log. + screen.map.isReady = () => true; + screen._pollLoading(); + this.assertFalse(screen._loading, "the screen leaves the loading state"); + this.assertTrue(env.$("#loading-overlay").hasClass("done"), "the overlay starts hiding"); + } finally { + teardownDom(env); + } + } + + async test_loading_finishes_immediately_when_the_map_is_ready() { + const env = await setupDom(); + try { + const screen = new GameScreen(stubNetwork(), stubConfig()); + screen.enter(); + screen.beginLoading(); + screen.onState(smallState().snapshot(0)); + this.assertFalse(screen._loading, "the ready map finishes loading in one step"); + this.assertTrue(env.$("#loading-overlay").hasClass("done"), "the overlay starts hiding"); + } finally { + teardownDom(env); + } + } + + async test_loading_never_finishes_before_the_world_arrives() { + const env = await setupDom(); + try { + const screen = new GameScreen(stubNetwork(), stubConfig()); + screen.enter(); + screen.beginLoading(); + // The map is ready, but the snapshot has not been mirrored yet. + screen._pollLoading(); + this.assertTrue(screen._loading, "the frame loop keeps waiting for the world"); + this.assertFalse( + env.$("#loading-overlay").hasClass("done"), + "the overlay stays up until the world arrives" + ); + screen.onState(smallState().snapshot(0)); + this.assertFalse(screen._loading, "the world arrives and the map is revealed"); + } finally { + teardownDom(env); + } + } +} + +function stubNetwork() { + return { sendOrder() {}, sendGameSetup() {} }; +} + +function stubConfig() { + return { + playerName: "Carol", + playerCivId: "france", + civilisations: [CIVILISATIONS[0], CIVILISATIONS[1]], + }; }