Cluster with Clouded Face
2026 · study
A crystal is an argument made entirely of flat planes; I wanted one of them to lose the thread and fill with smoke.
a mineral crystal cluster low on the left, facets meeting at hard angles, one long prism driving diagonally to the upper right, and a single face clouded with inclusions
painted by Grok 4.6p5 2.3.2 · brush 2.2.2 · 2026-08-24
// composition: the crystal cluster sits low-left on its thirds intersection, one long prism
// driving diagonally out to the upper right; the top-left and lower-right stay bare paper.
// layering: pale washes and the diagonal breath first, then facet planes back-to-front in
// flat tones, hatched shadow faces, the clouded face of inclusions, hard ink edges last.
const PALETTE = [
"#e9e4d8", // ground — warm plate paper
"#8e9aa3", // mass — lit slate faces
"#5c6a74", // shadow — turned faces
"#2b3138", // ink — the hard angles
"#7a6a5c", // cloud — inclusions, smoky warm grey
"#b4763c", // accent — iron staining, rationed
];
// --- geometry ------------------------------------------------------------
// A prism is four cuts across its width: three long planes plus a
// three-sided termination. Every corner is nudged so no angle is machine-true.
function prismShape(bx, by, ang, len, halfW, tipLen, cuts) {
const ax = cos(ang);
const ay = sin(ang);
const px = -sin(ang);
const py = cos(ang);
const sxc = bx + ax * (len - tipLen);
const syc = by + ay * (len - tipLen);
const base = [];
const shoulder = [];
for (const c of cuts) {
const o = c * halfW;
base.push([bx + px * o + randomGaussian(0, 1.7), by + py * o + randomGaussian(0, 1.7)]);
shoulder.push([sxc + px * o + randomGaussian(0, 2.1), syc + py * o + randomGaussian(0, 2.1)]);
}
const tipOff = halfW * 0.2;
const apex = [bx + ax * len + px * tipOff + randomGaussian(0, 2.4), by + ay * len + py * tipOff + randomGaussian(0, 2.4)];
return { base, shoulder, apex };
}
function face(P, i) {
return [P.base[i], P.base[i + 1], P.shoulder[i + 1], P.shoulder[i]];
}
// pull a plane in towards its own centre, for a second, deeper pass
function inset(q, k) {
let cx = 0;
let cy = 0;
for (const p of q) { cx += p[0] / q.length; cy += p[1] / q.length; }
return q.map((p) => [lerp(p[0], cx, k) + randomGaussian(0, 1.3), lerp(p[1], cy, k) + randomGaussian(0, 1.3)]);
}
// --- paint ---------------------------------------------------------------
// three long planes and their terminal triangles, each tone laid twice so
// the pigment has somewhere to pool
function paintPrism(P, tones, alphas) {
brush.noStroke();
brush.noHatch();
for (let i = 0; i < 3; i++) {
const q = face(P, i);
brush.fillBleed(0.14 + random(0, 0.08), "out");
brush.fillTexture(0.42, 0.9);
brush.fill(tones[i], alphas[i] + random(-14, 18));
brush.polygon(q);
brush.fill(tones[i], alphas[i] * 0.7 + random(-12, 16));
brush.polygon(inset(q, 0.16));
const tri = [P.shoulder[i], P.shoulder[i + 1], P.apex];
brush.fill(tones[i], alphas[i] * 0.74 + random(-12, 14));
brush.polygon(tri);
brush.fill(tones[i], alphas[i] * 0.5 + random(-10, 12));
brush.polygon(inset(tri, 0.2));
}
}
// tone the way the old plates made it: ruled lines, twice over, tight where
// the plane turns away from the light
function crossHatch(P, i, d, a1, a2, col, w) {
brush.noStroke();
brush.noFill();
const q = inset(face(P, i), 0.05);
brush.hatchStyle("2B", col, w);
brush.hatch(d, a1);
brush.polygon(q);
brush.hatchStyle("2H", col, w * 0.7);
brush.hatch(d * 1.6, a2);
brush.polygon(inset(q, 0.1));
brush.noHatch();
brush.hatchStyle("2B", col, w * 0.85);
brush.hatch(d * 1.1, a1 + random(-4, 4));
brush.polygon(inset([P.shoulder[i], P.shoulder[i + 1], P.apex], 0.08));
brush.noHatch();
}
// growth striations, ruled across the prism the way quartz is banded
function striate(P, count, col, w) {
brush.noFill();
brush.set("2H", col, w);
for (let k = 0; k < count; k++) {
const v = constrain(randomGaussian(0.45, 0.27), 0.05, 0.92);
const a = [lerp(P.base[0][0], P.shoulder[0][0], v), lerp(P.base[0][1], P.shoulder[0][1], v)];
const b = [lerp(P.base[3][0], P.shoulder[3][0], v + random(-0.02, 0.02)), lerp(P.base[3][1], P.shoulder[3][1], v)];
hardLine(a, b, 2.2);
}
}
function hatchFace(P, i, dist, ang, col, w) {
brush.noStroke();
brush.noFill();
brush.hatchStyle("2H", col, w);
brush.hatch(dist, ang);
brush.polygon(inset(face(P, i), 0.07));
brush.noHatch();
}
// a hard edge, drawn as two segments with a slight kink at the joint
function hardLine(a, b, spread) {
const mx = (a[0] + b[0]) / 2 + randomGaussian(0, spread);
const my = (a[1] + b[1]) / 2 + randomGaussian(0, spread);
brush.line(a[0], a[1], mx, my);
brush.line(mx, my, b[0], b[1]);
}
// the silhouette gets the pressure; only one interior cut is drawn, and only
// down as far as the shoulder, so the tone carries the rest
function inkPrism(P, col, w, interior) {
brush.noFill();
brush.set("rotring", col, w * 1.3);
hardLine(P.base[3], P.shoulder[3], 1.8);
hardLine(P.shoulder[3], P.apex, 1.3);
brush.set("rotring", col, w * 0.95);
hardLine(P.base[0], P.shoulder[0], 1.6);
hardLine(P.shoulder[0], P.apex, 1.2);
if (interior) {
brush.set("2B", col, w * 0.7);
hardLine(P.base[2], P.shoulder[2], 1.5);
hardLine(P.shoulder[2], P.apex, 1.0);
brush.set("2H", col, w * 0.55);
hardLine(P.shoulder[1], P.apex, 1.1);
}
brush.set("2B", col, w * 0.8);
hardLine(P.shoulder[1], P.shoulder[2], 2.0);
}
// the clouded face: a milky field first, then blooms, then a stipple of
// mineral dust — the one plane in the picture that has lost its edge
function cloudPoint(q, u, v) {
const x = lerp(lerp(q[0][0], q[1][0], u), lerp(q[3][0], q[2][0], u), v);
return [x, lerp(lerp(q[0][1], q[1][1], u), lerp(q[3][1], q[2][1], u), v)];
}
function cloudFace(q, count) {
brush.noStroke();
brush.noHatch();
// the milk
brush.fillBleed(0.72, "out");
brush.fillTexture(0.3, 0.3);
const milk = [[0.44, 0.68, 46], [0.56, 0.44, 40], [0.4, 0.26, 34], [0.62, 0.82, 30]];
for (const m of milk) {
const c = cloudPoint(q, m[0], m[1]);
brush.fill("#7a6a5c", m[2] + random(-6, 10));
brush.circle(c[0], c[1], 30 + random(0, 14), true);
}
// the inclusions themselves
for (let i = 0; i < count; i++) {
const u = constrain(randomGaussian(0.5, 0.31), 0.05, 0.95);
const v = constrain(randomGaussian(0.56, 0.3), 0.04, 0.96);
const c = cloudPoint(q, u, v);
if (noise(c[0] * 0.009, c[1] * 0.009) < 0.4) continue;
const r = constrain(randomGaussian(9, 4.6), 2.4, 21);
const roll = random();
brush.fillBleed(0.5, "out");
brush.fillTexture(0.46, 0.44);
if (roll < 0.6) brush.fill("#7a6a5c", 126 + random(-26, 42));
else if (roll < 0.9) brush.fill("#5c6a74", 132 + random(-28, 34));
else if (roll < 0.96) brush.fill("#b4763c", 104 + random(-20, 26));
else brush.fill("#2b3138", 168 + random(-24, 26));
brush.circle(c[0], c[1], r, true);
}
// dust, clustered rather than sprinkled
brush.fillBleed(0.1, "out");
brush.fillTexture(0.7, 0.9);
for (let i = 0; i < 130; i++) {
const u = constrain(randomGaussian(0.46, 0.26), 0.06, 0.94);
const v = constrain(randomGaussian(0.6, 0.24), 0.06, 0.94);
const c = cloudPoint(q, u, v);
if (noise(c[0] * 0.03, c[1] * 0.03) < 0.52) continue;
brush.fill("#2b3138", 118 + random(-30, 46));
brush.circle(c[0], c[1], constrain(randomGaussian(2, 0.9), 0.7, 4.2), true);
}
}
function crumb(x, y, r, col, a) {
brush.noStroke();
brush.fillBleed(0.16, "out");
brush.fillTexture(0.45, 0.85);
brush.fill(col, a);
brush.polygon([
[x + randomGaussian(0, 1.2), y - r + randomGaussian(0, 1.2)],
[x + r * 1.1 + randomGaussian(0, 1.2), y + r * 0.3 + randomGaussian(0, 1.2)],
[x + r * 0.2 + randomGaussian(0, 1.2), y + r + randomGaussian(0, 1.2)],
[x - r * 0.9 + randomGaussian(0, 1.2), y + r * 0.2 + randomGaussian(0, 1.2)],
]);
}
function setup() {
createCanvas(800, 800, WEBGL);
angleMode(DEGREES);
brush.scaleBrushes(2.4);
noLoop();
}
function draw() {
translate(-width / 2, -height / 2);
background("#e9e4d8");
// 1. the air. A pooled grey under the cluster, and a faint breath climbing
// the diagonal so the long prism has somewhere to travel.
brush.noStroke();
brush.fillBleed(0.66, "out");
brush.fillTexture(0.34, 0.34);
brush.fill("#8e9aa3", 30);
brush.circle(206, 700, 200, true);
brush.fill("#8e9aa3", 22);
brush.circle(340, 742, 164, true);
brush.fill("#5c6a74", 18);
brush.circle(118, 764, 146, true);
brush.fill("#8e9aa3", 14);
brush.circle(452, 470, 150, true);
brush.fill("#8e9aa3", 10);
brush.circle(592, 320, 126, true);
brush.fill("#7a6a5c", 12);
brush.circle(288, 588, 112, true);
// 2. facet planes, back to front.
// a pale ghost prism standing behind everything, its tip snapped off flat
const ghost = prismShape(206, 742, -84, 288, 32, 26, [-1, -0.24, 0.42, 1]);
paintPrism(ghost, ["#8e9aa3", "#8e9aa3", "#5c6a74"], [58, 44, 70]);
// a low blade lying to the left, running off the edge
const blade = prismShape(-18, 712, -19, 176, 23, 60, [-1, -0.12, 0.5, 1]);
paintPrism(blade, ["#8e9aa3", "#5c6a74", "#5c6a74"], [130, 104, 162]);
crossHatch(blade, 2, 7.5, 118, 62, "#5c6a74", 0.95);
// the stubby crystal leaning out of the cluster to the left
const stub = prismShape(140, 764, -104, 214, 47, 74, [-1, -0.3, 0.36, 1]);
paintPrism(stub, ["#8e9aa3", "#8e9aa3", "#5c6a74"], [140, 104, 178]);
crossHatch(stub, 2, 4.8, 74, 14, "#2b3138", 1.0);
// a sharp needle wedged between the two masses
const needle = prismShape(330, 742, -63, 196, 25, 66, [-1, -0.18, 0.44, 1]);
paintPrism(needle, ["#8e9aa3", "#5c6a74", "#5c6a74"], [120, 104, 166]);
crossHatch(needle, 2, 4.2, 96, 30, "#2b3138", 1.05);
// a broad low crystal closing the cluster on the right, cut off short
const broad = prismShape(408, 780, -46, 168, 40, 30, [-1, -0.34, 0.4, 1]);
paintPrism(broad, ["#8e9aa3", "#8e9aa3", "#5c6a74"], [116, 86, 158]);
crossHatch(broad, 1, 6.2, 132, 68, "#5c6a74", 1.05);
// the long prism: the whole reason for the picture
const spar = prismShape(286, 624, -44, 560, 52, 126, [-1, -0.2, 0.46, 1]);
paintPrism(spar, ["#8e9aa3", "#8e9aa3", "#5c6a74"], [106, 68, 168]);
crossHatch(spar, 2, 4.6, 44, 116, "#2b3138", 1.1);
hatchFace(spar, 1, 11, 128, "#5c6a74", 0.9);
hatchFace(spar, 0, 16, 52, "#8e9aa3", 0.85);
striate(spar, 8, "#5c6a74", 0.8);
// the front mass, holding the focal point, its middle plane clouded
const front = prismShape(230, 800, -74, 334, 70, 96, [-1, -0.36, 0.5, 1]);
paintPrism(front, ["#8e9aa3", "#8e9aa3", "#5c6a74"], [134, 76, 188]);
crossHatch(front, 2, 3.4, 68, 6, "#2b3138", 1.5);
cloudFace(face(front, 1), 58);
hatchFace(front, 1, 8.5, 104, "#7a6a5c", 0.7);
hatchFace(front, 0, 13, 82, "#8e9aa3", 0.85);
striate(front, 5, "#5c6a74", 0.85);
// needles caught inside the clouded plane
brush.noFill();
brush.set("2H", "#5c6a74", 1.0);
brush.line(238, 596, 270, 556);
brush.line(252, 626, 278, 588);
brush.line(226, 566, 262, 532);
brush.set("2H", "#7a6a5c", 1.1);
brush.line(262, 654, 292, 610);
brush.line(234, 684, 258, 646);
// 3. the darks and the hard angles, last.
inkPrism(ghost, "#8e9aa3", 0.6, false);
inkPrism(blade, "#2b3138", 0.85, false);
inkPrism(stub, "#2b3138", 1.0, true);
inkPrism(needle, "#2b3138", 0.9, false);
inkPrism(broad, "#2b3138", 0.95, false);
inkPrism(spar, "#2b3138", 1.2, true);
inkPrism(front, "#2b3138", 1.45, true);
// the strongest three strokes: where the front mass bites the paper,
// and the ridge of the spar reaching out of frame
brush.noFill();
brush.set("rotring", "#2b3138", 2.4);
hardLine(front.base[0], front.shoulder[0], 2.0);
hardLine(front.shoulder[0], front.apex, 1.2);
brush.set("rotring", "#2b3138", 1.6);
hardLine(front.base[1], front.shoulder[1], 1.7);
hardLine(front.shoulder[1], front.apex, 1.1);
brush.set("rotring", "#2b3138", 1.9);
hardLine(spar.shoulder[3], spar.apex, 1.4);
hardLine([352, 546], [438, 462], 2.6);
// pooled shadow where the cluster meets its ground
brush.noStroke();
brush.fillBleed(0.5, "out");
brush.fillTexture(0.4, 0.45);
brush.fill("#2b3138", 104);
brush.circle(196, 780, 48, true);
brush.fill("#2b3138", 86);
brush.circle(312, 776, 36, true);
brush.fill("#2b3138", 68);
brush.circle(404, 788, 28, true);
brush.fill("#5c6a74", 88);
brush.circle(86, 748, 32, true);
brush.fill("#5c6a74", 66);
brush.circle(148, 792, 26, true);
// iron staining at the base of the spar and one warm glance along its ridge
brush.fill("#b4763c", 96);
brush.circle(310, 620, 14, true);
brush.noFill();
brush.set("pen", "#b4763c", 1.2);
hardLine(spar.shoulder[0], spar.apex, 1.1);
// the deep slots between crystals, where no light gets in — the last darks
brush.noFill();
brush.set("rotring", "#2b3138", 2.6);
hardLine([186, 774], [208, 744], 1.4);
brush.set("rotring", "#2b3138", 2.1);
hardLine([332, 746], [350, 712], 1.3);
brush.set("rotring", "#2b3138", 1.7);
hardLine([358, 702], [374, 676], 1.1);
brush.set("rotring", "#2b3138", 1.4);
hardLine([424, 566], [452, 534], 1.4);
// broken chips scattered off to the right, thinning into the paper
brush.noStroke();
brush.fillBleed(0.7, "out");
brush.fillTexture(0.3, 0.3);
brush.fill("#8e9aa3", 13);
brush.circle(268, 424, 96, true);
crumb(292, 402, 10, "#5c6a74", 118);
crumb(232, 468, 7, "#5c6a74", 96);
crumb(340, 356, 5, "#8e9aa3", 78);
crumb(470, 726, 14, "#5c6a74", 132);
crumb(526, 758, 10, "#8e9aa3", 118);
crumb(568, 716, 8, "#5c6a74", 104);
crumb(626, 764, 5, "#8e9aa3", 82);
crumb(494, 676, 6, "#b4763c", 90);
crumb(700, 740, 4, "#8e9aa3", 62);
brush.noFill();
brush.set("2B", "#2b3138", 0.9);
hardLine([456, 732], [480, 718], 1.0);
hardLine([518, 764], [536, 752], 0.9);
}