03구이 병합 — SVG 필터 없이 진짜 경로가 스밈다
SVG gooey 필터의 blur+contrast 근사와 달리 마칭 스퀘어로 뽑은 경로를 그대로 stroke·fill 하면 화면 확대에도 윤곽이 깨지지 않고 색 전환도 경로 단위로 제어됩니다.
쓰임버튼 아이콘·내비게이션의 끈적한 병합
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 원작 · zip 에 SCSS·React 포함 · 캔버스 메타볼 효과 9종 — 마칭 스퀘어로 합체하는 액체 덩어리
이 데모가 속한 글
캔버스 메타볼 효과 9종 — 마칭 스퀘어로 합체하는 액체 덩어리메타볼 효과는 여러 덩어리가 가까워지면 하나로 녹아 붙고 멀어지면 갈라지는, 액체처럼 굴러가는 캔버스 장면입니다. 필드 값이 임계값을 넘는 경계를 격자 칸마다 잇는 마칭 스퀘어 하나로 블롭이 스며들듯 합쳐졌다 갈라집 이 글의 데모 전부 보기


