Files
Battle-for-Tismo/client/js/capabilities.js
T

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");
}