summaryrefslogtreecommitdiff
path: root/html/assets/snow.js
diff options
context:
space:
mode:
authorElizabeth Alexander Hunt <me@liz.coffee>2026-07-24 19:23:50 -0700
committerElizabeth Alexander Hunt <me@liz.coffee>2026-07-24 19:52:11 -0700
commit8520a5dec33de65d530af05c265c61a25cbc8491 (patch)
tree2af532504f0a0fc28a4df99c465241e1a95b7eb6 /html/assets/snow.js
parent4959e1cd05e7a01caaadacddf66f777c3a771641 (diff)
downloadlizdotcoffee-8520a5dec33de65d530af05c265c61a25cbc8491.tar.gz
lizdotcoffee-8520a5dec33de65d530af05c265c61a25cbc8491.zip
Adding some AI slop but it's okay because it looks really cool imo
Diffstat (limited to 'html/assets/snow.js')
-rw-r--r--html/assets/snow.js423
1 files changed, 423 insertions, 0 deletions
diff --git a/html/assets/snow.js b/html/assets/snow.js
new file mode 100644
index 0000000..ba9a46a
--- /dev/null
+++ b/html/assets/snow.js
@@ -0,0 +1,423 @@
+(function () {
+ var canvas = document.getElementById("snow");
+ var ctx = canvas.getContext("2d", { alpha: true });
+
+ // Cap the backing store on hi-dpi screens. Full 3x on a laptop panel
+ // triples the per-frame clear cost for detail nobody sees at 8px.
+ var DPR = Math.min(window.devicePixelRatio || 1, 2);
+
+ var W = 0, H = 0; // CSS pixels — all physics happens in this space
+ var rand = function (min, max) { return Math.random() * (max - min) + min; };
+
+ /* =============================================================
+ KOCH SNOWFLAKE SPRITES
+
+ Each flake is a Koch snowflake, rasterised once into a small
+ offscreen canvas and then blitted every frame — so the fractal
+ costs nothing at runtime no matter how deep we take it.
+
+ The Koch snowflake has 3-fold rotational symmetry (unlike a real
+ 6-armed crystal), so a sprite set has to cover 120° of rotation
+ rather than 60° to spin seamlessly.
+ ============================================================= */
+ var SIZES = [3, 6, 9, 13, 18]; // CSS px, across the circumcircle
+ var ROTATIONS = 12;
+ var ROT_SPAN = (Math.PI * 2) / 3;
+
+ var SHAPES = [
+ { fill: 0.18, line: 0.085, inner: false },
+ { fill: 0.32, line: 0.070, inner: true },
+ { fill: 0.08, line: 0.105, inner: false }
+ ];
+
+ // Unit-circumradius outlines, generated once per depth and reused
+ // for every size and rotation that needs them.
+ var kochCache = {};
+
+ function subdivide(ax, ay, bx, by, depth, out) {
+ if (depth === 0) {
+ out.push(ax, ay);
+ return;
+ }
+ var dx = (bx - ax) / 3, dy = (by - ay) / 3;
+ var p1x = ax + dx, p1y = ay + dy;
+ var p3x = ax + dx * 2, p3y = ay + dy * 2;
+
+ // Apex of the bump: perpendicular to the middle third, pushed out
+ // to equilateral height. Pick the sign that points away from the
+ // centre so bumps grow outward whatever the winding order.
+ var mx = (p1x + p3x) / 2, my = (p1y + p3y) / 2;
+ var ex = p3x - p1x, ey = p3y - p1y;
+ var elen = Math.sqrt(ex * ex + ey * ey);
+ var px = -ey / elen, py = ex / elen;
+ if (mx * px + my * py < 0) { px = -px; py = -py; }
+ var h = elen * Math.sqrt(3) / 2;
+ var p2x = mx + px * h, p2y = my + py * h;
+
+ subdivide(ax, ay, p1x, p1y, depth - 1, out);
+ subdivide(p1x, p1y, p2x, p2y, depth - 1, out);
+ subdivide(p2x, p2y, p3x, p3y, depth - 1, out);
+ subdivide(p3x, p3y, bx, by, depth - 1, out);
+ }
+
+ function kochOutline(depth) {
+ if (kochCache[depth]) return kochCache[depth];
+ var v = [];
+ for (var i = 0; i < 3; i++) {
+ var a = -Math.PI / 2 + i * (Math.PI * 2 / 3);
+ v.push(Math.cos(a), Math.sin(a));
+ }
+ var pts = [];
+ for (var s = 0; s < 3; s++) {
+ var n = (s + 1) % 3;
+ subdivide(v[s * 2], v[s * 2 + 1], v[n * 2], v[n * 2 + 1], depth, pts);
+ }
+ kochCache[depth] = pts;
+ return pts;
+ }
+
+ function tracePath(c, pts, R) {
+ c.beginPath();
+ c.moveTo(pts[0] * R, pts[1] * R);
+ for (var i = 2; i < pts.length; i += 2) {
+ c.lineTo(pts[i] * R, pts[i + 1] * R);
+ }
+ c.closePath();
+ }
+
+ // Iteration depth has to match the pixel budget: past roughly one
+ // segment per pixel the detail just turns to grey mush.
+ function depthForSize(sizeCss) {
+ if (sizeCss < 7) return 1;
+ if (sizeCss < 12) return 2;
+ return 3;
+ }
+
+ function drawKoch(c, R, p, sizeCss) {
+ var pts = kochOutline(depthForSize(sizeCss));
+
+ tracePath(c, pts, R);
+ c.fillStyle = "rgba(255,255,255," + p.fill + ")";
+ c.fill();
+
+ c.strokeStyle = "#ffffff";
+ c.lineWidth = Math.max(0.75, R * p.line);
+ c.lineJoin = "round";
+ c.stroke();
+
+ if (p.inner) {
+ // a second, counter-rotated flake inside — reads as facets
+ c.save();
+ c.rotate(Math.PI / 3);
+ tracePath(c, kochOutline(1), R * 0.42);
+ c.lineWidth = Math.max(0.6, R * 0.05);
+ c.stroke();
+ c.restore();
+ }
+ }
+
+ function makeSprite(sizeCss, shape, rotation) {
+ var pad = 2;
+ var dim = Math.ceil((sizeCss + pad * 2) * DPR);
+ var s = document.createElement("canvas");
+ s.width = dim;
+ s.height = dim;
+ var c = s.getContext("2d");
+
+ c.translate(dim / 2, dim / 2);
+ c.rotate(rotation);
+ var R = (sizeCss / 2) * DPR;
+
+ if (sizeCss < 4.5) {
+ // Below ~4px even one Koch iteration is mush. A soft disc reads
+ // better, and these are the distant flakes anyway.
+ var g = c.createRadialGradient(0, 0, 0, 0, 0, R);
+ g.addColorStop(0, "rgba(255,255,255,1)");
+ g.addColorStop(0.55, "rgba(255,255,255,0.9)");
+ g.addColorStop(1, "rgba(255,255,255,0)");
+ c.fillStyle = g;
+ c.beginPath();
+ c.arc(0, 0, R, 0, Math.PI * 2);
+ c.fill();
+ } else {
+ drawKoch(c, R, shape, sizeCss);
+ }
+ return s;
+ }
+
+ // sprites[shape][sizeTier] -> array of ROTATIONS canvases
+ var sprites = [];
+ for (var si = 0; si < SHAPES.length; si++) {
+ var byTier = [];
+ for (var ti = 0; ti < SIZES.length; ti++) {
+ var frames = [];
+ for (var ri = 0; ri < ROTATIONS; ri++) {
+ frames.push(makeSprite(SIZES[ti], SHAPES[si], (ri / ROTATIONS) * ROT_SPAN));
+ }
+ byTier.push(frames);
+ }
+ sprites.push(byTier);
+ }
+
+ /* =============================================================
+ VELOCITY FIELD
+
+ A coarse grid over the viewport. The cursor deposits momentum
+ into the cells it passes through; each frame that momentum
+ spreads to neighbours and decays, so a gust stays local to
+ where the cursor actually was.
+ ============================================================= */
+ var CELL = 44;
+ var cols = 0, rows = 0, gu, gv, tu, tv;
+
+ function buildGrid() {
+ cols = Math.ceil(W / CELL) + 2;
+ rows = Math.ceil(H / CELL) + 2;
+ var n = cols * rows;
+ gu = new Float32Array(n);
+ gv = new Float32Array(n);
+ tu = new Float32Array(n);
+ tv = new Float32Array(n);
+ }
+
+ var DEPOSIT_RADIUS = 3; // cells
+ var DEPOSIT_STRENGTH = 34; // px/s of field velocity per px of cursor travel
+ var CELL_MAX = 1400; // clamp so a fast flick can't explode the field
+ var DIFFUSION = 0.08; // neighbour blend per frame at 60fps
+ var DECAY_PER_SEC = 0.05; // fraction of a gust still present a second later
+
+ function deposit(px, py, dx, dy) {
+ var cx = px / CELL, cy = py / CELL;
+ var i0 = Math.max(0, Math.floor(cx) - DEPOSIT_RADIUS);
+ var i1 = Math.min(cols - 1, Math.floor(cx) + DEPOSIT_RADIUS);
+ var j0 = Math.max(0, Math.floor(cy) - DEPOSIT_RADIUS);
+ var j1 = Math.min(rows - 1, Math.floor(cy) + DEPOSIT_RADIUS);
+
+ for (var j = j0; j <= j1; j++) {
+ for (var i = i0; i <= i1; i++) {
+ var ddx = i - cx, ddy = j - cy;
+ var falloff = Math.exp(-(ddx * ddx + ddy * ddy) / (DEPOSIT_RADIUS * 0.75));
+ if (falloff < 0.01) continue;
+ var k = j * cols + i;
+ var u = gu[k] + dx * falloff * DEPOSIT_STRENGTH;
+ var v = gv[k] + dy * falloff * DEPOSIT_STRENGTH;
+ gu[k] = u > CELL_MAX ? CELL_MAX : (u < -CELL_MAX ? -CELL_MAX : u);
+ gv[k] = v > CELL_MAX ? CELL_MAX : (v < -CELL_MAX ? -CELL_MAX : v);
+ }
+ }
+ }
+
+ function relaxField(dt) {
+ var decay = Math.pow(DECAY_PER_SEC, dt);
+ var diffusion = Math.min(0.5, DIFFUSION * dt * 60);
+ for (var j = 0; j < rows; j++) {
+ for (var i = 0; i < cols; i++) {
+ var k = j * cols + i;
+ var su = 0, sv = 0, n = 0;
+ if (i > 0) { su += gu[k - 1]; sv += gv[k - 1]; n++; }
+ if (i < cols - 1) { su += gu[k + 1]; sv += gv[k + 1]; n++; }
+ if (j > 0) { su += gu[k - cols]; sv += gv[k - cols]; n++; }
+ if (j < rows - 1) { su += gu[k + cols]; sv += gv[k + cols]; n++; }
+ tu[k] = (gu[k] + (su / n - gu[k]) * diffusion) * decay;
+ tv[k] = (gv[k] + (sv / n - gv[k]) * diffusion) * decay;
+ }
+ }
+ var a = gu; gu = tu; tu = a;
+ var b = gv; gv = tv; tv = b;
+ }
+
+ var sampleU = 0, sampleV = 0;
+ function sampleField(px, py) {
+ var cx = px / CELL, cy = py / CELL;
+ var i = Math.floor(cx), j = Math.floor(cy);
+ if (i < 0) i = 0; else if (i > cols - 2) i = cols - 2;
+ if (j < 0) j = 0; else if (j > rows - 2) j = rows - 2;
+ var fx = cx - i, fy = cy - j;
+ if (fx < 0) fx = 0; else if (fx > 1) fx = 1;
+ if (fy < 0) fy = 0; else if (fy > 1) fy = 1;
+
+ var k = j * cols + i;
+ var w00 = (1 - fx) * (1 - fy), w10 = fx * (1 - fy);
+ var w01 = (1 - fx) * fy, w11 = fx * fy;
+
+ sampleU = gu[k] * w00 + gu[k + 1] * w10 + gu[k + cols] * w01 + gu[k + cols + 1] * w11;
+ sampleV = gv[k] * w00 + gv[k + 1] * w10 + gv[k + cols] * w01 + gv[k + cols + 1] * w11;
+ }
+
+ /* =============================================================
+ AMBIENT AIR
+
+ Turbulence comes from the curl of a stream function built out of
+ two octaves of sine. Taking the curl makes the field
+ divergence-free — air can swirl but never pile up or vanish —
+ which is most of the difference between "windy" and "jelly".
+ The coarse octave drifts slowly, the fine one churns fast.
+ ============================================================= */
+ var O1 = { kx: 0.0042, ky: 0.0058, amp: 12500, sx: 0.25, sy: -0.19 };
+ var O2 = { kx: 0.0125, ky: 0.0155, amp: 2400, sx: -0.62, sy: 0.78 };
+ var U1 = O1.amp * O1.ky, V1 = O1.amp * O1.kx;
+ var U2 = O2.amp * O2.ky, V2 = O2.amp * O2.kx;
+
+ // per-frame time offsets, hoisted out of the flake loop
+ var p1a = 0, p1b = 0, p2a = 0, p2b = 0;
+ var gustX = 0, gustY = 0;
+
+ function updateAir(t) {
+ p1a = t * O1.sx; p1b = t * O1.sy;
+ p2a = t * O2.sx; p2b = t * O2.sy;
+
+ // Gusts: three detuned sines summed, then squared with the sign
+ // kept. Squaring is what makes it gust rather than sway — long
+ // lulls near zero, then a sharp shove.
+ var env = Math.sin(t * 0.17) + 0.55 * Math.sin(t * 0.29 + 2.1)
+ + 0.30 * Math.sin(t * 0.53 + 4.4);
+ var gust = env > 0 ? env * env : -env * env;
+ gustX = gust * 46 + Math.sin(t * 0.041) * 18;
+ gustY = gust * Math.sin(t * 0.37 + 1.3) * 8;
+ }
+
+ var airU = 0, airV = 0;
+ function sampleAir(x, y) {
+ var a1 = x * O1.kx + p1a, b1 = y * O1.ky + p1b;
+ var a2 = x * O2.kx + p2a, b2 = y * O2.ky + p2b;
+ airU = U1 * Math.sin(a1) * Math.cos(b1) + U2 * Math.sin(a2) * Math.cos(b2) + gustX;
+ airV = -V1 * Math.cos(a1) * Math.sin(b1) - V2 * Math.cos(a2) * Math.sin(b2) + gustY;
+ }
+
+ /* ============================ flakes ============================ */
+ var flakes = [];
+
+ function buildFlakes() {
+ // scale the count to the viewport so a small window isn't overdrawn
+ var count = Math.round(Math.min(170, Math.max(55, (W * H) / 11000)));
+ flakes.length = 0;
+ for (var i = 0; i < count; i++) {
+ var depth = Math.random();
+ var tier = Math.min(SIZES.length - 1, Math.floor(depth * SIZES.length));
+ flakes.push({
+ frames: sprites[(Math.random() * SHAPES.length) | 0][tier],
+ half: 0,
+ x: rand(0, W),
+ // seed across the whole viewport, already at terminal speed, so
+ // the first frame looks like snow that has been falling a while
+ // rather than a batch dumped in from above
+ y: rand(-40, H),
+ vx: 0,
+ vy: 12 + depth * 30,
+ fall: 12 + depth * 30, // base fall speed, px/s
+ // fast, shallow flutter — the individual tumble of one crystal,
+ // distinct from the air it is riding in
+ sway: rand(0, Math.PI * 2),
+ swaySpeed: rand(2.2, 4.4),
+ swayAmp: 3 + (1 - depth) * 7,
+ spin: rand(-0.3, 0.3), // sprite cycles/sec (1 cycle = 120°)
+ angle: Math.random(),
+ alpha: 0.3 + depth * 0.55,
+ // Light flakes catch more of the wind AND change speed faster;
+ // heavy ones plough through. That split is what sells it as air
+ // rather than everything sloshing in unison.
+ response: 0.5 + (1 - depth) * 0.85,
+ drag: 3.0 + (1 - depth) * 6.5 // velocity response rate, per second
+ });
+ var f = flakes[i];
+ f.half = f.frames[0].width / 2;
+ }
+ }
+
+ function resize() {
+ W = window.innerWidth;
+ H = window.innerHeight;
+ canvas.width = Math.round(W * DPR);
+ canvas.height = Math.round(H * DPR);
+ buildGrid();
+ if (!flakes.length) buildFlakes();
+ }
+
+ /* ============================= input ============================ */
+ var lastX = null, lastY = null;
+
+ window.addEventListener("pointermove", function (e) {
+ var x = e.clientX, y = e.clientY;
+ if (lastX !== null) {
+ var dx = x - lastX, dy = y - lastY;
+ var dist = Math.sqrt(dx * dx + dy * dy);
+ // walk the segment so a fast flick paints one continuous gust
+ // instead of two disconnected puffs
+ var steps = Math.min(10, Math.max(1, Math.round(dist / (CELL * 0.5))));
+ for (var s = 1; s <= steps; s++) {
+ var t = s / steps;
+ deposit(lastX + dx * t, lastY + dy * t, dx / steps, dy / steps);
+ }
+ }
+ lastX = x;
+ lastY = y;
+ }, { passive: true });
+
+ window.addEventListener("pointerleave", function () { lastX = lastY = null; });
+ window.addEventListener("resize", resize);
+
+ /* ============================== loop ============================ */
+ var lastT = null, raf = 0;
+
+ function step(now) {
+ if (lastT === null) lastT = now;
+ var dt = Math.min((now - lastT) / 1000, 0.05);
+ lastT = now;
+
+ relaxField(dt);
+ updateAir(now / 1000);
+
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+ for (var i = 0; i < flakes.length; i++) {
+ var f = flakes[i];
+ sampleField(f.x, f.y); // cursor gusts
+ sampleAir(f.x, f.y); // ambient turbulence
+
+ f.sway += f.swaySpeed * dt;
+ var targetVX = (airU + sampleU) * f.response + Math.sin(f.sway) * f.swayAmp;
+ var targetVY = (airV + sampleV) * f.response + f.fall;
+
+ // exponential approach: correct at any timestep, and the rate is
+ // per-flake so light and heavy crystals visibly disagree
+ var ease = 1 - Math.exp(-f.drag * dt);
+ f.vx += (targetVX - f.vx) * ease;
+ f.vy += (targetVY - f.vy) * ease;
+
+ f.x += f.vx * dt;
+ f.y += f.vy * dt;
+
+ // tumble harder the faster the air is dragging past
+ f.angle += (f.spin + f.vx * 0.006) * dt;
+
+ if (f.y > H + 20) { f.y = -20; f.x = rand(0, W); f.vy = f.fall; }
+ else if (f.y < -80) { f.y = H + 20; }
+ if (f.x < -30) f.x = W + 30;
+ else if (f.x > W + 30) f.x = -30;
+
+ var ri = ((f.angle * ROTATIONS) | 0) % ROTATIONS;
+ if (ri < 0) ri += ROTATIONS;
+
+ ctx.globalAlpha = f.alpha;
+ // integer destination + unscaled source = fastest blit path
+ ctx.drawImage(f.frames[ri], (f.x * DPR - f.half) | 0, (f.y * DPR - f.half) | 0);
+ }
+
+ ctx.globalAlpha = 1;
+ raf = requestAnimationFrame(step);
+ }
+
+ // don't burn battery in a background tab
+ document.addEventListener("visibilitychange", function () {
+ if (document.hidden) {
+ cancelAnimationFrame(raf);
+ raf = 0;
+ } else if (!raf) {
+ lastT = null;
+ raf = requestAnimationFrame(step);
+ }
+ });
+
+ resize();
+ raf = requestAnimationFrame(step);
+})();