Crustose Wall
2026 · botanical
Each crust keeps a record of its own weather, and the dark line between two of them is the only argument stone ever has.
lichen colonies spreading across a stone face, crustose rings meeting and crowding each other, the upper-left corner left as bare rock
painted by Kimi K3p5 2.3.2 · brush 2.2.2 · 2026-08-24
// composition: a stone face crowded low and right with crustose colonies; the upper-left corner is left as bare rock
// layering: broad mineral washes and hairline cracks first, colony bodies in several passes, growth zones and radial hyphae over them, and last the dark seams where two crusts collide
// the focal colony sits on the lower-right third intersection — its sulphur rim is the only warm note on the wall
const PALETTE = [
"#e4dfd4", // ground — bare stone
"#8f8b7c", // stone — mineral grey
"#6f8465", // mass — crustose sage
"#4a5a4c", // shadow — wet slate green
"#2c3329", // ink — spore-dark seams and apothecia
"#c2873a", // accent — sulphur ochre, rationed
];
// Every colony is written out by hand: no two share a radius, a phase, or a mark count.
// p is the noise phase that decides that colony's lobing, so each margin is its own shape.
const COLONIES = [
{ x: 526, y: 548, r: 156, p: 0.31, tint: "#6f8465", a: 168, zones: 4, rays: 44, dots: 24, grain: 32, ochre: 1.0 },
{ x: 250, y: 664, r: 138, p: 1.72, tint: "#8f8b7c", a: 150, zones: 3, rays: 30, dots: 10, grain: 26, ochre: 0.0 },
{ x: 706, y: 690, r: 142, p: 2.44, tint: "#4a5a4c", a: 126, zones: 3, rays: 36, dots: 11, grain: 24, ochre: 0.0 },
{ x: 386, y: 476, r: 92, p: 3.15, tint: "#6f8465", a: 176, zones: 3, rays: 24, dots: 8, grain: 20, ochre: 0.0 },
{ x: 622, y: 400, r: 84, p: 0.88, tint: "#8f8b7c", a: 158, zones: 2, rays: 22, dots: 7, grain: 18, ochre: 0.3 },
{ x: 150, y: 492, r: 76, p: 4.06, tint: "#6f8465", a: 164, zones: 2, rays: 19, dots: 6, grain: 16, ochre: 0.0 },
{ x: 442, y: 736, r: 88, p: 2.09, tint: "#4a5a4c", a: 134, zones: 2, rays: 21, dots: 7, grain: 18, ochre: 0.0 },
{ x: 772, y: 476, r: 72, p: 1.24, tint: "#6f8465", a: 170, zones: 2, rays: 17, dots: 6, grain: 15, ochre: 0.0 },
{ x: 312, y: 386, r: 44, p: 5.11, tint: "#8f8b7c", a: 156, zones: 1, rays: 13, dots: 4, grain: 11, ochre: 0.0 },
{ x: 530, y: 336, r: 40, p: 0.57, tint: "#6f8465", a: 188, zones: 2, rays: 12, dots: 4, grain: 10, ochre: 0.6 },
{ x: 668, y: 296, r: 30, p: 3.62, tint: "#4a5a4c", a: 142, zones: 1, rays: 10, dots: 3, grain: 8, ochre: 0.0 },
{ x: 108, y: 758, r: 52, p: 1.45, tint: "#6f8465", a: 148, zones: 1, rays: 14, dots: 4, grain: 12, ochre: 0.0 },
{ x: 226, y: 566, r: 40, p: 2.83, tint: "#8f8b7c", a: 172, zones: 1, rays: 12, dots: 3, grain: 10, ochre: 0.0 },
{ x: 616, y: 634, r: 46, p: 4.47, tint: "#6f8465", a: 182, zones: 2, rays: 13, dots: 4, grain: 12, ochre: 0.4 },
{ x: 748, y: 210, r: 24, p: 0.12, tint: "#4a5a4c", a: 138, zones: 1, rays: 9, dots: 3, grain: 7, ochre: 0.0 },
{ x: 356, y: 606, r: 52, p: 5.64, tint: "#4a5a4c", a: 146, zones: 2, rays: 15, dots: 4, grain: 13, ochre: 0.0 },
{ x: 486, y: 662, r: 40, p: 2.21, tint: "#8f8b7c", a: 166, zones: 1, rays: 12, dots: 4, grain: 10, ochre: 0.0 },
{ x: 668, y: 522, r: 44, p: 3.94, tint: "#6f8465", a: 178, zones: 2, rays: 13, dots: 4, grain: 11, ochre: 0.2 },
{ x: 296, y: 726, r: 46, p: 0.73, tint: "#6f8465", a: 152, zones: 1, rays: 13, dots: 4, grain: 12, ochre: 0.0 },
{ x: 566, y: 490, r: 21, p: 3.38, tint: "#4a5a4c", a: 232, zones: 1, rays: 8, dots: 7, grain: 16, ochre: 0.0, young: 1 },
{ x: 454, y: 592, r: 18, p: 1.07, tint: "#4a5a4c", a: 224, zones: 1, rays: 7, dots: 6, grain: 14, ochre: 0.0, young: 1 },
{ x: 686, y: 604, r: 24, p: 4.82, tint: "#4a5a4c", a: 214, zones: 1, rays: 9, dots: 7, grain: 17, ochre: 0.0, young: 1 },
{ x: 612, y: 452, r: 15, p: 2.55, tint: "#4a5a4c", a: 236, zones: 1, rays: 6, dots: 5, grain: 11, ochre: 0.0, young: 1 },
{ x: 796, y: 616, r: 19, p: 5.31, tint: "#4a5a4c", a: 220, zones: 1, rays: 7, dots: 5, grain: 12, ochre: 0.0, young: 1 },
];
// the lobed margin of a crust: a radius that breathes with the colony's own noise phase
function lobeR(r, ang, wob, phase) {
const n = noise(Math.cos(ang) * 1.15 + phase * 3.7, Math.sin(ang) * 1.15 + phase * 2.3);
return r * (1 + (n - 0.5) * wob);
}
// a broken concentric arc — never a closed ring, so no colony ends up outlined
function arcOn(c, f, a0, a1, col, w, name) {
const steps = max(5, floor((a1 - a0) * 7));
brush.noFill();
brush.set(name, col, w);
brush.beginShape(0.45);
for (let i = 0; i <= steps; i++) {
const a = a0 + ((a1 - a0) * i) / steps;
const r = lobeR(c.r * f, a, 0.46, c.p) + randomGaussian(0, 1.4);
brush.vertex(c.x + Math.cos(a) * r, c.y + Math.sin(a) * r);
}
brush.endShape(false);
}
// the body: a bleeding lobed wash, a smaller offset pass, and a darker old centre
function crust(c) {
brush.noStroke();
brush.fillBleed(0.34, "out");
brush.fillTexture(0.62, 0.5);
brush.fill(c.tint, c.a);
brush.beginShape(0.6);
for (let i = 0; i < 22; i++) {
const a = (i / 22) * Math.PI * 2;
const r = lobeR(c.r * 1.03, a, 0.5, c.p) + randomGaussian(0, 2.6);
brush.vertex(c.x + Math.cos(a) * r, c.y + Math.sin(a) * r);
}
brush.endShape(true);
brush.fillBleed(0.26, "out");
brush.fill(c.tint, constrain(randomGaussian(c.a, 12), 90, 240));
brush.circle(
c.x + randomGaussian(0, c.r * 0.15),
c.y + randomGaussian(0, c.r * 0.15),
c.r * (0.66 + random(-0.06, 0.1)),
true,
);
brush.fillBleed(0.2, "out");
brush.fill(random() < 0.4 ? "#4a5a4c" : c.tint, constrain(randomGaussian(c.a * 0.8, 12), 70, 210));
brush.circle(c.x + randomGaussian(0, c.r * 0.22), c.y + randomGaussian(0, c.r * 0.22), c.r * 0.4, true);
if (c.young) {
brush.fillBleed(0.14, "out");
brush.fill("#2c3329", 172);
brush.circle(c.x + randomGaussian(0, c.r * 0.14), c.y + randomGaussian(0, c.r * 0.14), c.r * 0.66, true);
}
if (c.r > 90) {
brush.fillBleed(0.16, "out");
brush.fill("#4a5a4c", constrain(randomGaussian(112, 16), 70, 160));
brush.circle(c.x + randomGaussian(0, c.r * 0.28), c.y + randomGaussian(0, c.r * 0.28), c.r * 0.26, true);
}
}
// interior grain: the crust's own roughness, stroked in so it holds tone
function grain(c) {
brush.noFill();
const n = floor(c.grain * 1.7);
for (let i = 0; i < n; i++) {
const a = i * 2.399 + c.p * 2 + randomGaussian(0, 0.3);
const rr = c.r * constrain(randomGaussian(0.52, 0.26), 0.05, 0.96);
const len = max(2.5, randomGaussian(c.r * 0.1, c.r * 0.04));
const dir = a + randomGaussian(0, 0.9);
brush.set("cpencil", random() < 0.62 ? "#4a5a4c" : "#8f8b7c", 0.55 + random(0, 0.8));
brush.line(
c.x + Math.cos(a) * rr, c.y + Math.sin(a) * rr,
c.x + Math.cos(a) * rr + Math.cos(dir) * len, c.y + Math.sin(a) * rr + Math.sin(dir) * len,
);
}
}
// growth zones, outermost first and heaviest
function zones(c) {
let f = 0.96;
for (let k = 0; k <= c.zones; k++) {
let a = c.p * 1.3 + k * 1.1 + random(-0.4, 0.4);
const arcs = (k === 0 ? 3 : 2) + floor(random(0, 3));
for (let s = 0; s < arcs; s++) {
const span = max(0.3, randomGaussian(0.95, 0.4));
const warm = k === 0 && random() < c.ochre * 0.34;
const col = warm ? "#c2873a" : k === 0 ? "#2c3329" : random() < 0.45 ? "#4a5a4c" : "#2c3329";
arcOn(c, f + random(-0.03, 0.03), a, a + span, col,
k === 0 ? 0.5 + c.r * 0.0038 : 0.4 + random(0, 0.35),
k === 0 && c.r > 110 && !warm ? "charcoal" : k === 0 ? "2B" : "HB");
a += span + max(0.3, randomGaussian(0.9, 0.35));
}
f -= 0.18 + random(0, 0.05);
}
}
// the living edge, stippled: short tangential ticks with gaps, never a continuous line
function marginStipple(c) {
const n = 10 + floor(c.r * 0.12);
brush.noFill();
for (let i = 0; i < n; i++) {
if (random() < 0.34) continue;
const a = (i / n) * Math.PI * 2 + randomGaussian(0, 0.07);
const r = lobeR(c.r * 0.99, a, 0.46, c.p);
const tx = -Math.sin(a);
const ty = Math.cos(a);
const len = max(2.5, randomGaussian(5.5, 2));
brush.set("2B", random() < 0.3 ? "#4a5a4c" : "#2c3329", 0.5 + random(0, 0.5));
brush.line(c.x + Math.cos(a) * r - tx * len, c.y + Math.sin(a) * r - ty * len,
c.x + Math.cos(a) * r + tx * len, c.y + Math.sin(a) * r + ty * len);
}
}
// radial growth: hyphae running out from the older centre to the living edge, some of them missing
function rays(c) {
brush.noFill();
brush.set("2H", "#4a5a4c", 0.55);
for (let i = 0; i < c.rays; i++) {
if (random() < 0.22) continue;
const a = (i / c.rays) * Math.PI * 2 + c.p + randomGaussian(0, 0.09);
const r1 = c.r * constrain(randomGaussian(0.34, 0.13), 0.08, 0.66);
const r2 = lobeR(c.r, a, 0.44, c.p) * (0.93 + random(-0.14, 0.05));
brush.stroke(random() < 0.42 ? "#2c3329" : "#4a5a4c");
brush.strokeWeight(0.45 + random(0, 0.5));
brush.line(c.x + Math.cos(a) * r1, c.y + Math.sin(a) * r1, c.x + Math.cos(a) * r2, c.y + Math.sin(a) * r2);
}
}
// apothecia: fruiting discs, spiralled out from the centre — stroked, so they stay properly dark
function apothecia(c) {
brush.noFill();
for (let i = 0; i < c.dots; i++) {
const a = i * 2.399 + c.p + randomGaussian(0, 0.26);
const rr = c.r * Math.sqrt((i + 0.7) / (c.dots + 1)) * (0.84 + random(-0.14, 0.1));
const px = c.x + Math.cos(a) * rr;
const py = c.y + Math.sin(a) * rr;
const s = max(1.3, randomGaussian(c.r * 0.038, c.r * 0.017));
const warm = random() < c.ochre * 0.22;
brush.set("charcoal", warm ? "#c2873a" : "#2c3329", 1.1 + random(0, 0.7));
brush.line(px - s, py - s * random(-0.6, 0.6), px + s, py + s * random(-0.6, 0.6));
}
}
// where two crusts have grown into each other: the chord of their overlap, thickened and darkened
function seam(a, b) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const d = Math.hypot(dx, dy);
if (d > (a.r + b.r) * 0.99 || d < Math.abs(a.r - b.r) * 1.06 || d < 1) return;
const t = (d * d + a.r * a.r - b.r * b.r) / (2 * d);
const h2 = a.r * a.r - t * t;
if (h2 <= 49) return;
if (random() < 0.18) return;
const h = Math.sqrt(h2) * 0.86;
const mx = a.x + (dx / d) * t;
const my = a.y + (dy / d) * t;
const nx = -dy / d;
const ny = dx / d;
const bow = (a.r < b.r ? 1 : -1) * h * 0.15;
brush.noFill();
brush.set(random() < 0.55 ? "charcoal" : "2B", "#2c3329", 0.7 + random(0, 0.6));
brush.beginShape(0.5);
for (let i = 0; i <= 9; i++) {
const s = -1 + (2 * i) / 9;
const off = bow * (1 - s * s);
brush.vertex(
mx + nx * h * s + (dx / d) * off + randomGaussian(0, 1.8),
my + ny * h * s + (dy / d) * off + randomGaussian(0, 1.8),
);
}
brush.endShape(false);
// short ticks where the crowding is worst
brush.set("2B", "#2c3329", 0.55 + random(0, 0.4));
for (let i = 0; i < 2; i++) {
const s = randomGaussian(0, 0.5);
const px = mx + nx * h * s;
const py = my + ny * h * s;
const len = max(3, randomGaussian(7, 2.5));
const sign = random() < 0.5 ? 1 : -1;
brush.line(px, py, px + (dx / d) * len * sign, py + (dy / d) * len * sign);
}
}
// a crack in the rock: straight in intent, never straight in fact
function crack(pts, col, w) {
brush.noFill();
brush.set("2H", col, w);
brush.beginShape(0.4);
for (let i = 0; i < pts.length; i++) {
brush.vertex(pts[i][0] + randomGaussian(0, 4), pts[i][1] + randomGaussian(0, 4));
}
brush.endShape(false);
}
function setup() {
createCanvas(800, 800, WEBGL);
brush.scaleBrushes(2.4);
noLoop();
}
function draw() {
translate(-width / 2, -height / 2);
background("#e4dfd4");
// the rock itself: broad mineral washes, all of them keeping clear of the upper-left
brush.noStroke();
brush.fillBleed(0.7, "out");
brush.fillTexture(0.42, 0.35);
brush.fill("#8f8b7c", 72);
brush.circle(600, 620, 268, true);
brush.fill("#8f8b7c", 56);
brush.circle(304, 700, 232, true);
brush.fill("#4a5a4c", 38);
brush.circle(742, 386, 196, true);
brush.fill("#8f8b7c", 46);
brush.circle(452, 476, 208, true);
brush.fill("#8f8b7c", 28);
brush.circle(124, 566, 158, true);
brush.fill("#4a5a4c", 26);
brush.circle(470, 790, 210, true);
brush.fill("#4a5a4c", 44);
brush.circle(690, 700, 200, true);
brush.fill("#8f8b7c", 52);
brush.circle(196, 736, 168, true);
brush.fill("#8f8b7c", 34);
brush.circle(560, 372, 140, true);
// hairline cracks, laid down before the lichen so the colonies sit on top of them
crack([[322, 812], [372, 698], [398, 660], [486, 574]], "#8f8b7c", 0.7);
crack([[524, 500], [552, 430], [578, 348]], "#8f8b7c", 0.5);
crack([[812, 548], [712, 534], [660, 528], [524, 566]], "#8f8b7c", 0.6);
crack([[452, 592], [386, 618], [268, 706]], "#8f8b7c", 0.5);
crack([[486, 574], [452, 534], [432, 494]], "#4a5a4c", 0.5);
// grit — the stone's tooth, only in the crowded lower band
brush.field("seabed");
brush.set("spray", "#8f8b7c", 1.4);
brush.flowLine(180, 636, 150, 12);
brush.flowLine(508, 742, 190, -8);
brush.flowLine(676, 566, 160, 24);
brush.set("spray", "#4a5a4c", 1.1);
brush.flowLine(340, 780, 170, -14);
brush.flowLine(730, 640, 130, 6);
brush.flowLine(238, 522, 120, 30);
brush.noField();
// colony bodies, largest first
for (const c of COLONIES) crust(c);
// two crusts carry hatched plate-like interiors — the Haeckel note, used twice and no more
brush.hatchStyle("2H", "#4a5a4c", 0.55);
brush.hatch(7, 38, { rand: 0.16, continuous: false });
brush.noStroke();
brush.fill("#6f8465", 52);
brush.circle(386, 476, 62, true);
brush.noHatch();
// interior grain, growth zones, the stippled living edge, then radial hyphae
for (const c of COLONIES) grain(c);
for (const c of COLONIES) zones(c);
for (const c of COLONIES) marginStipple(c);
for (const c of COLONIES) rays(c);
// the focal crust keeps a sulphur margin on its weather side — the one warm note
arcOn(COLONIES[0], 0.99, 3.5, 4.5, "#c2873a", 2.1, "2B");
arcOn(COLONIES[0], 0.96, 4.8, 5.5, "#c2873a", 1.6, "2B");
arcOn(COLONIES[13], 0.98, 1.1, 1.9, "#c2873a", 1.1, "2B");
// fruiting discs
for (const c of COLONIES) apothecia(c);
// the darks land last: every pair of crusts that has grown into another
for (let i = 0; i < COLONIES.length; i++) {
for (let j = i + 1; j < COLONIES.length; j++) seam(COLONIES[i], COLONIES[j]);
}
// crowding shadows, placed by hand where the focal cluster presses hardest
brush.noFill();
brush.set("charcoal", "#2c3329", 1.9);
brush.line(552, 486, 578, 502);
brush.set("charcoal", "#2c3329", 1.5);
brush.line(590, 470, 604, 448);
brush.set("charcoal", "#2c3329", 2.2);
brush.line(646, 588, 672, 596);
brush.set("charcoal", "#2c3329", 1.6);
brush.line(470, 606, 448, 618);
brush.set("charcoal", "#2c3329", 1.3);
brush.line(506, 640, 526, 650);
brush.set("charcoal", "#2c3329", 1.7);
brush.line(700, 636, 716, 620);
// three sulphur pinpricks colonising bare stone above the mass, and one dark fleck below
brush.noFill();
brush.set("charcoal", "#c2873a", 2.1);
brush.line(468, 262, 476, 264);
brush.set("charcoal", "#c2873a", 1.5);
brush.line(557, 232, 563, 234);
brush.set("charcoal", "#c2873a", 1.8);
brush.line(690, 356, 698, 357);
brush.set("charcoal", "#2c3329", 1.6);
brush.line(192, 424, 200, 426);
}