(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); })();