Build notes

How this was built

MIRABILIS is six AI oil paintings and one small shader that makes them behave like wet paint. Static assets, no framework runtime, a strict CSP. Here is the whole thing.

The paintings

Every image is a Recraft V4.1 still life, generated as one cohesive family: the same warm candlelit chiaroscuro, the same dark ground, only the subject changing. Keeping the light and background fixed across prompts is what makes six separate images read as one atelier. The exact prompts are on theprompts page.

They are imported through Astro's image pipeline, which resizes each and re-encodes to WebP, turning a 1.7 megabyte source into a right-sized asset without any manual work.

The signature: liquid paint

Each painting is a texture on a single fullscreen WebGL triangle. The whole effect is one idea: before sampling the image, offset the coordinates. A little flowing noise gives a constant stir; a ripple centred on the cursor gives the interaction.

liquid-gl.ts · displacementGLSL
// warp the image's UVs, then sample the texture there
vec2 disp;
disp.x = fbm(uv * 2.6 + vec2(time, 0.0)) - 0.5;   // flowing noise
disp.y = fbm(uv * 2.6 + vec2(0.0, time)) - 0.5;
disp *= 0.010;                                    // a small, constant stir

// a ripple that follows the cursor
float d = distance(uv, pointer);
disp += normalize(uv - pointer) * sin(d*26.0 - time*3.2) * exp(-d*7.0) * hover;

vec3 col = texture2D(image, coverUV(uv + disp)).rgb;

Sampling the red, green and blue channels at slightly different offsets adds a thread of chromatic aberration, which reads as a wet, glassy edge, exactly the thing that sells "liquid" over "wobbly".

the wet edgeGLSL
// sample the three colour channels at slightly different offsets
col.r = texture2D(image, uv + disp + ca).r;
col.g = texture2D(image, uv + disp).g;
col.b = texture2D(image, uv + disp - ca).b;   // a wet, glassy edge

Feeding the shader without a second download

The texture is not a separate fetch. The shader uploads the very<img> element the browser already decoded, so it inherits the responsive image Astro picked and costs nothing extra. If WebGL is missing, or the visitor prefers reduced motion, that same image simply stays on screen, unmoved.

liquid-gl.ts · textureJS
// use the <img> the browser already decoded as the texture, so it costs
// no extra download and respects the responsive srcset Astro generated
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, imgElement);

Keeping it cheap

Each canvas caps its resolution, pauses when it scrolls out of view, and stops when the tab is hidden. The shader is a few kilobytes of JavaScript, so there is almost nothing for the main thread to do, which is what keeps the page fast even with the paint moving.

Deploy in two commands

terminalbash
npx wrangler pages project create mirabilis --production-branch main
npm run build
npx wrangler pages deploy dist --project-name mirabilis --branch main

This is one of three showcase sites. See the prompts, or go back to the atelier.