유체 시뮬레이션 9종 — 캔버스 연기·잉크 격자 계산 (WebGL 없이)
유체 시뮬레이션은 물이나 연기의 실제 나비에-스토크스 방정식을 그대로 푸는 게 아니라, 격자 위에 놓인 밀도·속도 배열을 매 프레임 이류하고 확산시키고 감쇠시켜 흐름을 흉내 내는 계산이다. 이 편은 라이브러리 없이 격자 한 장으로 캔버스 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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
아홉 개는 왜 이 순서인가
이 시리즈의 축은 html5 canvas 애니메이션 9종의 픽셀 단위 굴절·투영이나 파티클 효과 9종의 입자 하나하나가 아니다. 매 프레임 Float32Array 격자 하나에 속도(u, v)와 밀도(d)를 저장해 두고, 그 격자 전체를 이류시킨 뒤 확산시키고 감쇠시키는 오일러식 안정 유체(Stable Fluids) 계산이 이 편의 정체다. 입자를 하나씩 쌓아 올리는 것도 아니고 파동방정식으로 표면 높이를 굴절시키는 것도 아니라, 칸마다 숫자 두세 개를 반복해서 갱신하는 것뿐이다.
01은 그 반복의 뼈대(후방 이류)만 보여주고, 02는 이웃과 값을 섞는 확산 한 단계를 떼어 보여준다. 03은 격자 근사가 깎아버린 회전을 계산으로 되돌리고, 04·05는 01의 뼈대에 각각 클릭 주입과 부력을 얹은 응용이다. 06은 초기 조건 하나만 심고 감쇠를 관찰하는 반대 방향의 실험이고, 07은 채널을 셋으로 늘린 변형, 08은 사람 손이 직접 힘을 넣는 인터랙션, 09는 계산 결과가 아니라 그 결과를 화면에 어떻게 확대해 그리느냐의 문제다. 순서대로 보면 유체 시뮬레이션 하나가 몇 개의 독립된 부품(이류·확산·와도·경계)으로 쪼개진다는 것이 드러난다.
01안정 유체 이류
무엇이 보이나
속도장이 자기 자신을 옮기는 자기 이류와 밀도를 옮기는 물질 이류가 한 화면에서 겹쳐 돈다 — 지금 칸이 이전 프레임의 어디서 왔는지 속도를 따라 거슬러 올라가 샘플링하는 후방 이류(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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
렌더 실측은 누적면적 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; 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)]);
}
}
}
02확산 번짐
무엇이 보이나
셀마다 이웃 넷과 값을 조금씩 섞는 확산 한 단계가 잉크가 물에 번지는 스무딩이다 — 확산 계수를 크게 하면 데칼코마니처럼 부드럽게 퍼지고 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>매 걸음 이웃 넷과 값을 섞는 확산이 잉크를 종이에 번지게 합니다 — 눌러서 방울을 떨어뜨려 보세요</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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
렌더 실측은 누적면적 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컬링 소용돌이
무엇이 보이나
속도장의 컬(회전 성분)을 중심차분으로 구하고 그 크기의 기울기 방향으로 힘을 되먹이는 보텍시티 강화가 눌려 죽던 연기를 다시 살린다 — 저해상 격자가 수치 점성으로 깎아버린 작은 소용돌이를 계산 한 단계로 돌려놓는다. 대형 배너의 살아 있는 연기에 쓴다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
렌더 실측은 누적면적 7.641% · 강도 115.1 · 움직인 프레임 23/23이다.
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)]));
}
}
}
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;
}
}
}
04잉크 번짐
무엇이 보이나
클릭한 자리에 밀도 한 덩어리를 심고 확산은 낮게 잡은 채 이류만 남기면, 종이 위 잉크가 젖은 경계를 그리며 자란다 — 밀도가 진한 코어일수록 곱셈 감쇠로 천천히 움직여 옅은 경계만 계속 퍼지는 번짐 단계가 보인다. 01과 같은 격자·advect 코드를 공유하며 카드 hover 잉크 배경에 쓴다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
렌더 실측은 누적면적 13.76% · 강도 46.8 · 움직인 프레임 23/23이다.
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; }
05연기 상승
무엇이 보이나
밀도에 비례하는 부력을 속도장에 매 프레임 더하고 결정적 가우시안 노이즈로 옆을 살짝 흔들면 아래에서 피어오르는 연기 기둥이 된다 — 하단 고정 좌표 세 곳이 번갈아 점화해 기둥 여러 개가 동시에 피어오른다. 다크 모드 푸터의 연기 배경에 쓴다.

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

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
렌더 실측은 누적면적 61.031% · 강도 26.8 · 움직인 프레임 20/23이다.
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);
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;
}
}
}
07염료 혼합
무엇이 보이나
밀도 채널 세 개(RGB)를 각각 독립적으로 이류·확산시키면 두 색이 물처럼 섞이며 중간색이 부드럽게 파생된다 — 색을 미리 섞어 그리는 게 아니라 필드 계산의 결과로 섞인다. 브랜드 두 색을 섞는 히어로에 쓴다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
렌더 실측은 누적면적 43.042% · 강도 21.6 · 움직인 프레임 23/23이다.
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; }
08포인터 휘젓기
무엇이 보이나
pointermove 의 이전·현재 위치 차이를 속도장에 지수 반경으로 스플랫하면 커서가 유체를 실제로 저어 휘젓는 만큼 물감 자국이 남는다 — 이동 속도가 빠를수록 힘이 세게 들어간다. 놀면서 만지는 갤러리 인트로에 쓴다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
렌더 실측은 누적면적 51.135% · 강도 62.1 · 움직인 프레임 23/23이다.
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);
}
}
}
09밀도장 렌더링
무엇이 보이나
시뮬레이션이 들고 있는 데이터는 32×32 숫자 배열 하나뿐이라, 그 값을 화면 해상도로 어떻게 확대해 그리느냐에 따라 같은 데이터가 픽셀 블록으로도 매끈한 그라디언트로도 보인다 — 최근접 이웃과 쌍선형 보간을 나란히 두고 비교한다. 저해상도 계산을 고해상도로 보여주는 비교 데모에 쓴다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
렌더 실측은 누적면적 54.627% · 강도 40.0 · 움직인 프레임 23/23이다.
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);
}
유체 시뮬레이션 프로그램은 격자 위에서 무엇을 계산하나?
세 단계만 반복한다
9종 전부가 매 프레임 같은 세 단계를 돈다 — 힘을 더한다(부력·클릭·컬 강화), 이류시킨다(속도를 따라 값을 거슬러 샘플링한다), 확산·감쇠시킨다(이웃과 섞고 곱셈으로 줄인다). 순서를 바꾸거나 한 단계를 빼면 01~09 중 다른 항목이 나온다는 것이 이 시리즈의 핵심이다.
왜 격자인가
입자 하나하나의 위치를 쌓는 방식과 달리, 격자는 칸 수가 고정돼 있어 값이 아무리 오래 쌓여도 계산량이 늘지 않는다. 32×32 라는 작은 크기 하나로 9가지 서로 다른 장면을 만들 수 있는 이유이기도 하다.
파이썬 유체 시뮬레이션과 무엇이 다른가
같은 이류·확산·감쇠 절차를 파이썬 유체 시뮬레이션 코드로 옮겨도 결과는 똑같다 — 언어가 아니라 격자 배열과 반복문이 이 계산의 본질이기 때문이다. 이 편이 JS 로 쓰인 것은 브라우저에서 바로 돌아가야 하기 때문일 뿐, 무료 유체 시뮬레이션 예제를 다른 환경에서 재현하고 싶다면 advect·diffuse 두 함수만 옮기면 된다.
어디서 깨지는가 — 함정
압력 투영을 생략했다
이 9종은 전부 속도장의 발산을 0으로 만들어 부피를 보존하는(비압축성) 포아송 방정식, 즉 압력 투영(pressure projection) 단계를 풀지 않는다. 원래의 안정 유체 알고리즘이라면 이류 다음에 이 계산이 들어가야 하는데, 여기서는 그 자리를 감쇠 계수 하나로 때웠다. 그래서 08 포인터 휘젓기처럼 힘을 세게, 빠르게 여러 번 넣으면 밀도가 한 칸에 국소적으로 폭발적으로 쌓이는 순간이 생긴다.
유체 시뮬레이션 프로그램이라 부르기엔 근사가 많다
압력 투영이 빠졌다는 것은 이 데모들이 실제 유체 시뮬레이션 프로그램이 아니라 "그럴듯해 보이는" 시각적 근사라는 뜻이다. 실무에서 물리적으로 정확한 유체가 필요하다면 이 9종이 아니라 SPH 나 격자 기반 솔버(예: Verlet 물리엔진 9종 같은 위치기반 적분 방식)를 따로 찾아야 한다. 2zk3kwsz 안의 소스에는 그 생략을 주석으로 표시해 뒀으니, 실제 프로젝트에 쓰기 전에 먼저 읽어 보길 권한다.
변형 세 가지와 접근성
확산 계수를 올리면
02의 확산 계수를 0.02에서 0.30으로 올리면 잉크가 배수처럼 넓게 번지고 경계가 물러진다. 반복 이완 횟수는 그대로 두고 계수만 바꾸는 것으로 충분하다.
소용돌이 강화를 키우면
03의 컬 강화(VORT)를 12에서 30으로 올리면 연기 가장자리에 잔물결 소용돌이가 늘어난다. 값을 지나치게 키우면 격자가 흔들려 보이므로 30 근처가 안전선이다.
색을 바꾸면
$subject-blue 를 $subject-mint 로 바꾸면 차가운 잉크가 상큼한 민트 염료로 바뀐다. 계산 로직은 그대로고 팔레트 변수만 바뀐다.
접근성이 남기는 정지 프레임
9종 전부 prefers-reduced-motion 이 감지되면 시뮬레이션을 40스텝만 미리 돌려, 흐름이 이미 자리 잡은 정지 프레임 한 장만 그리고 멈춘다. 이후에도 포인터로 클릭하거나 움직이면 그 결과를 한 프레임씩 갱신해 보여준다. 어느 쪽이든 화면이 완전히 빈 채로 남지는 않는다.
FAQ
유체 시뮬레이션은 물리 엔진과 다른가?
다르다. 물리 엔진은 대개 강체나 입자 사이의 충돌·제약을 계산하고, 이 편의 유체 시뮬레이션은 격자 위 밀도·속도 필드를 이류·확산시키는 오일러식 계산이다. 계산 대상 자체가 다르다.
이 데모를 실제 프로젝트에 그대로 쓸 수 있나?
시각 효과로는 그대로 쓸 수 있지만, 압력 투영을 생략한 근사이므로 물리적으로 정확한 유체가 필요한 시뮬레이션(공학용 CFD 등)에는 맞지 않는다. 배경·인터랙션 장식 용도에 맞춰져 있다.
격자 크기(N)를 키우면 더 사실적인가?
세밀해지긴 하지만 계산량이 N의 제곱으로 늘어 60fps 를 유지하기 어려워진다. 이 9종은 32×32 로 캔버스 애니메이션에 필요한 만큼만 세밀하게 잡은 값이다.
참고: MDN getImageData · MDN putImageData · MDN Float32Array · MDN requestAnimationFrame · MDN pointermove · MDN prefers-reduced-motion · Wikipedia: Fluid simulation









