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>
This commit is contained in:
Evan
2026-07-02 15:54:06 -07:00
committed by GitHub
parent b6317964a7
commit b72956d0c0
8 changed files with 557 additions and 22 deletions
+153
View File
@@ -0,0 +1,153 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
export type WebGLGateStatus = "software" | "unsupported" | "limited";
// 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.",
],
},
];
// Shown for the "limited" status: WebGL works but texture sizes are capped
// below what the game needs, so the map may render with black areas (#4357).
// The only known cause is fingerprinting protection
// (privacy.resistFingerprinting — on by default in LibreWolf and Mullvad
// Browser, opt-in in Firefox). Unlike the other statuses this is a warning:
// the player may dismiss it and play anyway.
const LIMITED_SECTIONS: ReadonlyArray<{ title: string; steps: string[] }> = [
{
title: "Firefox / LibreWolf / Mullvad Browser",
steps: [
"Type about:config in the address bar and press Enter (accept any warning prompts).",
"Search for privacy.resistFingerprinting.exemptedDomains.",
`Add ${window.location.hostname} to the value (comma-separated if other domains are already listed).`,
"Restart your browser.",
],
},
];
const LIMITED_NOTES: string[] = [
"This keeps fingerprinting protection active everywhere else — only this site is exempted.",
"Alternatively, set privacy.resistFingerprinting to false to turn the protection off entirely.",
];
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 gate shown when the WebGL2 context is unusable ("software",
* "unsupported" — hard block) or degraded ("limited" — texture sizes capped
* by fingerprinting protection; dismissible via "Continue anyway"). Shows how
* to turn hardware acceleration / WebGL back on, or exempt the site from
* fingerprinting protection, 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 limited = this.status === "limited";
const software = this.status === "software";
const title = limited
? "Your browser is limiting WebGL"
: software
? "Hardware acceleration is off"
: "WebGL2 not supported";
const intro = limited
? 'A privacy setting is capping WebGL texture sizes below what the game needs, so the map may render with black areas. This is usually "resist fingerprinting" protection, which is on by default in some Firefox-based browsers. Here is how to exempt this site:'
: 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:";
const sections = limited ? LIMITED_SECTIONS : STEP_SECTIONS;
const notesTitle = limited ? "Notes" : "Safari";
const notes = limited ? LIMITED_NOTES : SAFARI_NOTES;
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>
${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">${notesTitle}</h3>
<ul
class="pl-5 list-disc text-sm leading-relaxed text-white/85 space-y-1.5"
>
${notes.map((note) => html`<li>${note}</li>`)}
</ul>
</section>
${limited
? html`
<button
class="mt-5 w-full py-2.5 rounded-lg bg-white/10 hover:bg-white/20 text-sm font-bold text-white transition-colors"
@click=${() => this.remove()}
>
Continue anyway
</button>
`
: null}
</div>
</div>
`;
}
}