Task 1 of 5
A path tracer needs random numbers by the million — every bounce picks a direction
out of a hat. On a CPU you call Math.random() and never think about it again.
On a GPU you can't, for two separate reasons. gpu.js's WebGPU backend simply refuses to
compile a kernel containing Math.random (it is one of exactly three things that
make a kernel decline the upgrade and stay on WebGL). And even where it compiles, 4,096
threads drawing from one generator is a fiction: there is no shared state on a GPU
and no defined order in which the threads would take their turns.
So the whole industry does the same thing instead: the thread's own coordinates
are 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.
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. The reason is always the same one — a shared stream needs shared state
and an ordering, and a GPU offers neither.
This page is an interactive exercise — the editor, the GPU runner and your saved progress need JavaScript. The text above is the full brief.