05연기 상승
밀도에 비례하는 부력을 속도장에 매 프레임 더하고 옆으로 살짝 흔드는 가우시안 노이즈를 얹으면 아래에서 피어오르는 연기 기둥이 됩니다. 부력은 온도를 따로 두지 않고 밀도 그 자체로 근사합니다.
쓰임다크 모드 푸터의 연기 배경
requestAnimationFrameMath.sin

<main class="stage stage--ink">
<div class="fa" id="fa">
<p class="fa__hint">
<svg class="fa__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M236 128a108 108 0 0 1-216 0a100 100 0 0 1 100-100a92 92 0 0 1 92 92a84 84 0 0 1-84 84a76 76 0 0 1-76-76a68 68 0 0 1 68-68a60 60 0 0 1 60 60a52 52 0 0 1-52 52a44 44 0 0 1-44-44a36 36 0 0 1 36-36a28 28 0 0 1 28 28a20 20 0 0 1-20 20a12 12 0 0 1-12-12" opacity=".2" fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24Zm0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88Zm0-152a64 64 0 1 0 64 64a64.07 64.07 0 0 0-64-64Zm0 112a48 48 0 1 1 48-48a48.05 48.05 0 0 1-48 48Z"/></g></svg>
<span>아래에서 피어오른 연기가 밀도만큼 받는 부력으로 올라가며 옆 바람에 흔들립니다 — 화면을 눌러 연기를 피워 보세요</span>
</p>
<div class="fa__field" id="field" role="application" aria-label="연기 상승 실험판" tabindex="0">
<canvas class="fa__cv" id="cv"></canvas>
<b class="fa__badge" aria-hidden="true">32<span class="fa__badgeUnit">격자</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 연기 상승 — 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판·배지만 맡는다.
// @keyframes 를 하나도 두지 않는다 — 렌더러가 CSS 시계를 고정하지 않고 실시간으로 24장을 찍는다(01·03·077 선례).
// 어두운 잉크 무대 위 연기는 무채색빛 파스텔 3단(옅은 연기 → 중간 → 진한 코어).
// 세 색 모두 채도 0.2 이하의 회색빛이라 BD26 파스텔 게이트를 처음부터 지키고,
// 어두운 무대 위 대비는 6.9~13.7 로 BD25(4.5:1)를 넘는다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-ink);
.fa {
// 연기 세 층 — 연회색빛(옅은) → 청회색빛(중간) → 밝은 코어(진한). 잉크 무대 위 대비 6.9~13.7
--dye-0: #b8b3c4;
--dye-1: #cdc8d6;
--dye-2: #e9e5ef;
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.fa__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-cream;
}
.fa__ico { width: 16px; height: 16px; flex: 0 0 auto; color: #b8b3c4; }
.fa__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $stage-ink;
box-shadow: inset 0 0 0 1px rgba($subject-cream, .14);
cursor: crosshair; touch-action: none;
}
.fa__field:focus-visible { outline: 2px solid #b8b3c4; outline-offset: 2px; }
.fa__cv { display: block; width: 100%; height: 100%; }
.fa__badge {
position: absolute; right: $sp-2; top: $sp-2;
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($subject-ink, .78);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.fa__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.fa { gap: $sp-1; }
.fa__field { height: 124px; }
.fa__badge { font-size: 10px; padding: 0 $sp-1; }
}
// reduce — 루프가 멈춘 한 장이 남으므로 커서 신호만 바꾼다.
@media (prefers-reduced-motion: reduce) {
.fa.is-still .fa__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fa {
--dye-0: #b8b3c4;
--dye-1: #cdc8d6;
--dye-2: #e9e5ef;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.fa__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #fff7e6;
}
.fa__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #b8b3c4;
}
.fa__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: #17141a;
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.14);
cursor: crosshair;
touch-action: none;
}
.fa__field:focus-visible {
outline: 2px solid #b8b3c4;
outline-offset: 2px;
}
.fa__cv {
display: block;
width: 100%;
height: 100%;
}
.fa__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.fa__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.fa {
gap: 4px;
}
.fa__field {
height: 124px;
}
.fa__badge {
font-size: 10px;
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.fa.is-still .fa__field {
cursor: default !important;
}
}// 연기 상승 — 밀도에 비례하는 부력을 속도장에 매 걸음 더한다. 온도장을 따로 두지 않고
// 밀도 자체가 곧 온도(더운 연기 = 진한 연기)라는 근사. 여기에 결정적 가우시안 옆흔들림을
// u 에 얹으면 아래에서 피어오르며 좌우로 갈라지는 연기 기둥이 된다 — 01·03·04 와 같은
// 32×32 안정 유체 격자와 advect 를 공유하고, 확산은 아주 약하게만(연기는 이류가 주역).
var root = document.getElementById('fa');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d', { willReadFrequently: true });
var css = getComputedStyle(root);
var PAL0 = css.getPropertyValue('--dye-0').trim();
var PAL1 = css.getPropertyValue('--dye-1').trim();
var PAL2 = css.getPropertyValue('--dye-2').trim();
var N = 32;
var SIZE = (N + 2) * (N + 2);
var IX = function (x, y) { return x + (N + 2) * y; };
var u = new Float32Array(SIZE), v = new Float32Array(SIZE);
var u0 = new Float32Array(SIZE), v0 = new Float32Array(SIZE);
var d = new Float32Array(SIZE), d0 = new Float32Array(SIZE);
var BUOY = 3.2; // 부력 계수 — 밀도 1 에 매 걸음 얹는 상승 속도. 크면 연기가 뿜어오른다
var WIND = 0.55; // 옆흔들림 진폭 — 가우시안 노이즈가 u 를 흔드는 세기
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, raf = 0, seedT = 0;
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
}
// 결정적 가우시안 노이즈 — Math.sin 해시 조합. Math.random 을 쓰지 않아
// 같은 편의 gif·poster 가 언제나 같은 연기 모양새로 담긴다.
function gnoise(x, y) {
return Math.sin(x * 12.9898 + y * 78.233) * 43758.5453 % 1;
}
// 후방 이류(semi-Lagrangian) — 칸 (x,y) 의 새 값을 속도를 dt 만큼 거슬러 간 좌표의
// 옛 값으로 채운다. 04 와 같은 구조를 05 용으로 다시 썼다(확산은 거시적으로 약하게).
function advect(b, dt) {
var src = b === 'u' ? u0 : (b === 'v' ? v0 : d0);
var dst = b === 'u' ? u : (b === 'v' ? v : d);
for (var j = 1; j <= N; j++) {
for (var i = 1; i <= N; i++) {
var x = i - dt * N * u0[IX(i, j)];
var y = j - dt * N * v0[IX(i, j)];
if (x < 0.5) x = 0.5; else if (x > N + 0.5) x = N + 0.5;
var i0 = x | 0; var i1 = i0 + 1;
if (y < 0.5) y = 0.5; else if (y > N + 0.5) y = N + 0.5;
var j0 = y | 0; var j1 = j0 + 1;
var s1 = x - i0, s0 = 1 - s1, t1 = y - j0, t0 = 1 - t1;
dst[IX(i, j)] = s0 * (t0 * src[IX(i0, j0)] + t1 * src[IX(i0, j1)]) +
s1 * (t0 * src[IX(i1, j0)] + t1 * src[IX(i1, j1)]);
}
}
}
// 아주 약한 확산 — 연기 가장자리를 스모키하게 부드럽게 하는 정도. 이류가 주역이라 이걸 크게
// 하면 기둥이 아니라 안개가 된다.
function diffuse(b, k, dt) {
var src = b === 'u' ? u : (b === 'v' ? v : d);
var a = dt * k * N * N;
for (var iter = 0; iter < 2; iter++) {
for (var j = 1; j <= N; j++) {
for (var i = 1; i <= N; i++) {
var idx = IX(i, j);
src[idx] = (src[idx] + a * (src[IX(i - 1, j)] + src[IX(i + 1, j)] + src[IX(i, j - 1)] + src[IX(i, j + 1)])) / (1 + 4 * a);
}
}
}
}
// 경계 — 위 가장자리로 빠져나간 연기는 사라지고(감쇠), 벽 유출 속도는 줄인다
function bounds() {
for (var i = 1; i <= N; i++) {
d[IX(i, 0)] = d[IX(i, 1)] * 0.35; d[IX(i, N + 1)] = d[IX(i, N)] * 0.35;
u[IX(i, 0)] = u[IX(i, 1)] * 0.4; u[IX(i, N + 1)] = u[IX(i, N)] * 0.4;
v[IX(i, 0)] = v[IX(i, 1)] * 0.4; v[IX(i, N + 1)] = v[IX(i, N)] * 0.4;
}
for (var j = 1; j <= N; j++) {
d[IX(0, j)] = d[IX(1, j)] * 0.5; d[IX(N + 1, j)] = d[IX(N, j)] * 0.5;
u[IX(0, j)] = u[IX(1, j)] * 0.4; u[IX(N + 1, j)] = u[IX(N, j)] * 0.4;
v[IX(0, j)] = v[IX(1, j)] * 0.4; v[IX(N + 1, j)] = v[IX(N, j)] * 0.4;
}
}
// 연기 플렉스(뿜기) — 밀도 덩어리를 가우시안으로 심고 위로 살짝 밀어올리는 초기 속도를 얹는다.
// 부력이 본 일을 시작하게 하는 점화만 담당 — 뒤는 밀도 곧 온도 근사가 알아서 올린다.
function puff(gx, gy, amp) {
var r = 3.2;
for (var j = -r; j <= r; j++) {
for (var i = -r; i <= r; i++) {
var q = (i * i + j * j) / (r * r);
if (q > 1) continue;
var f = Math.exp(-q * 2.2) * amp;
var ix = Math.round(gx + i), iy = Math.round(gy + j);
if (ix < 1 || ix > N || iy < 1 || iy > N) continue;
var idx = IX(ix, iy);
d[idx] = Math.min(2.4, d[idx] + f);
u[idx] += gnoise(ix, iy) * f * 0.06;
v[idx] -= f * 0.16; // 격자 y 는 위가 작은 값 — 상승 = v 감소. 점화용 살짝 밀어올림
}
}
}
// 미리보기 주입원 — 하단 세 곳 고정 좌표에서 2초(60스텝 @30fps)마다 번갈아 피운다. 시드 고정.
var seedSpots = [
{ x: N * 0.30, y: N - 1.5 },
{ x: N * 0.55, y: N - 1.5 },
{ x: N * 0.76, y: N - 1.5 }
];
var seedPeriod = 20;
var lastSeedStep = -999;
var stepCount = 0;
function step(dt) {
if (stepCount - lastSeedStep >= seedPeriod) {
// 세 곳을 3걸음에 걸쳐 연달아 피운다 — 쉼 없이 여러 기둥이 화면을 채운다
var order = (stepCount / seedPeriod | 0) % seedSpots.length;
puff(seedSpots[order].x + Math.sin(seedT * 0.9) * 1.2, seedSpots[order].y, 2.1);
if (stepCount - lastSeedStep >= 1) {
puff(seedSpots[(order + 1) % seedSpots.length].x + Math.cos(seedT * 0.7) * 1.4, seedSpots[(order + 1) % seedSpots.length].y, 2.1);
puff(seedSpots[(order + 2) % seedSpots.length].x + Math.sin(seedT * 0.5 + 2) * 1.4, seedSpots[(order + 2) % seedSpots.length].y, 2.1);
lastSeedStep = stepCount;
}
}
u0.set(u); v0.set(v); d0.set(d);
// 부력 — 밀도가 진한 칸일수록 위로(v 마이너스) 밀어올린다. 온도장 없는 근사의 핵심 한 줄.
for (var k = 0; k < SIZE; k++) v0[k] -= Math.min(1.6, d0[k]) * BUOY * dt;
// 옆흔들림 — 결정적 가우시안 노이즈로 매 걸음 u 를 살짝 흔든다. 시간축 해시를 더해 흔들림이
// 프레임마다 다르게 구르게 한다(연기 기둥이 갈라지며 올라가는 이유).
var h = Math.sin(seedT * 1.7) * 3.1;
for (var j = 1; j <= N; j++) {
for (var i = 1; i <= N; i++) {
var idx2 = IX(i, j);
var sw = gnoise(i * 0.31 + h, j * 0.27 - h * 0.6) + gnoise(i * 0.13 - h * 0.4, j * 0.19 + h * 0.8) * 0.5;
u0[idx2] += sw * WIND * dt * Math.min(1, d0[idx2] * 0.9);
}
}
advect('u', dt); u0.set(u);
advect('v', dt); v0.set(v);
advect('d', dt);
diffuse('d', 0.0006, dt);
bounds();
for (var m = 0; m < SIZE; m++) { d[m] *= 0.998; u[m] *= 0.988; v[m] *= 0.988; }
seedT += dt;
stepCount++;
}
function render() {
var img = ctx.createImageData(cv.width, cv.height);
var px = img.data;
var cellW = cv.width / N, cellH = cv.height / N;
var pal = [PAL0, PAL1, PAL2];
for (var py = 0; py < cv.height; py++) {
var gy = Math.min(N - 1, (py / cellH) | 0);
for (var pxx = 0; pxx < cv.width; pxx++) {
var gx = Math.min(N - 1, (pxx / cellW) | 0);
var q = d[IX(gx + 1, gy + 1)];
var o = (py * cv.width + pxx) * 4;
if (q <= 0.012) { px[o] = 23; px[o + 1] = 20; px[o + 2] = 26; px[o + 3] = 255; continue; }
var band = q > 0.85 ? 2 : (q > 0.34 ? 1 : 0);
var c = pal[band];
var rr = parseInt(c.slice(1, 3), 16), gg = parseInt(c.slice(3, 5), 16), bb = parseInt(c.slice(5, 7), 16);
var a = Math.min(1, q * 0.95);
px[o] = 23 + (rr - 23) * a; px[o + 1] = 20 + (gg - 20) * a; px[o + 2] = 26 + (bb - 26) * a; px[o + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function frame() {
step(1 / 30);
render();
raf = requestAnimationFrame(frame);
}
// 실제 사용 — pointermove 로 그 자리를 지나가며 연기를 피우고, 누르면 진하게 피운다
field.addEventListener('pointermove', function (e) {
if (e.pressure === 0 && e.buttons === 0) return;
var r = field.getBoundingClientRect();
var gx = 1 + ((e.clientX - r.left) / r.width * (N - 1));
var gy = 1 + ((e.clientY - r.top) / r.height * (N - 1));
puff(gx, gy, 0.5);
if (reduce) render();
});
field.addEventListener('pointerdown', function (e) {
var r = field.getBoundingClientRect();
var gx = 1 + ((e.clientX - r.left) / r.width * (N - 1));
var gy = 1 + ((e.clientY - r.top) / r.height * (N - 1));
puff(gx, gy, 2.0);
if (reduce) render();
});
field.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
puff(N * 0.5, N * 0.8, 2.0);
if (reduce) render();
});
window.addEventListener('resize', function () { fit(); if (reduce) render(); });
fit();
if (reduce) {
// reduce — 시뮬레이션을 40 걸음만 앞으로 감아 연기 기둥이 자리잡은 한 장을 그린다(빈 화면이 아니다)
for (var k = 0; k < 40; k++) step(1 / 30);
render();
} else {
// 웜업 — 캡처 첫 장이 빈 화면이 되지 않게 연기가 피어오르기 시작한 상태에서 루프를 연다
for (var w = 0; w < 90; w++) step(1 / 30);
raf = requestAnimationFrame(frame);
}// SmokeRise — vanilla 판(run/308/demo/05-fluid-smoke-rise/index.html) 의 React 포트.
// 밀도에 비례하는 부력(온도장 없는 근사)과 결정적 가우시안 옆흔들림으로 연기 기둥을 세운다.
// 격자 필드는 ref 상자 안에서만 산다 — React 상태로 두면 리렌더가 매 프레임 일어나 60fps 예산을 못 맞춘다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./SmokeRise.module.scss";
export type SmokeRiseProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
type PtrEvt = { clientX: number; clientY: number; pressure?: number; buttons?: number };
const N = 32;
const SIZE = (N + 2) * (N + 2);
function IX(x: number, y: number): number { return x + (N + 2) * y; }
const BUOY = 3.2;
const WIND = 0.55;
const SEED_SPOTS = [
{ x: N * 0.3, y: N - 1.5 },
{ x: N * 0.55, y: N - 1.5 },
{ x: N * 0.76, y: N - 1.5 },
];
const SEED_PERIOD = 20;
// 결정적 가우시안 노이즈 — Math.random 을 쓰지 않아 같은 편의 캡처가 항상 같은 연기를 담는다.
function gnoise(x: number, y: number): number {
return (Math.sin(x * 12.9898 + y * 78.233) * 43758.5453) % 1;
}
export default function SmokeRise({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#fff7e6",
}: SmokeRiseProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const palRef = useRef(["#b8b3c4", "#cdc8d6", "#e9e5ef"]) as Box<string[]>;
const u = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const v = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const u0 = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const v0 = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const d = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const d0 = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const seedTRef = useRef(0) as Box<number>;
const stepCountRef = useRef(0) as Box<number>;
const lastSeedStepRef = useRef(-999) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
cv.width = Math.round(r.width);
cv.height = Math.round(r.height);
}
function advect(b: "u" | "v" | "d", dt: number): void {
const src = b === "u" ? u0.current : b === "v" ? v0.current : d0.current;
const dst = b === "u" ? u.current : b === "v" ? v.current : d.current;
const uu = u0.current, vv = v0.current;
for (let j = 1; j <= N; j++) {
for (let i = 1; i <= N; i++) {
let x = i - dt * N * uu[IX(i, j)];
let y = j - dt * N * vv[IX(i, j)];
if (x < 0.5) x = 0.5; else if (x > N + 0.5) x = N + 0.5;
const i0 = x | 0, i1 = i0 + 1;
if (y < 0.5) y = 0.5; else if (y > N + 0.5) y = N + 0.5;
const j0 = y | 0, j1 = j0 + 1;
const s1 = x - i0, s0 = 1 - s1, t1 = y - j0, t0 = 1 - t1;
dst[IX(i, j)] = s0 * (t0 * src[IX(i0, j0)] + t1 * src[IX(i0, j1)]) +
s1 * (t0 * src[IX(i1, j0)] + t1 * src[IX(i1, j1)]);
}
}
}
function diffuse(b: "u" | "v" | "d", k: number, dt: number): void {
const src = b === "u" ? u.current : b === "v" ? v.current : d.current;
const a = dt * k * N * N;
for (let iter = 0; iter < 2; iter++) {
for (let j = 1; j <= N; j++) {
for (let i = 1; i <= N; i++) {
const idx = IX(i, j);
src[idx] = (src[idx] + a * (src[IX(i - 1, j)] + src[IX(i + 1, j)] + src[IX(i, j - 1)] + src[IX(i, j + 1)])) / (1 + 4 * a);
}
}
}
}
function bounds(): void {
const dd = d.current, uu = u.current, vv = v.current;
for (let i = 1; i <= N; i++) {
dd[IX(i, 0)] = dd[IX(i, 1)] * 0.35; dd[IX(i, N + 1)] = dd[IX(i, N)] * 0.35;
uu[IX(i, 0)] = uu[IX(i, 1)] * 0.4; uu[IX(i, N + 1)] = uu[IX(i, N)] * 0.4;
vv[IX(i, 0)] = vv[IX(i, 1)] * 0.4; vv[IX(i, N + 1)] = vv[IX(i, N)] * 0.4;
}
for (let j = 1; j <= N; j++) {
dd[IX(0, j)] = dd[IX(1, j)] * 0.5; dd[IX(N + 1, j)] = dd[IX(N, j)] * 0.5;
uu[IX(0, j)] = uu[IX(1, j)] * 0.4; uu[IX(N + 1, j)] = uu[IX(N, j)] * 0.4;
vv[IX(0, j)] = vv[IX(1, j)] * 0.4; vv[IX(N + 1, j)] = vv[IX(N, j)] * 0.4;
}
}
function puff(gx: number, gy: number, amp: number): void {
const r = 3.2;
const uu = u.current, vv = v.current, dd = d.current;
for (let j = -r; j <= r; j++) {
for (let i = -r; i <= r; i++) {
const q = (i * i + j * j) / (r * r);
if (q > 1) continue;
const f = Math.exp(-q * 2.2) * amp;
const ix = Math.round(gx + i), iy = Math.round(gy + j);
if (ix < 1 || ix > N || iy < 1 || iy > N) continue;
const idx = IX(ix, iy);
dd[idx] = Math.min(2.4, dd[idx] + f);
uu[idx] += gnoise(ix, iy) * f * 0.06;
vv[idx] -= f * 0.16;
}
}
}
function step(dt: number): void {
const uu = u.current, vv = v.current, dd = d.current;
const uu0 = u0.current, vv0 = v0.current, dd0 = d0.current;
if (stepCountRef.current - lastSeedStepRef.current >= SEED_PERIOD) {
const order = Math.floor(stepCountRef.current / SEED_PERIOD) % SEED_SPOTS.length;
const t = seedTRef.current;
puff(SEED_SPOTS[order].x + Math.sin(t * 0.9) * 1.2, SEED_SPOTS[order].y, 2.1);
puff(SEED_SPOTS[(order + 1) % SEED_SPOTS.length].x + Math.cos(t * 0.7) * 1.4, SEED_SPOTS[(order + 1) % SEED_SPOTS.length].y, 2.1);
puff(SEED_SPOTS[(order + 2) % SEED_SPOTS.length].x + Math.sin(t * 0.5 + 2) * 1.4, SEED_SPOTS[(order + 2) % SEED_SPOTS.length].y, 2.1);
lastSeedStepRef.current = stepCountRef.current;
}
uu0.set(uu); vv0.set(vv); dd0.set(dd);
for (let k = 0; k < SIZE; k++) vv0[k] -= Math.min(1.6, dd0[k]) * BUOY * dt;
const h = Math.sin(seedTRef.current * 1.7) * 3.1;
for (let j = 1; j <= N; j++) {
for (let i = 1; i <= N; i++) {
const idx = IX(i, j);
const sw = gnoise(i * 0.31 + h, j * 0.27 - h * 0.6) + gnoise(i * 0.13 - h * 0.4, j * 0.19 + h * 0.8) * 0.5;
uu0[idx] += sw * WIND * dt * Math.min(1, dd0[idx] * 0.9);
}
}
advect("u", dt); u0.current.set(u.current);
advect("v", dt); v0.current.set(v.current);
advect("d", dt);
diffuse("d", 0.0006, dt);
bounds();
const dd2 = d.current, uu2 = u.current, vv2 = v.current;
for (let m = 0; m < SIZE; m++) { dd2[m] *= 0.998; uu2[m] *= 0.988; vv2[m] *= 0.988; }
seedTRef.current += dt;
stepCountRef.current++;
}
function render(): void {
const ctx = ctxRef.current, cv = cvRef.current;
if (!ctx || !cv) return;
const img = ctx.createImageData(cv.width, cv.height);
const px = img.data;
const cellW = cv.width / N, cellH = cv.height / N;
const pal = palRef.current;
const dd = d.current;
for (let py = 0; py < cv.height; py++) {
const gy = Math.min(N - 1, (py / cellH) | 0);
for (let pxx = 0; pxx < cv.width; pxx++) {
const gx = Math.min(N - 1, (pxx / cellW) | 0);
const q = dd[IX(gx + 1, gy + 1)];
const o = (py * cv.width + pxx) * 4;
if (q <= 0.012) { px[o] = 23; px[o + 1] = 20; px[o + 2] = 26; px[o + 3] = 255; continue; }
const band = q > 0.85 ? 2 : q > 0.34 ? 1 : 0;
const c = pal[band];
const rr = parseInt(c.slice(1, 3), 16), gg = parseInt(c.slice(3, 5), 16), bb = parseInt(c.slice(5, 7), 16);
const a = Math.min(1, q * 0.95);
px[o] = 23 + (rr - 23) * a; px[o + 1] = 20 + (gg - 20) * a; px[o + 2] = 26 + (bb - 26) * a; px[o + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function frame(): void {
step(1 / 30);
render();
rafRef.current = requestAnimationFrame(frame);
}
function pointTo(e: PtrEvt): { gx: number; gy: number } | null {
const field = fieldRef.current;
if (!field) return null;
const r = field.getBoundingClientRect();
const gx = 1 + (e.clientX - r.left) / r.width * (N - 1);
const gy = 1 + (e.clientY - r.top) / r.height * (N - 1);
return { gx, gy };
}
function onDown(e: PtrEvt): void {
const p = pointTo(e);
if (!p) return;
puff(p.gx, p.gy, 2.0);
if (reduced()) render();
}
function onMove(e: PtrEvt): void {
if ((e.pressure ?? 0) === 0 && (e.buttons ?? 0) === 0) return;
const p = pointTo(e);
if (!p) return;
puff(p.gx, p.gy, 0.5);
if (reduced()) render();
}
function onKey(e: KeyEvt): void {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
puff(N * 0.5, N * 0.8, 2.0);
if (reduced()) render();
}
function onResize(): void { fit(); if (reduced()) render(); }
useEffect(() => {
const cv = cvRef.current;
if (!cv) return;
ctxRef.current = cv.getContext("2d", { willReadFrequently: true });
if (!ctxRef.current) return;
const root = rootRef.current;
if (root) {
const css = getComputedStyle(root);
const p0 = css.getPropertyValue("--dye-0").trim();
const p1 = css.getPropertyValue("--dye-1").trim();
const p2 = css.getPropertyValue("--dye-2").trim();
if (p0 && p1 && p2) palRef.current = [p0, p1, p2];
}
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
if (root) root.classList.add("is-still");
for (let k = 0; k < 40; k++) step(1 / 30);
render();
} else {
for (let w = 0; w < 90; w++) step(1 / 30);
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.root} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M236 128a108 108 0 0 1-216 0a100 100 0 0 1 100-100a92 92 0 0 1 92 92a84 84 0 0 1-84 84a76 76 0 0 1-76-76a68 68 0 0 1 68-68a60 60 0 0 1 60 60a52 52 0 0 1-52 52a44 44 0 0 1-44-44a36 36 0 0 1 36-36a28 28 0 0 1 28 28a20 20 0 0 1-20 20a12 12 0 0 1-12-12" opacity=".2" fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24Zm0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88Zm0-152a64 64 0 1 0 64 64a64.07 64.07 0 0 0-64-64Zm0 112a48 48 0 1 1 48-48a48.05 48.05 0 0 1-48 48Z"/></g></svg>
<span>아래에서 피어오른 연기가 밀도만큼 받는 부력으로 올라가며 옆 바람에 흔들립니다 — 화면을 눌러 연기를 피워 보세요</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label="연기 상승 실험판"
tabIndex={0}
onPointerDown={onDown}
onPointerMove={onMove}
onKeyDown={onKey}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true">32<span className={styles.badgeUnit}>격자</span></b>
</div>
</div>
</main>
);
}05 연기 상승 — 측정 누적면적 47.947% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 유체 시뮬레이션 9종 — 캔버스 연기·잉크 격자 계산 (WebGL 없이)
이 데모가 속한 글
유체 시뮬레이션 9종 — 캔버스 연기·잉크 격자 계산 (WebGL 없이)유체 시뮬레이션은 물이나 연기의 실제 나비에-스토크스 방정식을 그대로 푸는 게 아니라, 격자 위에 놓인 밀도·속도 배열을 매 프레임 이류하고 확산시키고 감쇠시켜 흐름을 흉내 내는 계산이다. 이 편은 라이브러리 없이 이 글의 데모 전부 보기


