Dust Across Light
2026 · celestial
The nebula is not painted; the sky around it is, until the bare paper has nowhere left to go but glow. Only the dust gets an edge.
a nebula drawn as a pastel plate on warm paper, its glow sweeping from the lower right to the upper left, a hard dust lane cutting across it at centre-left, foreground stars set sharp as black points
painted by Claude Fable 5p5 2.3.2 · brush 2.2.2 · 2026-08-25
// composition: a fan of nebular light sweeps from the lower-right corner up to the upper-left, broad at its
// foot and narrowing as it climbs; the core burns on the upper-left thirds crossing. One heavy dust lane
// cuts clean across it at centre-left. layering: the light is bare warm paper — rank after rank of grey
// veil is drifted in around it until the band has to glow, then the dust, then the sharp stars last.
const PALETTE = [
"#e9e3d5", // ground — warm paper, and the nebula's light itself
"#f8f3e7", // glow — the lifted core, a breath above the paper
"#8b969d", // haze — cool grey sky, the veils that press the light into shape
"#4f5860", // mass — the weight of the sky, heaviest in the lower left
"#1c2024", // shadow — near-black dust, the only hard edge in the picture
"#ac7f68", // accent — dusty rose in the core, and three star hearts
];
const PAPER = PALETTE[0];
const GLOW = PALETTE[1];
const HAZE = PALETTE[2];
const MASS = PALETTE[3];
const DUST = PALETTE[4];
const ROSE = PALETTE[5];
// the light runs corner to corner: AXIS_TAIL is its broad foot, AXIS_HEAD the narrow end it climbs to
const AXIS_TAIL = [790, 748];
const AXIS_HEAD = [70, 52];
// unit vector along that axis, and the unit perpendicular pointing to its upper-right side
const AX = -0.718;
const AY = -0.696;
const PX = 0.696;
const PY = -0.718;
// every sprayed stroke and every veil leans with this one wind, in degrees
const WIND = 224;
const TILT = Math.atan2(AY, AX);
function axisPoint(t) {
return [lerp(AXIS_TAIL[0], AXIS_HEAD[0], t), lerp(AXIS_TAIL[1], AXIS_HEAD[1], t)];
}
// signed distance from the axis: negative on the lower-left side, positive on the upper-right
function sideOf(x, y) {
return (x - AXIS_TAIL[0]) * PX + (y - AXIS_TAIL[1]) * PY;
}
// how far along the sweep a point falls: 0 at the broad foot, 1 at the narrow head
function alongT(x, y) {
return constrain(((x - AXIS_TAIL[0]) * AX + (y - AXIS_TAIL[1]) * AY) / 1002, 0, 1);
}
// the shore of the light: a tapering fan, wandered by noise so no edge of it runs straight
function shore(x, y) {
return lerp(150, 68, alongT(x, y)) + (noise(x * 0.0038, y * 0.0038) - 0.5) * 96;
}
// 0 inside the light, rising toward 1 out where the sky lies thickest
function skyWeight(x, y) {
const s = abs(sideOf(x, y)) - shore(x, y);
if (s < 0) return 0;
return constrain(map(s, 0, 240, 0.42, 1), 0.42, 1);
}
// a veil: a long lens of pigment lying along the wind, never quite the same shape twice
function veil(cx, cy, len, wid, tilt, col, op, bleed) {
const ca = Math.cos(TILT + tilt);
const sa = Math.sin(TILT + tilt);
brush.noStroke();
brush.fillBleed(bleed, "out");
brush.fillTexture(0.32, 0.34);
brush.fill(col, op);
brush.beginShape(0.9);
for (let k = 0; k < 9; k++) {
const th = (k / 9) * Math.PI * 2;
const rl = len * 0.5 * (1 + random(-0.14, 0.14));
const rw = wid * 0.5 * (1 + random(-0.24, 0.24));
const px = Math.cos(th) * rl;
const py = Math.sin(th) * rw;
brush.vertex(cx + px * ca - py * sa, cy + px * sa + py * ca);
}
brush.endShape(true);
}
// one rank of veils laid parallel to the light at a fixed remove from it; side -1 is the lower left.
// a veil that strays into the corridor is pushed back out — the light keeps its clean shore
function rank(side, off, count, col, op, len, wid, bleed, jitter) {
for (let k = 0; k < count; k++) {
const t = constrain((k + 0.5) / count + random(-0.1, 0.1), -0.06, 1.06);
const p = axisPoint(t);
const floor0 = lerp(150, 74, constrain(t, 0, 1)) + wid * 0.42;
const o = max(floor0, off + randomGaussian(0, jitter)) * side;
veil(
p[0] + PX * o,
p[1] + PY * o,
len * (1 + random(-0.22, 0.22)),
wid * (1 + random(-0.26, 0.26)),
random(-0.17, 0.17),
col,
constrain(randomGaussian(op, 15), 34, 250),
bleed
);
}
}
// dust: a tight, nearly opaque clot — enough bleed to keep its silhouette from going angular
function lobe(x, y, r, col, op) {
brush.noStroke();
brush.fillBleed(0.2, "out");
brush.fillTexture(0.3, 0.86);
brush.fill(col, op);
brush.circle(x, y, r, true);
}
// each lane is a centreline of [x, y, halfWidth]; its normal comes from the neighbouring points
function laneNormal(pts, i) {
const a = pts[max(0, i - 1)];
const b = pts[min(pts.length - 1, i + 1)];
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const m = Math.hypot(dx, dy) || 1;
return [-dy / m, dx / m];
}
function laneAt(pts, t) {
const i = min(pts.length - 2, floor(t));
const f = t - i;
return [
lerp(pts[i][0], pts[i + 1][0], f),
lerp(pts[i][1], pts[i + 1][1], f),
lerp(pts[i][2], pts[i + 1][2], f),
i,
];
}
// build a lane out of overlapping clots so it thickens and thins the way obscuring dust does
function dustChain(pts, count, col, op, spread, grow) {
for (let k = 0; k < count; k++) {
const p = laneAt(pts, random(0, pts.length - 1.001));
const n = laneNormal(pts, p[3]);
const o = randomGaussian(0, p[2] * spread);
lobe(
p[0] + n[0] * o + random(-5, 5),
p[1] + n[1] * o + random(-5, 5),
max(9, randomGaussian(p[2] * grow, p[2] * 0.2)),
col,
constrain(randomGaussian(op, 18), 120, 255)
);
}
}
// only part of a lane gets a knife edge, and only on the side that faces the light
function laneEdge(pts, i0, i1, side, col, w) {
brush.noFill();
brush.set("charcoal", col, w);
brush.beginShape(0.45);
for (let i = i0; i <= i1; i++) {
const n = laneNormal(pts, i);
const d = pts[i][2] * 0.86 * side;
brush.vertex(pts[i][0] + n[0] * d + random(-5, 5), pts[i][1] + n[1] * d + random(-5, 5));
}
brush.endShape(false);
}
// short tendrils peeling off a lane, dragged by the same wind as everything else
function tendrils(pts, count, col) {
brush.field("hand");
for (let k = 0; k < count; k++) {
const p = laneAt(pts, random(0.2, pts.length - 1.2));
const n = laneNormal(pts, p[3]);
const side = random() < 0.6 ? 1 : -1;
brush.set(random() < 0.5 ? "charcoal" : "2B", col, 0.6 + random(0, 0.6));
brush.flowLine(
p[0] + n[0] * p[2] * 0.8 * side,
p[1] + n[1] * p[2] * 0.8 * side,
max(12, randomGaussian(32, 13)),
WIND + random(-66, 66)
);
}
brush.noField();
}
// foreground stars: a sharp point, rotated so no two sit the same way up
function star(x, y, mag, col) {
const a = random(0, Math.PI);
const s = mag * 0.55;
brush.noFill();
brush.set("rotring", col, max(0.6, mag * 0.3));
brush.line(x - Math.cos(a) * s, y - Math.sin(a) * s, x + Math.cos(a) * s, y + Math.sin(a) * s);
if (mag > 2.2) {
const b = a + Math.PI / 2 + random(-0.24, 0.24);
const s2 = s * (0.5 + random(0, 0.45));
brush.line(x - Math.cos(b) * s2, y - Math.sin(b) * s2, x + Math.cos(b) * s2, y + Math.sin(b) * s2);
}
if (mag > 5.4) {
brush.set("2H", col, 0.55);
brush.circle(x + random(-1, 1), y + random(-1, 1), mag * 0.75, true);
const d = mag * 2;
brush.line(x - Math.cos(a) * d, y - Math.sin(a) * d, x - Math.cos(a) * s * 1.45, y - Math.sin(a) * s * 1.45);
brush.line(x + Math.cos(a) * s * 1.45, y + Math.sin(a) * s * 1.45, x + Math.cos(a) * d, y + Math.sin(a) * d);
}
}
const LANE_MAIN = [
[-46, 646, 14], [70, 566, 22], [182, 488, 30], [292, 418, 36],
[396, 362, 27], [482, 332, 17], [556, 316, 9],
];
const LANE_RIFT = [
[162, 430, 11], [266, 350, 15], [372, 292, 16], [470, 258, 12], [562, 238, 8],
];
const LANE_SPUR = [
[330, 402, 15], [352, 468, 11], [344, 542, 7],
];
// keep specks off the dust — a star behind a lane is a star you do not see
function onDust(x, y, pad) {
const lanes = [LANE_MAIN, LANE_RIFT, LANE_SPUR];
for (const pts of lanes) {
for (let i = 0; i < pts.length; i++) {
const dx = x - pts[i][0];
const dy = y - pts[i][1];
const r = pts[i][2] + pad;
if (dx * dx + dy * dy < r * r) return true;
}
}
return false;
}
function setup() {
createCanvas(800, 800, WEBGL);
brush.scaleBrushes(2.4);
noLoop();
}
function draw() {
translate(-width / 2, -height / 2);
background(PAPER);
// --- the lower-left sky: rank on rank, each one further out and heavier than the last
rank(-1, 214, 6, HAZE, 132, 290, 116, 0.5, 22);
rank(-1, 282, 5, HAZE, 176, 320, 138, 0.46, 26);
rank(-1, 356, 6, MASS, 178, 300, 148, 0.44, 28);
rank(-1, 432, 5, MASS, 206, 330, 160, 0.42, 30);
rank(-1, 512, 4, MASS, 218, 300, 172, 0.4, 30);
rank(-1, 590, 4, DUST, 152, 280, 184, 0.4, 32);
// --- the upper-right sky: the same veils, thinner every rank, letting the corner open out
rank(1, 210, 6, HAZE, 148, 290, 114, 0.5, 22);
rank(1, 278, 5, HAZE, 172, 320, 138, 0.46, 26);
rank(1, 352, 5, HAZE, 156, 310, 150, 0.44, 28);
rank(1, 436, 4, HAZE, 128, 330, 158, 0.44, 30);
rank(1, 520, 3, HAZE, 96, 300, 150, 0.46, 30);
// --- tooth: dust sprayed along the wind, thickest where the sky is already heavy
brush.field("curved");
brush.set("spray", HAZE, 1.5);
for (let i = 0; i < 620; i++) {
const x = random(-30, 830);
const y = random(-30, 830);
if (random() > skyWeight(x, y) * 0.95) continue;
brush.flowLine(x, y, max(24, randomGaussian(88, 32)), WIND + random(-32, 32));
}
brush.set("spray", MASS, 1.7);
for (let i = 0; i < 420; i++) {
const x = randomGaussian(190, 205);
const y = randomGaussian(615, 200);
if (random() > skyWeight(x, y) * 0.88) continue;
brush.flowLine(x, y, max(20, randomGaussian(66, 26)), WIND + random(-42, 42));
}
// --- the light: granular warmth inside the reserved band, densest around the core
brush.set("spray", GLOW, 1.5);
for (let i = 0; i < 200; i++) {
const t = constrain(randomGaussian(0.6, 0.3), 0.01, 0.99);
const p = axisPoint(t);
const o = randomGaussian(0, lerp(112, 50, t));
const x = p[0] + PX * o;
const y = p[1] + PY * o;
if (skyWeight(x, y) > 0.5) continue;
brush.flowLine(x, y, max(22, randomGaussian(70, 28)), WIND + random(-30, 30));
}
brush.noField();
// the core, laid in as four overlapping veils of light and one blush of warmth
veil(268, 292, 216, 128, 0.1, GLOW, 190, 0.5);
veil(318, 240, 176, 104, -0.14, GLOW, 176, 0.52);
veil(206, 352, 190, 112, 0.18, GLOW, 150, 0.54);
veil(390, 190, 150, 88, -0.08, GLOW, 128, 0.54);
veil(284, 272, 132, 82, 0.06, ROSE, 40, 0.58);
veil(336, 226, 96, 58, -0.2, ROSE, 30, 0.56);
// filaments curling off the band's shore, half sky and half light
brush.field("seabed");
for (let i = 0; i < 36; i++) {
const t = random(0.04, 0.96);
const p = axisPoint(t);
const side = random() < 0.58 ? -1 : 1;
const o = lerp(152, 70, t) * (0.76 + random(0, 0.56)) * side;
brush.set("2H", random() < 0.45 ? GLOW : HAZE, 0.7 + random(0, 0.7));
brush.flowLine(p[0] + PX * o, p[1] + PY * o, max(26, randomGaussian(72, 26)), WIND + random(-48, 48));
}
brush.noField();
// --- the lane that does the cutting: soft shoulder, black clotted over it, one knife edge to the light
dustChain(LANE_MAIN, 14, MASS, 150, 0.8, 1.5);
dustChain(LANE_MAIN, 46, DUST, 250, 0.3, 1.06);
dustChain(LANE_MAIN, 18, DUST, 255, 0.14, 0.7);
laneEdge(LANE_MAIN, 2, 3, 1, DUST, 2.2);
laneEdge(LANE_MAIN, 4, 5, 1, DUST, 1.5);
tendrils(LANE_MAIN, 16, DUST);
// a bright rim caught on the lit shoulder of the lane
brush.field("curved");
brush.set("spray", GLOW, 1.6);
for (let i = 0; i < 70; i++) {
const p = laneAt(LANE_MAIN, random(0.4, LANE_MAIN.length - 1.4));
const n = laneNormal(LANE_MAIN, p[3]);
const o = p[2] * (1.15 + random(0, 0.5));
brush.flowLine(p[0] + n[0] * o, p[1] + n[1] * o, max(18, randomGaussian(44, 16)), WIND + random(-28, 28));
}
brush.noField();
// the spur dropping off it, and a thin rift skimming just under the bright core
dustChain(LANE_SPUR, 18, DUST, 240, 0.44, 1.16);
dustChain(LANE_RIFT, 28, DUST, 232, 0.4, 1.16);
tendrils(LANE_RIFT, 10, DUST);
// where the lane finally gives out, up in the thinner sky
lobe(596, 312, 9, DUST, 190);
lobe(628, 306, 6, DUST, 150);
lobe(664, 300, 4, MASS, 130);
// --- stars: dark points where they cross the light, pale ones where the sky is dark
for (let i = 0; i < 220; i++) {
const x = random(6, 794);
const y = random(6, 794);
const w = skyWeight(x, y);
const dark = w > 0.6;
if (random() > (w === 0 ? 0.9 : 0.3)) continue;
if (!dark && onDust(x, y, 5)) continue;
const mag = max(1.4, randomGaussian(2.7, 1.1));
star(x, y, mag, dark ? (random() < 0.62 ? PAPER : GLOW) : random() < 0.22 ? MASS : DUST);
}
// a few pale stars burning straight through the dust lane
star(256, 440, 3.2, PAPER);
star(176, 498, 2.4, GLOW);
star(104, 606, 2.8, PAPER);
star(368, 296, 2.1, PAPER);
// the focal cluster, up and right of the core — the brightest company in the picture
star(340, 210, 8.2, DUST);
star(376, 238, 5.8, DUST);
star(310, 182, 4.4, DUST);
star(358, 172, 3.2, MASS);
star(396, 204, 2.7, DUST);
star(328, 244, 2.2, MASS);
// hand-set stars elsewhere, each a different size and weight
star(206, 116, 6.4, DUST);
star(492, 456, 6, DUST);
star(636, 596, 4.9, DUST);
star(126, 318, 3.7, DUST);
star(566, 108, 3.4, PAPER);
star(716, 392, 2.8, PAPER);
star(448, 648, 3.8, DUST);
star(680, 706, 2.4, PAPER);
// three rose hearts, and that is the whole ration of colour
star(296, 320, 4.8, ROSE);
star(418, 302, 3, ROSE);
star(540, 516, 2.5, ROSE);
}