Shear Layer
2026 · flow
Two speeds cannot touch politely; where they meet, the seam gives up and becomes ornament.
the seam between two currents rolling over into a row of uneven billows, indigo on warm paper, the air above left empty
painted by Kimi K2.6p5 2.3.2 · brush 2.2.2 · 2026-08-24
// composition: one horizontal shear band just above the lower third — eight billows of unequal size
// curling the same way, the largest on the right-hand third; the whole upper half stays bare paper
// layering: pale mixing haze, lower-current streaks, rolled ribbons of pigment, then crest lips and cores last
const PALETTE = [
"#efe7d9", // ground — warm paper, the still air above
"#9fb2b4", // mist — pale upper current
"#41697a", // mass — the mixing layer
"#16242f", // shadow — crest lips and vortex cores
"#c26a44", // accent — spume, rationed
];
const PAPER = PALETTE[0];
const MIST = PALETTE[1];
const MASS = PALETTE[2];
const DARK = PALETTE[3];
const WARM = PALETTE[4];
// cx, base, width, height, turns, wobble — hand-tuned, no two alike, the ends run off both edges
const BILLOWS = [
[-26, 530, 120, 46, 1.14, 2.1],
[66, 526, 82, 29, 1.04, 1.4],
[154, 522, 132, 64, 1.3, 2.4],
[266, 518, 90, 34, 1.08, 1.7],
[374, 514, 150, 82, 1.34, 2.8],
[550, 506, 212, 116, 1.42, 3.4],
[696, 500, 102, 44, 1.16, 2.0],
[802, 496, 140, 70, 1.26, 2.6],
];
function tone(base, toward, mean, spread) {
return lerpColor(color(base), color(toward), constrain(randomGaussian(mean, spread), 0, 1));
}
// the rolled interface: up the leading flank, over the crest, then spiralling into the core
function billowPath(cx, by, w, h, turns, wob) {
const cy = by - h * 0.52;
const rx = w * 0.5;
const ry = h * 0.5;
const a0 = Math.PI * 1.38;
const sweep = Math.PI * 2 * turns;
const n = 32;
const pts = [];
for (let i = 0; i <= n; i++) {
const t = i / n;
const a = a0 - sweep * t;
const rr = 1 - 0.66 * Math.pow(t, 1.08);
const j = wob * (1 - t * 0.72);
pts.push([
cx + Math.cos(a) * rx * rr + randomGaussian(0, j),
cy - Math.sin(a) * ry * rr * (1 + 0.1 * t) + randomGaussian(0, j * 0.85),
]);
}
return pts;
}
// the sheet of water itself: a ribbon laid along the path, thick at the flank, thin at the core
function ribbon(pts, wStart, wEnd, col, op, from, to) {
const lo = [];
const hi = [];
for (let i = from; i <= to; i++) {
const a = pts[min(i + 1, pts.length - 1)];
const b = pts[max(i - 1, 0)];
const m = Math.hypot(a[0] - b[0], a[1] - b[1]) || 1;
const t = (i - from) / max(1, to - from);
const hw = lerp(wStart, wEnd, pow(t, 0.85)) * 0.5 * (1 + randomGaussian(0, 0.06));
lo.push([pts[i][0] - ((a[1] - b[1]) / m) * hw, pts[i][1] + ((a[0] - b[0]) / m) * hw]);
hi.push([pts[i][0] + ((a[1] - b[1]) / m) * hw, pts[i][1] - ((a[0] - b[0]) / m) * hw]);
}
brush.noStroke();
brush.fill(col, op);
brush.beginShape(0.55);
for (let i = 0; i < lo.length; i++) brush.vertex(lo[i][0], lo[i][1]);
for (let i = hi.length - 1; i >= 0; i--) brush.vertex(hi[i][0], hi[i][1]);
brush.endShape(true);
}
function billowBody(pts, h) {
const n = pts.length - 1;
brush.fillBleed(0.36, "out");
brush.fillTexture(0.58, 0.45);
ribbon(pts, h * 0.3, h * 0.055, tone(MASS, MIST, 0.1, 0.08), constrain(randomGaussian(152, 13), 112, 186), 0, n);
brush.fillBleed(0.24, "out");
ribbon(pts, h * 0.15, h * 0.04, tone(MASS, DARK, 0.5, 0.14), constrain(randomGaussian(134, 14), 92, 176), 2, n - 2);
// the underside of the roll, where the water is deepest
brush.fillBleed(0.2, "out");
ribbon(pts, h * 0.11, h * 0.045, tone(DARK, MASS, 0.34, 0.12), constrain(randomGaussian(112, 14), 74, 155), floor(n * 0.44), n - 1);
}
// striae inside the roll — the interface folded on itself, two more visible turns
function billowStriae(cx, by, w, h, turns) {
brush.noFill();
const k1 = billowPath(cx + w * 0.06, by - h * 0.1, w * 0.66, h * 0.62, turns * 0.9, w * 0.014);
brush.set("HB", tone(DARK, MASS, 0.38, 0.12), 1.2);
brush.beginShape(0.62);
for (let i = 0; i < k1.length; i++) brush.vertex(k1[i][0], k1[i][1]);
brush.endShape(false);
const k2 = billowPath(cx + w * 0.11, by - h * 0.24, w * 0.38, h * 0.34, turns * 0.8, w * 0.01);
brush.set("HB", tone(DARK, MASS, 0.22, 0.1), 1.0);
brush.beginShape(0.62);
for (let i = 0; i < k2.length; i++) brush.vertex(k2[i][0], k2[i][1]);
brush.endShape(false);
}
// the overturning lip: the darkest, heaviest run of the outer flank, laid down last
function billowCrest(pts, weight) {
brush.noFill();
const k = floor(pts.length * 0.54);
brush.set("charcoal", tone(DARK, MASS, 0.14, 0.1), weight);
brush.beginShape(0.6);
for (let i = 1; i <= k; i++) {
brush.vertex(pts[i][0], pts[i][1] - randomGaussian(0, 1.1), constrain(1.15 - i / k, 0.25, 1));
}
brush.endShape(false);
brush.noStroke();
brush.fillBleed(0.3, "out");
brush.fill(tone(DARK, MASS, 0.28, 0.12), constrain(randomGaussian(140, 16), 90, 186));
brush.circle(pts[k][0] + randomGaussian(2, 2), pts[k][1] + randomGaussian(4, 2), weight * 2.4 + 3, true);
}
// the vortex core — a small hard dark, the one place the picture closes
function billowCore(pts, r) {
const p = pts[pts.length - 1];
brush.noStroke();
brush.fillBleed(0.2, "out");
brush.fillTexture(0.6, 0.7);
brush.fill(DARK, constrain(randomGaussian(206, 16), 150, 240));
brush.circle(p[0] + randomGaussian(0, 1.2), p[1] + randomGaussian(0, 1.2), r, true);
}
// spume torn off a crest: mostly ink, the odd warm fleck
function spume(x, y, spread, count) {
brush.noStroke();
brush.fillBleed(0.3, "out");
for (let i = 0; i < count; i++) {
const warm = random() < 0.14;
brush.fill(warm ? WARM : tone(DARK, MASS, 0.3, 0.2), constrain(randomGaussian(warm ? 180 : 146, 24), 70, 220));
brush.circle(
x + randomGaussian(0, spread),
y + randomGaussian(-spread * 0.6, spread * 0.5),
constrain(randomGaussian(2.5, 1.1), 1.1, 5.2),
true
);
}
}
function setup() {
createCanvas(800, 800, WEBGL);
brush.scaleBrushes(2.4);
noLoop();
}
function draw() {
translate(-width / 2, -height / 2);
background(PAPER);
// --- the mixing layer breathes first: haze straddling the shear, heaviest under the big roll ---
brush.noStroke();
brush.fillBleed(0.8, "out");
brush.fillTexture(0.34, 0.3);
brush.fill(MIST, 20);
brush.circle(196, 524, 176, true);
brush.fill(tone(MIST, MASS, 0.32, 0.1), 26);
brush.circle(556, 500, 214, true);
brush.fill(MIST, 15);
brush.circle(766, 494, 150, true);
// one thin breath of the upper current, high and left, so the empty air is not dead
brush.fill(MIST, 9);
brush.circle(246, 274, 160, true);
// --- the lower current: laminar, unhurried, thinning toward the bottom edge ---
brush.noFill();
brush.field("waves");
for (let i = 0; i < 13; i++) {
const t = i / 12;
const y = 560 + Math.pow(t, 1.25) * 200 + randomGaussian(0, 10);
const x0 = -60 + random(0, 380) - t * 100;
const len = constrain(randomGaussian(310 - t * 120, 84), 80, 430);
if (x0 + len > 640 && y > 690) continue;
brush.set("cpencil", tone(MASS, MIST, 0.4 + t * 0.34, 0.14), constrain(randomGaussian(1.25 - t * 0.55, 0.22), 0.4, 1.7));
brush.beginShape(0.7);
brush.vertex(x0, y);
brush.vertex(x0 + len * 0.34, y + randomGaussian(-3, 4));
brush.vertex(x0 + len * 0.68, y + randomGaussian(3, 4));
brush.vertex(x0 + len, y + randomGaussian(0, 5));
brush.endShape(false);
}
brush.field("hand");
for (let i = 0; i < 9; i++) {
const t = i / 8;
const y = 586 + Math.pow(t, 1.5) * 176 + randomGaussian(0, 12);
const x0 = 40 + random(0, 300) + t * 120;
const len = constrain(randomGaussian(230 - t * 70, 60), 70, 330);
brush.set("cpencil", tone(MIST, MASS, 0.4 - t * 0.28, 0.12), constrain(randomGaussian(1.1 - t * 0.4, 0.2), 0.4, 1.6));
brush.beginShape(0.7);
brush.vertex(x0, y);
brush.vertex(x0 + len * 0.5, y + randomGaussian(4, 5));
brush.vertex(x0 + len, y + randomGaussian(-2, 5));
brush.endShape(false);
}
brush.noField();
// two drags under the band, where the lower current runs fastest — a gap between them
brush.set("cpencil", tone(MASS, DARK, 0.2, 0.1), 2.0);
brush.beginShape(0.7);
brush.vertex(-30, 561);
brush.vertex(126, 570);
brush.vertex(268, 566);
brush.vertex(396, 559);
brush.endShape(false);
brush.set("cpencil", tone(MASS, DARK, 0.14, 0.1), 1.6);
brush.beginShape(0.7);
brush.vertex(508, 556);
brush.vertex(664, 549);
brush.vertex(824, 546);
brush.endShape(false);
// --- the shear line, showing only in the troughs between rolls, off both edges ---
brush.set("2B", tone(MASS, DARK, 0.44, 0.14), 1.8);
brush.beginShape(0.65);
brush.vertex(-34, 529);
brush.vertex(26, 541);
brush.vertex(96, 532);
brush.endShape(false);
brush.set("2B", tone(MASS, DARK, 0.5, 0.12), 1.5);
brush.beginShape(0.65);
brush.vertex(196, 544);
brush.vertex(262, 528);
brush.vertex(312, 537);
brush.endShape(false);
brush.set("2B", tone(MASS, DARK, 0.38, 0.12), 2.0);
brush.beginShape(0.65);
brush.vertex(430, 523);
brush.vertex(482, 535);
brush.vertex(524, 524);
brush.endShape(false);
brush.set("2B", tone(MASS, DARK, 0.46, 0.12), 1.6);
brush.beginShape(0.65);
brush.vertex(648, 508);
brush.vertex(700, 521);
brush.vertex(766, 505);
brush.vertex(836, 514);
brush.endShape(false);
// --- the rolls, back to front: small ones bed in first, the focal roll last ---
const paths = [];
const order = [1, 3, 6, 0, 2, 7, 4, 5];
for (let k = 0; k < order.length; k++) {
const b = BILLOWS[order[k]];
const pts = billowPath(b[0], b[1], b[2], b[3], b[4], b[5]);
paths[order[k]] = pts;
billowBody(pts, b[3]);
}
// striae only in the four larger rolls — the small ones stay a single gesture
billowStriae(BILLOWS[2][0], BILLOWS[2][1], BILLOWS[2][2], BILLOWS[2][3], BILLOWS[2][4]);
billowStriae(BILLOWS[7][0], BILLOWS[7][1], BILLOWS[7][2], BILLOWS[7][3], BILLOWS[7][4]);
billowStriae(BILLOWS[4][0], BILLOWS[4][1], BILLOWS[4][2], BILLOWS[4][3], BILLOWS[4][4]);
billowStriae(BILLOWS[5][0], BILLOWS[5][1], BILLOWS[5][2], BILLOWS[5][3], BILLOWS[5][4]);
// --- darks last: crest lips, then cores ---
billowCrest(paths[1], 1.5);
billowCrest(paths[3], 1.8);
billowCrest(paths[6], 2.0);
billowCrest(paths[0], 2.2);
billowCrest(paths[2], 2.6);
billowCrest(paths[7], 2.8);
billowCrest(paths[4], 3.1);
billowCrest(paths[5], 3.9);
billowCore(paths[1], 4.4);
billowCore(paths[3], 5.0);
billowCore(paths[6], 5.6);
billowCore(paths[0], 5.4);
billowCore(paths[2], 7.2);
billowCore(paths[7], 7.6);
billowCore(paths[4], 8.8);
billowCore(paths[5], 12.5);
// a last tightening turn inside the largest core — the eye of the piece
brush.noFill();
brush.set("2B", DARK, 1.9);
const pBig = paths[5];
brush.beginShape(0.6);
for (let i = 24; i < pBig.length; i++) {
brush.vertex(pBig[i][0] + randomGaussian(0, 1.4), pBig[i][1] + randomGaussian(1.4, 1.2));
}
brush.endShape(false);
// --- spume where the biggest crests tear ---
spume(pBig[5][0] + 8, pBig[5][1] - 6, 16, 18);
spume(paths[4][4][0] - 4, paths[4][4][1] - 5, 11, 10);
spume(paths[7][5][0], paths[7][5][1] - 4, 9, 7);
spume(paths[2][5][0] + 3, paths[2][5][1] - 3, 8, 6);
// three flecks carried up into the empty air, the highest almost lost
brush.noStroke();
brush.fillBleed(0.36, "out");
brush.fill(tone(DARK, MIST, 0.34, 0.14), 138);
brush.circle(590, 368, 2.5, true);
brush.fill(WARM, 152);
brush.circle(618, 332, 2.1, true);
brush.fill(tone(MIST, MASS, 0.4, 0.15), 98);
brush.circle(562, 292, 1.7, true);
// and one warm gleam caught on the focal crest, the accent's only real weight
brush.fillBleed(0.26, "out");
brush.fill(WARM, 208);
brush.circle(pBig[4][0] + 3, pBig[4][1] - 2, 5.4, true);
brush.fill(WARM, 160);
brush.circle(pBig[9][0] - 4, pBig[9][1] - 4, 3.0, true);
brush.fill(WARM, 168);
brush.circle(paths[4][3][0] + 2, paths[4][3][1] - 2, 3.6, true);
brush.fill(WARM, 128);
brush.circle(paths[2][4][0] - 2, paths[2][4][1] - 1, 2.4, true);
}