|
| 1 | +--- |
| 2 | +--- |
| 3 | + |
| 4 | +<canvas id="smoke"></canvas> |
| 5 | + |
| 6 | +<script> |
| 7 | + // Semi-Lagrangian smoke driven by curl-noise velocity. |
| 8 | + // |
| 9 | + // Why this architecture (vs particles): |
| 10 | + // A density grid advected along the same velocity field used by |
| 11 | + // CurlNoiseSketch. Each frame, for every cell, we trace the trajectory |
| 12 | + // one step backward in time and bilinearly sample the upstream density |
| 13 | + // (Stam, "Stable Fluids", 1999). This produces continuous smoke with |
| 14 | + // real carry-through of patterns, not just a cloud of dots. |
| 15 | + // |
| 16 | + // Why NO pressure projection (the usual fluid-sim bottleneck): |
| 17 | + // The velocity field is curl(ψ) for a scalar ψ, which is divergence- |
| 18 | + // free by construction. Projection exists to enforce div u = 0 for an |
| 19 | + // arbitrary u; we get that property for free, so the Poisson solve |
| 20 | + // drops out entirely. |
| 21 | + // |
| 22 | + // Lifecycle mirrors ParticleSketch / CurlNoiseSketch — module-scope |
| 23 | + // currentCleanup, idempotent init(), astro:before-swap teardown. |
| 24 | + let currentCleanup: (() => void) | null = null; |
| 25 | + |
| 26 | + function init() { |
| 27 | + if (currentCleanup) return; |
| 28 | + const canvas = document.getElementById('smoke') as HTMLCanvasElement | null; |
| 29 | + if (!canvas) return; |
| 30 | + |
| 31 | + const ctx = canvas.getContext('2d')!; |
| 32 | + const dpr = window.devicePixelRatio || 1; |
| 33 | + let w = 0, h = 0; |
| 34 | + |
| 35 | + // Grid resolution, preserving canvas aspect ratio. Recomputed on |
| 36 | + // resize. The offscreen buffer is reallocated to match. |
| 37 | + const TARGET_CELLS = 26000; |
| 38 | + let GW = 0, GH = 0; |
| 39 | + let cellW = 0, cellH = 0; |
| 40 | + |
| 41 | + let density: Float32Array, densityNext: Float32Array; |
| 42 | + let vxGrid: Float32Array, vyGrid: Float32Array; |
| 43 | + let off: HTMLCanvasElement, offCtx: CanvasRenderingContext2D, smokeImage: ImageData; |
| 44 | + |
| 45 | + function resize() { |
| 46 | + const rect = canvas!.getBoundingClientRect(); |
| 47 | + w = rect.width; |
| 48 | + h = rect.height; |
| 49 | + canvas!.width = w * dpr; |
| 50 | + canvas!.height = h * dpr; |
| 51 | + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); |
| 52 | + ctx.fillStyle = '#0f0f10'; |
| 53 | + ctx.fillRect(0, 0, w, h); |
| 54 | + |
| 55 | + const aspect = w / h; |
| 56 | + GW = Math.max(16, Math.round(Math.sqrt(TARGET_CELLS * aspect))); |
| 57 | + GH = Math.max(16, Math.round(TARGET_CELLS / GW)); |
| 58 | + cellW = w / GW; |
| 59 | + cellH = h / GH; |
| 60 | + |
| 61 | + const N = GW * GH; |
| 62 | + density = new Float32Array(N); |
| 63 | + densityNext = new Float32Array(N); |
| 64 | + vxGrid = new Float32Array(N); |
| 65 | + vyGrid = new Float32Array(N); |
| 66 | + |
| 67 | + off = document.createElement('canvas'); |
| 68 | + off.width = GW; |
| 69 | + off.height = GH; |
| 70 | + offCtx = off.getContext('2d')!; |
| 71 | + smokeImage = offCtx.createImageData(GW, GH); |
| 72 | + |
| 73 | + // Seed a baseline of density so the canvas isn't blank at t=0. |
| 74 | + // Mild, spatially modulated so it doesn't look flat. |
| 75 | + for (let j = 0; j < GH; j++) { |
| 76 | + for (let i = 0; i < GW; i++) { |
| 77 | + const nx = (i / GW - 0.4); |
| 78 | + const ny = (j / GH - 0.5); |
| 79 | + const r2 = nx * nx + ny * ny; |
| 80 | + density[j * GW + i] = 0.35 * Math.exp(-r2 * 3.0) + 0.08 * Math.random(); |
| 81 | + } |
| 82 | + } |
| 83 | + } |
| 84 | + resize(); |
| 85 | + |
| 86 | + const hash = (x: number, y: number) => { |
| 87 | + const s = Math.sin(x * 127.1 + y * 311.7) * 43758.5453; |
| 88 | + return s - Math.floor(s); |
| 89 | + }; |
| 90 | + const smoothstep = (t: number) => t * t * (3 - 2 * t); |
| 91 | + const noise = (x: number, y: number) => { |
| 92 | + const xi = Math.floor(x), yi = Math.floor(y); |
| 93 | + const xf = x - xi, yf = y - yi; |
| 94 | + const u = smoothstep(xf), v = smoothstep(yf); |
| 95 | + const a = hash(xi, yi); |
| 96 | + const b = hash(xi + 1, yi); |
| 97 | + const c = hash(xi, yi + 1); |
| 98 | + const d = hash(xi + 1, yi + 1); |
| 99 | + return (a * (1 - u) + b * u) * (1 - v) + (c * (1 - u) + d * u) * v; |
| 100 | + }; |
| 101 | + const fbm = (x: number, y: number) => |
| 102 | + noise(x, y) + 0.5 * noise(x * 2.0, y * 2.0); |
| 103 | + |
| 104 | + // Same curl-noise parameters as CurlNoiseSketch so the two pieces |
| 105 | + // share the same "weather". |
| 106 | + const SCALE = 0.0042; |
| 107 | + const EPS = 0.05; |
| 108 | + const SPEED = 2.2; // higher than particle version — density carries |
| 109 | + // visual mass so it can move faster without |
| 110 | + // leaving the trails-only look behind. |
| 111 | + |
| 112 | + // Accent color — this is the smoke's hue. Pure white reads as neon |
| 113 | + // against the dark bg; a muted accent reads as vapor. |
| 114 | + const BASE_RGB = [220, 214, 198]; |
| 115 | + let SMOKE_RGB = [...BASE_RGB]; |
| 116 | + const tinted = document.querySelector('[class*="tint-"]'); |
| 117 | + if (tinted) { |
| 118 | + const raw = getComputedStyle(tinted).getPropertyValue('--accent').trim(); |
| 119 | + const m = raw.match(/^#([0-9a-f]{6})$/i); |
| 120 | + if (m) { |
| 121 | + // 65/35 blend of paper-warm with the accent — the smoke looks |
| 122 | + // tinted without losing the "white smoke" read. |
| 123 | + const acc = [ |
| 124 | + parseInt(m[1].slice(0, 2), 16), |
| 125 | + parseInt(m[1].slice(2, 4), 16), |
| 126 | + parseInt(m[1].slice(4, 6), 16), |
| 127 | + ]; |
| 128 | + SMOKE_RGB = [ |
| 129 | + Math.round(BASE_RGB[0] * 0.65 + acc[0] * 0.35), |
| 130 | + Math.round(BASE_RGB[1] * 0.65 + acc[1] * 0.35), |
| 131 | + Math.round(BASE_RGB[2] * 0.65 + acc[2] * 0.35), |
| 132 | + ]; |
| 133 | + } |
| 134 | + } |
| 135 | + |
| 136 | + let mx = -9999, my = -9999; |
| 137 | + let mxPrev = -9999, myPrev = -9999; |
| 138 | + const onPointerMove = (e: PointerEvent) => { |
| 139 | + const rect = canvas!.getBoundingClientRect(); |
| 140 | + mxPrev = mx; myPrev = my; |
| 141 | + mx = e.clientX - rect.left; |
| 142 | + my = e.clientY - rect.top; |
| 143 | + }; |
| 144 | + const onPointerLeave = () => { |
| 145 | + mx = -9999; my = -9999; mxPrev = -9999; myPrev = -9999; |
| 146 | + }; |
| 147 | + canvas.addEventListener('pointermove', onPointerMove); |
| 148 | + canvas.addEventListener('pointerleave', onPointerLeave); |
| 149 | + |
| 150 | + let paused = false; |
| 151 | + const onPause = (e: Event) => { |
| 152 | + const detail = (e as CustomEvent).detail as { paused: boolean }; |
| 153 | + paused = !!detail?.paused; |
| 154 | + }; |
| 155 | + window.addEventListener('stage:pause', onPause); |
| 156 | + window.addEventListener('resize', resize); |
| 157 | + |
| 158 | + // Persistent Gaussian emitters — positioned as fractions of the |
| 159 | + // canvas so they follow resizes. They sit well left of the text |
| 160 | + // column so the smoke has space to develop before reaching the |
| 161 | + // center line. |
| 162 | + const EMITTERS = [ |
| 163 | + { fx: 0.26, fy: 0.24, fr: 0.13, s: 0.012 }, |
| 164 | + { fx: 0.14, fy: 0.55, fr: 0.11, s: 0.010 }, |
| 165 | + { fx: 0.32, fy: 0.82, fr: 0.12, s: 0.010 }, |
| 166 | + ]; |
| 167 | + |
| 168 | + const DISSIPATION = 0.9955; |
| 169 | + |
| 170 | + let t = 0; |
| 171 | + let rafId = 0; |
| 172 | + let aborted = false; |
| 173 | + const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; |
| 174 | + |
| 175 | + function frame() { |
| 176 | + if (aborted) return; |
| 177 | + if (!reduced) rafId = requestAnimationFrame(frame); |
| 178 | + if (paused) return; |
| 179 | + |
| 180 | + t += 0.0013; |
| 181 | + |
| 182 | + // --- 1. Velocity field (curl of fbm scalar) on the grid --- |
| 183 | + // Same pattern as CurlNoiseSketch but per cell instead of per |
| 184 | + // particle. Time drifts both axes, so eddies slowly reshape. |
| 185 | + for (let j = 0; j < GH; j++) { |
| 186 | + for (let i = 0; i < GW; i++) { |
| 187 | + const xs = (i + 0.5) * cellW * SCALE; |
| 188 | + const ys = (j + 0.5) * cellH * SCALE; |
| 189 | + const n_xp = fbm(xs + EPS + t, ys - t); |
| 190 | + const n_xn = fbm(xs - EPS + t, ys - t); |
| 191 | + const n_yp = fbm(xs + t, ys + EPS - t); |
| 192 | + const n_yn = fbm(xs + t, ys - EPS - t); |
| 193 | + const dpdx = (n_xp - n_xn) / (2 * EPS); |
| 194 | + const dpdy = (n_yp - n_yn) / (2 * EPS); |
| 195 | + const idx = j * GW + i; |
| 196 | + // Velocity in cells-per-frame. Factor cellW/cellH converts |
| 197 | + // "pixels per frame" (the scale SPEED targets) into cell units. |
| 198 | + vxGrid[idx] = dpdy * SPEED / cellW; |
| 199 | + vyGrid[idx] = -dpdx * SPEED / cellH; |
| 200 | + } |
| 201 | + } |
| 202 | + |
| 203 | + // --- 2. Semi-Lagrangian advection --- |
| 204 | + // For each cell, trace a trajectory one step back, bilinearly |
| 205 | + // sample the density there, store into densityNext. This is |
| 206 | + // unconditionally stable regardless of velocity magnitude — |
| 207 | + // the trade is numerical diffusion (fine details blur out). |
| 208 | + for (let j = 0; j < GH; j++) { |
| 209 | + for (let i = 0; i < GW; i++) { |
| 210 | + const idx = j * GW + i; |
| 211 | + let x = i - vxGrid[idx]; |
| 212 | + let y = j - vyGrid[idx]; |
| 213 | + if (x < 0) x = 0; else if (x > GW - 1.001) x = GW - 1.001; |
| 214 | + if (y < 0) y = 0; else if (y > GH - 1.001) y = GH - 1.001; |
| 215 | + const i0 = x | 0, j0 = y | 0; |
| 216 | + const fx = x - i0, fy = y - j0; |
| 217 | + const i1 = i0 + 1, j1 = j0 + 1; |
| 218 | + const d00 = density[j0 * GW + i0]; |
| 219 | + const d10 = density[j0 * GW + i1]; |
| 220 | + const d01 = density[j1 * GW + i0]; |
| 221 | + const d11 = density[j1 * GW + i1]; |
| 222 | + const d0 = d00 * (1 - fx) + d10 * fx; |
| 223 | + const d1 = d01 * (1 - fx) + d11 * fx; |
| 224 | + densityNext[idx] = (d0 * (1 - fy) + d1 * fy) * DISSIPATION; |
| 225 | + } |
| 226 | + } |
| 227 | + |
| 228 | + // --- 3. Sources: emitters + pointer drag --- |
| 229 | + for (const e of EMITTERS) { |
| 230 | + const cx = e.fx * GW; |
| 231 | + const cy = e.fy * GH; |
| 232 | + const r = e.fr * GW; |
| 233 | + const r2 = r * r; |
| 234 | + const i0 = Math.max(0, Math.floor(cx - r)); |
| 235 | + const i1 = Math.min(GW - 1, Math.ceil(cx + r)); |
| 236 | + const j0 = Math.max(0, Math.floor(cy - r)); |
| 237 | + const j1 = Math.min(GH - 1, Math.ceil(cy + r)); |
| 238 | + for (let j = j0; j <= j1; j++) { |
| 239 | + for (let i = i0; i <= i1; i++) { |
| 240 | + const dx = i - cx, dy = j - cy; |
| 241 | + const d2 = dx * dx + dy * dy; |
| 242 | + if (d2 < r2) { |
| 243 | + // Quadratic falloff — smooth edge, peak at center. |
| 244 | + const falloff = 1 - d2 / r2; |
| 245 | + densityNext[j * GW + i] += e.s * falloff * falloff; |
| 246 | + } |
| 247 | + } |
| 248 | + } |
| 249 | + } |
| 250 | + |
| 251 | + // Pointer injection — interpolate between previous and current |
| 252 | + // pointer positions so a fast drag paints a continuous stroke |
| 253 | + // instead of a dotted line of puffs. Step size ≈ half the brush |
| 254 | + // radius so consecutive stamps overlap. |
| 255 | + if (mx >= 0 && mx <= w && my >= 0 && my <= h) { |
| 256 | + const r = Math.max(3, GW * 0.04); |
| 257 | + const r2 = r * r; |
| 258 | + const POINTER_S = 0.09; |
| 259 | + |
| 260 | + const haveSegment = mxPrev >= 0 && mxPrev <= w && myPrev >= 0 && myPrev <= h; |
| 261 | + const sx = haveSegment ? mxPrev : mx; |
| 262 | + const sy = haveSegment ? myPrev : my; |
| 263 | + const dx = mx - sx, dy = my - sy; |
| 264 | + const segLen = Math.hypot(dx, dy); |
| 265 | + const stepPx = Math.max(1, (r * 0.5) * cellW); |
| 266 | + const stamps = Math.max(1, Math.ceil(segLen / stepPx)); |
| 267 | + |
| 268 | + for (let k = 0; k < stamps; k++) { |
| 269 | + const t = stamps === 1 ? 1 : k / (stamps - 1); |
| 270 | + const px = sx + dx * t; |
| 271 | + const py = sy + dy * t; |
| 272 | + const ci = px / cellW; |
| 273 | + const cj = py / cellH; |
| 274 | + const i0 = Math.max(0, Math.floor(ci - r)); |
| 275 | + const i1 = Math.min(GW - 1, Math.ceil(ci + r)); |
| 276 | + const j0 = Math.max(0, Math.floor(cj - r)); |
| 277 | + const j1 = Math.min(GH - 1, Math.ceil(cj + r)); |
| 278 | + for (let j = j0; j <= j1; j++) { |
| 279 | + for (let i = i0; i <= i1; i++) { |
| 280 | + const ddx = i - ci, ddy = j - cj; |
| 281 | + const d2 = ddx * ddx + ddy * ddy; |
| 282 | + if (d2 < r2) { |
| 283 | + const falloff = 1 - d2 / r2; |
| 284 | + densityNext[j * GW + i] += POINTER_S * falloff * falloff; |
| 285 | + } |
| 286 | + } |
| 287 | + } |
| 288 | + } |
| 289 | + // After consuming the stroke, collapse prev to current so the |
| 290 | + // next frame's segment starts from where we are, not where we |
| 291 | + // were two frames ago. |
| 292 | + mxPrev = mx; myPrev = my; |
| 293 | + } |
| 294 | + |
| 295 | + // --- 4. Swap density buffers --- |
| 296 | + const tmp = density; density = densityNext; densityNext = tmp; |
| 297 | + |
| 298 | + // --- 5. Render: density grid → small ImageData → upscale blit --- |
| 299 | + const data = smokeImage.data; |
| 300 | + const SR = SMOKE_RGB[0], SG = SMOKE_RGB[1], SB = SMOKE_RGB[2]; |
| 301 | + for (let k = 0; k < density.length; k++) { |
| 302 | + let d = density[k]; |
| 303 | + if (d > 1) d = 1; else if (d < 0) d = 0; |
| 304 | + const pi = k * 4; |
| 305 | + data[pi] = SR; |
| 306 | + data[pi + 1] = SG; |
| 307 | + data[pi + 2] = SB; |
| 308 | + data[pi + 3] = (d * 255) | 0; |
| 309 | + } |
| 310 | + offCtx.putImageData(smokeImage, 0, 0); |
| 311 | + ctx.clearRect(0, 0, w, h); |
| 312 | + ctx.imageSmoothingEnabled = true; |
| 313 | + ctx.imageSmoothingQuality = 'high'; |
| 314 | + ctx.drawImage(off, 0, 0, GW, GH, 0, 0, w, h); |
| 315 | + } |
| 316 | + |
| 317 | + if (reduced) { |
| 318 | + canvas.removeEventListener('pointermove', onPointerMove); |
| 319 | + canvas.removeEventListener('pointerleave', onPointerLeave); |
| 320 | + for (let i = 0; i < 120; i++) frame(); |
| 321 | + aborted = true; |
| 322 | + } else { |
| 323 | + frame(); |
| 324 | + } |
| 325 | + |
| 326 | + currentCleanup = () => { |
| 327 | + aborted = true; |
| 328 | + cancelAnimationFrame(rafId); |
| 329 | + window.removeEventListener('stage:pause', onPause); |
| 330 | + window.removeEventListener('resize', resize); |
| 331 | + currentCleanup = null; |
| 332 | + }; |
| 333 | + document.addEventListener('astro:before-swap', () => currentCleanup?.(), { once: true }); |
| 334 | + } |
| 335 | + |
| 336 | + init(); |
| 337 | + document.addEventListener('astro:page-load', init); |
| 338 | +</script> |
| 339 | + |
| 340 | +<style> |
| 341 | + canvas { |
| 342 | + display: block; |
| 343 | + width: 100%; |
| 344 | + height: 100%; |
| 345 | + background: var(--bg); |
| 346 | + /* On touch devices, claim the gesture for sketch interaction. |
| 347 | + Users scroll via the text column below. */ |
| 348 | + touch-action: none; |
| 349 | + } |
| 350 | +</style> |
0 commit comments