← dictionary
Rendering

WebGL Fingerprinting

getParameter looks like it asks your graphics card a question. Many answers are decided in software: some queried then thrown away, one never asked at all.

Category
Rendering
API
WebGLRenderingContext.getParameter
Reveals
graphics API, GPU model, driver
Spoofing
Spoofable, spoof detectable
Since
2011
Liverunning the probe…

getParameter reads like a hardware query. You ask for MAX_VERTEX_ATTRIBS and get 16; you ask for SUBPIXEL_BITS and get 4. It is natural to assume those numbers came from the graphics card.

Some did. Several did not, and the ones that did not are more interesting, because the reasons they hold the values they hold have nothing to do with your hardware. One of them is a workaround for a slow test suite that nobody has gotten around to removing.

The panel above shows your own set, with the graphics API underneath named.

Your WebGL fingerprint is not talking to your GPU

On every major desktop platform, Chromium does not hand WebGL to the system's OpenGL driver. It hands it to ANGLE — Almost Native Graphics Layer Engine — which re-implements OpenGL ES on top of whatever the platform actually prefers: Direct3D 11 on Windows, Metal on macOS, Vulkan or native GL elsewhere.

You do not have to take that on faith. WebGL ships a debug extension that hands your shader back after translation, and the output names the target language outright:

JavaScript
const gl = document.createElement("canvas").getContext("webgl2");
const ext = gl.getExtension("WEBGL_debug_shaders");

const sh = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(sh, `#version 300 es
precision mediump float;
uniform float uT;
out vec4 c;
void main(){ c = vec4(sin(uT), 0.5, 1.0, 1.0); }`);
gl.compileShader(sh);

console.log(ext.getTranslatedShaderSource(sh));

On this machine — macOS, Apple M2 Max — that returns Metal Shading Language, opening with the giveaway line:

cpp
#include <metal_stdlib>

#define ANGLE_ALWAYS_INLINE __attribute__((always_inline))

Six kilobytes of it, for a four-line shader. The same GLSL on a Windows machine comes back as HLSL. The renderer string names the same thing more directly — mine reads ANGLE (Apple, ANGLE Metal Renderer: Apple M2 Max, Unspecified Version) — but a string is text a browser can be told to print, whereas the translated shader is evidence of work actually performed.

Once you know a translation layer is in the middle, the limits stop looking like hardware readings and start looking like what they are: whatever that layer decided to report.

The one that is never measured

SUBPIXEL_BITS reports how many bits of sub-pixel precision the rasterizer uses when snapping geometry to the pixel grid. It is a genuine hardware characteristic and real GPUs differ.

ANGLE declares it as a field initializer:

cpp
GLuint subPixelBits = 4;

Across ANGLE's entire source tree there are exactly three places that touch that field: the declaration above, the line in Context.cpp that hands it to your getParameter call, and one backend that overwrites it —

cpp
mNativeCaps.subPixelBits = limitsVk.subPixelPrecisionBits;

That is the Vulkan backend, and it is a real device query. Every other backend — Direct3D 11, Metal, OpenGL, WebGPU — never assigns the field at all, so it keeps the literal 4. It reads 4 here, on Metal, as expected.

So a value that would be informative if it were measured is uninformative on most configurations because it is declared. But the honest version of that sentence has an exception in it: on a Vulkan-backed browser, typically Android or Linux, you may well see 8, and that 8 is a real reading of real silicon. Any article telling you this parameter is hardcoded "regardless of backend" has not checked the Vulkan path.

The one that is measured and then discarded

MAX_VERTEX_ATTRIBS is the cleaner illustration, because here the device is asked and the answer is thrown away.

Every backend queries it — the Vulkan path reads limitsVk.maxVertexInputAttributes, the D3D11 path derives it from D3D11_STANDARD_VERTEX_ELEMENT_COUNT (32), the GL path calls glGetIntegerv(GL_MAX_VERTEX_ATTRIBS). Then, in a backend-independent pass, ANGLE clamps every capability against constants in its own header:

cpp
ANGLE_LIMIT_CAP(caps->maxVertexAttributes, MAX_VERTEX_ATTRIBS);

where MAX_VERTEX_ATTRIBS = 16. Because Vulkan's own specification requires every conformant device to support at least 16 vertex input attributes, the clamp provably always binds: the device's answer is guaranteed to be discarded in favour of the constant. Everyone reports 16, forever, regardless of what their GPU can do.

This is the accurate general shape, and it is worth stating precisely because the sloppy version — "ANGLE hardcodes these" — is wrong. Most of these parameters are queried. They are then capped by a header constant that usually wins. A few escape the clamp entirely and carry real hardware information: MAX_TEXTURE_LOD_BIAS is an unclamped passthrough of the device's own float on both the Vulkan and GL backends, and MAX_SAMPLES is genuinely negotiated with the device on every real backend.

The famous asymmetry, and why the usual explanation is wrong

The best-known oddity in this surface is that on Chrome's default Windows backend, MAX_VERTEX_UNIFORM_VECTORS reports 4096 while MAX_FRAGMENT_UNIFORM_VECTORS reports 1024. A 4:1 split between the vertex and fragment stages looks like it must reflect some Direct3D register-count limit.

It does not. Direct3D 11's constant-buffer element limit is a common shader limit — the same for both stages. Here is what ANGLE actually returns for the fragment stage:

cpp
// TODO(geofflang): Remove hard-coded limit once the gl-uniform-arrays test can pass
// ...
        case D3D_FEATURE_LEVEL_11_0:
            return 1024;  // D3D11_REQ_CONSTANT_BUFFER_ELEMENT_COUNT;

The real Direct3D constant is commented out on the same line and replaced with a bare literal, because a WebGL conformance test called gl-uniform-arrays was too slow to finish with large uniform arrays. That TODO is still there.

The decisive evidence that this is an ANGLE artifact rather than a hardware or API one is in the OpenGL backend, which carries the same workaround under the same comment — but applies it to both stages:

cpp
// TODO(geofflang): The gl-uniform-arrays WebGL conformance test struggles to complete on time
// if the max uniform vectors is too large.  Artificially limit the maximum until the test is
// updated.
caps->maxVertexUniformVectors = std::min(1024, caps->maxVertexUniformVectors);
// ...
caps->maxFragmentUniformVectors = std::min(1024, caps->maxFragmentUniformVectors);

Metal and Vulkan apply it to neither, and assign both stages from a single variable, so they are symmetric by construction — this machine reads 1024/1024, from a 16 KB software uniform budget that has nothing to do with the M2's actual capabilities.

The celebrated 4:1 ratio is therefore not a Direct3D limit, not a GPU characteristic, and not a deliberate design. It is one workaround that got applied to one stage on one backend and both stages on another.

Two caveats before you go checking:

  • On NVIDIA the vertex figure is 4095, not 4096. ANGLE subtracts one constant register under a feature gated purely on the PCI vendor ID (ANGLE_FEATURE_CONDITION(features, skipVSConstantRegisterZero, isNvidia)). The single bit of hardware information in that number is which company made your card, not anything it can do.
  • Windows is not uniformly Direct3D. A blocklisted GPU or a VM falls back to SwiftShader, which runs ANGLE's Vulkan backend and reports the two stages symmetrically.

Measurement status

Claim How established
Metal reports 1024/1024, SUBPIXEL_BITS 4 Measured — Chrome 148, macOS, Apple M2 Max
GLSL translated to MSL on macOS Measured — same machine
D3D11 fragment limit is a literal 1024 under the geofflang TODO Read from ANGLE source; the numeral is verbatim
Vulkan is the only backend writing subPixelBits Verified by whole-tree search of ANGLE
D3D11 vertex limit is 4096 Not verified from source here. D3D11_REQ_CONSTANT_BUFFER_ELEMENT_COUNT lives in the Windows SDK, which is not present on a macOS checkout. It is the well-known SDK value and is corroborated indirectly, but I have not read it in a file

If you are on Windows and your numbers disagree with any of this, that is a finding — please tell me.

What this means for fingerprinting

WebGL is genuinely one of the higher-entropy surfaces on the web, but it is worth being precise about where the entropy lives:

  • The renderer string carries most of it. ANGLE (Apple, ANGLE Metal Renderer: Apple M2 Max, Unspecified Version) names a vendor, an API, and a specific chip.
  • The limits carry much less, and much of what they carry is about ANGLE rather than about you. They cluster hard by backend. Their most useful property is not identification but consistency — see below.
  • The rendered output — drawing something and hashing the pixels — is a separate signal that behaves much more like canvas.

How much any of this narrows you down depends on the population you are being compared against, which this page has no way to know — so it does not guess.

Spoofing

The renderer string is the most-spoofed value in this dictionary. Privacy browsers rewrite it, extensions rewrite it, and anti-detect browsers ship libraries of plausible GPU strings. Tor Browser and Firefox's resistFingerprinting instead disable or neuter WebGL, which is a different trade: no signal rather than a false one.

What makes a rewritten string weak is everything above. The string is one value; the limits are dozens, and they are not independent of it. Claim a Direct3D renderer while reporting symmetric uniform vectors and you have contradicted yourself, because the asymmetry is structural on that backend and impossible on Metal and Vulkan. Claim an NVIDIA card on Windows while reporting 4096 rather than 4095 and you have missed a vendor-gated workaround. Then there is the shading language the debug extension hands back, which has to agree too.

This is the same lesson as the gzip OS byte: a value with little entropy on its own becomes strong evidence when read beside a value that is supposed to agree with it.

References

  • ANGLEsrc/libANGLE/Caps.h for the shared defaults, src/libANGLE/Context.cpp for the ANGLE_LIMIT_CAP clamp pass, and src/libANGLE/renderer/*/ for each backend's capability initialization. All quoted code above is from this public tree.
  • MDN: getParameter(), WEBGL_debug_shaders, WEBGL_debug_renderer_info.
  • The Khronos WebGL and OpenGL ES specifications, for the required minimum each limit must meet.

How the probe works

This is the exact source that ran in the panel above — no summary, no drift.

JavaScript
// Reads the WebGL limit set and shows where it actually comes from. Most of
// these numbers are not measurements of your GPU — they are constants from the
// graphics API ANGLE translated your WebGL calls into. The probe also asks for
// your own shader back after translation, which names that API outright.

// Which graphics API is underneath, inferred from the renderer string ANGLE
// composes. This is a label, not a measurement — see the entry text.
function backendFrom(renderer) {
  if (!renderer) return "unknown";
  if (/SwiftShader/i.test(renderer)) return "SwiftShader (CPU)";
  if (/ANGLE Metal Renderer/i.test(renderer)) return "Metal";
  if (/Direct3D11|D3D11/i.test(renderer)) return "Direct3D 11";
  if (/Direct3D9/i.test(renderer)) return "Direct3D 9";
  if (/Vulkan/i.test(renderer)) return "Vulkan";
  if (/OpenGL|OpenGL ES/i.test(renderer)) return "OpenGL";
  if (/ANGLE/i.test(renderer)) return "ANGLE, backend not named";
  return "no ANGLE — native driver";
}

// Name the shading language ANGLE emitted, from unmistakable markers in the
// translated source rather than from the renderer string.
function shadingLanguage(src) {
  if (!src) return null;
  if (/#include\s*<metal_stdlib>/.test(src)) return "Metal Shading Language (MSL)";
  if (/\bcbuffer\b|SV_Position|SV_Target|\bstatic float4\b/.test(src)) return "HLSL (Direct3D)";
  if (/#version\s+\d+/.test(src)) return "GLSL (passed through to the host driver)";
  return "unrecognised";
}

export async function probe() {
  const canvas = document.createElement("canvas");
  const gl = canvas.getContext("webgl2") || canvas.getContext("webgl");
  if (!gl) throw new Error("no WebGL context — WebGL is disabled or unavailable here");

  const dbg = gl.getExtension("WEBGL_debug_renderer_info");
  const renderer = dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : null;

  const P = (name) => {
    try {
      const v = gl.getParameter(gl[name]);
      return v === undefined || v === null ? null : v;
    } catch {
      return null;
    }
  };

  const vertexVec = P("MAX_VERTEX_UNIFORM_VECTORS");
  const fragVec = P("MAX_FRAGMENT_UNIFORM_VECTORS");

  // Ask for our own shader back after ANGLE has translated it. The extension
  // is a standard debug extension; it may be absent, which is not an error.
  let translated = null;
  const shaderExt = gl.getExtension("WEBGL_debug_shaders");
  if (shaderExt) {
    const isGL2 = typeof WebGL2RenderingContext !== "undefined" && gl instanceof WebGL2RenderingContext;
    const source = isGL2
      ? "#version 300 es\nprecision mediump float;\nuniform float uT;\nout vec4 c;\nvoid main(){ c = vec4(sin(uT), 0.5, 1.0, 1.0); }"
      : "precision mediump float;\nuniform float uT;\nvoid main(){ gl_FragColor = vec4(sin(uT), 0.5, 1.0, 1.0); }";
    const sh = gl.createShader(gl.FRAGMENT_SHADER);
    gl.shaderSource(sh, source);
    gl.compileShader(sh);
    if (gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
      translated = shaderExt.getTranslatedShaderSource(sh);
    }
    gl.deleteShader(sh);
  }

  const value = {
    "Renderer string": renderer ?? "hidden (WEBGL_debug_renderer_info unavailable)",
    "Graphics API underneath": backendFrom(renderer),
    "Your GLSL was translated to": shadingLanguage(translated) ?? "unavailable (WEBGL_debug_shaders absent)",
    "MAX_VERTEX_UNIFORM_VECTORS": vertexVec,
    "MAX_FRAGMENT_UNIFORM_VECTORS": fragVec,
    "Vertex vs fragment": vertexVec === fragVec ? "symmetric" : `asymmetric (${vertexVec} vs ${fragVec})`,
    "SUBPIXEL_BITS": P("SUBPIXEL_BITS"),
    "MAX_TEXTURE_SIZE": P("MAX_TEXTURE_SIZE"),
    "MAX_TEXTURE_IMAGE_UNITS": P("MAX_TEXTURE_IMAGE_UNITS"),
    "MAX_VERTEX_ATTRIBS": P("MAX_VERTEX_ATTRIBS"),
    "MAX_VARYING_VECTORS": P("MAX_VARYING_VECTORS"),
  };

  // WebGL2-only parameters; omit rather than show nulls on a WebGL1 context.
  const lodBias = P("MAX_TEXTURE_LOD_BIAS");
  if (lodBias !== null) value["MAX_TEXTURE_LOD_BIAS"] = lodBias;

  return {
    facts: {
      "Graphics": renderer
        ? `${backendFrom(renderer)}${/SwiftShader/i.test(renderer) ? "" : " (hardware)"}`
        : backendFrom(renderer),
    },
    label: `${backendFrom(renderer)} · ${vertexVec}/${fragVec} uniform vectors`,
    value,
  };
}