03グーイ融合 — SVGフィルタなしで本物のパスが滲む
SVGグーフィーフィルタのblur+contrast近似と違い、マーチングスクエアで得たパスをそのまま塗ると拡大しても輪郭が崩れず色遷移もパス単位で制御できます。
使いどころボタンアイコン·ナビゲーションの粘着的な融合
Path2DrequestAnimationFramesetAttribute

<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・Path2D・setAttribute</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 구이 병합(03). @keyframes 없음 — 움직임은 rAF(마칭 스퀘어 경로 + 경계를 따라 전환되는
// 스티키 병합 채움)가 만든다. SVG 필터를 쓰지 않는다 — 실제 경로 계산.
// 무대는 잉크. 파스텔을 --gm-* 로 편다(편 공통 규격).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-pink;
@include stage($stage-ink);
// ── 파스텔 팔레트(BD26 — vivid 잔존 0) ──
$gm-glow: $subject-pink; // 별 채움·잔광(핑크)
$gm-body: $subject-mint; // 몸통 기본 채움(민트)
$gm-sky: $subject-sky; // 이동 트레일(하늘색)
$gm-lilac: $subject-lilac; // 테두리 하이라이트(라일락)
$gm-cream: $subject-cream; // 코어 하이라이트
$gm-line: #3a3542; // 어두운 무대 위 패널 경계(뮤트)
$gm-text: #e8e4ef; // 잉크 위 본문(밝은 라벤더 그레이·뮤트)
.cd {
--gm-glow: #{$gm-glow};
--gm-body: #{$gm-body};
--gm-sky: #{$gm-sky};
--gm-lilac: #{$gm-lilac};
--gm-cream: #{$gm-cream};
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: $gm-text;
}
.cd__field {
position: relative; height: 188px;
border-radius: $r-card; overflow: hidden;
background: $stage-ink;
box-shadow: inset 0 0 0 1px $gm-line;
}
.cd__field:focus-visible { outline: 2px solid $gm-body; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($gm-text, .72); }
.cd__val { color: $gm-body; font-weight: 700; }
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 104px; }
.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 {
--gm-glow: #ff9ebb;
--gm-body: #4cd4a6;
--gm-sky: #6ec8ff;
--gm-lilac: #b9a0ff;
--gm-cream: #fff7e6;
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: #e8e4ef;
}
.cd__field {
position: relative;
height: 188px;
border-radius: 16px;
overflow: hidden;
background: #17141a;
box-shadow: inset 0 0 0 1px #3a3542;
}
.cd__field:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(232, 228, 239, 0.72);
}
.cd__val {
color: #4cd4a6;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 104px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 구이 병합(03). 02 와 같은 마칭 스퀘어 경계 추출(4비트 16경우 + 선형 보간 + quadraticCurveTo
// 평활)로 닫힌 윤곽을 Path2D 로 만들되, 채움을 "색이 경로를 따라 전환되는" 스티키 방식으로 얹는다:
// 각 고리의 도심(무게중심)에서 가장 가까운 볼의 색이 그 고리를 칠하고, 볼마다 잔광 트레일을
// 오프스크린 트레일 캔버스에 축적(알파 감쇠)해 경로를 따라 색이 흘러간다.
// SVG goo 필터(feGaussianBlur + feColorMatrix)를 전혀 쓰지 않는다 — 병합은 전부 실제 경로 계산.
// 고리 개수를 aria 로 내보내고 title 요소 setAttribute 로 갱신해 접근성 메타를 살린다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var N_BALL = 3; // ボール 개수 — 2 는 대화 장면, 5 는 군무
var THRESH = 1.0; // 필드 임계값 — 낮으면 더 끈적하게 늘어난다
var CELL = 8; // 격자 셀 픽셀 크기
var TRAIL = 0.965; // 트레일 잔광 감쇠(프레임당) — 1 에 가까울수록 길게 남는다
// ────────────────────────────────────────────────────
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_BALL);
// 접근성 메타 — 고리 개수를 title data 특성으로 내보낸다(setAttribute)
var liveMeta = document.createElement('span');
liveMeta.setAttribute('aria-hidden', 'false');
liveMeta.setAttribute('data-loops', '0');
var css = getComputedStyle(root);
function tok(name) { return css.getPropertyValue(name).trim(); }
var C_BODY = tok('--gm-body');
var C_GLOW = tok('--gm-glow');
var C_SKY = tok('--gm-sky');
var C_LILAC = tok('--gm-lilac');
var C_CREAM = tok('--gm-cream');
// 볼마다 고유 파스텔 — 경로를 따라 색이 전환되는 축
var BALL_COLS = [C_BODY, C_GLOW, C_SKY];
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, COLS = 0, ROWS = 0;
var grid = null;
var trailCv = document.createElement('canvas');
var trailCtx = null;
var raf = 0, t0 = -1;
// 시드 고정 PRNG — 미리보기가 항상 같은 ボール 궤도로 흐른다
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(0x317A03);
var balls = [];
function buildBalls() {
balls = [];
for (var i = 0; i < N_BALL; i++) {
balls.push({
fx: 0.3 + rand() * 0.4, fy: 0.26 + rand() * 0.4,
ph: rand() * Math.PI * 2, ax: 0.3 + rand() * 0.2, ay: 0.28 + rand() * 0.2,
cx: 0.5, cy: 0.5, rad: 34 + rand() * 16
});
}
}
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
trailCv.width = cv.width; trailCv.height = cv.height;
trailCtx = trailCv.getContext('2d');
COLS = Math.max(16, Math.round(W / CELL));
ROWS = Math.max(12, Math.round(H / CELL));
grid = new Float32Array((COLS + 1) * (ROWS + 1));
}
function ballPos(b, t) {
return [
(b.cx + Math.sin(t * b.fx + b.ph) * b.ax) * W,
(b.cy + Math.cos(t * b.fy + b.ph * 1.4) * b.ay) * H
];
}
function fieldAt(px, py, t) {
var sum = 0;
for (var i = 0; i < balls.length; i++) {
var p = ballPos(balls[i], t);
var dx = px - p[0], dy = py - p[1];
var d2 = dx * dx + dy * dy;
if (d2 < 1) d2 = 1;
var r2 = balls[i].rad * balls[i].rad * 0.5;
sum += r2 / d2;
}
return sum;
}
function sampleGrid(t) {
var cw = W / COLS, ch = H / ROWS;
for (var gy = 0; gy <= ROWS; gy++) {
for (var gx = 0; gx <= COLS; gx++) {
grid[gy * (COLS + 1) + gx] = fieldAt(gx * cw, gy * ch, t);
}
}
}
var EDGES = { T: 0, R: 1, B: 2, L: 3 };
function caseSegments(cs) {
switch (cs) {
case 0: case 15: return [];
case 1: case 14: return [[EDGES.T, EDGES.L]];
case 2: case 13: return [[EDGES.T, EDGES.R]];
case 3: case 12: return [[EDGES.L, EDGES.R]];
case 4: case 11: return [[EDGES.R, EDGES.B]];
case 6: case 9: return [[EDGES.T, EDGES.B]];
case 7: case 8: return [[EDGES.L, EDGES.B]];
case 5: return 'AMB-x';
case 10: return 'AMB-+';
}
return [];
}
function lerpPt(gx, gy, edge, cw, ch) {
var vTL = grid[gy * (COLS + 1) + gx];
var vTR = grid[gy * (COLS + 1) + gx + 1];
var vBR = grid[(gy + 1) * (COLS + 1) + gx + 1];
var vBL = grid[(gy + 1) * (COLS + 1) + gx];
var x0 = gx * cw, y0 = gy * ch;
function ip(a, b) { return (THRESH - a) / (b - a); }
switch (edge) {
case EDGES.T: return [x0 + cw * ip(vTL, vTR), y0];
case EDGES.B: return [x0 + cw * ip(vBL, vBR), y0 + ch];
case EDGES.L: return [x0, y0 + ch * ip(vTL, vBL)];
case EDGES.R: return [x0 + cw, y0 + ch * ip(vTR, vBR)];
}
return [x0, y0];
}
function collectSegments(t) {
var cw = W / COLS, ch = H / ROWS;
var pts = [];
for (var gy = 0; gy < ROWS; gy++) {
for (var gx = 0; gx < COLS; gx++) {
var vTL = grid[gy * (COLS + 1) + gx];
var vTR = grid[gy * (COLS + 1) + gx + 1];
var vBR = grid[(gy + 1) * (COLS + 1) + gx + 1];
var vBL = grid[(gy + 1) * (COLS + 1) + gx];
var cs = (vTL > THRESH ? 1 : 0) | (vTR > THRESH ? 2 : 0) | (vBR > THRESH ? 4 : 0) | (vBL > THRESH ? 8 : 0);
if (cs === 0 || cs === 15) continue;
var segs = caseSegments(cs);
if (typeof segs === 'string') {
var mc = fieldAt((gx + 0.5) * cw, (gy + 0.5) * ch, t);
var flip = (cs === 5) === (mc <= THRESH);
var a = flip ? [[EDGES.T, EDGES.R], [EDGES.L, EDGES.B]]
: [[EDGES.T, EDGES.L], [EDGES.R, EDGES.B]];
for (var k = 0; k < 2; k++) {
pts.push([lerpPt(gx, gy, a[k][0], cw, ch), lerpPt(gx, gy, a[k][1], cw, ch)]);
}
} else {
for (var s = 0; s < segs.length; s++) {
pts.push([lerpPt(gx, gy, segs[s][0], cw, ch), lerpPt(gx, gy, segs[s][1], cw, ch)]);
}
}
}
}
return pts;
}
function chainSegments(segs) {
var eps2 = (CELL * 1.5) * (CELL * 1.5);
var used = new Uint8Array(segs.length);
var loops = [];
for (var i = 0; i < segs.length; i++) {
if (used[i]) continue;
used[i] = 1;
var loop = [segs[i][0], segs[i][1]];
var guard = 0;
while (guard++ < 4000) {
var tail = loop[loop.length - 1];
var found = -1;
for (var j = 0; j < segs.length; j++) {
if (used[j]) continue;
var dx = segs[j][0][0] - tail[0], dy = segs[j][0][1] - tail[1];
if (dx * dx + dy * dy < eps2) { found = j; loop.push(segs[j][1]); break; }
dx = segs[j][1][0] - tail[0]; dy = segs[j][1][1] - tail[1];
if (dx * dx + dy * dy < eps2) { loop.push(segs[j][0]); found = j; break; }
}
if (found < 0) break;
used[found] = 1;
}
if (loop.length >= 3) loops.push(loop);
}
return loops;
}
function smoothLoop(path, loop) {
var n = loop.length;
path.moveTo((loop[0][0] + loop[1][0]) / 2, (loop[0][1] + loop[1][1]) / 2);
for (var i = 1; i <= n; i++) {
var cur = loop[i % n];
var nxt = loop[(i + 1) % n];
path.quadraticCurveTo(cur[0], cur[1], (cur[0] + nxt[0]) / 2, (cur[1] + nxt[1]) / 2);
}
path.closePath();
}
// 고리 도심 — 무게중심에서 가장 가까운 볼의 색으로 고리를 칠한다(색이 경로를 따라 전환)
function loopCentroid(loop) {
var sx = 0, sy = 0;
for (var i = 0; i < loop.length; i++) { sx += loop[i][0]; sy += loop[i][1]; }
return [sx / loop.length, sy / loop.length];
}
function nearestBall(cx, cy, t) {
var best = 0, bd = 1e9;
for (var i = 0; i < balls.length; i++) {
var p = ballPos(balls[i], t);
var d = (p[0] - cx) * (p[0] - cx) + (p[1] - cy) * (p[1] - cy);
if (d < bd) { bd = d; best = i; }
}
return best;
}
function draw(t) {
sampleGrid(t);
var loops = chainSegments(collectSegments(t));
liveMeta.setAttribute('data-loops', String(loops.length));
// ① 트레일 캔버스 — 이전 프레임을 살짝 어둡게 해 잔광을 축적
if (trailCtx) {
trailCtx.globalCompositeOperation = 'source-over';
trailCtx.fillStyle = 'rgba(23,20,26,' + (1 - TRAIL) * 1.6 + ')';
trailCtx.fillRect(0, 0, W, H);
// ボール 궤적 잔광 — 볼마다 자기 색 알파 원을 찍는다
trailCtx.globalCompositeOperation = 'lighter';
for (var i = 0; i < balls.length; i++) {
var p = ballPos(balls[i], t);
var g = trailCtx.createRadialGradient(p[0], p[1], 0, p[0], p[1], balls[i].rad * 1.5);
var col = BALL_COLS[i % BALL_COLS.length];
g.addColorStop(0, hexA(col, 0.2));
g.addColorStop(1, hexA(col, 0));
trailCtx.fillStyle = g;
trailCtx.fillRect(0, 0, W, H);
}
trailCtx.globalCompositeOperation = 'source-over';
}
// ② 본 캔버스 — 트레일 위에 구이 몸통
ctx.fillStyle = '#17141a';
ctx.fillRect(0, 0, W, H);
if (trailCtx) { ctx.drawImage(trailCv, 0, 0); }
// 배경 잔광 드리프트 — 느린 안개(누적면적)
var mist = ctx.createRadialGradient(
W * (0.5 + Math.sin(t * 0.14 + 2) * 0.3), H * (0.5 + Math.cos(t * 0.12) * 0.3), 0,
W * 0.5, H * 0.5, Math.max(W, H) * 0.78
);
mist.addColorStop(0, 'rgba(110,200,255,0.08)');
mist.addColorStop(1, 'rgba(23,20,26,0)');
ctx.fillStyle = mist;
ctx.fillRect(0, 0, W, H);
// 고리별 스티키 채움 — 도심에서 가장 가까운 ボール 색, stroke 는 라일락 하이라이트
for (var li = 0; li < loops.length; li++) {
var path = new Path2D();
smoothLoop(path, loops[li]);
var c = loopCentroid(loops[li]);
var bi = nearestBall(c[0], c[1], t);
ctx.fillStyle = BALL_COLS[bi % BALL_COLS.length];
ctx.globalAlpha = 0.92;
ctx.fill(path);
ctx.globalAlpha = 1;
ctx.strokeStyle = C_LILAC;
ctx.lineWidth = 1.6;
ctx.lineJoin = 'round';
ctx.stroke(path);
}
// ボール 코어 — 크림 점
for (var b2 = 0; b2 < balls.length; b2++) {
var p2 = ballPos(balls[b2], t);
ctx.fillStyle = C_CREAM;
ctx.beginPath(); ctx.arc(p2[0], p2[1], 2.6, 0, Math.PI * 2); ctx.fill();
}
}
// '#rrggbb' + 알파 → rgba 문자열 (캔버스 그라디언트용)
function hexA(hex, a) {
if (hex.charAt(0) !== '#') return hex;
var r = parseInt(hex.slice(1, 3), 16);
var g = parseInt(hex.slice(3, 5), 16);
var b = parseInt(hex.slice(5, 7), 16);
return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')';
}
function stillFrame() { draw(0.6); }
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
draw(t);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
buildBalls();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// GooeyMerge — vanilla 판(run/317/demo/03-metaball-gooey-merge-svg/index.html) 의 React 포트.
// 마칭 스퀘어 경계 추출로 만든 실제 경로(Path2D)를 그대로 fill+stroke 하고, 볼마다 잔광
// 트레일을 축적해 경로를 따라 색이 전환되는 스티키 병합을 만든다 — SVG 필터를 쓰지 않는다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GooeyMerge.module.scss";
export type GooeyMergeProps = {
duration?: string;
easing?: string;
color?: string;
balls?: number; // 볼 개수 — 2 는 대화 장면, 5 는 군무
threshold?: number; // 필드 임계값
cell?: number; // 격자 셀 픽셀 크기
trail?: number; // 트레일 잔광 감쇠(프레임당)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type Ball = { fx: number; fy: number; ph: number; ax: number; ay: number; cx: number; cy: number; rad: number };
const FIELD_LABEL = "구이 병합 끈적 합체 실험판";
const E_T = 0, E_R = 1, E_B = 2, E_L = 3;
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 caseSegments(cs: number): number[][] | string {
switch (cs) {
case 0: case 15: return [];
case 1: case 14: return [[E_T, E_L]];
case 2: case 13: return [[E_T, E_R]];
case 3: case 12: return [[E_L, E_R]];
case 4: case 11: return [[E_R, E_B]];
case 6: case 9: return [[E_T, E_B]];
case 7: case 8: return [[E_L, E_B]];
case 5: return "AMB-x";
case 10: return "AMB-+";
}
return [];
}
function hexA(hex: string, a: number): string {
if (hex.charAt(0) !== "#") return hex;
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r},${g},${b},${a})`;
}
export default function GooeyMerge({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#ff9ebb",
balls: nBall = 3,
threshold = 1.0,
cell = 8,
trail = 0.965,
}: GooeyMergeProps) {
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 trailCvRef = useRef<HTMLCanvasElement | null>(null);
const trailCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const colsRef = useRef(0) as Box<number>;
const rowsRef = useRef(0) as Box<number>;
const gridRef = useRef(new Float32Array(0)) as Box<Float32Array>;
const colRef = useRef<{ body: string; glow: string; sky: string; lilac: string; cream: string } | null>(null);
const ballNRef = useRef(nBall) as Box<number>;
const thrRef = useRef(threshold) as Box<number>;
const cellRef = useRef(cell) as Box<number>;
const trailRef = useRef(trail) as Box<number>;
const ballsRef = useRef<Ball[]>([]) as Box<Ball[]>;
const rafRef = useRef(0) as Box<number>;
const [n, setN] = useState(String(nBall));
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 buildBalls(): void {
const rand = makePRNG(0x317a03);
const arr: Ball[] = [];
for (let i = 0; i < ballNRef.current; i++) {
arr.push({
fx: 0.3 + rand() * 0.4, fy: 0.26 + rand() * 0.4,
ph: rand() * Math.PI * 2, ax: 0.3 + rand() * 0.2, ay: 0.28 + rand() * 0.2,
cx: 0.5, cy: 0.5, rad: 34 + rand() * 16,
});
}
ballsRef.current = arr;
}
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 tc = trailCvRef.current;
if (tc) {
tc.width = cv.width;
tc.height = cv.height;
trailCtxRef.current = tc.getContext("2d");
}
colsRef.current = Math.max(16, Math.round(r.width / cellRef.current));
rowsRef.current = Math.max(12, Math.round(r.height / cellRef.current));
gridRef.current = new Float32Array((colsRef.current + 1) * (rowsRef.current + 1));
ctxRef.current = ctx;
}
function ballPos(b: Ball, t: number, w: number, h: number): [number, number] {
return [
(b.cx + Math.sin(t * b.fx + b.ph) * b.ax) * w,
(b.cy + Math.cos(t * b.fy + b.ph * 1.4) * b.ay) * h,
];
}
function fieldAt(px: number, py: number, t: number): number {
const { w, h } = sizeRef.current;
let sum = 0;
const balls = ballsRef.current;
for (let i = 0; i < balls.length; i++) {
const p = ballPos(balls[i], t, w, h);
const dx = px - p[0];
const dy = py - p[1];
let d2 = dx * dx + dy * dy;
if (d2 < 1) d2 = 1;
sum += (balls[i].rad * balls[i].rad * 0.5) / d2;
}
return sum;
}
function sampleGrid(t: number): void {
const { w: W, h: H } = sizeRef.current;
const cw = W / colsRef.current;
const ch = H / rowsRef.current;
const g = gridRef.current;
for (let gy = 0; gy <= rowsRef.current; gy++) {
for (let gx = 0; gx <= colsRef.current; gx++) {
g[gy * (colsRef.current + 1) + gx] = fieldAt(gx * cw, gy * ch, t);
}
}
}
function lerpPt(gx: number, gy: number, edge: number, cw: number, ch: number): [number, number] {
const g = gridRef.current;
const stride = colsRef.current + 1;
const vTL = g[gy * stride + gx];
const vTR = g[gy * stride + gx + 1];
const vBR = g[(gy + 1) * stride + gx + 1];
const vBL = g[(gy + 1) * stride + gx];
const x0 = gx * cw;
const y0 = gy * ch;
const ip = (a: number, b: number) => (thrRef.current - a) / (b - a);
switch (edge) {
case E_T: return [x0 + cw * ip(vTL, vTR), y0];
case E_B: return [x0 + cw * ip(vBL, vBR), y0 + ch];
case E_L: return [x0, y0 + ch * ip(vTL, vBL)];
case E_R: return [x0 + cw, y0 + ch * ip(vTR, vBR)];
}
return [x0, y0];
}
function collectSegments(t: number): [[number, number], [number, number]][] {
const { w: W, h: H } = sizeRef.current;
const cw = W / colsRef.current;
const ch = H / rowsRef.current;
const g = gridRef.current;
const stride = colsRef.current + 1;
const thr = thrRef.current;
const pts: [[number, number], [number, number]][] = [];
for (let gy = 0; gy < rowsRef.current; gy++) {
for (let gx = 0; gx < colsRef.current; gx++) {
const vTL = g[gy * stride + gx];
const vTR = g[gy * stride + gx + 1];
const vBR = g[(gy + 1) * stride + gx + 1];
const vBL = g[(gy + 1) * stride + gx];
const cs =
(vTL > thr ? 1 : 0) | (vTR > thr ? 2 : 0) | (vBR > thr ? 4 : 0) | (vBL > thr ? 8 : 0);
if (cs === 0 || cs === 15) continue;
const segs = caseSegments(cs);
if (typeof segs === "string") {
const mc = fieldAt((gx + 0.5) * cw, (gy + 0.5) * ch, t);
const flip = (cs === 5) === (mc <= thr);
const a = flip
? [[E_T, E_R], [E_L, E_B]] as number[][]
: [[E_T, E_L], [E_R, E_B]] as number[][];
for (let k = 0; k < 2; k++) {
pts.push([lerpPt(gx, gy, a[k][0], cw, ch), lerpPt(gx, gy, a[k][1], cw, ch)]);
}
} else {
for (let s = 0; s < segs.length; s++) {
pts.push([lerpPt(gx, gy, segs[s][0], cw, ch), lerpPt(gx, gy, segs[s][1], cw, ch)]);
}
}
}
}
return pts;
}
function chainSegments(segs: [[number, number], [number, number]][]): [number, number][][] {
const cellPx = cellRef.current;
const eps2 = (cellPx * 1.5) * (cellPx * 1.5);
const used = new Uint8Array(segs.length);
const loops: [number, number][][] = [];
for (let i = 0; i < segs.length; i++) {
if (used[i]) continue;
used[i] = 1;
const loop: [number, number][] = [segs[i][0], segs[i][1]];
let guard = 0;
while (guard++ < 4000) {
const tail = loop[loop.length - 1];
let found = -1;
for (let j = 0; j < segs.length; j++) {
if (used[j]) continue;
let dx = segs[j][0][0] - tail[0];
let dy = segs[j][0][1] - tail[1];
if (dx * dx + dy * dy < eps2) { found = j; loop.push(segs[j][1]); break; }
dx = segs[j][1][0] - tail[0];
dy = segs[j][1][1] - tail[1];
if (dx * dx + dy * dy < eps2) { loop.push(segs[j][0]); found = j; break; }
}
if (found < 0) break;
used[found] = 1;
}
if (loop.length >= 3) loops.push(loop);
}
return loops;
}
function smoothLoop(path: Path2D, loop: [number, number][]): void {
const n = loop.length;
path.moveTo((loop[0][0] + loop[1][0]) / 2, (loop[0][1] + loop[1][1]) / 2);
for (let i = 1; i <= n; i++) {
const cur = loop[i % n];
const nxt = loop[(i + 1) % n];
path.quadraticCurveTo(cur[0], cur[1], (cur[0] + nxt[0]) / 2, (cur[1] + nxt[1]) / 2);
}
path.closePath();
}
function loopCentroid(loop: [number, number][]): [number, number] {
let sx = 0, sy = 0;
for (let i = 0; i < loop.length; i++) { sx += loop[i][0]; sy += loop[i][1]; }
return [sx / loop.length, sy / loop.length];
}
function nearestBall(cx: number, cy: number, t: number): number {
const { w, h } = sizeRef.current;
let best = 0, bd = Infinity;
const balls = ballsRef.current;
for (let i = 0; i < balls.length; i++) {
const p = ballPos(balls[i], t, w, h);
const d = (p[0] - cx) * (p[0] - cx) + (p[1] - cy) * (p[1] - cy);
if (d < bd) { bd = d; best = i; }
}
return best;
}
function draw(t: number): void {
const ctx = ctxRef.current;
const trailCtx = trailCtxRef.current;
const trailCv = trailCvRef.current;
const col = colRef.current;
const { w: W, h: H } = sizeRef.current;
if (!ctx || !col) return;
sampleGrid(t);
const loops = chainSegments(collectSegments(t));
const ballCols = [col.body, col.glow, col.sky];
if (trailCtx) {
trailCtx.globalCompositeOperation = "source-over";
trailCtx.fillStyle = `rgba(23,20,26,${(1 - trailRef.current) * 1.6})`;
trailCtx.fillRect(0, 0, W, H);
trailCtx.globalCompositeOperation = "lighter";
const balls = ballsRef.current;
for (let i = 0; i < balls.length; i++) {
const p = ballPos(balls[i], t, W, H);
const g = trailCtx.createRadialGradient(p[0], p[1], 0, p[0], p[1], balls[i].rad * 1.5);
const c = ballCols[i % ballCols.length];
g.addColorStop(0, hexA(c, 0.2));
g.addColorStop(1, hexA(c, 0));
trailCtx.fillStyle = g;
trailCtx.fillRect(0, 0, W, H);
}
trailCtx.globalCompositeOperation = "source-over";
}
ctx.fillStyle = "#17141a";
ctx.fillRect(0, 0, W, H);
if (trailCv) ctx.drawImage(trailCv, 0, 0);
const mist = ctx.createRadialGradient(
W * (0.5 + Math.sin(t * 0.14 + 2) * 0.3), H * (0.5 + Math.cos(t * 0.12) * 0.3), 0,
W * 0.5, H * 0.5, Math.max(W, H) * 0.78,
);
mist.addColorStop(0, "rgba(110,200,255,0.08)");
mist.addColorStop(1, "rgba(23,20,26,0)");
ctx.fillStyle = mist;
ctx.fillRect(0, 0, W, H);
for (let li = 0; li < loops.length; li++) {
const path = new Path2D();
smoothLoop(path, loops[li]);
const c = loopCentroid(loops[li]);
const bi = nearestBall(c[0], c[1], t);
ctx.fillStyle = ballCols[bi % ballCols.length];
ctx.globalAlpha = 0.92;
ctx.fill(path);
ctx.globalAlpha = 1;
ctx.strokeStyle = col.lilac;
ctx.lineWidth = 1.6;
ctx.lineJoin = "round";
ctx.stroke(path);
}
const balls = ballsRef.current;
for (let i = 0; i < balls.length; i++) {
const p = ballPos(balls[i], t, W, H);
ctx.fillStyle = col.cream;
ctx.beginPath();
ctx.arc(p[0], p[1], 2.6, 0, Math.PI * 2);
ctx.fill();
}
}
function stillFrame(): void { draw(0.6); }
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
draw(t);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
ballNRef.current = nBall;
thrRef.current = threshold;
cellRef.current = cell;
trailRef.current = trail;
const cs = getComputedStyle(root);
const tok = (name: string) => cs.getPropertyValue(name).trim();
colRef.current = {
body: tok("--gm-body"),
glow: tok("--gm-glow"),
sky: tok("--gm-sky"),
lilac: tok("--gm-lilac"),
cream: tok("--gm-cream"),
};
buildBalls();
fit();
setN(String(nBall));
function onResize(): void {
fit();
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
}, [nBall, threshold, cell, trail]);
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>
<canvas ref={trailCvRef} hidden></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{n}</b>볼 · 끈적 병합 경로</span>
<span className={styles.metaDim}>canvas · Path2D · setAttribute</span>
</div>
</div>
</main>
);
}03 グーイ融合 — SVGフィルタなしで本物のパスが滲む — 計測 累積面積 43.647% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · キャンバス メタボール アニメーション9種 — マーチングスクエアで融合する液体の塊
このデモが属する記事
キャンバス メタボール アニメーション9種 — マーチングスクエアで融合する液体の塊メタボール アニメーションは球の輪郭を滑らかにモーフィングさせる仕事ではなく、格子点ごとのスカラー場を毎フレーム計算する仕事です。ライブラリ·WebGL なし — 閾値を跨ぐ格子をつなぐマーチングスクエアひとつでブロブが液体のように融合と分 この記事のデモをすべて見る


