Group Near the Limb
2026 · celestial
Drawn as a surface and not a disc, the sun turns out to be weather: a field of boiling grain with five cold bruises drifting across it.
a group of sunspots on a granulated solar surface, the limb curving away into cold sky at the upper right
painted by DeepSeek V4p5 2.3.2 · brush 2.2.2 · 2026-08-25
// composition: the limb sweeps across the upper right into cold sky; the spot group holds the lower-left thirds crossing
// layering: warm mottled surface, granulation combed over the whole face, limb darkening, the dark sky beyond, then penumbra, filaments, umbra last
// the paper stays open across the lower right — the quiet half of the face, where nothing has happened for a week
const PALETTE = [
"#efe4cf", // ground — paper, the bright photosphere
"#c8a061", // mass — granulation, warm ochre
"#8d5f33", // shadow — penumbra, burnt earth
"#241a13", // ink — umbra, near-black and warm
"#2b3242", // void — cold sky past the edge
"#d9762a", // accent — faculae along the limb, rationed
];
const PAPER = PALETTE[0];
const OCHRE = PALETTE[1];
const EARTH = PALETTE[2];
const UMBRA = PALETTE[3];
const SKY = PALETTE[4];
const FLARE = PALETTE[5];
// The sun's centre lies far off the lower-left corner, so the visible limb
// reads as one long gentle curve — a surface running away, never a disc.
const CX = -260;
const CY = 1180;
const CR = 1330;
// The arc enters the top edge near x=354 and leaves the right edge near y=376.
const LIMB_DEGS = [-72, -68, -64, -60, -56, -52, -48, -44, -40, -36, -32];
function limbPoint(deg, inset) {
const a = (deg * Math.PI) / 180;
const r = CR - inset;
return [CX + Math.cos(a) * r, CY + Math.sin(a) * r];
}
function onFace(x, y, margin) {
return dist(x, y, CX, CY) < CR - margin;
}
// Every grain gets its own colour: mostly ochre, often earth, a rare dark fleck.
// dark flecks are rationed out to the patches near the group, and withheld elsewhere.
function grainColour(darkChance) {
const r = random();
if (r < darkChance) {
return lerpColor(color(EARTH), color(UMBRA), constrain(randomGaussian(0.4, 0.2), 0, 0.8));
}
if (r < 0.56) {
return lerpColor(color(OCHRE), color(EARTH), constrain(randomGaussian(0.6, 0.24), 0.05, 1));
}
return lerpColor(color(PAPER), color(OCHRE), constrain(randomGaussian(0.84, 0.15), 0.35, 1));
}
// Broad mottling: the value of the surface itself, before any grain sits on it.
function surfacePatch(x, y, r, towards, amt, alpha) {
brush.fill(lerpColor(color(OCHRE), color(towards), amt), alpha);
brush.circle(x, y, r, true);
}
// The cells themselves: chunky short dashes, thrown by a gaussian, clipped to the face.
function grainPatch(cx, cy, spread, count, meanDir, scale, darkChance) {
for (let i = 0; i < count; i++) {
const x = cx + randomGaussian(0, spread);
const y = cy + randomGaussian(0, spread * 0.84);
if (!onFace(x, y, 14)) continue;
brush.set(random() < 0.2 ? "2B" : "cpencil", grainColour(darkChance), constrain(randomGaussian(1.9, 0.7), 0.5, 3.2));
brush.flowLine(
x,
y,
constrain(randomGaussian(9 * scale, 4.5 * scale), 2.5, 24),
meanDir + randomGaussian(0, 60)
);
}
}
// Pepper between the cells: spray, so the whole face carries a tooth.
function grainStipple(cx, cy, spread, count, meanDir) {
for (let i = 0; i < count; i++) {
const x = cx + randomGaussian(0, spread);
const y = cy + randomGaussian(0, spread * 0.86);
if (!onFace(x, y, 12)) continue;
brush.set(
"spray",
lerpColor(color(OCHRE), color(EARTH), constrain(randomGaussian(0.4, 0.26), 0, 1)),
constrain(randomGaussian(1.5, 0.5), 0.5, 2.8)
);
brush.flowLine(x, y, constrain(randomGaussian(20, 9), 6, 46), meanDir + randomGaussian(0, 58));
}
}
// The lanes between cells, so the grain reads as one surface rather than scattered dust.
function grainLanes(cx, cy, spread, count, meanDir) {
for (let i = 0; i < count; i++) {
const x = cx + randomGaussian(0, spread);
const y = cy + randomGaussian(0, spread * 0.8);
if (!onFace(x, y, 18)) continue;
brush.set(
"cpencil",
lerpColor(color(OCHRE), color(EARTH), constrain(randomGaussian(0.62, 0.24), 0.05, 1)),
constrain(randomGaussian(1.1, 0.35), 0.4, 2.1)
);
brush.flowLine(x, y, constrain(randomGaussian(32, 13), 10, 64), meanDir + randomGaussian(0, 40));
}
}
// A darker rim just inside the edge — the sun dims where we look through more of it.
function limbDarkening() {
brush.noStroke();
brush.fillBleed(0.42, "in");
brush.fillTexture(0.52, 0.5);
for (let i = 0; i <= 26; i++) {
const deg = -71 + (i / 26) * 40;
const p = limbPoint(deg, 46 + randomGaussian(0, 16));
brush.fill(
lerpColor(color(OCHRE), color(EARTH), constrain(randomGaussian(0.62, 0.22), 0.1, 1)),
constrain(randomGaussian(104, 22), 40, 165)
);
brush.circle(p[0], p[1], constrain(randomGaussian(52, 12), 24, 80), true);
}
for (let i = 0; i <= 15; i++) {
const deg = -70 + (i / 15) * 38;
const p = limbPoint(deg, 13 + randomGaussian(0, 4));
brush.fill(
lerpColor(color(EARTH), color(UMBRA), constrain(randomGaussian(0.46, 0.16), 0.1, 0.85)),
constrain(randomGaussian(158, 24), 90, 215)
);
brush.circle(p[0], p[1], constrain(randomGaussian(22, 6), 9, 34), true);
}
}
// The sky beyond: one shape laid down again and again until it is properly cold.
function coldSky() {
brush.noStroke();
brush.fillBleed(0.14, "out");
brush.fillTexture(0.5, 0.85);
const alphas = [168, 152, 138, 120, 96];
for (let pass = 0; pass < 5; pass++) {
brush.fill(lerpColor(color(SKY), color(UMBRA), 0.06 * pass), alphas[pass]);
brush.beginShape(0.14);
for (let i = 0; i < LIMB_DEGS.length; i++) {
const p = limbPoint(LIMB_DEGS[i], -1 - pass * 1.6 + randomGaussian(0, 1.8));
brush.vertex(p[0], p[1]);
}
brush.vertex(940, 500);
brush.vertex(940, -110);
brush.vertex(240, -110);
brush.endShape(true);
}
// deep weather, kept far out in the corner so the edge itself stays clean
brush.fillBleed(0.4, "out");
for (let i = 0; i < 5; i++) {
const p = limbPoint(-62 + i * 6 + randomGaussian(0, 3), -140 - randomGaussian(50, 34));
brush.fill(lerpColor(color(SKY), color(UMBRA), constrain(randomGaussian(0.7, 0.18), 0, 1)), constrain(randomGaussian(74, 18), 30, 120));
brush.circle(p[0], p[1], constrain(randomGaussian(84, 20), 40, 130), true);
}
}
// One spot: penumbra chain first, then the fibrous ring, then the umbra as the last dark note.
// halfLen runs along the limb, halfWid across it — the foreshortening near the edge.
function sunspot(cx, cy, halfLen, halfWid, ang, filaments) {
const ux = Math.cos(ang);
const uy = Math.sin(ang);
const nx = -uy;
const ny = ux;
brush.noStroke();
brush.fillBleed(0.42, "out");
brush.fillTexture(0.66, 0.5);
const steps = halfLen > 30 ? 7 : 5;
for (let i = 0; i <= steps; i++) {
const t = -1 + (2 * i) / steps;
const prof = 0.56 + 0.6 * (1 - t * t);
const jx = randomGaussian(0, halfWid * 0.13);
const px = cx + ux * halfLen * t + nx * jx;
const py = cy + uy * halfLen * t + ny * jx;
brush.fill(
lerpColor(color(EARTH), color(OCHRE), constrain(randomGaussian(0.18, 0.14), 0, 0.6)),
constrain(randomGaussian(150, 22), 90, 205)
);
brush.circle(px, py, constrain(halfWid * prof + randomGaussian(0, 2.6), 3.5, 90), true);
}
// inner penumbra, deeper and shouldered off centre
brush.fillBleed(0.26, "out");
const inner = halfLen > 30 ? 5 : 3;
for (let i = 0; i <= inner; i++) {
const t = -0.72 + (1.5 * i) / inner;
const px = cx + ux * halfLen * t + nx * randomGaussian(0, halfWid * 0.1) + nx * halfWid * 0.08;
const py = cy + uy * halfLen * t + ny * randomGaussian(0, halfWid * 0.1) + ny * halfWid * 0.08;
brush.fill(lerpColor(color(EARTH), color(UMBRA), constrain(randomGaussian(0.4, 0.16), 0.05, 0.8)), constrain(randomGaussian(160, 22), 100, 215));
brush.circle(px, py, constrain(halfWid * 0.62 + randomGaussian(0, 2.2), 3, 62), true);
}
// the fibrous penumbra: filaments combed outward from the umbra
brush.noFill();
const axisOut = halfLen + halfWid * 0.72;
for (let i = 0; i < filaments; i++) {
const a = (i / filaments) * Math.PI * 2 + randomGaussian(0, 0.15);
const ca = Math.cos(a);
const sa = Math.sin(a);
const r0 = constrain(randomGaussian(0.42, 0.07), 0.26, 0.6);
const r1 = constrain(randomGaussian(1.0, 0.1), 0.7, 1.2);
const x0 = cx + ux * halfLen * ca * r0 + nx * halfWid * sa * r0;
const y0 = cy + uy * halfLen * ca * r0 + ny * halfWid * sa * r0;
const x1 = cx + ux * axisOut * ca * r1 + nx * halfWid * 1.02 * sa * r1;
const y1 = cy + uy * axisOut * ca * r1 + ny * halfWid * 1.02 * sa * r1;
brush.set(
random() < 0.3 ? "2H" : "cpencil",
lerpColor(color(EARTH), color(UMBRA), constrain(randomGaussian(0.58, 0.24), 0.1, 0.96)),
constrain(randomGaussian(0.8, 0.28), 0.3, 1.5)
);
brush.line(x0, y0, x1, y1);
}
// umbra — the darkest thing in the picture, and never centred
brush.noStroke();
brush.fillBleed(0.16, "out");
brush.fillTexture(0.3, 0.8);
const cores = halfLen > 30 ? 8 : 4;
for (let i = 0; i <= cores; i++) {
const t = -0.36 + (0.7 * i) / cores;
const px = cx + ux * halfLen * t + nx * randomGaussian(0, halfWid * 0.11);
const py = cy + uy * halfLen * t + ny * randomGaussian(0, halfWid * 0.11);
brush.fill(lerpColor(color(UMBRA), color(EARTH), constrain(randomGaussian(0.06, 0.07), 0, 0.3)), constrain(randomGaussian(240, 10), 200, 252));
brush.circle(px, py, constrain(halfWid * 0.36 + randomGaussian(0, 2.2), 2.5, 40), true);
}
// then scrub the core solid — a fill this dark still needs graphite laid into it
brush.noFill();
const scrub = halfLen > 30 ? 26 : 12;
for (let i = 0; i < scrub; i++) {
const a = (i / scrub) * Math.PI * 2 + randomGaussian(0, 0.3);
const rr = constrain(randomGaussian(0.24, 0.1), 0.02, 0.42);
const px = cx + ux * halfLen * Math.cos(a) * rr + nx * halfWid * Math.sin(a) * rr;
const py = cy + uy * halfLen * Math.cos(a) * rr + ny * halfWid * Math.sin(a) * rr;
brush.set("2B", lerpColor(color(UMBRA), color(EARTH), constrain(randomGaussian(0.05, 0.06), 0, 0.24)), constrain(randomGaussian(2.6, 0.8), 0.9, 4.4));
brush.line(px, py, px + randomGaussian(0, halfWid * 0.2), py + randomGaussian(0, halfWid * 0.2));
}
}
// A pore: no penumbra to speak of, just a bruise in the granulation.
function pore(x, y, r) {
brush.noStroke();
brush.fillBleed(0.46, "out");
brush.fillTexture(0.55, 0.55);
brush.fill(lerpColor(color(EARTH), color(OCHRE), 0.2), constrain(randomGaussian(132, 20), 70, 190));
brush.circle(x, y, r * 1.8 + randomGaussian(0, 1), true);
brush.fillBleed(0.16, "out");
brush.fill(lerpColor(color(UMBRA), color(EARTH), constrain(randomGaussian(0.14, 0.1), 0, 0.4)), constrain(randomGaussian(218, 16), 160, 245));
brush.circle(x, y, r + randomGaussian(0, 0.6), true);
}
function setup() {
createCanvas(800, 800, WEBGL);
brush.scaleBrushes(2.4);
noLoop();
}
function draw() {
translate(-width / 2, -height / 2);
background(PAPER);
// ---- the surface itself: broad warm mottling, heaviest on the diagonal
brush.noStroke();
brush.fillBleed(0.68, "out");
brush.fillTexture(0.36, 0.4);
surfacePatch(300, 460, 268, EARTH, 0.16, 72);
surfacePatch(468, 246, 238, EARTH, 0.06, 66);
surfacePatch(624, 296, 202, EARTH, 0.3, 78);
surfacePatch(160, 292, 182, PAPER, 0.3, 52);
surfacePatch(256, 566, 176, EARTH, 0.42, 58);
surfacePatch(500, 592, 214, PAPER, 0.5, 30);
surfacePatch(392, 116, 170, EARTH, 0.2, 56);
surfacePatch(94, 636, 152, PAPER, 0.34, 26);
surfacePatch(700, 470, 150, EARTH, 0.24, 44);
brush.fillBleed(0.5, "out");
for (let i = 0; i < 34; i++) {
const x = constrain(randomGaussian(360, 200), -30, 790);
const y = constrain(randomGaussian(340, 210), -30, 790);
if (!onFace(x, y, 26)) continue;
surfacePatch(
x,
y,
constrain(randomGaussian(78, 26), 30, 148),
random() < 0.68 ? EARTH : PAPER,
constrain(randomGaussian(0.38, 0.22), 0, 0.85),
constrain(randomGaussian(46, 15), 14, 84)
);
}
// ---- granulation: dense under the limb and around the group, thinning to
// nothing across the lower right
brush.field("seabed");
grainStipple(430, 214, 178, 104, 40);
grainStipple(300, 462, 168, 82, 28);
grainStipple(608, 300, 128, 66, 50);
grainStipple(150, 320, 132, 44, 20);
grainStipple(500, 560, 140, 26, 46);
grainPatch(424, 206, 136, 168, 40, 1);
grainPatch(238, 116, 126, 112, 34, 1.05);
grainPatch(604, 292, 112, 130, 48, 0.9);
grainPatch(300, 456, 152, 176, 30, 1);
grainPatch(150, 606, 122, 86, 24, 1.1);
grainPatch(520, 500, 134, 82, 44, 0.95);
grainPatch(78, 316, 108, 64, 20, 1.1);
grainPatch(440, 700, 116, 32, 52, 0.85);
grainPatch(668, 610, 96, 24, 60, 0.8);
grainLanes(430, 220, 168, 34, 38);
grainLanes(300, 470, 156, 26, 28);
grainLanes(600, 330, 118, 22, 50);
brush.noField();
limbDarkening();
coldSky();
// ---- the edge itself: three broken passes, running off both canvas edges
brush.noFill();
brush.set("2B", lerpColor(color(EARTH), color(UMBRA), 0.55), 0.95);
brush.beginShape(0.18);
for (let i = 0; i < 5; i++) {
const p = limbPoint(-73 + i * 2.6, 3 + randomGaussian(0, 1.5));
brush.vertex(p[0], p[1]);
}
brush.endShape(false);
brush.set("pen", lerpColor(color(EARTH), color(UMBRA), 0.4), 0.8);
brush.beginShape(0.18);
for (let i = 0; i < 5; i++) {
const p = limbPoint(-57 + i * 2.4, 4 + randomGaussian(0, 1.8));
brush.vertex(p[0], p[1]);
}
brush.endShape(false);
brush.set("2B", lerpColor(color(EARTH), color(UMBRA), 0.66), 1.05);
brush.beginShape(0.18);
for (let i = 0; i < 5; i++) {
const p = limbPoint(-41 + i * 2.2, 3 + randomGaussian(0, 1.6));
brush.vertex(p[0], p[1]);
}
brush.endShape(false);
// ---- faculae: the one bright note, a few flecks riding the inside of the edge
brush.noFill();
for (let i = 0; i < 11; i++) {
const p = limbPoint(-68 + i * 3.4 + randomGaussian(0, 1.2), 24 + randomGaussian(0, 13));
brush.set(
"cpencil",
lerpColor(color(FLARE), color(OCHRE), constrain(randomGaussian(0.32, 0.22), 0, 0.9)),
constrain(randomGaussian(1.7, 0.45), 0.8, 2.7)
);
brush.line(p[0], p[1], p[0] + randomGaussian(18, 8), p[1] + randomGaussian(12, 7));
}
// ---- the spot group: leader on the thirds crossing, a train breaking away
// down-right, a tight follower crowding the left edge
sunspot(262, 534, 38, 32, 0.53, 38);
sunspot(364, 606, 25, 17, 0.44, 22);
sunspot(176, 462, 23, 17, 0.62, 21);
sunspot(146, 424, 12, 9, 0.7, 12);
sunspot(438, 662, 13, 9, 0.36, 13);
// ---- pores: the group's loose change, plus two strays that break the line
pore(322, 574, 5.6);
pore(342, 552, 3.6);
pore(392, 628, 4.2);
pore(212, 490, 4);
pore(198, 438, 3);
pore(478, 686, 3.4);
pore(512, 704, 2.6);
pore(506, 392, 3.2);
pore(596, 462, 2.4);
// ---- last darks: flecks combed off the group into clean paper
brush.noFill();
for (let i = 0; i < 30; i++) {
const t = i / 30;
const x = 240 + t * 340 + randomGaussian(0, 38);
const y = 520 + t * 250 + randomGaussian(0, 34);
if (!onFace(x, y, 18)) continue;
brush.set(
"cpencil",
lerpColor(color(EARTH), color(UMBRA), constrain(randomGaussian(0.44, 0.22), 0, 0.85)),
constrain(randomGaussian(1, 0.34), 0.3, 1.8)
);
brush.line(x, y, x + randomGaussian(10, 6), y + randomGaussian(8, 5));
}
}