09빗물 웅덩이 — 낙하 충격파가 볼을 밀어내는 장면
빗방울이 필드에 착수하는 순간 원형 충격파를 임계값에 더했다 빼면 웅덩이 덩어리가 출렁이며 갈라지고, 잔물결 위에서 병합이 반복되는 창가 빗물 장면이 완성됩니다.
쓰임날씨·무드 페이지의 히어로 장면
getImageDataputImageDatarequestAnimationFrame

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<span>빗방울이 웅덩이에 닿는 순간 원형 충격파가 필드를 밀어냈다 사라지면 덩어리가 출렁이며 갈라지고 다시 병합합니다</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="빗물 웅덩이 메타볼 실험판" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta"><b class="cd__val" id="cdN">3</b>덩어리 · 빗물 웅덩이</span>
<span class="cd__meta cd__meta--dim">canvas · getImageData · putImageData</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 빗물 웅덩이(09). @keyframes 없음 — 충격파 시뮬레이션 + getImageData/putImageData rAF 루프뿐.
// 무대는 잉크. 편 공통 파스텔 팔레트를 --mb-* 로 (07~09 동일 계열, 항목마다 무대만 다름).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-sky;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트 ──
$mb-deep: #2a2d3a; // 웅덩이 얕은 물(최하 임계값) — 잉크보다 한 단계 밝은 밤물
$mb-mid: $subject-sky; // 물 몸통
$mb-top: $subject-cream; // 물 꼭대기 하이라이트
$mb-drop: $subject-cream; // 낙하 빗방울 줄
$mb-glass: #3d4358; // 창가 배경 유리빛
$mb-text: #d8dce6;
.cd {
--mb-deep: #{$mb-deep};
--mb-mid: #{$mb-mid};
--mb-top: #{$mb-top};
--mb-drop: #{$mb-drop};
--mb-glass: #{$mb-glass};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__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: rgba($mb-text, .82);
}
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $mb-deep;
box-shadow: inset 0 0 0 1px rgba($mb-text, .16);
}
.cd__field:focus-visible { outline: 2px solid $mb-mid; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($mb-text, .72); }
.cd__val { color: $mb-mid; font-weight: 700; }
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 착수 직후 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__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);
}
.cd {
--mb-deep: #2a2d3a;
--mb-mid: #6ec8ff;
--mb-top: #fff7e6;
--mb-drop: #fff7e6;
--mb-glass: #3d4358;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__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: rgba(216, 220, 230, 0.82);
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #2a2d3a;
box-shadow: inset 0 0 0 1px rgba(216, 220, 230, 0.16);
}
.cd__field:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 220, 230, 0.72);
}
.cd__val {
color: #6ec8ff;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 빗물 웅덩이(09). 하단 웅덩이 = 스칼라 필드 위의 부력 물방울 3개(표면장력으로 밀어내며 모여 병합).
// 위에선 빗방울이 낙하하고, 물에 닿는 순간 원형 충격파(음의 가우시안 링, 살짝 지나면 양의 재융합 링)를
// 필드에 더했다 빼서 덩어리가 출렁이며 갈라진다. 필드는 저해상도 캔버스에 가산 원으로 적분하고
// getImageData 로 읽어 임계값 두 단으로 색을 매긴 뒤 putImageData 로 되돌려 화면 캔버스에 확대한다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var N_BLOB = 3; // 웅덩이 덩어리 수 — 5 로 늘리면 잔물결 파편이 많아진다
var RAIN_MS = 360; // 빗방울 평균 간격(밀리초) — 줄이면 폭우
var SHOCK = 0.9; // 충격파 세기 — 크면 착수마다 덩어리가 크게 갈라진다
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var nOut = document.getElementById('cdN');
nOut.textContent = String(N_BLOB);
var css = getComputedStyle(root);
function tok(name) { return css.getPropertyValue(name).trim(); }
var C_DEEP = tok('--mb-deep');
var C_MID = tok('--mb-mid');
var C_TOP = tok('--mb-top');
var C_DROP = tok('--mb-drop');
var C_GLASS = tok('--mb-glass');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0;
var LOWW = 0, LOWH = 0, SCALE = 4;
var fieldCv = document.createElement('canvas');
var fieldCtx = fieldCv.getContext('2d');
var bandCv = document.createElement('canvas');
var bandCtx = bandCv.getContext('2d');
var raf = 0, t0 = -1;
function makeRand(seed) {
var st = seed >>> 0;
return function () {
st = (st + 0x6D2B79F5) >>> 0;
var t = st;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = makeRand(0x317A09);
var blobs = [];
var drops = [];
var shocks = [];
var nextDropAt = 0.1;
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
LOWW = Math.max(48, Math.round(W / SCALE));
LOWH = Math.max(30, Math.round(H / SCALE));
fieldCv.width = LOWW; fieldCv.height = LOWH;
bandCv.width = LOWW; bandCv.height = LOWH;
ctx.imageSmoothingEnabled = true;
}
function reset() {
blobs = [];
for (var i = 0; i < N_BLOB; i++) {
blobs.push({
x: (0.3 + 0.4 * i / Math.max(1, N_BLOB - 1) + (rand() - 0.5) * 0.1) * LOWW,
y: LOWH * (0.74 + rand() * 0.1),
vx: (rand() - 0.5) * 3, vy: (rand() - 0.5) * 1.2,
r: LOWH * (0.26 + rand() * 0.06),
ph: rand() * Math.PI * 2
});
}
drops = [];
shocks = [];
nextDropAt = 0.1;
}
function spawnDrop(t) {
drops.push({ x: (0.12 + rand() * 0.76) * LOWW, y: -6, vy: 60 + rand() * 40 });
}
// ── 시뮬레이션 ──
function update(t, dt) {
if (t >= nextDropAt) {
spawnDrop(t);
nextDropAt = t + (RAIN_MS / 1000) * (0.55 + rand() * 0.9);
}
// 빗방울 낙하 — 수면(웅덩이 상단 평균)에 닿으면 충격파 발생
var surf = LOWH * 0.62;
for (var i = drops.length - 1; i >= 0; i--) {
var dp = drops[i];
dp.y += dp.vy * dt;
if (dp.y >= surf) {
shocks.push({ x: dp.x, y: dp.y, born: t, life: 0.55, amp: SHOCK });
drops.splice(i, 1);
}
}
while (shocks.length && t - shocks[0].born > 1.2) shocks.shift();
// 덩어리 — 표면장력(중심 당김) + 충격파 반발 + 감쇠
var j, k;
for (j = 0; j < blobs.length; j++) {
var b = blobs[j];
var hx = LOWW / 2, hy = LOWH * 0.8;
b.vx += (hx - b.x) * 0.55 * dt;
b.vy += (hy - b.y) * 0.55 * dt;
b.vx += Math.sin(t * 0.8 + b.ph) * 1.1 * dt;
for (k = 0; k < shocks.length; k++) {
var sh = shocks[k];
var u = (t - sh.born) / sh.life;
if (u >= 1) continue;
var dx = b.x - sh.x, dy = b.y - sh.y;
var dist = Math.hypot(dx, dy) || 1;
var ring = Math.sin(u * Math.PI) * sh.amp * Math.exp(-u * 1.6);
var push = ring * 10 / (1 + dist * 0.16);
b.vx += (dx / dist) * push * dt * 9;
b.vy += (dy / dist) * push * dt * 9;
}
b.vx *= 0.985; b.vy *= 0.985;
b.x += b.vx * dt; b.y += b.vy * dt;
b.y = Math.min(b.y, LOWH * 0.92);
}
}
// ── 필드 적분(가산 원) + 충격파 링 반영 ──
function drawField(t) {
fieldCtx.globalCompositeOperation = 'source-over';
fieldCtx.fillStyle = '#000';
fieldCtx.fillRect(0, 0, LOWW, LOWH);
fieldCtx.globalCompositeOperation = 'lighter';
var j, k;
for (j = 0; j < blobs.length; j++) {
var b = blobs[j];
var wob = 1 + Math.sin(t * 1.6 + b.ph) * 0.045;
var g = fieldCtx.createRadialGradient(b.x, b.y, 0, b.x, b.y, b.r * wob);
g.addColorStop(0, 'rgba(255,255,255,0.95)');
g.addColorStop(0.5, 'rgba(255,255,255,0.42)');
g.addColorStop(1, 'rgba(255,255,255,0)');
fieldCtx.fillStyle = g;
fieldCtx.fillRect(b.x - b.r, b.y - b.r, b.r * 2, b.r * 2);
}
// 충격파 — 얇은 링(가우시안 테두리)을 필드에 더했다 빼고(위상 반전) 재융합 물결을 남긴다
for (k = 0; k < shocks.length; k++) {
var sh = shocks[k];
var u = (t - sh.born) / sh.life;
if (u >= 1) continue;
var rr = 3 + u * LOWW * 0.42;
var amp = Math.sin(u * Math.PI) * sh.amp;
var ringW = 4.5;
var g2 = fieldCtx.createRadialGradient(sh.x, sh.y, Math.max(0.1, rr - ringW), sh.x, sh.y, rr + ringW);
var peak = Math.round(Math.max(0, Math.min(255, amp * 210)));
var dip = Math.round(Math.max(0, Math.min(255, -amp * 160)));
g2.addColorStop(0, 'rgba(' + dip + ',' + dip + ',' + dip + ',1)');
g2.addColorStop(0.5, 'rgba(' + peak + ',' + peak + ',' + peak + ',1)');
g2.addColorStop(1, 'rgba(0,0,0,1)');
fieldCtx.fillStyle = g2;
fieldCtx.fillRect(sh.x - rr - ringW, sh.y - rr - ringW, (rr + ringW) * 2, (rr + ringW) * 2);
}
fieldCtx.globalCompositeOperation = 'source-over';
}
// ── 임계값 색 매김 + putImageData ──
var work = null;
function classify() {
var img = fieldCtx.getImageData(0, 0, LOWW, LOWH);
var d = img.data;
if (!work || work.width !== LOWW || work.height !== LOWH) {
work = bandCtx.createImageData(LOWW, LOWH);
}
var wd = work.data;
// 캔버스 색 파싱은 매 프레임 하지 않고 값 배열로 미리 계산한다 — 여기선 간단히 16진 파싱
var deep = hexRGB(C_DEEP), mid = hexRGB(C_MID), top = hexRGB(C_TOP);
var glass = hexRGB(C_GLASS);
for (var y = 0; y < LOWH; y++) {
for (var x = 0; x < LOWW; x++) {
var idx = (y * LOWW + x) * 4;
var v = d[idx] / 255;
var r, g, b;
if (v > 0.66) { r = top[0]; g = top[1]; b = top[2]; }
else if (v > 0.3) { r = mid[0]; g = mid[1]; b = mid[2]; }
else if (v > 0.14) { r = deep[0]; g = deep[1]; b = deep[2]; }
else {
// 물 밖 — 창가 배경(위쪽 유리빛 → 아래 어두움) 수직 그라디언트
var k = y / LOWH;
r = Math.round(glass[0] * (1 - k) + deep[0] * 0.55 * k);
g = Math.round(glass[1] * (1 - k) + deep[1] * 0.55 * k);
b = Math.round(glass[2] * (1 - k) + deep[2] * 0.55 * k);
}
wd[idx] = r; wd[idx + 1] = g; wd[idx + 2] = b; wd[idx + 3] = 255;
}
}
bandCtx.putImageData(work, 0, 0);
}
function hexRGB(h) {
var s = (h || '#000').replace('#', '');
if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2];
return [parseInt(s.slice(0, 2), 16) || 0, parseInt(s.slice(2, 4), 16) || 0, parseInt(s.slice(4, 6), 16) || 0];
}
function draw(t) {
drawField(t);
classify();
ctx.clearRect(0, 0, W, H);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(bandCv, 0, 0, LOWW, LOWH, 0, 0, W, H);
// 낙하 중인 빗방울 — 짧은 세로 줄무늬
ctx.strokeStyle = C_DROP;
ctx.lineWidth = 1.6;
ctx.globalAlpha = 0.9;
for (var i = 0; i < drops.length; i++) {
var dp = drops[i];
ctx.beginPath();
ctx.moveTo(dp.x * SCALE, dp.y * SCALE - 9);
ctx.lineTo(dp.x * SCALE, dp.y * SCALE + 2);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
function stillFrame() {
reset();
drops.push({ x: LOWW * 0.5, y: LOWH * 0.2, vy: 80 });
shocks.push({ x: LOWW * 0.55, y: LOWH * 0.66, born: 0.1, life: 0.55, amp: SHOCK });
draw(0.16);
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
var dt = Math.min(0.05, (now - (frame.prev || now)) / 1000);
frame.prev = now;
update(t, dt);
draw(t);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
reset();
if (reduce) stillFrame();
});
fit();
reset();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// RainPond — vanilla 판(run/317/demo/09-metaball-rain-pond-ripple-merge/index.html) 의 React 포트.
// 빗물 웅덩이 — 하단 부력 덩어리(표면장력으로 모여 병합) 위로 빗방울이 낙하하고, 착수 순간 원형
// 충격파 링이 필드를 밀어냈다 빠지며 덩어리가 출렁이고 갈라진다. 필드는 저해상도 가산 원으로 적분해
// getImageData 로 읽고 임계값 색을 매겨 putImageData 로 되돌려 확대한다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./RainPond.module.scss";
export type RainPondProps = {
duration?: string;
easing?: string;
color?: string;
blobs?: number; // 웅덩이 덩어리 수
rainMs?: number; // 빗방울 평균 간격(밀리초)
shock?: number; // 충격파 세기
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type Blob = { x: number; y: number; vx: number; vy: number; r: number; ph: number };
type Drop = { x: number; y: number; vy: number };
type Shock = { x: number; y: number; born: number; life: number; amp: number };
type RGB = [number, number, number];
const FIELD_LABEL = "빗물 웅덩이 메타볼 실험판";
const SCALE = 4;
function makePRNG(seed: number): () => number {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hexRGB(h: string): RGB {
let s = (h || "#000").replace("#", "");
if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2];
return [parseInt(s.slice(0, 2), 16) || 0, parseInt(s.slice(2, 4), 16) || 0, parseInt(s.slice(4, 6), 16) || 0];
}
export default function RainPond({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#6ec8ff",
blobs: nBlob = 3,
rainMs = 360,
shock = 0.9,
}: RainPondProps) {
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 sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const lowRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const fieldCvRef = useRef<HTMLCanvasElement | null>(null);
const bandCvRef = useRef<HTMLCanvasElement | null>(null);
const fieldCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const bandCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const workRef = useRef<ImageData | null>(null);
const colRef = useRef<{ deep: RGB; mid: RGB; top: RGB; glass: RGB; drop: string } | null>(null);
const blobRef = useRef(nBlob) as Box<number>;
const rainRef = useRef(rainMs) as Box<number>;
const shockRef = useRef(shock) as Box<number>;
const stateRef = useRef({ blobs: [] as Blob[], drops: [] as Drop[], shocks: [] as Shock[], nextAt: 0.1 }) as Box<{ blobs: Blob[]; drops: Drop[]; shocks: Shock[]; nextAt: number }>;
const rafRef = useRef(0) as Box<number>;
const [n, setN] = useState(String(nBlob));
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function reset(): void {
const { w: lw, h: lh } = lowRef.current;
const rand = makePRNG(0x317a09);
const blobs: Blob[] = [];
const nb = blobRef.current;
for (let i = 0; i < nb; i++) {
blobs.push({
x: (0.3 + (0.4 * i) / Math.max(1, nb - 1) + (rand() - 0.5) * 0.1) * lw,
y: lh * (0.74 + rand() * 0.1),
vx: (rand() - 0.5) * 3, vy: (rand() - 0.5) * 1.2,
r: lh * (0.26 + rand() * 0.06),
ph: rand() * Math.PI * 2,
});
}
stateRef.current = { blobs, drops: [], shocks: [], nextAt: 0.1 };
}
function update(t: number, dt: number): void {
const st = stateRef.current;
const { w: lw, h: lh } = lowRef.current;
const rand = makePRNG(0x317a09 + Math.floor(t * 9973));
if (t >= st.nextAt) {
st.drops.push({ x: (0.12 + rand() * 0.76) * lw, y: -6, vy: 60 + rand() * 40 });
st.nextAt = t + (rainRef.current / 1000) * (0.55 + rand() * 0.9);
}
const surf = lh * 0.62;
for (let i = st.drops.length - 1; i >= 0; i--) {
const dp = st.drops[i];
dp.y += dp.vy * dt;
if (dp.y >= surf) {
st.shocks.push({ x: dp.x, y: dp.y, born: t, life: 0.55, amp: shockRef.current });
st.drops.splice(i, 1);
}
}
while (st.shocks.length && t - st.shocks[0].born > 1.2) st.shocks.shift();
for (let j = 0; j < st.blobs.length; j++) {
const b = st.blobs[j];
const hx = lw / 2, hy = lh * 0.8;
b.vx += (hx - b.x) * 0.55 * dt;
b.vy += (hy - b.y) * 0.55 * dt;
b.vx += Math.sin(t * 0.8 + b.ph) * 1.1 * dt;
for (let k = 0; k < st.shocks.length; k++) {
const sh = st.shocks[k];
const u = (t - sh.born) / sh.life;
if (u >= 1) continue;
const dx = b.x - sh.x, dy = b.y - sh.y;
const dist = Math.hypot(dx, dy) || 1;
const ring = Math.sin(u * Math.PI) * sh.amp * Math.exp(-u * 1.6);
const push = (ring * 10) / (1 + dist * 0.16);
b.vx += (dx / dist) * push * dt * 9;
b.vy += (dy / dist) * push * dt * 9;
}
b.vx *= 0.985; b.vy *= 0.985;
b.x += b.vx * dt; b.y += b.vy * dt;
b.y = Math.min(b.y, lh * 0.92);
}
}
function drawField(t: number): void {
const fieldCtx = fieldCtxRef.current;
const st = stateRef.current;
const { w: lw, h: lh } = lowRef.current;
if (!fieldCtx) return;
fieldCtx.globalCompositeOperation = "source-over";
fieldCtx.fillStyle = "#000";
fieldCtx.fillRect(0, 0, lw, lh);
fieldCtx.globalCompositeOperation = "lighter";
for (let j = 0; j < st.blobs.length; j++) {
const b = st.blobs[j];
const wob = 1 + Math.sin(t * 1.6 + b.ph) * 0.045;
const g = fieldCtx.createRadialGradient(b.x, b.y, 0, b.x, b.y, b.r * wob);
g.addColorStop(0, "rgba(255,255,255,0.95)");
g.addColorStop(0.5, "rgba(255,255,255,0.42)");
g.addColorStop(1, "rgba(255,255,255,0)");
fieldCtx.fillStyle = g;
fieldCtx.fillRect(b.x - b.r, b.y - b.r, b.r * 2, b.r * 2);
}
for (let k = 0; k < st.shocks.length; k++) {
const sh = st.shocks[k];
const u = (t - sh.born) / sh.life;
if (u >= 1) continue;
const rr = 3 + u * lw * 0.42;
const amp = Math.sin(u * Math.PI) * sh.amp;
const ringW = 4.5;
const g2 = fieldCtx.createRadialGradient(sh.x, sh.y, Math.max(0.1, rr - ringW), sh.x, sh.y, rr + ringW);
const peak = Math.round(Math.max(0, Math.min(255, amp * 210)));
const dip = Math.round(Math.max(0, Math.min(255, -amp * 160)));
g2.addColorStop(0, `rgba(${dip},${dip},${dip},1)`);
g2.addColorStop(0.5, `rgba(${peak},${peak},${peak},1)`);
g2.addColorStop(1, "rgba(0,0,0,1)");
fieldCtx.fillStyle = g2;
fieldCtx.fillRect(sh.x - rr - ringW, sh.y - rr - ringW, (rr + ringW) * 2, (rr + ringW) * 2);
}
fieldCtx.globalCompositeOperation = "source-over";
}
function classify(): void {
const fieldCtx = fieldCtxRef.current;
const bandCtx = bandCtxRef.current;
const col = colRef.current;
const { w: lw, h: lh } = lowRef.current;
if (!fieldCtx || !bandCtx || !col) return;
const img = fieldCtx.getImageData(0, 0, lw, lh);
const d = img.data;
let work = workRef.current;
if (!work || work.width !== lw || work.height !== lh) {
work = bandCtx.createImageData(lw, lh);
workRef.current = work;
}
const wd = work.data;
for (let y = 0; y < lh; y++) {
for (let x = 0; x < lw; x++) {
const idx = (y * lw + x) * 4;
const v = d[idx] / 255;
let r: number, g: number, b: number;
if (v > 0.66) { r = col.top[0]; g = col.top[1]; b = col.top[2]; }
else if (v > 0.3) { r = col.mid[0]; g = col.mid[1]; b = col.mid[2]; }
else if (v > 0.14) { r = col.deep[0]; g = col.deep[1]; b = col.deep[2]; }
else {
const k = y / lh;
r = Math.round(col.glass[0] * (1 - k) + col.deep[0] * 0.55 * k);
g = Math.round(col.glass[1] * (1 - k) + col.deep[1] * 0.55 * k);
b = Math.round(col.glass[2] * (1 - k) + col.deep[2] * 0.55 * k);
}
wd[idx] = r; wd[idx + 1] = g; wd[idx + 2] = b; wd[idx + 3] = 255;
}
}
bandCtx.putImageData(work, 0, 0);
}
function draw(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const bandCv = bandCvRef.current;
const st = stateRef.current;
const { w: W, h: H } = sizeRef.current;
const { w: lw, h: lh } = lowRef.current;
if (!ctx || !bandCv || !col) return;
drawField(t);
classify();
ctx.clearRect(0, 0, W, H);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(bandCv, 0, 0, lw, lh, 0, 0, W, H);
ctx.strokeStyle = col.drop;
ctx.lineWidth = 1.6;
ctx.globalAlpha = 0.9;
for (let i = 0; i < st.drops.length; i++) {
const dp = st.drops[i];
ctx.beginPath();
ctx.moveTo(dp.x * SCALE, dp.y * SCALE - 9);
ctx.lineTo(dp.x * SCALE, dp.y * SCALE + 2);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) 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);
const lw = Math.max(48, Math.round(r.width / SCALE));
const lh = Math.max(30, Math.round(r.height / SCALE));
lowRef.current = { w: lw, h: lh };
const fc = fieldCvRef.current as HTMLCanvasElement; fc.width = lw; fc.height = lh;
const bc = bandCvRef.current as HTMLCanvasElement; bc.width = lw; bc.height = lh;
fieldCtxRef.current = fc.getContext("2d");
bandCtxRef.current = bc.getContext("2d");
ctx.imageSmoothingEnabled = true;
ctxRef.current = ctx;
}
function stillFrame(): void {
const { w: lw, h: lh } = lowRef.current;
reset();
stateRef.current.drops.push({ x: lw * 0.5, y: lh * 0.2, vy: 80 });
stateRef.current.shocks.push({ x: lw * 0.55, y: lh * 0.66, born: 0.1, life: 0.55, amp: shockRef.current });
draw(0.16);
}
let t0 = -1;
let prev = 0;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
const dt = Math.min(0.05, (now - (prev || now)) / 1000);
prev = now;
update(t, dt);
draw(t);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
blobRef.current = nBlob;
rainRef.current = rainMs;
shockRef.current = shock;
const cs = getComputedStyle(root);
const tok = (name: string) => cs.getPropertyValue(name).trim();
colRef.current = {
deep: hexRGB(tok("--mb-deep")),
mid: hexRGB(tok("--mb-mid")),
top: hexRGB(tok("--mb-top")),
glass: hexRGB(tok("--mb-glass")),
drop: tok("--mb-drop"),
};
fit();
reset();
setN(String(nBlob));
function onResize(): void {
fit();
reset();
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nBlob, rainMs, shock]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>빗방울이 웅덩이에 닿는 순간 원형 충격파가 필드를 밀어냈다 사라지면 덩어리가 출렁이며 갈라지고 다시 병합합니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{n}</b>덩어리 · 빗물 웅덩이</span>
<span className={styles.metaDim}>canvas · getImageData · putImageData</span>
</div>
</div>
</main>
);
}09 빗물 웅덩이 — 낙하 충격파가 볼을 밀어내는 장면 — 측정 누적면적 52.152% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 캔버스 메타볼 효과 9종 — 마칭 스퀘어로 합체하는 액체 덩어리
이 데모가 속한 글
캔버스 메타볼 효과 9종 — 마칭 스퀘어로 합체하는 액체 덩어리메타볼 효과는 여러 덩어리가 가까워지면 하나로 녹아 붙고 멀어지면 갈라지는, 액체처럼 굴러가는 캔버스 장면입니다. 필드 값이 임계값을 넘는 경계를 격자 칸마다 잇는 마칭 스퀘어 하나로 블롭이 스며들듯 합쳐졌다 갈라집 이 글의 데모 전부 보기


