Task 1 of 5

Dice Every Thread Can Roll

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.

no shared dice on a GPU — so the thread hashes its own address into its own
Goal: build this thread's seed out of frame, this.thread.x and this.thread.y, so that no two threads and no two frames ever share a stream.

Requirements

Hint 1 — what is wrong with the starter

Run 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.

Hint 2 — stir one coordinate at a time

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.

Hint 3 — why the frame belongs in there

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.

Same idea elsewhere

Counter-based, per-thread generators are how every production GPU renderer does this: CUDA's cuRAND is initialised as 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.

All tasks in Progressive Path Tracing: Noise Melting Into an Image

  1. Dice Every Thread Can Roll
  2. Where the Ray Hits
  3. One Path Per Pixel, and It Looks Terrible
  4. The Buffer That Outlives the Frame
  5. The Noise Falls Like 1/√n

This page is an interactive exercise — the editor, the GPU runner and your saved progress need JavaScript. The text above is the full brief.