In art history, facture means the way the paint is laid on. Not what was painted, but how the brush went. That is what this engine carries across.
Above is Van Gogh’s The Starry Night drawn twice and laid one over the other. Drag the seam: on the left, direction thrown away; on the right, the brush direction pulled out of the original. The colour, the stroke positions and the cell grid are identical — only the angle differs. That difference is the whole project.
Slicing a screen into a grid and filling it with colour is common enough. What separates the Facture look from that is the three rules below — and they are not a sequence. All three hold at every cell at once.
Every colour on screen comes from the original. A colour map reduced to 144px on its long side is sampled bilinearly; there is no palette kept on the side. A screen does read duller than paint, so saturation is lifted ×1.28 and brightness ×1.07 — not making colours that were never there, but recovering what the display loses.
Each cell’s stroke lies along the direction the original was actually painted. The churning night sky runs along the tangent of its swirls, the cypress along its vertical flame, the wheat the way the wind lays it. Take the direction away and what is left is a mosaic.
Every stroke’s length, width and position sit a little off from its neighbours, and a per-cell brush value shakes brightness by ±8%. On bright cells a lighter ridge is laid along part of the stroke to make the raised crest of impasto, and that bright segment travels along the stroke’s own axis — light running the way the brush went. Dark cells get no ridge; it would not be visible there anyway.
Fitting a work to its own proportions leaves space beside the picture. Left empty the screen reads as broken, so the margin is painted too. Its grain is a swirl unrelated to the work, wrapping around the picture — it stays calm even as works change — while its colour does the opposite, taking hold at the picture’s edge and fading toward the ground as it goes. The point is to make the margin look like something that bled out of the painting.
The early approach of drawing procedurally was abandoned. Every scene now samples data pulled out of the original painting. There are four steps: the first two happen once, before the build; the last two on every frame.
The extractor pulls a colour map and a brush-direction field out of the source image and writes them as a TS module. The colour is RGB reduced to 144px on its long side, proportions kept. The direction comes from the structure tensor — find where luminance climbs most steeply, then take the contour direction orthogonal to it. That contour is exactly the grain the paint was laid in. Flat places, where the gradient is even, have no direction to speak of, so the field is weighted by coherence and blurred again to let them borrow their neighbours’.
At runtime a work is two functions. They see the area the picture occupies as 0..1 coordinates; one returns the colour at that point, the other the brush angle.
type Scene = (nx, ny, t, ar) => RGB // colour at the point type FlowFn = (nx, ny, t, ar) => number // brush angle (rad)
Both are called once per cell, every frame. At 1080p with an 11px cell that is roughly 17,600 calls a frame. Which is why these functions never build an array or an object inside.
The renderer divides the canvas into a cell grid and takes a colour and an angle at each cell centre. It rotates the coordinate system by that angle, lays down one elongated rectangle, and on bright cells puts a ridge on top. Length, width and position are shaken by a deterministic hash of the cell coordinates, so the same place always takes the same shape. The grid starts one cell outside the screen so that rotated strokes never leave the edges bare.
Time is handled as elapsed seconds, not as a frame count. Adding a constant per frame changes the speed when a covered window slows rAF down, or on a 120Hz display. dt is capped at 0.25s so time does not jump when you come back to the tab.
Changing works blends the outgoing scene with the incoming one. Colour can simply be interpolated, but direction cannot.
A brushstroke is symmetric through 180°. A brush lying at 10° and one at 190° are the same picture, so the sign of a direction carries no meaning — and blending angles as plain numbers breaks that fact. 10° and 170° sit only 20° apart across 0°, and yet the midpoint of the two numbers is 90°: exactly vertical.
The fix is to double the angle and hold it as a unit vector. Doubled, the two become 20° and 340°, which close in on 0°; blend the vectors, halve the result, and it passes the near way with no ambiguity. It is also why the extracted field is stored in that form to begin with — one direction is two int8s, the cosine and sine of the doubled angle.
What runs on screen is the canvas renderer, but the share-card images and the thumbnails in the strip below are made on the server. A server has no canvas, so a separate copy bakes the same stroke geometry into an SVG string. Thumbnails just shrink the cell to 4px and use that copy as it is — rather than writing the geometry out a third time.
The cost is that the two copies have to be kept in step by hand. Change a stroke ratio or a colour correction on one side and the other has to follow. The extractor is in the same position: the Python copy that bakes ahead of time and the browser copy that handles an uploaded image on the spot must produce the same result, and a comparison script checks that they do.
| Item | |
|---|---|
| Colour map, long side | 144 px |
| Direction field working resolution | 432 px |
| Structure tensor blur σ (pre / component / re-blur) | 1.2 · 5.0 · 4.0 |
| Size of one direction | int8 × 2 (±127) |
| One work | gzip about 75 KB |
| Cell edge | 11 CSS px |
| Cells per frame (1080p) | about 17,600 |
| Stroke length | 1.70–2.30 × cell |
| Stroke width | 0.50–0.76 × cell |
| Position jitter | ±0.25 cell |
| Ridge (length / thickness) | 38% · 30% |
| Ridge travel along the stroke | 0.7 /s |
| Saturation · brightness correction | ×1.28 · ×1.07 |
| Work crossfade | 2.7 /s — about 0.37 s |
| dt cap | 0.25 s |
| devicePixelRatio cap | 2 |