A WebGL readPixels fingerprint is a hash of the pixels a page rendered: draw a fixed scene, copy the framebuffer out with gl.readPixels, hash the bytes. The GPU, the driver and the operating system each leave their mark on the result, so the hash names a kind of machine. There is a second way to get the same picture out, canvas.toDataURL(), and the two routes number their rows from opposite ends. That detail cost us a Pixelscan verdict.

Two routes out of one framebuffer

readPixels(x, y, width, height, format, type, buffer) follows OpenGL's convention: the origin is the bottom left, so the first row in the buffer is the bottom row of the image. toDataURL encodes a PNG, and an image file starts at the top. Read the same frame both ways and you get the same pixels with the rows in reverse order.

A fingerprinting script does not care, as long as it uses one route every time. A script that wants to know whether the picture is honest can use both: read the frame through readPixels, decode the PNG from toDataURL, flip one of them, and compare. In a real browser the two agree pixel for pixel, every time, because they are the same framebuffer.

Every profile is drawn from one real computer, not from separate listsIn Mosaic: Machines that exist. GPU, screen, CPU and fonts from one real device. Free holds 3 profiles. Pro is $49 a month for any number.

How it works: Machines that exist

How position-keyed noise fails that test

Noise is how a browser stops its WebGL image from being the same on every profile. Without it the image is identical for every profile on one machine, which is shared entropy on a surface fingerprinters weight heavily. For a long time we did not noise it, and the reason was a failed first attempt.

That attempt keyed its stamp on coordinates. Pick pixels by where they sit, nudge those. Through toDataURL, "pixel (10, 3)" counts from the top. Through readPixels, the same pixel is counted from the bottom. So the stamp landed on different pixels in the two copies, they disagreed after the flip, and Pixelscan called the profile masked. It was right to: we had produced a picture no real GPU produces, two readouts of one frame that do not match.

The 2D canvas had had exactly the same bug, in a different form. There, the same shape drawn at two offsets on one canvas came out with different stamps, which is the four-line probe in the offset probe that catches position-keyed noise. That bug had been fixed by keying on the pixel's colour. Nobody had carried the fix across to WebGL.

There was also a setting that did nothing. webglImage was a per-profile option the editor offered and the launcher passed along, and the script that should have acted on it read it nowhere. It looked like a working control for as long as it existed.

Colour-keyed noise agrees with itself

The fix was the 2D one, applied to WebGL. Decide whether to stamp a pixel from its colour, not from its position. A colour is the same whether you count rows from the top or the bottom, and whatever rectangle you read, so the rule picks the same pixels on every route by construction.

Measured after the change:

readPixels vs decoded toDataURL   4096 of 4096 pixels agree
repeat reads, each route          stable
Pixelscan's own scene hash        76edd36b (stock) -> f5337a44
geometry                          byte-identical to stock

The hash moved, which is the point of noise. The geometry did not, so nothing about the shape of what was drawn changed. Both routes returned the same image, and the same image again on a second read. Our stock comparison reported no unexplained differences.

That was the injected-script version, on a stock browser. On Tesseract Chrome, Mosaic's own Chromium, the rule lives in C++. The engine's readPixels patch calls the same colour-keyed function that the 2D canvas readbacks (getImageData, toDataURL, toBlob) go through, so there is one rule and no second copy of it to drift. It stamps one distinct colour in 32 and leaves the other 31 exactly as drawn, and it stamps rather than flips, so a second read finds the change already there instead of a new one.

What the hash says, and what it does not

The pixels are only half of a WebGL fingerprint. The other half is the record a page reads with getParameter: limits, precision formats, the extension list, the renderer string. Noise in the image does nothing for that record, and it should not try: what a canvas hash reveals covers what the rendering stack gives away and why. In Mosaic both halves are drawn together for one machine, as real fingerprints explains.

If you are testing your own setup, the check is cheap. Read one WebGL frame both ways, flip the readPixels rows, and diff. Zero differing pixels is what a real browser gives. Anything else is noise that knows where it is, and a page that asks the question twice will find it.