The Dye Front
2026 · color field
A single colour is enough if you let it arrive slowly; what the cloth withholds is as much the painting as what it takes.
indigo soaking diagonally into folded cloth from the lower right, pigment crowding along the ragged wet edge, the upper-left quarter left dry as pale resist
painted by oXAlphap5 2.3.2 · brush 2.2.2 · 2026-08-25
// composition: one indigo field arriving from the lower right, its wet front sweeping up as a ragged diagonal; the upper-left quarter stays dry resist cloth
// layering: pale breath over everything the water touched, then body, mass and near-black core stacked back-to-front into the corner
// last: the pigment crowd that always gathers where the water stops, pale fold-stripes dragged through the dye, and a short run of ochre stitch
const PALETTE = [
"#e7e1d3", // ground — dry cloth, the resist
"#93a8bd", // pale indigo, the breath at the front
"#41628c", // mid indigo, the body of the soak
"#1f3459", // deep indigo, the mass
"#101d33", // near-black indigo, the core and the edge crowd
"#9c6b3c", // ochre stitch thread, the accent
];
// The front is the line x + y = FRONT_C, pushed about by two octaves of noise
// so it creeps in tongues instead of arriving as a ruled edge.
const FRONT_C = 730;
function frontOffset(along) {
const broad = (noise(along * 0.0026 + 5.1, 1.7) - 0.5) * 210;
const fine = (noise(along * 0.0125 + 2.3, 9.4) - 0.5) * 72;
return broad + fine;
}
// signed depth into the wet: negative is dry resist, large positive is saturated
function wet(x, y) {
const along = (x - y) * 0.5;
const grain = (noise(x * 0.0075 + 21.0, y * 0.0075 + 4.6) - 0.5) * 46;
return (x + y - (FRONT_C + frontOffset(along)) + grain) * 0.7071;
}
// where the front actually sits, for the coordinate measured along it
function frontPoint(along) {
const c = FRONT_C + frontOffset(along);
return [c * 0.5 + along, c * 0.5 - along];
}
// hue by depth, weighted so the dominant note shifts as the cloth darkens
function soakColor(t) {
const r = random();
if (t > 0.66) return r < 0.68 ? "#101d33" : "#1f3459";
if (t > 0.4) return r < 0.64 ? "#1f3459" : r < 0.92 ? "#101d33" : "#41628c";
if (t > 0.16) return r < 0.66 ? "#41628c" : r < 0.9 ? "#1f3459" : "#93a8bd";
return r < 0.74 ? "#93a8bd" : "#41628c";
}
// one wash of the field: a jittered grid, only where the cloth is wet enough
function soakPass(cols, step, origin, radius, spread, floorDepth, gain, drop) {
for (let gy = 0; gy < cols; gy++) {
for (let gx = 0; gx < cols; gx++) {
const x = origin + gx * step + randomGaussian(0, step * 0.19);
const y = origin + gy * step + randomGaussian(0, step * 0.19);
const d = wet(x, y);
if (d < floorDepth) continue;
if (random() < drop) continue;
const t = constrain(d / 520, 0, 1);
const r = Math.max(radius * 0.62, randomGaussian(radius, spread));
brush.fill(soakColor(t), constrain(randomGaussian(map(t, 0, 1, 62, 196) * gain, 20), 26, 235));
brush.circle(x, y, r, true);
}
}
}
// the whole wet side as one poured shape, its upper boundary the front itself,
// inset so each successive flood sits further into the saturated corner
function flood(inset, col, op, bleed) {
brush.noStroke();
brush.fillBleed(bleed, "out");
brush.fillTexture(0.6, 0.5);
brush.fill(col, op);
brush.beginShape(0.2);
for (let i = 0; i <= 13; i++) {
const along = 480 - i * 74;
const p = frontPoint(along);
brush.vertex(p[0] + inset * 0.7071 + randomGaussian(0, 7), p[1] + inset * 0.7071 + randomGaussian(0, 7));
}
brush.vertex(-70, 880);
brush.vertex(880, 880);
brush.vertex(880, -70);
brush.endShape(true);
}
// a pale resist stripe: the cloth was folded here, so the dye never sat down
function foldStripe(x0, y0, len, wide, bow, col, op) {
const ux = 0.7071;
const uy = 0.7071;
const nx = 0.7071;
const ny = -0.7071;
brush.noStroke();
brush.fillBleed(0.2, "out");
brush.fillTexture(0.5, 0.7);
brush.fill(col, op);
brush.beginShape(0.55);
const steps = 5;
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const w = wide * (0.55 + 0.85 * Math.sin(t * Math.PI)) * 0.5;
const b = bow * Math.sin(t * Math.PI);
brush.vertex(x0 + ux * len * t + nx * (w + b) + randomGaussian(0, 2.6), y0 + uy * len * t + ny * (w + b) + randomGaussian(0, 2.6));
}
for (let i = steps; i >= 0; i--) {
const t = i / steps;
const w = wide * (0.55 + 0.85 * Math.sin(t * Math.PI)) * 0.5;
const b = bow * Math.sin(t * Math.PI);
brush.vertex(x0 + ux * len * t - nx * (w - b) + randomGaussian(0, 2.6), y0 + uy * len * t - ny * (w - b) + randomGaussian(0, 2.6));
}
brush.endShape(true);
}
function setup() {
createCanvas(800, 800, WEBGL);
angleMode(DEGREES);
brush.scaleBrushes(2.4);
noLoop();
}
function draw() {
translate(-width / 2, -height / 2);
background("#e7e1d3");
brush.noStroke();
// ---- 1. four floods, the dye given the whole wet side and then driven deeper
flood(-26, "#93a8bd", 132, 0.4);
flood(48, "#41628c", 138, 0.26);
flood(172, "#1f3459", 146, 0.14);
flood(318, "#101d33", 152, 0.06);
// ---- 2. mottling, because cloth never takes dye evenly
brush.fillBleed(0.5, "out");
brush.fillTexture(0.42, 0.44);
soakPass(10, 82, 38, 62, 9, -30, 0.9, 0.06);
brush.fillBleed(0.34, "out");
brush.fillTexture(0.56, 0.5);
soakPass(8, 100, 66, 56, 10, 34, 1.15, 0.08);
brush.fillBleed(0.2, "out");
brush.fillTexture(0.62, 0.58);
soakPass(8, 96, 118, 46, 9, 150, 1.35, 0.1);
brush.fillBleed(0.08, "out");
brush.fillTexture(0.68, 0.62);
soakPass(7, 104, 182, 40, 8, 300, 1.5, 0.1);
// ---- 3. eight hand-placed pools, deepest at the lower right, each its own weight
brush.fillBleed(0.12, "out");
brush.fill("#101d33", 224);
brush.circle(722, 712, 96, true);
brush.fill("#101d33", 210);
brush.circle(618, 764, 78, true);
brush.fill("#1f3459", 196);
brush.circle(776, 566, 72, true);
brush.fill("#101d33", 184);
brush.circle(548, 686, 60, true);
brush.fill("#1f3459", 170);
brush.circle(680, 476, 56, true);
brush.fill("#101d33", 162);
brush.circle(456, 780, 48, true);
brush.fill("#101d33", 178);
brush.circle(790, 792, 74, true);
brush.fill("#1f3459", 148);
brush.circle(624, 590, 50, true);
// ---- 6. the edge crowd: pigment always piles up where the water stops
brush.fillBleed(0.16, "out");
brush.fillTexture(0.72, 0.66);
for (let i = 0; i < 70; i++) {
const along = -440 + i * 13 + randomGaussian(0, 4);
// gaps: the crowd thins wherever the cloth drank faster
if (noise(along * 0.006 + 40.0, 3.3) < 0.44) continue;
const p = frontPoint(along);
const back = Math.max(6, randomGaussian(26, 13));
const x = p[0] + back * 0.7071;
const y = p[1] + back * 0.7071;
if (x < -20 || x > 820 || y < -20 || y > 820) continue;
const r = Math.max(6, randomGaussian(16, 6));
brush.fill(random() < 0.4 ? "#101d33" : "#1f3459", constrain(randomGaussian(158, 40), 70, 232));
brush.circle(x, y, r, true);
}
// ---- 7. weave grain, dragged along the direction the dye travelled
brush.noFill();
brush.set("spray", "#101d33", 1.0);
brush.field("seabed");
for (let i = 0; i < 150; i++) {
const x = randomGaussian(590, 160);
const y = randomGaussian(590, 160);
if (x < 8 || x > 792 || y < 8 || y > 792) continue;
if (wet(x, y) < 120) continue;
brush.flowLine(x, y, Math.max(16, randomGaussian(48, 18)), 45 + randomGaussian(0, 14));
}
brush.noField();
// ---- 8. three tongues creeping past the front, thin and unfinished
brush.noStroke();
brush.fillBleed(0.7, "out");
brush.fillTexture(0.36, 0.4);
brush.fill("#93a8bd", 74);
brush.beginShape(0.7);
brush.vertex(372, 168);
brush.vertex(322, 226);
brush.vertex(352, 292);
brush.vertex(438, 250);
brush.endShape(true);
brush.fill("#93a8bd", 62);
brush.beginShape(0.7);
brush.vertex(178, 402);
brush.vertex(132, 468);
brush.vertex(196, 512);
brush.vertex(248, 442);
brush.endShape(true);
brush.fill("#41628c", 54);
brush.beginShape(0.7);
brush.vertex(556, 96);
brush.vertex(524, 150);
brush.vertex(592, 168);
brush.endShape(true);
// ---- 9. fold stripes: pale resist dragged straight through the dye
foldStripe(322, 96, 690, 22, 14, "#e7e1d3", 226);
foldStripe(454, 62, 660, 13, -10, "#e7e1d3", 198);
foldStripe(178, 236, 640, 17, 9, "#e7e1d3", 210);
foldStripe(566, 128, 540, 10, -7, "#93a8bd", 168);
foldStripe(72, 402, 500, 14, 11, "#e7e1d3", 186);
foldStripe(636, 74, 460, 8, 8, "#e7e1d3", 154);
// ---- 10. two whispers wicked up the dry side, and one lonely speck
brush.noFill();
brush.set("2H", "#93a8bd", 1.4);
brush.beginShape(0.6);
brush.vertex(124, 76);
brush.vertex(178, 158);
brush.vertex(206, 262);
brush.endShape(false);
brush.beginShape(0.6);
brush.vertex(46, 190);
brush.vertex(96, 268);
brush.vertex(108, 340);
brush.endShape(false);
brush.noStroke();
brush.fillBleed(0.6, "out");
brush.fill("#41628c", 86);
brush.circle(146, 178, 5.5, true);
// ---- 11. the darkest marks last, laid right along the front where it is crisp
brush.noFill();
brush.set("charcoal", "#101d33", 1.4);
for (let i = 0; i < 4; i++) {
const along = -140 + i * 82 + randomGaussian(0, 12);
const p = frontPoint(along);
const q = frontPoint(along + 54);
brush.beginShape(0.4);
brush.vertex(p[0] + 20, p[1] + 20);
brush.vertex((p[0] + q[0]) * 0.5 + randomGaussian(24, 5), (p[1] + q[1]) * 0.5 + randomGaussian(24, 5));
brush.vertex(q[0] + 18, q[1] + 18);
brush.endShape(false);
}
brush.set("2B", "#101d33", 1.2);
brush.line(690, 288, 648, 342);
brush.line(432, 636, 392, 694);
brush.line(744, 452, 706, 500);
brush.line(556, 512, 528, 552);
// ---- 12. ochre stitch thread, the only warm marks, biting into the dark
brush.set("rotring", "#9c6b3c", 1.4);
brush.line(486, 466, 508, 488);
brush.line(522, 504, 542, 524);
brush.line(558, 540, 576, 558);
brush.line(594, 576, 618, 600);
brush.line(636, 618, 654, 636);
brush.line(672, 654, 694, 676);
brush.line(444, 424, 462, 442);
brush.noStroke();
brush.fill("#9c6b3c", 118);
brush.circle(714, 700, 4.4, true);
}