# The Buffer That Outlives the Frame

*Task 4 of 5 · [Progressive Path Tracing: Noise Melting Into an Image](https://gpu.rocks/learn/path-tracing-c99efc67.md) · GPU.js Learn*

Nothing in this scene moves. So every frame you trace is another independent
estimate of the *same* integral — and the way to spend them is not to throw the last
one away, but to keep a running average of all of them in a buffer that survives from frame
to frame. Frame 48 is not 48 times more work per frame; it is the same work, added to what
you already had.

The average has to be exact, not exponential. *Video Filters* blends each new
frame in with a fixed `alpha` because its scene is moving and old frames go
stale; here nothing goes stale, so every frame gets an equal vote:

```js
next = (previous * n + sample) / (n + 1)
```

And the buffer lives on the card. This is the ping-pong from *Pipelines &
Textures* doing real work: the kernel reads the very texture it is about to replace, so
`immutable: true` is what makes the loop legal — each call renders into a
*fresh* texture and last frame's numbers stay safe to read while this frame's are
being written. Leave it off and gpu.js stops you with the reason. (Auto runs this one on
WebGL throughout, deliberately: a graphical kernel can never be a WebGPU kernel, so
letting the maths upgrade would hand the buffer across backends once a frame — measured
here at 71 ms against 25 ms for the whole 48-frame loop.)

Then render every pass. Consecutive `render()` calls collapse into a
scrubber, so you get to drag the noise away by hand.

## Figures

- **the new frame gets 1/(n+1) of the vote; everything before it keeps the rest**

## Goal

**Goal:** make `accumulate` a running mean, and make the
feedback loop legal, so that 48 traced frames melt into one clean image.

## Requirements

- Return `(old * n + now) / (n + 1)` — an exact mean of every frame so far, not a blend
- Add `immutable: true` to `accumulate` (keep `pipeline: true`)
- Call `render(paint.canvas)` *inside* the loop, once per frame, for the scrubber

## Hint 1 — run the starter and read the error

gpu.js refuses the second pass of the loop: the kernel's input is its own
output storage. WebGL names the fix in the message; WebGPU only tells you the buffer
belongs to the kernel. Either way `immutable: true` is it — a fresh output
texture per call, so reading last frame's is safe.

## Hint 2 — where the n comes from

`n` is how many frames are *already* in the buffer, which is
the loop counter. At `n = 0` the formula returns the new sample untouched,
which is exactly right: the zero buffer must not get a vote.

## Hint 3 — the two edits

In the kernel body:

```js
return (old * n + now) / (n + 1);
```

and in its settings, beside `pipeline: true`:

```js
immutable: true,
```

## Same idea elsewhere

Every "progressive" viewport you have ever watched resolve — Cycles, Arnold's IPR,
a Substance or Unreal path-traced preview — is this loop: a persistent accumulation buffer
plus a sample count. On the GPU it is always two textures being ping-ponged, because a
shader may not read the surface it writes; WebGPU makes you bind two storage textures and
swap them, CUDA makes you swap two device pointers, and `immutable: true` is
gpu.js doing that bookkeeping for you. Real-time renderers do the same trick within a
*moving* scene by reprojecting the history with motion vectors — that is what the
"TA" in TAA stands for.

## Starter code

```js
const gpu = new GPU({ mode });

// The whole random-number generator. It is a pure function: same state in,
// same number out, on every backend and every run.
gpu.addFunction(function nextRandom(state) {
  let p = state * 78.233 + 0.7213;
  p = p - Math.floor(p);
  p = p * (p + 61.7);
  return p - Math.floor(p);
});

// Nearest hit of a unit-direction ray against a sphere, or -1 for a miss.
gpu.addFunction(function sphereT(ox, oy, oz, dx, dy, dz, cx, cy, cz, r) {
  const ex = ox - cx;
  const ey = oy - cy;
  const ez = oz - cz;
  const b = ex * dx + ey * dy + ez * dz;
  const c = ex * ex + ey * ey + ez * ez - r * r;
  const disc = b * b - c;
  if (disc <= 0) {
    return -1;
  }
  const t = -b - Math.sqrt(disc);
  if (t <= 0.001) {
    return -1;
  }
  return t;
});

// The floor: one division, no quadratic.
gpu.addFunction(function planeT(oy, dy, planeY) {
  if (dy > -0.0001) {
    return -1;
  }
  const t = (planeY - oy) / dy;
  if (t <= 0.001) {
    return -1;
  }
  return t;
});

// The only light in the scene is the sky itself.
gpu.addFunction(function skyLight(dy) {
  if (dy < 0) {
    return 0.3;
  }
  return 0.3 + 0.7 * dy;
});

const trace = gpu.createKernel(function (frame, spp) {
  // This thread's own stream: start from the frame, stir in the column,
  // then the row. Every stir goes through nextRandom, so two threads one
  // pixel apart come out completely unrelated.
  let seed = 0.1237 + frame * 0.0173;
  seed = nextRandom(seed + this.thread.x * 0.0713);
  seed = nextRandom(seed + this.thread.y * 0.0917);

  let total = 0;
  for (let s = 0; s < spp; s++) {
    // One camera ray, jittered inside this pixel — so the edges antialias
    // themselves for free as the frames pile up.
    seed = nextRandom(seed);
    const u = ((this.thread.x + seed) / 64) * 2 - 1;
    seed = nextRandom(seed);
    const v = ((this.thread.y + seed) / 64) * 2 - 1;
    let dx = u;
    let dy = v;
    let dz = -1.7;
    let len = Math.sqrt(dx * dx + dy * dy + dz * dz);
    dx = dx / len;
    dy = dy / len;
    dz = dz / len;
    let ox = 0;
    let oy = 0.3;
    let oz = 1.5;
    let through = 1;

    // Up to three segments: camera → surface → surface → sky.
    for (let d = 0; d < 3; d++) {
      const tA = sphereT(ox, oy, oz, dx, dy, dz, -0.45, 0, -0.4, 0.5);
      const tB = sphereT(ox, oy, oz, dx, dy, dz, 0.5, -0.2, -0.1, 0.3);
      const tP = planeT(oy, dy, -0.5);
      let t = 999;
      let which = -1;
      if (tA > 0 && tA < t) {
        t = tA;
        which = 0;
      }
      if (tB > 0 && tB < t) {
        t = tB;
        which = 1;
      }
      if (tP > 0 && tP < t) {
        t = tP;
        which = 2;
      }
      if (which < 0) {
        // Nothing in the way — the path ends in the sky, which is the light.
        total = total + through * skyLight(dy);
        break;
      }

      const hx = ox + t * dx;
      const hy = oy + t * dy;
      const hz = oz + t * dz;
      let nx = 0;
      let ny = 1;
      let nz = 0;
      let albedo = 0.55;
      if (which === 0) {
        nx = (hx + 0.45) / 0.5;
        ny = hy / 0.5;
        nz = (hz + 0.4) / 0.5;
        albedo = 0.85;
      }
      if (which === 1) {
        nx = (hx - 0.5) / 0.3;
        ny = (hy + 0.2) / 0.3;
        nz = (hz + 0.1) / 0.3;
        albedo = 0.3;
      }

      // A uniformly random point on the unit sphere, out of this thread's
      // stream: z is uniform on [-1, 1] and phi uniform around the axis.
      seed = nextRandom(seed);
      const z = 2 * seed - 1;
      seed = nextRandom(seed);
      const phi = 6.2831853 * seed;
      const rr = Math.sqrt(Math.max(1 - z * z, 0));
      const sx = rr * Math.cos(phi);
      const sy = rr * Math.sin(phi);
      const sz = z;

      through = through * albedo;
      dx = nx + sx;
      dy = ny + sy;
      dz = nz + sz;
      len = Math.sqrt(dx * dx + dy * dy + dz * dz);
      dx = dx / len;
      dy = dy / len;
      dz = dz / len;

      // Step off the surface so the next ray does not hit it at t = 0.
      ox = hx + 0.001 * nx;
      oy = hy + 0.001 * ny;
      oz = hz + 0.001 * nz;
    }
  }
  return total / spp;
}, { output: [64, 64], loopMaxIterations: 8, pipeline: true });

// A buffer of zeros to start the average from — the same trick Pipelines &
// Textures uses to get its seed onto the card as a texture.
const black = gpu.createKernel(function () {
  return 0;
}, { output: [64, 64], pipeline: true });

const accumulate = gpu.createKernel(function (previous, sample, n) {
  const old = previous[this.thread.y][this.thread.x];
  const now = sample[this.thread.y][this.thread.x];
  // TODO: the running mean of every frame so far — (old * n + now) / (n + 1).
  return now;
}, {
  output: [64, 64],
  pipeline: true,
  // TODO: this kernel reads its own previous output. Run it as-is and let
  // gpu.js tell you the setting it wants.
});

// Radiance in, pixels out. The square root is a gamma curve: it is what
// makes a 0.2 and a 0.4 look as different as they are.
const paint = gpu.createKernel(function (image) {
  const g = Math.sqrt(Math.max(image[this.thread.y][this.thread.x], 0));
  this.color(g, g, g, 1);
}, { output: [64, 64], graphical: true });

// A real dial: moving it re-runs the whole program.
const spp = slider('samples per frame', { min: 1, max: 4, value: 2, step: 1 });
const FRAMES = 48;

let acc = await black();
for (let f = 0; f < FRAMES; f++) {
  const sample = await trace(f, spp);
  acc = await accumulate(acc, sample, f);
  await paint(acc);
  render(paint.canvas);
}

console.log('total samples per pixel:', FRAMES * spp);
```

---

Interactive version: https://gpu.rocks/learn/path-tracing-c99efc67/4

[Previous task](https://gpu.rocks/learn/path-tracing-c99efc67/3.md) · [Next task](https://gpu.rocks/learn/path-tracing-c99efc67/5.md)
