03カールの渦
速度場のカール(回転成分)を差分で求め、その勾配方向に力を足すボルティシティ・コンファインメントが、死んだ煙を蘇らせます。格子近似が削り取った小さな渦を計算で戻す一段階です。
使いどころ大型バナーの生きた煙
Float32ArrayMath.hypot

<main class="stage stage--ink">
<div class="fa" id="fa">
<p class="fa__hint">
<svg class="fa__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M240 144h-32a80 80 0 0 1-80 80a88 88 0 0 1-88-88a96 96 0 0 1 96-96a104 104 0 0 1 104 104" opacity=".2"/><path d="M248 144a8 8 0 0 1-16 0a96.11 96.11 0 0 0-96-96a88.1 88.1 0 0 0-88 88a80.09 80.09 0 0 0 80 80a72.08 72.08 0 0 0 72-72a64.07 64.07 0 0 0-64-64a56.06 56.06 0 0 0-56 56a48.05 48.05 0 0 0 48 48a40 40 0 0 0 40-40a32 32 0 0 0-32-32a24 24 0 0 0-24 24a16 16 0 0 0 16 16a8 8 0 0 0 8-8a8 8 0 0 1 0-16a16 16 0 0 1 16 16a24 24 0 0 1-24 24a32 32 0 0 1-32-32a40 40 0 0 1 40-40a48.05 48.05 0 0 1 48 48a56.06 56.06 0 0 1-56 56a64.07 64.07 0 0 1-64-64a72.08 72.08 0 0 1 72-72a80.09 80.09 0 0 1 80 80a88.1 88.1 0 0 1-88 88a96.11 96.11 0 0 1-96-96A104.11 104.11 0 0 1 136 32a112.12 112.12 0 0 1 112 112"/></g></svg>
<span>カール(回転成分)の勾配方向へ力を戻すボルティシティ強化が、押しつぶされた渦を蘇らせます — カーソルで渦を描いてみましょう</span>
</p>
<div class="fa__field" id="field" role="application" aria-label="カール渦実験盤" tabindex="0">
<canvas class="fa__cv" id="cv"></canvas>
<b class="fa__badge" aria-hidden="true">12<span class="fa__badgeUnit">強化</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 컬링 소용돌이 — 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판·배지만 맡는다.
// @keyframes 를 하나도 두지 않는다 — 렌더러가 CSS 시계를 고정하지 않고 실시간으로 24장을 찍는다(01·012·077 선례).
// 염료 팔레트는 항목01 과 같은 파스텔 세 층(채도 0.50 이하)을 잉크 무대 위에 그대로 쓴다 — 대비 7.6~13.0.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.fa {
// 염료 세 층 — 파스텔 하늘색(옅은) → 파스텔 라일락(중간) → 파스텔 산호(진한). 잉크 무대 위 대비 7.6~13.0
--dye-0: #9ac2df;
--dye-1: #b19edb;
--dye-2: #e0a3b8;
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.fa__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-cream;
}
.fa__ico { width: 16px; height: 16px; flex: 0 0 auto; color: #9ac2df; }
.fa__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $stage-ink;
box-shadow: inset 0 0 0 1px rgba($subject-cream, .14);
cursor: crosshair; touch-action: none;
}
.fa__field:focus-visible { outline: 2px solid #9ac2df; outline-offset: 2px; }
.fa__cv { display: block; width: 100%; height: 100%; }
.fa__badge {
position: absolute; right: $sp-2; top: $sp-2;
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($subject-ink, .78);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.fa__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.fa { gap: $sp-1; }
.fa__field { height: 124px; }
.fa__badge { font-size: 10px; padding: 0 $sp-1; }
}
// reduce — 루프가 멈춘 한 장이 남으므로 커서 신호만 바꾼다.
@media (prefers-reduced-motion: reduce) {
.fa.is-still .fa__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fa {
--dye-0: #9ac2df;
--dye-1: #b19edb;
--dye-2: #e0a3b8;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.fa__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #fff7e6;
}
.fa__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #9ac2df;
}
.fa__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: #17141a;
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.14);
cursor: crosshair;
touch-action: none;
}
.fa__field:focus-visible {
outline: 2px solid #9ac2df;
outline-offset: 2px;
}
.fa__cv {
display: block;
width: 100%;
height: 100%;
}
.fa__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.fa__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.fa {
gap: 4px;
}
.fa__field {
height: 124px;
}
.fa__badge {
font-size: 10px;
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.fa.is-still .fa__field {
cursor: default !important;
}
}// 컬링 소용돌이(vorticity confinement) — 저해상 격자는 수치 점성 때문에 회전이 금방 눌려 죽는다.
// 매 걸음 속도장의 컬(curl, 회전 성분)을 중심차분으로 구하고, |curl| 의 기울기를 따라
// 회전 축 쪽으로 힘을 되먹여 죽어가던 소용돌이를 다시 살린 뒤 이류로 옮긴다.
var root = document.getElementById('fa');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d', { willReadFrequently: true });
var css = getComputedStyle(root);
var PAL0 = css.getPropertyValue('--dye-0').trim();
var PAL1 = css.getPropertyValue('--dye-1').trim();
var PAL2 = css.getPropertyValue('--dye-2').trim();
// N — 格子 한 변 칸 수. 480px 무대에 32칸이면 한 칸 15px, 컬 계산 + 이류를 60fps 안에 담는다
var N = 32;
var SIZE = (N + 2) * (N + 2);
var IX = function (x, y) { return x + (N + 2) * y; };
var u = new Float32Array(SIZE), v = new Float32Array(SIZE);
var u0 = new Float32Array(SIZE), v0 = new Float32Array(SIZE);
var d = new Float32Array(SIZE), d0 = new Float32Array(SIZE);
var curl = new Float32Array(SIZE);
var EPS = 1e-5; // 기울기 정규화 분모 0 방지
var VORT = 12; // 보텍시티 強化 계수 — 클수록 소용돌이가 더 세게 되살아난다
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, raf = 0, seedT = 0;
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
}
// 컬 계산 — 중심차분으로 각 칸의 회전 강도(스칼라)를 구한다. 절반 스케일을 곱해 dt 와 맞춘다.
// 이류 직전의 u0·v0(주입 직후)에서 읽는다 — 強化 힘이 같은 걸음 이류로 옮겨지게.
// curl > 0 은 반시계, curl < 0 은 시계 방향 회전이다.
function calcCurl() {
for (var j = 1; j <= N; j++) {
for (var i = 1; i <= N; i++) {
curl[IX(i, j)] = 0.5 * ((v0[IX(i + 1, j)] - v0[IX(i - 1, j)]) - (u0[IX(i, j + 1)] - u0[IX(i, j - 1)]));
}
}
}
// 보텍시티 強化 — |curl| 이 커지는 방향(기울기)으로 힘을 더한다.
// 회전이 몰린 곳으로 밀어붙이므로, 눌려 죽던 소용돌이가 다시 골을 파 만다.
// 힘은 이류의 원본인 u0·v0 에 얹는다 — 이류 결과(u)에 더하면 다음 줄에서 통째로 덮여 사라진다.
function confine(dt) {
for (var j = 2; j < N; j++) {
for (var i = 2; i < N; i++) {
var idx = IX(i, j);
var gx = (Math.abs(curl[IX(i + 1, j)]) - Math.abs(curl[IX(i - 1, j)])) * 0.5;
var gy = (Math.abs(curl[IX(i, j + 1)]) - Math.abs(curl[IX(i, j - 1)])) * 0.5;
var len = Math.sqrt(gx * gx + gy * gy) + EPS;
gx /= len; gy /= len;
var c = curl[idx];
u0[idx] += gx * c * VORT * dt;
v0[idx] += gy * c * VORT * dt;
}
}
}
// 후방 이류 — 칸 (x,y) 의 새 값을, 속도를 dt 만큼 거슬러 간 좌표의 옛 값으로 채운다.
function advect(b, dt) {
var src = b === 'u' ? u0 : (b === 'v' ? v0 : d0);
var dst = b === 'u' ? u : (b === 'v' ? v : d);
for (var j = 1; j <= N; j++) {
for (var i = 1; i <= N; i++) {
var x = i - dt * N * u0[IX(i, j)];
var y = j - dt * N * v0[IX(i, j)];
if (x < 0.5) x = 0.5; else if (x > N + 0.5) x = N + 0.5;
var i0 = x | 0; var i1 = i0 + 1;
if (y < 0.5) y = 0.5; else if (y > N + 0.5) y = N + 0.5;
var j0 = y | 0; var j1 = j0 + 1;
var s1 = x - i0, s0 = 1 - s1, t1 = y - j0, t0 = 1 - t1;
dst[IX(i, j)] = s0 * (t0 * src[IX(i0, j0)] + t1 * src[IX(i0, j1)]) +
s1 * (t0 * src[IX(i1, j0)] + t1 * src[IX(i1, j1)]);
}
}
}
// 경계 — 벽 반사 근사. 유출 속도를 0 으로 두고 밀도는 감쇠시킨다
function bounds() {
for (var i = 1; i <= N; i++) {
d[IX(i, 0)] = d[IX(i, 1)] * 0.5; d[IX(i, N + 1)] = d[IX(i, N)] * 0.5;
u[IX(i, 0)] = u[IX(i, 1)] * 0.5; u[IX(i, N + 1)] = u[IX(i, N)] * 0.5;
v[IX(i, 0)] = v[IX(i, 1)] * 0.5; v[IX(i, N + 1)] = v[IX(i, N)] * 0.5;
}
for (var j = 1; j <= N; j++) {
d[IX(0, j)] = d[IX(1, j)] * 0.5; d[IX(N + 1, j)] = d[IX(N, j)] * 0.5;
u[IX(0, j)] = u[IX(1, j)] * 0.5; u[IX(N + 1, j)] = u[IX(N, j)] * 0.5;
v[IX(0, j)] = v[IX(1, j)] * 0.5; v[IX(N + 1, j)] = v[IX(N, j)] * 0.5;
}
}
// 미리보기 주입원 — 시드 고정. 서로 반대로 도는 회전 소스 두 개가 그 사이 염료 띠를 감아올린다.
// seedT 로 결정적: 같은 편의 gif·poster 는 언제나 같은 소용돌이가 담긴다.
function inject(t) {
var s1x = N * 0.32 + Math.sin(t * 0.4) * N * 0.04;
var s1y = N * 0.5 + Math.cos(t * 0.33) * N * 0.05;
var s2x = N * 0.68 + Math.sin(t * 0.31 + 2) * N * 0.04;
var s2y = N * 0.5 + Math.cos(t * 0.47 + 1) * N * 0.05;
var rv = 4.5; // 회전 소스 반경 — 넓게 깔아야 염료 띠 전체가 감긴다
var spin = 5.2 + Math.sin(t * 0.8) * 1.4;
for (var j = -rv; j <= rv; j++) {
for (var i = -rv; i <= rv; i++) {
var q = (i * i + j * j) / (rv * rv);
if (q > 1) continue;
var f = Math.exp(-q * 2.4);
// 접선 방향 속도 (-j, i) — 반지름 밖으로 갈수록 약해지는 회전 소스 두 개(부호 반대)
var idx1 = IX(Math.round(s1x + i), Math.round(s1y + j));
u0[idx1] += -j * f * spin * 0.3; v0[idx1] += i * f * spin * 0.3;
var idx2 = IX(Math.round(s2x + i), Math.round(s2y + j));
u0[idx2] += j * f * spin * 0.3; v0[idx2] += -i * f * spin * 0.3;
}
}
// 염료 띠 — 두 소용돌이를 잇는 길쭉한 밴드. 감기는 모양새가 프레임마다 자라난다
var mx = N * 0.5 + Math.sin(t * 0.6) * N * 0.06;
var my = N * 0.5 + Math.cos(t * 0.5) * N * 0.06;
var rx = N * 0.22, ry = N * 0.16;
for (var y = -Math.round(ry); y <= Math.round(ry); y++) {
for (var x = -Math.round(rx); x <= Math.round(rx); x++) {
var qq = (x * x) / (rx * rx) + (y * y) / (ry * ry);
if (qq > 1) continue;
var amt = Math.exp(-qq * 1.4) * 0.8;
var idxM = IX(Math.round(mx + x), Math.round(my + y));
d0[idxM] += amt * ((t * 0.22 % 1) < 0.5 ? 1 : 0.6);
}
}
}
function step(dt) {
u0.set(u); v0.set(v); d0.set(d);
inject(seedT);
calcCurl();
confine(dt);
advect('u', dt); u0.set(u);
advect('v', dt); v0.set(v);
advect('d', dt);
bounds();
for (var k = 0; k < SIZE; k++) d[k] *= 0.995; // 밀도는 천천히 죽는다 — 화면이 꽉 찬 채 굳지 않게
seedT += dt;
}
function render() {
var img = ctx.createImageData(cv.width, cv.height);
var px = img.data;
var cellW = cv.width / N, cellH = cv.height / N;
var pal = [PAL0, PAL1, PAL2];
for (var py = 0; py < cv.height; py++) {
var gy = Math.min(N - 1, (py / cellH) | 0);
for (var pxx = 0; pxx < cv.width; pxx++) {
var gx = Math.min(N - 1, (pxx / cellW) | 0);
var q = d[IX(gx + 1, gy + 1)];
var o = (py * cv.width + pxx) * 4;
if (q <= 0) { px[o] = 23; px[o + 1] = 20; px[o + 2] = 26; px[o + 3] = 255; continue; }
var band = q > 0.62 ? 2 : (q > 0.3 ? 1 : 0);
var c = pal[band];
var rr = parseInt(c.slice(1, 3), 16), gg = parseInt(c.slice(3, 5), 16), bb = parseInt(c.slice(5, 7), 16);
var a = Math.min(1, q);
px[o] = 23 + (rr - 23) * a; px[o + 1] = 20 + (gg - 20) * a; px[o + 2] = 26 + (bb - 26) * a; px[o + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function frame() {
step(1 / 30);
render();
raf = requestAnimationFrame(frame);
}
// 사람이 휘젓기 — 포인터 이동량을 그 칸 주변에 지수 반경으로 얹는다. 커서 궤적 자체가 새 컬원이 된다.
function splat(gx, gy, fx, fy, amt) {
var r = 3;
for (var j = -r; j <= r; j++) {
for (var i = -r; i <= r; i++) {
var q = (i * i + j * j) / (r * r);
if (q > 1) continue;
if (gx + i < 1 || gx + i > N || gy + j < 1 || gy + j > N) continue;
var f = Math.exp(-q * 3) * amt;
var idx = IX(gx + i, gy + j);
u[idx] += fx * f; v[idx] += fy * f;
d[idx] = Math.min(2.2, d[idx] + f * 0.7);
}
}
}
var last = null;
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
var gx = 1 + Math.round((e.clientX - r.left) / r.width * (N - 1));
var gy = 1 + Math.round((e.clientY - r.top) / r.height * (N - 1));
if (last) {
var fx = (e.clientX - last.x) * 0.14, fy = (e.clientY - last.y) * 0.14;
splat(gx, gy, fx, fy, Math.min(1.6, Math.hypot(fx, fy)));
} else {
splat(gx, gy, 0, 0, 0.8);
}
last = { x: e.clientX, y: e.clientY };
if (reduce) render();
});
field.addEventListener('pointerleave', function () { last = null; });
field.addEventListener('keydown', function (e) {
var dir = { ArrowLeft: [-2, 0], ArrowRight: [2, 0], ArrowUp: [0, -2], ArrowDown: [0, 2] }[e.key];
if (!dir) return;
e.preventDefault();
splat(N >> 1, N >> 1, dir[0], dir[1], 1.2);
if (reduce) render();
});
window.addEventListener('resize', function () { fit(); if (reduce) render(); });
fit();
if (reduce) {
// reduce — 시뮬레이션을 40 걸음만 앞으로 감아 소용돌이가 자리잡은 한 장을 그린다(빈 화면이 아니다)
for (var k = 0; k < 40; k++) step(1 / 30);
render();
} else {
// 웜업 — 캡처 첫 장이 빈 화면이 되지 않게 소용돌이가 감기기 시작한 상태에서 루프를 연다
for (var w = 0; w < 90; w++) step(1 / 30);
raf = requestAnimationFrame(frame);
}// CurlSwirl — vanilla 판(run/308/demo/03-fluid-curl-swirl/index.html) 의 React 포트.
// 저해상 격자의 수치 점성으로 눌려 죽는 소용돌이를 컬(vorticity) 기울기 방향 힘으로 되살린다.
// 격자 필드는 ref 상자 안에서만 산다 — React 상태로 두면 리렌더가 매 프레임 일어나 60fps 예산을 못 맞춘다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./CurlSwirl.module.scss";
export type CurlSwirlProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
type PtrEvt = { clientX: number; clientY: number };
const N = 32;
const SIZE = (N + 2) * (N + 2);
function IX(x: number, y: number): number { return x + (N + 2) * y; }
const EPS = 1e-5;
const VORT = 12;
export default function CurlSwirl({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#9ac2df",
}: CurlSwirlProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const palRef = useRef(["#9ac2df", "#b19edb", "#e0a3b8"]) as Box<string[]>;
const u = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const v = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const u0 = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const v0 = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const d = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const d0 = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const curl = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const seedTRef = useRef(0) as Box<number>;
const lastRef = useRef<{ x: number; y: number } | null>(null) as Box<{ x: number; y: number } | null>;
const rafRef = useRef(0) as Box<number>;
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
cv.width = Math.round(r.width);
cv.height = Math.round(r.height);
}
function calcCurl(): void {
const uu0 = u0.current, vv0 = v0.current, cc = curl.current;
for (let j = 1; j <= N; j++) {
for (let i = 1; i <= N; i++) {
cc[IX(i, j)] = 0.5 * ((vv0[IX(i + 1, j)] - vv0[IX(i - 1, j)]) - (uu0[IX(i, j + 1)] - uu0[IX(i, j - 1)]));
}
}
}
function confine(dt: number): void {
const uu0 = u0.current, vv0 = v0.current, cc = curl.current;
for (let j = 2; j < N; j++) {
for (let i = 2; i < N; i++) {
const idx = IX(i, j);
let gx = (Math.abs(cc[IX(i + 1, j)]) - Math.abs(cc[IX(i - 1, j)])) * 0.5;
let gy = (Math.abs(cc[IX(i, j + 1)]) - Math.abs(cc[IX(i, j - 1)])) * 0.5;
const len = Math.sqrt(gx * gx + gy * gy) + EPS;
gx /= len; gy /= len;
const c = cc[idx];
uu0[idx] += gx * c * VORT * dt;
vv0[idx] += gy * c * VORT * dt;
}
}
}
function advect(b: "u" | "v" | "d", dt: number): void {
const src = b === "u" ? u0.current : b === "v" ? v0.current : d0.current;
const dst = b === "u" ? u.current : b === "v" ? v.current : d.current;
const uu = u0.current, vv = v0.current;
for (let j = 1; j <= N; j++) {
for (let i = 1; i <= N; i++) {
let x = i - dt * N * uu[IX(i, j)];
let y = j - dt * N * vv[IX(i, j)];
if (x < 0.5) x = 0.5; else if (x > N + 0.5) x = N + 0.5;
const i0 = x | 0, i1 = i0 + 1;
if (y < 0.5) y = 0.5; else if (y > N + 0.5) y = N + 0.5;
const j0 = y | 0, j1 = j0 + 1;
const s1 = x - i0, s0 = 1 - s1, t1 = y - j0, t0 = 1 - t1;
dst[IX(i, j)] = s0 * (t0 * src[IX(i0, j0)] + t1 * src[IX(i0, j1)]) +
s1 * (t0 * src[IX(i1, j0)] + t1 * src[IX(i1, j1)]);
}
}
}
function bounds(): void {
const dd = d.current, uu = u.current, vv = v.current;
for (let i = 1; i <= N; i++) {
dd[IX(i, 0)] = dd[IX(i, 1)] * 0.5; dd[IX(i, N + 1)] = dd[IX(i, N)] * 0.5;
uu[IX(i, 0)] = uu[IX(i, 1)] * 0.5; uu[IX(i, N + 1)] = uu[IX(i, N)] * 0.5;
vv[IX(i, 0)] = vv[IX(i, 1)] * 0.5; vv[IX(i, N + 1)] = vv[IX(i, N)] * 0.5;
}
for (let j = 1; j <= N; j++) {
dd[IX(0, j)] = dd[IX(1, j)] * 0.5; dd[IX(N + 1, j)] = dd[IX(N, j)] * 0.5;
uu[IX(0, j)] = uu[IX(1, j)] * 0.5; uu[IX(N + 1, j)] = uu[IX(N, j)] * 0.5;
vv[IX(0, j)] = vv[IX(1, j)] * 0.5; vv[IX(N + 1, j)] = vv[IX(N, j)] * 0.5;
}
}
function inject(t: number): void {
const uu0 = u0.current, vv0 = v0.current, dd0 = d0.current;
const s1x = N * 0.32 + Math.sin(t * 0.4) * N * 0.04;
const s1y = N * 0.5 + Math.cos(t * 0.33) * N * 0.05;
const s2x = N * 0.68 + Math.sin(t * 0.31 + 2) * N * 0.04;
const s2y = N * 0.5 + Math.cos(t * 0.47 + 1) * N * 0.05;
const rv = 4.5;
const spin = 5.2 + Math.sin(t * 0.8) * 1.4;
for (let j = -rv; j <= rv; j++) {
for (let i = -rv; i <= rv; i++) {
const q = (i * i + j * j) / (rv * rv);
if (q > 1) continue;
const f = Math.exp(-q * 2.4);
const idx1 = IX(Math.round(s1x + i), Math.round(s1y + j));
uu0[idx1] += -j * f * spin * 0.3; vv0[idx1] += i * f * spin * 0.3;
const idx2 = IX(Math.round(s2x + i), Math.round(s2y + j));
uu0[idx2] += j * f * spin * 0.3; vv0[idx2] += -i * f * spin * 0.3;
}
}
const mx = N * 0.5 + Math.sin(t * 0.6) * N * 0.06;
const my = N * 0.5 + Math.cos(t * 0.5) * N * 0.06;
const rx = N * 0.22, ry = N * 0.16;
for (let y = -Math.round(ry); y <= Math.round(ry); y++) {
for (let x = -Math.round(rx); x <= Math.round(rx); x++) {
const qq = (x * x) / (rx * rx) + (y * y) / (ry * ry);
if (qq > 1) continue;
const amt = Math.exp(-qq * 1.4) * 0.8;
const idxM = IX(Math.round(mx + x), Math.round(my + y));
dd0[idxM] += amt * ((t * 0.22 % 1) < 0.5 ? 1 : 0.6);
}
}
}
function step(dt: number): void {
u0.current.set(u.current); v0.current.set(v.current); d0.current.set(d.current);
inject(seedTRef.current);
calcCurl();
confine(dt);
advect("u", dt); u0.current.set(u.current);
advect("v", dt); v0.current.set(v.current);
advect("d", dt);
bounds();
const dd = d.current;
for (let k = 0; k < SIZE; k++) dd[k] *= 0.995;
seedTRef.current += dt;
}
function render(): void {
const ctx = ctxRef.current, cv = cvRef.current;
if (!ctx || !cv) return;
const img = ctx.createImageData(cv.width, cv.height);
const px = img.data;
const cellW = cv.width / N, cellH = cv.height / N;
const pal = palRef.current;
const dd = d.current;
for (let py = 0; py < cv.height; py++) {
const gy = Math.min(N - 1, (py / cellH) | 0);
for (let pxx = 0; pxx < cv.width; pxx++) {
const gx = Math.min(N - 1, (pxx / cellW) | 0);
const q = dd[IX(gx + 1, gy + 1)];
const o = (py * cv.width + pxx) * 4;
if (q <= 0) { px[o] = 23; px[o + 1] = 20; px[o + 2] = 26; px[o + 3] = 255; continue; }
const band = q > 0.62 ? 2 : q > 0.3 ? 1 : 0;
const c = pal[band];
const rr = parseInt(c.slice(1, 3), 16), gg = parseInt(c.slice(3, 5), 16), bb = parseInt(c.slice(5, 7), 16);
const a = Math.min(1, q);
px[o] = 23 + (rr - 23) * a; px[o + 1] = 20 + (gg - 20) * a; px[o + 2] = 26 + (bb - 26) * a; px[o + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function frame(): void {
step(1 / 30);
render();
rafRef.current = requestAnimationFrame(frame);
}
function splat(gx: number, gy: number, fx: number, fy: number, amt: number): void {
const r = 3;
const uu = u.current, vv = v.current, dd = d.current;
for (let j = -r; j <= r; j++) {
for (let i = -r; i <= r; i++) {
const q = (i * i + j * j) / (r * r);
if (q > 1) continue;
if (gx + i < 1 || gx + i > N || gy + j < 1 || gy + j > N) continue;
const f = Math.exp(-q * 3) * amt;
const idx = IX(gx + i, gy + j);
uu[idx] += fx * f; vv[idx] += fy * f;
dd[idx] = Math.min(2.2, dd[idx] + f * 0.7);
}
}
}
function onMove(e: PtrEvt): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
const gx = 1 + Math.round((e.clientX - r.left) / r.width * (N - 1));
const gy = 1 + Math.round((e.clientY - r.top) / r.height * (N - 1));
const last = lastRef.current;
if (last) {
const fx = (e.clientX - last.x) * 0.14, fy = (e.clientY - last.y) * 0.14;
splat(gx, gy, fx, fy, Math.min(1.6, Math.hypot(fx, fy)));
} else {
splat(gx, gy, 0, 0, 0.8);
}
lastRef.current = { x: e.clientX, y: e.clientY };
if (reduced()) render();
}
function onLeave(): void { lastRef.current = null; }
function onKey(e: KeyEvt): void {
const dir = ({ ArrowLeft: [-2, 0], ArrowRight: [2, 0], ArrowUp: [0, -2], ArrowDown: [0, 2] } as Record<string, number[]>)[e.key];
if (!dir) return;
e.preventDefault();
splat(N >> 1, N >> 1, dir[0], dir[1], 1.2);
if (reduced()) render();
}
function onResize(): void { fit(); if (reduced()) render(); }
useEffect(() => {
const cv = cvRef.current;
if (!cv) return;
ctxRef.current = cv.getContext("2d", { willReadFrequently: true });
if (!ctxRef.current) return;
const root = rootRef.current;
if (root) {
const css = getComputedStyle(root);
const p0 = css.getPropertyValue("--dye-0").trim();
const p1 = css.getPropertyValue("--dye-1").trim();
const p2 = css.getPropertyValue("--dye-2").trim();
if (p0 && p1 && p2) palRef.current = [p0, p1, p2];
}
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
if (root) root.classList.add("is-still");
for (let k = 0; k < 40; k++) step(1 / 30);
render();
} else {
for (let w = 0; w < 90; w++) step(1 / 30);
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.root} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M240 144h-32a80 80 0 0 1-80 80a88 88 0 0 1-88-88a96 96 0 0 1 96-96a104 104 0 0 1 104 104" opacity=".2"/><path d="M248 144a8 8 0 0 1-16 0a96.11 96.11 0 0 0-96-96a88.1 88.1 0 0 0-88 88a80.09 80.09 0 0 0 80 80a72.08 72.08 0 0 0 72-72a64.07 64.07 0 0 0-64-64a56.06 56.06 0 0 0-56 56a48.05 48.05 0 0 0 48 48a40 40 0 0 0 40-40a32 32 0 0 0-32-32a24 24 0 0 0-24 24a16 16 0 0 0 16 16a8 8 0 0 0 8-8a8 8 0 0 1 0-16a16 16 0 0 1 16 16a24 24 0 0 1-24 24a32 32 0 0 1-32-32a40 40 0 0 1 40-40a48.05 48.05 0 0 1 48 48a56.06 56.06 0 0 1-56 56a64.07 64.07 0 0 1-64-64a72.08 72.08 0 0 1 72-72a80.09 80.09 0 0 0 80 80a88.1 88.1 0 0 1-88 88a96.11 96.11 0 0 1-96-96A104.11 104.11 0 0 1 136 32a112.12 112.12 0 0 1 112 112"/></g></svg>
<span>컬(회전 성분)의 기울기 방향으로 힘을 되먹이는 보텍시티 강화가 눌린 연기를 다시 삽니다 — 커서로 소용돌이를 그려보세요</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label="컬링 소용돌이 실험판"
tabIndex={0}
onPointerMove={onMove}
onPointerLeave={onLeave}
onKeyDown={onKey}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true">12<span className={styles.badgeUnit}>강화</span></b>
</div>
</div>
</main>
);
}03 カールの渦 — 計測 累積面積 7.641% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · 流体シミュレーション9種 — canvas の煙・インク格子計算 (WebGLなし)
このデモが属する記事
流体シミュレーション9種 — canvas の煙・インク格子計算 (WebGLなし)流体シミュレーションとは、ナビエ–ストークス方程式を厳密に解くことではなく、格子状に並べた密度と速度の配列を毎フレーム「移流・拡散・減衰」させて流れを模す計算のことだ。この記事では canvas 2D コンテキストだけで、32×32 の F この記事のデモをすべて見る


