01안정 유체 이류
한 프레임 뒤를 예측하는 게 아니라, 지금 픽셀의 값을 이전 프레임의 어디서 가져왔는지 거슬러 샘플링하는 후방 이류(backtrace) 하나로 시간步幅이 커져도 터지지 않는 유체가 됩니다. 속도장이 자기 자신을 옮기는 자기 이류와 밀도를 옮기는 물질 이류를 겹쳐 그립니다.
쓰임히어로 배경의 천천히 도는 유체
Float32ArrayMath.hypotrequestAnimationFrame

<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 포함 · 유체 시뮬레이션 9종 — 캔버스 연기·잉크 격자 계산 (WebGL 없이)
이 데모가 속한 글
유체 시뮬레이션 9종 — 캔버스 연기·잉크 격자 계산 (WebGL 없이)유체 시뮬레이션은 물이나 연기의 실제 나비에-스토크스 방정식을 그대로 푸는 게 아니라, 격자 위에 놓인 밀도·속도 배열을 매 프레임 이류하고 확산시키고 감쇠시켜 흐름을 흉내 내는 계산이다. 이 편은 라이브러리 없이 이 글의 데모 전부 보기


