Logged the WebGL setup and held the loading screen until the map is ready
This commit is contained in:
+5
-1
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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]],
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user