a starling murmuration folding over itself at dusk, fifteen hundred small decisions
painted by Kimi K3p5 2.3.2 · brush 2.2.2 · 2026-08-24
// composition: one torn ribbon of birds folding across the upper-right, thinning to a sparse trail toward lower-left; horizon glow low
// layering: deep mauve dusk washes darkest at top, pale horizon glow band, then ~1900 near-ink tick-marks aligned to the ribbon's local direction
const PALETTE = [
"#8d7590", // dusk mauve sky
"#6f5e78", // deeper mauve, upper sky
"#ecd9c4", // pale horizon glow
"#26222b", // ink: the birds
"#efe9e2", // ground paper
];
// centerline of the murmuration: tail lower-left, body folding back on itself upper-right
const SPINE = [
[128, 668], [232, 588], [332, 488], [438, 384], [548, 288],
[648, 208], [706, 148], [664, 106], [566, 128], [508, 196], [532, 262],
];
function spinePoint(t) {
const ft = t * (SPINE.length - 1);
const i = Math.min(SPINE.length - 2, Math.floor(ft));
const u = ft - i;
const a = SPINE[i];
const b = SPINE[i + 1];
return [a[0] + (b[0] - a[0]) * u, a[1] + (b[1] - a[1]) * u];
}
function setup() {
createCanvas(800, 800, WEBGL);
brush.scaleBrushes(2.2);
noLoop();
}
function draw() {
translate(-width / 2, -height / 2);
background("#efe9e2");
// dusk sky: broad mauve washes, darkest at the top, with a deepening second pass
brush.noStroke();
brush.fillBleed(0.5, "out");
brush.fillTexture(0.4, 0.45);
brush.fill("#6f5e78", 140);
brush.rect(-40, -40, 880, 300);
brush.fill("#5e4f68", 90);
brush.rect(-40, -40, 880, 190);
brush.fill("#8d7590", 115);
brush.rect(-40, 220, 880, 260);
brush.fill("#8d7590", 80);
brush.rect(-40, 450, 880, 200);
// quiet mauve tint across the low ground so no bare paper survives the dusk
brush.fill("#8d7590", 42);
brush.rect(-40, 630, 880, 220);
// one pale horizon glow, low and off to the left
brush.fill("#ecd9c4", 95);
brush.rect(-40, 620, 640, 130);
brush.fill("#ecd9c4", 65);
brush.rect(40, 648, 420, 80);
// the murmuration: tick-marks whose density is the shape
for (let i = 0; i < 1900; i++) {
// bias draws toward the folded head of the ribbon
const t = Math.pow(random(), 0.52);
const [cx, cy] = spinePoint(t);
const [ax, ay] = spinePoint(Math.max(0, t - 0.02));
const [bx, by] = spinePoint(Math.min(1, t + 0.02));
const tang = Math.atan2(by - ay, bx - ax);
// ribbon widens toward the head; edges torn by noise
const wBase = 8 + 96 * Math.pow(t, 1.7);
const torn = noise(cx * 0.012, cy * 0.012) * 1.5 + 0.25;
const off = randomGaussian(0, wBase * 0.42 * torn);
// a few strays peel off the flock entirely
const stray = random() < 0.035 ? randomGaussian(0, 90) : 0;
const px = cx + Math.cos(tang + Math.PI / 2) * (off + stray);
const py = cy + Math.sin(tang + Math.PI / 2) * (off + stray);
if (px < -10 || px > 810 || py < -10 || py > 810) continue;
// each bird: a 2-4px tick aligned to local flow, slightly scattered
const a = tang + randomGaussian(0, 0.24);
const len = random(2, 4.2);
const w = random() < 0.14 ? 1.4 : random(0.85, 1.1); // a few nearer, heavier birds
brush.set("pen", "#1d1a22", w);
brush.line(
px - Math.cos(a) * len * 0.5, py - Math.sin(a) * len * 0.5,
px + Math.cos(a) * len * 0.5, py + Math.sin(a) * len * 0.5
);
}
}