Gate users without GPU-accelerated WebGL2 instead of running at ~1fps

A small number of users run WebGL without GPU acceleration (hardware
acceleration disabled, blocklisted driver, or a locked-down machine) and
get a software (SwiftShader) context, which renders the game at ~1fps.

Demand a GPU-accelerated WebGL2 context at game start; if we can't get
one, show a full-screen gate with per-browser instructions for enabling
hardware acceleration / WebGL instead of letting the game crawl.

- initGL() demands failIfMajorPerformanceCaveat AND inspects the renderer
  string — Chrome still hands back SwiftShader when acceleration is turned
  off in settings (vs. a blocklisted driver). Classifies ok / software /
  unsupported.
- GPURenderer throws GLUnavailableError on a non-accelerated context; the
  game-start catch shows the gate. The orphaned canvas is cleaned up.
- <webgl-gate> Lit component renders the actionable gate (intentionally
  inlined/untranslated — rarely seen, browser-specific troubleshooting).
- Log a gl_init analytics event (status + renderer) every session so we
  can size the affected %.
- Unit tests for initGL's ok/software/unsupported branching.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
evanpelle
2026-06-17 19:11:11 -07:00
parent 661d96ba28
commit 2e3f957630
7 changed files with 404 additions and 22 deletions
+45 -20
View File
@@ -70,9 +70,12 @@ import {
applyGraphicsOverrides,
createRenderSettings,
deepAssign,
GLUnavailableError,
MapRenderer,
preloadAtlasData,
type RenderSettings,
showGLGate,
trackGLInit,
} from "./render/gl";
import { ALL_UNIT_TYPES, UnitState } from "./render/types";
import { SoundManager } from "./sound/SoundManager";
@@ -187,6 +190,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,
@@ -295,26 +304,42 @@ 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;
}
trackGLInit("ok", "");
(window as unknown as { __webglView?: unknown }).__webglView = view;
+1
View File
@@ -169,6 +169,7 @@ declare global {
interface Window {
turnstile: any;
adsEnabled: boolean;
gtag?: (...args: any[]) => void;
PageOS: {
session: {
newPageView: () => void;
+110
View File
@@ -0,0 +1,110 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
export type WebGLGateStatus = "software" | "unsupported";
// 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.",
],
},
];
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 blocking gate shown when a GPU-accelerated WebGL2 context can't
* be obtained — software rendering (~1fps) or no WebGL2 at all. Shows how to
* turn hardware acceleration / WebGL back on 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 software = this.status === "software";
const title = software
? "Hardware acceleration is off"
: "WebGL2 not supported";
const intro = 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:";
return html`
<div
class="fixed inset-0 z-[10000] flex items-center justify-center bg-black/85 p-5"
>
<div
class="w-full max-w-lg max-h-[85vh] overflow-y-auto p-6 sm:p-8 rounded-xl bg-surface text-white shadow-2xl"
>
<h2 class="text-xl font-bold mb-3">${title}</h2>
<p class="text-sm leading-relaxed text-white/85 mb-5">${intro}</p>
${STEP_SECTIONS.map(
(section) => html`
<section class="mb-5">
<h3 class="text-sm font-bold text-white mb-1.5">
${section.title}
</h3>
<ol
class="pl-5 list-decimal text-sm leading-relaxed text-white/85 space-y-1.5"
>
${section.steps.map((step) => html`<li>${step}</li>`)}
</ol>
</section>
`,
)}
<section class="mb-0">
<h3 class="text-sm font-bold text-white mb-1.5">Safari</h3>
<ul
class="pl-5 list-disc text-sm leading-relaxed text-white/85 space-y-1.5"
>
${SAFARI_NOTES.map((note) => html`<li>${note}</li>`)}
</ul>
</section>
</div>
</div>
`;
}
}
+9 -2
View File
@@ -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";
@@ -192,12 +193,18 @@ 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.
const res = initGL(canvas, {
alpha: false,
antialias: false,
powerPreference: "high-performance",
});
if (!gl) throw new Error("WebGL2 not supported");
if (res.status !== "ok") {
throw new GLUnavailableError(res.status, res.renderer);
}
const gl = res.gl;
this.gl = gl;
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
+1
View File
@@ -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";
+116
View File
@@ -0,0 +1,116 @@
/**
* 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";
export type GLResult =
| { gl: WebGL2RenderingContext; status: "ok" }
| { gl: null; status: "software" | "unsupported"; renderer: string };
// 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 };
}
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",
renderer: string,
): void {
window.gtag?.("event", "gl_init", {
status,
renderer: status === "ok" ? "" : renderer,
});
}
/**
* Show the full-screen WebGL gate (no GPU-accelerated context). The markup and
* per-browser fix steps live in the <webgl-gate> 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);
});
}
+122
View File
@@ -0,0 +1,122 @@
import { GLUnavailableError, initGL } from "../../src/client/render/gl/initGL";
// WEBGL_debug_renderer_info.UNMASKED_RENDERER_WEBGL
const UNMASKED_RENDERER_WEBGL = 0x9246;
// jsdom has no WebGL, so stand in a minimal fake context. When `renderer` is
// provided the fake exposes WEBGL_debug_renderer_info reporting it.
function fakeContext(renderer?: string): WebGL2RenderingContext {
return {
getExtension: (name: string) =>
name === "WEBGL_debug_renderer_info" && renderer !== undefined
? { UNMASKED_RENDERER_WEBGL }
: null,
getParameter: (param: number) =>
param === UNMASKED_RENDERER_WEBGL ? renderer : 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 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");
});
});