Task 2 of 6
The rule that makes this a model of temperature rather than a downhill slide
needs a coin toss per spin per sweep. On a CPU you reach for Math.random() without
thinking. Think about it here: an ordinary generator is a stream — one hidden
state, advanced once per call, so call number 900 depends on call number 899. That is a
sequential dependency, and it is the same one Thinking in Parallel rules out. There is no
ordering between 16,384 threads and nothing they can share. gpu.js is blunt about it too: the
WebGPU backend refuses Math.random at compile time, and on WebGL a kernel
that calls it is no longer reproducible.
So stop asking for a stream and ask for a function:
u = hash(x, y, seed). Same thread, same sweep, same number — every time, on every
backend. Different thread, unrelated number. No state, no ordering, and a run you can replay
exactly, which is what makes a stochastic simulation debuggable at all. This is not a
compromise for gpu.js's benefit; it is what production GPU Monte Carlo does.
A hash has one job: make the output look nothing like the input. Three moves do it. The
fold multiplies x, y and seed by unrelated odd
constants and adds them, which spreads the three inputs across sixteen bits. The
squaring step is the only nonlinear one, and without it a hash is just an affine map:
neighbouring cells would come out a fixed distance apart, which is not randomness, it is a
ramp. The byte swap moves the high eight bits down where the next multiply can reach
them. Every intermediate stays under 2²⁴ — the largest integer a 32-bit float
holds exactly — so WebGPU, WebGL and the CPU compute the identical value and nothing about your
run depends on which one you got.
hash(x, y, seed) in
[0, 1), using no Math.random anywhere.x, y and seed into one value below 6553665536 so it lands in [0, 1)Math.random — the same seed must give the same field every runThree odd constants, one modulo:
let h = (x * 1103 + y * 2749 + seed * 3571) % 65536;
Leave seed out and the field is the same 16,384 numbers on every sweep — the
simulation would take one step and then repeat it forever. The seed is what makes this a
sequence of fields rather than one field.
Squaring is the nonlinear part. h % 2048 keeps the value small enough
that q * q stays under 2²⁴ and the arithmetic is still exact:
let q = h % 2048;
h = (h + q * q) % 65536;
The swap exchanges the high and low bytes. Note which side gets the multiply —
h % 256 is the LOW byte, so it is the one that has to move UP:
h = (h % 256) * 256 + Math.floor(h / 256);let h = (x * 1103 + y * 2749 + seed * 3571) % 65536;
let q = h % 2048;
h = (h + q * q) % 65536;
h = (h % 256) * 256 + Math.floor(h / 256);
h = (h * 253 + 30011) % 65536;
q = h % 2048;
h = (h + q * q) % 65536;
return h / 65536;
Two rounds, not one — and the reason is not where you would look for it. Inside a
single field one round is already fine: neighbouring cells measure 0.021
against the two-round hash's 0.032, which is the same noise. The damage is
between consecutive seeds. One squaring plus one multiply is still so nearly
affine that hash(x, y, k) and hash(x+1, y+1, k+1) correlate at
0.30, against 0.007–0.021 with two rounds — and that is precisely the
pair the next task puts side by side, a red cell drawing at seed 2k and the
black neighbour that reads it drawing at 2k + 1.
curand's
counter-based generators, and essentially every shader that needs noise. The reason is the same
everywhere: a stream forces an order on things that have none, while a hash gives every thread
an independent draw for free and makes the whole run reproducible. Keeping the arithmetic
inside the exactly-representable integer range is the other half of the trick, and it is why
real GPU hashes are written in integer types rather than floats.
This page is an interactive exercise — the editor, the GPU runner and your saved progress need JavaScript. The text above is the full brief.