The Noise Overlay

If you look at the background of this page — the yellow part (#ffb700, thank you for noticing), between the cards — there's a faint grayscale grain over it. It's there on every page of this site. The texture is what stops a flat color background from feeling like a flat color background.

What is it? In a nutshell: a single canvas the size of the document, filled with random pixels at very low alpha, sitting between the background color and the content.

The first version was a shader

When I built this, I was mid-way through a different post about WebGL shaders, so my brain reached for that hammer. The shader version is six lines of GLSL:

uniform vec2 u_resolution;
float hash(vec2 p) {
  return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453123);
}
void main() {
  float n = hash(gl_FragCoord.xy);
  gl_FragColor = vec4(n, n, n, 0.06);
}

A hash function turns each pixel's coordinates into a deterministic-but-chaotic value between 0 and 1, that value goes into all three channels (so it's grayscale), and the whole thing renders at 6% alpha. Wrapped in regl it took maybe forty lines including the canvas wiring, the resize observer, and the regl boilerplate. It looked great.

But then

Halfway through the implementation it hit me: this noise is static. I draw it once on mount, again on resize, and never touch it. There's no animation loop. There's no per-frame computation. The shader runs exactly twice in the lifetime of a page.

A shader is an instrument for parallel computation. The reason to put noise in a fragment shader is when you want to do something with it — animate it, modulate it with u_time, distort the backdrop through a feDisplacementMap. None of that was happening here. I was using a CNC mill to cut a piece of paper.

What I was actually paying for, every page load, was:

  • A WebGL context
  • Shader compilation on first render
  • The regl runtime in my JS bundle (~38KB)
  • A few MB of GL state per context

For a one-shot static effect, all of that is overhead.

The Canvas 2D version

Same visual, no GPU:

const ctx = canvas.getContext('2d')
const draw = () => {
  const rect = canvas.getBoundingClientRect()
  canvas.width = Math.max(1, Math.floor(rect.width))
  canvas.height = Math.max(1, Math.floor(rect.height))
  const img = ctx.createImageData(canvas.width, canvas.height)
  const data = img.data
  for (let i = 0; i < data.length; i += 4) {
    const v = (Math.random() * 256) | 0
    data[i] = v
    data[i + 1] = v
    data[i + 2] = v
    data[i + 3] = 15 // ~6% alpha
  }
  ctx.putImageData(img, 0, 0)
}
draw()
new ResizeObserver(draw).observe(canvas)

That's the entire component. No vertex shader, no buffer, no uniforms, no shader compile, no GL context. Just an ImageData filled with random bytes, written into the canvas with putImageData. The grayscale comes from the same value going into R, G, and B; the subtlety from a 15/255 alpha (~6%, matching the 0.06 from the shader). The browser composites the canvas over the page like any other transparent layer.

For 30 megapixels (a wide, tall document), the inner loop runs 30 million times. That's about 100ms of CPU on first paint and zero work after that. I'll trade 100ms of one-shot CPU for the runtime cost of a perpetually-allocated WebGL context every time.

When the shader earns its keep

Animated grain (each frame regenerated for that "moving film" feel), grain that reacts to mouse position or scroll, distortion driven by noise, or any compositing trick where the noise needs to be sampled by another shader — those are all things Math.random() can't keep up with at 60fps and where moving the work to the GPU pays back the WebGL context cost.

The pattern, more generally: shaders are for per-frame, parallel computation. If you're not doing per-frame computation and you're not exploiting parallelism, you're paying for an instrument you're not playing.

Other wisdom learned

  • Anchor it to the document, not the viewport. If you use position: fixed the noise stays still while the page scrolls, which makes it read as a screen overlay instead of a paper texture. Absolute, sized to the parent, scrolls with content.
  • Render at 1× DPR. Noise is high-frequency, so doubling resolution to match the device pixel ratio doesn't visibly help and on a long page can push the canvas backing buffer past 100MB.