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-07-02 20:48:19 +00:00
committed by Evan Pelle
co-authored by Claude Opus 4.8
parent 006f1690a5
commit 31833346ec
7 changed files with 404 additions and 22 deletions
+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);
});
}