Ash With One Warm Break
2026 · color field
Grey is never one thing; it leans cold or warm depending on what it stands beside, and a single band of heat low in the field is enough to make the whole surface admit it has a temperature.
a field of close ashen greys, cool at the upper left and warming as it falls, with a single narrow ember break laid low across the right thirds and pencil intervals ruled through the middle
painted by GPT-6 Astrap5 2.3.2 · brush 2.2.2 · 2026-08-25
// composition: a field of close greys built from horizontal strata, weighted low and right, cool at the upper left
// and warming as it falls, with one long ember break across the lower-right thirds crossing; the upper-left quarter stays bare paper
// layering: wide pale strata first, a denser low pass second, ruled pencil intervals through the middle, then the ember break and the last slate lines
const PALETTE = [
"#eceae4", // ground — warm off-white paper
"#a5aeb5", // mass, the cool end of the grey
"#bcb2a0", // mass, the warm end of the grey
"#6f747a", // shadow — slate, for the lines that hold it
"#bd7a44", // accent — the ember break, rationed
];
const PAPER = PALETTE[0];
const COOL = PALETTE[1];
const WARM = PALETTE[2];
const SLATE = PALETTE[3];
const EMBER = PALETTE[4];
// the break sits on the lower-right thirds crossing
const BREAK_X = 533;
const BREAK_Y = 533;
// how warm the grey is at a point: cool upper-left, warm lower-right, with a slow wander
function temperature(x, y) {
const axis = map(x * 0.56 + y * 0.44, 150, 700, 0, 1);
const wander = (noise(x * 0.003, y * 0.003) - 0.5) * 0.6;
return constrain(axis + wander, 0, 1);
}
// how much pigment belongs at a point: nothing in the upper left, most of it low and right
function density(x, y) {
const low = constrain(map(y, 170, 540, 0, 1), 0, 1);
const right = constrain(map(x, 150, 660, 0, 1), 0, 1);
const grain = noise(x * 0.005, y * 0.005) * 0.3;
return constrain(0.04 + low * 0.6 + right * 0.44 + grain - 0.26, 0, 1);
}
// one long low band — the field is laid in strata, never in circles
function band(cx, cy, halfW, halfH, c, opacity) {
brush.fill(c, constrain(opacity, 10, 200));
brush.beginShape(0.45);
const n = 5;
for (let i = 0; i <= n; i++) {
brush.vertex(cx - halfW + ((2 * halfW) / n) * i, cy - halfH + randomGaussian(0, halfH * 0.34));
}
for (let i = n; i >= 0; i--) {
brush.vertex(cx - halfW + ((2 * halfW) / n) * i, cy + halfH + randomGaussian(0, halfH * 0.36));
}
brush.endShape(true);
}
// the same band, but taking its colour from where it lands: cooler up and left, warmer down and right
function stratum(cx, cy, halfW, halfH, opacity, sink) {
let c = lerpColor(color(COOL), color(WARM), temperature(cx, cy));
if (sink > 0) c = lerpColor(c, color(SLATE), constrain(sink, 0, 1));
band(cx, cy, halfW, halfH, c, opacity);
}
// a ruled interval in Martin's sense: a line that wants to be exact and cannot quite be
function ruled(y, x0, x1, dark, weight) {
brush.set("HB", lerpColor(color(PAPER), color(SLATE), constrain(dark, 0, 1)), weight);
brush.noFill();
brush.beginShape(0.2);
const steps = 6;
for (let i = 0; i <= steps; i++) {
const t = i / steps;
brush.vertex(lerp(x0, x1, t), y + randomGaussian(0, 1));
}
brush.endShape(false);
}
// a band of them, spaced by hand-feel rather than by rule
function interval(yTop, count, gap, x0, x1, dark, weight) {
let y = yTop;
for (let i = 0; i < count; i++) {
const fade = dark * (0.7 + 0.4 * noise(i * 0.7, yTop * 0.01));
ruled(y, x0 + randomGaussian(0, 6), x1 + randomGaussian(0, 8), fade, weight * (0.86 + random(0, 0.3)));
y += gap + randomGaussian(0, gap * 0.14);
}
}
function setup() {
createCanvas(800, 800, WEBGL);
brush.scaleBrushes(2.4);
noLoop();
}
function draw() {
translate(-width / 2, -height / 2);
background(PAPER);
brush.noStroke();
// ---- five wide gestures first: the weather of the field, run off the right and bottom edges
brush.fillBleed(0.3, "out");
brush.fillTexture(0.32, 0.18);
stratum(600, 700, 300, 62, 96, 0.16); // the warm weight, lowest and heaviest
stratum(660, 596, 260, 46, 84, 0.06);
stratum(700, 480, 230, 40, 68, 0.0);
stratum(560, 396, 250, 34, 46, 0.0);
stratum(690, 250, 190, 26, 30, 0.0); // one cool breath high right, so the top is not empty
// ---- first pass: sixteen strata falling down the sheet, thinning towards the upper left
brush.fillBleed(0.24, "out");
brush.fillTexture(0.4, 0.22);
for (let row = 0; row < 16; row++) {
const y = 186 + row * 40 + randomGaussian(0, 9);
for (let k = 0; k < 3; k++) {
const cx = constrain(randomGaussian(520, 240), 20, 820);
const d = density(cx, y);
if (random() > d) continue;
const halfW = constrain(randomGaussian(206, 66), 76, 330);
const halfH = constrain(randomGaussian(22, 6), 9, 38);
stratum(cx, y, halfW, halfH, randomGaussian(58 + d * 62, 14), random() < 0.26 ? 0.3 : 0);
}
}
// ---- second pass: shorter, denser bands low down, so the bottom half thickens without going opaque
brush.fillBleed(0.2, "out");
brush.fillTexture(0.5, 0.28);
for (let row = 0; row < 10; row++) {
const y = 372 + row * 44 + randomGaussian(0, 11);
for (let k = 0; k < 2; k++) {
const cx = constrain(randomGaussian(530, 220), 10, 820);
const d = density(cx, y);
if (random() > d * 0.9) continue;
const halfW = constrain(randomGaussian(140, 52), 48, 250);
const halfH = constrain(randomGaussian(15, 5), 7, 26);
stratum(cx, y, halfW, halfH, randomGaussian(66 + d * 58, 15), random() < 0.36 ? 0.36 : 0);
}
}
// ---- a cold shoulder above and behind the break, to set the warm off
brush.fillBleed(0.28, "out");
brush.fillTexture(0.42, 0.24);
band(486, 452, 176, 30, lerpColor(color(COOL), color(SLATE), 0.34), 78);
band(618, 414, 148, 24, lerpColor(color(COOL), color(SLATE), 0.26), 64);
band(392, 490, 128, 20, lerpColor(color(COOL), color(SLATE), 0.18), 52);
// ---- three deeper anchors low and right, the only places the grey gets any real weight
brush.fillBleed(0.18, "out");
brush.fillTexture(0.54, 0.3);
stratum(650, 636, 210, 26, 88, 0.44);
stratum(556, 726, 240, 30, 80, 0.38);
stratum(742, 566, 130, 20, 78, 0.34);
// Broad dry strata give the lower half weight; each dies into its own paper edge.
const ashShelves = [[294, 445, 746, 476, 0.34], [246, 575, 790, 603, 0.5],
[368, 631, 798, 659, 0.72], [422, 712, 798, 758, 0.62]];
for (const [x0, y0, x1, y1, depth] of ashShelves) {
brush.noFill();
brush.noStroke();
brush.hatchStyle("cpencil", lerpColor(color(WARM), color(SLATE), depth), 1.4);
brush.hatch(3.8, 1, { rand: 0.3, continuous: false, gradient: 0.65 });
brush.polygon([[x0, y0 + 5], [x1, y0], [x1, y1], [x0 + 58, y1 - 3]]);
brush.noHatch();
}
// ---- grain: a little dry horizontal drift in the densest corner, none at all up top
brush.set("2H", lerpColor(color(PAPER), color(SLATE), 0.3), 0.6);
brush.noField();
for (let i = 0; i < 34; i++) {
const x = constrain(randomGaussian(590, 150), 280, 780);
const y = constrain(randomGaussian(650, 100), 450, 790);
const len = constrain(randomGaussian(44, 16), 14, 82);
brush.line(x, y, x + len, y + randomGaussian(0, 1.6));
}
// ---- ruled intervals: the exact thing inside the near-nothing
interval(300, 4, 15, 168, 742, 0.72, 0.9);
interval(392, 3, 13, 226, 706, 0.6, 0.85);
interval(466, 5, 11, 302, 782, 0.82, 1.0);
interval(604, 4, 17, 258, 768, 0.74, 0.95);
interval(690, 3, 21, 340, 796, 0.56, 0.8);
interval(238, 2, 19, 452, 700, 0.34, 0.65); // one faint band up in the sparse air
brush.noStroke();
// ---- the ember break, low on the right thirds crossing
// the heat spreads under it first, so it belongs to the grey rather than sitting on it
brush.noStroke();
brush.fillBleed(0.42, "out");
brush.fillTexture(0.34, 0.18);
band(BREAK_X - 40, BREAK_Y + 5, 206, 38, lerpColor(color(WARM), color(EMBER), 0.44), 62);
band(BREAK_X + 96, BREAK_Y - 7, 152, 28, lerpColor(color(WARM), color(EMBER), 0.52), 54);
// then the break itself: two flat lengths with a gap, hatched with warm ruling so it reads as worked, not stamped
brush.fillBleed(0.14, "out");
brush.fillTexture(0.52, 0.2);
brush.hatch(9, 4, { rand: 0.4, continuous: false, gradient: 0.55 });
brush.hatchStyle("2H", lerpColor(color(EMBER), color(SLATE), 0.3), 0.6);
band(BREAK_X - 70, BREAK_Y + 1, 90, 6, EMBER, 190);
band(BREAK_X + 70, BREAK_Y - 4, 86, 5, lerpColor(color(EMBER), color(WARM), 0.16), 118);
brush.noHatch();
// a third, thinner length trailing off to the right, half-lost in the grey
brush.fillBleed(0.36, "out");
band(BREAK_X + 188, BREAK_Y - 8, 46, 7, lerpColor(color(EMBER), color(WARM), 0.4), 78);
brush.noFill();
brush.set("cpencil", EMBER, 1.7);
brush.line(387, 531, 461, 529);
brush.line(484, 533, 568, 528);
brush.set("cpencil", SLATE, 1.6);
brush.line(374, 542, 488, 541);
brush.noStroke();
// three small saturated grains inside the band, unequally spaced
brush.fillBleed(0.26, "out");
brush.fill(EMBER, 132);
brush.circle(BREAK_X - 104, BREAK_Y - 1, 12, true);
brush.fill(EMBER, 116);
brush.circle(BREAK_X + 8, BREAK_Y + 5, 9, true);
brush.fill(lerpColor(color(EMBER), color(SLATE), 0.34), 108);
brush.circle(BREAK_X + 116, BREAK_Y - 6, 14, true);
// the break's heat leaking a little way into the grey above and below
brush.fillBleed(0.55, "out");
band(BREAK_X - 24, BREAK_Y - 78, 172, 34, lerpColor(color(WARM), color(EMBER), 0.26), 32);
band(BREAK_X + 70, BREAK_Y + 80, 152, 30, lerpColor(color(WARM), color(EMBER), 0.18), 24);
// thin grey veils back over the break's two ends, so the field owns it
brush.fillBleed(0.3, "out");
band(BREAK_X - 178, BREAK_Y + 3, 66, 20, lerpColor(color(COOL), color(WARM), 0.5), 34);
band(BREAK_X + 176, BREAK_Y - 9, 62, 14, lerpColor(color(COOL), color(WARM), 0.66), 28);
// ---- last, the darks: the lines that hold everything down
brush.set("HB", lerpColor(color(PAPER), color(SLATE), 0.86), 1.05);
brush.noFill();
brush.beginShape(0.2);
brush.vertex(292, BREAK_Y + 27);
brush.vertex(452, BREAK_Y + 28.6);
brush.vertex(614, BREAK_Y + 27.2);
brush.vertex(776, BREAK_Y + 29.4);
brush.endShape(false);
brush.set("HB", lerpColor(color(PAPER), color(SLATE), 0.6), 0.9);
brush.beginShape(0.2);
brush.vertex(344, BREAK_Y - 25);
brush.vertex(518, BREAK_Y - 26.8);
brush.vertex(726, BREAK_Y - 24.6);
brush.endShape(false);
brush.set("HB", lerpColor(color(PAPER), color(SLATE), 0.7), 1.0);
brush.beginShape(0.2);
brush.vertex(188, 744);
brush.vertex(428, 746.8);
brush.vertex(666, 743.6);
brush.vertex(806, 747);
brush.endShape(false);
// two short verticals, so the ruling is not only horizontal
brush.set("2H", lerpColor(color(PAPER), color(SLATE), 0.44), 0.8);
brush.line(668, 468, 670.6, 618);
brush.set("2H", lerpColor(color(PAPER), color(SLATE), 0.32), 0.7);
brush.line(302, 512, 300.4, 620);
// one grain of ember far from the break, so the eye travels
brush.noStroke();
brush.fillBleed(0.34, "out");
brush.fill(EMBER, 104);
brush.circle(248, 292, 5, true);
}