Task 1 of 5
A path tracer needs random numbers by the million — every bounce picks a direction
out of a hat. It cannot ask Math.random() for them: WebGPU refuses to compile a
kernel containing it, and even where it compiles, thousands of threads drawing from
one generator is a fiction, because a GPU has no shared state and no defined order
in which the threads would take their turns. The Ising Model makes that
argument in full and builds the generator from first principles; if you have not met it
there, the short version is what follows.
The fix is to make the thread's own coordinates the seed. Hash
this.thread.x, this.thread.y and a frame counter into a starting
state, then walk that state forward. Every thread gets a private stream, nothing is shared,
and — because a hash is a pure function — the same run produces the same picture every
single time. What is new here is the frame counter: Ising re-seeds per sweep to
keep a simulation honest, and a path tracer re-seeds per frame so that each frame's noise is
independent of the last, which is the only reason averaging them helps.
The generator is written for you. Notice how small the numbers stay:
gpu.addFunction(function nextRandom(state) {
let p = state * 78.233 + 0.7213;
p = p - Math.floor(p); // wrap into 0…1
p = p * (p + 61.7);
return p - Math.floor(p);
});
That restraint is deliberate. A GPU float has 24 bits of mantissa, and taking the
fractional part of a big number throws away exactly the bits the integer part is using — so
the famous fract(sin(x) * 43758.5453) one-liner, whose intermediate reaches
40,000, has about eight bits of randomness left by the time you see it. Keep every value you
wrap under a hundred and you keep seventeen.
frame,
this.thread.x and this.thread.y, so that no two threads and no two
frames ever share a stream.frame, this.thread.x and this.thread.ynextRandom — a raw sum of coordinates is not a random numberMath.random: pressing ▶ Run twice must produce the identical staticRun it. Every pixel is the same shade, because every thread computed the same
seed from the same constant. A seed that does not mention this.thread.x is
a seed 4,096 threads agree on.
Add a coordinate, hash, add the next, hash again. Each hash smears whatever went in across the whole 0…1 range, so two seeds that started 0.0713 apart end up with nothing in common:
let seed = 0.1237 + frame * 0.0173;
seed = nextRandom(seed + this.thread.x * 0.0713);
seed = nextRandom(seed + this.thread.y * 0.0917);
The constants are arbitrary; what matters is that all three numbers get in.
Leave frame out and every frame draws the same numbers. The next
tasks average frames together to kill noise — and averaging a value with a perfect copy
of itself removes nothing at all.
curand_init(seed, thread_id, 0, &state),
and WebGPU and Metal shaders hash global_invocation_id with a frame index using
exactly this shape. It is the same tool The Ising Model reaches for, which
is worth noticing: a renderer and a statistical-mechanics simulation have almost nothing in
common except that both need a private stream per thread, and both get it the same way.
This page is an interactive exercise — the editor, the GPU runner and your saved progress need JavaScript. The text above is the full brief.