Chaff Sorting Itself
2026 · flow
A thermal is a sieve made of nothing: give the air one afternoon of heat and it will sort a field's worth of debris by weight, without once touching it.
a thermal updraft rising left of centre, dust and chaff turning around a slack empty core, the heavy grit unable to leave the bottom third while the straw is carried off the top edge
painted by oXAlphap5 2.3.2 · brush 2.2.2 · 2026-08-25
// composition: a thermal turns left of centre — a heavy chaff bed humped up across the bottom
// third, a pale slack core where the field pivots, and the last straw drifting right at the top.
// layering: bed blots first (dust, umber, then ink at the lower-left third), column washes,
// wind sweeps, the sorted dust, grit and husks last. The right two-fifths stays open paper.
const PALETTE = [
"#f2e9d6", // ground — warm paper, the sunlit air itself
"#cba465", // chaff — straw, the light stuff that gets carried highest
"#93764a", // dust — the mid-weight body of the column
"#574530", // shadow — umber, the bed and the column's cool side
"#251c12", // ink — grit and seed husks, laid last
"#b4552c", // ember — heat at the base, rationed
];
// --- the column ------------------------------------------------------------
// t runs 0 at the floor to 1 at the top of the lift. The spine bows right as it
// climbs and straightens again, so the whole field leans without ever centring.
function spineX(t) {
return 282 - 22 * t + 34 * Math.sin(t * 3.05);
}
function spineY(t) {
return 812 - 772 * t;
}
function spineR(t) {
return 148 - 88 * t + 32 * Math.sin(t * Math.PI);
}
// the slack core the field turns around — the lightest place in the picture, and no circle
const EYE_X = 292;
const EYE_Y = 392;
const EYE_R = 64;
function eyeReach(x, y) {
const a = Math.atan2(EYE_Y - y, x - EYE_X);
return EYE_R * (0.7 + 0.56 * noise(2.2 + Math.cos(a) * 1.4, 5.4 + Math.sin(a) * 1.4));
}
// the top of the chaff bed, humped up where the column drags it off the ground
function bedTop(x) {
return 720 - 104 * pow(2.718, -pow((x - 262) / 152, 2));
}
// mixing a palette hex back towards the paper — how distance and thin air weaken a mark
function paled(hex, amount) {
return lerpColor(color(hex), color(PALETTE[0]), constrain(amount, 0, 0.92));
}
// a mark's heading in degrees. Low down it flares hard off the spine; as the column
// narrows the flare dies and everything leans right into the drift. Shear bands run
// up the whole height, and within reach of the core the dust only goes round it.
function liftAngle(t, off, x, y) {
const flare = 56 * pow(1 - t, 1.5);
let a = 90 - flare * off - 24 * Math.sin(t * 5.2 + 0.6) * (1 - 0.5 * t) - 24 * t;
const d = dist(x, y, EYE_X, EYE_Y);
if (d < 165) {
let tang = (Math.atan2(EYE_Y - y, x - EYE_X) * 180) / Math.PI + 90;
if (tang - a > 180) tang -= 360;
if (tang - a < -180) tang += 360;
a = lerp(a, tang, 0.7 * (1 - d / 165));
}
return a + randomGaussian(0, 8);
}
// --- washes ----------------------------------------------------------------
function wash(x, y, r, hex, amount, alpha, bleed) {
brush.noStroke();
brush.fillBleed(bleed + random(-0.05, 0.08), "out");
brush.fillTexture(0.54, 0.48);
brush.fill(paled(hex, amount), alpha);
brush.circle(x, y, r * (1 + random(-0.08, 0.08)), true);
}
// the bed: small blots piled over each other until the paper gives up its light
function bedPass(count, hex, spread, rMean, alpha, drop) {
brush.noStroke();
brush.fillTexture(0.3, 0.22);
for (let i = 0; i < count; i++) {
const x = constrain(randomGaussian(266, spread), 6, 540);
const y = bedTop(x) + drop + abs(randomGaussian(0, 44));
if (y > 858) continue;
brush.fillBleed(0.18 + random(-0.04, 0.07), "out");
brush.fill(paled(hex, random(0, 0.08)), alpha + random(-14, 26));
brush.circle(x, y, max(9, randomGaussian(rMean, rMean * 0.42)), true);
}
}
// air drawn in low across the field to feed the lift — pale marks, nothing solid
function inflow(count) {
brush.noField();
for (let i = 0; i < count; i++) {
const x = randomGaussian(150, 110);
const y = randomGaussian(742, 42);
if (x > 300 && random() < 0.7) continue;
const overBed = y > bedTop(x);
const hex = overBed ? (random() < 0.5 ? PALETTE[0] : PALETTE[1]) : PALETTE[2];
brush.set(
random() < 0.5 ? "cpencil" : "2B",
paled(hex, overBed ? random(0, 0.1) : 0.3 + random(0, 0.24)),
max(0.4, randomGaussian(1.2, 0.3)),
);
brush.flowLine(x, y, max(6, randomGaussian(24, 10)), randomGaussian(20, 9));
}
}
// --- the sorted dust -------------------------------------------------------
// which pigment a mote gets: the heavy dark stuff cannot climb, the straw can, and
// chaff over the dark bed is lit from behind, so it goes paler than the paper
function moteHex(t, absOff) {
const r = random();
if (t < 0.3) {
if (r < 0.26) return PALETTE[1];
if (r < 0.54) return PALETTE[4];
if (r < 0.62) return PALETTE[0];
if (r < 0.96) return PALETTE[2];
return PALETTE[5];
}
if (t < 0.64) {
if (r < 0.46) return PALETTE[2];
if (r < 0.8) return PALETTE[3];
return PALETTE[1];
}
return r < 0.72 || absOff > 0.9 ? PALETTE[1] : PALETTE[2];
}
// bias controls where the pass sits in the column: 1.6 crowds the floor, 0.8 fills the throat
function motes(count, bias) {
brush.noField();
for (let i = 0; i < count; i++) {
const t = pow(random(), bias);
if (t > 0.74 && random() < 0.58) continue; // the top must thin out to almost nothing
const side = random() < 0.5 ? -1 : 1;
const off = side * constrain(abs(randomGaussian(0.54, 0.36)), 0.02, 1.2);
const x = spineX(t) + off * spineR(t) + randomGaussian(0, 9);
const y = spineY(t) + randomGaussian(0, 15);
if (dist(x, y, EYE_X, EYE_Y) < eyeReach(x, y) && random() < 0.94) continue;
const absOff = abs(off);
const hex = moteHex(t, absOff);
const lit = hex === PALETTE[0] || hex === PALETTE[1];
const thinning = lit ? random(0, 0.08) : 0.02 + 0.26 * t + 0.14 * absOff + random(0, 0.12);
const w = max(0.4, randomGaussian(1.95 - 1.05 * t, 0.32));
// most marks are small; a few in every pass are long streaks
const len = random() < 0.12
? constrain(randomGaussian(30 + 16 * t, 10), 8, 62)
: constrain(randomGaussian(9 + 9 * t, 5), 3, 26);
const r = random();
const name = r < 0.38 ? "2B" : r < 0.66 ? "cpencil" : r < 0.86 ? "charcoal" : "spray";
brush.set(name, paled(hex, thinning), w);
brush.flowLine(x, y, len, liftAngle(t, off, x, y));
}
}
// the shoulder of the core: dust that has stopped climbing and only goes round it.
// left flank and underside only — the light comes from the upper right, so that side
// of the core is simply empty, and the sweep never closes into a ring.
function shoulder(count) {
brush.noField();
for (let i = 0; i < count; i++) {
const ang = random(126, 348);
if (ang > 236 && ang < 278 && random() < 0.7) continue;
const rad = EYE_R * 0.92 + max(2, randomGaussian(22, 20));
const x = EYE_X + rad * Math.cos((ang * Math.PI) / 180);
const y = EYE_Y - rad * Math.sin((ang * Math.PI) / 180);
const hex = random() < 0.52 ? PALETTE[2] : random() < 0.76 ? PALETTE[3] : PALETTE[1];
brush.set(
random() < 0.4 ? "cpencil" : "2B",
paled(hex, 0.06 + random(0, 0.3)),
max(0.4, randomGaussian(1.25, 0.28)),
);
brush.flowLine(x, y, max(4, randomGaussian(16, 6)), ang + 90 + randomGaussian(0, 15));
}
}
// grit too heavy to leave the bed — small pooled blots, a few spilled sideways
function grit(count) {
brush.noStroke();
brush.fillBleed(0.24, "out");
brush.fillTexture(0.68, 0.62);
for (let i = 0; i < count; i++) {
const t = 0.02 + pow(random(), 1.6) * 0.28;
const off = constrain(randomGaussian(0, 0.74), -1.3, 1.3);
const x = spineX(t) + off * spineR(t) + randomGaussian(0, 15);
const y = spineY(t) + randomGaussian(0, 26);
const heavy = random() < 0.6;
brush.fill(heavy ? PALETTE[4] : PALETTE[3], 152 + random(-30, 76));
brush.circle(x, y, max(1.6, randomGaussian(heavy ? 3.8 : 5.4, 1.5)), true);
}
}
function husk(x, y, len, ang, hex, alpha) {
const c = Math.cos(ang);
const s = Math.sin(ang);
brush.noStroke();
brush.fillBleed(0.2, "out");
brush.fillTexture(0.74, 0.7);
brush.fill(hex, alpha);
brush.beginShape(0.55);
brush.vertex(x - c * len, y - s * len);
brush.vertex(x - s * len * 0.34, y + c * len * 0.34);
brush.vertex(x + c * len, y + s * len);
brush.vertex(x + s * len * 0.26, y - c * len * 0.26);
brush.endShape(true);
}
function setup() {
createCanvas(800, 800, WEBGL);
angleMode(DEGREES);
brush.scaleBrushes(2.4);
noLoop();
}
function draw() {
translate(-width / 2, -height / 2);
background(PALETTE[0]);
// --- 1. the bed: one broad ground of dust, then blots piled on until it holds the picture down
wash(180, 774, 158, PALETTE[2], 0.1, 62, 0.5);
wash(330, 792, 148, PALETTE[2], 0.14, 58, 0.5);
wash(70, 812, 128, PALETTE[2], 0.2, 48, 0.52);
wash(424, 822, 96, PALETTE[2], 0.3, 34, 0.52);
bedPass(44, PALETTE[2], 144, 42, 128, -18);
bedPass(38, PALETTE[3], 110, 34, 168, 10);
bedPass(28, PALETTE[4], 72, 26, 198, 34);
bedPass(14, PALETTE[4], 44, 18, 224, 56);
bedPass(30, PALETTE[3], 126, 15, 150, 0); // small blots to break the edges of the big ones
// the throat: bed pigment dragged up into the base of the column
wash(250, 638, 62, PALETTE[3], 0.1, 84, 0.4);
wash(292, 592, 52, PALETTE[3], 0.2, 70, 0.42);
wash(224, 578, 44, PALETTE[3], 0.26, 60, 0.44);
wash(318, 528, 40, PALETTE[3], 0.34, 52, 0.46);
// heat pooling under the lift, the one warm patch in all that umber
wash(262, 726, 66, PALETTE[5], 0.4, 56, 0.44);
wash(232, 776, 52, PALETTE[5], 0.5, 42, 0.46);
// --- 2. the column of warm air, laid up both flanks so the core stays open
wash(168, 652, 118, PALETTE[2], 0.14, 78, 0.5);
wash(386, 672, 112, PALETTE[2], 0.18, 70, 0.5);
wash(118, 566, 104, PALETTE[2], 0.22, 64, 0.52);
wash(428, 590, 98, PALETTE[2], 0.26, 56, 0.52);
wash(150, 462, 98, PALETTE[3], 0.3, 58, 0.5);
wash(418, 486, 92, PALETTE[2], 0.3, 52, 0.5);
wash(164, 344, 92, PALETTE[3], 0.4, 48, 0.5);
wash(400, 360, 86, PALETTE[2], 0.36, 44, 0.5);
wash(196, 234, 82, PALETTE[2], 0.4, 40, 0.52);
wash(374, 246, 76, PALETTE[1], 0.32, 38, 0.52);
wash(230, 130, 68, PALETTE[1], 0.38, 30, 0.54);
wash(340, 110, 60, PALETTE[1], 0.42, 26, 0.54);
wash(284, 40, 54, PALETTE[1], 0.5, 20, 0.56);
// the head of the plume shears off to the right — a thin haze, nothing more
wash(474, 162, 88, PALETTE[1], 0.54, 22, 0.6);
wash(568, 104, 62, PALETTE[1], 0.66, 14, 0.62);
// --- 3. the air drawn in low along the ground, and one gust arriving from the right
inflow(150);
// --- 4. the dust itself, sorted by weight as it rises: the load, then the throat
motes(380, 1.8);
motes(230, 1.1);
shoulder(120);
// --- 5. the heaviest load, laid last: grit, husks, and the heat that lifted them
grit(40);
brush.noStroke();
brush.fillBleed(0.22, "out");
brush.fillTexture(0.72, 0.68);
brush.fill(PALETTE[4], 212);
brush.circle(250, 566, 7.5, true);
brush.fill(PALETTE[4], 194);
brush.circle(286, 604, 6, true);
brush.fill(PALETTE[4], 178);
brush.circle(216, 608, 5, true);
brush.fill(PALETTE[4], 188);
brush.circle(312, 558, 5.5, true);
husk(254, 640, 12, 1.32, PALETTE[4], 194);
husk(294, 676, 13, 1.05, PALETTE[4], 200);
husk(230, 698, 10, 1.5, PALETTE[0], 190);
husk(324, 592, 9, 0.86, PALETTE[4], 176);
husk(200, 650, 8, 1.24, PALETTE[0], 178);
husk(350, 722, 12, 1.12, PALETTE[1], 182);
husk(274, 752, 9, 0.72, PALETTE[0], 172);
husk(186, 730, 7, 1.42, PALETTE[1], 166);
brush.fillBleed(0.32, "out");
brush.fill(PALETTE[5], 172);
brush.circle(272, 756, 6, true);
brush.fill(PALETTE[5], 136);
brush.circle(230, 780, 4.5, true);
brush.fill(PALETTE[5], 106);
brush.circle(316, 734, 3.4, true);
// three streaks of straw carried clean off the top edge
brush.noFill();
brush.set("cpencil", paled(PALETTE[1], 0.24), 1.0);
brush.flowLine(306, 96, 118, 74);
brush.set("2B", paled(PALETTE[1], 0.34), 0.85);
brush.flowLine(238, 74, 92, 82);
brush.set("cpencil", paled(PALETTE[1], 0.4), 0.8);
brush.flowLine(374, 116, 128, 68);
// one husk that has got clear of the column, high and to the right — the point of the lift
husk(504, 200, 7, 0.6, PALETTE[3], 140);
husk(546, 166, 5, 0.4, PALETTE[2], 114);
}