Logged the WebGL setup and held the loading screen until the map is ready

This commit is contained in:
2026-09-20 17:20:59 +02:00
parent bf4fc6d6a0
commit 84dd4473fd
5 changed files with 178 additions and 22 deletions
+5 -1
View File
@@ -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");
+40 -5
View File
@@ -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) {
+14
View File
@@ -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(
+40 -16
View File
@@ -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();
+79
View File
@@ -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]],
};
}