Where the Crests Cross
2026 · flow
A wake is a rule made visible: two lines leaving and a train of arcs catching up, and the water only turns to foam where they refuse to agree.
the V-wake behind a hull leaving the lower-right corner, transverse crests interfering against the near arm, foam kept as hard-edged paper over prussian troughs
painted by DeepSeek V4p5 2.3.2 · brush 2.2.2 · 2026-08-25
// composition: the V-wake of a hull leaving the lower-right corner, sweeping up-left; the near crest arm
// carries the weight, and wherever a transverse crest crosses it the water hardens into a knot of foam.
// layering: pale water washes, transverse crests and rays, the prussian trough, foam knots, hull and ink last.
// the paper stays bare below the near arm and to the right of the far arm — flat water on both sides.
const PALETTE = [
"#e9e3d4", // ground — warm paper; the foam is reserved out of it
"#b3c2ce", // wash — flat water lying away from the wake
"#3f6a97", // mass — indigo, the body of every crest
"#1c3453", // shadow — prussian trough behind the crest
"#111b28", // ink — the hull, and the hard edge under foam
"#cf8a4c", // accent — low sun caught on a few foam tops
];
const PAPER = PALETTE[0];
const WASH = PALETTE[1];
const BLUE = PALETTE[2];
const DEEP = PALETTE[3];
const INK = PALETTE[4];
const SUN = PALETTE[5];
// the wake is a coordinate system: s runs away from the stern, t runs across the wedge
// (t = -1 on the near arm, t = +1 on the far one). Everything below is drawn in it.
const APEX = [744, 678];
const U = [-0.72, -0.694]; // wake axis, up-left at 44 degrees
const V = [0.694, -0.72]; // across the wake, positive toward the far arm
const CRESTS = [86, 150, 231, 318, 402, 519, 636, 778, 908];
const KNOT_R = [11, 16, 21, 28, 22, 18, 13, 9, 6];
function gj(mean, sd, lo, hi) {
return constrain(randomGaussian(mean, sd), lo, hi);
}
function fade(base, amt) {
return lerpColor(color(base), color(PAPER), constrain(amt, 0, 0.86));
}
function wakePt(s, lat) {
return [APEX[0] + U[0] * s + V[0] * lat, APEX[1] + U[1] * s + V[1] * lat];
}
// the near side of the wedge opens wider — the far arm is foreshortened away from us
function spread(t) {
return t < 0 ? 0.4 : 0.3;
}
function armPt(s, side) {
return wakePt(s, side * spread(side) * s * (1 + s / 9000));
}
// a transverse crest bows away from the stern at the centre and lands on both arms
function crestPt(s0, t) {
const s = s0 * (1 - 0.17 * t * t);
return wakePt(s, t * spread(t) * s * (1 + s / 9000));
}
function transverse(s0, t0, t1, weight, amt) {
brush.noFill();
brush.set(random() < 0.55 ? "cpencil" : "2B", fade(random() < 0.68 ? BLUE : DEEP, amt), weight);
brush.beginShape(0.5);
for (let i = 0; i <= 16; i++) {
const t = lerp(t0, t1, i / 16);
const p = crestPt(s0, t);
brush.vertex(p[0] + gj(0, 2.4, -7, 7), p[1] + gj(0, 2.4, -7, 7), gj(1.05, 0.2, 0.5, 1.55));
}
brush.endShape(false);
}
// a ray of the wake — the locus of one t across the whole crest family
function ray(t0, sA, sB, weight, amt) {
brush.noFill();
brush.set("2H", fade(BLUE, amt), weight);
brush.beginShape(0.4);
for (let i = 0; i <= 12; i++) {
const p = crestPt(lerp(sA, sB, i / 12), t0 + gj(0, 0.012, -0.04, 0.04));
brush.vertex(p[0], p[1], gj(0.9, 0.2, 0.4, 1.4));
}
brush.endShape(false);
}
// en-echelon crest, oblique to the arm and trailing back toward the stern
function feather(s0, side, weight, amt) {
const a = crestPt(s0, side * 1.04);
const b = crestPt(s0 * 0.95, side * (0.8 + random(-0.06, 0.06)));
const c = crestPt(s0 * 0.885, side * (0.5 + random(-0.09, 0.09)));
brush.noFill();
brush.set(random() < 0.5 ? "2B" : "cpencil", fade(random() < 0.55 ? DEEP : BLUE, amt), weight);
brush.beginShape(0.6);
brush.vertex(a[0] + gj(0, 2, -5, 5), a[1] + gj(0, 2, -5, 5), 1.25);
brush.vertex(b[0] + gj(0, 2.6, -6, 6), b[1] + gj(0, 2.6, -6, 6), 0.95);
brush.vertex(c[0] + gj(0, 3.2, -8, 8), c[1] + gj(0, 3.2, -8, 8), 0.55);
brush.endShape(false);
}
function arm(side, sA, sB, col, weight) {
brush.noFill();
brush.set("2B", col, weight);
brush.beginShape(0.45);
for (let i = 0; i <= 20; i++) {
const p = armPt(lerp(sA, sB, i / 20), side);
brush.vertex(p[0] + gj(0, 2.6, -8, 8), p[1] + gj(0, 2.6, -8, 8), gj(1.1, 0.22, 0.5, 1.6));
}
brush.endShape(false);
}
// the outline of a patch of the wake — a quad of its own coordinates, but walked round the
// perimeter with a wandering hand, so no boundary is ever a ruled one
function patchPoly(sA, sB, tA, tB) {
const pts = [];
const wob = (t) => constrain(t + gj(0, 0.04, -0.09, 0.09), -0.99, 0.99);
const sob = (s) => s * (1 + gj(0, 0.03, -0.07, 0.07));
for (let i = 0; i <= 6; i++) pts.push(crestPt(sob(lerp(sA, sB, i / 6)), wob(tA)));
for (let i = 1; i <= 3; i++) pts.push(crestPt(sob(sB), lerp(tA, tB, i / 4)));
for (let i = 0; i <= 6; i++) pts.push(crestPt(sob(lerp(sB, sA, i / 6)), wob(tB)));
for (let i = 1; i <= 3; i++) pts.push(crestPt(sob(sA), lerp(tB, tA, i / 4)));
return pts;
}
// flat colour, thin enough that it is never read as a shape
function plate(sA, sB, tA, tB, col, op, bleed) {
brush.noStroke();
brush.fillBleed(bleed, "out");
brush.fillTexture(0.62, 0.72);
brush.fill(col, op);
brush.polygon(patchPoly(sA, sB, tA, tB));
}
// tone that runs with the crest and thins away from it — the water's grain, not its silhouette
function band(sA, sB, tA, tB, name, col, weight, dist, ang) {
brush.noFill();
brush.hatchStyle(name, col, weight);
brush.hatch(dist, ang, { rand: 0.32, continuous: false, gradient: 0.55 });
brush.polygon(patchPoly(sA, sB, tA, tB));
brush.noHatch();
}
function wash(x, y, r, col, op, bleed) {
brush.noStroke();
brush.fillBleed(bleed, "out");
brush.fillTexture(0.42, 0.35);
brush.fill(col, op);
brush.circle(x, y, r, true);
}
// foam: paper laid back over the dark with almost no bleed, so its edge stays hard
function foamKnot(x, y, r, rim) {
brush.noStroke();
brush.fillBleed(0.07, "out");
brush.fillTexture(0.72, 0.95);
brush.fill(PAPER, 238);
brush.circle(x + gj(0, 1.8, -5, 5), y + gj(0, 1.8, -5, 5), r, true);
if (r > 9) {
brush.fill(WASH, 74);
brush.circle(x + gj(6, 3, -3, 13), y + gj(7, 3, -2, 14), r * 0.44, true);
}
if (rim) {
brush.noFill();
brush.set("rotring", INK, 0.85);
brush.beginShape(0.7);
brush.vertex(x - r * 1.02, y - r * 0.2);
brush.vertex(x - r * 0.4, y - r * 0.98);
brush.vertex(x + r * 0.55, y - r * 0.86);
brush.endShape(false);
}
}
function setup() {
createCanvas(800, 800, WEBGL);
brush.scaleBrushes(2.4);
noLoop();
}
function draw() {
translate(-width / 2, -height / 2);
background(PAPER);
// --- pale air, three broad breaths of it, before anything has an edge ---
wash(316, 288, 208, WASH, 74, 0.6);
wash(520, 424, 186, WASH, 88, 0.55);
wash(150, 706, 140, WASH, 22, 0.72);
wash(704, 192, 152, WASH, 18, 0.72);
// --- the water inside the wedge. Four chains of blotting stepping out from the stern in the
// wake's own coordinates: the chain against the near crest is prussian and nearly opaque, each
// one further in only breathes on the paper. Nothing crosses the crest, so it keeps a hard
// edge against clean paper — the colour of a mark follows how much of it there is. ---
brush.noStroke();
brush.fillTexture(0.6, 0.55);
brush.fillBleed(0.2, "out");
for (let i = 0; i < 72; i++) {
const u = i / 71;
const s = 40 + 740 * pow(u, 1.9) * (1 + gj(0, 0.05, -0.1, 0.1));
const t = gj(-0.72, 0.19, -0.96, -0.18);
if (s > 800) continue;
const op = gj(152 - 76 * u, 16, 38, 178);
const r = 11 + s * 0.105;
const p = crestPt(s, t);
brush.fill(t < -0.58 && u < 0.52 ? DEEP : u < 0.76 ? (random() < 0.7 ? BLUE : DEEP) : WASH, op);
brush.circle(p[0], p[1], gj(r, r * 0.2, r * 0.55, r * 1.7), true);
}
// the inside of the wedge and its far half, only breathed on
brush.fillBleed(0.3, "out");
for (let i = 0; i < 12; i++) {
const s = gj(300, 190, 60, 700);
const p = crestPt(s, gj(0.24, 0.42, -0.2, 0.92));
brush.fill(random() < 0.4 ? BLUE : WASH, gj(64 - s * 0.04, 12, 18, 84));
brush.circle(p[0], p[1], gj(44, 12, 20, 70), true);
}
plate(60, 400, -0.06, 0.6, WASH, 58, 0.28);
plate(280, 660, 0.06, 0.92, WASH, 32, 0.3);
// grain: the crest's own tooth, and one cross-grain where the trough is deepest
band(60, 430, -0.98, -0.8, "cpencil", DEEP, 0.7, 4.2, 22);
band(150, 470, -0.94, -0.62, "2H", fade(DEEP, 0.42), 0.5, 9, 106);
// the deepest note: prussian banked hard inside the crest, close behind the stern
brush.fillBleed(0.16, "out");
for (let s = 62; s < 400; s += 42) {
const lat = -spread(-1) * s * (1 + s / 9000) + gj(16, 6, 4, 32);
const p = wakePt(s, lat);
brush.fill(DEEP, gj(192, 16, 140, 220));
brush.circle(p[0], p[1], gj(15, 3.5, 7, 25), true);
}
// --- the transverse crests, back-to-front: bodied and near, dissolving far out ---
for (let k = CRESTS.length - 1; k >= 0; k--) {
const s0 = CRESTS[k];
const amt = constrain(map(s0, 80, 950, 0.04, 0.62), 0, 0.62);
const w = map(k, 0, 8, 2.1, 0.7) + random(-0.12, 0.16);
if (k < 3) {
brush.noFill();
brush.set("marker", fade(BLUE, 0.3 + amt * 0.25), 2.3 - k * 0.35);
brush.beginShape(0.5);
for (let i = 0; i <= 10; i++) {
const p = crestPt(s0 * 1.008, lerp(-0.96, 0.9, i / 10));
brush.vertex(p[0] + gj(0, 3, -8, 8), p[1] + gj(0, 3, -8, 8), gj(1, 0.2, 0.5, 1.5));
}
brush.endShape(false);
}
if (k >= 7) {
// the furthest crests only survive in fragments
transverse(s0, -1.0, -0.18, w, amt + 0.08);
transverse(s0 * 1.008, 0.28, 0.98, w * 0.85, amt + 0.14);
} else {
transverse(s0, -1.0, k % 3 === 2 ? 0.42 : 1.0, w, amt);
if (k % 3 === 2) transverse(s0 * 1.01, 0.62, 1.0, w * 0.8, amt + 0.12);
}
if (k < 6) transverse(s0 * (1 + random(0.02, 0.05)), -0.94, random(-0.1, 0.6), w * 0.6, amt + 0.16);
}
// --- rays, kept short: where one crosses a crest the two waves interfere and it brightens ---
ray(-0.55, 96, 520, 0.62, 0.4);
ray(-0.55, 604, 830, 0.58, 0.54);
ray(-0.26, 118, 466, 0.55, 0.48);
ray(0.34, 104, 430, 0.58, 0.44);
ray(0.7, 132, 358, 0.5, 0.52);
brush.noStroke();
brush.fillBleed(0.1, "out");
brush.fillTexture(0.65, 0.9);
for (let k = 1; k < 6; k++) {
for (const t0 of [-0.55, -0.26, 0.34, 0.7]) {
if (random() < 0.52) continue;
const p = crestPt(CRESTS[k] * (1 + gj(0, 0.014, -0.03, 0.03)), t0);
brush.fill(PAPER, gj(220, 20, 150, 246));
brush.circle(p[0], p[1], gj(8.4 - k * 0.6, 1.3, 2.6, 12), true);
}
}
// --- the far arm: thinner, cooler, starving as it recedes toward the top edge ---
arm(1, 26, 268, fade(DEEP, 0.08), 1.7);
arm(1, 244, 462, fade(DEEP, 0.26), 1.2);
arm(1, 448, 664, fade(BLUE, 0.42), 0.85);
arm(1, 630, 756, fade(BLUE, 0.62), 0.6);
arm(1, 120, 430, fade(BLUE, 0.4), 0.7);
feather(160, 1, 0.85, 0.34);
feather(232, 1, 0.9, 0.36);
feather(318, 1, 0.95, 0.4);
feather(404, 1, 0.85, 0.46);
feather(524, 1, 0.8, 0.5);
feather(640, 1, 0.7, 0.56);
for (let k = 1; k < 8; k++) {
const p = armPt(CRESTS[k] * 0.83, 1);
foamKnot(p[0], p[1], KNOT_R[k] * 0.46 + gj(0, 0.8, -2, 2), false);
}
// --- the near arm: the crest line the whole picture hangs from, laid in overlapping passes
// so it thickens near the stern and starves as it runs off the left edge ---
arm(-1, 16, 250, fade(DEEP, 0.0), 2.5);
arm(-1, 236, 424, fade(DEEP, 0.06), 2.1);
arm(-1, 462, 604, fade(DEEP, 0.16), 1.6);
arm(-1, 588, 736, fade(DEEP, 0.38), 1.0);
arm(-1, 60, 470, fade(BLUE, 0.16), 1.1);
arm(-1, 330, 690, fade(BLUE, 0.42), 0.75);
arm(-1, 700, 796, fade(BLUE, 0.62), 0.6);
feather(120, -1, 1.35, 0.06);
feather(188, -1, 1.4, 0.08);
feather(258, -1, 1.5, 0.1);
feather(336, -1, 1.45, 0.14);
feather(418, -1, 1.3, 0.2);
feather(506, -1, 1.15, 0.28);
feather(596, -1, 1.0, 0.36);
feather(694, -1, 0.85, 0.46);
feather(790, -1, 0.7, 0.56);
// foam torn along the near arm — irregular, gapped, never a row of beads
const RIDGE = [58, 104, 146, 196, 246, 300, 366, 436, 528, 648];
brush.noStroke();
brush.fillBleed(0.08, "out");
brush.fillTexture(0.7, 0.95);
for (let i = 0; i < RIDGE.length; i++) {
const s = RIDGE[i] * (1 + gj(0, 0.022, -0.05, 0.05));
const lat = -spread(-1) * s * (1 + s / 9000) - gj(5, 3.4, -3, 15);
const p = wakePt(s, lat);
brush.fill(PAPER, gj(234 - s * 0.11, 18, 104, 246));
brush.circle(p[0], p[1], gj(12 - s * 0.007, 2.6, 3, 17), true);
}
for (let k = 0; k < CRESTS.length; k++) {
const p = armPt(CRESTS[k] * 0.83, -1);
foamKnot(p[0], p[1], KNOT_R[k] + gj(0, 1.2, -3, 3), k > 0 && k < 6);
}
// the focal knot, hand-built: the crest at s=318 tears open against the arm
foamKnot(486, 566, 19, true);
foamKnot(506, 552, 14, false);
foamKnot(464, 584, 11, true);
foamKnot(524, 574, 9, false);
foamKnot(452, 556, 7, false);
brush.noFill();
brush.set("charcoal", DEEP, 1.6);
brush.beginShape(0.55);
brush.vertex(432, 604);
brush.vertex(478, 596);
brush.vertex(524, 588);
brush.vertex(566, 596);
brush.endShape(false);
brush.set("2B", fade(DEEP, 0.12), 1.1);
brush.beginShape(0.6);
brush.vertex(444, 540);
brush.vertex(490, 528);
brush.vertex(534, 534);
brush.endShape(false);
// prussian pushed back in between the foam close behind the stern, so the whitest
// torn edges have something black to sit against
brush.noStroke();
brush.fillBleed(0.14, "out");
brush.fillTexture(0.5, 0.6);
const TUCK = [78, 112, 152, 186, 224, 268, 312, 352];
for (let i = 0; i < TUCK.length; i++) {
const s = TUCK[i] * (1 + gj(0, 0.02, -0.05, 0.05));
const lat = -spread(-1) * s * (1 + s / 9000) + gj(22, 8, 6, 40);
const p = wakePt(s, lat);
brush.fill(random() < 0.3 ? INK : DEEP, gj(196, 18, 140, 226));
brush.circle(p[0], p[1], gj(13 - i * 0.5, 3, 5, 20), true);
}
// --- the hull, cut by the corner, and the spray thrown off its quarter ---
brush.noStroke();
brush.fillBleed(0.12, "out");
brush.fillTexture(0.34, 0.8);
brush.fill(INK, 226);
brush.polygon([[700, 726], [752, 674], [790, 704], [822, 816], [716, 828]]);
brush.fill(INK, 208);
brush.polygon([[712, 736], [750, 692], [782, 716], [806, 800], [724, 810]]);
brush.hatchStyle("2B", fade(INK, 0.26), 0.85);
brush.hatch(4.6, 44, { rand: 0.4, continuous: false, gradient: 0.5 });
brush.polygon([[702, 728], [752, 676], [792, 706], [826, 820], [714, 832]]);
brush.noHatch();
brush.noFill();
brush.set("rotring", INK, 1.7);
brush.beginShape(0.15);
brush.vertex(694, 730);
brush.vertex(750, 674);
brush.vertex(800, 704);
brush.endShape(false);
brush.set("charcoal", INK, 2.4);
brush.line(698, 734, 744, 690);
brush.set("2B", SUN, 1.2);
brush.line(712, 714, 750, 678);
brush.set("rotring", fade(INK, 0.34), 0.9);
brush.line(762, 692, 784, 764);
// waterline: the hard dark where the hull sits into the trough it just left
brush.set("charcoal", INK, 2.6);
brush.line(702, 722, 744, 684);
brush.set("2B", fade(INK, 0.2), 1.7);
brush.line(716, 706, 748, 676);
brush.noStroke();
brush.fillBleed(0.06, "out");
brush.fillTexture(0.7, 0.95);
brush.fill(PAPER, 236);
brush.circle(700, 654, 13, true);
brush.fill(PAPER, 210);
brush.circle(672, 636, 9, true);
brush.fill(PAPER, 190);
brush.circle(654, 620, 6.5, true);
brush.fill(PAPER, 168);
brush.circle(688, 622, 4.6, true);
brush.fill(SUN, 148);
brush.circle(712, 640, 4.2, true);
brush.fill(PAPER, 150);
brush.circle(640, 604, 3.4, true);
brush.fill(SUN, 120);
brush.circle(618, 588, 2.6, true);
// --- flat water: a few long flicks in the reserved paper, sparser the further out ---
brush.field("hand");
brush.set("2H", fade(WASH, 0.18), 0.65);
brush.flowLine(96, 742, 120, 356);
brush.flowLine(206, 780, 96, 350);
brush.flowLine(56, 618, 88, 344);
brush.set("2H", fade(WASH, 0.3), 0.55);
brush.flowLine(742, 118, 112, 214);
brush.flowLine(676, 246, 88, 208);
brush.flowLine(788, 322, 96, 202);
brush.flowLine(596, 96, 74, 216);
brush.noField();
// --- last darks: the rills that break off the near crest, and one deep tick under the focal knot ---
brush.set("rotring", INK, 1.0);
brush.line(524, 590, 552, 606);
brush.line(470, 600, 492, 612);
brush.line(604, 618, 630, 632);
brush.set("pen", fade(INK, 0.22), 0.8);
brush.line(392, 578, 414, 590);
brush.line(318, 542, 336, 552);
brush.line(248, 508, 262, 516);
brush.set("pen", fade(INK, 0.4), 0.7);
brush.line(168, 470, 180, 478);
brush.set("2B", SUN, 1.0);
brush.line(498, 540, 516, 536);
brush.line(566, 566, 578, 562);
brush.line(622, 606, 638, 602);
brush.set("2B", lerpColor(color(SUN), color(PAPER), 0.3), 0.85);
brush.line(452, 552, 466, 548);
brush.line(672, 636, 684, 632);
}