Skip to content

Commit f44954b

Browse files
RuitingMaclaude
andcommitted
Add Smoke sketch + mobile touch handling
- New SmokeSketch component: ~26K-cell density grid, semi-Lagrangian advection on curl-noise velocity field. No pressure projection needed because curl(ψ) is divergence-free by construction. Continuous pointer stroke interpolation between frames so fast drags paint without gaps. - New /sketches/smoke/ page, tint-plum. - Home feed: add Smoke entry. - touch-action: none on sketch canvases so mobile users stir the sketch instead of scrolling away from it; 45vh → 45svh on mobile stage to stop Safari URL-bar shifts from reflowing the hero. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 84b5cdb commit f44954b

5 files changed

Lines changed: 466 additions & 1 deletion

File tree

src/components/ParticleSketch.astro

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,5 +226,8 @@
226226
width: 100%;
227227
height: 100%;
228228
background: var(--bg);
229+
/* On touch devices, claim the gesture for sketch interaction.
230+
Users scroll via the text column below. */
231+
touch-action: none;
229232
}
230233
</style>

src/components/SmokeSketch.astro

Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
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>

src/layouts/BaseLayout.astro

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,9 @@ const ogImage = image ? new URL(image, Astro.site).toString() : undefined;
227227
}
228228
.stage {
229229
position: relative;
230-
height: 45vh;
230+
/* svh = small viewport height, ignores dynamic URL bar on mobile.
231+
Without it, 45vh shifts when Safari collapses its chrome. */
232+
height: 45svh;
231233
border-right: none;
232234
border-bottom: 1px solid var(--rule);
233235
}

src/pages/index.astro

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ import BaseLayout from '../layouts/BaseLayout.astro';
33
import LobbySketch from '../components/LobbySketch.astro';
44
55
const sketches = [
6+
{
7+
slug: 'smoke',
8+
title: 'Smoke',
9+
date: '2026-04-19',
10+
kind: 'GENERATIVE',
11+
summary: '密度场 + 半拉格朗日平流——真正的烟。',
12+
},
613
{
714
slug: 'hello-world',
815
title: 'Flow Field',

0 commit comments

Comments
 (0)