Thick paint you push around. A loaded brush lays a ridged stroke and picks up the wet paint it crosses, so colours drag into each other; a palette knife scrapes paint off and piles it where it stops. Paint stays wet until it dries, by the clock or when you say so, and then later strokes ride over it. Light from the upper left shows every ridge, and wet paint shines. Colours mix like pigment: blue dragged through yellow goes green.
import { OilGpu } from "shodo/paint";
const sheet = new OilGpu(canvas, 900, 600, { weave: 0.5, openTime: 0.5 });
sheet.brush([[40, 60], [400, 80]], { radius: 12, color: [0.82, 0.12, 0.08], hide: 1.6 });
sheet.knife([[100, 120], [200, 40]], { width: 20 });
sheet.render();
That is a cadmium-red stroke on a medium canvas, then a knife dragged through it while it is wet.
Two sheets
| Class | What it is |
|---|---|
OilGpu | Draws on a WebGL2 canvas. Use this one. |
OilSurface | The same paint in plain numbers on the CPU, for tests and tools. render() returns RGBA bytes. Slow on big sheets. |
Both take the same calls and give the same picture (within 2 levels of 255).
The sheet
weavesmooth boardcoarse canvasThe canvas under the paint. Shows on bare canvas and through thin paint, hides under thick paint. A brush running dry skips over the threads. Fixed when the sheet is made; default 0.5.
openTimedries in secondsstays wetHow long paint stays workable. 0 is about 5 s, 0.5 about 2 minutes, 1 never (the default). Call
tick(seconds)a few times a second for it to act.reliefflatraking lightHow strongly light shows the paint’s height. Default 0.4.
1 more dial
glossmattglossyHow much wet paint shines. Dried paint shines about a third as much. Default 0.5.
scale (cells per sheet unit, 1 by default) makes a sharper sheet of the same picture: at 2 the sheet has twice the cells each way, and the weave, the bristles, how far a brush runs and how the light reads the ridges all keep their size. Radius and coordinates stay in cells, so multiply them by the scale too. Fixed when the sheet is made. Painting live, a pointer move and a render take about 0.5 ms at 640 by 480 and 0.6 ms at scale 2 on a desktop GPU. A whole 40-point brush stroke drawn in one call costs about 3 ms at 640 by 480 and 4 to 5 ms at scale 2 on a desktop GPU, a knife stroke 7 and 16 ms (3 and 6 before the plough in entry 80, 2 and 3.3 before entry 77); a live knife frame stays about 1.5 ms at scale 2. The knife carries paint the same distance at any scale (since 2026-11-10’s entry 75).
relief, gloss and openTime are properties too: change them any time and call render().
The brush
brush(path, options) paints along a path of [x, y] points. brushStroke(options) returns { move(points) } for drawing as the pointer moves, keeping the paint on the brush between moves.
| Option | What it does |
|---|---|
radius | Half the brush’s width in cells, up to about 50 (31 before 2026-11-10). |
color | sRGB, 0 to 1 a channel. |
load | How much paint is on the brush. The default, 10, lays about 0.4 thick in one pass and runs down by about half over 160 px. 0 is a clean brush that only moves paint. |
pressure | 0 to 1. Lays and lifts more as it rises. |
hide | How well the paint covers: 1 is average, under 1 a transparent pigment (ultramarine 0.35, viridian 0.3), over 1 an opaque one (titanium white 2, the cadmiums 1.6). Transparent paint over dry paint is a glaze. |
seed | Picks the bristle pattern. Give each stroke a new one. |
The knife
knife(path, { width, pressure }) drags a flat blade along the path. It scrapes the wet paint under it thin, carries it along and sets the rest down where it stops, raising an edge there. It moves paint, never adds or removes any. knifeStroke(options) returns { move(points), end() }; end() sets the pile down. Since 2026-11-10 (entry 80) the blade also ploughs: it pushes a coarse flow ahead of it, which moves the wet paint in front of and beside it, so paint banks up along both sides of a drag. The flow stops when the blade lifts.
Fast strokes
A pointer moving fast sends samples far apart, and a stroke drawn straight between them shows each one as a corner. Pass the samples through smoother(step) from shodo/paint first (since 2026-11-10): add(points) returns the points to draw now and end() the last ones. It rounds the corners and lags the pointer by half a segment. It works for any medium’s strokes.
const smooth = smoother(2);
const stroke = sheet.brushStroke({ radius: 12, color: [0.82, 0.12, 0.08] });
stroke.move(smooth.add(pointerSamples));
stroke.move(smooth.end());
Drying
dry(share) dries share of the wet paint at once (1 by default, everything). tick(seconds) dries by the clock at the sheet’s openTime. Dried paint looks the same as it did wet, apart from the shine, and nothing can pick it up or scrape it: later strokes go over it.
Reading back
OilGpu.read() returns the state as arrays (pigment, volume, dry height, the dried colour), and held() how much paint is left on the last brush. Both stall the GPU, so use them in tests and tools, not every frame.
Try it
playground/lab/oil.html on the playground’s dev server: brush and knife, seven colours with their own hiding power, sliders for every dial, Dry and Clear. Add ?weave=0 to ?weave=1 to the address for the canvas.
Not yet
One rate of drying for all paint (no fat over lean), round brushes only, a perfectly regular canvas weave, no saving or undo, no feel panel. How it was built and why: research/oil-paint.md.