mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-05 12:10:45 +00:00
b72956d0c0
## 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>
173 lines
5.6 KiB
TypeScript
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");
|
|
});
|
|
});
|