Sixty Degrees of Rime
2026 · weather
Frost knows one rule, sixty degrees, and never uses it the same way twice. The clear glass in the corner is simply where the pane is still winning.
frost ferning out of the lower-left corner of a cold windowpane, thinning as it climbs the diagonal until the glass runs clear near the upper right, with lamplight somewhere behind it
painted by Mistral Medium 3.1p5 2.3.2 · brush 2.2.2 · 2026-08-25
// composition: frost climbs out of the lower-left corner of a pane and thins up the diagonal;
// the heaviest rosette sits on the thirds crossing near (267, 533), the upper-right third stays clear glass.
// layering: haze washes, rime whites, crystal grain, ferns trunk-to-needle, then the sill and the darks.
const PALETTE = [
"#ccd5d6", // ground — cold glass, the paper
"#93a5ab", // haze — breath settled on the pane
"#f4f3ee", // rime — frost white
"#5f747f", // slate — mid crystal
"#2f3d47", // ink — trunks, sill, the last darks
"#bd7c3e", // lamp — amber somewhere behind the glass, rationed
];
// Frost keeps one law: sixty degrees off whatever it grew from. It never uses it twice the same way.
const HEX_TURN = Math.PI / 3;
function dir(deg) {
return (deg * Math.PI) / 180;
}
// deeper generations pale toward the haze; the trunks keep the ink
function crystal(depth) {
const t = constrain(randomGaussian(depth, 0.12), 0, 1);
return lerpColor(color(PALETTE[4]), color(PALETTE[1]), t);
}
// how willing the pane is to freeze here: lower-left says yes, upper-right says no
function frostiness(x, y) {
const slope = 1.16 - (x / 800) * 0.6 - (1 - y / 800) * 0.8;
return constrain(slope + noise(x * 0.004, y * 0.004) * 0.4 - 0.12, 0, 1);
}
// one wandering crystal axis, tapering as it goes; returns its joints for branching
function axis(x, y, ang, len, segs, name, col, w, taper) {
const pts = [];
let a = ang;
let cx = x;
let cy = y;
for (let i = 0; i <= segs; i++) {
pts.push([cx, cy]);
a += randomGaussian(0, 0.04);
const step = (len / segs) * (0.84 + random(0, 0.3));
cx += Math.cos(a) * step;
cy += Math.sin(a) * step;
}
brush.noFill();
brush.set(name, col, w);
brush.beginShape(0.28);
for (let i = 0; i < pts.length; i++) {
brush.vertex(pts[i][0], pts[i][1], 1 - taper * (i / segs));
}
brush.endShape(false);
return pts;
}
// third order: a spine and two or three barbs, the smallest thing frost bothers to make
function needle(x, y, ang, len, w) {
const pts = axis(x, y, ang, len, 3, random() < 0.5 ? "2H" : "pen", crystal(0.5), w, 0.55);
for (let i = 1; i < pts.length; i++) {
if (random() < 0.14) continue;
const p = pts[i];
const side = random() < 0.5 ? 1 : -1;
const b = len * (0.3 + random(0, 0.24)) * (1 - 0.2 * i);
const a = ang + side * HEX_TURN + randomGaussian(0, 0.08);
brush.line(p[0], p[1], p[0] + Math.cos(a) * b, p[1] + Math.sin(a) * b);
if (random() < 0.45) {
const a2 = ang - side * HEX_TURN + randomGaussian(0, 0.1);
const b2 = b * (0.6 + random(0, 0.3));
brush.line(p[0], p[1], p[0] + Math.cos(a2) * b2, p[1] + Math.sin(a2) * b2);
}
}
}
// second order: needles alternating along a shorter spine, skipping a joint now and then
function frond(x, y, ang, len, w) {
const pts = axis(x, y, ang, len, 5, random() < 0.6 ? "2B" : "cpencil", crystal(0.24), w, 0.5);
let side = random() < 0.5 ? 1 : -1;
for (let i = 1; i < pts.length - 1; i++) {
if (random() < 0.08) {
side = -side;
continue;
}
const t = i / (pts.length - 1);
const a = ang + side * HEX_TURN + randomGaussian(0, 0.09);
needle(pts[i][0], pts[i][1], a, len * (0.52 - 0.2 * t) * (1 + random(-0.16, 0.2)), w * 0.66);
side = -side;
}
}
// first order: the trunk, dark and slow, hanging fronds off every joint
function fern(x, y, ang, len, w) {
const pts = axis(x, y, ang, len, 7, random() < 0.5 ? "charcoal" : "2B", crystal(0.04), w, 0.66);
brush.set("charcoal", crystal(0.16), w * 0.6); // a second pass on the trunk, slightly off
brush.beginShape(0.28);
for (let i = 0; i < pts.length; i++) {
brush.vertex(pts[i][0] + random(-2.4, 2.4), pts[i][1] + random(-2.4, 2.4), 1 - 0.7 * (i / 7));
}
brush.endShape(false);
let side = random() < 0.5 ? 1 : -1;
for (let i = 1; i < pts.length - 1; i++) {
const t = i / (pts.length - 1);
const a = ang + side * HEX_TURN + randomGaussian(0, 0.1);
frond(pts[i][0], pts[i][1], a, len * (0.48 - 0.24 * t) * (1 + random(-0.16, 0.24)), w * 0.56);
if (random() < 0.5) {
// a twin riding the same joint the other way — where frost thickens into a rosette
frond(
pts[i][0] + random(-3, 3),
pts[i][1] + random(-3, 3),
ang - side * HEX_TURN + randomGaussian(0, 0.11),
len * (0.32 - 0.13 * t) * (1 + random(-0.14, 0.2)),
w * 0.46,
);
}
side = -side;
}
return pts;
}
function haze(x, y, r, op, t) {
brush.noStroke();
brush.fillBleed(0.54 + random(0, 0.2), "out");
brush.fillTexture(0.4, 0.4);
brush.fill(lerpColor(color(PALETTE[1]), color(PALETTE[3]), constrain(t, 0, 1)), op);
brush.circle(x, y, r, true);
}
function rime(x, y, r, op) {
brush.noStroke();
brush.fillBleed(0.34 + random(0, 0.24), "out");
brush.fillTexture(0.62, 0.55);
brush.fill(lerpColor(color(PALETTE[2]), color(PALETTE[1]), constrain(randomGaussian(0.14, 0.13), 0, 0.5)), op);
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(PALETTE[0]);
// ---- the cold half of the pane: four big breaths of haze, hand-placed
haze(58, 742, 250, 96, 0.42);
haze(214, 664, 208, 78, 0.3);
haze(-24, 528, 186, 70, 0.36);
haze(330, 782, 176, 58, 0.22);
// ---- and a scatter of smaller ones, only where the pane is willing to freeze
for (let i = 0; i < 40; i++) {
const x = Math.max(-60, randomGaussian(160, 200));
const y = Math.min(860, randomGaussian(646, 158));
if (random() > frostiness(x, y)) continue;
haze(x, y, Math.max(34, randomGaussian(84, 26)), Math.max(28, randomGaussian(62, 18)), random(0, 0.55));
}
// ---- rime whites laid into the haze, densest against the corner
for (let i = 0; i < 44; i++) {
const x = Math.max(-50, randomGaussian(140, 175));
const y = Math.min(850, randomGaussian(672, 140));
if (random() > frostiness(x, y) * 0.92) continue;
rime(x, y, Math.max(20, randomGaussian(54, 20)), Math.max(96, randomGaussian(168, 34)));
}
rime(92, 748, 128, 214);
rime(30, 640, 96, 196);
rime(196, 700, 86, 178);
rime(250, 562, 74, 164);
rime(122, 556, 62, 142);
// ---- crystal grain: short strokes all leaning sixty degrees, laid under the structure
for (let i = 0; i < 150; i++) {
const x = Math.max(-40, randomGaussian(180, 210));
const y = Math.min(850, randomGaussian(650, 165));
if (random() > frostiness(x, y) * 0.8) continue;
const a = dir(random() < 0.62 ? -58 : -118) + randomGaussian(0, 0.07);
const g = Math.max(9, randomGaussian(30, 13));
brush.set(random() < 0.5 ? "2H" : "cpencil", crystal(random(0.42, 0.86)), 0.5 + random(0, 0.5));
brush.line(x, y, x + Math.cos(a) * g, y + Math.sin(a) * g);
}
// ---- the glass itself, two long drags across the clear third
brush.field("hand");
brush.set("cpencil", lerpColor(color(PALETTE[1]), color(PALETTE[0]), 0.5), 0.9);
brush.flowLine(430, 118, 380, 22);
brush.set("cpencil", lerpColor(color(PALETTE[1]), color(PALETTE[0]), 0.66), 0.8);
brush.flowLine(524, 468, 300, 12);
brush.noField();
// ---- amber breath from beyond the pane, sitting in the clear glass
brush.noStroke();
brush.fillBleed(0.72, "out");
brush.fillTexture(0.3, 0.35);
brush.fill(PALETTE[5], 26);
brush.circle(636, 214, 152, true);
brush.fill(PALETTE[5], 17);
brush.circle(704, 300, 98, true);
// ---- four trunks out of the corner: the whole weight of the picture
fern(24, 782, dir(-42), 468, 3.6);
fern(-32, 704, dir(-17), 424, 3.2);
fern(98, 788, dir(-66), 372, 3.3);
fern(212, 784, dir(-33), 328, 2.7);
// ---- second wave, riding the diagonal and thinning as it climbs
frond(324, 566, dir(-50), 198, 2.1);
frond(402, 474, dir(-27), 166, 1.8);
frond(468, 638, dir(-61), 152, 1.9);
frond(520, 372, dir(-40), 122, 1.4);
frond(150, 470, dir(-7), 142, 1.7);
frond(616, 736, dir(-112), 172, 1.8);
frond(724, 792, dir(-129), 196, 2.0);
frond(-14, 356, dir(28), 130, 1.5);
// ---- outliers: single crystals testing the clear glass
needle(596, 300, dir(-34), 62, 1.15);
needle(654, 228, dir(-19), 42, 0.95);
needle(552, 212, dir(-62), 36, 0.9);
needle(748, 566, dir(-151), 82, 1.2);
// ---- the darks, last: a knot of facets crowding the focal rosette
needle(266, 536, dir(-38), 62, 2.2);
needle(262, 540, dir(-158), 46, 2.0);
needle(272, 528, dir(82), 40, 1.8);
needle(258, 546, dir(-96), 52, 1.9);
needle(280, 542, dir(-20), 44, 1.7);
needle(196, 618, dir(-52), 54, 1.9);
needle(132, 688, dir(-28), 48, 1.8);
brush.set("charcoal", PALETTE[4], 1.6);
brush.line(266, 536, 244, 566);
brush.line(212, 596, 186, 626);
brush.line(102, 722, 74, 748);
// ---- the pane: a left stile and a bottom rail, meeting where the frost began
brush.set("charcoal", lerpColor(color(PALETTE[4]), color(PALETTE[1]), 0.62), 1.5);
brush.beginShape(0.2);
brush.vertex(18, -24);
brush.vertex(24, 320);
brush.vertex(20, 620);
brush.vertex(30, 792);
brush.endShape(false);
brush.set("charcoal", lerpColor(color(PALETTE[4]), color(PALETTE[3]), 0.3), 1.8);
brush.beginShape(0.2);
brush.vertex(-18, 792);
brush.vertex(180, 784);
brush.vertex(360, 778);
brush.endShape(false);
brush.set("charcoal", lerpColor(color(PALETTE[4]), color(PALETTE[0]), 0.72), 1.2);
brush.beginShape(0.2);
brush.vertex(360, 778);
brush.vertex(600, 772);
brush.vertex(824, 768);
brush.endShape(false);
// ---- two crystals catching the lamp
brush.noStroke();
brush.fillBleed(0.4, "out");
brush.fill(PALETTE[5], 138);
brush.circle(288, 512, 3.6, true);
brush.fill(PALETTE[5], 102);
brush.circle(126, 686, 2.8, true);
}