Prism

Painting with noise

The aurora behind this site — fractal noise, curl, and three colors, in one WebGL fragment shader.

webglgraphicsglsl

The background of this site's front page is not a video, not a canvas gradient, and not 4 MB of Lottie. It is one fragment shader, about a hundred lines of GLSL, repainting the sky sixty times a second for roughly the energy cost of scrolling a webpage. This post is the whole recipe.

Noise is the pigment

Everything organic-looking in shader land starts with value noise or simplex noise — a smooth random field. Stack a few octaves at doubling frequencies and you get fractal Brownian motion (fBm), the texture of clouds, marble, and — with the right palette — polar skies.

glsl
float fbm(vec2 p) {
  float v = 0.0, a = 0.5;
  for (int i = 0; i < 5; i++) {
    v += a * snoise(p);
    p = p * 2.03 + 17.0;   // decorrelate octaves
    a *= 0.55;
  }
  return v;
}

Raw fBm is static texture. The aurora trick is domain warping — feed noise its own output as coordinates, then drag the domain with time:

glsl
vec2 q = vec2(fbm(p + uTime * 0.06), fbm(p + vec2(5.2, 1.3)));
vec2 r = vec2(fbm(p + 1.8 * q + uTime * 0.09), fbm(p + 1.9 * q));
float field = fbm(p + 1.7 * r);

Warped noise stops looking like static and starts looking like weather. Iñigo Quílez has the canonical article; I have never once shipped a shader without his site open in another tab.

Three colors, borrowed from the UI

The palette isn't hardcoded. The page hands the shader the same OKLCH-derived roles the buttons use — primary, tertiary, secondary — as linear-RGB uniforms, and the field value picks along a two-stop gradient with a highlight kiss on top:

glsl
vec3 col = mix(uColorA, uColorB, smoothstep(0.15, 0.85, field));
col = mix(col, uColorC, pow(max(r.y, 0.0), 2.5) * 0.6);
col *= vertical;             // fade toward horizon

Because the uniforms come from the design system, re-theming the site re-lights the sky. No shader edits, no second palette to maintain.

The parts that keep it honest

A pretty shader that eats batteries is a bug with good lighting. Prism's scene guards itself:

  • Resolution cap. The canvas renders at min(devicePixelRatio, 1.75), and drops to 1.0 when the tab reports low FPS. Auroras are low-frequency; nobody can tell.
  • Visibility. IntersectionObserver + visibilitychange pause the loop the moment the canvas scrolls away or the tab hides. Scroll down and the GPU goes quiet.
  • Reduced motion. prefers-reduced-motion freezes time at a nice frame — you still get the painting, just not the weather.
  • No WebGL? The <canvas> sits on top of a CSS gradient built from the same tokens. Failure looks like a design choice.

Debugging noise

You cannot console.log a fragment. You can output your intermediates as color, which is better:

glsl
// gl_FragColor = vec4(vec3(field), 1.0);      // inspect the raw field
// gl_FragColor = vec4(q * 0.5 + 0.5, 0.0, 1.0); // inspect the warp

Half of shader authorship is commenting one of these back in, squinting, and adjusting a single magic number by 0.1. It is the most meditative programming I know. The sky above this text took about forty evenings of it, and I'd call every one of them well spent.