Demo

How it works

Three steps.

01

Pick a picture

Twenty-two source plates, generated rather than shipped. Or drop your own image on the stage.

02

Lay lenses over it

Each cell is a mask that samples the picture from a displaced position. An animated wave field decides the displacement.

03

Take it away

A PNG at up to four times the preview, or a folder of numbered frames that loops seamlessly.

The panel

Seven groups, most of them off.

Everything past the first two sections has a master switch and starts off, so the thing you open is simple and gets complicated only where you ask it to.

Export

A still, or a loop that closes.

A PNG comes out at one, two or four times the preview, and it is the preview enlarged rather than a different composition — the wave field is defined in fractions of the frame, not in pixels.

A loop is harder. Every animated quantity has to come back to where it started, and the displacement field originally could not: its time frequencies only realigned after about ten thousand cycles. Space and time are separated now, so the field looks the same and repeats exactly. Everything else — drift, orbit, the field itself — is rounded to a whole number of cycles across the loop, and that rounding is the loop.

Which leaves a choice: bend the speed to fit the length, or the length to fit the speed. Bending the speed is the wrong answer, and it was the first one — at the default a two-second loop ran at nearly twice the preview. So the panel reports the ratio, and Fit picks the length that needs no rounding at all.

Under the hood

Two renderers, one interface.

Drawing each lens on the CPU costs a fixed amount per shape, so it scales with how many there are. A fragment shader inverts that: it is handed a pixel and works out for itself which lens covers it, so the cost is per pixel and flat. Neither wins everywhere — at the default grid the CPU is fifteen times faster, and at twenty-five thousand shapes the GPU is twenty times faster.

So the default picks. Dense grids and anything feathered go to the GPU, everything else stays on the CPU, and the header says which one actually drew the last frame rather than which one was asked for.

Getting the two to agree needed one shared change. The per-shape random draw used the usual sine-and-fract hash, which is precision-dependent: JavaScript computes it in double and a GPU in single, and past a few thousand shapes the two produce entirely different numbers. Both look random, which is what makes it hard to catch — it stayed invisible while the hash only picked colours and appeared the moment it started deciding geometry. Both now run the same integer hash.

Built with

TypeScript React Canvas 2D WebGL2 Vite Netlify Claude Code