09Rain pond — drop shockwaves that push the blobs apart
Add a circular shockwave to the field the instant a drop lands, then remove it — the pond mass shudders and splits, merging again and again on the ripples of a window-side rain scene.
Best forWeather and mood-page hero scenes
getImageDataputImageDatarequestAnimationFrame

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<span>The instant a raindrop hits the pond, a circular shockwave pushes the field outward and fades — the blob ripples, splits, and merges again</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="Rain-Pond Ripple test panel" 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>blobs · rain-pond ripple</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 Rain pond — drop shockwaves that push the blobs apart — Measured area 52.152% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · 9 Metaball Effect Scenes, Marching Squares Canvas
The post this demo lives in
9 Metaball Effect Scenes, Marching Squares CanvasNo library, no WebGL — marching squares links grid cells whose field crosses one threshold, and blobs merge and split li See all demos in this post
If you like this effect - similar demos
Creative UI06 Metaball merge — smooth union of blobs9 Ray Marching Scenes in Canvas (No WebGL)
Creative UI07 Wave ripple — the whole pattern drifts9 Reaction Diffusion Simulations, No Library
Creative UI07 Acid dissolve — a reaction rule that erases the material it meetsFalling Sand Simulator, 9 Ways (No Library)