流体シミュレーション9種 — canvas の煙・インク格子計算 (WebGLなし)
流体シミュレーションとは、ナビエ–ストークス方程式を厳密に解くことではなく、格子状に並べた密度と速度の配列を毎フレーム「移流・拡散・減衰」させて流れを模す計算のことだ。この記事では canvas 2D コンテキストだけで、32×32 の Float32Array 格子1枚から9つのシーンを作る。
01 fluid-stable-advect
02 fluid-diffuse-blur
03 fluid-curl-swirl
04 fluid-ink-bloom
05 fluid-smoke-rise
06 fluid-vortex-decay
07 fluid-dye-mix
08 fluid-pointer-stir
09 fluid-density-field
自動再生 · タイルを押すとその項目へ · すべて1つのzip
なぜこの9種を、この順番で並べたのか
本記事の軸は、canvas シーンエフェクト9種のピクセル単位の屈折・投影でも、canvas パーティクルエフェクト9種の粒子一つひとつの散らばりでもない。毎フレーム Float32Array の格子に速度(u, v)と密度(d)を保存し、格子全体を移流させ、拡散させ、減衰させるオイラー的な Stable Fluids 系の計算こそが、この9種の正体だ。粒子を積み上げるわけでも、波動方程式で表面を屈折させるわけでもなく、各セルの数字2〜3個をループで更新しているだけである。
ボード01はそのループの骨格(後方移流)だけを見せ、02は隣接セルと値を混ぜる拡散の1ステップを取り出す。03は格子近似が削り落とした回転を計算で戻し、04・05は01の骨格にクリック注入と浮力を乗せた応用だ。06は初期条件を1つだけ植えて減衰を観察する逆方向の実験、07はチャンネルを3つに広げたバリエーション、08は人の手が直接力を入れるインタラクション、09は計算結果そのものではなく、結果を画面へどう拡大して描くかという問題を扱う。順に見ていくと、流体シミュレーションが移流・拡散・渦度・境界という独立した部品に分解できることがわかる。
01安定流体の移流
何が見えるか
速度場が自分自身を運ぶ自己移流と、密度を運ぶ物質移流が1つの画面で重なって回る — 注目するセルが前フレームのどこから来たかを速度をさかのぼってサンプリングする後方移流(backtrace)が、時間刻みを大きくしても値が発散しない安定性の鍵である。ヒーロー背景のゆっくり回る流体に使える。

<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="M237.94 107.19l-89.13-89.13a20 20 0 0 0-28.28 0l-89.13 89.13a20 20 0 0 0 0 28.28l89.13 89.13a20 20 0 0 0 28.28 0l89.13-89.13a20 20 0 0 0 0-28.28ZM167 143.46a8 8 0 0 1-11.4 0L128 115.86l-27.6 27.6a8 8 0 1 1-11.4-11.32l33.2-33.46a8 8 0 0 1 11.4 0l33.2 33.46a8 8 0 0 1 .2 11.32Z" opacity=".2"/><path d="M243.54 101.75L154.4 12.61a28 28 0 0 0-39.66 0L25.6 101.75a28 28 0 0 0 0 39.66l89.14 89.14a28 28 0 0 0 39.66 0l89.14-89.14a28 28 0 0 0 0-39.66Zm-11.4 28.26l-89.13 89.14a12 12 0 0 1-17 0L36.9 130a12 12 0 0 1 0-17L126 23.87a12 12 0 0 1 17 0l89.14 89.14a12 12 0 0 1 0 16.98Zm-64.76-83.75a8 8 0 0 0-11.71 0l-33.65 33.87a4 4 0 0 1-5.71 0L82.49 100.1a8 8 0 1 0-11.4 11.24l33.51 33.9a20 20 0 0 0 28.49 0l33.51-33.9a8 8 0 0 0 .18-12.02Z"/></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장을 찍는다(012·077 선례).
// 염료 팔레트는 파스텔(채도 0.50 이하)로 직접 잡는다 — 이 편은 기능색 편이 아니므로 BD26 기준을 처음부터 지킨다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.fa {
// 염료 세 층 — 파스텔 하늘색(옅은) → 파스텔 라일락(중간) → 파스텔 산호(진한). 잉크 무대 위 대비 7.6~13.0
--dye-0: #9ac2df;
--dye-1: #b19edb;
--dye-2: #e0a3b8;
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: #9ac2df; }
.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 #9ac2df; 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: #9ac2df;
--dye-1: #b19edb;
--dye-2: #e0a3b8;
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: #9ac2df;
}
.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 #9ac2df;
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;
}
}// 안정 유체 이류(semi-Lagrangian) — 다음 프레임을 내다보지 않고, 지금 칸의 값이
// 한 걸음 전 어디에서 왔는지 속도를 거슬러 샘플링한다. 시간 간격이 커져도 폭발하지 않는다.
// 속도장이 자기 자신을 옮기는 자기 이류와 염료 밀도를 옮기는 물질 이류를 한 프레임에 겹쳐 돌린다.
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();
// N — 格子 한 변 칸 수. 480px 무대에 32칸이면 한 칸 15px, 60fps 안에 계산이 들어온다
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 reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, raf = 0, seedT = 0;
var stirring = false;
// 화면 픽셀 → 格子 좌표 (0..1) 로 바꾸는 비율
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
}
// 후방 이류 — 칸 (x,y) 의 새 값을, 속도를 dt 만큼 거슬러 간 좌표의 옛 값으로 채운다.
// 좌표는 칸 배열 범위로 가두고(경계 밖 샘플링 방지) 네 이웃의 면적 가중평균(bilinear)로 읽는다.
// 셀 하나가 차지하는 공간은 1/N 이다 — 속도(칸/초) × dt(초) × N 이 되짚는 칸 수가 된다.
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)]);
}
}
}
// 경계 — 벽 반사 근사. 유출 속도를 0 으로 두고 밀도는 감쇠시킨다
function bounds() {
var n2 = N + 2;
for (var i = 1; i <= N; i++) {
d[IX(i, 0)] = d[IX(i, 1)] * 0.5; d[IX(i, N + 1)] = d[IX(i, N)] * 0.5;
u[IX(i, 0)] = u[IX(i, 1)] * 0.5; u[IX(i, N + 1)] = u[IX(i, N)] * 0.5;
v[IX(i, 0)] = v[IX(i, 1)] * 0.5; v[IX(i, N + 1)] = v[IX(i, N)] * 0.5;
}
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.5; u[IX(N + 1, j)] = u[IX(N, j)] * 0.5;
v[IX(0, j)] = v[IX(1, j)] * 0.5; v[IX(N + 1, j)] = v[IX(N, j)] * 0.5;
}
}
// 미리보기 주입원 — 시드 고정. 잎새 모양 두 바퀴를 그리는 속도 소스와 궤적을 따라 얹히는 염료.
// seedT 로 결정적: 같은 편의 gif·poster 는 언제나 같은 흐름이 담긴다.
function inject(t) {
var cx = N * 0.5 + Math.sin(t * 0.9) * N * 0.22;
var cy = N * 0.55 + Math.sin(t * 1.7) * N * 0.16;
var dx = Math.cos(t * 0.9) * 0.9 * N * 0.22, dy = Math.cos(t * 1.7) * 1.7 * N * 0.16 * 0.35;
var amp = 42;
var r = 2.6;
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 * 3.2) * amp;
var idx = IX(Math.round(cx + i), Math.round(cy + j));
u0[idx] += dx * f * 0.06;
v0[idx] += dy * f * 0.06;
var hue = (t * 0.22) % 1;
var amt = Math.exp(-q * 2.2) * 0.9;
d0[idx] += amt * (hue < 0.5 ? 1 : 0.55);
}
}
}
function step(dt) {
u0.set(u); v0.set(v); d0.set(d);
inject(seedT);
advect('u', dt); u0.set(u);
advect('v', dt); v0.set(v);
advect('d', dt);
bounds();
for (var k = 0; k < SIZE; k++) d[k] *= 0.995; // 밀도는 천천히 죽는다 — 화면이 꽉 찬 채 굳지 않게
seedT += dt;
}
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) { px[o] = 23; px[o + 1] = 20; px[o + 2] = 26; px[o + 3] = 255; continue; }
var band = q > 0.62 ? 2 : (q > 0.3 ? 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);
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);
}
// 사람이 휘젓기 — 포인터 이동량을 그 칸 주변에 지수 반경으로 얹는다
function splat(gx, gy, fx, fy, amt) {
var r = 3;
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 * 3) * amt;
var idx = IX(gx + i, gy + j);
if (gx + i < 1 || gx + i > N || gy + j < 1 || gy + j > N) continue;
u[idx] += fx * f; v[idx] += fy * f;
d[idx] = Math.min(2.2, d[idx] + f * 0.7);
}
}
}
var last = null;
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
var gx = 1 + Math.round((e.clientX - r.left) / r.width * (N - 1));
var gy = 1 + Math.round((e.clientY - r.top) / r.height * (N - 1));
if (last) {
var fx = (e.clientX - last.x) * 0.14, fy = (e.clientY - last.y) * 0.14;
splat(gx, gy, fx, fy, Math.min(1.6, Math.hypot(fx, fy)));
} else {
splat(gx, gy, 0, 0, 0.8);
}
last = { x: e.clientX, y: e.clientY };
if (reduce) render();
});
field.addEventListener('pointerleave', function () { last = null; stirring = false; });
field.addEventListener('keydown', function (e) {
var dir = { ArrowLeft: [-2, 0], ArrowRight: [2, 0], ArrowUp: [0, -2], ArrowDown: [0, 2] }[e.key];
if (!dir) return;
e.preventDefault();
var gx = N >> 1, gy = N >> 1;
splat(gx, gy, dir[0], dir[1], 1.2);
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 {
raf = requestAnimationFrame(frame);
}// StableAdvect — vanilla 판(run/308/demo/01-fluid-stable-advect/index.html) 의 React 포트.
// 안정 유체(Stable Fluids) semi-Lagrangian 후방 이류. 격자 필드는 ref 상자 안에서만 산다 —
// React 상태로 두면 리렌더가 매 프레임 일어나 60fps 예산을 못 맞춘다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./StableAdvect.module.scss";
export type StableAdvectProps = {
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 };
const N = 32;
const SIZE = (N + 2) * (N + 2);
function IX(x: number, y: number): number { return x + (N + 2) * y; }
export default function StableAdvect({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: StableAdvectProps) {
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(["#9ac2df", "#b19edb", "#e0a3b8"]) 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 sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const seedTRef = useRef(0) as Box<number>;
const lastRef = useRef<{ x: number; y: number } | null>(null) as Box<{ x: number; y: number } | null>;
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();
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
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 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.5; dd[IX(i, N + 1)] = dd[IX(i, N)] * 0.5;
uu[IX(i, 0)] = uu[IX(i, 1)] * 0.5; uu[IX(i, N + 1)] = uu[IX(i, N)] * 0.5;
vv[IX(i, 0)] = vv[IX(i, 1)] * 0.5; vv[IX(i, N + 1)] = vv[IX(i, N)] * 0.5;
}
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.5; uu[IX(N + 1, j)] = uu[IX(N, j)] * 0.5;
vv[IX(0, j)] = vv[IX(1, j)] * 0.5; vv[IX(N + 1, j)] = vv[IX(N, j)] * 0.5;
}
}
function inject(t: number): void {
const cx = N * 0.5 + Math.sin(t * 0.9) * N * 0.22;
const cy = N * 0.55 + Math.sin(t * 1.7) * N * 0.16;
const dx = Math.cos(t * 0.9) * 0.9 * N * 0.22, dy = Math.cos(t * 1.7) * 1.7 * N * 0.16 * 0.35;
const amp = 42, r = 2.6;
const u0v = u0.current, v0v = v0.current, d0v = d0.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 * 3.2) * amp;
const idx = IX(Math.round(cx + i), Math.round(cy + j));
u0v[idx] += dx * f * 0.06;
v0v[idx] += dy * f * 0.06;
const hue = (t * 0.22) % 1;
const amt = Math.exp(-q * 2.2) * 0.9;
d0v[idx] += amt * (hue < 0.5 ? 1 : 0.55);
}
}
}
function step(dt: number): void {
u0.current.set(u.current); v0.current.set(v.current); d0.current.set(d.current);
inject(seedTRef.current);
advect("u", dt); u0.current.set(u.current);
advect("v", dt); v0.current.set(v.current);
advect("d", dt);
bounds();
const dd = d.current;
for (let k = 0; k < SIZE; k++) dd[k] *= 0.995;
seedTRef.current += dt;
}
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) { px[o] = 23; px[o + 1] = 20; px[o + 2] = 26; px[o + 3] = 255; continue; }
const band = q > 0.62 ? 2 : q > 0.3 ? 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);
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 splat(gx: number, gy: number, fx: number, fy: number, amt: number): void {
const r = 3;
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;
if (gx + i < 1 || gx + i > N || gy + j < 1 || gy + j > N) continue;
const f = Math.exp(-q * 3) * amt;
const idx = IX(gx + i, gy + j);
uu[idx] += fx * f; vv[idx] += fy * f;
dd[idx] = Math.min(2.2, dd[idx] + f * 0.7);
}
}
}
function onMove(e: PtrEvt): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
const gx = 1 + Math.round((e.clientX - r.left) / r.width * (N - 1));
const gy = 1 + Math.round((e.clientY - r.top) / r.height * (N - 1));
const last = lastRef.current;
if (last) {
const fx = (e.clientX - last.x) * 0.14, fy = (e.clientY - last.y) * 0.14;
splat(gx, gy, fx, fy, Math.min(1.6, Math.hypot(fx, fy)));
} else {
splat(gx, gy, 0, 0, 0.8);
}
lastRef.current = { x: e.clientX, y: e.clientY };
if (reduced()) render();
}
function onLeave(): void { lastRef.current = null; }
function onKey(e: KeyEvt): void {
const dir: Record<string, number[]> = { ArrowLeft: [-2, 0], ArrowRight: [2, 0], ArrowUp: [0, -2], ArrowDown: [0, 2] };
const dd2 = dir[e.key];
if (!dd2) return;
e.preventDefault();
splat(N >> 1, N >> 1, dd2[0], dd2[1], 1.2);
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 {
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="M237.94 107.19l-89.13-89.13a20 20 0 0 0-28.28 0l-89.13 89.13a20 20 0 0 0 0 28.28l89.13 89.13a20 20 0 0 0 28.28 0l89.13-89.13a20 20 0 0 0 0-28.28ZM167 143.46a8 8 0 0 1-11.4 0L128 115.86l-27.6 27.6a8 8 0 1 1-11.4-11.32l33.2-33.46a8 8 0 0 1 11.4 0l33.2 33.46a8 8 0 0 1 .2 11.32Z" opacity=".2"/><path d="M243.54 101.75L154.4 12.61a28 28 0 0 0-39.66 0L25.6 101.75a28 28 0 0 0 0 39.66l89.14 89.14a28 28 0 0 0 39.66 0l89.14-89.14a28 28 0 0 0 0-39.66Zm-11.4 28.26l-89.13 89.14a12 12 0 0 1-17 0L36.9 130a12 12 0 0 1 0-17L126 23.87a12 12 0 0 1 17 0l89.14 89.14a12 12 0 0 1 0 16.98Zm-64.76-83.75a8 8 0 0 0-11.71 0l-33.65 33.87a4 4 0 0 1-5.71 0L82.49 100.1a8 8 0 1 0-11.4 11.24l33.51 33.9a20 20 0 0 0 28.49 0l33.51-33.9a8 8 0 0 0 .18-12.02Z"/></g></svg>
<span>각 칸은 물결을 되짚어 이전 값을 가져옵니다 — 커서를 얹으면 흐름이 구부러집니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label="유체 이류 실험판"
tabIndex={0}
onPointerMove={onMove}
onPointerLeave={onLeave}
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>
);
}01 · 安定流体の移流 — 計測 累積面積 11.342% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
レンダリング実測は、累積面積 11.342% · 強度 155.3 · 動いたフレーム 23/23 である。
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, i1 = i0 + 1;
if (y < 0.5) y = 0.5; else if (y > N + 0.5) y = N + 0.5;
var j0 = y | 0, j1 = j0 + 1;
// 4近傍の双線形補間で滑らかにサンプリング
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)]);
}
}
}
02拡散のにじみ
何が見えるか
各セルが隣接4セルと値を少しずつ混ぜる拡散の1ステップが、インクが水ににじむようなスムージングである — 拡散係数を大きくするとデカルコマニーのように柔らかく広がり、0に近づけると境界が鮮明に残る。水彩背景やテクスチャーセクションに使える。

<main class="stage stage--paper">
<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="M208 144a80 80 0 0 1-160 0c0-72 80-128 80-128s80 56 80 128" opacity=".2"/><path d="M174 47.75a254.2 254.2 0 0 0-41.45-38.3a8 8 0 0 0-9.18 0A254.2 254.2 0 0 0 82 47.75C54.51 79.32 40 112.6 40 144a88 88 0 0 0 176 0c0-31.4-14.51-64.68-42-96.25M128 216a72.08 72.08 0 0 1-72-72c0-57.23 55.47-105 72-118c16.53 13 72 60.75 72 118a72.08 72.08 0 0 1-72 72m55.89-62.66a57.6 57.6 0 0 1-46.56 46.55a9 9 0 0 1-1.33.11a8 8 0 0 1-1.32-15.89c16.57-2.79 30.63-16.85 33.44-33.45a8 8 0 0 1 15.78 2.68Z"/></g></svg>
<span>毎ステップ隣接4マスと値を混ぜる拡散が、紙にインクをにじませます — 押して雫を落としてみましょう</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">6<span class="fa__badgeUnit">緩和</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 확산 번짐 — 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판·배지만 맡는다.
// @keyframes 를 하나도 두지 않는다 — 렌더러가 CSS 시계를 고정하지 않고 실시간으로 24장을 찍는다(01·012·077 선례).
// 종이(paper) 무대 위 염료는 밝은 배경에서 보이는 어두운·낮은 채도 파스텔 세 층으로 잡는다 —
// 회보라(짙음) → 청회색(중간) → 연회색(옅음). 채도 전부 0.21 이하로 BD26 파스텔 게이트를 처음부터 지킨다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.fa {
// 염료 세 층 — 짙은 회보라 → 청회색 → 연회색. 흰 종이 위 대비 6.0~9.9
--dye-0: #8b7f95;
--dye-1: #5c4f66;
--dye-2: #2d2836;
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-ink;
}
.fa__ico { width: 16px; height: 16px; flex: 0 0 auto; color: #5c4f66; }
.fa__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $stage-paper;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .12);
cursor: crosshair; touch-action: none;
}
.fa__field:focus-visible { outline: 2px solid #5c4f66; 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($stage-paper, .82);
color: $subject-ink; font-weight: 700; font-size: 11px;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .1);
}
.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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fa {
--dye-0: #8b7f95;
--dye-1: #5c4f66;
--dye-2: #2d2836;
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: #17141a;
}
.fa__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #5c4f66;
}
.fa__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.12);
cursor: crosshair;
touch-action: none;
}
.fa__field:focus-visible {
outline: 2px solid #5c4f66;
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(255, 255, 255, 0.82);
color: #17141a;
font-weight: 700;
font-size: 11px;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.1);
}
.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;
}
}// 확산(diffuse) — 이류처럼 값을 옮기지 않는다. 셀 하나가 이웃 네 칸과 값을 조금씩 섞는 일을
// 매 걸음 반복하면(가우스-사이델 緩和), 진한 점 하나가 몇 걸음 만에 부드러운 얼룩으로 퍼진다.
// 속도장 없이 밀도(d) 하나만으로 돌아간다 — 이류 비중을 없애 확산 자체가 움직임의 전부가 되게 한다.
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();
// N — 格子 한 변 칸 수. 480px 무대에 32칸이면 한 칸 15px, 緩和 6회를 60fps 예산 안에 담는다
var N = 32;
var SIZE = (N + 2) * (N + 2);
var IX = function (x, y) { return x + (N + 2) * y; };
var d = new Float32Array(SIZE), d0 = new Float32Array(SIZE);
var ITER = 6; // 가우스-사이델 緩和 반복 횟수 — 4~10회 권장, 6회면 한 걸음에 충분히 번진다
var DIFF = 0.02; // 확산 계수 — 클수록 데칼코마니처럼 빨리 퍼지고, 0 에 가까우면 선명한 경계가 남는다
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);
}
// 경계 — 종이 가장자리에서 잉크가 반사돼 옅게 남는다
function bounds() {
for (var i = 1; i <= N; i++) {
d[IX(i, 0)] = d[IX(i, 1)] * 0.5; d[IX(i, N + 1)] = d[IX(i, N)] * 0.5;
}
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;
}
}
// 확산 한 걸음 — x0(방금 주입한 소스)를 목표로, x(이전 값)를 이웃 네 칸과 섞어가며 완화한다.
// a 가 클수록 이웃의 영향이 커져 한 걸음에 더 멀리 번진다. 경계는 반복마다 다시 잡는다.
function diffuse(dt) {
var a = dt * DIFF * N * N;
for (var k = 0; k < ITER; k++) {
for (var j = 1; j <= N; j++) {
for (var i = 1; i <= N; i++) {
d[IX(i, j)] = (d0[IX(i, j)] + a * (d[IX(i - 1, j)] + d[IX(i + 1, j)] + d[IX(i, j - 1)] + d[IX(i, j + 1)])) / (1 + 4 * a);
}
}
bounds();
}
}
// 미리보기 주입원 — 시드 고정. 종이 위를 천천히 오가는 붓끝이 방울을 떨어뜨린다.
// seedT 로 결정적: 같은 편의 gif·poster 는 언제나 같은 번짐이 담긴다.
function inject(t) {
var cx = N * 0.5 + Math.sin(t * 0.55) * N * 0.28;
var cy = N * 0.5 + Math.cos(t * 0.4) * N * 0.24;
var r = 2.2;
var amt = 0.85 + Math.sin(t * 2.3) * 0.15;
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 * 3) * amt;
var idx = IX(Math.round(cx + i), Math.round(cy + j));
d0[idx] = Math.min(2, d0[idx] + f);
}
}
}
function step(dt) {
d0.set(d);
inject(seedT);
diffuse(dt);
for (var k = 0; k < SIZE; k++) d[k] *= 0.992; // 아주 천천히 마른다 — 화면이 꽉 찬 채 굳지 않게
seedT += dt;
}
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.002) { px[o] = 255; px[o + 1] = 255; px[o + 2] = 255; px[o + 3] = 255; continue; }
var band = q > 0.62 ? 2 : (q > 0.28 ? 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);
px[o] = 255 + (rr - 255) * a; px[o + 1] = 255 + (gg - 255) * a; px[o + 2] = 255 + (bb - 255) * a; px[o + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function frame() {
step(1 / 30);
render();
raf = requestAnimationFrame(frame);
}
// 사람이 찍기 — 눌러 둔 자리에 방울을 계속 떨어뜨린다. 확산은 다음 걸음부터 저절로 번진다.
function splat(gx, gy, amt) {
var r = 3;
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;
if (gx + i < 1 || gx + i > N || gy + j < 1 || gy + j > N) continue;
var f = Math.exp(-q * 3) * amt;
var idx = IX(gx + i, gy + j);
d[idx] = Math.min(2.2, d[idx] + f);
}
}
}
var down = false;
function pointAt(e) {
var r = field.getBoundingClientRect();
var gx = 1 + Math.round((e.clientX - r.left) / r.width * (N - 1));
var gy = 1 + Math.round((e.clientY - r.top) / r.height * (N - 1));
splat(gx, gy, 0.9);
if (reduce) render();
}
field.addEventListener('pointerdown', function (e) { down = true; pointAt(e); });
field.addEventListener('pointermove', function (e) { if (down) pointAt(e); });
window.addEventListener('pointerup', function () { down = false; });
field.addEventListener('pointerleave', function () { down = false; });
field.addEventListener('keydown', function (e) {
var dir = { ArrowLeft: [-2, 0], ArrowRight: [2, 0], ArrowUp: [0, -2], ArrowDown: [0, 2] }[e.key];
if (!dir) return;
e.preventDefault();
splat((N >> 1) + dir[0], (N >> 1) + dir[1], 1.1);
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 {
raf = requestAnimationFrame(frame);
}// DiffuseBlur — vanilla 판(run/308/demo/02-fluid-diffuse-blur/index.html) 의 React 포트.
// 속도장 없이 밀도(d) 하나만으로 가우스-사이델 완화 6회를 매 걸음 돌려 확산 자체가 움직임의 전부가 되게 한다.
// 격자 필드는 ref 상자 안에서만 산다 — React 상태로 두면 리렌더가 매 프레임 일어나 60fps 예산을 못 맞춘다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./DiffuseBlur.module.scss";
export type DiffuseBlurProps = {
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; buttons?: number };
const N = 32;
const SIZE = (N + 2) * (N + 2);
function IX(x: number, y: number): number { return x + (N + 2) * y; }
const ITER = 6;
const DIFF = 0.02;
export default function DiffuseBlur({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#17141a",
}: DiffuseBlurProps) {
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(["#8b7f95", "#5c4f66", "#2d2836"]) as Box<string[]>;
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 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 bounds(): void {
const dd = d.current;
for (let i = 1; i <= N; i++) {
dd[IX(i, 0)] = dd[IX(i, 1)] * 0.5; dd[IX(i, N + 1)] = dd[IX(i, N)] * 0.5;
}
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;
}
}
function diffuse(dt: number): void {
const dd = d.current, dd0 = d0.current;
const a = dt * DIFF * N * N;
for (let k = 0; k < ITER; k++) {
for (let j = 1; j <= N; j++) {
for (let i = 1; i <= N; i++) {
dd[IX(i, j)] = (dd0[IX(i, j)] + a * (dd[IX(i - 1, j)] + dd[IX(i + 1, j)] + dd[IX(i, j - 1)] + dd[IX(i, j + 1)])) / (1 + 4 * a);
}
}
bounds();
}
}
function inject(t: number): void {
const cx = N * 0.5 + Math.sin(t * 0.55) * N * 0.28;
const cy = N * 0.5 + Math.cos(t * 0.4) * N * 0.24;
const r = 2.2;
const amt = 0.85 + Math.sin(t * 2.3) * 0.15;
const dd0 = d0.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 * 3) * amt;
dd0[IX(Math.round(cx + i), Math.round(cy + j))] = Math.min(2, dd0[IX(Math.round(cx + i), Math.round(cy + j))] + f);
}
}
}
function step(dt: number): void {
d0.current.set(d.current);
inject(seedTRef.current);
diffuse(dt);
const dd = d.current;
for (let k = 0; k < SIZE; k++) dd[k] *= 0.992;
seedTRef.current += dt;
}
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.002) { px[o] = 255; px[o + 1] = 255; px[o + 2] = 255; px[o + 3] = 255; continue; }
const band = q > 0.62 ? 2 : q > 0.28 ? 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);
px[o] = 255 + (rr - 255) * a; px[o + 1] = 255 + (gg - 255) * a; px[o + 2] = 255 + (bb - 255) * a; px[o + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function frame(): void {
step(1 / 30);
render();
rafRef.current = requestAnimationFrame(frame);
}
function splat(gx: number, gy: number, amt: number): void {
const r = 3;
const 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;
if (gx + i < 1 || gx + i > N || gy + j < 1 || gy + j > N) continue;
const f = Math.exp(-q * 3) * amt;
const idx = IX(gx + i, gy + j);
dd[idx] = Math.min(2.2, dd[idx] + f);
}
}
}
function pointAt(e: PtrEvt): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
const gx = 1 + Math.round((e.clientX - r.left) / r.width * (N - 1));
const gy = 1 + Math.round((e.clientY - r.top) / r.height * (N - 1));
splat(gx, gy, 0.9);
if (reduced()) render();
}
function onKey(e: KeyEvt): void {
const dir = ({ ArrowLeft: [-2, 0], ArrowRight: [2, 0], ArrowUp: [0, -2], ArrowDown: [0, 2] } as Record<string, number[]>)[e.key];
if (!dir) return;
e.preventDefault();
splat((N >> 1) + dir[0], (N >> 1) + dir[1], 1.1);
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 {
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="M208 144a80 80 0 0 1-160 0c0-72 80-128 80-128s80 56 80 128" opacity=".2"/><path d="M174 47.75a254.2 254.2 0 0 0-41.45-38.3a8 8 0 0 0-9.18 0A254.2 254.2 0 0 0 82 47.75C54.51 79.32 40 112.6 40 144a88 88 0 0 0 176 0c0-31.4-14.51-64.68-42-96.25M128 216a72.08 72.08 0 0 1-72-72c0-57.23 55.47-105 72-118c16.53 13 72 60.75 72 118a72.08 72.08 0 0 1-72 72m55.89-62.66a57.6 57.6 0 0 1-46.56 46.55a9 9 0 0 1-1.33.11a8 8 0 0 1-1.32-15.89c16.57-2.79 30.63-16.85 33.44-33.45a8 8 0 0 1 15.78 2.68Z"/></g></svg>
<span>매 걸음 이웃 넷과 값을 섞는 확산이 잉크를 종이에 번지게 합니다 — 눌러서 방울을 떨어뜨려 보세요</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label="확산 번짐 실험판"
tabIndex={0}
onPointerDown={(e: PtrEvt) => pointAt(e)}
onPointerMove={(e: PtrEvt) => { if ((e.buttons ?? 0) !== 0) pointAt(e); }}
onKeyDown={onKey}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true">6<span className={styles.badgeUnit}>완화</span></b>
</div>
</div>
</main>
);
}02 · 拡散のにじみ — 計測 累積面積 45.362% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
レンダリング実測は、累積面積 45.362% · 強度 31.5 · 動いたフレーム 23/23 である。
function diffuse(dt) {
var a = dt * DIFF * N * N;
// ガウス=ザイデル風の反復緩和で少しずつ混ぜる
for (var k = 0; k < ITER; k++) {
for (var j = 1; j <= N; j++) {
for (var i = 1; i <= N; i++) {
d[IX(i, j)] = (d0[IX(i, j)] + a * (d[IX(i - 1, j)] + d[IX(i + 1, j)] +
d[IX(i, j - 1)] + d[IX(i, j + 1)])) / (1 + 4 * a);
}
}
bounds(); // 境界セルの値を毎反復で更新
}
}
03カール(回転)の渦
何が見えるか
速度場の回転成分(curl)を取り出して接線方向の力として足し戻すと、格子近似が削り落とした小さな渦が復活する — 濃度の境目に糸を巻くような螺旋ができ、油の滴や渦巻き背景の表現に向く。渦が自発的に発生するのではなく、計算が意図的に回転を補っている点がポイントだ。

<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="M240 144h-32a80 80 0 0 1-80 80a88 88 0 0 1-88-88a96 96 0 0 1 96-96a104 104 0 0 1 104 104" opacity=".2"/><path d="M248 144a8 8 0 0 1-16 0a96.11 96.11 0 0 0-96-96a88.1 88.1 0 0 0-88 88a80.09 80.09 0 0 0 80 80a72.08 72.08 0 0 0 72-72a64.07 64.07 0 0 0-64-64a56.06 56.06 0 0 0-56 56a48.05 48.05 0 0 0 48 48a40 40 0 0 0 40-40a32 32 0 0 0-32-32a24 24 0 0 0-24 24a16 16 0 0 0 16 16a8 8 0 0 0 8-8a8 8 0 0 1 0-16a16 16 0 0 1 16 16a24 24 0 0 1-24 24a32 32 0 0 1-32-32a40 40 0 0 1 40-40a48.05 48.05 0 0 1 48 48a56.06 56.06 0 0 1-56 56a64.07 64.07 0 0 1-64-64a72.08 72.08 0 0 1 72-72a80.09 80.09 0 0 1 80 80a88.1 88.1 0 0 1-88 88a96.11 96.11 0 0 1-96-96A104.11 104.11 0 0 1 136 32a112.12 112.12 0 0 1 112 112"/></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">12<span class="fa__badgeUnit">強化</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 컬링 소용돌이 — 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판·배지만 맡는다.
// @keyframes 를 하나도 두지 않는다 — 렌더러가 CSS 시계를 고정하지 않고 실시간으로 24장을 찍는다(01·012·077 선례).
// 염료 팔레트는 항목01 과 같은 파스텔 세 층(채도 0.50 이하)을 잉크 무대 위에 그대로 쓴다 — 대비 7.6~13.0.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.fa {
// 염료 세 층 — 파스텔 하늘색(옅은) → 파스텔 라일락(중간) → 파스텔 산호(진한). 잉크 무대 위 대비 7.6~13.0
--dye-0: #9ac2df;
--dye-1: #b19edb;
--dye-2: #e0a3b8;
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: #9ac2df; }
.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 #9ac2df; 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: #9ac2df;
--dye-1: #b19edb;
--dye-2: #e0a3b8;
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: #9ac2df;
}
.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 #9ac2df;
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;
}
}// 컬링 소용돌이(vorticity confinement) — 저해상 격자는 수치 점성 때문에 회전이 금방 눌려 죽는다.
// 매 걸음 속도장의 컬(curl, 회전 성분)을 중심차분으로 구하고, |curl| 의 기울기를 따라
// 회전 축 쪽으로 힘을 되먹여 죽어가던 소용돌이를 다시 살린 뒤 이류로 옮긴다.
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();
// N — 格子 한 변 칸 수. 480px 무대에 32칸이면 한 칸 15px, 컬 계산 + 이류를 60fps 안에 담는다
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 curl = new Float32Array(SIZE);
var EPS = 1e-5; // 기울기 정규화 분모 0 방지
var VORT = 12; // 보텍시티 強化 계수 — 클수록 소용돌이가 더 세게 되살아난다
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);
}
// 컬 계산 — 중심차분으로 각 칸의 회전 강도(스칼라)를 구한다. 절반 스케일을 곱해 dt 와 맞춘다.
// 이류 직전의 u0·v0(주입 직후)에서 읽는다 — 強化 힘이 같은 걸음 이류로 옮겨지게.
// curl > 0 은 반시계, curl < 0 은 시계 방향 회전이다.
function calcCurl() {
for (var j = 1; j <= N; j++) {
for (var i = 1; i <= N; i++) {
curl[IX(i, j)] = 0.5 * ((v0[IX(i + 1, j)] - v0[IX(i - 1, j)]) - (u0[IX(i, j + 1)] - u0[IX(i, j - 1)]));
}
}
}
// 보텍시티 強化 — |curl| 이 커지는 방향(기울기)으로 힘을 더한다.
// 회전이 몰린 곳으로 밀어붙이므로, 눌려 죽던 소용돌이가 다시 골을 파 만다.
// 힘은 이류의 원본인 u0·v0 에 얹는다 — 이류 결과(u)에 더하면 다음 줄에서 통째로 덮여 사라진다.
function confine(dt) {
for (var j = 2; j < N; j++) {
for (var i = 2; i < N; i++) {
var idx = IX(i, j);
var gx = (Math.abs(curl[IX(i + 1, j)]) - Math.abs(curl[IX(i - 1, j)])) * 0.5;
var gy = (Math.abs(curl[IX(i, j + 1)]) - Math.abs(curl[IX(i, j - 1)])) * 0.5;
var len = Math.sqrt(gx * gx + gy * gy) + EPS;
gx /= len; gy /= len;
var c = curl[idx];
u0[idx] += gx * c * VORT * dt;
v0[idx] += gy * c * VORT * dt;
}
}
}
// 후방 이류 — 칸 (x,y) 의 새 값을, 속도를 dt 만큼 거슬러 간 좌표의 옛 값으로 채운다.
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)]);
}
}
}
// 경계 — 벽 반사 근사. 유출 속도를 0 으로 두고 밀도는 감쇠시킨다
function bounds() {
for (var i = 1; i <= N; i++) {
d[IX(i, 0)] = d[IX(i, 1)] * 0.5; d[IX(i, N + 1)] = d[IX(i, N)] * 0.5;
u[IX(i, 0)] = u[IX(i, 1)] * 0.5; u[IX(i, N + 1)] = u[IX(i, N)] * 0.5;
v[IX(i, 0)] = v[IX(i, 1)] * 0.5; v[IX(i, N + 1)] = v[IX(i, N)] * 0.5;
}
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.5; u[IX(N + 1, j)] = u[IX(N, j)] * 0.5;
v[IX(0, j)] = v[IX(1, j)] * 0.5; v[IX(N + 1, j)] = v[IX(N, j)] * 0.5;
}
}
// 미리보기 주입원 — 시드 고정. 서로 반대로 도는 회전 소스 두 개가 그 사이 염료 띠를 감아올린다.
// seedT 로 결정적: 같은 편의 gif·poster 는 언제나 같은 소용돌이가 담긴다.
function inject(t) {
var s1x = N * 0.32 + Math.sin(t * 0.4) * N * 0.04;
var s1y = N * 0.5 + Math.cos(t * 0.33) * N * 0.05;
var s2x = N * 0.68 + Math.sin(t * 0.31 + 2) * N * 0.04;
var s2y = N * 0.5 + Math.cos(t * 0.47 + 1) * N * 0.05;
var rv = 4.5; // 회전 소스 반경 — 넓게 깔아야 염료 띠 전체가 감긴다
var spin = 5.2 + Math.sin(t * 0.8) * 1.4;
for (var j = -rv; j <= rv; j++) {
for (var i = -rv; i <= rv; i++) {
var q = (i * i + j * j) / (rv * rv);
if (q > 1) continue;
var f = Math.exp(-q * 2.4);
// 접선 방향 속도 (-j, i) — 반지름 밖으로 갈수록 약해지는 회전 소스 두 개(부호 반대)
var idx1 = IX(Math.round(s1x + i), Math.round(s1y + j));
u0[idx1] += -j * f * spin * 0.3; v0[idx1] += i * f * spin * 0.3;
var idx2 = IX(Math.round(s2x + i), Math.round(s2y + j));
u0[idx2] += j * f * spin * 0.3; v0[idx2] += -i * f * spin * 0.3;
}
}
// 염료 띠 — 두 소용돌이를 잇는 길쭉한 밴드. 감기는 모양새가 프레임마다 자라난다
var mx = N * 0.5 + Math.sin(t * 0.6) * N * 0.06;
var my = N * 0.5 + Math.cos(t * 0.5) * N * 0.06;
var rx = N * 0.22, ry = N * 0.16;
for (var y = -Math.round(ry); y <= Math.round(ry); y++) {
for (var x = -Math.round(rx); x <= Math.round(rx); x++) {
var qq = (x * x) / (rx * rx) + (y * y) / (ry * ry);
if (qq > 1) continue;
var amt = Math.exp(-qq * 1.4) * 0.8;
var idxM = IX(Math.round(mx + x), Math.round(my + y));
d0[idxM] += amt * ((t * 0.22 % 1) < 0.5 ? 1 : 0.6);
}
}
}
function step(dt) {
u0.set(u); v0.set(v); d0.set(d);
inject(seedT);
calcCurl();
confine(dt);
advect('u', dt); u0.set(u);
advect('v', dt); v0.set(v);
advect('d', dt);
bounds();
for (var k = 0; k < SIZE; k++) d[k] *= 0.995; // 밀도는 천천히 죽는다 — 화면이 꽉 찬 채 굳지 않게
seedT += dt;
}
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) { px[o] = 23; px[o + 1] = 20; px[o + 2] = 26; px[o + 3] = 255; continue; }
var band = q > 0.62 ? 2 : (q > 0.3 ? 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);
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);
}
// 사람이 휘젓기 — 포인터 이동량을 그 칸 주변에 지수 반경으로 얹는다. 커서 궤적 자체가 새 컬원이 된다.
function splat(gx, gy, fx, fy, amt) {
var r = 3;
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;
if (gx + i < 1 || gx + i > N || gy + j < 1 || gy + j > N) continue;
var f = Math.exp(-q * 3) * amt;
var idx = IX(gx + i, gy + j);
u[idx] += fx * f; v[idx] += fy * f;
d[idx] = Math.min(2.2, d[idx] + f * 0.7);
}
}
}
var last = null;
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
var gx = 1 + Math.round((e.clientX - r.left) / r.width * (N - 1));
var gy = 1 + Math.round((e.clientY - r.top) / r.height * (N - 1));
if (last) {
var fx = (e.clientX - last.x) * 0.14, fy = (e.clientY - last.y) * 0.14;
splat(gx, gy, fx, fy, Math.min(1.6, Math.hypot(fx, fy)));
} else {
splat(gx, gy, 0, 0, 0.8);
}
last = { x: e.clientX, y: e.clientY };
if (reduce) render();
});
field.addEventListener('pointerleave', function () { last = null; });
field.addEventListener('keydown', function (e) {
var dir = { ArrowLeft: [-2, 0], ArrowRight: [2, 0], ArrowUp: [0, -2], ArrowDown: [0, 2] }[e.key];
if (!dir) return;
e.preventDefault();
splat(N >> 1, N >> 1, dir[0], dir[1], 1.2);
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);
}// CurlSwirl — vanilla 판(run/308/demo/03-fluid-curl-swirl/index.html) 의 React 포트.
// 저해상 격자의 수치 점성으로 눌려 죽는 소용돌이를 컬(vorticity) 기울기 방향 힘으로 되살린다.
// 격자 필드는 ref 상자 안에서만 산다 — React 상태로 두면 리렌더가 매 프레임 일어나 60fps 예산을 못 맞춘다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./CurlSwirl.module.scss";
export type CurlSwirlProps = {
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 };
const N = 32;
const SIZE = (N + 2) * (N + 2);
function IX(x: number, y: number): number { return x + (N + 2) * y; }
const EPS = 1e-5;
const VORT = 12;
export default function CurlSwirl({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#9ac2df",
}: CurlSwirlProps) {
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(["#9ac2df", "#b19edb", "#e0a3b8"]) 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 curl = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const seedTRef = useRef(0) as Box<number>;
const lastRef = useRef<{ x: number; y: number } | null>(null) as Box<{ x: number; y: number } | null>;
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 calcCurl(): void {
const uu0 = u0.current, vv0 = v0.current, cc = curl.current;
for (let j = 1; j <= N; j++) {
for (let i = 1; i <= N; i++) {
cc[IX(i, j)] = 0.5 * ((vv0[IX(i + 1, j)] - vv0[IX(i - 1, j)]) - (uu0[IX(i, j + 1)] - uu0[IX(i, j - 1)]));
}
}
}
function confine(dt: number): void {
const uu0 = u0.current, vv0 = v0.current, cc = curl.current;
for (let j = 2; j < N; j++) {
for (let i = 2; i < N; i++) {
const idx = IX(i, j);
let gx = (Math.abs(cc[IX(i + 1, j)]) - Math.abs(cc[IX(i - 1, j)])) * 0.5;
let gy = (Math.abs(cc[IX(i, j + 1)]) - Math.abs(cc[IX(i, j - 1)])) * 0.5;
const len = Math.sqrt(gx * gx + gy * gy) + EPS;
gx /= len; gy /= len;
const c = cc[idx];
uu0[idx] += gx * c * VORT * dt;
vv0[idx] += gy * c * VORT * dt;
}
}
}
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 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.5; dd[IX(i, N + 1)] = dd[IX(i, N)] * 0.5;
uu[IX(i, 0)] = uu[IX(i, 1)] * 0.5; uu[IX(i, N + 1)] = uu[IX(i, N)] * 0.5;
vv[IX(i, 0)] = vv[IX(i, 1)] * 0.5; vv[IX(i, N + 1)] = vv[IX(i, N)] * 0.5;
}
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.5; uu[IX(N + 1, j)] = uu[IX(N, j)] * 0.5;
vv[IX(0, j)] = vv[IX(1, j)] * 0.5; vv[IX(N + 1, j)] = vv[IX(N, j)] * 0.5;
}
}
function inject(t: number): void {
const uu0 = u0.current, vv0 = v0.current, dd0 = d0.current;
const s1x = N * 0.32 + Math.sin(t * 0.4) * N * 0.04;
const s1y = N * 0.5 + Math.cos(t * 0.33) * N * 0.05;
const s2x = N * 0.68 + Math.sin(t * 0.31 + 2) * N * 0.04;
const s2y = N * 0.5 + Math.cos(t * 0.47 + 1) * N * 0.05;
const rv = 4.5;
const spin = 5.2 + Math.sin(t * 0.8) * 1.4;
for (let j = -rv; j <= rv; j++) {
for (let i = -rv; i <= rv; i++) {
const q = (i * i + j * j) / (rv * rv);
if (q > 1) continue;
const f = Math.exp(-q * 2.4);
const idx1 = IX(Math.round(s1x + i), Math.round(s1y + j));
uu0[idx1] += -j * f * spin * 0.3; vv0[idx1] += i * f * spin * 0.3;
const idx2 = IX(Math.round(s2x + i), Math.round(s2y + j));
uu0[idx2] += j * f * spin * 0.3; vv0[idx2] += -i * f * spin * 0.3;
}
}
const mx = N * 0.5 + Math.sin(t * 0.6) * N * 0.06;
const my = N * 0.5 + Math.cos(t * 0.5) * N * 0.06;
const rx = N * 0.22, ry = N * 0.16;
for (let y = -Math.round(ry); y <= Math.round(ry); y++) {
for (let x = -Math.round(rx); x <= Math.round(rx); x++) {
const qq = (x * x) / (rx * rx) + (y * y) / (ry * ry);
if (qq > 1) continue;
const amt = Math.exp(-qq * 1.4) * 0.8;
const idxM = IX(Math.round(mx + x), Math.round(my + y));
dd0[idxM] += amt * ((t * 0.22 % 1) < 0.5 ? 1 : 0.6);
}
}
}
function step(dt: number): void {
u0.current.set(u.current); v0.current.set(v.current); d0.current.set(d.current);
inject(seedTRef.current);
calcCurl();
confine(dt);
advect("u", dt); u0.current.set(u.current);
advect("v", dt); v0.current.set(v.current);
advect("d", dt);
bounds();
const dd = d.current;
for (let k = 0; k < SIZE; k++) dd[k] *= 0.995;
seedTRef.current += dt;
}
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) { px[o] = 23; px[o + 1] = 20; px[o + 2] = 26; px[o + 3] = 255; continue; }
const band = q > 0.62 ? 2 : q > 0.3 ? 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);
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 splat(gx: number, gy: number, fx: number, fy: number, amt: number): void {
const r = 3;
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;
if (gx + i < 1 || gx + i > N || gy + j < 1 || gy + j > N) continue;
const f = Math.exp(-q * 3) * amt;
const idx = IX(gx + i, gy + j);
uu[idx] += fx * f; vv[idx] += fy * f;
dd[idx] = Math.min(2.2, dd[idx] + f * 0.7);
}
}
}
function onMove(e: PtrEvt): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
const gx = 1 + Math.round((e.clientX - r.left) / r.width * (N - 1));
const gy = 1 + Math.round((e.clientY - r.top) / r.height * (N - 1));
const last = lastRef.current;
if (last) {
const fx = (e.clientX - last.x) * 0.14, fy = (e.clientY - last.y) * 0.14;
splat(gx, gy, fx, fy, Math.min(1.6, Math.hypot(fx, fy)));
} else {
splat(gx, gy, 0, 0, 0.8);
}
lastRef.current = { x: e.clientX, y: e.clientY };
if (reduced()) render();
}
function onLeave(): void { lastRef.current = null; }
function onKey(e: KeyEvt): void {
const dir = ({ ArrowLeft: [-2, 0], ArrowRight: [2, 0], ArrowUp: [0, -2], ArrowDown: [0, 2] } as Record<string, number[]>)[e.key];
if (!dir) return;
e.preventDefault();
splat(N >> 1, N >> 1, dir[0], dir[1], 1.2);
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="M240 144h-32a80 80 0 0 1-80 80a88 88 0 0 1-88-88a96 96 0 0 1 96-96a104 104 0 0 1 104 104" opacity=".2"/><path d="M248 144a8 8 0 0 1-16 0a96.11 96.11 0 0 0-96-96a88.1 88.1 0 0 0-88 88a80.09 80.09 0 0 0 80 80a72.08 72.08 0 0 0 72-72a64.07 64.07 0 0 0-64-64a56.06 56.06 0 0 0-56 56a48.05 48.05 0 0 0 48 48a40 40 0 0 0 40-40a32 32 0 0 0-32-32a24 24 0 0 0-24 24a16 16 0 0 0 16 16a8 8 0 0 0 8-8a8 8 0 0 1 0-16a16 16 0 0 1 16 16a24 24 0 0 1-24 24a32 32 0 0 1-32-32a40 40 0 0 1 40-40a48.05 48.05 0 0 1 48 48a56.06 56.06 0 0 1-56 56a64.07 64.07 0 0 1-64-64a72.08 72.08 0 0 1 72-72a80.09 80.09 0 0 0 80 80a88.1 88.1 0 0 1-88 88a96.11 96.11 0 0 1-96-96A104.11 104.11 0 0 1 136 32a112.12 112.12 0 0 1 112 112"/></g></svg>
<span>컬(회전 성분)의 기울기 방향으로 힘을 되먹이는 보텍시티 강화가 눌린 연기를 다시 삽니다 — 커서로 소용돌이를 그려보세요</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label="컬링 소용돌이 실험판"
tabIndex={0}
onPointerMove={onMove}
onPointerLeave={onLeave}
onKeyDown={onKey}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true">12<span className={styles.badgeUnit}>강화</span></b>
</div>
</div>
</main>
);
}03 · カールの渦 — 計測 累積面積 7.641% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
レンダリング実測は、累積面積 7.641% · 強度 115.1 · 動いたフレーム 23/23 である。
function vorticity(dt) {
for (var j = 1; j <= N; j++) {
for (var i = 1; i <= N; i++) {
// カール = 速度場の回転(z成分のみ)
curl[IX(i, j)] = (v[IX(i + 1, j)] - v[IX(i - 1, j)]) -
(u[IX(i, j + 1)] - u[IX(i, j - 1)]);
curl[IX(i, j)] *= 0.5;
}
}
for (var j = 1; j <= N; j++) {
for (var i = 1; i <= N; i++) {
var cx = 0.5 * (Math.abs(curl[IX(i + 1, j)]) - Math.abs(curl[IX(i - 1, j)]));
var cy = 0.5 * (Math.abs(curl[IX(i, j + 1)]) - Math.abs(curl[IX(i, j - 1)]));
var len = Math.sqrt(cx * cx + cy * cy) + 1e-5;
// 渦度の勾配に垂直な向きへ力を加える
u[IX(i, j)] += VORT * dt * (cy / len) * curl[IX(i, j)];
v[IX(i, j)] -= VORT * dt * (cx / len) * curl[IX(i, j)];
}
}
}
04インクのにじみ(境界反射)
何が見えるか
クリックした位置に染料を注入すると、移流と拡散が組み合わさって紙に落としたインクが滲むように広がる — 格子の端では濃度を反射させる境界処理を入れており、壁に当たった染料が跳ね返る様子も見える。ローディング演出の背景や、クリック誘導の遊びに使える。

<main class="stage stage--paper">
<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="M180 208a52 52 0 0 1-104 0c0-44 52-120 52-120s52 76 52 120Z" opacity=".2"/><path d="M133.87 41.42a8 8 0 0 0-11.74 0C107.42 57.11 68 104.61 68 152a60 60 0 0 0 120 0c0-47.39-39.42-94.89-54.13-110.58ZM128 196a44 44 0 0 1-44-44c0-31.68 26.05-68.51 44-90.06 17.95 21.55 44 58.38 44 90.06a44 44 0 0 1-44 44Z"/></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 없음.
// 염료 팔레트는 어둡고 채도 낮은 회보라 3단(옅은 번짐 → 중간 → 진한 코어) — 종이 위 먹빛이라
// 채도를 처음부터 낮게 잡아 BD26 을 지킨다(세 색 모두 채도 <0.21).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-paper);
.fa {
--dye-0: #cdc8d6;
--dye-1: #8f89a3;
--dye-2: #3a3550;
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-ink;
}
.fa__ico { width: 16px; height: 16px; flex: 0 0 auto; color: #8f89a3; }
.fa__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $stage-paper;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .14);
cursor: crosshair; touch-action: none;
}
.fa__field:focus-visible { outline: 2px solid #3a3550; 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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fa {
--dye-0: #cdc8d6;
--dye-1: #8f89a3;
--dye-2: #3a3550;
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: #17141a;
}
.fa__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #8f89a3;
}
.fa__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.14);
cursor: crosshair;
touch-action: none;
}
.fa__field:focus-visible {
outline: 2px solid #3a3550;
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;
}
}// 잉크 번짐 — 확산은 낮게 두고 이류만 남긴다. 클릭한 자리에 밀도 한 덩어리(가우시안)를 심으면
// 종이 위 잉크가 젖은 경계를 그리며 자란다. 밀도가 진할수록 곱셈 감쇠(decay = 1 - k*density)로
// 더 천천히 번지게 해 번짐 단계가 눈에 보이게 한다 — 안정 유체 이류(01)와 같은 格子·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 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);
}
// 01 과 동일한 후방 이류(semi-Lagrangian) — 확산 없이 값을 옮기기만 해도 경계가 또렷하게 남는다.
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.5; d[IX(i, N + 1)] = d[IX(i, N)] * 0.5;
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;
}
}
// 방울 심기 — 밀도 덩어리를 반경 3~4칸 가우시안으로 심고, 살짝 바깥으로 밀어내는 속도를 얹어
// 원이 아니라 젖은 경계처럼 삐뚤빼뚤 자라게 한다.
function bloom(gx, gy, amp, jitter) {
var r = 3.6;
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.4) * 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);
// 바깥쪽으로 밀어내는 속도 — 종이 섬유를 따라 삐뚤게(jitter)
var len = Math.sqrt(i * i + j * j) || 1;
var nx = i / len, ny = j / len;
var wob = Math.sin(iy * 1.7 + ix * 0.6 + jitter) * 0.4;
u[idx] += (nx + wob) * f * 0.045;
v[idx] += (ny - wob) * f * 0.045;
}
}
}
// 미리보기 — 시드 고정 좌표 세 곳에 2초(60스텝 @30fps)마다 번갈아 새 방울을 심는 결정적 패턴.
var seedSpots = [
{ x: N * 0.32, y: N * 0.38 },
{ x: N * 0.68, y: N * 0.56 },
{ x: N * 0.46, y: N * 0.74 }
];
var seedPeriod = 60; // step 단위(1/30 초 * 60 = 2초)
var lastSeedStep = -999;
var stepCount = 0;
function step(dt) {
if (stepCount - lastSeedStep >= seedPeriod) {
var spot = seedSpots[(stepCount / seedPeriod | 0) % seedSpots.length];
bloom(spot.x, spot.y, 1.7, seedT);
lastSeedStep = stepCount;
}
// 밀도가 진할수록 느리게 번지는 곱셈 감쇠 — decay = 1 - k*density. 진한 코어는 거의 안 움직이고
// 옅은 경계만 계속 퍼진다(단계적으로 보이는 이유).
for (var k = 0; k < SIZE; k++) {
var decay = 1 - Math.min(0.7, d[k] * 0.32);
u[k] *= decay; v[k] *= decay;
}
u0.set(u); v0.set(v); d0.set(d);
advect('u', dt); u0.set(u);
advect('v', dt); v0.set(v);
advect('d', dt);
diffuse('d', 0.0009, dt); // 확산은 아주 낮게 — 이류가 경계를 만드는 주역
bounds();
for (var m = 0; m < SIZE; m++) { d[m] *= 0.9985; u[m] *= 0.985; v[m] *= 0.985; }
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.02) { px[o] = 255; px[o + 1] = 255; px[o + 2] = 255; px[o + 3] = 255; continue; }
var band = q > 0.9 ? 2 : (q > 0.4 ? 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.85 + 0.15);
px[o] = 255 + (rr - 255) * a; px[o + 1] = 255 + (gg - 255) * a; px[o + 2] = 255 + (bb - 255) * a; px[o + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function frame() {
step(1 / 30);
render();
raf = requestAnimationFrame(frame);
}
// 실제 사용 — pointerdown 으로 큰 방울, pointermove 로 이어 끄는 작은 방울
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));
bloom(gx, gy, 2.0, seedT * 3.1);
if (reduce) render();
});
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));
bloom(gx, gy, 0.55, seedT * 3.1);
if (reduce) render();
});
field.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
bloom(N * 0.5, N * 0.5, 2.0, seedT * 3.1);
if (reduce) render();
});
window.addEventListener('resize', function () { fit(); if (reduce) render(); });
fit();
if (reduce) {
for (var k = 0; k < 40; k++) step(1 / 30);
render();
} else {
raf = requestAnimationFrame(frame);
}// InkBloom — vanilla 판(run/308/demo/04-fluid-ink-bloom/index.html) 의 React 포트.
// 클릭한 자리에 밀도 한 덩어리를 심고 확산은 낮게·이류만 남겨 종이 위 잉크 번짐을 흉내낸다.
// 격자 필드는 ref 상자 안에서만 산다 — React 상태로 두면 리렌더가 매 프레임 일어나 60fps 예산을 못 맞춘다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./InkBloom.module.scss";
export type InkBloomProps = {
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 SEED_SPOTS = [
{ x: N * 0.32, y: N * 0.38 },
{ x: N * 0.68, y: N * 0.56 },
{ x: N * 0.46, y: N * 0.74 },
];
const SEED_PERIOD = 60;
export default function InkBloom({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#17141a",
}: InkBloomProps) {
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(["#cdc8d6", "#8f89a3", "#3a3550"]) 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.5; dd[IX(i, N + 1)] = dd[IX(i, N)] * 0.5;
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 bloom(gx: number, gy: number, amp: number, jitter: number): void {
const r = 3.6;
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.4) * 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);
const len = Math.sqrt(i * i + j * j) || 1;
const nx = i / len, ny = j / len;
const wob = Math.sin(iy * 1.7 + ix * 0.6 + jitter) * 0.4;
uu[idx] += (nx + wob) * f * 0.045;
vv[idx] += (ny - wob) * f * 0.045;
}
}
}
function step(dt: number): void {
const uu = u.current, vv = v.current, dd = d.current;
if (stepCountRef.current - lastSeedStepRef.current >= SEED_PERIOD) {
const spot = SEED_SPOTS[Math.floor(stepCountRef.current / SEED_PERIOD) % SEED_SPOTS.length];
bloom(spot.x, spot.y, 1.7, seedTRef.current);
lastSeedStepRef.current = stepCountRef.current;
}
for (let k = 0; k < SIZE; k++) {
const decay = 1 - Math.min(0.7, dd[k] * 0.32);
uu[k] *= decay; vv[k] *= decay;
}
u0.current.set(uu); v0.current.set(vv); d0.current.set(dd);
advect("u", dt); u0.current.set(u.current);
advect("v", dt); v0.current.set(v.current);
advect("d", dt);
diffuse("d", 0.0009, dt);
bounds();
const dd2 = d.current, uu2 = u.current, vv2 = v.current;
for (let m = 0; m < SIZE; m++) { dd2[m] *= 0.9985; uu2[m] *= 0.985; vv2[m] *= 0.985; }
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.02) { px[o] = 255; px[o + 1] = 255; px[o + 2] = 255; px[o + 3] = 255; continue; }
const band = q > 0.9 ? 2 : q > 0.4 ? 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.85 + 0.15);
px[o] = 255 + (rr - 255) * a; px[o + 1] = 255 + (gg - 255) * a; px[o + 2] = 255 + (bb - 255) * a; px[o + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function frame(): void {
step(1 / 30);
render();
rafRef.current = requestAnimationFrame(frame);
}
function onDown(e: PtrEvt): void {
const field = fieldRef.current;
if (!field) return;
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);
bloom(gx, gy, 2.0, seedTRef.current * 3.1);
if (reduced()) render();
}
function onMove(e: PtrEvt): void {
if ((e.pressure ?? 0) === 0 && (e.buttons ?? 0) === 0) return;
const field = fieldRef.current;
if (!field) return;
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);
bloom(gx, gy, 0.55, seedTRef.current * 3.1);
if (reduced()) render();
}
function onKey(e: KeyEvt): void {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
bloom(N * 0.5, N * 0.5, 2.0, seedTRef.current * 3.1);
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 {
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="M180 208a52 52 0 0 1-104 0c0-44 52-120 52-120s52 76 52 120Z" opacity=".2"/><path d="M133.87 41.42a8 8 0 0 0-11.74 0C107.42 57.11 68 104.61 68 152a60 60 0 0 0 120 0c0-47.39-39.42-94.89-54.13-110.58ZM128 196a44 44 0 0 1-44-44c0-31.68 26.05-68.51 44-90.06 17.95 21.55 44 58.38 44 90.06a44 44 0 0 1-44 44Z"/></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>
);
}04 · インクのにじみ — 計測 累積面積 13.76% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
レンダリング実測は、累積面積 13.76% · 強度 46.8 · 動いたフレーム 23/23 である。
// 境界セル:濃度は反射、速度は反転(壁で跳ね返す)
function bounds(b) {
for (var i = 1; i <= N; i++) {
d[IX(0, i)] = b === 0 ? -d[IX(1, i)] : d[IX(1, i)];
d[IX(N + 1, i)] = b === 0 ? -d[IX(N, i)] : d[IX(N, i)];
d[IX(i, 0)] = b === 0 ? -d[IX(i, 1)] : d[IX(i, 1)];
d[IX(i, N + 1)] = b === 0 ? -d[IX(i, N)] : d[IX(i, N)];
}
}
05煙の上昇
何が見えるか
下から湧き上がる煙が浮力で持ち上がり、渦を巻きながら天井近くで横に広がる — 20ステップごとに3点同時点火し、揺らぎは固定シードの決定論的ガウスノイズで作るので、リロードしても同じ煙が立ち上がる。ヒーローやカードの背景に、煙エフェクトとしてそのまま配置できる。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
レンダリング実測は、累積面積 47.947% · 強度 57.1 · 動いたフレーム 23/23 である。
// 浮力:密度が高いほど強く上向きの力が働く
for (var k = 0; k < size; k++) {
v0[k] -= Math.min(1.6, d0[k]) * BUOY * dt;
}
// 20ステップごとに3点同時点火(シード固定のガウスノイズ)
if (frame % 20 === 0) {
for (var s = 0; s < 3; s++) {
var px = 0.5 + 0.28 * Math.cos(2.1 * s + frame * 0.013);
var py = 0.12 + 0.05 * noise(frame * 0.7 + s * 31);
inject(px, py);
}
}
06渦の減衰
何が見えるか
最初に1つの大きな渦を植え、あとは何も足さずに減衰だけを観察する — 渦の糸がほどけ、濃度が全体へ均されて消えていく様子は、減衰係数と拡散が実際に何をしているかを一番素直に見せてくれる。流体シミュレーション サイトのアニメーション終了フレームを設計するときの参考にもできる。

<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="M128 216a88 88 0 1 1 88-88 8 8 0 0 1-16 0 72 72 0 1 0-72 72 8 8 0 0 1 0 16Z" opacity=".2"/><path d="M128 24a104.2 104.2 0 0 0-73.5 30.5 8 8 0 0 0 11.3 11.3A88 88 0 1 1 40 128a8 8 0 0 0-16 0 104 104 0 1 0 104-104Zm0 40a64 64 0 1 0 64 64 8 8 0 0 0-16 0 48 48 0 1 1-48-48 8 8 0 0 0 0-16Z"/></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 없음.
// 염료 팔레트는 항목01 과 같은 파스텔 3단(하늘색 → 라일락 → 산호)을 재사용한다 — 잉크 무대 위 대비 7.6~13.0.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.fa {
--dye-0: #9ac2df;
--dye-1: #b19edb;
--dye-2: #e0a3b8;
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: #9ac2df; }
.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 #9ac2df; 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: #9ac2df;
--dye-1: #b19edb;
--dye-2: #e0a3b8;
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: #9ac2df;
}
.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 #9ac2df;
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;
}
}// 소용돌이 감쇠 — 초기화 시점에 랭킨 소용돌이(Rankine vortex) 속도장을 딱 한 번 심는다.
// 중심 반경(r_core) 안쪽은 강체 회전(속도 ∝ 거리), 바깥쪽은 속도 ∝ 1/거리로 줄어든다.
// 이후엔 연속 주입 없이 약한 확산 + 매 프레임 곱셈 감쇠만 걸어 소용돌이가 스스로 흐려져 사라지게 둔다.
// 감쇠가 끝나면 자동으로 다시 심어 루프를 반복한다 — 클릭하면 그 자리에 즉시 새로 심어진다.
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 reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, raf = 0;
var R_CORE = N * 0.17; // 강체 회전 반경 — 이 안쪽은 각속도가 일정하다
var OMEGA = 1.35; // 중심 각속도 — 값이 클수록 처음에 세게 돈다
var LIFE = 110; // 씨드 뒤 이 스텝(≈3.7초 @30fps) 이 지나면 자동으로 다시 심는다
var lifeStep = 0;
var cycle = 0;
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
}
// 01 과 동일한 후방 이류(semi-Lagrangian).
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.5; d[IX(i, N + 1)] = d[IX(i, N)] * 0.5;
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 seedVortex(cx, cy, dir) {
u.fill(0); v.fill(0); d.fill(0);
u0.fill(0); v0.fill(0); d0.fill(0);
for (var j = 1; j <= N; j++) {
for (var i = 1; i <= N; i++) {
var dx = i - cx, dy = j - cy;
var r = Math.sqrt(dx * dx + dy * dy) || 0.0001;
var vTheta = r <= R_CORE ? OMEGA * r : OMEGA * (R_CORE * R_CORE) / r;
var idx = IX(i, j);
// 접선 방향(반시계=dir 1, 시계=dir -1): (-dy/r, dx/r)
u[idx] = dir * (-dy / r) * vTheta;
v[idx] = dir * (dx / r) * vTheta;
// 밀도는 소용돌이 팔 모양을 보이려고 코어 주변 링 모양으로 심는다
var ring = Math.exp(-Math.pow((r - R_CORE * 1.15) / (R_CORE * 0.85), 2));
d[idx] = ring * 1.4;
}
}
lifeStep = 0;
}
function step(dt) {
u0.set(u); v0.set(v); d0.set(d);
advect('u', dt); u0.set(u);
advect('v', dt); v0.set(v);
advect('d', dt);
diffuse('u', 0.0016, dt);
diffuse('v', 0.0016, dt);
diffuse('d', 0.0016, dt);
bounds();
// 매 프레임 곱셈 감쇠 — 연속 주입 없이 이것만으로 소용돌이가 시간이 갈수록 흐려진다
var decay = 0.9905;
for (var k = 0; k < SIZE; k++) { u[k] *= decay; v[k] *= decay; d[k] *= 0.988; }
lifeStep++;
if (lifeStep >= LIFE) {
cycle++;
var dir = cycle % 2 === 0 ? 1 : -1;
var cx = N * 0.5 + (cycle % 3 - 1) * N * 0.05;
var cy = N * 0.5 + ((cycle + 1) % 3 - 1) * N * 0.05;
seedVortex(cx, cy, dir);
}
}
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) { px[o] = 23; px[o + 1] = 20; px[o + 2] = 26; px[o + 3] = 255; continue; }
var band = q > 0.62 ? 2 : (q > 0.3 ? 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);
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);
}
// 실제 사용 — 클릭한 자리에 즉시 새 소용돌이를 심는다(같은 seedVortex 를 자동 루프와 공유).
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));
cycle++;
seedVortex(gx, gy, cycle % 2 === 0 ? 1 : -1);
if (reduce) render();
});
field.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
cycle++;
seedVortex(N * 0.5, N * 0.5, cycle % 2 === 0 ? 1 : -1);
if (reduce) render();
});
window.addEventListener('resize', function () { fit(); if (reduce) render(); });
fit();
seedVortex(N * 0.5, N * 0.5, 1);
if (reduce) {
// reduce — 40 걸음만 앞으로 감아 감쇠가 진행 중인 한 장을 그린다(빈 화면도, 갓 심은 화면도 아니다)
for (var k = 0; k < 40; k++) step(1 / 30);
render();
} else {
raf = requestAnimationFrame(frame);
}// VortexDecay — vanilla 판(run/308/demo/06-fluid-vortex-decay/index.html) 의 React 포트.
// 초기화 때 랭킨 소용돌이 속도장을 한 번 심고 점성 감쇠만 두면 시간이 갈수록 퍼져 사라지는 소용돌이가 관찰된다.
// 격자 필드는 ref 상자 안에서만 산다 — React 상태로 두면 리렌더가 매 프레임 일어나 60fps 예산을 못 맞춘다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./VortexDecay.module.scss";
export type VortexDecayProps = {
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 };
const N = 32;
const SIZE = (N + 2) * (N + 2);
function IX(x: number, y: number): number { return x + (N + 2) * y; }
const R_CORE = N * 0.17;
const OMEGA = 1.35;
const LIFE = 110;
export default function VortexDecay({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: VortexDecayProps) {
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(["#9ac2df", "#b19edb", "#e0a3b8"]) 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 lifeStepRef = useRef(0) as Box<number>;
const cycleRef = useRef(0) 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.5; dd[IX(i, N + 1)] = dd[IX(i, N)] * 0.5;
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 seedVortex(cx: number, cy: number, dir: number): void {
const uu = u.current, vv = v.current, dd = d.current;
const u0v = u0.current, v0v = v0.current, d0v = d0.current;
uu.fill(0); vv.fill(0); dd.fill(0);
u0v.fill(0); v0v.fill(0); d0v.fill(0);
for (let j = 1; j <= N; j++) {
for (let i = 1; i <= N; i++) {
const dx = i - cx, dy = j - cy;
const r = Math.sqrt(dx * dx + dy * dy) || 0.0001;
const vTheta = r <= R_CORE ? OMEGA * r : OMEGA * (R_CORE * R_CORE) / r;
const idx = IX(i, j);
uu[idx] = dir * (-dy / r) * vTheta;
vv[idx] = dir * (dx / r) * vTheta;
const ring = Math.exp(-Math.pow((r - R_CORE * 1.15) / (R_CORE * 0.85), 2));
dd[idx] = ring * 1.4;
}
}
lifeStepRef.current = 0;
}
function step(dt: number): void {
u0.current.set(u.current); v0.current.set(v.current); d0.current.set(d.current);
advect("u", dt); u0.current.set(u.current);
advect("v", dt); v0.current.set(v.current);
advect("d", dt);
diffuse("u", 0.0016, dt);
diffuse("v", 0.0016, dt);
diffuse("d", 0.0016, dt);
bounds();
const uu = u.current, vv = v.current, dd = d.current;
const decay = 0.9905;
for (let k = 0; k < SIZE; k++) { uu[k] *= decay; vv[k] *= decay; dd[k] *= 0.988; }
lifeStepRef.current++;
if (lifeStepRef.current >= LIFE) {
cycleRef.current++;
const c = cycleRef.current;
const dir = c % 2 === 0 ? 1 : -1;
const cx = N * 0.5 + (c % 3 - 1) * N * 0.05;
const cy = N * 0.5 + ((c + 1) % 3 - 1) * N * 0.05;
seedVortex(cx, cy, dir);
}
}
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) { px[o] = 23; px[o + 1] = 20; px[o + 2] = 26; px[o + 3] = 255; continue; }
const band = q > 0.62 ? 2 : q > 0.3 ? 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);
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 onDown(e: PtrEvt): void {
const field = fieldRef.current;
if (!field) return;
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);
cycleRef.current++;
seedVortex(gx, gy, cycleRef.current % 2 === 0 ? 1 : -1);
if (reduced()) render();
}
function onKey(e: KeyEvt): void {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
cycleRef.current++;
seedVortex(N * 0.5, N * 0.5, cycleRef.current % 2 === 0 ? 1 : -1);
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);
seedVortex(N * 0.5, N * 0.5, 1);
if (reduced()) {
if (root) root.classList.add("is-still");
for (let k = 0; k < 40; k++) step(1 / 30);
render();
} else {
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="M128 216a88 88 0 1 1 88-88 8 8 0 0 1-16 0 72 72 0 1 0-72 72 8 8 0 0 1 0 16Z" opacity=".2"/><path d="M128 24a104.2 104.2 0 1 0 73.5 30.5 8 8 0 0 0-11.3 11.3A88 88 0 1 1 40 128a8 8 0 0 0-16 0A104 104 0 0 0 128 24Zm0 40a64 64 0 1 1-64 64 8 8 0 0 0 16 0 48 48 0 1 0 48-48 8 8 0 0 1 0-16Z"/></g></svg>
<span>중심은 강체처럼 함께 돌고 바깥은 멀어질수록 느려지며, 점성으로 시간이 갈수록 흐려져 사라집니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label="소용돌이 감쇠 실험판"
tabIndex={0}
onPointerDown={onDown}
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>
);
}06 · 渦の減衰 — 計測 累積面積 61.031% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
レンダリング実測は、累積面積 61.031% · 強度 26.8 · 動いたフレーム 20/23 である。
// 毎フレーム、全セルへ一律の減衰をかけるだけ
for (var k = 0; k < size; k++) {
u0[k] = u[k] * DISSIPATION;
v0[k] = v[k] * DISSIPATION;
d0[k] = d[k] * DISSIPATION_D;
}
// 初期条件:中心に1つの回転を植える(以後は足さない)
for (var j = 1; j <= N; j++) {
for (var i = 1; i <= N; i++) {
var dx = i - N * 0.5, dy = j - N * 0.5;
var r = Math.sqrt(dx * dx + dy * dy) + 1e-5;
u[IX(i, j)] = -dy / r * Math.exp(-r * 0.18);
v[IX(i, j)] = dx / r * Math.exp(-r * 0.18);
}
}
073色の染料混合
何が見えるか
密度チャンネルを1つから3つに増やし、パステル調の染料3色が互いに混ざりながら流れる — チャンネルごとに独立した移流・拡散を回すだけで、色同士のなじみ方が自然に決まる。ブランドカラー3色を使った液状ににじむ見出し背景や、セクションの遷移に向く。

<main class="stage stage--yellow">
<div class="dm" id="dm">
<p class="dm__hint">
<svg class="dm__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,184a80,80,0,1,1,80-80A80.09,80.09,0,0,1,128,208ZM112,72v56a16,16,0,0,0,32,0V72a16,16,0,0,0-32,0Z" opacity=".2"/><path d="M128,16A112,112,0,1,0,240,128,112.12,112.12,0,0,0,128,16Zm0,192a80,80,0,1,1,80-80A80.09,80.09,0,0,1,128,208ZM112,72v56a16,16,0,0,0,32,0V72a16,16,0,0,0-32,0Z"/></g></svg>
<span>三つのチャンネルがそれぞれ流れて混ざり合います — カーソルで二色の間をかき混ぜてみましょう</span>
</p>
<div class="dm__field" id="field" role="application" aria-label="染料混合実験盤" tabindex="0">
<canvas class="dm__cv" id="cv"></canvas>
<b class="dm__badge" aria-hidden="true">3<span class="dm__badgeUnit">チャンネル</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 유체 염료 혼합 — 움직임은 전부 캔버스 rAF 루프 안에 있다(01 과 같은 구조). CSS 는 무대·판·배지.
// @keyframes 를 두지 않는다 — 렌더러가 CSS 시계 대신 실시간으로 24장을 찍는다.
// 무대는 $stage-yellow(토큰 참조라 BD26 대상 아님). 염료 리터럴은 전부 파스텔로 직접 잡는다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-yellow);
.dm {
// 염료 채널 세 색 — 파스텔 로즈(채도 .42)·세이지(.23)·코발트(.40). 종이색 base 는 노랑 무대 위 판색
--dye-r: #dba7ab;
--dye-g: #a8c9ab;
--dye-b: #a7b9d9;
--dye-paper: #fff3cf;
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.dm__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-ink;
}
.dm__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-ink; }
.dm__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: #fff3cf;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .22);
cursor: crosshair; touch-action: none;
}
.dm__field:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.dm__cv { display: block; width: 100%; height: 100%; }
.dm__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, .82);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.dm__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.dm { gap: $sp-1; }
.dm__field { height: 124px; }
.dm__badge { font-size: 10px; padding: 0 $sp-1; }
}
// reduce — 루프가 멈춘 한 장이 남으므로 커서 신호만 바꾼다.
@media (prefers-reduced-motion: reduce) {
.dm.is-still .dm__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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dm {
--dye-r: #dba7ab;
--dye-g: #a8c9ab;
--dye-b: #a7b9d9;
--dye-paper: #fff3cf;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.dm__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: #17141a;
}
.dm__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #17141a;
}
.dm__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: #fff3cf;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.22);
cursor: crosshair;
touch-action: none;
}
.dm__field:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.dm__cv {
display: block;
width: 100%;
height: 100%;
}
.dm__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.82);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.dm__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.dm {
gap: 4px;
}
.dm__field {
height: 124px;
}
.dm__badge {
font-size: 10px;
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.dm.is-still .dm__field {
cursor: default !important;
}
}// 유체 염료 혼합 — 밀도 채널을 하나가 아니라 셋(R,G,B 역할)으로 두고 각각 독립 이류한다.
// 속도장은 하나를 공유하므로 세 염료는 같은 물 위에서 움직이고, 겹치는 곳의 중간색은
// 색을 미리 섞어 그린 결과가 아니라 チャンネル 값의 합이 렌더식을 통과한 결과다.
var root = document.getElementById('dm');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d', { willReadFrequently: true });
var css = getComputedStyle(root);
var ROSE = css.getPropertyValue('--dye-r').trim();
var SAGE = css.getPropertyValue('--dye-g').trim();
var COBALT = css.getPropertyValue('--dye-b').trim();
var CREAM = css.getPropertyValue('--dye-paper').trim();
function hex(c) { return [parseInt(c.slice(1, 3), 16), parseInt(c.slice(3, 5), 16), parseInt(c.slice(5, 7), 16)]; }
var CR = hex(CREAM), K1 = hex(ROSE), K2 = hex(SAGE), K3 = hex(COBALT);
// N=48 — 채널이 셋이라 32보다 살짝 올려도 한 프레임 계산이 가볍다(칸 2,500 × 3チャンネル)
var N = 48;
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 r = new Float32Array(SIZE), r0 = new Float32Array(SIZE);
var g = new Float32Array(SIZE), g0 = new Float32Array(SIZE);
var b = new Float32Array(SIZE), b0 = new Float32Array(SIZE);
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, raf = 0, seedT = 0, spin = 0;
function fit() {
var rect = field.getBoundingClientRect();
W = rect.width; H = rect.height;
cv.width = Math.round(W); cv.height = Math.round(H);
}
// 후방 이류 — 한 칸의 새 값을 속도를 dt 만큼 거슬러 간 좌표의 옛 값으로 채운다(チャンネル 공통)
function advect(dst, src, dt) {
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, i1 = i0 + 1;
if (y < 0.5) y = 0.5; else if (y > N + 0.5) y = N + 0.5;
var j0 = y | 0, 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(dst, src, k) {
for (var j = 1; j <= N; j++) {
for (var i = 1; i <= N; i++) {
var idx = IX(i, j);
dst[idx] = src[idx] + k * (src[IX(i - 1, j)] + src[IX(i + 1, j)] + src[IX(i, j - 1)] + src[IX(i, j + 1)] - 4 * src[idx]);
}
}
}
// チャンネル 경계 — 벽으로 새어나간 염료는 절반으로 감쇠
function dyeBounds(d) {
var n2 = N + 2;
for (var i = 1; i <= N; i++) {
d[IX(i, 0)] = d[IX(i, 1)] * 0.5; d[IX(i, N + 1)] = d[IX(i, N)] * 0.5;
}
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;
}
}
function velBounds() {
var n2 = N + 2;
for (var i = 1; i <= N; i++) {
u[IX(i, 0)] = u[IX(i, 1)] * 0.5; u[IX(i, N + 1)] = u[IX(i, N)] * 0.5;
v[IX(i, 0)] = v[IX(i, 1)] * 0.5; v[IX(i, N + 1)] = v[IX(i, N)] * 0.5;
}
for (var j = 1; j <= N; j++) {
u[IX(0, j)] = u[IX(1, j)] * 0.5; u[IX(N + 1, j)] = u[IX(N, j)] * 0.5;
v[IX(0, j)] = v[IX(1, j)] * 0.5; v[IX(N + 1, j)] = v[IX(N, j)] * 0.5;
}
}
// 공통 속도장 — 중심 회전(와지) + 왼쪽에서 오른쪽 흐름. 두 염료를 계속 서로 쪽으로 밀어낸다
function velSource() {
var cx = N * 0.5, cy = N * 0.52;
for (var j = 2; j <= N - 1; j++) {
for (var i = 2; i <= N - 1; i++) {
var dx = i - cx, dy = j - cy;
var q = Math.sqrt(dx * dx + dy * dy) / (N * 0.5);
var w = Math.exp(-q * q * 2.2) * 0.55;
u[IX(i, j)] += (-dy * 0.05 + 0.16) * w;
v[IX(i, j)] += (dx * 0.05) * w;
}
}
spin += 1;
}
// 시드 주입 — 좌상단에서 도는 로즈(R), 우하단에서 도는 코발트(B), 가운데를 지나는 세이지(G).
// seedT 로 결정적: 같은 편의 gif·poster 는 언제나 같은 흐름이 담긴다.
function inject(t) {
var ax = N * 0.27 + Math.sin(t * 1.1) * N * 0.09;
var ay = N * 0.3 + Math.cos(t * 0.9) * N * 0.08;
var bx = N * 0.7 + Math.sin(t * 0.8 + 2.1) * N * 0.08;
var by = N * 0.7 + Math.cos(t * 1.3 + 1.4) * N * 0.07;
var mx = N * 0.5 + Math.sin(t * 0.55 + 4.0) * N * 0.13;
var my = N * 0.5 + Math.cos(t * 0.62 + 1.0) * N * 0.12;
splatDye(r0, ax, ay, 1.3);
splatDye(b0, bx, by, 1);
splatDye(g0, mx, my, 0.8);
}
function splatDye(d, cx, cy, amp) {
var rad = 3;
for (var j = -rad; j <= rad; j++) {
for (var i = -rad; i <= rad; i++) {
var q = (i * i + j * j) / (rad * rad);
if (q > 1) continue;
var gi = Math.round(cx) + i, gj = Math.round(cy) + j;
if (gi < 1 || gi > N || gj < 1 || gj > N) continue;
d[IX(gi, gj)] += Math.exp(-q * 2.4) * amp;
}
}
}
function step(dt) {
velSource();
u0.set(u); v0.set(v);
advect(u, u0, dt); u0.set(u);
advect(v, v0, dt); v0.set(v);
velBounds();
r0.set(r); g0.set(g); b0.set(b);
inject(seedT);
diffuse(r, r0, 0.09); diffuse(g, g0, 0.09); diffuse(b, b0, 0.09);
r0.set(r); g0.set(g); b0.set(b);
advect(r, r0, dt); advect(g, g0, dt); advect(b, b0, dt);
dyeBounds(r); dyeBounds(g); dyeBounds(b);
for (var k = 0; k < SIZE; k++) { r[k] *= 0.996; g[k] *= 0.996; b[k] *= 0.996; }
seedT += dt;
}
// 렌더 — finalRGB = base + R*colorR + G*colorG + B*colorB. 중간색은 이 식을 통과하며 자연히 파생한다
function render() {
var img = ctx.createImageData(cv.width, cv.height);
var px = img.data;
var cellW = cv.width / N, cellH = cv.height / N;
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 idx = IX(gx + 1, gy + 1);
var qr = Math.min(1.6, r[idx]) / 1.6;
var qg = Math.min(1.6, g[idx]) / 1.6;
var qb = Math.min(1.6, b[idx]) / 1.6;
var o = (py * cv.width + pxx) * 4;
px[o] = Math.min(255, CR[0] + qr * (K1[0] - CR[0]) + qg * (K2[0] - CR[0]) + qb * (K3[0] - CR[0]));
px[o + 1] = Math.min(255, CR[1] + qr * (K1[1] - CR[1]) + qg * (K2[1] - CR[1]) + qb * (K3[1] - CR[1]));
px[o + 2] = Math.min(255, CR[2] + qr * (K1[2] - CR[2]) + qg * (K2[2] - CR[2]) + qb * (K3[2] - CR[2]));
px[o + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function frame() {
step(1 / 30);
render();
raf = requestAnimationFrame(frame);
}
// 사람이 휘젓기 — 프레임 간 위치 차이를 지수 반경으로 공통 속도장에 얹는다(01 splat 확대판)
function splat(gx, gy, fx, fy, amt) {
var rad = 4;
for (var j = -rad; j <= rad; j++) {
for (var i = -rad; i <= rad; i++) {
var q = (i * i + j * j) / (rad * rad);
if (q > 1) continue;
if (gx + i < 1 || gx + i > N || gy + j < 1 || gy + j > N) continue;
var f = Math.exp(-q * 3) * amt;
var idx = IX(gx + i, gy + j);
u[idx] += fx * f; v[idx] += fy * f;
}
}
}
var last = null;
field.addEventListener('pointermove', function (e) {
var rect = field.getBoundingClientRect();
var gx = 1 + Math.round((e.clientX - rect.left) / rect.width * (N - 1));
var gy = 1 + Math.round((e.clientY - rect.top) / rect.height * (N - 1));
if (last) {
var fx = (e.clientX - last.x) * 0.18, fy = (e.clientY - last.y) * 0.18;
splat(gx, gy, fx, fy, Math.min(2, Math.hypot(fx, fy)));
} else {
splat(gx, gy, 0, 0, 0.9);
}
last = { x: e.clientX, y: e.clientY };
if (reduce) render();
});
field.addEventListener('pointerleave', function () { last = null; });
field.addEventListener('keydown', function (e) {
var dir = { ArrowLeft: [-2, 0], ArrowRight: [2, 0], ArrowUp: [0, -2], ArrowDown: [0, 2] }[e.key];
if (!dir) return;
e.preventDefault();
splat(N >> 1, N >> 1, dir[0], dir[1], 1.2);
if (reduce) render();
});
window.addEventListener('resize', function () { fit(); if (reduce) render(); });
fit();
if (reduce) {
// reduce — 시뮬레이션을 44 걸음만 앞으로 감아 두 색이 만난 한 장을 그린다(빈 화면이 아니다)
for (var k = 0; k < 44; k++) step(1 / 30);
render();
} else {
raf = requestAnimationFrame(frame);
}// DyeMix — vanilla 판(run/308/demo/07-fluid-dye-mix/index.html) 의 React 포트.
// 밀도 채널을 하나가 아니라 셋(R,G,B 역할)으로 두고 공유 속도장 위에서 각각 독립 이류·확산한다.
// 겹치는 곳의 중간색은 미리 섞은 색이 아니라 채널 값이 렌더식을 통과한 결과다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./DyeMix.module.scss";
export type DyeMixProps = {
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 };
const N = 48;
const SIZE = (N + 2) * (N + 2);
function IX(x: number, y: number): number { return x + (N + 2) * y; }
export default function DyeMix({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#a8c9ab",
}: DyeMixProps) {
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({ cr: [255, 243, 207], k1: [219, 167, 171], k2: [168, 201, 171], k3: [167, 185, 217] }) as Box<{ cr: number[]; k1: number[]; k2: number[]; k3: number[] }>;
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 r = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const r0 = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const g = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const g0 = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const bC = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const b0 = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const seedTRef = useRef(0) as Box<number>;
const lastRef = useRef<{ x: number; y: number } | null>(null) as Box<{ x: number; y: number } | null>;
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 rect = field.getBoundingClientRect();
cv.width = Math.round(rect.width);
cv.height = Math.round(rect.height);
}
function advect(dst: Float32Array, src: Float32Array, dt: number): void {
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(dst: Float32Array, src: Float32Array, k: number): void {
for (let j = 1; j <= N; j++) {
for (let i = 1; i <= N; i++) {
const idx = IX(i, j);
dst[idx] = src[idx] + k * (src[IX(i - 1, j)] + src[IX(i + 1, j)] + src[IX(i, j - 1)] + src[IX(i, j + 1)] - 4 * src[idx]);
}
}
}
function dyeBounds(d: Float32Array): void {
for (let i = 1; i <= N; i++) {
d[IX(i, 0)] = d[IX(i, 1)] * 0.5; d[IX(i, N + 1)] = d[IX(i, N)] * 0.5;
}
for (let 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;
}
}
function velBounds(): void {
const uu = u.current, vv = v.current;
for (let i = 1; i <= N; i++) {
uu[IX(i, 0)] = uu[IX(i, 1)] * 0.5; uu[IX(i, N + 1)] = uu[IX(i, N)] * 0.5;
vv[IX(i, 0)] = vv[IX(i, 1)] * 0.5; vv[IX(i, N + 1)] = vv[IX(i, N)] * 0.5;
}
for (let j = 1; j <= N; j++) {
uu[IX(0, j)] = uu[IX(1, j)] * 0.5; uu[IX(N + 1, j)] = uu[IX(N, j)] * 0.5;
vv[IX(0, j)] = vv[IX(1, j)] * 0.5; vv[IX(N + 1, j)] = vv[IX(N, j)] * 0.5;
}
}
function velSource(): void {
const cx = N * 0.5, cy = N * 0.52;
const uu = u.current, vv = v.current;
for (let j = 2; j <= N - 1; j++) {
for (let i = 2; i <= N - 1; i++) {
const dx = i - cx, dy = j - cy;
const q = Math.sqrt(dx * dx + dy * dy) / (N * 0.5);
const w = Math.exp(-q * q * 2.2) * 0.55;
uu[IX(i, j)] += (-dy * 0.05 + 0.16) * w;
vv[IX(i, j)] += (dx * 0.05) * w;
}
}
}
function splatDye(d: Float32Array, cx: number, cy: number, amp: number): void {
const rad = 3;
for (let j = -rad; j <= rad; j++) {
for (let i = -rad; i <= rad; i++) {
const q = (i * i + j * j) / (rad * rad);
if (q > 1) continue;
const gi = Math.round(cx) + i, gj = Math.round(cy) + j;
if (gi < 1 || gi > N || gj < 1 || gj > N) continue;
d[IX(gi, gj)] += Math.exp(-q * 2.4) * amp;
}
}
}
function inject(t: number): void {
const ax = N * 0.27 + Math.sin(t * 1.1) * N * 0.09;
const ay = N * 0.3 + Math.cos(t * 0.9) * N * 0.08;
const bx = N * 0.7 + Math.sin(t * 0.8 + 2.1) * N * 0.08;
const by = N * 0.7 + Math.cos(t * 1.3 + 1.4) * N * 0.07;
const mx = N * 0.5 + Math.sin(t * 0.55 + 4.0) * N * 0.13;
const my = N * 0.5 + Math.cos(t * 0.62 + 1.0) * N * 0.12;
splatDye(r0.current, ax, ay, 1.3);
splatDye(b0.current, bx, by, 1);
splatDye(g0.current, mx, my, 0.8);
}
function step(dt: number): void {
velSource();
u0.current.set(u.current); v0.current.set(v.current);
advect(u.current, u0.current, dt); u0.current.set(u.current);
advect(v.current, v0.current, dt); v0.current.set(v.current);
velBounds();
r0.current.set(r.current); g0.current.set(g.current); b0.current.set(bC.current);
inject(seedTRef.current);
diffuse(r.current, r0.current, 0.09); diffuse(g.current, g0.current, 0.09); diffuse(bC.current, b0.current, 0.09);
r0.current.set(r.current); g0.current.set(g.current); b0.current.set(bC.current);
advect(r.current, r0.current, dt); advect(g.current, g0.current, dt); advect(bC.current, b0.current, dt);
dyeBounds(r.current); dyeBounds(g.current); dyeBounds(bC.current);
const rr = r.current, gg = g.current, bb = bC.current;
for (let k = 0; k < SIZE; k++) { rr[k] *= 0.996; gg[k] *= 0.996; bb[k] *= 0.996; }
seedTRef.current += dt;
}
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 rr = r.current, gg = g.current, bb = bC.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 idx = IX(gx + 1, gy + 1);
const qr = Math.min(1.6, rr[idx]) / 1.6;
const qg = Math.min(1.6, gg[idx]) / 1.6;
const qb = Math.min(1.6, bb[idx]) / 1.6;
const o = (py * cv.width + pxx) * 4;
px[o] = Math.min(255, pal.cr[0] + qr * (pal.k1[0] - pal.cr[0]) + qg * (pal.k2[0] - pal.cr[0]) + qb * (pal.k3[0] - pal.cr[0]));
px[o + 1] = Math.min(255, pal.cr[1] + qr * (pal.k1[1] - pal.cr[1]) + qg * (pal.k2[1] - pal.cr[1]) + qb * (pal.k3[1] - pal.cr[1]));
px[o + 2] = Math.min(255, pal.cr[2] + qr * (pal.k1[2] - pal.cr[2]) + qg * (pal.k2[2] - pal.cr[2]) + qb * (pal.k3[2] - pal.cr[2]));
px[o + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function frame(): void {
step(1 / 30);
render();
rafRef.current = requestAnimationFrame(frame);
}
function splat(gx: number, gy: number, fx: number, fy: number, amt: number): void {
const rad = 4;
const uu = u.current, vv = v.current;
for (let j = -rad; j <= rad; j++) {
for (let i = -rad; i <= rad; i++) {
const q = (i * i + j * j) / (rad * rad);
if (q > 1) continue;
if (gx + i < 1 || gx + i > N || gy + j < 1 || gy + j > N) continue;
const f = Math.exp(-q * 3) * amt;
const idx = IX(gx + i, gy + j);
uu[idx] += fx * f; vv[idx] += fy * f;
}
}
}
function onMove(e: PtrEvt): void {
const field = fieldRef.current;
if (!field) return;
const rect = field.getBoundingClientRect();
const gx = 1 + Math.round((e.clientX - rect.left) / rect.width * (N - 1));
const gy = 1 + Math.round((e.clientY - rect.top) / rect.height * (N - 1));
const last = lastRef.current;
if (last) {
const fx = (e.clientX - last.x) * 0.18, fy = (e.clientY - last.y) * 0.18;
splat(gx, gy, fx, fy, Math.min(2, Math.hypot(fx, fy)));
} else {
splat(gx, gy, 0, 0, 0.9);
}
lastRef.current = { x: e.clientX, y: e.clientY };
if (reduced()) render();
}
function onLeave(): void { lastRef.current = null; }
function onKey(e: KeyEvt): void {
const dir: Record<string, number[]> = { ArrowLeft: [-2, 0], ArrowRight: [2, 0], ArrowUp: [0, -2], ArrowDown: [0, 2] };
const dv = dir[e.key];
if (!dv) return;
e.preventDefault();
splat(N >> 1, N >> 1, dv[0], dv[1], 1.2);
if (reduced()) render();
}
function onResize(): void { fit(); if (reduced()) render(); }
function hexToRgb(c: string): number[] {
return [parseInt(c.slice(1, 3), 16), parseInt(c.slice(3, 5), 16), parseInt(c.slice(5, 7), 16)];
}
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 pr = css.getPropertyValue("--dye-r").trim();
const pg = css.getPropertyValue("--dye-g").trim();
const pb = css.getPropertyValue("--dye-b").trim();
const pp = css.getPropertyValue("--dye-paper").trim();
if (pr && pg && pb && pp) {
palRef.current = { cr: hexToRgb(pp), k1: hexToRgb(pr), k2: hexToRgb(pg), k3: hexToRgb(pb) };
}
}
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
if (root) root.classList.add("is-still");
for (let k = 0; k < 44; k++) step(1 / 30);
render();
} else {
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="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,184a80,80,0,1,1,80-80A80.09,80.09,0,0,1,128,208ZM112,72v56a16,16,0,0,0,32,0V72a16,16,0,0,0-32,0Z" opacity=".2"/><path d="M128,16A112,112,0,1,0,240,128,112.12,112.12,0,0,0,128,16Zm0,192a80,80,0,1,1,80-80A80.09,80.09,0,0,1,128,208ZM112,72v56a16,16,0,0,0,32,0V72a16,16,0,0,0-32,0Z"/></g></svg>
<span>채널 세 개가 각자 흘러 섞입니다 — 커서로 두 색 사이를 저어 보세요</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label="염료 혼합 실험판"
tabIndex={0}
onPointerMove={onMove}
onPointerLeave={onLeave}
onKeyDown={onKey}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true">3<span className={styles.badgeUnit}>채널</span></b>
</div>
</div>
</main>
);
}07 · 染料の混合 — 計測 累積面積 43.042% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
レンダリング実測は、累積面積 43.042% · 強度 21.6 · 動いたフレーム 23/23 である。
// 染料3チャンネルをそれぞれ独立に移流・拡散する
for (var c = 0; c < 3; c++) {
advectChannel(dye[c], dye0[c], dt);
diffuseChannel(dye[c], dye0[c], dt);
}
// 3チャンネルの密度をそのままRGBにマップ
for (var k = 0; k < size; k++) {
px[k * 4] = palR(dye[0][k]); // チャンネル0 → R
px[k * 4 + 1] = palG(dye[1][k]); // チャンネル1 → G
px[k * 4 + 2] = palB(dye[2][k]); // チャンネル2 → B
}
08ポインターでかき混ぜる
何が見えるか
ポインターの移動がそのまま力と染料の注入になる — なぞった軌跡に沿って流体が引きずられ、離すと慣性で流れ続け、減衰しながら静まっていく。pointermove の差分を速度場に足すだけで実現でき、流体シミュレーション web ページの最小構成のインタラクションとしてそのまま使える。

<main class="stage stage--paper">
<div class="ps" id="ps">
<p class="ps__hint">
<svg class="ps__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M100.69,180.7,41.34,121.35a16,16,0,0,1,0-22.63L98.72,41.34a16,16,0,0,1,22.63,0l99.31,99.32a16,16,0,0,1,0,22.62l-57.38,57.38a16,16,0,0,1-22.63,0Z" opacity=".2"/><path d="M228.69,133.66,131.32,36.28a24,24,0,0,0-33.94,0L38.34,95.31a24,24,0,0,0,0,34l97.37,97.37a24,24,0,0,0,33.94,0l59-59A24,24,0,0,0,228.69,133.66Zm-16.97,17L152.68,209.7a8,8,0,0,1-11.31,0L44,112.34a8,8,0,0,1,0-11.32L103.06,42a8,8,0,0,1,11.31,0l97.35,97.35A8,8,0,0,1,211.72,150.63Z"/></g></svg>
<span>カーソルが動いた分だけ速度場を押し出します — 速くかき混ぜるほど渦が大きくなります</span>
</p>
<div class="ps__field" id="field" role="application" aria-label="ポインターかき混ぜ実験盤" tabindex="0">
<canvas class="ps__cv" id="cv"></canvas>
<b class="ps__badge" aria-hidden="true">stir<span class="ps__badgeUnit">splat</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 유체 포인터 휘젓기 — 이 편의 인터랙션 중심. 움직임은 전부 캔버스 rAF 루프 안에 있다.
// @keyframes 를 두지 않는다 — 렌더러가 CSS 시계 대신 실시간으로 24장을 찍는다.
// 무대는 $stage-paper(흰 종이). 어두운 파스텔 염료 두 색을 그 위에 직접 잡는다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.ps {
// 짙은 파스텔 틸(채도 .18)·자두(채도 .20) — 흰 종이 위 대비 8~10 대
--dye-teal: #33454a;
--dye-plum: #4a3550;
--dye-paper: #ffffff;
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ps__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-ink;
}
.ps__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-ink; }
.ps__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
cursor: crosshair; touch-action: none;
}
.ps__field:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.ps__cv { display: block; width: 100%; height: 100%; }
.ps__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, .82);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.ps__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.ps { gap: $sp-1; }
.ps__field { height: 124px; }
.ps__badge { font-size: 10px; padding: 0 $sp-1; }
}
// reduce — 루프가 멈춘 한 장이 남으므로 커서 신호만 바꾼다.
@media (prefers-reduced-motion: reduce) {
.ps.is-still .ps__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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ps {
--dye-teal: #33454a;
--dye-plum: #4a3550;
--dye-paper: #ffffff;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ps__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: #17141a;
}
.ps__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #17141a;
}
.ps__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
cursor: crosshair;
touch-action: none;
}
.ps__field:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.ps__cv {
display: block;
width: 100%;
height: 100%;
}
.ps__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.82);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.ps__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.ps {
gap: 4px;
}
.ps__field {
height: 124px;
}
.ps__badge {
font-size: 10px;
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.ps.is-still .ps__field {
cursor: default !important;
}
}// 유체 포인터 휘젓기 — 이 편 9종의 인터랙션 중심. 실제 포인터든 결정적 미리보기든
// 프레임 간 위치 차이(델타)를 같은 splat() 한 함수로 속도장에 꽂아 넣는다.
// 01 보다 반경·세기를 키워 "휘젓는 느낌"을 화면 전체로 퍼뜨린다.
var root = document.getElementById('ps');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d', { willReadFrequently: true });
var css = getComputedStyle(root);
var TEAL = css.getPropertyValue('--dye-teal').trim();
var PLUM = css.getPropertyValue('--dye-plum').trim();
var PAPER = css.getPropertyValue('--dye-paper').trim();
function hex(c) { return [parseInt(c.slice(1, 3), 16), parseInt(c.slice(3, 5), 16), parseInt(c.slice(5, 7), 16)]; }
var CR = hex(PAPER), K1 = hex(TEAL), K2 = hex(PLUM);
var N = 40;
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 reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, raf = 0, seedT = 0;
function fit() {
var rect = field.getBoundingClientRect();
W = rect.width; H = rect.height;
cv.width = Math.round(W); cv.height = Math.round(H);
}
function advect(b, dt) {
var src = b === 0 ? u0 : b === 1 ? v0 : d0;
var dst = b === 0 ? u : b === 1 ? 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, i1 = i0 + 1;
if (y < 0.5) y = 0.5; else if (y > N + 0.5) y = N + 0.5;
var j0 = y | 0, 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 bounds() {
for (var i = 1; i <= N; i++) {
d[IX(i, 0)] = d[IX(i, 1)] * 0.5; d[IX(i, N + 1)] = d[IX(i, N)] * 0.5;
u[IX(i, 0)] = u[IX(i, 1)] * 0.5; u[IX(i, N + 1)] = u[IX(i, N)] * 0.5;
v[IX(i, 0)] = v[IX(i, 1)] * 0.5; v[IX(i, N + 1)] = v[IX(i, N)] * 0.5;
}
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.5; u[IX(N + 1, j)] = u[IX(N, j)] * 0.5;
v[IX(0, j)] = v[IX(1, j)] * 0.5; v[IX(N + 1, j)] = v[IX(N, j)] * 0.5;
}
}
function step(dt) {
u0.set(u); v0.set(v); d0.set(d);
advect(0, dt); u0.set(u);
advect(1, dt); v0.set(v);
advect(2, dt);
bounds();
for (var k = 0; k < SIZE; k++) { u[k] *= 0.992; v[k] *= 0.992; d[k] *= 0.986; }
seedT += dt;
}
// splat — 인터랙션의 유일한 진입점. 실제 포인터도, 아래 lissajous 미리보기도 이 함수 하나만 부른다.
// 01(반경 3, amt 계수 3)보다 반경을 두 배 이상 키우고 완만한 지수 감쇠를 써 소용돌이가 넓게 번지게 한다.
function splat(gx, gy, fx, fy, amt) {
var rad = 7;
for (var j = -rad; j <= rad; j++) {
for (var i = -rad; i <= rad; i++) {
var q = (i * i + j * j) / (rad * rad);
if (q > 1) continue;
var gi = gx + i, gj = gy + j;
if (gi < 1 || gi > N || gj < 1 || gj > N) continue;
var f = Math.exp(-q * 1.8) * amt;
var idx = IX(gi, gj);
u[idx] += fx * f; v[idx] += fy * f;
d[idx] = Math.min(2.4, d[idx] + f * 0.62);
}
}
}
// 결정적 미리보기 — 리사주 곡선 위를 도는 가짜 포인터. 이전 위치와의 델타를 실제 포인터와
// 똑같이 계산해 splat() 에 넘긴다. seedT 로 고정되므로 gif·poster 는 항상 같은 궤적을 그린다.
var lastLX = null, lastLY = null;
function inject(t) {
var lx = N * 0.5 + Math.sin(t * 1.3) * N * 0.36;
var ly = N * 0.5 + Math.sin(t * 2.1 + 1.2) * N * 0.30;
var gx = Math.round(lx), gy = Math.round(ly);
if (lastLX !== null) {
var fx = (lx - lastLX) * 5.2, fy = (ly - lastLY) * 5.2;
splat(gx, gy, fx, fy, Math.min(2.6, Math.hypot(fx, fy) * 0.9 + 0.4));
} else {
splat(gx, gy, 0, 0, 1.1);
}
lastLX = lx; lastLY = ly;
}
function render() {
var img = ctx.createImageData(cv.width, cv.height);
var px = img.data;
var cellW = cv.width / N, cellH = cv.height / N;
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.02) { px[o] = CR[0]; px[o + 1] = CR[1]; px[o + 2] = CR[2]; px[o + 3] = 255; continue; }
var a = Math.min(1, q);
var band = q > 1.1 ? K2 : K1;
px[o] = CR[0] + (band[0] - CR[0]) * a;
px[o + 1] = CR[1] + (band[1] - CR[1]) * a;
px[o + 2] = CR[2] + (band[2] - CR[2]) * a;
px[o + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function frame() {
inject(seedT);
step(1 / 30);
render();
raf = requestAnimationFrame(frame);
}
var last = null;
field.addEventListener('pointermove', function (e) {
var rect = field.getBoundingClientRect();
var gx = 1 + Math.round((e.clientX - rect.left) / rect.width * (N - 1));
var gy = 1 + Math.round((e.clientY - rect.top) / rect.height * (N - 1));
if (last) {
var fx = (e.clientX - last.x) * 0.42, fy = (e.clientY - last.y) * 0.42;
splat(gx, gy, fx, fy, Math.min(2.6, Math.hypot(fx, fy)));
} else {
splat(gx, gy, 0, 0, 1.1);
}
last = { x: e.clientX, y: e.clientY };
if (reduce) render();
});
field.addEventListener('pointerleave', function () { last = null; });
field.addEventListener('keydown', function (e) {
var dir = { ArrowLeft: [-3, 0], ArrowRight: [3, 0], ArrowUp: [0, -3], ArrowDown: [0, 3] }[e.key];
if (!dir) return;
e.preventDefault();
splat(N >> 1, N >> 1, dir[0], dir[1], 1.8);
if (reduce) render();
});
window.addEventListener('resize', function () { fit(); if (reduce) render(); });
fit();
if (reduce) {
// reduce — 리사주 미리보기를 46 걸음 앞으로 감아 소용돌이가 자리 잡은 한 장을 그린다
for (var k = 0; k < 46; k++) { inject(seedT); step(1 / 30); }
render();
} else {
raf = requestAnimationFrame(frame);
}// PointerStir — vanilla 판(run/308/demo/08-fluid-pointer-stir/index.html) 의 React 포트.
// 이 편 9종의 인터랙션 중심. 실제 포인터든 결정적 리사주 미리보기든 프레임 간 델타를
// 같은 splat() 한 함수로 속도장에 꽂아 넣는다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./PointerStir.module.scss";
export type PointerStirProps = {
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 };
const N = 40;
const SIZE = (N + 2) * (N + 2);
function IX(x: number, y: number): number { return x + (N + 2) * y; }
export default function PointerStir({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: PointerStirProps) {
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({ cr: [255, 255, 255], k1: [51, 69, 74], k2: [74, 53, 80] }) as Box<{ cr: number[]; k1: number[]; k2: number[] }>;
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 lastRef = useRef<{ x: number; y: number } | null>(null) as Box<{ x: number; y: number } | null>;
const lastLRef = useRef<{ x: number; y: number } | null>(null) as Box<{ x: number; y: number } | null>;
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 rect = field.getBoundingClientRect();
cv.width = Math.round(rect.width);
cv.height = Math.round(rect.height);
}
function advect(b: 0 | 1 | 2, dt: number): void {
const src = b === 0 ? u0.current : b === 1 ? v0.current : d0.current;
const dst = b === 0 ? u.current : b === 1 ? 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 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.5; dd[IX(i, N + 1)] = dd[IX(i, N)] * 0.5;
uu[IX(i, 0)] = uu[IX(i, 1)] * 0.5; uu[IX(i, N + 1)] = uu[IX(i, N)] * 0.5;
vv[IX(i, 0)] = vv[IX(i, 1)] * 0.5; vv[IX(i, N + 1)] = vv[IX(i, N)] * 0.5;
}
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.5; uu[IX(N + 1, j)] = uu[IX(N, j)] * 0.5;
vv[IX(0, j)] = vv[IX(1, j)] * 0.5; vv[IX(N + 1, j)] = vv[IX(N, j)] * 0.5;
}
}
function step(dt: number): void {
u0.current.set(u.current); v0.current.set(v.current); d0.current.set(d.current);
advect(0, dt); u0.current.set(u.current);
advect(1, dt); v0.current.set(v.current);
advect(2, dt);
bounds();
const uu = u.current, vv = v.current, dd = d.current;
for (let k = 0; k < SIZE; k++) { uu[k] *= 0.992; vv[k] *= 0.992; dd[k] *= 0.986; }
seedTRef.current += dt;
}
function splat(gx: number, gy: number, fx: number, fy: number, amt: number): void {
const rad = 7;
const uu = u.current, vv = v.current, dd = d.current;
for (let j = -rad; j <= rad; j++) {
for (let i = -rad; i <= rad; i++) {
const q = (i * i + j * j) / (rad * rad);
if (q > 1) continue;
const gi = gx + i, gj = gy + j;
if (gi < 1 || gi > N || gj < 1 || gj > N) continue;
const f = Math.exp(-q * 1.8) * amt;
const idx = IX(gi, gj);
uu[idx] += fx * f; vv[idx] += fy * f;
dd[idx] = Math.min(2.4, dd[idx] + f * 0.62);
}
}
}
function inject(t: number): void {
const lx = N * 0.5 + Math.sin(t * 1.3) * N * 0.36;
const ly = N * 0.5 + Math.sin(t * 2.1 + 1.2) * N * 0.30;
const gx = Math.round(lx), gy = Math.round(ly);
const last = lastLRef.current;
if (last) {
const fx = (lx - last.x) * 5.2, fy = (ly - last.y) * 5.2;
splat(gx, gy, fx, fy, Math.min(2.6, Math.hypot(fx, fy) * 0.9 + 0.4));
} else {
splat(gx, gy, 0, 0, 1.1);
}
lastLRef.current = { x: lx, y: ly };
}
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.02) { px[o] = pal.cr[0]; px[o + 1] = pal.cr[1]; px[o + 2] = pal.cr[2]; px[o + 3] = 255; continue; }
const a = Math.min(1, q);
const band = q > 1.1 ? pal.k2 : pal.k1;
px[o] = pal.cr[0] + (band[0] - pal.cr[0]) * a;
px[o + 1] = pal.cr[1] + (band[1] - pal.cr[1]) * a;
px[o + 2] = pal.cr[2] + (band[2] - pal.cr[2]) * a;
px[o + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function frame(): void {
inject(seedTRef.current);
step(1 / 30);
render();
rafRef.current = requestAnimationFrame(frame);
}
function onMove(e: PtrEvt): void {
const field = fieldRef.current;
if (!field) return;
const rect = field.getBoundingClientRect();
const gx = 1 + Math.round((e.clientX - rect.left) / rect.width * (N - 1));
const gy = 1 + Math.round((e.clientY - rect.top) / rect.height * (N - 1));
const last = lastRef.current;
if (last) {
const fx = (e.clientX - last.x) * 0.42, fy = (e.clientY - last.y) * 0.42;
splat(gx, gy, fx, fy, Math.min(2.6, Math.hypot(fx, fy)));
} else {
splat(gx, gy, 0, 0, 1.1);
}
lastRef.current = { x: e.clientX, y: e.clientY };
if (reduced()) render();
}
function onLeave(): void { lastRef.current = null; }
function onKey(e: KeyEvt): void {
const dir: Record<string, number[]> = { ArrowLeft: [-3, 0], ArrowRight: [3, 0], ArrowUp: [0, -3], ArrowDown: [0, 3] };
const dv = dir[e.key];
if (!dv) return;
e.preventDefault();
splat(N >> 1, N >> 1, dv[0], dv[1], 1.8);
if (reduced()) render();
}
function onResize(): void { fit(); if (reduced()) render(); }
function hexToRgb(c: string): number[] {
return [parseInt(c.slice(1, 3), 16), parseInt(c.slice(3, 5), 16), parseInt(c.slice(5, 7), 16)];
}
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 pt = css.getPropertyValue("--dye-teal").trim();
const pp = css.getPropertyValue("--dye-plum").trim();
const pw = css.getPropertyValue("--dye-paper").trim();
if (pt && pp && pw) {
palRef.current = { cr: hexToRgb(pw), k1: hexToRgb(pt), k2: hexToRgb(pp) };
}
}
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
if (root) root.classList.add("is-still");
for (let k = 0; k < 46; k++) { inject(seedTRef.current); step(1 / 30); }
render();
} else {
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="M100.69,180.7,41.34,121.35a16,16,0,0,1,0-22.63L98.72,41.34a16,16,0,0,1,22.63,0l99.31,99.32a16,16,0,0,1,0,22.62l-57.38,57.38a16,16,0,0,1-22.63,0Z" opacity=".2"/><path d="M228.69,133.66,131.32,36.28a24,24,0,0,0-33.94,0L38.34,95.31a24,24,0,0,0,0,34l97.37,97.37a24,24,0,0,0,33.94,0l59-59A24,24,0,0,0,228.69,133.66Zm-16.97,17L152.68,209.7a8,8,0,0,1-11.31,0L44,112.34a8,8,0,0,1,0-11.32L103.06,42a8,8,0,0,1,11.31,0l97.35,97.35A8,8,0,0,1,211.72,150.63Z"/></g></svg>
<span>커서가 움직인 만큼 속도장을 밀어냅니다 — 빠르게 휘저을수록 소용돌이가 커집니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label="포인터 휘젓기 실험판"
tabIndex={0}
onPointerMove={onMove}
onPointerLeave={onLeave}
onKeyDown={onKey}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true">stir<span className={styles.badgeUnit}>splat</span></b>
</div>
</div>
</main>
);
}08 · ポインタのかき混ぜ — 計測 累積面積 51.135% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
レンダリング実測は、累積面積 51.135% · 強度 62.1 · 動いたフレーム 23/23 である。
canvas.addEventListener('pointermove', function (e) {
var dx = e.offsetX / W - last.x, dy = e.offsetY / H - last.y;
// ポインターの移動差分を速度と染料の両方に加算
var i = 1 + Math.floor(e.offsetX / W * N);
var j = 1 + Math.floor(e.offsetY / H * N);
u0[IX(i, j)] += dx * FORCE;
v0[IX(i, j)] += dy * FORCE;
d0[IX(i, j)] += 0.35; // なぞった跡に染料を残す
last.x = e.offsetX / W; last.y = e.offsetY / H;
});
09密度場の可視化
何が見えるか
計算ではなく描画の問題に焦点を当てた1枚 — 密度の数値を等高線風の階調に量子化して示すと、同じ格子計算でも見え方が測定器のように変わる。流体シミュレーション web 上で動く学習素材として、目に見える「美しさ」と計算の中身の「数値」を橋渡しするビューだ。

<main class="stage stage--orange">
<div class="df" id="df">
<p class="df__hint">
<svg class="df__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M200,32H56A24,24,0,0,0,32,56V200a24,24,0,0,0,24,24H200a24,24,0,0,0,24-24V56A24,24,0,0,0,200,32Zm8,168a8,8,0,0,1-8,8H56a8,8,0,0,1-8-8V56a8,8,0,0,1,8-8H200a8,8,0,0,1,8,8Z" opacity=".2"/><path d="M224,48V208a16,16,0,0,1-16,16H48a16,16,0,0,1-16-16V48A16,16,0,0,1,48,32H208A16,16,0,0,1,224,48Z" opacity="0"/><path d="M200,40H56A16,16,0,0,0,40,56V200a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V56A16,16,0,0,0,200,40ZM96,200H56V168H96Zm0-56H56V112H96Zm0-56H56V56H96Zm104,112H112V168h88Zm0-56H112V112h88Zm0-56H112V56h88Z"/></g></svg>
<span>同じ32×32の密度配列を二回描きます — 左はマス目そのまま、右は補間で滑らかにします</span>
</p>
<div class="df__duo" id="duo" role="application" aria-label="密度場拡大比較実験盤" tabindex="0">
<div class="df__pane">
<canvas class="df__cv" id="cvA"></canvas>
<b class="df__tag">最近傍</b>
</div>
<div class="df__pane">
<canvas class="df__cv" id="cvB"></canvas>
<b class="df__tag">補間</b>
</div>
<b class="df__badge" aria-hidden="true">32<span class="df__badgeUnit">×32</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 유체 밀도장 확대 비교 — 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·두 판·라벨.
// @keyframes 를 두지 않는다 — 렌더러가 CSS 시계 대신 실시간으로 24장을 찍는다.
// 무대는 $stage-orange(토큰 참조라 BD26 대상 아님). 밀도 리터럴은 따뜻한 파스텔로 직접 잡는다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
.df {
// 밀도 농도 세 단계 — 모래(채도 .53)·밀짚(.52)·황동(.50). 종이색 base 는 주황 무대 위 판색
--den-1: #e6d0ad;
--den-2: #dab88a;
--den-3: #c9985e;
--den-paper: #fff7ea;
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.df__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-ink;
}
.df__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-ink; }
.df__duo {
position: relative; height: 208px;
display: grid; grid-template-columns: 1fr 1fr; gap: $sp-1;
cursor: crosshair; touch-action: none;
}
.df__duo:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.df__pane {
position: relative;
border-radius: $r-card; overflow: hidden;
background: #fff7ea;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .22);
}
.df__cv { display: block; width: 100%; height: 100%; }
.df__tag {
position: absolute; left: $sp-1; bottom: $sp-1;
padding: 0 $sp-1; border-radius: $r-pill;
background: rgba($subject-ink, .82);
color: $subject-cream; font-weight: 700; font-size: 10px; line-height: 18px;
}
.df__badge {
position: absolute; right: $sp-1; top: $sp-1;
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($subject-ink, .82);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.df__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.df { gap: $sp-1; }
.df__duo { height: 124px; }
.df__badge { font-size: 10px; padding: 0 $sp-1; }
}
// reduce — 루프가 멈춘 한 장이 남으므로 커서 신호만 바꾼다.
@media (prefers-reduced-motion: reduce) {
.df.is-still .df__duo { 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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.df {
--den-1: #e6d0ad;
--den-2: #dab88a;
--den-3: #c9985e;
--den-paper: #fff7ea;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.df__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: #17141a;
}
.df__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #17141a;
}
.df__duo {
position: relative;
height: 208px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
cursor: crosshair;
touch-action: none;
}
.df__duo:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.df__pane {
position: relative;
border-radius: 16px;
overflow: hidden;
background: #fff7ea;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.22);
}
.df__cv {
display: block;
width: 100%;
height: 100%;
}
.df__tag {
position: absolute;
left: 4px;
bottom: 4px;
padding: 0 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.82);
color: #fff7e6;
font-weight: 700;
font-size: 10px;
line-height: 18px;
}
.df__badge {
position: absolute;
right: 4px;
top: 4px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.82);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.df__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.df {
gap: 4px;
}
.df__duo {
height: 124px;
}
.df__badge {
font-size: 10px;
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.df.is-still .df__duo {
cursor: default !important;
}
}// 유체 밀도장 확대 비교 — 시뮬레이션 데이터는 32×32 한 장뿐이다(그게 이 항목의 핵심 소재).
// 같은 배열을 두 캔버스에 그리되 왼쪽은 imageSmoothingEnabled=false(最近傍 이웃, 칸이 그대로 보임),
// 오른쪽은 true + quality high(쌍선형 補間, 부드럽게 펴짐)로 확대한다. 렌더 경로만 다르다.
var root = document.getElementById('df');
var duo = document.getElementById('duo');
var cvA = document.getElementById('cvA');
var cvB = document.getElementById('cvB');
var ctxA = cvA.getContext('2d', { willReadFrequently: true });
var ctxB = cvB.getContext('2d', { willReadFrequently: true });
var css = getComputedStyle(root);
var W1 = css.getPropertyValue('--den-1').trim();
var W2 = css.getPropertyValue('--den-2').trim();
var W3 = css.getPropertyValue('--den-3').trim();
var PAPER = css.getPropertyValue('--den-paper').trim();
function hex(c) { return [parseInt(c.slice(1, 3), 16), parseInt(c.slice(3, 5), 16), parseInt(c.slice(5, 7), 16)]; }
var CR = hex(PAPER), C1 = hex(W1), C2 = hex(W2), C3 = hex(W3);
// N=32 고정 — 이 값 자체가 보여주고 싶은 내용이다
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 reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, seedT = 0;
// 오프스크린 32×32 원본 — 이 한 장을 양쪽이 같이 쓴다
var src = document.createElement('canvas');
src.width = N; src.height = N;
var sctx = src.getContext('2d');
var simg = sctx.createImageData(N, N);
function fit() {
var rect = duo.getBoundingClientRect();
var w = Math.round(rect.width), h = Math.round(rect.height);
cvA.width = w; cvA.height = h;
cvB.width = w; cvB.height = h;
}
function advect(b, dt) {
var s = b === 0 ? u0 : b === 1 ? v0 : d0;
var dst = b === 0 ? u : b === 1 ? 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, i1 = i0 + 1;
if (y < 0.5) y = 0.5; else if (y > N + 0.5) y = N + 0.5;
var j0 = y | 0, j1 = j0 + 1;
var s1 = x - i0, s0 = 1 - s1, t1 = y - j0, t0 = 1 - t1;
dst[IX(i, j)] = s0 * (t0 * s[IX(i0, j0)] + t1 * s[IX(i0, j1)]) +
s1 * (t0 * s[IX(i1, j0)] + t1 * s[IX(i1, j1)]);
}
}
}
function bounds() {
for (var i = 1; i <= N; i++) {
d[IX(i, 0)] = d[IX(i, 1)] * 0.5; d[IX(i, N + 1)] = d[IX(i, N)] * 0.5;
u[IX(i, 0)] = u[IX(i, 1)] * 0.5; u[IX(i, N + 1)] = u[IX(i, N)] * 0.5;
v[IX(i, 0)] = v[IX(i, 1)] * 0.5; v[IX(i, N + 1)] = v[IX(i, N)] * 0.5;
}
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.5; u[IX(N + 1, j)] = u[IX(N, j)] * 0.5;
v[IX(0, j)] = v[IX(1, j)] * 0.5; v[IX(N + 1, j)] = v[IX(N, j)] * 0.5;
}
}
// 결정적 시드 — 두 시드가 대각선으로 지나가며 밀도를 심는다. seedT 고정이라 매번 같은 흐름
function inject(t) {
var ax = N * 0.34 + Math.sin(t * 0.8) * N * 0.16;
var ay = N * 0.3 + t * N * 0.055;
var bx = N * 0.66 + Math.sin(t * 1.1 + 2.0) * N * 0.14;
var by = N * 0.75 - Math.sin(t * 0.7) * N * 0.1;
splatDye(Math.round(ax), Math.round(ay), 1);
splatDye(Math.round(bx), Math.round(by), 0.85);
}
function splatDye(cx, cy, amp) {
var rad = 2.4;
for (var j = -3; j <= 3; j++) {
for (var i = -3; i <= 3; i++) {
var q = (i * i + j * j) / (rad * rad);
if (q > 1) continue;
var gi = cx + i, gj = cy + j;
if (gi < 1 || gi > N || gj < 1 || gj > N) continue;
d[IX(gi, gj)] += Math.exp(-q * 2.2) * amp;
}
}
}
// 느린 확산 바람 — 밀도가 칸 사이를 스며들게 해 두 렌더의 차이를 계속 보여준다
function drift() {
var cx = N * 0.5, cy = N * 0.5;
for (var j = 2; j <= N - 1; j++) {
for (var i = 2; i <= N - 1; i++) {
var dx = i - cx, dy = j - cy;
var q = Math.sqrt(dx * dx + dy * dy) / (N * 0.5);
var w = Math.exp(-q * q * 1.6) * 0.4;
u[IX(i, j)] += (-dy * 0.045 + 0.07) * w;
v[IX(i, j)] += (dx * 0.045) * w;
}
}
}
function step(dt) {
drift();
inject(seedT);
u0.set(u); v0.set(v); d0.set(d);
advect(0, dt); u0.set(u);
advect(1, dt); v0.set(v);
advect(2, dt);
bounds();
for (var k = 0; k < SIZE; k++) { d[k] *= 0.988; u[k] *= 0.99; v[k] *= 0.99; }
seedT += dt;
}
// 오프스크린 32×32 한 장을 그린다 — 여기까지가 데이터. 확대는 아래 두 호출이 다르게 한다
function paintSource() {
var px = simg.data;
for (var j = 0; j < N; j++) {
for (var i = 0; i < N; i++) {
var q = d[IX(i + 1, j + 1)];
var band, a;
if (q <= 0.02) { band = CR; a = 0; }
else if (q > 1.15) { band = C3; a = Math.min(1, q - 0.15); }
else if (q > 0.5) { band = C2; a = Math.min(1, q + 0.1); }
else { band = C1; a = Math.min(1, q * 1.6); }
var o = (j * N + i) * 4;
px[o] = CR[0] + (band[0] - CR[0]) * a;
px[o + 1] = CR[1] + (band[1] - CR[1]) * a;
px[o + 2] = CR[2] + (band[2] - CR[2]) * a;
px[o + 3] = 255;
}
}
sctx.putImageData(simg, 0, 0);
}
function render() {
paintSource();
var w = cvA.width, h = cvA.height;
// 왼쪽 — 最近傍 이웃: 칸이 네모 그대로 커진다
ctxA.imageSmoothingEnabled = false;
ctxA.clearRect(0, 0, w, h);
ctxA.drawImage(src, 0, 0, w, h);
// 오른쪽 — 쌍선형 補間: 같은 소스를 부드럽게 펴낸다
ctxB.imageSmoothingEnabled = true;
ctxB.imageSmoothingQuality = 'high';
ctxB.clearRect(0, 0, w, h);
ctxB.drawImage(src, 0, 0, w, h);
}
function frame() {
step(1 / 30);
render();
raf = requestAnimationFrame(frame);
}
// 사람 손 — 간단한 스플랏. 두 판이 같은 배열을 쓰므로 한 번의 스플랏이 양쪽에 동시에 나타난다
function splat(gx, gy, fx, fy, amt) {
var rad = 3;
for (var j = -rad; j <= rad; j++) {
for (var i = -rad; i <= rad; i++) {
var q = (i * i + j * j) / (rad * rad);
if (q > 1) continue;
var gi = gx + i, gj = gy + j;
if (gi < 1 || gi > N || gj < 1 || gj > N) continue;
var f = Math.exp(-q * 2.6) * amt;
var idx = IX(gi, gj);
u[idx] += fx * f; v[idx] += fy * f;
d[idx] = Math.min(2, d[idx] + f * 0.8);
}
}
}
var last = null;
var target = null;
duo.addEventListener('pointerdown', function (e) {
var rect = duo.getBoundingClientRect();
var half = rect.width / 2;
target = (e.clientX - rect.left) < half ? cvA : cvB;
target.dispatchEvent(new PointerEvent('pointermove', { clientX: e.clientX, clientY: e.clientY }));
});
duo.addEventListener('pointermove', function (e) {
if (!target && e.pointerType === 'mouse' && e.buttons === 0) {
// 마우스는 누르지 않아도 흐르는 배경 시드가 있으니 스플랏은 억제한다(08 과 체감을 구분)
}
var host = target || duo;
var rect = host.getBoundingClientRect();
var gx = 1 + Math.round((e.clientX - rect.left) / rect.width * (N - 1));
var gy = 1 + Math.round((e.clientY - rect.top) / rect.height * (N - 1));
if (last) {
var fx = (e.clientX - last.x) * 0.15, fy = (e.clientY - last.y) * 0.15;
splat(gx, gy, fx, fy, Math.min(1.8, Math.hypot(fx, fy)));
} else {
splat(gx, gy, 0, 0, 0.8);
}
last = { x: e.clientX, y: e.clientY };
if (reduce) render();
});
function endPtr() { last = null; target = null; }
duo.addEventListener('pointerup', endPtr);
duo.addEventListener('pointerleave', endPtr);
duo.addEventListener('keydown', function (e) {
var dir = { ArrowLeft: [-2, 0], ArrowRight: [2, 0], ArrowUp: [0, -2], ArrowDown: [0, 2] }[e.key];
if (!dir) return;
e.preventDefault();
splat(N >> 1, N >> 1, dir[0], dir[1], 1.4);
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 {
raf = requestAnimationFrame(frame);
}// DensityField — vanilla 판(run/308/demo/09-fluid-density-field/index.html) 의 React 포트.
// 시뮬레이션 데이터는 32×32 배열 한 장뿐(이 항목의 핵심 소재). 같은 오프스크린 소스를
// imageSmoothingEnabled=false(최근접)와 true+quality high(쌍선형)로 각각 확대해 나란히 비교한다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./DensityField.module.scss";
export type DensityFieldProps = {
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; buttons: number; pointerType: string };
const N = 32;
const SIZE = (N + 2) * (N + 2);
function IX(x: number, y: number): number { return x + (N + 2) * y; }
export default function DensityField({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#fff7e6",
}: DensityFieldProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const duoRef = useRef<HTMLDivElement | null>(null);
const cvARef = useRef<HTMLCanvasElement | null>(null);
const cvBRef = useRef<HTMLCanvasElement | null>(null);
const ctxARef = useRef<CanvasRenderingContext2D | null>(null);
const ctxBRef = useRef<CanvasRenderingContext2D | null>(null);
const srcRef = useRef<HTMLCanvasElement | null>(null);
const sctxRef = useRef<CanvasRenderingContext2D | null>(null);
const simgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const palRef = useRef({ cr: [255, 247, 234], c1: [230, 208, 173], c2: [218, 184, 138], c3: [201, 152, 94] }) as Box<{ cr: number[]; c1: number[]; c2: number[]; c3: number[] }>;
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 lastRef = useRef<{ x: number; y: number } | null>(null) as Box<{ x: number; y: number } | null>;
const targetRef = useRef<HTMLCanvasElement | null>(null) as Box<HTMLCanvasElement | null>;
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 duo = duoRef.current, cvA = cvARef.current, cvB = cvBRef.current;
if (!duo || !cvA || !cvB) return;
const rect = duo.getBoundingClientRect();
const w = Math.round(rect.width), h = Math.round(rect.height);
cvA.width = w; cvA.height = h;
cvB.width = w; cvB.height = h;
}
function advect(b: 0 | 1 | 2, dt: number): void {
const s = b === 0 ? u0.current : b === 1 ? v0.current : d0.current;
const dst = b === 0 ? u.current : b === 1 ? 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 * s[IX(i0, j0)] + t1 * s[IX(i0, j1)]) +
s1 * (t0 * s[IX(i1, j0)] + t1 * s[IX(i1, j1)]);
}
}
}
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.5; dd[IX(i, N + 1)] = dd[IX(i, N)] * 0.5;
uu[IX(i, 0)] = uu[IX(i, 1)] * 0.5; uu[IX(i, N + 1)] = uu[IX(i, N)] * 0.5;
vv[IX(i, 0)] = vv[IX(i, 1)] * 0.5; vv[IX(i, N + 1)] = vv[IX(i, N)] * 0.5;
}
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.5; uu[IX(N + 1, j)] = uu[IX(N, j)] * 0.5;
vv[IX(0, j)] = vv[IX(1, j)] * 0.5; vv[IX(N + 1, j)] = vv[IX(N, j)] * 0.5;
}
}
function splatDye(cx: number, cy: number, amp: number): void {
const rad = 2.4;
const dd = d.current;
for (let j = -3; j <= 3; j++) {
for (let i = -3; i <= 3; i++) {
const q = (i * i + j * j) / (rad * rad);
if (q > 1) continue;
const gi = cx + i, gj = cy + j;
if (gi < 1 || gi > N || gj < 1 || gj > N) continue;
dd[IX(gi, gj)] += Math.exp(-q * 2.2) * amp;
}
}
}
function inject(t: number): void {
const ax = N * 0.34 + Math.sin(t * 0.8) * N * 0.16;
const ay = N * 0.3 + t * N * 0.055;
const bx = N * 0.66 + Math.sin(t * 1.1 + 2.0) * N * 0.14;
const by = N * 0.75 - Math.sin(t * 0.7) * N * 0.1;
splatDye(Math.round(ax), Math.round(ay), 1);
splatDye(Math.round(bx), Math.round(by), 0.85);
}
function drift(): void {
const cx = N * 0.5, cy = N * 0.5;
const uu = u.current, vv = v.current;
for (let j = 2; j <= N - 1; j++) {
for (let i = 2; i <= N - 1; i++) {
const dx = i - cx, dy = j - cy;
const q = Math.sqrt(dx * dx + dy * dy) / (N * 0.5);
const w = Math.exp(-q * q * 1.6) * 0.4;
uu[IX(i, j)] += (-dy * 0.045 + 0.07) * w;
vv[IX(i, j)] += (dx * 0.045) * w;
}
}
}
function step(dt: number): void {
drift();
inject(seedTRef.current);
u0.current.set(u.current); v0.current.set(v.current); d0.current.set(d.current);
advect(0, dt); u0.current.set(u.current);
advect(1, dt); v0.current.set(v.current);
advect(2, dt);
bounds();
const dd = d.current, uu = u.current, vv = v.current;
for (let k = 0; k < SIZE; k++) { dd[k] *= 0.988; uu[k] *= 0.99; vv[k] *= 0.99; }
seedTRef.current += dt;
}
function paintSource(): void {
const sctx = sctxRef.current, simg = simgRef.current;
if (!sctx || !simg) return;
const px = simg.data;
const pal = palRef.current;
const dd = d.current;
for (let j = 0; j < N; j++) {
for (let i = 0; i < N; i++) {
const q = dd[IX(i + 1, j + 1)];
let band: number[], a: number;
if (q <= 0.02) { band = pal.cr; a = 0; }
else if (q > 1.15) { band = pal.c3; a = Math.min(1, q - 0.15); }
else if (q > 0.5) { band = pal.c2; a = Math.min(1, q + 0.1); }
else { band = pal.c1; a = Math.min(1, q * 1.6); }
const o = (j * N + i) * 4;
px[o] = pal.cr[0] + (band[0] - pal.cr[0]) * a;
px[o + 1] = pal.cr[1] + (band[1] - pal.cr[1]) * a;
px[o + 2] = pal.cr[2] + (band[2] - pal.cr[2]) * a;
px[o + 3] = 255;
}
}
sctx.putImageData(simg, 0, 0);
}
function render(): void {
paintSource();
const src = srcRef.current;
const ctxA = ctxARef.current, ctxB = ctxBRef.current;
const cvA = cvARef.current, cvB = cvBRef.current;
if (!src || !ctxA || !ctxB || !cvA || !cvB) return;
const w = cvA.width, h = cvA.height;
ctxA.imageSmoothingEnabled = false;
ctxA.clearRect(0, 0, w, h);
ctxA.drawImage(src, 0, 0, w, h);
ctxB.imageSmoothingEnabled = true;
ctxB.imageSmoothingQuality = "high";
ctxB.clearRect(0, 0, w, h);
ctxB.drawImage(src, 0, 0, w, h);
}
function frame(): void {
step(1 / 30);
render();
rafRef.current = requestAnimationFrame(frame);
}
function splat(gx: number, gy: number, fx: number, fy: number, amt: number): void {
const rad = 3;
const uu = u.current, vv = v.current, dd = d.current;
for (let j = -rad; j <= rad; j++) {
for (let i = -rad; i <= rad; i++) {
const q = (i * i + j * j) / (rad * rad);
if (q > 1) continue;
const gi = gx + i, gj = gy + j;
if (gi < 1 || gi > N || gj < 1 || gj > N) continue;
const f = Math.exp(-q * 2.6) * amt;
const idx = IX(gi, gj);
uu[idx] += fx * f; vv[idx] += fy * f;
dd[idx] = Math.min(2, dd[idx] + f * 0.8);
}
}
}
function onDown(e: PtrEvt): void {
const duo = duoRef.current;
if (!duo) return;
const rect = duo.getBoundingClientRect();
const half = rect.width / 2;
targetRef.current = (e.clientX - rect.left) < half ? cvARef.current : cvBRef.current;
}
function onMove(e: PtrEvt): void {
const host = targetRef.current || duoRef.current;
if (!host) return;
const rect = host.getBoundingClientRect();
const gx = 1 + Math.round((e.clientX - rect.left) / rect.width * (N - 1));
const gy = 1 + Math.round((e.clientY - rect.top) / rect.height * (N - 1));
const last = lastRef.current;
if (last) {
const fx = (e.clientX - last.x) * 0.15, fy = (e.clientY - last.y) * 0.15;
splat(gx, gy, fx, fy, Math.min(1.8, Math.hypot(fx, fy)));
} else {
splat(gx, gy, 0, 0, 0.8);
}
lastRef.current = { x: e.clientX, y: e.clientY };
if (reduced()) render();
}
function endPtr(): void { lastRef.current = null; targetRef.current = null; }
function onKey(e: KeyEvt): void {
const dir: Record<string, number[]> = { ArrowLeft: [-2, 0], ArrowRight: [2, 0], ArrowUp: [0, -2], ArrowDown: [0, 2] };
const dv = dir[e.key];
if (!dv) return;
e.preventDefault();
splat(N >> 1, N >> 1, dv[0], dv[1], 1.4);
if (reduced()) render();
}
function onResize(): void { fit(); if (reduced()) render(); }
function hexToRgb(c: string): number[] {
return [parseInt(c.slice(1, 3), 16), parseInt(c.slice(3, 5), 16), parseInt(c.slice(5, 7), 16)];
}
useEffect(() => {
const cvA = cvARef.current, cvB = cvBRef.current;
if (!cvA || !cvB) return;
ctxARef.current = cvA.getContext("2d", { willReadFrequently: true });
ctxBRef.current = cvB.getContext("2d", { willReadFrequently: true });
const src = document.createElement("canvas");
src.width = N; src.height = N;
srcRef.current = src;
sctxRef.current = src.getContext("2d");
if (sctxRef.current) simgRef.current = sctxRef.current.createImageData(N, N);
const root = rootRef.current;
if (root) {
const css = getComputedStyle(root);
const p1 = css.getPropertyValue("--den-1").trim();
const p2 = css.getPropertyValue("--den-2").trim();
const p3 = css.getPropertyValue("--den-3").trim();
const pp = css.getPropertyValue("--den-paper").trim();
if (p1 && p2 && p3 && pp) {
palRef.current = { cr: hexToRgb(pp), c1: hexToRgb(p1), c2: hexToRgb(p2), c3: hexToRgb(p3) };
}
}
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 {
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="M200,40H56A16,16,0,0,0,40,56V200a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V56A16,16,0,0,0,200,40Z" opacity=".2"/><path d="M200,40H56A16,16,0,0,0,40,56V200a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V56A16,16,0,0,0,200,40ZM96,200H56V168H96Zm0-56H56V112H96Zm0-56H56V56H96Zm104,112H112V168h88Zm0-56H112V112h88Zm0-56H112V56h88Z"/></g></svg>
<span>같은 32×32 밀도 배열을 두 번 그립니다 — 왼쪽은 칸 그대로, 오른쪽은 보간으로 펴냅니다</span>
</p>
<div
className={styles.duo}
ref={duoRef}
role="application"
aria-label="밀도장 확대 비교 실험판"
tabIndex={0}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={endPtr}
onPointerLeave={endPtr}
onKeyDown={onKey}
>
<div className={styles.pane}>
<canvas className={styles.cv} ref={cvARef}></canvas>
<b className={styles.tag}>최근접</b>
</div>
<div className={styles.pane}>
<canvas className={styles.cv} ref={cvBRef}></canvas>
<b className={styles.tag}>보간</b>
</div>
<b className={styles.badge} aria-hidden="true">32<span className={styles.badgeUnit}>×32</span></b>
</div>
</div>
</main>
);
}09 · 密度フィールドの描画 — 計測 累積面積 54.627% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
レンダリング実測は、累積面積 54.627% · 強度 40.0 · 動いたフレーム 23/23 である。
// 32×32のImageDataをCanvas全体へ最近傍で拡大
var img = ctx.createImageData(N, N);
for (var k = 0; k < size; k++) {
// 密度を5段階に量子化して等高線風の階調に
var q = Math.min(4, Math.floor(d[k] * 5));
var c = LEVELS[q];
img.data[k * 4] = c[0];
img.data[k * 4 + 1] = c[1];
img.data[k * 4 + 2] = c[2];
img.data[k * 4 + 3] = 255;
}
off.putImageData(img, 0, 0);
ctx.imageSmoothingEnabled = false; // ピクセル感を残す
ctx.drawImage(off.canvas, 0, 0, W, H);
格子シミュレーションは毎フレーム何を計算しているのか
3ステップを繰り返すだけ
9種すべてが毎フレーム同じ3ステップを回す — 力を足す(浮力・クリック・渦強化)、移流する(速度に沿って値をさかのぼってサンプリングする)、拡散・減衰する(隣と混ぜ、乗算で減らす)。順序を変えるか1ステップを抜くと、01〜09の別の項目が生まれるのがこのシリーズの核である。
なぜ格子なのか
粒子の位置を1つずつ積む方式と違い、格子はセル数が固定なので、値がどれだけ長く溜まっても計算量は増えない。32×32 という小さなサイズ1つで9通りの異なるシーンが作れる理由でもある。
Python の流体シミュレーションと何が違うのか
同じ移流・拡散・減衰の手続きを Python のコードに書き換えても結果は同じだ — 言語ではなく格子配列とループがこの計算の本質だからである。JS で書いているのはブラウザーで直接動かすためで、別の環境に移植するなら advect と diffuse の2関数だけをコピーすれば足りる。web で動く流体シミュレーションを探すと今日は WebGL シェーダーがほとんどだが、この9種は意図的に 2D コンテキストに留めている。
どこで破綻するか — 落とし穴
圧力投影を省略している
この9種はいずれも、ポアソン方程式(非圧縮性、すなわち密度が消えない条件を強制する)、すなわち圧力投影(pressure projection)のステップを解いていない。本来の Stable Fluids なら移流の後に発散を除去する計算が入るはずだが、ここではその場所を減衰係数1つで代用した。そのため08のように力を強く速く何度も入れると、密度が1セルに爆発的に溜まる瞬間が現れる。
流体シミュレーションと呼ぶには近似が多い
圧力投影がないということは、これらのデモが厳密な流体シミュレーションではなく「それらしく見える」視覚的近似だということだ。実務で物理的に正確な流体が必要なら、この9種ではなく SPH や格子ベースのソルバー(Verlet ソフトボディ物理のような積分方式)を探すべきである。2zk3kwsz のソースにはその省略をコメントで明示してあるので、実プロジェクトに使う前に一度目を通すことを勧める。
バリエーション3つとアクセシビリティ
拡散係数を上げると
02の拡散係数を 0.08 から 0.30 に上げると、インクが水路のように広くにじみ、境界が柔らかくなる。反復緩和の回数はそのままで、係数だけ変えれば十分だ。
渦の強化を上げると
03の渦強化(VORT)を 12 から 30 に上げると、煙の縁にさざ波のような渦が増える。値を上げすぎると格子が震えて見えるので、30 付近が安全ラインだ。
色を変えると
$subject-blue を $subject-mint に変えると、冷たいインクが爽やかなミントの染料に変わる。計算ロジックはそのままに、パレット変数だけを差し替える。
アクセシビリティが残す静止フレーム
01・03・06・07・09のように自動で繰り返し再生される項目は、prefers-reduced-motion を検知するとシミュレーションを40ステップだけ先行して回し、流れがすでに定着した静止フレームを1枚だけ描いて止まる。02・04・05・08のようにポインターで操作する項目は最初の1枚だけを残し、以後はクリックや移動のたびに結果を1フレームずつ更新して見せる。どちらの場合も、画面が完全な空白のまま残ることはない。
FAQ
流体シミュレーションと物理エンジンの違いは?
異なるものである。物理エンジンは主に剛体や粒子間の衝突・拘束を計算するが、本記事の流体シミュレーションは格子状の密度・速度場を移流・拡散させるオイラー的な計算だ。計算対象そのものが違う。
このデモをそのまま実案件で使えるか?
視覚効果としてはそのまま使えるが、圧力投影を省いた近似なので、物理的に正確な流体が必要な用途(工学用 CFD など)には合わない。背景やインタラクション装飾向けに作られている。
格子サイズ(N)を大きくするとリアルになる?
細かくはなるが、計算量は N の2乗で増え、60fps の維持が難しくなる。この9種は 32×32 と、canvas アニメーションに必要なだけの精細さに抑えた値である。
参考: MDN getImageData · MDN putImageData · MDN Float32Array · MDN requestAnimationFrame · MDN pointermove · MDN prefers-reduced-motion · Wikipedia: Fluid simulation









