diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 977759e9c..ce00f2a27 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -70,10 +70,13 @@ import { applyGraphicsOverrides, createRenderSettings, deepAssign, + GLUnavailableError, MapRenderer, preloadAtlasData, renderDpr, type RenderSettings, + showGLGate, + trackGLInit, } from "./render/gl"; import { ALL_UNIT_TYPES, UnitState } from "./render/types"; import { SoundManager } from "./sound/SoundManager"; @@ -188,6 +191,12 @@ export function joinLobby( if (startingModal) { startingModal.classList.add("hidden"); } + // No GPU-accelerated WebGL2: gate with an actionable message rather + // than the generic crash modal (the game would crawl at ~1fps). + if (e instanceof GLUnavailableError) { + showGLGate(e.glStatus); + return; + } showErrorModal( e.message, e.stack, @@ -296,26 +305,53 @@ function createWebGLView( }; const palette = new Float32Array(4096 * 2 * 4); - const view = new MapRenderer( - glCanvas, - { - mapWidth, - mapHeight, - unitTypes: [...ALL_UNIT_TYPES], - players: [], - // Pre-allocate renderer textures for up to 1024 players. We add players - // dynamically via view.addPlayers() as they come in from the simulation, - // but the NamePass / palette / relation matrix all need a static upper - // bound at construction time. - maxPlayers: 1024, - }, - terrainBytes, - palette, - config, - settings, - captureRaf, - captureCaf, - ); + // Log the GPU init result on every session so we can size the real % of + // users on software/missing WebGL2. MapRenderer constructs the GL context; + // a non-accelerated context throws GLUnavailableError (handled by the + // game-start catch, which shows the gate). + let view: MapRenderer; + try { + view = new MapRenderer( + glCanvas, + { + mapWidth, + mapHeight, + unitTypes: [...ALL_UNIT_TYPES], + players: [], + // Pre-allocate renderer textures for up to 1024 players. We add players + // dynamically via view.addPlayers() as they come in from the simulation, + // but the NamePass / palette / relation matrix all need a static upper + // bound at construction time. + maxPlayers: 1024, + }, + terrainBytes, + palette, + config, + settings, + captureRaf, + captureCaf, + ); + } catch (e) { + if (e instanceof GLUnavailableError) { + trackGLInit(e.glStatus, e.renderer); + } + // The renderer never took ownership of the canvas, so remove it here — + // otherwise it lingers in the DOM holding a (possibly software) GL context. + glCanvas.remove(); + throw e; + } + // Fingerprint-capped context (#4357): the game runs, but the map may render + // with black areas. Warn with fix instructions; the player can continue. + if (view.glLimited) { + trackGLInit( + "limited", + view.glLimited.renderer, + view.glLimited.maxTextureSize, + ); + showGLGate("limited"); + } else { + trackGLInit("ok", ""); + } (window as unknown as { __webglView?: unknown }).__webglView = view; diff --git a/src/client/Main.ts b/src/client/Main.ts index d5e861e13..3ec47f8a9 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -180,6 +180,7 @@ declare global { interface Window { turnstile: any; adsEnabled: boolean; + gtag?: (...args: any[]) => void; PageOS: { session: { newPageView: () => void; diff --git a/src/client/components/WebGLGate.ts b/src/client/components/WebGLGate.ts new file mode 100644 index 000000000..025d580e1 --- /dev/null +++ b/src/client/components/WebGLGate.ts @@ -0,0 +1,153 @@ +import { html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +export type WebGLGateStatus = "software" | "unsupported" | "limited"; + +// Hard-block troubleshooting screen seen by a tiny fraction of sessions (no +// GPU-accelerated WebGL2). The content is intentionally NOT translated — it's +// rarely shown and is full of browser-specific UI strings — so it's inlined +// here rather than routed through translateText/en.json. +const STEP_SECTIONS: ReadonlyArray<{ title: string; steps: string[] }> = [ + { + title: "Google Chrome", + steps: [ + "Click the three dots in the top-right corner and select Settings.", + "Click System on the left menu.", + 'Toggle on "Use graphics/hardware acceleration when available".', + "Relaunch your browser.", + "Type chrome://flags into your address bar and press Enter.", + "Search for WebGL in the flags search bar.", + 'Set "WebGL Draft Extensions" (and "WebGL Developer Extensions", if shown) to Enabled.', + "Click Relaunch to apply the changes.", + ], + }, + { + title: "Microsoft Edge", + steps: [ + "Click the three dots in the top-right corner and choose Settings.", + 'Select "System and performance" on the left menu.', + 'Ensure "Use hardware acceleration when available" is toggled on.', + "Go to edge://flags in your address bar and press Enter.", + 'Search for WebGL and set "WebGL Draft Extensions" to Enabled.', + "Click Restart to apply.", + ], + }, + { + title: "Mozilla Firefox", + steps: [ + "Type about:config in the address bar and press Enter (accept any warning prompts).", + "Search for webgl.disabled and ensure the value is set to false.", + "Search for webgl.force-enabled and toggle the value to true.", + "Restart your browser.", + ], + }, +]; + +// Shown for the "limited" status: WebGL works but texture sizes are capped +// below what the game needs, so the map may render with black areas (#4357). +// The only known cause is fingerprinting protection +// (privacy.resistFingerprinting — on by default in LibreWolf and Mullvad +// Browser, opt-in in Firefox). Unlike the other statuses this is a warning: +// the player may dismiss it and play anyway. +const LIMITED_SECTIONS: ReadonlyArray<{ title: string; steps: string[] }> = [ + { + title: "Firefox / LibreWolf / Mullvad Browser", + steps: [ + "Type about:config in the address bar and press Enter (accept any warning prompts).", + "Search for privacy.resistFingerprinting.exemptedDomains.", + `Add ${window.location.hostname} to the value (comma-separated if other domains are already listed).`, + "Restart your browser.", + ], + }, +]; + +const LIMITED_NOTES: string[] = [ + "This keeps fingerprinting protection active everywhere else — only this site is exempted.", + "Alternatively, set privacy.resistFingerprinting to false to turn the protection off entirely.", +]; + +const SAFARI_NOTES: string[] = [ + "Mac: WebGL is on by default. If it has been restricted, open Safari > Settings (or Preferences) > Websites > WebGL and set WebGL to Allow or On for this site or globally.", + "iPhone/iPad: WebGL is natively supported and always on for iOS 8 and later.", +]; + +/** + * Full-screen gate shown when the WebGL2 context is unusable ("software", + * "unsupported" — hard block) or degraded ("limited" — texture sizes capped + * by fingerprinting protection; dismissible via "Continue anyway"). Shows how + * to turn hardware acceleration / WebGL back on, or exempt the site from + * fingerprinting protection, across the most popular browsers. Shown + * imperatively from the game-start path. + */ +@customElement("webgl-gate") +export class WebGLGate extends LitElement { + @property() status: WebGLGateStatus = "software"; + + // Render into light DOM so global styles (Tailwind utilities, bg-surface) apply. + createRenderRoot() { + return this; + } + + render() { + const limited = this.status === "limited"; + const software = this.status === "software"; + const title = limited + ? "Your browser is limiting WebGL" + : software + ? "Hardware acceleration is off" + : "WebGL2 not supported"; + const intro = limited + ? 'A privacy setting is capping WebGL texture sizes below what the game needs, so the map may render with black areas. This is usually "resist fingerprinting" protection, which is on by default in some Firefox-based browsers. Here is how to exempt this site:' + : software + ? "Your browser is rendering without GPU acceleration, so the game can't run smoothly. Here is how to activate it across the most popular web browsers:" + : "Your browser doesn't support WebGL2, which this game requires. Here is how to enable it across the most popular web browsers:"; + const sections = limited ? LIMITED_SECTIONS : STEP_SECTIONS; + const notesTitle = limited ? "Notes" : "Safari"; + const notes = limited ? LIMITED_NOTES : SAFARI_NOTES; + + return html` +
+
+

${title}

+

${intro}

+ ${sections.map( + (section) => html` +
+

+ ${section.title} +

+
    + ${section.steps.map((step) => html`
  1. ${step}
  2. `)} +
+
+ `, + )} +
+

${notesTitle}

+
    + ${notes.map((note) => html`
  • ${note}
  • `)} +
+
+ ${limited + ? html` + + ` + : null} +
+
+ `; + } +} diff --git a/src/client/render/gl/MapRenderer.ts b/src/client/render/gl/MapRenderer.ts index 899787116..31ce496bb 100644 --- a/src/client/render/gl/MapRenderer.ts +++ b/src/client/render/gl/MapRenderer.ts @@ -104,6 +104,15 @@ export class MapRenderer { this.onContextRestored?.(); }; + /** + * Set when the context is hardware-accelerated but its MAX_TEXTURE_SIZE is + * below what the game needs (fingerprinting protection, #4357). The game + * runs, but the map may render with black areas — the owner should warn. + */ + get glLimited(): { renderer: string; maxTextureSize: number } | null { + return this.renderer?.glLimited ?? null; + } + // ---- Camera ---- setCameraState(x: number, y: number, z: number): void { diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index e241e00a9..0f11e2960 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -27,6 +27,7 @@ import type { UnitState, } from "../types"; import { Camera } from "./Camera"; +import { GLUnavailableError, initGL } from "./initGL"; import { BarPass } from "./passes/BarPass"; import { BorderComputePass } from "./passes/BorderComputePass"; import { BorderStampPass } from "./passes/BorderStampPass"; @@ -102,6 +103,13 @@ export class GPURenderer { private camera: Camera; private res: GPUResources; + /** + * Set when the context is hardware-accelerated but its MAX_TEXTURE_SIZE is + * below what the game needs (fingerprinting protection, #4357). The game + * runs, but the map may render with black areas — the owner should warn. + */ + readonly glLimited: { renderer: string; maxTextureSize: number } | null; + // Passes private terrainPass: TerrainPass; private territoryPass: TerritoryPass; @@ -204,12 +212,25 @@ export class GPURenderer { this.raf = raf; this.caf = caf; - const gl = canvas.getContext("webgl2", { + // Demand a GPU-accelerated context. A software (SwiftShader) or missing + // WebGL2 context throws GLUnavailableError, which the game-start path + // turns into an actionable gate instead of letting the game crawl at + // ~1fps. A fingerprint-capped context ("limited" — MAX_TEXTURE_SIZE below + // the palette width, #4357) proceeds anyway; glLimited lets the owner + // warn the player that the map may render with black areas. + const res = initGL(canvas, { alpha: false, antialias: false, powerPreference: "high-performance", }); - if (!gl) throw new Error("WebGL2 not supported"); + if (res.gl === null) { + throw new GLUnavailableError(res.status, res.renderer); + } + this.glLimited = + res.status === "limited" + ? { renderer: res.renderer, maxTextureSize: res.maxTextureSize } + : null; + const gl = res.gl; this.gl = gl; gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); diff --git a/src/client/render/gl/index.ts b/src/client/render/gl/index.ts index cffd5e134..4e03efb1a 100644 --- a/src/client/render/gl/index.ts +++ b/src/client/render/gl/index.ts @@ -3,6 +3,7 @@ export type { AttackRingInput } from "../types"; // the debug GUI into the main bundle; dynamically import "./debug/index". export { GraphicsOverridesSchema } from "./GraphicsOverrides"; export type { GraphicsOverrides } from "./GraphicsOverrides"; +export { GLUnavailableError, showGLGate, trackGLInit } from "./initGL"; export { MapRenderer } from "./MapRenderer"; export { preloadAtlasData } from "./passes/name-pass/AtlasData"; export type { SpawnCenter } from "./passes/SpawnOverlayPass"; diff --git a/src/client/render/gl/initGL.ts b/src/client/render/gl/initGL.ts new file mode 100644 index 000000000..9e7d8b1c6 --- /dev/null +++ b/src/client/render/gl/initGL.ts @@ -0,0 +1,142 @@ +/** + * WebGL2 context acquisition that demands a GPU-accelerated context. + * + * Software-rendered WebGL (SwiftShader/llvmpipe — hardware acceleration off, + * blocklisted driver, or a locked-down machine) runs the game at ~1fps, which + * is unplayable. `failIfMajorPerformanceCaveat: true` forces a real GPU + * context: Chrome returns null instead of silently handing back a software + * context. We branch on that to gate the user with an actionable message + * rather than running at 1fps. + */ + +import type { WebGLGateStatus } from "../../components/WebGLGate"; +import { getPaletteSize } from "./utils/ColorUtils"; + +export type GLResult = + | { gl: WebGL2RenderingContext; status: "ok" } + | { + gl: WebGL2RenderingContext; + status: "limited"; + renderer: string; + maxTextureSize: number; + } + | { gl: null; status: "software" | "unsupported"; renderer: string }; + +// The renderer unconditionally allocates a PALETTE_SIZE-wide (4096) palette +// texture, so a context whose MAX_TEXTURE_SIZE is below that renders wrong: +// the oversized texImage2D calls fail silently and territory/map areas come +// out black (#4357). In practice this means fingerprinting protection — +// privacy.resistFingerprinting (on by default in LibreWolf, opt-in in +// Firefox) caps MAX_TEXTURE_SIZE at 2048. "limited" still returns the +// context: the player is warned with fix instructions but may continue. +const REQUIRED_TEXTURE_SIZE = getPaletteSize(); + +// Renderer strings reported by software WebGL backends. Mirrors the detection +// in utilities/Diagnostic.ts. +const SOFTWARE_RENDERER = /swiftshader|llvmpipe|software/i; + +/** Read the unmasked GPU renderer string, or "unknown" if unavailable. */ +function readRenderer(gl: WebGL2RenderingContext): string { + const dbg = gl.getExtension("WEBGL_debug_renderer_info"); + return dbg ? String(gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL)) : "unknown"; +} + +/** + * Acquire a GPU-accelerated WebGL2 context on `canvas`. + * + * @param attrs Context attributes to merge with the mandatory + * `failIfMajorPerformanceCaveat: true`. A second `getContext("webgl2")` + * call on the same canvas returns the already-created context (ignoring + * attrs), so these must be the attributes the renderer wants. + */ +export function initGL( + canvas: HTMLCanvasElement, + attrs: WebGLContextAttributes = {}, +): GLResult { + // 1. demand a GPU-accelerated context + const accel = canvas.getContext("webgl2", { + ...attrs, + failIfMajorPerformanceCaveat: true, + }); + if (accel) { + // failIfMajorPerformanceCaveat does NOT reliably reject a software context + // when hardware acceleration is turned off in browser settings (as opposed + // to a blocklisted driver) — Chrome still hands back a SwiftShader context. + // So inspect the renderer and gate if it's software; the game is unplayable + // (~1fps) on it either way. + const renderer = readRenderer(accel); + if (SOFTWARE_RENDERER.test(renderer)) { + return { gl: null, status: "software", renderer }; + } + // Fingerprinting protection caps texture sizes on an otherwise + // hardware-accelerated context; the map renders with black areas (#4357). + const maxTextureSize = Number(accel.getParameter(accel.MAX_TEXTURE_SIZE)); + if (maxTextureSize < REQUIRED_TEXTURE_SIZE) { + return { gl: accel, status: "limited", renderer, maxTextureSize }; + } + return { gl: accel, status: "ok" }; + } + + // 2. probe what's actually available. A canvas locks to the first context + // that *succeeds*; the failed (null) call above does NOT lock it, but we use + // a throwaway canvas here regardless to avoid any chance of context lock-in. + const probe = document.createElement("canvas").getContext("webgl2"); + if (!probe) return { gl: null, status: "unsupported", renderer: "" }; + + // WebGL2 exists but couldn't be obtained accelerated → treat as software. + return { gl: null, status: "software", renderer: readRenderer(probe) }; +} + +/** + * Thrown by the renderer when a GPU-accelerated WebGL2 context can't be + * obtained. Carries the detected status + renderer so the caller can gate the + * user and log the outcome. + */ +export class GLUnavailableError extends Error { + constructor( + readonly glStatus: "software" | "unsupported", + readonly renderer: string, + ) { + super(`WebGL2 unavailable: ${glStatus}`); + this.name = "GLUnavailableError"; + } +} + +/** + * Report the WebGL2 GPU-init outcome to analytics (Google Tag). Fires on every + * session so we can size the share of users on a software or missing WebGL2 + * context. `renderer` is the unmasked GPU string for non-ok outcomes, empty + * otherwise. + */ +export function trackGLInit( + status: "ok" | "software" | "unsupported" | "limited", + renderer: string, + maxTextureSize?: number, +): void { + window.gtag?.("event", "gl_init", { + status, + renderer: status === "ok" ? "" : renderer, + ...(maxTextureSize !== undefined && { + max_texture_size: maxTextureSize, + }), + }); +} + +/** + * Show the full-screen WebGL gate (no GPU-accelerated context). The markup and + * per-browser fix steps live in the Lit component, which is loaded + * on demand — it's only ever needed in this failure case. + */ +export function showGLGate(status: WebGLGateStatus): void { + if (document.querySelector("webgl-gate")) { + return; + } + void import("../../components/WebGLGate").then(({ WebGLGate }) => { + if (document.querySelector("webgl-gate")) { + return; + } + const gate = new WebGLGate(); + gate.status = status; + document.body.appendChild(gate); + }); +} diff --git a/tests/client/initGL.test.ts b/tests/client/initGL.test.ts new file mode 100644 index 000000000..9317f71f0 --- /dev/null +++ b/tests/client/initGL.test.ts @@ -0,0 +1,172 @@ +import { GLUnavailableError, initGL } from "../../src/client/render/gl/initGL"; + +// WEBGL_debug_renderer_info.UNMASKED_RENDERER_WEBGL +const UNMASKED_RENDERER_WEBGL = 0x9246; +// GL_MAX_TEXTURE_SIZE +const MAX_TEXTURE_SIZE = 0x0d33; + +// jsdom has no WebGL, so stand in a minimal fake context. When `renderer` is +// provided the fake exposes WEBGL_debug_renderer_info reporting it. +// `maxTextureSize` defaults to a typical desktop-GPU value. +function fakeContext( + renderer?: string, + maxTextureSize = 16384, +): WebGL2RenderingContext { + return { + MAX_TEXTURE_SIZE, + getExtension: (name: string) => + name === "WEBGL_debug_renderer_info" && renderer !== undefined + ? { UNMASKED_RENDERER_WEBGL } + : null, + getParameter: (param: number) => + param === UNMASKED_RENDERER_WEBGL + ? renderer + : param === MAX_TEXTURE_SIZE + ? maxTextureSize + : null, + } as unknown as WebGL2RenderingContext; +} + +// initGL distinguishes the accelerated request from the probe by the presence +// of failIfMajorPerformanceCaveat in the attrs, so the stub branches on it. +function stubGetContext(opts: { + accelerated: WebGL2RenderingContext | null; + probe: WebGL2RenderingContext | null; +}) { + return vi + .spyOn(HTMLCanvasElement.prototype, "getContext") + .mockImplementation(((_type: string, attrs?: WebGLContextAttributes) => + attrs?.failIfMajorPerformanceCaveat + ? opts.accelerated + : opts.probe) as any); +} + +describe("initGL", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns ok with the accelerated context when the renderer is hardware", () => { + const accel = fakeContext("Apple M1"); + stubGetContext({ accelerated: accel, probe: fakeContext() }); + + const res = initGL(document.createElement("canvas")); + + expect(res.status).toBe("ok"); + expect(res.gl).toBe(accel); + }); + + it("reports software when getContext returns a software renderer (accel off)", () => { + // failIfMajorPerformanceCaveat doesn't reject SwiftShader when hardware + // acceleration is disabled in settings, so a context is still returned. + stubGetContext({ + accelerated: fakeContext("Google SwiftShader"), + probe: null, + }); + + const res = initGL(document.createElement("canvas")); + + expect(res.status).toBe("software"); + expect(res.gl).toBeNull(); + if (res.status === "software") { + expect(res.renderer).toBe("Google SwiftShader"); + } + }); + + it("requests the accelerated context with the caller's attrs plus the caveat flag", () => { + const spy = stubGetContext({ accelerated: fakeContext(), probe: null }); + + initGL(document.createElement("canvas"), { + alpha: false, + powerPreference: "high-performance", + }); + + expect(spy).toHaveBeenCalledWith("webgl2", { + alpha: false, + powerPreference: "high-performance", + failIfMajorPerformanceCaveat: true, + }); + }); + + it("reports software with the unmasked renderer when only a non-accelerated context exists", () => { + stubGetContext({ accelerated: null, probe: fakeContext("SwiftShader") }); + + const res = initGL(document.createElement("canvas")); + + expect(res.status).toBe("software"); + expect(res.gl).toBeNull(); + if (res.status === "software") { + expect(res.renderer).toBe("SwiftShader"); + } + }); + + it("reports software with an 'unknown' renderer when the debug extension is unavailable", () => { + stubGetContext({ accelerated: null, probe: fakeContext() }); + + const res = initGL(document.createElement("canvas")); + + expect(res.status).toBe("software"); + if (res.status === "software") { + expect(res.renderer).toBe("unknown"); + } + }); + + it("reports limited (but still returns the context) when MAX_TEXTURE_SIZE is below the palette size", () => { + // privacy.resistFingerprinting (LibreWolf default, Firefox opt-in) caps + // MAX_TEXTURE_SIZE at 2048 on an otherwise hardware-accelerated context; + // the 4096-wide palette texture then fails silently and the map renders + // with black areas (#4357). The player is warned but may continue. + const accel = fakeContext("AMD Radeon", 2048); + stubGetContext({ accelerated: accel, probe: null }); + + const res = initGL(document.createElement("canvas")); + + expect(res.status).toBe("limited"); + expect(res.gl).toBe(accel); + if (res.status === "limited") { + expect(res.renderer).toBe("AMD Radeon"); + expect(res.maxTextureSize).toBe(2048); + } + }); + + it("returns ok when MAX_TEXTURE_SIZE is exactly the palette size", () => { + const accel = fakeContext("Adreno 640", 4096); + stubGetContext({ accelerated: accel, probe: null }); + + const res = initGL(document.createElement("canvas")); + + expect(res.status).toBe("ok"); + expect(res.gl).toBe(accel); + }); + + it("reports software (not limited) when a software renderer also has capped textures", () => { + stubGetContext({ + accelerated: fakeContext("Google SwiftShader", 2048), + probe: null, + }); + + const res = initGL(document.createElement("canvas")); + + expect(res.status).toBe("software"); + }); + + it("reports unsupported when no WebGL2 context can be created at all", () => { + stubGetContext({ accelerated: null, probe: null }); + + const res = initGL(document.createElement("canvas")); + + expect(res.status).toBe("unsupported"); + expect(res.gl).toBeNull(); + }); +}); + +describe("GLUnavailableError", () => { + it("is an Error carrying the status and renderer", () => { + const err = new GLUnavailableError("software", "SwiftShader"); + + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe("GLUnavailableError"); + expect(err.glStatus).toBe("software"); + expect(err.renderer).toBe("SwiftShader"); + }); +});