gfx-tier · notes from production
Quality detection assumes it is running on a page you control. Ours is not. It runs embedded on other people's sites, in browsers that block, mask or simply lie about a third of the APIs we would like to ask.
Every 3D browser game has to answer one question before it draws a frame: how much can this device actually do? Get it wrong upward and a school Chromebook renders four frames a second. Get it wrong downward and a gaming desktop gets a blurry toy.
The usual answer is to probe: read the GPU string, count the cores, check the pixel ratio, pick a tier. That works fine on a page you own. It does not survive being embedded somewhere else, and every item below cost real debugging time to find.
| What you want | What you get |
|---|---|
WEBGL_debug_renderer_info | Missing entirely in privacy modes, or present and returning a masked, generic string. You cannot tell "unknown GPU" from "GPU that will not say". |
devicePixelRatio | Honest, and useless. A phone reports 3 while being unable to fill a third of those pixels at speed. Believing it is the fastest route to a slideshow. |
hardwareConcurrency | Clamped, absent, or reporting a number that has nothing to do with the GPU you are about to push. |
deviceMemory | Coarse where it exists, absent where it does not, and missing on the platform you most need it. |
So the entire probe is written defensively. Every single read goes through one wrapper:
function safe(fn, fallback) {
try {
const v = fn();
return v === undefined || v === null ? fallback : v;
} catch (e) {
return fallback;
}
}
Which looks like paranoia until you have watched a game die on someone else's page because an extension replaced one getter with a throw.
A wrong guess degrades quality. An exception kills the game. Those are not comparable failures, so nothing in this file is allowed to throw.
You need a WebGL context to ask about the GPU. The obvious move is to use the canvas you are about to render into — it already exists, and making a second one feels wasteful.
Do that and you have quietly ruined your renderer. The first getContext
call on a canvas binds a context with the attributes you passed at that moment, and every later
call returns that same context regardless of what you ask for. So the probe's throwaway
attributes — no alpha, no depth, no antialias — become the settings the game renders
with. Three.js then appears to accept your carefully chosen antialias: true and
silently ignores it.
Nothing errors. The scene simply looks slightly wrong on every device, in a way that is very hard to attribute, because the code that chose the antialias flag is nowhere near the code that broke it.
The fix is one line of discipline: probe on a throwaway 1×1 canvas, read what you need, release it.
canvas = document.createElement('canvas');
canvas.width = 1;
canvas.height = 1;
gl = safe(() => canvas.getContext('webgl2', attrs), null);
Even with every probe working, the answer is an inference from proxies. A device that reports like a mid-tier laptop might be one with eleven other tabs open, a thermally throttled phone, or a machine running your game inside an iframe on a page that is itself doing heavy work.
So detection only picks the starting tier. A frame-rate guard corrects it from the one piece of
evidence that is not a proxy — actual frame times — returning 'down',
'up' or null. The important part is the hysteresis: a single slow frame
must never move the tier, or one hiccup makes the renderer thrash between settings, which looks far
worse than simply sitting at the wrong tier.
Guess once, then measure. That pairing is what keeps a 3D browser game playable on a school Chromebook and still worth looking at on a desktop.
One file, zero dependencies, no build step, and it knows nothing about Three.js beyond the two lines that set renderer flags:
github.com/kentog751/gfx-tier · MIT
It has been running in production across several 3D titles at Free Games Online, on every device that has turned up — including the ones that lie.