mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 12:20:46 +00:00
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:
@@ -70,9 +70,12 @@ import {
|
||||
applyGraphicsOverrides,
|
||||
createRenderSettings,
|
||||
deepAssign,
|
||||
GLUnavailableError,
|
||||
MapRenderer,
|
||||
preloadAtlasData,
|
||||
type RenderSettings,
|
||||
showGLGate,
|
||||
trackGLInit,
|
||||
} from "./render/gl";
|
||||
import { ALL_UNIT_TYPES, UnitState } from "./render/types";
|
||||
import { SoundManager } from "./sound/SoundManager";
|
||||
@@ -187,6 +190,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,
|
||||
@@ -295,26 +304,42 @@ 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;
|
||||
}
|
||||
trackGLInit("ok", "");
|
||||
|
||||
(window as unknown as { __webglView?: unknown }).__webglView = view;
|
||||
|
||||
|
||||
@@ -169,6 +169,7 @@ declare global {
|
||||
interface Window {
|
||||
turnstile: any;
|
||||
adsEnabled: boolean;
|
||||
gtag?: (...args: any[]) => void;
|
||||
PageOS: {
|
||||
session: {
|
||||
newPageView: () => void;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
export type WebGLGateStatus = "software" | "unsupported";
|
||||
|
||||
// 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.",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
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 blocking gate shown when a GPU-accelerated WebGL2 context can't
|
||||
* be obtained — software rendering (~1fps) or no WebGL2 at all. Shows how to
|
||||
* turn hardware acceleration / WebGL back on 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 software = this.status === "software";
|
||||
const title = software
|
||||
? "Hardware acceleration is off"
|
||||
: "WebGL2 not supported";
|
||||
const intro = 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:";
|
||||
|
||||
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>
|
||||
${STEP_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">Safari</h3>
|
||||
<ul
|
||||
class="pl-5 list-disc text-sm leading-relaxed text-white/85 space-y-1.5"
|
||||
>
|
||||
${SAFARI_NOTES.map((note) => html`<li>${note}</li>`)}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
UnitState,
|
||||
} from "../types";
|
||||
import { Camera } from "./Camera";
|
||||
import { GLUnavailableError, initGL } from "./initGL";
|
||||
import { BarPass } from "./passes/BarPass";
|
||||
import { BorderComputePass } from "./passes/BorderComputePass";
|
||||
import { BorderStampPass } from "./passes/BorderStampPass";
|
||||
@@ -192,12 +193,18 @@ export class GPURenderer {
|
||||
this.raf = raf;
|
||||
this.caf = caf;
|
||||
|
||||
const gl = canvas.getContext("webgl2", {
|
||||
// Demand a GPU-accelerated context. A software (SwiftShader) or missing
|
||||
// WebGL2 context throws GLUnavailableError, which the game-start path turns
|
||||
// into an actionable gate instead of letting the game crawl at ~1fps.
|
||||
const res = initGL(canvas, {
|
||||
alpha: false,
|
||||
antialias: false,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
if (!gl) throw new Error("WebGL2 not supported");
|
||||
if (res.status !== "ok") {
|
||||
throw new GLUnavailableError(res.status, res.renderer);
|
||||
}
|
||||
const gl = res.gl;
|
||||
this.gl = gl;
|
||||
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ export type { AttackRingInput } from "../types";
|
||||
// the debug GUI into the main bundle; dynamically import "./debug/index".
|
||||
export { GraphicsOverridesSchema } from "./GraphicsOverrides";
|
||||
export type { GraphicsOverrides } from "./GraphicsOverrides";
|
||||
export { GLUnavailableError, showGLGate, trackGLInit } from "./initGL";
|
||||
export { MapRenderer } from "./MapRenderer";
|
||||
export { preloadAtlasData } from "./passes/name-pass/AtlasData";
|
||||
export type { SpawnCenter } from "./passes/SpawnOverlayPass";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user