Files
OpenFrontIO/tests/client/initGL.test.ts
T
Evan b72956d0c0 Gate users without GPU-accelerated WebGL2 instead of running at ~1fps (#4324)
## Problem

After the WebGL2 renderer migration, a small number of users (~a dozen
of 100k DAU) report ~5fps. Root cause: they run WebGL **without GPU
acceleration** (hardware acceleration disabled, blocklisted driver, or a
locked-down machine), so they get a SwiftShader/software context.
Software-rendered WebGL is hopeless for a real-time game — ~1fps
locally.

We are not supporting a canvas2d fallback. Instead: demand a
GPU-accelerated context, and if we can't get one, **gate** the user with
actionable instructions rather than letting the game crawl.

## What this does

- **`initGL()`**
([initGL.ts](../blob/webgl-software-render-gate/src/client/render/gl/initGL.ts))
— demands `failIfMajorPerformanceCaveat: true` **and** inspects the
unmasked renderer string. The flag alone isn't enough: when hardware
acceleration is turned off in browser *settings* (vs. a blocklisted
driver), Chrome still hands back a SwiftShader context, so we'd
otherwise run at 1fps. Classifies the outcome as `ok` / `software` /
`unsupported`.
- **`GPURenderer`** throws `GLUnavailableError` on a non-accelerated
context; the game-start `catch` shows the gate and removes the orphaned
canvas.
- **`<webgl-gate>`** Lit component renders a full-screen blocking gate
with per-browser steps (Chrome / Edge / Firefox / Safari) for enabling
hardware acceleration / WebGL.
- **`gl_init` analytics event** fires every session (`status` +
`renderer` for non-ok) via the existing Google Tag, so we can size the
real affected % within a day.

## Notes / decisions

- The gate copy is **intentionally inlined (not translated)** — it's a
rarely-seen, browser-specific troubleshooting screen; 28 Crowdin keys
would be poor cost/benefit, and a non-English user still has to navigate
English browser menus.
- `showGLGate` lazy-loads the component (`import()`), so the `render/gl`
module that `Renderer.ts` imports doesn't statically pull a UI component
into its graph.

## Update: fingerprint-capped contexts (#4357)

A third failure class, integrated after the initial PR:
`privacy.resistFingerprinting` (default-on in LibreWolf and Mullvad
Browser, opt-in in Firefox) caps `MAX_TEXTURE_SIZE` at 2048 on an
otherwise hardware-accelerated context. The renderer unconditionally
allocates a 4096-wide palette texture, so the oversized `texImage2D`
calls fail silently and the whole map renders **black** (#4357).

- `initGL` now reads `MAX_TEXTURE_SIZE` after the software check and
classifies the context as **`limited`** when it's below
`getPaletteSize()` (4096 — the hard floor every game needs).
- Unlike `software`/`unsupported`, **`limited` is a warning, not a hard
block**: `initGL` still returns the context, the game starts normally,
and the gate is shown with a "Continue anyway" button. `GPURenderer`
exposes the capped renderer/size via `glLimited` (surfaced through
`MapRenderer`), which `ClientGameRunner` uses to show the warning and
log analytics.
- The gate shows fingerprinting-specific instructions for `limited` (add
the site to `privacy.resistFingerprinting.exemptedDomains` in
`about:config`) instead of the hardware-acceleration steps.
- `gl_init` reports `max_texture_size` alongside the renderer for this
status, so we can size the RFP-affected population too.

Fixes #4357

## Test plan

- [x] Unit tests for `initGL`'s `ok` / `software` / `unsupported`
branching, incl. the "returns a context but renderer is software" case
(`tests/client/initGL.test.ts`).
- [x] lint / prettier / tsc clean.
- [x] **Verified in real browsers (macOS).** All three gate states
reproduced:
- `software`: Chrome with `--use-gl=angle --use-angle=swiftshader`
(confirmed "Software only" at `chrome://gpu`), and Chrome with hardware
acceleration toggled off in settings — both show the hard gate instead
of a 1fps game.
- `unsupported`: Firefox with `webgl.disabled=true` shows the
unsupported gate.
- `limited`: Firefox with `privacy.resistFingerprinting=true`
(MAX_TEXTURE_SIZE capped to 2048, same as LibreWolf's default) shows the
dismissible warning; "Continue anyway" starts the game, and exempting
the site via `privacy.resistFingerprinting.exemptedDomains` removes the
warning.

## Acceptance criteria

- Accelerated users: unchanged.
- Software / no-accel users: see the enable-acceleration gate, not a
1fps game.
- No-WebGL2 users: see the unsupported gate.
- `gl_init` fires every session with status (+ renderer for non-ok).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 15:54:06 -07:00

173 lines
5.6 KiB
TypeScript

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