Foundations, part 6

Random numbers every GPU agrees on

The famous one line sine hash can roll different numbers on different GPUs. Here's why, and an integer hash that gives the same numbers everywhere.

  • Part 66 min dose
  • Intermediate

In the noise article, a hash was a shader’s dice, a little arithmetic that turns a cell’s number into a random looking number from 0 to 1. There’s one famous hash you’ll find in shaders everywhere:

frac(sin(dot(p, float2(12.9898, 78.233))) * 43758.5453)

It hides a nasty secret, though. On two different GPUs it can roll different numbers. Let’s find out why, then build a hash that rolls the very same numbers on every GPU.

Sparkles from two hashes
Drag the split line, or press Tab to reach it and use the arrow keys

Turn on JavaScript to run this demo.

How far the picture sits from the origin, as a power of ten. 6 is a million cells away.0.00
Which float hash fills the left half.
Sparkles placed by the hash, or each cell's number as a shade of grey.
lib.hlsl and hero.hlsl draw the demo above
Fig. 1

Left of the line the sine hash places one sparkle per cell, right of it the integer hash we’ll build. Slide Distance to 5, a hundred thousand cells from the origin, then to 6. On my PC the left sparkles bunch into rows, then vanish. Your GPU may break in its own way. Set Show to Numbers to see each cell’s raw number.

What sin really is on a GPU

A GPU doesn’t work out sine exactly. It runs a fast approximation in hardware, and the rules for how good it must be are loose:

  • GLSL ES 3.00, the language behind WebGL 2, leaves the precision of sine undefined.
  • Vulkan allows an error of 2−112^{-11}, about 0.0005, but only between −π-\pi and π\pi.
  • Direct3D allows 0.0008 between −100π-100\pi and 100π100\pi.

Outside those ranges, nothing is promised at all. On my PC, sin is off by up to 0.0003 for inputs below three thousand, and by more further out. Mobile GPUs, like the ones in Quest headsets and Android phones, can run math written with half at 16 bit precision, where Vulkan allows sine an error of 2−72^{-7}, almost 0.008.

The 43758 magnifier

Now let’s see what the hash does with that slightly wobbly sine:

a=12.9898 x+78.233 yh=frac⁡(43758.5453sin⁡a)\begin{aligned} a &= 12.9898\,x + 78.233\,y \\ h &= \operatorname{frac}(43758.5453 \sin a) \end{aligned}

xx and yy are the cell’s numbers, aa the angle they make, and hh the hash. Frac keeps only the part after the decimal point. Multiplying by 43758 turns every tiny wobble into a big one. If two GPUs disagree about sin⁡a\sin a by 0.00002, the products differ by almost 0.9, and after frac the two hashes have nothing in common.

What 43758 does to a wobble

Turn on JavaScript to run this demo.

Code for the plot above
  • f1(x):frac((sin(1.0) + x * 0.000001) * 43758.5453)
  • f2(x):sin(1.0) + x * 0.000001
Fig. 2

The bottom axis shows how far a GPU’s sine of 1 is off, in millionths. The dashed pink sine looks flat, while the solid mint hash climbs from 0 to 1 every 23 millionths.

I can’t put a second GPU in your computer, so the next demo fakes one. It works out each cell’s hash twice, once with your GPU’s sin and once with a stand-in sine of mine built from a short polynomial.

Where two GPUs disagree

Turn on JavaScript to run this demo.

Which hash to draw, or the cells where the two disagree.
How many cells fit across the short side of the picture.24
amplify.hlsl draws the demo above
Fig. 3

The stand-in sine plays another GPU. On my PC the two sines differ by less than 0.0002 here, yet nearly every cell is pink, meaning its two hashes disagree. Mint cells agree. Switch Show to see each hash alone. Both look like fine noise.

Large inputs make it worse even on one GPU. A float keeps about seven significant digits, so around a million, neighbouring floats are 0.0625 apart. Far from the origin aa runs into the millions, and its fine digits are gone before sin even starts. On my PC, sin’s answer out there can be off by more than 1. That’s the rows in the first demo. A hash fed _Time.y gets coarser the same way as a world runs on.

Why VRChat makes it matter

Every player in VRChat renders your avatar on their own GPU. With a sine hash, the glitter on your outfit can sparkle in different places for each viewer, and a pattern placed by world position far from the origin can turn streaky. On Quest and Android, avatars may only use VRChat’s mobile shaders, but world shaders run there, on mobile GPUs.

Hash without Sine gives steadier floats

Dave Hoskins’ Hash without Sine, the one from the noise article, skips sine. It only multiplies, adds and takes frac, and GLSL ES asks for every multiply and add to be rounded correctly. Pick it under Left half in the first demo. On my PC it holds up even ten million cells out.

A little wiggle room is left. A GPU may fuse a multiply and an add into one step that rounds once, and Direct3D lets hardware cut off the last bit instead of rounding it. Either can change a last digit, and a hash turns last digits into new numbers. Rare, but possible.

Whole numbers wrap the same everywhere

Integers never round. A uint holds exactly 32 bits, and when a sum or a product overflows, it wraps around and keeps the lowest 32 bits. GLSL ES 3.00 demands this, and Direct3D’s integer math works the same way. Same input, same bits, on every GPU.

PCG is a hash made of exactly that. Mark Jarzynski and Marc Olano tested over a hundred GPU hash variants for quality and speed, and PCG came out a good default. Nathan Reed puts it plainly. “It should probably be your default GPU hash function.”

uint pcg(uint v)
{
    uint state = v * 747796405u + 2891336453u;
    uint word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u;
    return (word >> 22u) ^ word;
}

Line one multiplies, adds and wraps. Line two shifts the bits by an amount they pick themselves and mixes them back in with xor (^). Line three folds the high bits into the low ones. For a cell’s two numbers, nest it: pcg(x + pcg(y)). The paper found that nesting keeps the quality, while adding weighted numbers first makes repeating patterns.

From bits to a number from 0 to 1

u=⌊n/28⌋⋅2−24u = \lfloor n / 2^8 \rfloor \cdot 2^{-24}

nn is the 32 bit hash. Dividing by 282^8 and rounding down keeps its top 24 bits (n >> 8u), and uu runs from 0 up to a hair under 1. Why 24? A float holds every whole number up to 2242^{24} exactly, and multiplying by a power of two only changes its exponent. Nothing rounds, so every GPU gets the exact same float. That’s toUnit in the code under the first demo.

From a position to a whole number

A shader’s positions are floats. Round down with floor, turn that into an int, and only then into a uint: uint2(int2(floor(p))).

Skipping the int is a trap. GLSL ES 3.00 leaves a negative float turned into a uint undefined, and Direct3D clamps it to 0. From int to uint, GLSL ES and HLSL both keep the bits, so -1 becomes 4294967295 and every cell keeps a number of its own.

Through int or straight to uint

Turn on JavaScript to run this demo.

How the position turns into the cell's whole numbers.
How many cells fit across the short side of the picture.10
cells.hlsl draws the demo above
Fig. 4

Switch Convert to Straight to uint. On my PC every negative number becomes 0, so rows and columns left of and below the middle repeat, and the bottom left is one block. Through int, every tile gets its own color.

In practice

A sparkle per UV cell, with pcg2 and toUnit from the code under the first demo:

uint2 cell = uint2(int2(floor(i.uv * _Cells)));
uint n = pcg2(cell);
float bright = toUnit(n);       // 0 to 1, the same on every GPU
float size = toUnit(pcg(n));    // a second, fresh number
  • Integers in Unity. Whole number math needs #pragma target 3.5 or higher.
  • Full precision. GLSL ES lets a mediump integer be as small as 16 bits. Keep hashes in plain uint, never min16uint.
  • Steady input. A world position far from the origin has lost its fine digits before any hash sees it. Hash UVs or object positions when you can.

Credits

Takeaways

  • A GPU’s sine is an approximation with loose rules, and the sine hash blows its tiny errors up into different numbers.
  • Hash without Sine is much steadier, and integer hashes such as PCG give the very same bits on every GPU.
  • Turn positions into whole numbers through int, and hashes into 0 to 1 with their top 24 bits.
Search the lessons

Type any word from the lessons, like normalize or smoothstep. The start of a word is enough.