471 lines
16 KiB
JavaScript
471 lines
16 KiB
JavaScript
// 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");
|
|
}
|