Keyboard shortcuts

Anywhere on the site, while you are not typing.

M
Weather sound on or off
E
Copy my email
T
Day or night
S
Change the season
D
Portfolio
?
These shortcuts
Esc
Close what is open

all posts

A lake that never makes the page wait

How the WebGL lake at the top of this site paints in a worker, compiles its shaders alongside, holds to a pixel budget, and costs nothing when you can't see it.

Creaturesfish · ducks · heronWorkerOffscreenCanvas paintBitmapstransferred, not copiedWebGL2 · every frameWave simulationhalf-float · 9-point · fixed 60 HzCausticsfrom refracted triangle areasCompositedepth · glints · shorelineThe pagetext first · a 48 × 32 preview · then the live framewakeslayers
A worker paints the terrain off the main thread, the GPU steps the water, and the page shows its text and a blurred preview before any of it is ready.

The lake is the first thing you see on this site and the heaviest thing on the page. Painting its terrain takes the better part of a second on a laptop and several seconds on a phone. The water is a GPU simulation stepping every frame, and the creatures run on the CPU.

So it goes last. The rules: the text paints first, the lake never blocks input, and it costs nothing when you can't see it.

The lake full screen: a painted pond with an island, a jetty, lily pads and trees around the shore.
The lake at /pond: painted in code, the water simulated, the creatures alive.

Painting off the main thread

The terrain paints in a Web Worker on an OffscreenCanvas. Layers come back as ImageBitmaps and data as transferred buffers, so nothing is copied on the way out. The painting code is loaded only when it's needed, and with a worker it never ships to the main thread at all.

Where OffscreenCanvas can't draw, painting waits for the main thread to go idle, with a timeout, since Safari has no requestIdleCallback. It still lands after the page has painted.

Never an empty box

  • A 48 × 32 picture of the finished scene is inlined and blurred, so the hero reads as the lake coming into focus.
  • The scene announces its first real frame, so the page fades it in instead of waiting on it.
  • Creatures wait for the water, so nothing floats on an empty frame.

The GPU

A close view of the water: caustics on the bed, fish under the surface and a duck near the island.
Up close: caustics on the bed and fish under the surface, stepped at a fixed 60 Hz on the GPU.

Shader compiles overlap the painting. Linking starts early; the blocking "did it work?" question is only asked at the first frame, by which point the programs have compiled.

The simulation steps at a fixed 60 Hz, whatever the display runs at, on half-float targets. If WebGL2 or float render targets are missing, it falls back to a still painting with the creatures still moving on top.

Pixel budgets. The device pixel ratio is capped at 2 and the width at 1,700 pixels: enough for a sharp scene without painting a poster.

components/hero/lake/scene.ts

function measure() {  const rect = fxCanvas.getBoundingClientRect();  if (!rect.width || !rect.height) return null;  const dpr = Math.min(window.devicePixelRatio || 1, maxDpr);  let h = Math.round(rect.height * dpr);  if (h * W > maxWidth) h = Math.floor(maxWidth / W);  return h;}

Resizing is cheap. Small changes, a scrollbar or a zoom step, don't trigger a repaint. A real resize shows the nearest existing painting while the new one is made.

Costs nothing when you can't see it

  • It pauses off screen (an IntersectionObserver) and in hidden tabs.
  • Under reduced motion it draws one still frame.
  • It recovers from a lost WebGL context.

Sound cues moved off the input path too, so a splash never waits on the audio context.

How it was measured

  • On an isolated production build, never the dev server.
  • In headless Chrome on the real GPU.
  • With the Long Animation Frames API to find main-thread stalls, because a full trace changed timing enough to hide them.
  • Variants compared back to back, because GPU numbers swing with machine load.

The temporary instrumentation was removed once the work was done.

Keep reading