Added a capability report when the WebGL map renderer is unavailable

This commit is contained in:
2026-09-20 09:41:41 +02:00
parent 34e755d1a4
commit b29be13799
10 changed files with 911 additions and 18 deletions
+32
View File
@@ -864,6 +864,38 @@ input:focus, select:focus { border-color: var(--accent); }
.modal-close { padding: 4px 11px; } .modal-close { padding: 4px 11px; }
/* Capability report: one row per WebGL/WASM feature with a status badge. */
.capability-row {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.capability-status {
flex: 0 0 auto;
min-width: 68px;
text-align: center;
padding: 2px 6px;
border-radius: 6px;
font-size: 11px;
letter-spacing: 0.06em;
font-weight: 600;
background: rgba(110, 175, 255, 0.18);
color: var(--muted);
}
.capability-pass .capability-status { background: rgba(90, 200, 120, 0.2); color: #8fe0a3; }
.capability-fail .capability-status { background: rgba(220, 90, 90, 0.22); color: #ff9a9a; }
.capability-optional .capability-status { background: rgba(230, 180, 90, 0.2); color: #ffcf7a; }
.capability-info .capability-status { background: rgba(150, 160, 180, 0.18); color: var(--muted); }
.capability-label { font-weight: 600; }
.capability-detail { color: var(--muted); font-size: 12px; margin-top: 2px; }
.capability-requirements { margin: 0; padding-left: 40px; list-style: disc; }
.capability-requirements li { margin-bottom: 6px; color: var(--muted); font-size: 13px; }
/* Let the checks scroll while the card stays inside the viewport. */
#capabilities-list { flex: 1 1 auto; min-height: 0; }
#capabilities-min { flex: 0 0 auto; max-height: 28vh; }
.tabs { .tabs {
display: flex; display: flex;
/* Wrap rather than scroll: every tab stays visible with no clipped edge, /* Wrap rather than scroll: every tab stays visible with no clipped edge,
+18
View File
@@ -273,6 +273,24 @@
</div> </div>
</div> </div>
<!-- Graphics compatibility report: shown when the GPU (WebGL) map
renderer is unavailable and the DOM renderer takes over. -->
<div id="modal-capabilities" class="modal hidden">
<div class="modal-card">
<div class="modal-head">
<span id="capabilities-title">Graphics compatibility report</span>
<button class="modal-close aero">✕</button>
</div>
<div id="capabilities-summary" class="modal-sub"></div>
<div id="capabilities-list" class="modal-body"></div>
<div class="modal-sub">Minimum requirements for the GPU map renderer</div>
<ul id="capabilities-min" class="modal-body capability-requirements"></ul>
<div class="row">
<button id="capabilities-ok" class="aero primary">Continue</button>
</div>
</div>
</div>
<!-- Nation modal: government, technology, diplomacy and budget tabs --> <!-- Nation modal: government, technology, diplomacy and budget tabs -->
<div id="modal-nation" class="modal hidden"> <div id="modal-nation" class="modal hidden">
<div class="modal-card"> <div class="modal-card">
+470
View File
@@ -0,0 +1,470 @@
// Runtime capability report for the WebGL map renderer. The game itself only
// needs ordinary DOM APIs: when the GPU map fails to start, MapView falls back
// to the DOM renderer and play continues. This module explains *why* the GPU
// path is unavailable. It probes every WebGL feature `map_view/webgl.js` uses
// (the context, the limits the vertex/fragment programs rely on, the exact
// shader programs, the off-screen fog buffer and the two optional extensions)
// and confirms that the game does not need WebAssembly at all, so a player
// whose map fell back to the DOM renderer gets a concrete list of what is
// missing instead of a silent downgrade.
//
// It never throws: any environment without a DOM (the Node test suite) or
// without WebGL is reported as unavailable rather than raising. The probe
// deliberately does not call canvas.getContext in environments that have no
// WebGL constructors (jsdom), because those would log a not-implemented warning.
import {
CONTEXT_ATTRIBUTES,
FOG_FRAGMENT_SHADER,
FOG_VERTEX_SHADER,
MAP_FRAGMENT_SHADER,
MAP_VERTEX_SHADER,
} from "./map_view/webgl.js";
// The terrain program binds these fragment texture units in one draw: the atlas,
// grass, sand, forest, mountain, mountain normal, coast and water normal.
export const NEEDED_TEXTURE_UNITS = 8;
// The terrain vertex layout uses attribute locations 0..6.
export const NEEDED_VERTEX_ATTRIBS = 7;
// The shipped terrain atlas is 441 px wide, the water normal map 256 px.
export const ATLAS_TEXTURE_SIZE = 441;
export const WATER_NORMAL_TEXTURE_SIZE = 256;
// The documented floor for the GPU path. The DOM renderer runs everywhere, so
// these are the requirements for the *enhanced* renderer, not to play at all.
export const MINIMUM_REQUIREMENTS = [
"A browser with JavaScript and either WebGL 2.0 or WebGL 1.0 (WebGL 2.0 recommended).",
`At least ${NEEDED_TEXTURE_UNITS} fragment texture units and ${NEEDED_VERTEX_ATTRIBS} vertex attributes (both are inside the WebGL 1.0 guaranteed minimum).`,
`A maximum texture size of at least ${ATLAS_TEXTURE_SIZE} px for the default map (one texel per tile is needed for larger maps).`,
"No WebGL extensions are required; WEBGL_debug_renderer_info and WEBGL_lose_context are optional.",
"No WebAssembly (WASM) support is required; the game contains no WASM.",
"Hardware-accelerated WebGL recommended; a software rasteriser works but the game prefers the DOM renderer.",
];
// One row of the report. `ok` is null when a check could not be evaluated.
function check(id, label, level, ok, need, value, detail) {
return { id, label, level, ok, need, value, detail };
}
export function detectWebAssembly() {
const out = { available: false, validate: false, error: null };
if (typeof WebAssembly === "undefined") {
out.error = "WebAssembly is not defined in this browser.";
return out;
}
out.available = typeof WebAssembly === "object" && WebAssembly !== null;
try {
if (typeof WebAssembly.validate === "function") {
// The eight-byte empty module: a cheap proof the engine can validate WASM.
out.validate = WebAssembly.validate(new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0]));
}
} catch (error) {
out.error = String((error && error.message) || error);
}
return out;
}
function createProbeContext(canvas) {
const out = { gl: null, version: null, owned: false, error: null };
if (typeof window === "undefined" || typeof document === "undefined") {
out.error = "Not running in a browser (no window/document).";
return out;
}
const has2 = typeof window.WebGL2RenderingContext !== "undefined";
const has1 = typeof window.WebGLRenderingContext !== "undefined";
if (!has2 && !has1) {
out.error = "The browser does not expose WebGL (WebGLRenderingContext is undefined).";
return out;
}
const element = canvas || document.createElement("canvas");
out.owned = !canvas;
try {
if (has2) {
const gl2 = element.getContext("webgl2", CONTEXT_ATTRIBUTES);
if (gl2) {
out.gl = gl2;
out.version = 2;
return out;
}
}
const gl1 = has1
? element.getContext("webgl", CONTEXT_ATTRIBUTES) ||
element.getContext("experimental-webgl", CONTEXT_ATTRIBUTES)
: null;
if (gl1) {
out.gl = gl1;
out.version = 1;
return out;
}
out.error = "getContext returned null for webgl2, webgl and experimental-webgl.";
} catch (error) {
out.error = `getContext threw: ${(error && error.message) || error}`;
}
return out;
}
function glInfo(gl) {
const info = { vendor: "unknown", renderer: "unknown" };
try {
const debug = gl.getExtension("WEBGL_debug_renderer_info");
info.vendor = String(gl.getParameter(debug ? debug.UNMASKED_VENDOR_WEBGL : gl.VENDOR) || "unknown");
info.renderer = String(gl.getParameter(debug ? debug.UNMASKED_RENDERER_WEBGL : gl.RENDERER) || "unknown");
} catch {
// Some locked-down drivers refuse the query; the fallback labels stay.
}
return info;
}
// The CPU rasterisers known to be slower than the DOM renderer.
export function isSoftwareRenderer(renderer) {
return /swiftshader|llvmpipe|softpipe|software|mesa offscreen|basic render/i.test(String(renderer || ""));
}
function trimLog(log, phase) {
const text = String(log || "").replace(/\s+/g, " ").trim();
return text ? `${phase}: ${text}` : `${phase} failed`;
}
// Compiles and links one program, then frees every GL object it made. A mock
// context in tests can implement just the compile/link surface.
function probeProgram(gl, vertexSource, fragmentSource) {
let vertex = null;
let fragment = null;
let program = null;
try {
vertex = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertex, vertexSource);
gl.compileShader(vertex);
if (!gl.getShaderParameter(vertex, gl.COMPILE_STATUS)) {
return { ok: false, detail: trimLog(gl.getShaderInfoLog(vertex), "vertex shader") };
}
fragment = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragment, fragmentSource);
gl.compileShader(fragment);
if (!gl.getShaderParameter(fragment, gl.COMPILE_STATUS)) {
return { ok: false, detail: trimLog(gl.getShaderInfoLog(fragment), "fragment shader") };
}
program = gl.createProgram();
gl.attachShader(program, vertex);
gl.attachShader(program, fragment);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
return { ok: false, detail: trimLog(gl.getProgramInfoLog(program), "link") };
}
return { ok: true, detail: "compiled and linked" };
} catch (error) {
return { ok: false, detail: `threw: ${(error && error.message) || error}` };
} finally {
if (program) gl.deleteProgram(program);
if (vertex) gl.deleteShader(vertex);
if (fragment) gl.deleteShader(fragment);
}
}
function probeFramebuffer(gl) {
let texture = null;
let fbo = null;
try {
texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 4, 4, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
fbo = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
return {
ok: status === gl.FRAMEBUFFER_COMPLETE,
detail: status === gl.FRAMEBUFFER_COMPLETE
? "RGBA render target is complete"
: `framebuffer status 0x${Number(status).toString(16)}`,
};
} catch (error) {
return { ok: false, detail: `threw: ${(error && error.message) || error}` };
} finally {
try {
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
if (fbo) gl.deleteFramebuffer(fbo);
if (texture) gl.deleteTexture(texture);
} catch {
// A context torn down underneath us: nothing left to free.
}
}
}
function parameter(gl, name) {
try {
return Number(gl.getParameter(gl[name])) || 0;
} catch {
return 0;
}
}
function hasExtension(gl, name) {
try {
return !!gl.getExtension(name);
} catch {
return false;
}
}
function requiredTextureSize(mapSize) {
const tiles = mapSize
? Math.max(Number(mapSize.x) || 0, Number(mapSize.y) || 0)
: 0;
return Math.max(ATLAS_TEXTURE_SIZE, WATER_NORMAL_TEXTURE_SIZE, tiles);
}
// Builds the report. `options.gl`/`options.version` let tests (and callers that
// already hold a context) skip context creation; `options.mapSize` raises the
// texture-size requirement to the map's one-texel-per-tile coast field.
export function detectCapabilities(options = {}) {
const mapSize = options.mapSize || null;
const report = {
ok: false,
renderer: "dom",
reason: "",
wasm: detectWebAssembly(),
webgl: {
available: false,
version: null,
name: null,
error: null,
info: null,
software: false,
},
checks: [],
minRequirements: MINIMUM_REQUIREMENTS.slice(),
};
let gl = options.gl || null;
let version = options.version || null;
let owned = false;
if (!gl) {
const probe = createProbeContext(options.canvas);
gl = probe.gl;
version = probe.version;
owned = probe.owned;
report.webgl.error = probe.error;
}
if (!gl) {
report.checks.push(check(
"webgl-context",
"WebGL context",
"required",
false,
"a webgl2, webgl or experimental-webgl context",
"none",
report.webgl.error || "The browser did not provide a WebGL context."
));
report.checks.push(check(
"webassembly",
"WebAssembly (WASM)",
"info",
report.wasm.available,
"not required",
report.wasm.available ? "available" : "unavailable",
"The game does not use WebAssembly; this is informational only."
));
report.reason = "WebGL is unavailable, so the map uses the DOM compatibility renderer.";
return report;
}
report.webgl.available = true;
report.webgl.version = version || 1;
report.webgl.name = report.webgl.version === 2 ? "WebGL 2" : "WebGL 1";
report.webgl.info = glInfo(gl);
report.webgl.software = isSoftwareRenderer(report.webgl.info.renderer);
const checks = [];
checks.push(check(
"webgl-context",
"WebGL context",
"required",
true,
"a webgl2, webgl or experimental-webgl context",
report.webgl.name,
`${report.webgl.info.vendor} / ${report.webgl.info.renderer}`
));
const units = parameter(gl, "MAX_TEXTURE_IMAGE_UNITS");
checks.push(check(
"texture-units",
"Fragment texture units",
"required",
units >= NEEDED_TEXTURE_UNITS,
`${NEEDED_TEXTURE_UNITS} (terrain samplers)`,
String(units),
units >= NEEDED_TEXTURE_UNITS
? "Enough samplers for the blended terrain shader."
: "Too few samplers for the blended terrain shader."
));
const attribs = parameter(gl, "MAX_VERTEX_ATTRIBS");
checks.push(check(
"vertex-attribs",
"Vertex attributes",
"required",
attribs >= NEEDED_VERTEX_ATTRIBS,
`${NEEDED_VERTEX_ATTRIBS} (terrain vertex layout)`,
String(attribs),
attribs >= NEEDED_VERTEX_ATTRIBS
? "Enough attribute slots for the terrain mesh."
: "Too few attribute slots for the terrain mesh."
));
const neededSize = requiredTextureSize(mapSize);
const maxSize = parameter(gl, "MAX_TEXTURE_SIZE");
checks.push(check(
"texture-size",
"Maximum texture size",
"required",
maxSize >= neededSize,
`${neededSize} px${mapSize ? " (map/atlas)" : " (atlas)"}`,
`${maxSize} px`,
maxSize >= neededSize
? "The terrain atlas, normal maps and coast field all fit."
: "The terrain atlas or coast field will not upload."
));
const terrain = probeProgram(gl, MAP_VERTEX_SHADER, MAP_FRAGMENT_SHADER);
checks.push(check(
"terrain-program",
"Terrain shader program (GLSL ES 1.00)",
"required",
terrain.ok,
"the renderer's terrain program",
terrain.ok ? "compiled" : "failed",
terrain.detail
));
const fog = probeProgram(gl, FOG_VERTEX_SHADER, FOG_FRAGMENT_SHADER);
checks.push(check(
"fog-program",
"Fog post-process program",
"optional",
fog.ok,
"the renderer's fog blur program",
fog.ok ? "compiled" : "failed",
fog.ok ? "The fog of war gets its soft blurred edge." : fog.detail
));
const fbo = probeFramebuffer(gl);
checks.push(check(
"framebuffer",
"Off-screen fog buffer (RGBA)",
"optional",
fbo.ok,
"a complete RGBA color attachment",
fbo.ok ? "complete" : "unavailable",
fbo.ok ? "The fog of war is blurred off-screen before compositing." : fbo.detail
));
const debugInfo = hasExtension(gl, "WEBGL_debug_renderer_info");
checks.push(check(
"debug-renderer-info",
"WEBGL_debug_renderer_info extension",
"optional",
debugInfo,
"not required",
debugInfo ? "present" : "absent",
debugInfo ? "Real GPU name is reported." : "The generic vendor/renderer is reported instead."
));
const loseContext = hasExtension(gl, "WEBGL_lose_context");
checks.push(check(
"lose-context",
"WEBGL_lose_context extension",
"optional",
loseContext,
"not required",
loseContext ? "present" : "absent",
loseContext ? "Contexts can be released on restart." : "Restarting leaves the context to the browser."
));
checks.push(check(
"webassembly",
"WebAssembly (WASM)",
"info",
report.wasm.available,
"not required",
report.wasm.available ? "available" : "unavailable",
"The game does not use WebAssembly; this is informational only."
));
report.checks = checks;
report.ok = checks.every((entry) => entry.level !== "required" || entry.ok);
report.renderer = report.ok ? (report.webgl.version === 2 ? "webgl2" : "webgl") : "dom";
if (!report.ok) {
report.reason = "WebGL is present but a required feature is missing, so the map uses the DOM compatibility renderer.";
} else if (report.webgl.software) {
report.reason = "WebGL is only available through a software rasteriser, so the map uses the DOM compatibility renderer.";
} else {
report.reason = "WebGL is fully supported.";
}
if (owned) {
try {
const lose = gl.getExtension("WEBGL_lose_context");
if (lose) lose.loseContext();
} catch {
// The probe context is disposable either way.
}
}
return report;
}
export function capabilityRendererLabel(renderer) {
if (renderer === "webgl2") return "WebGL 2 (GPU)";
if (renderer === "webgl") return "WebGL 1 (GPU)";
return "DOM (compatibility mode)";
}
// PASS / FAIL / OPTIONAL / INFO, for a colour-coded row.
export function capabilityStatus(entry) {
if (entry.level === "info") return "INFO";
if (entry.ok) return "PASS";
if (entry.level === "required") return "FAIL";
return "OPTIONAL";
}
export function summarizeCapabilityReport(report) {
if (!report.webgl.available) {
return "No WebGL context could be created, so the map is drawn with the DOM "
+ "compatibility renderer. The game is fully playable; only the GPU map is off.";
}
if (!report.ok) {
return "WebGL is present but is missing a required feature, so the map is drawn "
+ "with the DOM compatibility renderer. The game is fully playable; only the GPU map is off.";
}
if (report.webgl.software) {
return "WebGL is only available through a software rasteriser, so the map is drawn "
+ "with the DOM compatibility renderer for a steady frame rate.";
}
return "WebGL is fully supported.";
}
// A plain-text report for the developer console (and for tests).
export function formatCapabilityReport(report) {
const lines = [];
lines.push("Battle for 'Tismo — client capability report");
lines.push(summarizeCapabilityReport(report));
lines.push(`GPU map renderer: ${capabilityRendererLabel(report.renderer)}`);
lines.push(`WebGL: ${report.webgl.available ? report.webgl.name : "unavailable"}`);
if (report.webgl.info) {
lines.push(`GPU: ${report.webgl.info.vendor} / ${report.webgl.info.renderer}` +
(report.webgl.software ? " (software rasteriser)" : ""));
}
lines.push(`WebAssembly: ${report.wasm.available ? "available" : "unavailable"} (not used by the game)`);
lines.push("");
lines.push("Features used by the GPU map:");
for (const entry of report.checks) {
lines.push(` [${capabilityStatus(entry)}] ${entry.label}: need ${entry.need}; have ${entry.value}`);
if (!entry.ok && entry.detail) lines.push(` ${entry.detail}`);
}
lines.push("");
lines.push("Minimum requirements for the GPU map renderer:");
for (const requirement of report.minRequirements) lines.push(` - ${requirement}`);
return lines.join("\n");
}
+24 -2
View File
@@ -8,10 +8,11 @@
// snapshot plumbing and the order flow. // snapshot plumbing and the order flow.
import { MapView } from "./map_view.js"; import { MapView } from "./map_view.js";
import { CityModal, NationModal, ConfirmModal, NewsModal, MapModesModal } from "./modals.js"; import { CityModal, NationModal, ConfirmModal, NewsModal, MapModesModal, CapabilityModal } from "./modals.js";
import { GameFeed } from "./feed.js"; import { GameFeed } from "./feed.js";
import { CIVILISATIONS, PROTO_UNITS } from "../../shared/data.js"; import { CIVILISATIONS, PROTO_UNITS } from "../../shared/data.js";
import { bootLog } from "./loading.js"; import { bootLog } from "./loading.js";
import { formatCapabilityReport } from "./capabilities.js";
import { panelMethods } from "./game_screen/panels.js"; import { panelMethods } from "./game_screen/panels.js";
export class GameScreen { export class GameScreen {
@@ -48,6 +49,9 @@ export class GameScreen {
this._news = []; this._news = [];
this._seenNews = null; this._seenNews = null;
this._entered = false; this._entered = false;
// The capability report is shown at most once per join, when the GPU map
// renderer was not used (see `_reportCapabilities`).
this._capabilitiesReported = false;
this._lastFrame = 0; this._lastFrame = 0;
this._fpsFrames = 0; this._fpsFrames = 0;
this._fpsElapsed = 0; this._fpsElapsed = 0;
@@ -72,6 +76,7 @@ export class GameScreen {
this.confirmModal = new ConfirmModal(); this.confirmModal = new ConfirmModal();
this.newsModal = new NewsModal(); this.newsModal = new NewsModal();
this.mapModesModal = new MapModesModal(); this.mapModesModal = new MapModesModal();
this.capabilityModal = new CapabilityModal();
this.feed = new GameFeed($("#game-feed")); this.feed = new GameFeed($("#game-feed"));
this.$stackMenu = $("#stack-menu"); this.$stackMenu = $("#stack-menu");
this.$statTooltip = $("#stat-tooltip"); this.$statTooltip = $("#stat-tooltip");
@@ -199,10 +204,27 @@ export class GameScreen {
if (this._loading) { if (this._loading) {
this._loading = false; this._loading = false;
bootLog.step("Updated the HUD"); bootLog.step("Updated the HUD");
bootLog.finish(); bootLog.finish(() => this._reportCapabilities(state));
} }
} }
// 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.
_reportCapabilities(state) {
if (this._capabilitiesReported || !this._entered) return;
// A forced "dom" mode (tests, benchmark) is a deliberate choice, not a
// compatibility problem, so it is never reported.
if (this.map.rendererMode === "dom" || this.map.glRenderer) return;
this._capabilitiesReported = true;
const mapSize = state && state.mapConfig ? state.mapConfig.mapSize : null;
const report = this.map.capabilityReport(mapSize);
if (typeof console !== "undefined" && console.warn) {
console.warn(formatCapabilityReport(report));
}
this.capabilityModal.show(report);
}
_bindMap() { _bindMap() {
this.map.onUnitSelected = (id) => { this.map.onUnitSelected = (id) => {
if (!id) { if (!id) {
+13 -3
View File
@@ -55,23 +55,33 @@ export class LoadingLog {
} }
} }
finish() { // `onHidden` runs once the overlay has actually left the screen, so callers
if (!this.active) return; // can show something (the capability report) without it landing behind the
// fading loading panel. It fires at once when the log was never bound or was
// already finished.
finish(onHidden) {
if (!this.active) {
if (onHidden) onHidden();
return;
}
this.step("Ready"); this.step("Ready");
this._line(now() - this.startedAt, "Total map setup", true); this._line(now() - this.startedAt, "Total map setup", true);
this.active = false; this.active = false;
const notify = () => { if (onHidden) onHidden(); };
if (this.$root) { if (this.$root) {
const $root = this.$root; const $root = this.$root;
$root.addClass("done"); $root.addClass("done");
// Fade out after a beat so the last lines can be read. Hiding is deferred // Fade out after a beat so the last lines can be read. Hiding is deferred
// through requestAnimationFrame, which the test harness stubs out, so a // through requestAnimationFrame, which the test harness stubs out, so a
// finished log never touches the DOM after the test window is closed. // finished log never touches the DOM after the test window is closed.
const hide = () => $root.addClass("hidden"); const hide = () => { $root.addClass("hidden"); notify(); };
if (typeof requestAnimationFrame === "function") { if (typeof requestAnimationFrame === "function") {
setTimeout(() => requestAnimationFrame(hide), 2500); setTimeout(() => requestAnimationFrame(hide), 2500);
} else { } else {
hide(); hide();
} }
} else {
notify();
} }
} }
+22
View File
@@ -15,6 +15,7 @@ import { inputMethods } from "./map_view/input.js";
import { politicalMethods } from "./map_view/political.js"; import { politicalMethods } from "./map_view/political.js";
import { modeMethods } from "./map_view/modes.js"; import { modeMethods } from "./map_view/modes.js";
import { createGLMapRenderer } from "./map_view/webgl.js"; import { createGLMapRenderer } from "./map_view/webgl.js";
import { detectCapabilities } from "./capabilities.js";
import { CAMERA_TILT } from "./map_view/constants.js"; import { CAMERA_TILT } from "./map_view/constants.js";
// Re-exported for tests and callers that use the border geometry directly. // Re-exported for tests and callers that use the border geometry directly.
@@ -190,6 +191,12 @@ export class MapView {
this.glRenderer = null; this.glRenderer = null;
this.glCanvas = null; this.glCanvas = null;
this.rendererMode = options.renderer || "auto"; this.rendererMode = options.renderer || "auto";
// Set when the GPU renderer is not used: "webgl-unavailable" (no context, a
// shader/limit failure) or "software" (a CPU rasteriser, slower than DOM).
this.rendererFallback = null;
// Lazily built capability report for the fallback notice (see
// `capabilityReport`); null until asked for.
this.capabilities = null;
if (this.rendererMode !== "dom") this._setupGLRenderer(); if (this.rendererMode !== "dom") this._setupGLRenderer();
this._setupCameraInput(); this._setupCameraInput();
@@ -207,6 +214,7 @@ export class MapView {
const renderer = createGLMapRenderer(canvas); const renderer = createGLMapRenderer(canvas);
if (!renderer) { if (!renderer) {
if (canvas.parentNode) canvas.parentNode.removeChild(canvas); if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
this.rendererFallback = "webgl-unavailable";
return; return;
} }
// A CPU rasteriser (no usable GPU) can be slower than the DOM renderer. // A CPU rasteriser (no usable GPU) can be slower than the DOM renderer.
@@ -214,6 +222,7 @@ export class MapView {
if (this.rendererMode === "auto" && renderer.softwareRenderer()) { if (this.rendererMode === "auto" && renderer.softwareRenderer()) {
renderer.dispose(); renderer.dispose();
if (canvas.parentNode) canvas.parentNode.removeChild(canvas); if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
this.rendererFallback = "software";
return; return;
} }
this.glRenderer = renderer; this.glRenderer = renderer;
@@ -221,6 +230,19 @@ export class MapView {
this.$world.addClass("gl-render"); this.$world.addClass("gl-render");
} }
// A detailed, cached report of every WebGL/WASM feature this client needs.
// Only meaningful when the GPU renderer was not used; it probes a throwaway
// context, so it is built on demand (when the fallback notice appears) rather
// than in the constructor. `mapSize` sharpens the texture-size requirement to
// the map being played; it falls back to the world already built, if any.
capabilityReport(mapSize) {
if (!this.capabilities) {
const size = mapSize || (this.mapConfig && this.mapConfig.mapSize) || null;
this.capabilities = detectCapabilities({ mapSize: size });
}
return this.capabilities;
}
// Draws the map canvas from the current view state, if WebGL is active. Safe // Draws the map canvas from the current view state, if WebGL is active. Safe
// to call every frame: the renderer skips untouched camera/content state. // to call every frame: the renderer skips untouched camera/content state.
_glRender() { _glRender() {
+26 -13
View File
@@ -87,6 +87,20 @@ const FOG_FLOATS = 7;
// A flat sample, used when a view has no relief field (tests, stub views). // A flat sample, used when a view has no relief field (tests, stub views).
const ZERO_SAMPLE = { h: 0, dx: 0, dy: 0 }; const ZERO_SAMPLE = { h: 0, dx: 0, dy: 0 };
// The context attributes every map context is created with. Kept here so the
// capability probe (client/js/capabilities.js) requests exactly the same context
// the renderer would, instead of a subtly different one.
export const CONTEXT_ATTRIBUTES = {
alpha: true,
antialias: false,
depth: false,
stencil: false,
premultipliedAlpha: false,
preserveDrawingBuffer: false,
powerPreference: "high-performance",
failIfMajorPerformanceCaveat: false,
};
const ROAD = [138 / 255, 90 / 255, 43 / 255, 1]; const ROAD = [138 / 255, 90 / 255, 43 / 255, 1];
// Unclaimed land on the political map, a flat muted olive. Painting it here // Unclaimed land on the political map, a flat muted olive. Painting it here
// lets the political/economic modes skip the textured terrain mesh entirely. // lets the political/economic modes skip the textured terrain mesh entirely.
@@ -317,6 +331,15 @@ void main() {
// spreads over about twenty-four. // spreads over about twenty-four.
const FOG_BLUR_STEP = 3.0; const FOG_BLUR_STEP = 3.0;
// The exact programs the renderer compiles, exported so the capability probe can
// test GLSL ES support against the real shaders rather than a stand-in.
export {
VERTEX_SHADER as MAP_VERTEX_SHADER,
FRAGMENT_SHADER as MAP_FRAGMENT_SHADER,
FS_VERTEX_SHADER as FOG_VERTEX_SHADER,
FS_FRAGMENT_SHADER as FOG_FRAGMENT_SHADER,
};
// Builds a renderer on the given canvas, or null when no WebGL context can be // Builds a renderer on the given canvas, or null when no WebGL context can be
// created. The availability check is done without touching canvas.getContext in // created. The availability check is done without touching canvas.getContext in
// environments (jsdom) that would throw and log. // environments (jsdom) that would throw and log.
@@ -392,20 +415,10 @@ export class GLMapRenderer {
} }
_initContext() { _initContext() {
const attributes = {
alpha: true,
antialias: false,
depth: false,
stencil: false,
premultipliedAlpha: false,
preserveDrawingBuffer: false,
powerPreference: "high-performance",
failIfMajorPerformanceCaveat: false,
};
const gl = const gl =
this.canvas.getContext("webgl2", attributes) || this.canvas.getContext("webgl2", CONTEXT_ATTRIBUTES) ||
this.canvas.getContext("webgl", attributes) || this.canvas.getContext("webgl", CONTEXT_ATTRIBUTES) ||
this.canvas.getContext("experimental-webgl", attributes); this.canvas.getContext("experimental-webgl", CONTEXT_ATTRIBUTES);
if (!gl) throw new Error("no WebGL context"); if (!gl) throw new Error("no WebGL context");
this.gl = gl; this.gl = gl;
this.isWebGL2 = typeof window !== "undefined" && this.isWebGL2 = typeof window !== "undefined" &&
+1
View File
@@ -11,4 +11,5 @@ export { NationModal } from "./modals/nation.js";
export { ConfirmModal } from "./modals/confirm.js"; export { ConfirmModal } from "./modals/confirm.js";
export { NewsModal } from "./modals/news.js"; export { NewsModal } from "./modals/news.js";
export { MapModesModal } from "./modals/map_modes.js"; export { MapModesModal } from "./modals/map_modes.js";
export { CapabilityModal } from "./modals/capabilities.js";
export { describeNews } from "./modals/format.js"; export { describeNews } from "./modals/format.js";
+49
View File
@@ -0,0 +1,49 @@
// The graphics compatibility report. Shown when the GPU map renderer could not
// be used, so the player sees exactly which WebGL (and WebAssembly) features
// this client is missing rather than a silent downgrade to the DOM renderer.
// The report itself is built by `client/js/capabilities.js`.
import { capabilityStatus, summarizeCapabilityReport } from "../capabilities.js";
export class CapabilityModal {
constructor() {
this.$modal = $("#modal-capabilities");
this.$summary = $("#capabilities-summary");
this.$list = $("#capabilities-list");
this.$min = $("#capabilities-min");
this.$modal.find(".modal-close").on("click", () => this.close());
this.$modal.find("#capabilities-ok").on("click", () => this.close());
}
isShowing() {
return !this.$modal.hasClass("hidden");
}
show(report) {
this.$summary.text(summarizeCapabilityReport(report));
this.$list.empty();
for (const entry of report.checks) this.$list.append(this._row(entry));
this.$min.empty();
for (const requirement of report.minRequirements) {
this.$min.append($("<li></li>").text(requirement));
}
this.$modal.removeClass("hidden");
}
close() {
this.$modal.addClass("hidden");
}
_row(entry) {
const status = capabilityStatus(entry);
let detail = `Need ${entry.need}; have ${entry.value}`;
if (entry.detail) detail += ` — ${entry.detail}`;
const $row = $("<div></div>").addClass(`capability-row capability-${status.toLowerCase()}`);
$row.append($("<span></span>").addClass("capability-status").text(status));
const $text = $("<div></div>").addClass("capability-text");
$text.append($("<div></div>").addClass("capability-label").text(entry.label));
$text.append($("<div></div>").addClass("capability-detail").text(detail));
$row.append($text);
return $row;
}
}
+256
View File
@@ -0,0 +1,256 @@
import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom } from "./framework/dom.js";
import { GameScreen } from "../client/js/game_screen.js";
import { CIVILISATIONS } from "../shared/data.js";
import { smallState } from "./framework/helpers.js";
import {
NEEDED_TEXTURE_UNITS,
NEEDED_VERTEX_ATTRIBS,
capabilityStatus,
detectCapabilities,
detectWebAssembly,
formatCapabilityReport,
summarizeCapabilityReport,
} from "../client/js/capabilities.js";
import { CapabilityModal } from "../client/js/modals/capabilities.js";
// A stand-in WebGL context that answers exactly the surface `detectCapabilities`
// probes, so the checks can be exercised without a GPU.
function createMockGL(overrides = {}) {
const constants = {
VERTEX_SHADER: 0x8b31,
FRAGMENT_SHADER: 0x8b30,
COMPILE_STATUS: 0x8b81,
LINK_STATUS: 0x8b82,
MAX_TEXTURE_IMAGE_UNITS: 0x8872,
MAX_VERTEX_ATTRIBS: 0x8869,
MAX_TEXTURE_SIZE: 0x0d33,
VENDOR: 0x1f00,
RENDERER: 0x1f01,
TEXTURE_2D: 0x0de1,
RGBA: 0x1908,
UNSIGNED_BYTE: 0x1401,
FRAMEBUFFER: 0x8d40,
COLOR_ATTACHMENT0: 0x8ce0,
FRAMEBUFFER_COMPLETE: 0x8cd5,
};
const parameters = {
[constants.MAX_TEXTURE_IMAGE_UNITS]: overrides.textureUnits ?? 16,
[constants.MAX_VERTEX_ATTRIBS]: overrides.vertexAttribs ?? 16,
[constants.MAX_TEXTURE_SIZE]: overrides.maxTextureSize ?? 8192,
[constants.VENDOR]: overrides.vendor ?? "Test Vendor",
[constants.RENDERER]: overrides.renderer ?? "Test GPU",
};
const extensions = overrides.extensions || {};
const compileOk = !overrides.compileFails;
const gl = {
...constants,
getParameter: (name) => (name in parameters ? parameters[name] : null),
getExtension: (name) => extensions[name] || null,
createShader: () => ({ shader: true }),
shaderSource: () => {},
compileShader: () => {},
getShaderParameter: () => compileOk,
getShaderInfoLog: () => (compileOk ? "" : "ERROR: 0:1: sampler limit exceeded"),
deleteShader: () => {},
createProgram: () => ({ program: true }),
attachShader: () => {},
linkProgram: () => {},
getProgramParameter: () => true,
getProgramInfoLog: () => "",
deleteProgram: () => {},
createTexture: () => ({ texture: true }),
bindTexture: () => {},
texImage2D: () => {},
deleteTexture: () => {},
createFramebuffer: () => ({ fbo: true }),
bindFramebuffer: () => {},
framebufferTexture2D: () => {},
checkFramebufferStatus: () => constants.FRAMEBUFFER_COMPLETE,
deleteFramebuffer: () => {},
};
if (overrides.framebufferFails) gl.checkFramebufferStatus = () => 0x8cd6;
return gl;
}
function checkById(report, id) {
return report.checks.find((entry) => entry.id === id);
}
export class CapabilitiesTest extends TestCase {
async test_missing_context_reports_the_dom_fallback_and_requirements() {
const env = await setupDom();
try {
const report = detectCapabilities();
this.assertFalse(report.webgl.available, "WebGL is reported unavailable");
this.assertEqual(report.renderer, "dom", "the DOM renderer is the verdict");
this.assertFalse(checkById(report, "webgl-context").ok, "the context check fails");
this.assertFalse(report.ok, "a missing context is not a supported report");
this.assertNotEmpty(report.minRequirements, "the requirements are listed");
this.assertTrue(
report.reason.includes("WebGL"),
"the reason names the missing GPU path"
);
const text = formatCapabilityReport(report);
this.assertTrue(text.includes("WebAssembly"), "the report mentions WebAssembly");
this.assertTrue(text.includes("Minimum requirements"), "the report lists the minimums");
} finally {
teardownDom(env);
}
}
async test_supported_context_passes_every_required_check() {
const report = detectCapabilities({ gl: createMockGL(), version: 2 });
this.assertTrue(report.webgl.available, "the context is available");
this.assertEqual(report.webgl.name, "WebGL 2", "the version is reported");
this.assertTrue(report.ok, "all required checks pass");
this.assertEqual(report.renderer, "webgl2", "the GPU verdict is used");
for (const entry of report.checks) {
if (entry.level === "required") this.assertTrue(entry.ok, `${entry.id} should pass`);
}
}
async test_too_few_texture_units_makes_a_required_check_fail() {
const report = detectCapabilities({
gl: createMockGL({ textureUnits: NEEDED_TEXTURE_UNITS - 1 }),
version: 1,
});
const units = checkById(report, "texture-units");
this.assertFalse(units.ok, "the texture-unit check fails");
this.assertFalse(report.ok, "the report is not supported");
this.assertEqual(report.renderer, "dom", "the DOM renderer is the verdict");
this.assertEqual(capabilityStatus(units), "FAIL", "the row is marked FAIL");
}
async test_too_few_vertex_attributes_fails() {
const report = detectCapabilities({
gl: createMockGL({ vertexAttribs: NEEDED_VERTEX_ATTRIBS - 1 }),
version: 1,
});
this.assertFalse(checkById(report, "vertex-attribs").ok, "the attribute check fails");
this.assertFalse(report.ok, "the report is not supported");
}
async test_shader_compile_failure_is_reported_in_the_detail() {
const report = detectCapabilities({ gl: createMockGL({ compileFails: true }), version: 1 });
const shader = checkById(report, "terrain-program");
this.assertFalse(shader.ok, "the terrain program check fails");
this.assertTrue(shader.detail.includes("sampler limit"), "the compiler log is surfaced");
this.assertFalse(report.ok, "a broken shader blocks the GPU path");
}
async test_software_rasteriser_is_flagged_but_still_passes_required_checks() {
const report = detectCapabilities({
gl: createMockGL({ renderer: "SwiftShader Device (Subzero)" }),
version: 1,
});
this.assertTrue(report.webgl.software, "the CPU rasteriser is detected");
this.assertTrue(report.ok, "the required checks still pass");
this.assertTrue(
summarizeCapabilityReport(report).includes("software"),
"the summary explains the downgrade"
);
}
async test_optional_failures_do_not_block_the_gpu_path() {
const report = detectCapabilities({
gl: createMockGL({ extensions: {}, framebufferFails: true, compileFails: false }),
version: 1,
});
this.assertFalse(checkById(report, "framebuffer").ok, "the optional buffer check can fail");
this.assertEqual(capabilityStatus(checkById(report, "framebuffer")), "OPTIONAL", "it is optional");
this.assertTrue(report.ok, "optional gaps keep the GPU path");
}
async test_wasm_is_reported_as_informational_only() {
const wasm = detectWebAssembly();
this.assertTrue(wasm.available, "Node/browsers expose WebAssembly");
const report = detectCapabilities({ gl: createMockGL(), version: 1 });
const entry = checkById(report, "webassembly");
this.assertEqual(entry.level, "info", "WASM is informational, not required");
this.assertEqual(capabilityStatus(entry), "INFO", "and it is never marked FAIL");
}
async test_format_lists_every_check_and_requirement() {
const report = detectCapabilities({ gl: createMockGL(), version: 2 });
const text = formatCapabilityReport(report);
for (const entry of report.checks) {
this.assertTrue(text.includes(entry.label), `the report lists ${entry.id}`);
}
for (const requirement of report.minRequirements) {
this.assertTrue(text.includes(requirement), "the report lists every requirement");
}
}
async test_modal_renders_a_row_per_check_and_the_requirements() {
const env = await setupDom();
try {
const report = detectCapabilities();
const modal = new CapabilityModal();
this.assertFalse(modal.isShowing(), "the modal starts hidden");
modal.show(report);
this.assertTrue(modal.isShowing(), "the modal is shown");
this.assertEqual(
env.$("#capabilities-list .capability-row").length,
report.checks.length,
"one row per check"
);
this.assertEqual(
env.$("#capabilities-min li").length,
report.minRequirements.length,
"one line per requirement"
);
this.assertNotEmpty(env.$("#capabilities-summary").text(), "the summary is filled in");
modal.close();
this.assertFalse(modal.isShowing(), "the modal can be closed");
} finally {
teardownDom(env);
}
}
async test_joining_without_webgl_surfaces_the_report_once() {
const env = await setupDom();
const warnings = [];
const originalWarn = console.warn;
console.warn = (...args) => warnings.push(args.join(" "));
try {
const network = { sendOrder() {}, sendGameSetup() {} };
const config = {
playerName: "Carol",
playerCivId: "france",
civilisations: [CIVILISATIONS[0], CIVILISATIONS[1]],
};
const screen = new GameScreen(network, config);
screen.enter();
this.assertEqual(
screen.map.rendererFallback,
"webgl-unavailable",
"the GPU path is known to be down"
);
this.assertNull(screen.map.glRenderer, "jsdom has no GPU renderer");
const snapshot = smallState().snapshot(0);
screen.onState(snapshot);
this.assertFalse(screen.capabilityModal.isShowing(), "nothing shows before the report runs");
screen._reportCapabilities(snapshot);
this.assertTrue(screen.capabilityModal.isShowing(), "the report modal opens on join");
this.assertGreater(
env.$("#capabilities-list .capability-row").length,
0,
"it lists the features"
);
this.assertTrue(
warnings.some((line) => line.includes("capability report")),
"the report is also logged to the console"
);
screen.capabilityModal.close();
screen._reportCapabilities(snapshot);
this.assertFalse(screen.capabilityModal.isShowing(), "the report is shown at most once");
} finally {
console.warn = originalWarn;
teardownDom(env);
}
}
}