How the moving ink in the header is drawn

graphicswebglglsl

There are no images in the header canvas. Everything is computed live: two triangles cover the screen, and every pixel runs the same fragment shader.

Palette

Once the colors are set, half the picture is done. I picked three from traditional Chinese painting pigments:

Name RGB Role
Ink (墨) (.071, .071, .078) Base
Rice paper (宣纸) (.925, .894, .827) Highlights
Azurite (石青) (.165, .353, .545) Midtones

The whole image is an interpolation between these three.

Noise and domain warping

The building block is a 3D noise function, noise(vec3), stacked five octaves deep into fbm (fractal Brownian motion):

float fbm(vec3 p) {
  float sum = 0., amp = 1., norm = 0.;
  for (int i = 0; i < 5; i++) {
    sum  += amp * noise(p);
    norm += amp;
    amp  *= 0.5;
    p = p * 2.03 + vec3(3.1, 1.7, 0.);
  }
  return sum / norm;
}

Using fbm directly as color looks like clouds, not ink. The key step is domain warping: two fbm calls produce an offset field q, which is added back onto the coordinates, and the warped coordinates are used to sample the noise a second time.

vec2 q  = vec2(fbm(vec3(p * 0.5, t * 0.1)),
               fbm(vec3(p * 0.5 + vec2(1.0), t * 0.1)));
vec2 pw = p + 3. * q;
float n = fbm(vec3(pw, t * .07));

The 3. * q factor controls how far the ink bleeds. Turn it up and it gets wilder; turn it down and it looks more like fog.

Mapping to color

The noise is squeezed into 0–1, then two smoothstep ranges control the ink → azurite and azurite → paper transitions:

float k  = clamp(0.5 + 2. * n, 0., 1.);
vec3 col = mix(INK, QING, smoothstep(.1, .5, k));
col      = mix(col, PAPER, smoothstep(.5, .9, k));

Time t slowly advances along the noise’s third dimension, so the image spreads like ink in water.

What changed when it became a header

The assignment canvas was square. The header is a wide strip, which would stretch the noise horizontally, so a uAspect uniform scales the x coordinate by the aspect ratio to keep the noise isotropic. Also:

  • requestAnimationFrame replaces setInterval, still capped at 30 fps;
  • drawing stops when the header scrolls out of view or the tab goes to the background;
  • with “reduce motion” turned on in the OS, a single still frame is drawn;
  • device pixel ratio is capped at 1.5 and canvas width at 1920 to save GPU.

← All posts