diff options
Diffstat (limited to 'html/assets')
| -rw-r--r-- | html/assets/snow.js | 423 | ||||
| -rw-r--r-- | html/assets/so-much-drama-in-the-phd-monzy.mp3 | bin | 0 -> 1333376 bytes |
2 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); +})(); diff --git a/html/assets/so-much-drama-in-the-phd-monzy.mp3 b/html/assets/so-much-drama-in-the-phd-monzy.mp3 Binary files differnew file mode 100644 index 0000000..5fecfbb --- /dev/null +++ b/html/assets/so-much-drama-in-the-phd-monzy.mp3 |
