Watercolour that behaves like watercolour. Paint pools, creeps along the tooth of the paper, darkens at its rim as it dries and settles into the valleys. Tilt the sheet and it runs.
import { PAPERS, WetGpu } from "shodo/paint";
const sheet = new WetGpu(canvas, 600, 400, {
paper: PAPERS.coldPress,
feel: { wetness: 0.8, edges: 0.7, gravity: 0.6 },
});
That sheet takes flooded strokes that dry with hard tidelines and drip down the page. Leave feel out and you get the sheet shodo has always made.
Feel
Nine dials, each from 0 to 1. Before 2026-11-10 (record entry 62) there were six: strength, granulation and bleed came then. Every dial rests at 0.5 except gravity, which rests at 0. A dial you don’t set stays where it rests.
wetnessdampfloodedHow much water a stroke carries: dry strokes sit still, wet ones run, bloom and pool.
dryingstays opensets fastHow fast the sheet gives its water up, and so how long a stroke stays open to work into.
edgessofttidelinesHow dark the rim of a wash gets as it dries.
6 more dials
flowsyrupracingHow freely standing water moves over the paper.
stainingliftsstainsHow hard dried paint holds on when a clear brush goes over it.
gravityflat tableupright easelTilts the sheet so wet paint runs down it and drips off the bottom of shapes.
strengthtintdeepHow much colour a loaded brush lays down in one stroke.
granulationsmoothspeckledHow much pigment drops out into the grain of the paper, speckling a wash.
bleedcrispfeatheredHow far a wet stroke feathers out into damp paper around it.
The same words live in WET_FEEL, so a controls panel can be built straight from it:
import { WET_FEEL } from "shodo/paint";
for (const [key, dial] of Object.entries(WET_FEEL)) {
// dial.title, dial.summary, dial.ends ([low word, high word]), dial.value (where it rests)
}
A sheet’s feel is set when it is made. To change it, make a new sheet, and free the old one with dispose(). The wet lab in the playground has every dial on a panel to try.
Things worth knowing
- Wetness changes time, not size. A stroke never spreads past the paper its brush wets, but a wetter one stays open longer, so it has longer to run and bloom into its neighbours.
- Gravity needs water. A drip runs only as far as its water lasts, so pair gravity with some wetness. Thirsty paper (
coldPress) soaks a drip up sooner thanlaid. - Drips need dry paper below them. A drip runs onto paper that was dry; inside a wash that is already wet, gravity moves the water without leaving a trail.
Paper
The paper sets the tooth paint settles into, how thirsty the sheet is and its colour. Four to start from: laid, sanded, coldPress (the default for wet sheets) and toned. Any field of a Paper can be changed:
const paper = { ...PAPERS.coldPress, scale: 10, absorbency: 0.5 };
Pigments
Give a sheet three named pigments and their glazes mix the way transparent paint does, worked out wavelength by wavelength: hansa yellow over phthalo blue is a clean green, phthalo green over quinacridone rose goes grey.
import { PIGMENTS, WetGpu } from "shodo/paint";
const sheet = new WetGpu(canvas, 600, 400, {
triad: [PIGMENTS.hansaYellow, PIGMENTS.quinacridoneRose, PIGMENTS.phthaloBlue],
});
const blue = sheet.ink(2, 0.6);
Resolution
Added 2026-11-10. scale is how many cells a sheet has per unit. Make the sheet scale times larger and multiply every length you pass (coordinates and radius) by the same number, and you get the same painting, sharper. Paint per length, edge darkening and the paper’s grain all follow the scale.
const k = Math.min(2, devicePixelRatio);
const sheet = new WetGpu(canvas, 640 * k, 480 * k, { scale: k });
sheet.brush(40 * k, 200 * k, 560 * k, 200 * k, { radius: 20 * k, pigment, load: 1 });
Blooms and runs still spread a set number of cells per step, so on a larger sheet they reach a little less far.
Gouache
Added 2026-11-10. Gouache is opaque watercolour: a light colour over a dark one covers it, a thick coat shows exactly the colour you mixed, it dries flat with hardly a rim and a wet brush lifts it again.
const sheet = new WetGpu(canvas, 600, 400, { medium: "gouache" });
sheet.brush(40, 200, 560, 200, { radius: 20, pigment: { color: [0.95, 0.85, 0.15], granulation: 0 }, load: 1 });
One medium per sheet. Gouache ignores granulation. feel and physics still apply on top of gouache’s own physics (GOUACHE_PHYSICS). In the lab, open wet.html?gouache for a gouache sheet with a white pan.
Underneath: physics
feel is a front for sixteen numbers from the simulation (after Curtis and others, 1997). Set them through physics when a dial is not enough; anything set there wins over feel.
new WetGpu(canvas, 600, 400, { feel: { wetness: 0.8 }, physics: { edge: 0.03, tilt: [0.02, 0.04] } });
WET_PHYSICS holds the numbers every sheet rests on. wetPhysics() fills in the rest of a partial set, holds each number inside a range the simulation stays steady in and refuses anything that is not a number. tilt is the one thing physics can do that feel cannot: point gravity sideways.
The presets WETNESS, DRYING and TILT (added 2026-11-10, record entry 50) are partial physics sets you can spread into physics. Since entry 51 the feel dials are the first thing to reach for; the presets stay.