paint-a-pint

Hack the North 2026 semi-finalist (top 10%)! Digital simulation of acrylic painting made for avid makers, minus the hassle and fumes. Built with HTML/CSS and Three.js + WebGL for 3D models and Spectral.js for mixing as pigments; keeping the artist in me alive.

💭

brainstorming notes: anything, ideas - social media, nova scotia, art...a chat message: that's not a hackathon project, that's a multi-month passion projecta list of ideas crossed out: hardware, basic web app, machine learning/ai, computer vision...
LayerChoiceWhy
RenderingThree.js (WebGL)The 3D room, easel, desk, paint tubes and palette.
Paint enginePlain JavaScript over typed arrays, no DOMRuns the same in the browser and in Node, so I can test the paint physics without opening the room.
ColourKubelka-Munk over a 38-band spectrum, following Spectral.jsColours mix like pigment (blue + yellow makes green, white tints) instead of blending in RGB.
InputPointer EventsPen pressure, smooth fast strokes and palm rejection for Apple Pencil and Wacom.
UIPlain HTML, CSS and DOM, DM Sans self-hostedNo framework. The card, tooltips and palette controls are built in code.
BuildViteDev server and bundling.
TestingNode scriptsChecks for drying, mixing, undo, the palette and saving that I can re-run after a change.
DeployVercelStatic hosting plus analytics. There's no server.
Built withClaude Code (Sonnet 5)How I built it, prompt by prompt.
two polaroids: entering the room, and inside the canvas

How it works

Colours convert to a 38-band reflectance spectrum, mix with Kubelka-Munk, then convert back to linear RGB.

It's a typed-array, allocation-free port of Spectral.js, with the data tables generated from the package by a script. At about 0.2 µs per mix it's cheap enough to run for every bristle on every pixel it touches.

Tinting strength and opacity are per-paint data, so Prussian blue overpowers a weak pigment.

Here's the calculation and what the values correlate to in behaviour - f = water / (solids + water): above 0.3 the paint is open (blends, gets picked up), between 0.3 and 0.12 it's tacky (drags, breaks up), and below 0.12 it locks into a dried film.

Evaporation slows for thick paint and again once a skin forms, so glazes dry in seconds and impasto stays workable. A simulated clock with a time-warp control lets you skip the wait.

Locked paint is inert, so new paint covers it instead of mixing. That's the whole difference between wet-on-wet and wet-on-dry.

Each bristle carries its own load and colour, lays paint along its own path, runs dry, skips over the canvas weave as it empties (dry brush), and picks up open paint it crosses. Flat, filbert, round and palette knife all share this machinery.

Ridges: bristles shove open paint sideways. Displacements are queued and applied after every bristle has run (so order doesn't matter), spread bilinearly over four pixels, and carry their colour and volume.

Each pixel stores paint height on top of a fixed canvas-weave height. The engine emits an unlit colour map and a tangent-space normal map, and the 3D easel and palette use both.

That way the room's own lights shade the paint's thickness, and the ridges don't get lit twice.

The 1300 x 950 canvas is split into 32 px tiles. Only tiles holding wet paint get dried, only dirty tiles get re-shaded, and tiles changed by drying alone are shaded lazily on a per-frame budget. An sRGB lookup table replaced a pow() in the shader loop.

The 3D room renders on demand: a frame is drawn only when the camera, easel, hover state or paint texture changes, and the shadow map only when something that casts a shadow moves. On touch devices the pixel ratio is capped, MSAA is reduced and the shadow map is halved.

Idle and painting draw calls dropped to zero in desktop Chrome. I haven't measured it on the iPad yet.

A full-canvas snapshot is about 28 MB, so my first undo only went back one step. Now a step saves only the tiles a stroke touches, the first time it touches them.

History is capped at 40 steps or 96 MB, and a test asserts that every undo restores the canvas exactly, including clear and dry-now.

The palette mesh is built from a parametric outline (an ellipse with a dent and a thumb hole), extruded into a wooden board, and a second engine instance is UV-mapped onto it.

A pointer ray is intersected with the mesh, the hit's UV is converted to engine pixels, and that drives the squeeze and mix tools. The hole and dent aren't in the mesh, so they can't be painted. Paint on the palette dries and is lit like everything else.

Pointer Events with coalesced samples, so fast strokes keep their shape. Apple Pencil and Wacom pressure drive the brush directly, and touch is ignored for a few seconds after a pen is detected (palm rejection).

Mouse and trackpad have no pressure, so it's faked from stroke speed: move fast and the stroke gets lighter, like a hand skimming.

The engine exports its colour, wet layer and dried film (about 30 MB of floats), and a change counter says when a stroke, clear, dry-now or undo made a save worth doing. Every two seconds, and when the tab is hidden, it gzips the state with the browser's stream API and writes it to IndexedDB with a timestamp.

On load, the paint is dried for however long the tab was closed, so a wet painting reopened the next day is dry.

hello, thanks for reading! design thoughts coming soon...