mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-10 13:34:36 +00:00
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:
@@ -70,10 +70,13 @@ import {
|
||||
applyGraphicsOverrides,
|
||||
createRenderSettings,
|
||||
deepAssign,
|
||||
GLUnavailableError,
|
||||
MapRenderer,
|
||||
preloadAtlasData,
|
||||
renderDpr,
|
||||
type RenderSettings,
|
||||
showGLGate,
|
||||
trackGLInit,
|
||||
} from "./render/gl";
|
||||
import { ALL_UNIT_TYPES, UnitState } from "./render/types";
|
||||
import { SoundManager } from "./sound/SoundManager";
|
||||
@@ -188,6 +191,12 @@ export function joinLobby(
|
||||
if (startingModal) {
|
||||
startingModal.classList.add("hidden");
|
||||
}
|
||||
// No GPU-accelerated WebGL2: gate with an actionable message rather
|
||||
// than the generic crash modal (the game would crawl at ~1fps).
|
||||
if (e instanceof GLUnavailableError) {
|
||||
showGLGate(e.glStatus);
|
||||
return;
|
||||
}
|
||||
showErrorModal(
|
||||
e.message,
|
||||
e.stack,
|
||||
@@ -296,26 +305,53 @@ function createWebGLView(
|
||||
};
|
||||
|
||||
const palette = new Float32Array(4096 * 2 * 4);
|
||||
const view = new MapRenderer(
|
||||
glCanvas,
|
||||
{
|
||||
mapWidth,
|
||||
mapHeight,
|
||||
unitTypes: [...ALL_UNIT_TYPES],
|
||||
players: [],
|
||||
// Pre-allocate renderer textures for up to 1024 players. We add players
|
||||
// dynamically via view.addPlayers() as they come in from the simulation,
|
||||
// but the NamePass / palette / relation matrix all need a static upper
|
||||
// bound at construction time.
|
||||
maxPlayers: 1024,
|
||||
},
|
||||
terrainBytes,
|
||||
palette,
|
||||
config,
|
||||
settings,
|
||||
captureRaf,
|
||||
captureCaf,
|
||||
);
|
||||
// Log the GPU init result on every session so we can size the real % of
|
||||
// users on software/missing WebGL2. MapRenderer constructs the GL context;
|
||||
// a non-accelerated context throws GLUnavailableError (handled by the
|
||||
// game-start catch, which shows the gate).
|
||||
let view: MapRenderer;
|
||||
try {
|
||||
view = new MapRenderer(
|
||||
glCanvas,
|
||||
{
|
||||
mapWidth,
|
||||
mapHeight,
|
||||
unitTypes: [...ALL_UNIT_TYPES],
|
||||
players: [],
|
||||
// Pre-allocate renderer textures for up to 1024 players. We add players
|
||||
// dynamically via view.addPlayers() as they come in from the simulation,
|
||||
// but the NamePass / palette / relation matrix all need a static upper
|
||||
// bound at construction time.
|
||||
maxPlayers: 1024,
|
||||
},
|
||||
terrainBytes,
|
||||
palette,
|
||||
config,
|
||||
settings,
|
||||
captureRaf,
|
||||
captureCaf,
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof GLUnavailableError) {
|
||||
trackGLInit(e.glStatus, e.renderer);
|
||||
}
|
||||
// The renderer never took ownership of the canvas, so remove it here —
|
||||
// otherwise it lingers in the DOM holding a (possibly software) GL context.
|
||||
glCanvas.remove();
|
||||
throw e;
|
||||
}
|
||||
// Fingerprint-capped context (#4357): the game runs, but the map may render
|
||||
// with black areas. Warn with fix instructions; the player can continue.
|
||||
if (view.glLimited) {
|
||||
trackGLInit(
|
||||
"limited",
|
||||
view.glLimited.renderer,
|
||||
view.glLimited.maxTextureSize,
|
||||
);
|
||||
showGLGate("limited");
|
||||
} else {
|
||||
trackGLInit("ok", "");
|
||||
}
|
||||
|
||||
(window as unknown as { __webglView?: unknown }).__webglView = view;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user