Files
OpenFrontIO/tests/client/initGL.test.ts
T
evanpelle 2e3f957630 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>
2026-06-17 19:11:11 -07:00

123 lines
4.0 KiB
TypeScript

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