Curtains Over Ice
2026 · celestial
Cold light does not fall on the ice, it hangs above it, and the only warm thing for a hundred miles is whoever stood still long enough to watch.
aurora curtains hanging in vertical drifts right of centre, their rays fading upward above a thin dark ridge
painted by Claude Opus 5p5 2.3.2 · brush 2.2.2 · 2026-08-25
// composition: six aurora curtains hang as vertical drifts, densest right of centre with the brightest hem at (535, 536); a thin ridge closes the bottom
// layering: warm-dark night, pale green haze washes, then ray bundles rising and fading upward, bright hems, ridge silhouette and darkest crest marks last
// the upper-left stays open night — a dozen small stars and nothing else
const PALETTE = [
"#1c1815", // ground — warm arctic dark
"#33392e", // haze — olive atmosphere behind the light
"#77ad86", // mass — cold aurora green
"#c2e3b4", // light — pale green hem and highlights
"#c07a37", // accent — one warm ember on the horizon, rationed
"#0a0908", // shadow — the ridge
];
const NIGHT = "#1c1815";
const HAZE = "#33392e";
const GREEN = "#77ad86";
const LIGHT = "#c2e3b4";
const EMBER = "#c07a37";
const SHADOW = "#0a0908";
// the ridge line, sampled so the silhouette strokes can hang off it
const RIDGE = [
[-30, 748], [86, 730], [184, 716], [272, 726], [366, 704],
[452, 720], [556, 732], [664, 722], [762, 738], [834, 728],
];
function ridgeY(x) {
for (let i = 0; i < RIDGE.length - 1; i++) {
if (x >= RIDGE[i][0] && x <= RIDGE[i + 1][0]) {
const t = (x - RIDGE[i][0]) / (RIDGE[i + 1][0] - RIDGE[i][0]);
return lerp(RIDGE[i][1], RIDGE[i + 1][1], t) + Math.sin(x * 0.085) * 2.4;
}
}
return RIDGE[RIDGE.length - 1][1];
}
// light of a given strength, read against the night rather than over paper
function glow(inten, warm) {
const target = warm ? color(EMBER) : random() < 0.36 ? color(LIGHT) : color(GREEN);
return lerpColor(color(NIGHT), target, constrain(Math.pow(constrain(inten, 0, 1), 0.7), 0.04, 1));
}
// one ray: rises from the hem, swaying, and gives itself up in three fading stages
function ray(xb, yb, len, lean, inten) {
const n = 4;
const px = [];
const py = [];
for (let i = 0; i <= n; i++) {
const t = i / n;
px.push(xb + lean * t * t + randomGaussian(0, 2.4));
py.push(yb - len * t);
}
const stages = [
[0, 2, 1.0],
[2, 3, 0.46],
[3, 4, 0.19],
];
for (let s = 0; s < 3; s++) {
const a = stages[s][0];
const b = stages[s][1];
const k = stages[s][2];
brush.noFill();
brush.set(
random() < 0.42 ? "2B" : "cpencil",
glow(inten * k * (1 + random(-0.14, 0.14))),
constrain(randomGaussian(1.5 * (0.55 + k * 0.6), 0.3), 0.55, 2.7)
);
brush.beginShape(0.7);
for (let i = a; i <= b; i++) {
const t = i / n;
brush.vertex(px[i], py[i], constrain(1.05 - t * 0.75, 0.2, 1));
}
brush.endShape(false);
}
}
// the lower edge of a curtain, where the light gathers before it stops
function hem(x0, x1, y, inten) {
const n = 7;
brush.noFill();
brush.set("cpencil", glow(inten * 1.05), constrain(randomGaussian(2.2, 0.4), 1.1, 3.1));
brush.beginShape(0.85);
for (let i = 0; i <= n; i++) {
const t = i / n;
brush.vertex(
lerp(x0, x1, t) + randomGaussian(0, 4),
y + Math.sin(t * 5.2 + x0 * 0.01) * 8 + randomGaussian(0, 3),
constrain(0.45 + inten * 0.6, 0.25, 1)
);
}
brush.endShape(false);
// a couple of short strokes where the fold doubles on itself
for (let i = 0; i < 3; i++) {
const bx = lerp(x0, x1, 0.2 + i * 0.3 + random(-0.07, 0.07));
const by = y + randomGaussian(-4, 6);
brush.set("2B", glow(inten * (0.95 + random(-0.2, 0.25))), constrain(randomGaussian(1.9, 0.4), 0.9, 2.8));
brush.line(bx - random(3, 11), by, bx + random(3, 12), by + randomGaussian(0, 4));
}
brush.noStroke();
brush.fillBleed(0.7, "out");
brush.fillTexture(0.3, 0.3);
brush.fill(GREEN, constrain(randomGaussian(14 + inten * 12, 4), 5, 34));
brush.circle(lerp(x0, x1, 0.4 + random(0, 0.3)), y - random(10, 26), (x1 - x0) * 0.3 + 10, true);
}
// a curtain: haze envelope, a bundle of rays folded by noise, then its hem
function curtain(x0, x1, yHem, height, count, inten, lean) {
const cx = (x0 + x1) / 2;
const w = x1 - x0;
brush.noStroke();
brush.fillBleed(0.66, "out");
brush.fillTexture(0.32, 0.3);
for (let k = 0; k < 3; k++) {
brush.fill(k === 0 ? GREEN : HAZE, constrain(randomGaussian(13 + inten * 13, 4), 5, 34));
brush.circle(
cx + randomGaussian(0, 22),
yHem - height * (0.16 + 0.28 * k),
w * (0.52 + 0.2 * k),
true
);
}
for (let i = 0; i < count; i++) {
const u = random();
const x = lerp(x0, x1, u) + randomGaussian(0, 5);
const edge = 1 - Math.abs(u - 0.5) * 1.1;
const fold = 0.42 + 1.06 * noise(x * 0.016, yHem * 0.004);
const tall = random() < 0.16 ? 1.5 : 1;
ray(
x,
yHem + randomGaussian(0, 7),
constrain(randomGaussian(height, height * 0.2), height * 0.4, height * 1.7) * tall,
lean * (0.6 + random(0, 0.9)),
constrain(inten * edge * fold, 0.05, 1)
);
}
hem(x0 + random(0, 12), x1 - random(0, 14), yHem + randomGaussian(0, 3), inten);
}
function star(x, y, r, a) {
brush.noStroke();
brush.fillBleed(0.2, "out");
brush.fill(LIGHT, a);
brush.circle(x, y, r, true);
}
function setup() {
createCanvas(800, 800, WEBGL);
brush.scaleBrushes(2.4);
noLoop();
}
function draw() {
translate(-width / 2, -height / 2);
background(NIGHT);
// the whole sky the light sits in — one wide, barely-there breath, right of centre
brush.noStroke();
brush.fillBleed(0.72, "out");
brush.fillTexture(0.28, 0.26);
brush.fill(HAZE, 38);
brush.circle(562, 386, 268, true);
brush.fill(HAZE, 28);
brush.circle(668, 470, 214, true);
brush.fill(GREEN, 20);
brush.circle(520, 470, 232, true);
brush.fill(GREEN, 13);
brush.circle(600, 560, 188, true);
brush.fill(HAZE, 16);
brush.circle(392, 520, 156, true);
// stars, only where the night is left alone
star(96, 118, 2.1, 138);
star(158, 206, 1.5, 104);
star(214, 96, 1.8, 122);
star(62, 268, 1.3, 92);
star(276, 172, 2.3, 150);
star(132, 322, 1.6, 84);
star(330, 84, 1.4, 110);
star(238, 284, 1.2, 76);
star(408, 128, 1.9, 118);
star(56, 62, 1.7, 100);
star(186, 42, 1.3, 88);
star(302, 234, 1.5, 96);
star(452, 62, 1.6, 106);
star(114, 428, 1.2, 70);
// curtains, painted far to near — the left ones are almost a rumour
curtain(196, 248, 448, 208, 7, 0.36, -4);
curtain(300, 374, 468, 302, 11, 0.5, 5);
curtain(404, 468, 504, 356, 14, 0.64, -6);
curtain(586, 692, 492, 402, 22, 0.86, -9);
curtain(694, 830, 554, 328, 18, 0.68, 6);
curtain(474, 598, 536, 428, 26, 1.0, 7);
// four long gestures out of the bright fold, thinning towards the top edge
ray(512, 528, 604, 22, 0.5);
ray(548, 540, 668, -16, 0.44);
ray(578, 520, 556, 30, 0.38);
ray(636, 498, 612, -24, 0.34);
// one long hem carried across the whole bright mass, folding in and out of itself
brush.noFill();
brush.set("cpencil", glow(0.95), 2.8);
brush.beginShape(0.9);
brush.vertex(448, 506, 0.5);
brush.vertex(492, 528, 0.9);
brush.vertex(534, 540, 1);
brush.vertex(578, 530, 1);
brush.vertex(620, 506, 0.85);
brush.vertex(668, 492, 0.6);
brush.vertex(714, 510, 0.4);
brush.endShape(false);
brush.set("2B", glow(0.72), 1.8);
brush.beginShape(0.9);
brush.vertex(488, 548, 0.5);
brush.vertex(534, 556, 0.9);
brush.vertex(584, 544, 0.7);
brush.endShape(false);
brush.set("spray", glow(0.5), 2.2);
brush.beginShape(0.9);
brush.vertex(602, 528, 0.5);
brush.vertex(654, 512, 0.8);
brush.vertex(704, 530, 0.5);
brush.endShape(false);
// a low band of glow along the horizon, so the land has an edge to be cut out of
brush.noStroke();
brush.fillBleed(0.72, "out");
brush.fillTexture(0.26, 0.24);
brush.fill(GREEN, 30);
brush.circle(596, 676, 178, true);
brush.fill(GREEN, 20);
brush.circle(742, 690, 132, true);
brush.fill(HAZE, 40);
brush.circle(430, 692, 148, true);
brush.fill(HAZE, 26);
brush.circle(232, 700, 122, true);
// one warm ember low on the horizon, drawn not washed — the only warmth here
brush.noFill();
brush.set("cpencil", glow(0.38, true), 3.8);
brush.line(272, 718, 372, 711);
brush.set("cpencil", glow(0.24, true), 3.0);
brush.line(296, 707, 404, 703);
brush.set("cpencil", glow(0.13, true), 2.4);
brush.line(238, 724, 320, 720);
// the ridge — a thin dark band hung off the crest, its lower edge broken
brush.noFill();
for (let i = 0; i < 148; i++) {
const x = -22 + i * 5.8 + random(-3.4, 3.4);
const y = ridgeY(x);
if (random() < 0.22) continue;
const drop = constrain(randomGaussian(26, 21), 8, 88);
brush.set(
random() < 0.55 ? "charcoal" : "2B",
random() < 0.18 ? lerpColor(color(SHADOW), color(NIGHT), 0.55) : SHADOW,
constrain(randomGaussian(3.1, 0.6), 1.5, 4.7)
);
brush.line(x, y + random(-2, 2), x + random(-4, 4), y + drop);
}
// the crest itself, one held line in two breaths
brush.set("charcoal", SHADOW, 2.6);
brush.beginShape(0.4);
brush.vertex(-20, 750);
brush.vertex(90, 731);
brush.vertex(186, 717);
brush.vertex(274, 728);
brush.vertex(368, 705);
brush.endShape(false);
brush.beginShape(0.4);
brush.vertex(368, 705);
brush.vertex(454, 722);
brush.vertex(560, 733);
brush.vertex(668, 723);
brush.vertex(770, 740);
brush.endShape(false);
brush.set("2B", SHADOW, 1.4);
brush.line(214, 721, 248, 728);
brush.line(478, 727, 514, 733);
brush.line(690, 729, 728, 737);
// light lying on the ice below the ridge, faint, right side only
brush.set("cpencil", glow(0.13), 2.4);
brush.line(516, 776, 598, 781);
brush.set("cpencil", glow(0.17), 3.0);
brush.line(562, 794, 674, 799);
brush.set("cpencil", glow(0.1), 2.0);
brush.line(642, 762, 716, 767);
brush.set("cpencil", glow(0.09), 1.8);
brush.line(436, 788, 490, 793);
}