05물거품 파티클 — 격자 셀 기반 파편
크레스트에서 떨어져 나온 거품을 파티클로 흩리되 좌표를 매번 다시 뽑지 않고 '이 거품이 어떤 격자 칸에서 태어났는가'로 수명과 위치를 계산해, 파도가 치고 빠지는 흔적을 남깁니다.
쓰임여름 이벤트·해변 페스티벌 무드 배경
grid-cellparticleslifecycle

<main class="stage">
<div class="fp" id="fp">
<p class="fp__hint">
<svg class="fp__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M16 120c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".25"/><path d="M16 168c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".15"/><path d="M228.92 49.09a112 112 0 1 0 14 84.91a8 8 0 0 0-15.5-4a96 96 0 1 1-12-72.91A8 8 0 0 1 216 71.63V40a8 8 0 0 1-8-8h-31.63a8 8 0 0 1-5.65-13.09ZM64 104a12 12 0 1 1 12 12a12 12 0 0 1-12-12m40 64a12 12 0 1 1 12 12a12 12 0 0 1-12-12m72-16a16 16 0 1 1 16 16a16 16 0 0 1-16-16" opacity=".9"/></g></svg>
<span>격자 셀마다 거품 밀도를 계산합니다 — 좌표를 다시 뽑지 않고 태어난 칸에서 수명을 셉니다. 바람을 세게 불어보세요</span>
</p>
<div class="fp__field fp__grid-cell" id="field" role="application" aria-label="물거품 파티클 실험판" tabindex="0">
<canvas class="fp__cv" id="cv"></canvas>
<b class="fp__badge" aria-hidden="true"><span class="fp__badgeNum" id="foamVal">거품 0</span><span class="fp__badgeUnit" id="windVal">바람 1.0</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 물거품 파티클(격자 셀 기반) — 크레스트에서 발생 → advect(흐름 이동) → 지수 감쇠하는 오일러 밀도장.
// 움직임은 전부 캔버스 rAF 안에 있다. @keyframes 를 두지 않는다(077 방식).
// 색은 전부 토큰 또는 mix() 로컬 변수(BD26) — 헥스 리터럴 금지.
// 캔버스가 쓰는 색은 커스텀 프로퍼티로 내려보낸다 — 값은 이 파일 한 곳에만 둔다.
$fp-sea0: mix($subject-blue-deep, $stage-paper, 54%); // 바탕 바다색 — 진한 파랑
$fp-sea1: mix($subject-sky, $stage-paper, 62%); // 바탕 바다색 — 밝은 하늘 민트
$fp-foam0: mix($subject-cream, $stage-paper, 70%); // 거품 밀도 낮음 — 옅은 크림
$fp-foam1: $subject-cream; // 거품 밀도 높음 — 진한 크림
$duration: $dur-loop;
$easing: $ease-spring;
$color: $fp-foam1;
@include stage($stage-paper);
.fp {
--sea0: #{$fp-sea0};
--sea1: #{$fp-sea1};
--foam0: #{$fp-foam0};
--foam1: #{$fp-foam1};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.fp__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;
}
.fp__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-blue; }
.fp__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $fp-sea0;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
cursor: crosshair; touch-action: none;
}
.fp__field:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.fp__cv { display: block; width: 100%; height: 100%; }
.fp__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;
}
.fp__badgeNum { font-size: 13px; color: $stage-yellow; }
.fp__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.fp { gap: $sp-1; }
.fp__field { height: 124px; }
.fp__badge { font-size: 10px; padding: 0 $sp-1; }
.fp__badgeNum { font-size: 11px; }
.fp__hint { font-size: 10px; }
}
// reduce — 루프가 아예 안 돈다. 거품이 이미 자란 정적 한 장이 남으므로
// 끌 수 있다는 신호(커서)만 거둔다.
@media (prefers-reduced-motion: reduce) {
.fp.is-still .fp__field { cursor: default !important; }
.fp.is-still .fp__badge { opacity: .92 !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);
}
.fp {
--sea0: rgb(48.1176470588%, 62.7294117647%, 95.1294117647%);
--sea1: rgb(64.7450980392%, 86.6274509804%, 100%);
--foam0: rgb(100%, 97.8039215686%, 93.137254902%);
--foam1: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.fp__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;
}
.fp__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #2f6df6;
}
.fp__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: rgb(48.1176470588%, 62.7294117647%, 95.1294117647%);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
cursor: crosshair;
touch-action: none;
}
.fp__field:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.fp__cv {
display: block;
width: 100%;
height: 100%;
}
.fp__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;
}
.fp__badgeNum {
font-size: 13px;
color: #ffd23f;
}
.fp__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.fp {
gap: 4px;
}
.fp__field {
height: 124px;
}
.fp__badge {
font-size: 10px;
padding: 0 4px;
}
.fp__badgeNum {
font-size: 11px;
}
.fp__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.fp.is-still .fp__field {
cursor: default !important;
}
.fp.is-still .fp__badge {
opacity: 0.92 !important;
}
}// 물거품 파티클(격자 셀 기반) — 01 과 같은 뿌리(정현파 6개 mulberry32(7))의 2D 파도장에서
// 크레스트(문턱 넘는 칸)를 오일러 관점으로 스캔한다. 개별 파티클 좌표를 매 프레임 새로 뽑지 않고,
// "이 거품이 어느 격자 셀(grid-cell)에서 태어났는가"(cellId)로 밀도(density[cellId])와 수명(life[cellId])을
// 저장해 크레스트에서 발생 → 성장 → 파도 위상을 따라 흐름 이동 → 소멸하는 오일러 필드를 만든다.
// 포인터로 "바람"을 불면 발생량(spawn rate)이 커진다.
var root = document.getElementById('fp');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var foamVal = document.getElementById('foamVal');
var windVal = document.getElementById('windVal');
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
function v(name) { return css.getPropertyValue(name).trim(); }
var SEA0 = v('--sea0'), SEA1 = v('--sea1'); // 바탕 바다색 램프
var FOAM0 = v('--foam0'), FOAM1 = v('--foam1'); // 거품 밀도 낮음→높음 램프(크림 계열)
var COLS = 90; // 오일러 격자 가로 칸 수 — 저해상 셀(파티클 좌표 아님)
var STEP = 4; // 격자 한 칸이 렌더 버퍼에서 차지하는 픽셀
var NW = 6; // 겹칠 정현파 개수(공통 물리, 01 과 동일 뿌리)
var LIFE_MAX = 26; // 거품 한 점의 최대 수명(프레임 단위 근사 — 시간 dt 로 감쇠)
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
var u = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((u ^ (u >>> 14)) >>> 0) / 4294967296;
};
}
var rng = mulberry32(7);
var rngSpawn = mulberry32(19); // 발생 판정 전용 — 파도장 시드와 분리(같은 시드를 재사용하면 상관이 생긴다)
var WAVES = [];
(function () {
var lam = 34;
for (var i = 0; i < NW; i++) {
var ang = rng() * Math.PI * 2;
WAVES.push({
lam: lam,
freq: (Math.PI * 2) / lam,
dirX: Math.cos(ang), dirY: Math.sin(ang),
amp: 0.58 / (i * 0.62 + 1),
spd: 0.55 + rng() * 0.25,
ph: rng() * Math.PI * 2
});
lam *= 0.55;
}
})();
for (var w = 0; w < NW; w++) {
WAVES[w].om = WAVES[w].spd * 3.6 * Math.sqrt(1 / WAVES[w].lam) * 6;
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1, ROWS = 0;
var hgt = null; // 파도장 높이(Float32Array)
var dens = null; // 셀별 거품 밀도(Float32Array, 0..1) — 오일러 필드 본체
var buf = null, bctx = null, out = null;
var RW = 0, RH = 0;
var raf = 0, auto = true, nowSec = 0, lastT = 0;
var wind = 1.0, windT = 1.0;
var windAng = 0.5, windAngT = 0.5;
var lastPX = -1, lastPY = -1, lastPT = 0;
var totalFoam = 0;
function hex2rgb(s) {
var pm = /^\s*rgb\(\s*([\d.]+)%\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)\s*$/.exec(s);
if (pm) return [Math.round(pm[1] * 2.55), Math.round(pm[2] * 2.55), Math.round(pm[3] * 2.55)];
s = s.replace('#', '');
if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2];
var n = parseInt(s, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
var RS0 = hex2rgb(SEA0), RS1 = hex2rgb(SEA1);
var RF0 = hex2rgb(FOAM0), RF1 = hex2rgb(FOAM1);
function fit() {
var r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ROWS = Math.max(30, Math.round(COLS * H / W));
var n = COLS * ROWS;
hgt = new Float32Array(n);
dens = new Float32Array(n);
RW = COLS * STEP; RH = ROWS * STEP;
buf = document.createElement('canvas');
buf.width = RW; buf.height = RH;
bctx = buf.getContext('2d');
out = bctx.createImageData(RW, RH);
}
// 파도장 높이 합성 — 01 과 같은 뿌리
function fieldcalc(t, wstr, wang) {
var i = 0;
for (var y = 0; y < ROWS; y++) {
for (var x = 0; x < COLS; x++, i++) {
var h = 0;
for (var w2 = 0; w2 < NW; w2++) {
var WV = WAVES[w2];
var da = wang * 0.5;
var dx = WV.dirX * Math.cos(da) - WV.dirY * Math.sin(da);
var dy = WV.dirX * Math.sin(da) + WV.dirY * Math.cos(da);
var ph = (x * dx + y * dy) * WV.freq * 0.62 + t * WV.om + WV.ph;
h += WV.amp * wstr * Math.sin(ph);
}
hgt[i] = h;
}
}
}
// 거품 오일러 스텝 — 셀마다: (1) 크레스트면 발생 확률로 밀도를 채운다(spawn),
// (2) 파도 위상 방향으로 밀도를 옆 셀에 조금 옮긴다(advect — 흐름 이동, 개별 좌표 추적 아님),
// (3) 전체를 지수 감쇠시켜 수명이 다한 거품이 자연히 사라지게 한다.
var densNext = null;
function foamStep(dt, wstr, wang) {
if (!densNext || densNext.length !== dens.length) densNext = new Float32Array(dens.length);
var eff = 0;
for (var i = 0; i < NW; i++) eff += WAVES[i].amp;
eff *= wstr;
var cut = eff * 0.74; // 크레스트 문턱(03 과 같은 비율대)
var spawnRate = 0.10 + Math.min(0.55, (wstr - 0.6) * 0.28); // 바람이 셀수록 발생량 ↑
var decay = Math.exp(-dt * (1000 / LIFE_MAX) * 0.06); // 수명 감쇠(지수)
var flowX = Math.cos(windAng) * 0.9, flowY = Math.sin(windAng) * 0.9;
var total = 0;
for (var y = 0; y < ROWS; y++) {
var row = y * COLS;
for (var x = 0; x < COLS; x++) {
var idx = row + x;
// advect — 위상 흐름 반대 방향(이전 위치)에서 밀도를 끌어와 이 셀에 놓는다(semi-Lagrangian 근사)
var sx = x - flowX, sy = y - flowY;
var ix = Math.round(sx), iy = Math.round(sy);
if (ix < 0) ix = 0; else if (ix >= COLS) ix = COLS - 1;
if (iy < 0) iy = 0; else if (iy >= ROWS) iy = ROWS - 1;
var carried = dens[iy * COLS + ix] * decay;
// spawn — 크레스트 셀만 결정적 난수로 발생 판정(같은 셀·같은 프레임은 같은 판정 — 시드 고정)
if (hgt[idx] > cut) {
var r = rngSpawn();
if (r < spawnRate) carried = Math.min(1, carried + 0.55 + r * 0.4);
}
densNext[idx] = carried;
total += carried;
}
}
var tmp = dens; dens = densNext; densNext = tmp;
totalFoam = total;
}
// 렌더 — 바탕 바다색(높이 낮은 대비) 위에 거품 밀도를 크림 램프로 얹는다. 밀도가 진할수록 불투명.
function render() {
var o = out.data;
for (var y = 0; y < ROWS; y++) {
var row = y * COLS;
for (var x = 0; x < COLS; x++) {
var idx = row + x;
var u = hgt[idx] * 0.7 + 0.5;
if (u < 0) u = 0; else if (u > 1) u = 1;
var r = RS0[0] + (RS1[0] - RS0[0]) * u;
var g = RS0[1] + (RS1[1] - RS0[1]) * u;
var b = RS0[2] + (RS1[2] - RS0[2]) * u;
var f = dens[idx];
if (f > 0.02) {
if (f > 1) f = 1;
var fr = RF0[0] + (RF1[0] - RF0[0]) * f;
var fg = RF0[1] + (RF1[1] - RF0[1]) * f;
var fb = RF0[2] + (RF1[2] - RF0[2]) * f;
r = r * (1 - f) + fr * f;
g = g * (1 - f) + fg * f;
b = b * (1 - f) + fb * f;
}
for (var sy = 0; sy < STEP; sy++) {
var base = ((y * STEP + sy) * RW + x * STEP) * 4;
for (var sx2 = 0; sx2 < STEP; sx2++, base += 4) {
o[base] = r;
o[base + 1] = g;
o[base + 2] = b;
o[base + 3] = 255;
}
}
}
}
bctx.putImageData(out, 0, 0);
ctx.drawImage(buf, 0, 0, W, H);
}
function frame(now) {
nowSec = now / 1000;
var dt = lastT ? (now - lastT) : 16.7;
lastT = now;
if (auto) {
var th = nowSec * 0.31;
windT = 1.05 + Math.sin(th * 0.9 + 0.4) * 0.55 + Math.sin(th * 2.3) * 0.12;
windAngT = 0.5 + Math.sin(th * 0.42) * 0.85;
}
wind += (windT - wind) * 0.05;
windAng += (windAngT - windAng) * 0.05;
fieldcalc(nowSec, wind, windAng);
foamStep(dt, wind, windAng);
render();
foamVal.textContent = '거품 ' + Math.round(totalFoam);
windVal.textContent = '바람 ' + wind.toFixed(2);
raf = requestAnimationFrame(frame);
}
// 정지판 — 발생·감쇠를 몇 스텝 미리 돌려 거품이 이미 자란 정적 한 장을 만든다(빈 바다가 아니다)
function still() {
fieldcalc(7.3, 1.35, 0.5);
windAng = 0.5;
for (var k = 0; k < 40; k++) foamStep(33, 1.35, 0.5);
render();
}
function localXY(e) {
var r = field.getBoundingClientRect();
return [e.clientX - r.left, e.clientY - r.top];
}
field.addEventListener('pointermove', function (e) {
var p = localXY(e);
var t = performance.now();
if (lastPX >= 0 && t > lastPT) {
var vx = p[0] - lastPX, vy = p[1] - lastPY;
var sp2 = Math.sqrt(vx * vx + vy * vy);
if (sp2 > 0.5) {
windT = Math.max(0.35, Math.min(2.1, 0.9 + sp2 * 0.02));
windAngT = Math.atan2(vy, vx) * 0.16;
}
}
lastPX = p[0]; lastPY = p[1]; lastPT = t;
auto = false;
});
field.addEventListener('pointerleave', function () {
auto = true;
lastPX = -1;
});
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key];
if (!d) return;
e.preventDefault();
auto = false;
windT = Math.max(0.35, Math.min(2.1, windT + d[0] * 0.18));
windAngT = windAngT + d[1] * 0.1;
if (reduce) { fieldcalc(nowSec || 3.1, windT, windAngT); foamStep(33, windT, windAngT); render(); }
});
window.addEventListener('resize', function () { fit(); if (reduce) still(); });
fit();
if (reduce) {
root.classList.add('is-still');
still();
} else {
raf = requestAnimationFrame(frame);
}// FoamParticles — vanilla 판(run/313/demo/05-ocean-foam-particles/index.html) 의 React 포트.
// 01 과 같은 뿌리(정현파 6개, 시드 7)의 2D 파도장에서 크레스트를 오일러 관점으로 스캔해
// 셀별 거품 밀도(density)를 저장한다 — 개별 파티클 좌표를 매 프레임 새로 뽑지 않는다.
// 발생(spawn, 결정적 난수) → advect(파도 위상 흐름 이동) → 지수 감쇠(수명)를 한 스텝으로 처리.
// 움직임은 캔버스 rAF 루프가 전부이므로 @keyframes 는 없다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./FoamParticles.module.scss";
export type FoamParticlesProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type PointerMotionEvent = { clientX: number; clientY: number };
const COLS = 90;
const STEP = 4;
const NW = 6;
const LIFE_MAX = 26;
function mulberry32(a: number): () => number {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
const t = Math.imul(a ^ (a >>> 15), 1 | a);
const u = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((u ^ (u >>> 14)) >>> 0) / 4294967296;
};
}
type Wave = {
lam: number; freq: number; dirX: number; dirY: number;
amp: number; spd: number; ph: number; om: number;
};
function buildWaves(): Wave[] {
const rng = mulberry32(7);
const out: Wave[] = [];
let lam = 34;
for (let i = 0; i < NW; i++) {
const ang = rng() * Math.PI * 2;
const wv: Wave = {
lam,
freq: (Math.PI * 2) / lam,
dirX: Math.cos(ang),
dirY: Math.sin(ang),
amp: 0.58 / (i * 0.62 + 1),
spd: 0.55 + rng() * 0.25,
ph: rng() * Math.PI * 2,
om: 0,
};
wv.om = wv.spd * 3.6 * Math.sqrt(1 / wv.lam) * 6;
out.push(wv);
lam *= 0.55;
}
return out;
}
function hex2rgb(s: string): [number, number, number] {
const pm = /^\s*rgb\(\s*([\d.]+)%\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)\s*$/.exec(s);
if (pm) return [Math.round(+pm[1] * 2.55), Math.round(+pm[2] * 2.55), Math.round(+pm[3] * 2.55)];
let t = s.replace("#", "");
if (t.length === 3) t = t[0] + t[0] + t[1] + t[1] + t[2] + t[2];
const n = parseInt(t, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export default function FoamParticles({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#fff7e6",
}: FoamParticlesProps) {
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 foamValRef = useRef<HTMLSpanElement | null>(null);
const windValRef = useRef<HTMLSpanElement | null>(null);
const wavesRef = useRef<Wave[] | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const rafRef = useRef(0) as Box<number>;
const lastTRef = useRef(0) as Box<number>;
const stillRef = useRef(false) as Box<boolean>;
const rngSpawnRef = useRef<(() => number) | null>(null);
type St = {
rows: number; rw: number; rh: number;
hgt: Float32Array | null;
dens: Float32Array | null;
densNext: Float32Array | null;
out: ImageData | null;
bctx: CanvasRenderingContext2D | null;
buf: HTMLCanvasElement | null;
auto: boolean;
wind: number; windT: number;
windAng: number; windAngT: number;
lastPX: number; lastPY: number; lastPT: number;
totalFoam: number;
sea0: [number, number, number] | null;
sea1: [number, number, number] | null;
foam0: [number, number, number] | null;
foam1: [number, number, number] | null;
};
const stRef = useRef({
rows: 0, rw: 0, rh: 0,
hgt: null, dens: null, densNext: null, out: null, bctx: null, buf: null,
auto: true,
wind: 1.0, windT: 1.0,
windAng: 0.5, windAngT: 0.5,
lastPX: -1, lastPY: -1, lastPT: 0,
totalFoam: 0,
sea0: null, sea1: null, foam0: null, foam1: null,
} as St) as Box<St>;
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, cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
const ctx = ctxRef.current;
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const st = stRef.current;
st.rows = Math.max(30, Math.round(COLS * r.height / r.width));
const n = COLS * st.rows;
st.hgt = new Float32Array(n);
st.dens = new Float32Array(n);
st.densNext = new Float32Array(n);
st.rw = COLS * STEP; st.rh = st.rows * STEP;
const buf = document.createElement("canvas");
buf.width = st.rw; buf.height = st.rh;
st.buf = buf;
st.bctx = buf.getContext("2d");
if (st.bctx) st.out = st.bctx.createImageData(st.rw, st.rh);
}
function fieldcalc(t: number, wstr: number, wang: number): void {
const st = stRef.current;
const WV = wavesRef.current;
if (!st.hgt || !WV) return;
let i = 0;
for (let y = 0; y < st.rows; y++) {
for (let x = 0; x < COLS; x++, i++) {
let h = 0;
for (let k = 0; k < NW; k++) {
const wv = WV[k];
const da = wang * 0.5;
const dx = wv.dirX * Math.cos(da) - wv.dirY * Math.sin(da);
const dy = wv.dirX * Math.sin(da) + wv.dirY * Math.cos(da);
const ph = (x * dx + y * dy) * wv.freq * 0.62 + t * wv.om + wv.ph;
h += wv.amp * wstr * Math.sin(ph);
}
st.hgt[i] = h;
}
}
}
// 거품 오일러 스텝 — 발생(크레스트 문턱, 결정적 난수) → advect(위상 흐름 이동) → 지수 감쇠
function foamStep(dt: number, wstr: number, wang: number): void {
const st = stRef.current;
const WV = wavesRef.current;
const rngSpawn = rngSpawnRef.current;
if (!st.hgt || !st.dens || !st.densNext || !WV || !rngSpawn) return;
let eff = 0;
for (let i = 0; i < NW; i++) eff += WV[i].amp;
eff *= wstr;
const cut = eff * 0.74;
const spawnRate = 0.10 + Math.min(0.55, (wstr - 0.6) * 0.28);
const decay = Math.exp(-dt * (1000 / LIFE_MAX) * 0.06);
const flowX = Math.cos(wang) * 0.9, flowY = Math.sin(wang) * 0.9;
let total = 0;
for (let y = 0; y < st.rows; y++) {
const row = y * COLS;
for (let x = 0; x < COLS; x++) {
const idx = row + x;
const sx = x - flowX, sy = y - flowY;
let ix = Math.round(sx), iy = Math.round(sy);
if (ix < 0) ix = 0; else if (ix >= COLS) ix = COLS - 1;
if (iy < 0) iy = 0; else if (iy >= st.rows) iy = st.rows - 1;
let carried = st.dens[iy * COLS + ix] * decay;
if (st.hgt[idx] > cut) {
const r = rngSpawn();
if (r < spawnRate) carried = Math.min(1, carried + 0.55 + r * 0.4);
}
st.densNext[idx] = carried;
total += carried;
}
}
const tmp = st.dens; st.dens = st.densNext; st.densNext = tmp;
st.totalFoam = total;
}
function render(): void {
const st = stRef.current;
const ctx = ctxRef.current;
if (!st.out || !st.bctx || !st.buf || !ctx || !st.hgt || !st.dens) return;
if (!st.sea0 || !st.sea1 || !st.foam0 || !st.foam1) return;
const o = st.out.data;
const RS0 = st.sea0, RS1 = st.sea1, RF0 = st.foam0, RF1 = st.foam1;
for (let y = 0; y < st.rows; y++) {
const row = y * COLS;
for (let x = 0; x < COLS; x++) {
const idx = row + x;
let u = st.hgt[idx] * 0.7 + 0.5;
if (u < 0) u = 0; else if (u > 1) u = 1;
let r = RS0[0] + (RS1[0] - RS0[0]) * u;
let g = RS0[1] + (RS1[1] - RS0[1]) * u;
let b = RS0[2] + (RS1[2] - RS0[2]) * u;
let f = st.dens[idx];
if (f > 0.02) {
if (f > 1) f = 1;
const fr = RF0[0] + (RF1[0] - RF0[0]) * f;
const fg = RF0[1] + (RF1[1] - RF0[1]) * f;
const fb = RF0[2] + (RF1[2] - RF0[2]) * f;
r = r * (1 - f) + fr * f;
g = g * (1 - f) + fg * f;
b = b * (1 - f) + fb * f;
}
for (let sy = 0; sy < STEP; sy++) {
let base = ((y * STEP + sy) * st.rw + x * STEP) * 4;
for (let sx2 = 0; sx2 < STEP; sx2++, base += 4) {
o[base] = r;
o[base + 1] = g;
o[base + 2] = b;
o[base + 3] = 255;
}
}
}
}
st.bctx.putImageData(st.out, 0, 0);
ctx.drawImage(st.buf, 0, 0, sizeRef.current.w, sizeRef.current.h);
}
function frame(now: number): void {
const st = stRef.current;
const sec = now / 1000;
const dt = lastTRef.current ? now - lastTRef.current : 16.7;
lastTRef.current = now;
if (st.auto) {
const th = sec * 0.31;
st.windT = 1.05 + Math.sin(th * 0.9 + 0.4) * 0.55 + Math.sin(th * 2.3) * 0.12;
st.windAngT = 0.5 + Math.sin(th * 0.42) * 0.85;
}
st.wind += (st.windT - st.wind) * 0.05;
st.windAng += (st.windAngT - st.windAng) * 0.05;
fieldcalc(sec, st.wind, st.windAng);
foamStep(dt, st.wind, st.windAng);
render();
if (foamValRef.current) foamValRef.current.textContent = "거품 " + Math.round(st.totalFoam);
if (windValRef.current) windValRef.current.textContent = "바람 " + st.wind.toFixed(2);
rafRef.current = requestAnimationFrame(frame);
}
function onPointerMove(e: PointerMotionEvent): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
const px = e.clientX - r.left, py = e.clientY - r.top;
const t = performance.now();
const st = stRef.current;
if (st.lastPX >= 0 && t > st.lastPT) {
const vx = px - st.lastPX, vy = py - st.lastPY;
const sp = Math.sqrt(vx * vx + vy * vy);
if (sp > 0.5) {
st.windT = Math.max(0.35, Math.min(2.1, 0.9 + sp * 0.02));
st.windAngT = Math.atan2(vy, vx) * 0.16;
}
}
st.lastPX = px; st.lastPY = py; st.lastPT = t;
st.auto = false;
}
function onPointerLeave(): void {
stRef.current.auto = true;
stRef.current.lastPX = -1;
}
function onResize(): void {
fit();
if (reduced()) {
const st = stRef.current;
fieldcalc(7.3, 1.35, 0.5);
st.windAng = 0.5;
for (let k = 0; k < 40; k++) foamStep(33, 1.35, 0.5);
render();
}
}
useEffect(() => {
const cv = cvRef.current;
const root = rootRef.current;
if (!cv || !root) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
wavesRef.current = buildWaves();
rngSpawnRef.current = mulberry32(19);
const cs = getComputedStyle(root);
const tok = (name: string) => cs.getPropertyValue(name).trim();
const st = stRef.current;
st.sea0 = hex2rgb(tok("--sea0"));
st.sea1 = hex2rgb(tok("--sea1"));
st.foam0 = hex2rgb(tok("--foam0"));
st.foam1 = hex2rgb(tok("--foam1"));
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
stillRef.current = true;
fieldcalc(7.3, 1.35, 0.5);
st.windAng = 0.5;
for (let k = 0; k < 40; k++) foamStep(33, 1.35, 0.5);
render();
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
const cls = [styles.root, stillRef.current ? styles.isStill : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} 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="M16 120c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".25"/><path d="M16 168c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".15"/><path d="M228.92 49.09a112 112 0 1 0 14 84.91a8 8 0 0 0-15.5-4a96 96 0 1 1-12-72.91A8 8 0 0 1 216 71.63V40a8 8 0 0 1-8-8h-31.63a8 8 0 0 1-5.65-13.09ZM64 104a12 12 0 1 1 12 12a12 12 0 0 1-12-12m40 64a12 12 0 1 1 12 12a12 12 0 0 1-12-12m72-16a16 16 0 1 1 16 16a16 16 0 0 1-16-16" opacity=".9"/></g></svg>
<span>격자 셀마다 거품 밀도를 계산합니다 — 좌표를 다시 뽑지 않고 태어난 칸에서 수명을 셉니다. 바람을 세게 불어보세요</span>
</p>
<div className={styles.field} ref={fieldRef} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum} ref={foamValRef}>거품 0</span><span className={styles.badgeUnit} ref={windValRef}>바람 1.0</span></b>
</div>
</div>
</main>
);
}05 물거품 파티클 — 격자 셀 기반 파편 — 측정 누적면적 60.21% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 캔버스 바다 애니메이션 9종 — 겹친 정현파로 파도장을 계산한다
이 데모가 속한 글
캔버스 바다 애니메이션 9종 — 겹친 정현파로 파도장을 계산한다바다 애니메이션은 사진이나 gif를 반복 재생하는 장면이 아니라 격자 칸마다 물결 높이를 실시간으로 다시 계산해서 그리는 화면입니다. 이 아홉 장면은 겹친 정현파로 파도장을 계산한다는 원리 하나 위에서 물빛과 거품, 이 글의 데모 전부 보기


