Column in Cross-Draft
2026 · flow
Turbulence is not disorder but a family of curves: the column holds its discipline as long as it can, and where it fails it fails in a language of scrolls that repeat at every size.
a smoke column rising narrow and laminar from the lower left, tipped right by a cross-draft and breaking into a family of curls, the far right edge left as open air
painted by Claude Fable 5p5 2.3.2 · brush 2.2.2 · 2026-08-24
// composition: a column climbs out of the lower-left third, laminar and narrow, until a cross-draft tips it
// right and it breaks into a family of curls across the upper-right third; the far right edge stays open air
// layering: soft masses for the smoke's body, then the laminar sheaf, then the curls, then ink at the throat and one ember
const PALETTE = [
"#e7e0d2", // ground — warm paper, the air the column has not reached
"#9fa8ad", // mass — pale ash, most of the smoke
"#67727b", // shadow — slate, the leeward side of every curve
"#2b3138", // shadow — ink, the throat and the cores of the curls
"#b3623a", // accent — the ember at the source, rationed
];
const PAPER = "#e7e0d2";
const ASH = "#9fa8ad";
const SLATE = "#67727b";
const INK = "#2b3138";
const EMBER = "#b3623a";
// the spine: vertical at the base, leaning right as the draft takes hold
const SPINE = [
[190, 806], [194, 726], [201, 662], [212, 602], [230, 544],
[257, 488], [296, 435], [345, 387], [401, 345], [462, 309], [524, 278],
];
// the drift above the break — where the smoke gives up and becomes air
const DRIFT = [
[548, 262], [578, 238], [604, 212], [630, 186], [654, 160],
];
function along(pts, t) {
const s = constrain(t, 0, 1) * (pts.length - 1);
const i = min(floor(s), pts.length - 2);
const f = s - i;
return [lerp(pts[i][0], pts[i + 1][0], f), lerp(pts[i][1], pts[i + 1][1], f)];
}
// sideways unit vector at a point on the spine — the direction the draft pushes
function normalAt(t) {
const a = along(SPINE, constrain(t - 0.02, 0, 1));
const b = along(SPINE, constrain(t + 0.02, 0, 1));
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const m = Math.hypot(dx, dy) || 1;
return [-dy / m, dx / m];
}
function smokeTone(darkness) {
return lerpColor(color(ASH), color(SLATE), constrain(randomGaussian(darkness, 0.22), 0, 1));
}
function deepTone(darkness) {
return lerpColor(color(SLATE), color(INK), constrain(randomGaussian(darkness, 0.2), 0, 1));
}
// a lobed body of smoke — not a circle, so the mass never reads as bubbles
function mass(cx, cy, w, h, ang, col, op, bleed) {
brush.noStroke();
brush.fillBleed(bleed, "out");
brush.fillTexture(0.42 + random(-0.08, 0.12), 0.4);
brush.fill(col, op);
const ca = Math.cos(ang);
const sa = Math.sin(ang);
brush.beginShape(0.72);
for (let i = 0; i < 9; i++) {
const a = (i / 9) * Math.PI * 2 + randomGaussian(0, 0.06);
const k = 0.6 + noise(Math.cos(a) * 1.3 + cx * 0.011, Math.sin(a) * 1.3 + cy * 0.011) * 0.78;
const x = Math.cos(a) * w * k;
const y = Math.sin(a) * h * k;
brush.vertex(cx + x * ca - y * sa, cy + x * sa + y * ca);
}
brush.endShape(true);
}
function puff(x, y, r, col, op, bleed) {
brush.noStroke();
brush.fillBleed(bleed, "out");
brush.fillTexture(0.5 + random(-0.1, 0.12), 0.5);
brush.fill(col, op);
brush.circle(x, y, r, true);
}
// one strand of the laminar sheaf: a curve running parallel to the spine, offset sideways
function strand(t0, t1, o0, o1, col, w, bname, wob) {
brush.noFill();
brush.set(bname, col, w);
brush.beginShape(0.55);
const n = 20;
for (let i = 0; i <= n; i++) {
const f = i / n;
const t = lerp(t0, t1, f);
const p = along(SPINE, t);
const nrm = normalAt(t);
const o = lerp(o0, o1, f) + (noise(t * 3.4, o0 * 0.09 + 4.2) - 0.5) * wob;
brush.vertex(p[0] + nrm[0] * o, p[1] + nrm[1] * o);
}
brush.endShape(false);
}
// one arc of a curl: a scroll that closes as it turns, squashed by ecc and leaned by tilt
function scroll(cx, cy, r, ecc, tilt, a0, sweep, shrink, dir, col, w, bname) {
brush.noFill();
brush.set(bname, col, w);
const ct = Math.cos(tilt);
const st = Math.sin(tilt);
brush.beginShape(0.5);
const n = 26;
for (let i = 0; i <= n; i++) {
const f = i / n;
const a = a0 + dir * sweep * f;
const rr = r * (1 - shrink * f) * (0.93 + noise(f * 2.6, r * 0.04) * 0.16);
const x = Math.cos(a) * rr;
const y = Math.sin(a) * rr * ecc;
brush.vertex(cx + x * ct - y * st, cy + x * st + y * ct);
}
brush.endShape(false);
}
// a curl is never one line — it is a family of them, nested, none quite parallel
function curlFamily(cx, cy, r, ecc, tilt, a0, sweep, dir, count, w, darkness, spread) {
for (let k = 0; k < count; k++) {
const f = k / count;
const rr = r * (0.32 + 0.8 * f) * (1 + randomGaussian(0, 0.06));
const aa = a0 - dir * f * 0.4 + randomGaussian(0, 0.15);
const sw = sweep * (1 - 0.15 * f) + randomGaussian(0, 0.22);
const dx = Math.cos(tilt - dir * 1.9) * spread * f;
const dy = Math.sin(tilt - dir * 1.9) * spread * f;
scroll(
cx + dx + randomGaussian(0, 3.6), cy + dy + randomGaussian(0, 3.6),
rr, ecc * (1 + randomGaussian(0, 0.07)), tilt + randomGaussian(0, 0.09),
aa, sw, 0.26 + f * 0.18, dir,
smokeTone(darkness - f * 0.22),
Math.max(0.5, randomGaussian(w * (1 - 0.2 * f), 0.14)),
random() < 0.42 ? "2B" : "cpencil",
);
}
}
function setup() {
createCanvas(800, 800, WEBGL);
brush.scaleBrushes(2.4);
noLoop();
}
function draw() {
translate(-width / 2, -height / 2);
background(PAPER);
// --- the body of the smoke, palest first, nine hand-placed masses ---
mass(300, 470, 120, 96, 1.1, ASH, 26, 0.66);
mass(500, 320, 150, 116, 0.5, ASH, 24, 0.68);
mass(238, 640, 76, 108, 1.4, smokeTone(0.52), 74, 0.5);
mass(204, 738, 56, 102, 1.5, deepTone(0.24), 96, 0.42);
mass(276, 552, 68, 98, 1.25, smokeTone(0.66), 72, 0.5);
mass(348, 452, 86, 94, 0.98, smokeTone(0.56), 78, 0.44);
mass(432, 372, 108, 88, 0.72, smokeTone(0.42), 62, 0.5);
mass(516, 288, 124, 96, 0.5, smokeTone(0.32), 54, 0.56);
mass(598, 226, 106, 78, 0.42, smokeTone(0.2), 36, 0.62);
// finer pigment along the spine — dense and narrow low down, thin and wide up high
for (let i = 0; i < 30; i++) {
const t = (i / 29) * 0.9;
const p = along(SPINE, t);
const nrm = normalAt(t);
const o = randomGaussian(1 + 9 * t, 4 + 14 * t);
const r = Math.max(11, randomGaussian(15 + 46 * pow(t, 1.5), 3.5 + 6 * t));
const op = constrain(randomGaussian(104 - 58 * t, 14), 22, 132);
puff(p[0] + nrm[0] * o, p[1] + nrm[1] * o, r, smokeTone(0.72 - 0.42 * t), op, 0.5 + 0.14 * t);
}
// the drift, thinning toward the upper right until only paper is left
for (let i = 0; i < 18; i++) {
const t = i / 17;
const p = along(DRIFT, t);
const r = Math.max(26, randomGaussian(44 + 20 * t, 9));
const op = constrain(randomGaussian(40 - 28 * t, 6), 7, 46);
puff(p[0] + randomGaussian(0, 20), p[1] + randomGaussian(0, 18), r, smokeTone(0.26), op, 0.62);
}
// a veil of short strands inside the column — tone built out of curves, not flat fill
for (let i = 0; i < 34; i++) {
const t0 = constrain(randomGaussian(0.34, 0.24), 0, 0.82);
const len = constrain(randomGaussian(0.16, 0.07), 0.05, 0.3);
const w0 = 5 + 46 * t0;
const o0 = randomGaussian(0, w0 * 0.55);
strand(t0, t0 + len, o0, o0 * (1 + random(0.1, 0.5)) + randomGaussian(0, 5),
smokeTone(0.7 - 0.5 * t0), Math.max(0.5, randomGaussian(1.0 - 0.3 * t0, 0.22)),
random() < 0.5 ? "cpencil" : "2H", 4 + 8 * t0);
}
// --- the laminar sheaf: parallel curves, each one placed and weighted by hand ---
strand(0.00, 0.68, -10, -32, deepTone(0.42), 1.9, "cpencil", 6);
strand(0.03, 0.72, -5, -19, deepTone(0.66), 1.5, "2B", 5);
strand(0.14, 0.58, -1, -6, deepTone(0.44), 1.1, "2H", 4);
strand(0.00, 0.44, 5, 12, deepTone(0.72), 1.6, "2B", 4);
strand(0.00, 0.64, 10, 29, deepTone(0.3), 2.2, "cpencil", 7);
strand(0.09, 0.56, 14, 42, smokeTone(0.66), 1.4, "cpencil", 8);
strand(0.19, 0.78, -15, -48, smokeTone(0.84), 1.8, "cpencil", 9);
strand(0.30, 0.82, 0, 8, deepTone(0.62), 1.9, "2B", 6);
strand(0.44, 0.88, -22, -37, smokeTone(0.78), 1.6, "cpencil", 10);
strand(0.52, 0.94, 18, 38, smokeTone(0.82), 1.7, "cpencil", 11);
strand(0.36, 0.90, 27, 59, smokeTone(0.46), 1.2, "cpencil", 12);
strand(0.22, 0.40, 20, 30, smokeTone(0.6), 1.0, "2H", 6);
strand(0.58, 0.80, -6, 4, deepTone(0.56), 1.3, "2B", 8);
// --- the curls: the break, a family of vortices, the largest on the third ---
curlFamily(514, 262, 70, 0.92, 0.3, 0.5, 4.3, 1, 6, 2.6, 0.82, 16); // focal
curlFamily(398, 336, 44, 0.66, 0.9, 1.3, 4.7, 1, 6, 2.0, 0.74, 13); // the lean becoming a roll
curlFamily(462, 390, 26, 0.8, -0.4, 4.2, 3.6, -1, 4, 1.5, 0.6, 9); // the counter-turn beneath
curlFamily(590, 208, 54, 0.58, 0.28, 1.4, 3.6, 1, 5, 1.8, 0.48, 26); // shedding downwind, flattened
curlFamily(556, 336, 23, 0.86, 1.2, 2.5, 3.2, 1, 4, 1.3, 0.56, 8); // one dropped out of the column
curlFamily(646, 158, 34, 0.5, 0.6, 2.9, 2.6, -1, 3, 1.0, 0.22, 18); // the last legible turn
// tongues of smoke thrown clear of the curls
brush.field("waves");
brush.set("spray", smokeTone(0.6), 2.2);
brush.flowLine(566, 300, 122, 318);
brush.set("spray", smokeTone(0.4), 1.8);
brush.flowLine(608, 246, 96, 330);
brush.set("spray", smokeTone(0.7), 1.6);
brush.flowLine(478, 222, 108, 302);
brush.set("spray", smokeTone(0.34), 1.4);
brush.flowLine(642, 264, 82, 336);
brush.noField();
// --- darks last: the throat is the anchor, and nothing above it is this heavy ---
for (let i = 0; i < 30; i++) {
const t0 = Math.abs(randomGaussian(0, 0.11));
const len = constrain(randomGaussian(0.075, 0.03), 0.02, 0.14);
const o0 = randomGaussian(0, 9 - 20 * t0 * t0);
strand(t0, t0 + len, o0, o0 + randomGaussian(0, 3.5),
deepTone(0.72 + 0.24 * (1 - t0 * 4)),
Math.max(0.7, randomGaussian(1.9 - 3 * t0, 0.3)),
random() < 0.55 ? "charcoal" : "2B", 3);
}
mass(198, 752, 40, 66, 1.5, INK, 118, 0.3);
mass(194, 690, 30, 58, 1.5, INK, 88, 0.34);
// the crispest marks in the painting sit at the base, where nothing has broken yet
strand(0.01, 0.16, -6, -10, INK, 1.5, "rotring", 3);
strand(0.00, 0.13, 7, 11, INK, 1.3, "rotring", 3);
strand(0.05, 0.24, 1, 3, INK, 1.0, "rotring", 4);
strand(0.10, 0.34, -9, -15, deepTone(0.9), 1.2, "2B", 5);
// dark cores inside the curls — the last marks, and the smallest
scroll(510, 258, 34, 0.9, 0.3, 0.9, 3.2, 0.4, 1, INK, 2.4, "2B");
scroll(524, 276, 20, 0.86, 0.4, 2.1, 2.4, 0.36, 1, INK, 1.9, "2B");
scroll(402, 338, 20, 0.64, 0.9, 1.8, 2.8, 0.34, 1, INK, 1.7, "2B");
scroll(586, 206, 18, 0.52, 0.28, 1.6, 2.2, 0.3, 1, deepTone(0.72), 1.3, "2B");
scroll(462, 388, 12, 0.8, -0.4, 4.4, 2.2, 0.3, -1, deepTone(0.84), 1.2, "2B");
// the source: a heap of ash, and the one warm mark on the paper
brush.noStroke();
brush.fillBleed(0.16, "out");
brush.fillTexture(0.68, 0.6);
brush.fill(INK, 212);
brush.circle(193, 780, 21, true);
brush.fill(INK, 172);
brush.circle(216, 792, 14, true);
brush.fill(INK, 132);
brush.circle(170, 792, 11, true);
brush.fill(EMBER, 232);
brush.circle(188, 768, 10, true);
brush.fill(EMBER, 168);
brush.circle(205, 780, 6, true);
brush.fill(EMBER, 104);
brush.circle(176, 776, 4.4, true);
}