02마칭 스퀘어 — 16경우 테이블로 경계선을 잇는 윤곽
격자 네 꼭짓점의 임계값 초과 여부를 4비트 경우 수로 묶고 선형 보간으로 경계 점을 찾아 곡선으로 이으면, 계단진 픽셀 가장자리가 매끈한 닫힌 윤곽이 됩니다.
쓰임로고·캐릭터의 액체 실루엣
Path2DquadraticCurveTo

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<span>네 모서리의 임계 초과 비트로 16가지 경우를 고르고 경계 점을 선형 보간으로 찾으면 매끈한 닫힌 윤곽이 됩니다 — 볼이 다니며 합체와 분리를 반복합니다</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">5</b>볼 · 마칭 스퀘어 윤곽</span>
<span class="cd__meta cd__meta--dim">canvas · getImageData · Path2D · quadraticCurveTo</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 마칭 스퀘어(02). @keyframes 없음 — 움직임은 rAF(스칼라 필드 + 4비트 16경우 테이블
// + 선형 보간 경계 + quadraticCurveTo 평활)가 만든다.
// 무대는 옐로, 피사체는 블루 계열. 파스텔을 --ms-* 로 편다(편 공통 규격).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-yellow);
// ── 파스텔 팔레트(BD26 — vivid 잔존 0) ──
$ms-fill: $subject-blue; // 볼 몸통 채움
$ms-deep: $subject-blue-deep; // 몸통보다 한 단 깊은 채움(층감)
$ms-edge: $stage-ink; // 윤곽 잉크
$ms-dot: $stage-ink; // 보간점 표식
$ms-cream: $subject-cream; // 크림 하이라이트
$ms-text: $stage-ink; // 옐로 무대 본문 잉크
.cd {
--ms-fill: #{$ms-fill};
--ms-deep: #{$ms-deep};
--ms-edge: #{$ms-edge};
--ms-cream: #{$ms-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;
// en·ja 번역문이 ko 보다 길어 480×300 에서 3줄로 넘친다(BD14) — 폭·레이아웃은 그대로,
// 글자만 살짝 줄여 3줄이 돼도 예산 안에 들어오게 한다 (2026-09-15)
font-weight: 700; font-size: clamp(9px, 2.5vw, 11px); line-height: 1.22;
color: $ms-text;
}
.cd__field {
position: relative; height: 188px;
border-radius: $r-card; overflow: hidden;
background: $stage-yellow;
box-shadow: inset 0 0 0 1px rgba($ms-text, .2);
}
.cd__field:focus-visible { outline: 2px solid $ms-fill; 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($ms-text, .74); }
.cd__val { color: $ms-deep; 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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--ms-fill: #2f6df6;
--ms-deep: #0a4fe8;
--ms-edge: #17141a;
--ms-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(9px, 2.5vw, 11px);
line-height: 1.22;
color: #17141a;
}
.cd__field {
position: relative;
height: 188px;
border-radius: 16px;
overflow: hidden;
background: #ffd23f;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.2);
}
.cd__field:focus-visible {
outline: 2px solid #2f6df6;
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(23, 20, 26, 0.74);
}
.cd__val {
color: #0a4fe8;
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;
}
}// 마칭 스퀘어(02). 스칼라 필드(볼마다 r²/d² 감쇠 합)를 오프스크린 저해상 캔버스에 굽는다 —
// 격자 모서리당 1px 크기로, 볼마다 감쇠 radialGradient 스프라이트를 lighter 합성으로 겹한다.
// getImageData 로 그 픽셀을 읽어 Float32Array 격자에 되돌리는 게 필드 샘플링이다(07 의 패턴).
// 각 칸의 네 모서리 코너값이 임계값을 넘는지에 따라 4비트 인덱스(16경우)를 만들고,
// 경우 테이블이 어떤 변을 경계가 가로지르는지 알려주면 그 위치를 변 양 끝 코너값의
// 선형 보간으로 정한다. 이 점들을 짝지어 폴리라인을 만들고 quadraticCurveTo 로 평활한
// 닫힌 윤곽을 Path2D 에 담아 fill+stroke — 3~5개 볼이 가까워지면 다리로 합체, 멀어지면 분리된다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var N_BALL = 5; // 볼 개수 — 합체/분리가 잦은 밀도
var THRESH = 1.0; // 필드 임계값
var CELL = 8; // 격자 셀 픽셀 크기 — 보간 밀도
// ────────────────────────────────────────────────────
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);
var css = getComputedStyle(root);
function tok(name) { return css.getPropertyValue(name).trim(); }
var C_FILL = tok('--ms-fill');
var C_DEEP = tok('--ms-deep');
var C_EDGE = tok('--ms-edge');
var C_CREAM = tok('--ms-cream');
var C_BG = '#ffd23f';
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, COLS = 0, ROWS = 0;
var grid = null;
var raf = 0, t0 = -1;
// 오프스크린 필드 캔버스 — 격자 모서리당 1px. 여기에 감쇠를 굽고 getImageData 로 읽는다
var fieldCv = document.createElement('canvas');
var fieldCtx = fieldCv.getContext('2d', { willReadFrequently: true });
var LOWW = 0, LOWH = 0;
// 시드 고정 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(0x317A02);
var balls = [];
function buildBalls() {
balls = [];
for (var i = 0; i < N_BALL; i++) {
balls.push({
fx: 0.3 + rand() * 0.5, fy: 0.24 + rand() * 0.44,
ph: rand() * Math.PI * 2, ax: 0.3 + rand() * 0.24, ay: 0.28 + rand() * 0.24,
cx: 0.5, cy: 0.5, rad: 42 + rand() * 22
});
}
}
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
COLS = Math.max(16, Math.round(W / CELL));
ROWS = Math.max(12, Math.round(H / CELL));
LOWW = COLS + 1; LOWH = ROWS + 1;
fieldCv.width = LOWW; fieldCv.height = LOWH;
grid = new Float32Array(LOWW * LOWH);
}
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
];
}
// ── 필드 굽기 + 픽셀 판독 ──
// 볼마다 r²/d² 감쇠를 회색조 radialGradient 로 오프스크린 캔버스(격자 모서리당 1px)에 그리고
// lighter 합성으로 겹한다 — 겹치는 볼의 감쇠가 픽셀 값으로 그대로 더해진다(메타볼 합산).
// 스프라이트 반경 안에서 gradient offset fr 마다 값 v=1/(fr·K)² 을 SCALE 배로 픽셀에 쓰고,
// getImageData 로 읽은 뒤 SCALE 로 나누면 이전 fieldAt 판(Σ r²·0.5/d²)과 같은 척도로 돌아온다.
// 임계값(1.0)이 새지 않도록 SCALE 을 넉넉히 잡아 포화(255) 가 임계값 부근에서 일어나지 않게 한다.
// 임계값(1.0) 부근의 양자화를 줄이려 SCALE 을 넉넉히 잡는다 — v=1(임계)이 픽셀 100 에 대응.
// 255 클램프는 블롭 깊은 내부(v≥2.55, 어차피 case 15)에서만 일어나 윤곽에 영향 없다.
var K = 8, SCALE = 100, STOPS = 32;
var rad2Cache = [];
function b_rad2(i) {
if (!rad2Cache[i]) rad2Cache[i] = balls[i].rad * balls[i].rad * 0.5;
return rad2Cache[i];
}
function drawField(t) {
var cw = W / COLS, ch = H / ROWS;
fieldCtx.globalCompositeOperation = 'source-over';
fieldCtx.fillStyle = '#000';
fieldCtx.fillRect(0, 0, LOWW, LOWH);
fieldCtx.globalCompositeOperation = 'lighter';
for (var i = 0; i < balls.length; i++) {
var p = ballPos(balls[i], t);
var cx = p[0] / cw, cy = p[1] / ch;
var R = Math.sqrt(b_rad2(i)) * K / cw;
var g = fieldCtx.createRadialGradient(cx, cy, 0, cx, cy, R);
for (var s = 0; s < STOPS; s++) {
var off = s / STOPS;
var fr = Math.max(off, 1 / (STOPS * 4));
var v = 1 / (fr * fr * K * K);
var px = Math.min(255, Math.round(v * SCALE));
g.addColorStop(off, 'rgb(' + px + ',' + px + ',' + px + ')');
}
g.addColorStop(1, 'rgb(0,0,0)'); // 꼬리 절단 — 반경 밖은 0(gradient 마지막 정지점이 밖으로 펴지는 걸 끊는다)
fieldCtx.fillStyle = g;
fieldCtx.fillRect(0, 0, LOWW, LOWH);
}
fieldCtx.globalCompositeOperation = 'source-over';
}
function classify() {
var img = fieldCtx.getImageData(0, 0, LOWW, LOWH);
var d = img.data;
for (var gy = 0; gy < LOWH; gy++) {
for (var gx = 0; gx < LOWW; gx++) {
grid[gy * LOWW + gx] = d[(gy * LOWW + gx) * 4] / SCALE;
}
}
}
// ambiguous(5·10) 칸의 중심값만 실계산으로 보강 — getImageData 픽셀과 같은 r²/d² 합
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;
sum += (b_rad2(i)) / d2;
}
return sum;
}
function sampleGrid(t) {
drawField(t);
classify();
}
// 16경우 마칭 스퀘어 — 칸의 코너 비트(TL=1 TR=2 BR=4 BL=8, 임계 초과=1)에 따라
// 경계가 지나는 변 쌍을 돌려준다: top·right·bottom·left 변 코드
// ambiguous 5·10 는 중심값으로 판정해 두 선으로 나눈다.
var EDGES = { T: 0, R: 1, B: 2, L: 3 };
function caseSegments(cs) {
switch (cs) {
case 0: return [];
case 15: return [];
case 1: return [[EDGES.T, EDGES.L]];
case 14: return [[EDGES.T, EDGES.L]];
case 2: return [[EDGES.T, EDGES.R]];
case 13: return [[EDGES.T, EDGES.R]];
case 3: return [[EDGES.L, EDGES.R]];
case 12: return [[EDGES.L, EDGES.R]];
case 4: return [[EDGES.R, EDGES.B]];
case 11: return [[EDGES.R, EDGES.B]];
case 6: return [[EDGES.T, EDGES.B]];
case 9: return [[EDGES.T, EDGES.B]];
case 7: return [[EDGES.L, EDGES.B]];
case 8: return [[EDGES.L, EDGES.B]];
case 5: return 'AMB-x'; // 중심 낮음: TL 블롭과 BR 블롭 분리
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 = []; // [x, y, nx, ny] 시작점·끝점
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') {
// ambiguous — 칸 중심값으로 판정
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++) {
var p1 = lerpPt(gx, gy, a[k][0], cw, ch);
var p2 = lerpPt(gx, gy, a[k][1], cw, ch);
pts.push([p1, p2]);
}
} else {
for (var s = 0; s < segs.length; s++) {
var q1 = lerpPt(gx, gy, segs[s][0], cw, ch);
var q2 = lerpPt(gx, gy, segs[s][1], cw, ch);
pts.push([q1, q2]);
}
}
}
}
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;
}
// 고리를 quadraticCurveTo 로 평활 — 중점 규칙(각 점을 제어점, 중점을 앵커)
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 draw(t) {
sampleGrid(t);
var segs = collectSegments(t);
var loops = chainSegments(segs);
ctx.fillStyle = C_BG;
ctx.fillRect(0, 0, W, H);
// 옐로 무대 배경 변화 — 느린 크림 안개 드리프트(누적면적)
var mist = ctx.createRadialGradient(
W * (0.5 + Math.sin(t * 0.12) * 0.32), H * (0.5 + Math.cos(t * 0.1) * 0.3), 0,
W * 0.5, H * 0.5, Math.max(W, H) * 0.8
);
mist.addColorStop(0, 'rgba(255,247,230,0.5)');
mist.addColorStop(1, 'rgba(255,247,230,0)');
ctx.fillStyle = mist;
ctx.fillRect(0, 0, W, H);
// 임계값 내부 미리보기 채움(격자 픽셀) — 윤곽이 무엇을 감싸는지 보여주는 바탕
var cw = W / COLS, ch = H / ROWS;
ctx.fillStyle = C_DEEP;
ctx.globalAlpha = 0.16;
for (var gy = 0; gy < ROWS; gy++) {
for (var gx = 0; gx < COLS; gx++) {
if (grid[gy * (COLS + 1) + gx] > THRESH) ctx.fillRect(gx * cw, gy * ch, cw, ch);
}
}
ctx.globalAlpha = 1;
// 닫힌 윤곽 — Path2D 에 평활 고리를 담아 fill + stroke
var path = new Path2D();
for (var li = 0; li < loops.length; li++) smoothLoop(path, loops[li]);
ctx.fillStyle = C_FILL;
ctx.globalAlpha = 0.9;
ctx.fill(path);
ctx.globalAlpha = 1;
ctx.strokeStyle = C_EDGE;
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.stroke(path);
// 볼 중심 크림 점 — 코어 하이라이트
for (var i = 0; i < balls.length; i++) {
var p = ballPos(balls[i], t);
ctx.fillStyle = C_CREAM;
ctx.beginPath(); ctx.arc(p[0], p[1], 2.6, 0, Math.PI * 2); ctx.fill();
}
}
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);
}
})();// MarchingSquares — vanilla 판(run/317/demo/02-metaball-marching-squares/index.html) 의 React 포트.
// 마칭 스퀘어 — 스칼라 필드(볼마다 r²/d² 감쇠 합)를 오프스크린 저해상 캔버스(격자 모서리당 1px)에
// radialGradient 스프라이트 + lighter 합성으로 굽고 getImageData 로 읽어 격자에 되돌린다(07 의 패턴).
// 각 칸 네 모서리의 임계 초과 비트로 16가지 경우를 골라 경계가 지나는 변 위 점을 선형 보간으로 찾는다.
// 세그먼트를 닫힌 고리로 엮어 quadraticCurveTo 로 평활한 뒤 Path2D 에 fill+stroke — 볼이 합체/분리한다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MarchingSquares.module.scss";
export type MarchingSquaresProps = {
duration?: string;
easing?: string;
color?: string;
balls?: number; // 볼 개수
threshold?: number; // 필드 임계값
cell?: 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;
const K = 8, SCALE = 100, STOPS = 32; // 필드 스프라이트 — 감쇠 절단 계수·판독 척도·gradient 정지점 수
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;
};
}
// 16경우 테이블 — 코너 비트(TL=1 TR=2 BR=4 BL=8). 5·10 은 ambiguous(문자열 표식)
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 [];
}
export default function MarchingSquares({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
balls: nBall = 5,
threshold = 1.0,
cell = 8,
}: MarchingSquaresProps) {
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 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<{ fill: string; deep: string; edge: string; cream: string; bg: 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 ballsRef = useRef<Ball[]>([]) as Box<Ball[]>;
const lowRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const fieldCvRef = useRef<HTMLCanvasElement | null>(null);
const fieldCtxRef = useRef<CanvasRenderingContext2D | null>(null);
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(0x317a02);
const arr: Ball[] = [];
for (let i = 0; i < ballNRef.current; i++) {
arr.push({
fx: 0.3 + rand() * 0.5, fy: 0.24 + rand() * 0.44,
ph: rand() * Math.PI * 2, ax: 0.3 + rand() * 0.24, ay: 0.28 + rand() * 0.24,
cx: 0.5, cy: 0.5, rad: 42 + rand() * 22,
});
}
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);
colsRef.current = Math.max(16, Math.round(r.width / cellRef.current));
rowsRef.current = Math.max(12, Math.round(r.height / cellRef.current));
// 오프스크린 필드 캔버스 — 격자 모서리당 1px
let fieldCv = fieldCvRef.current;
if (!fieldCv) {
fieldCv = document.createElement("canvas");
fieldCvRef.current = fieldCv;
fieldCtxRef.current = fieldCv.getContext("2d", { willReadFrequently: true });
}
lowRef.current.w = colsRef.current + 1;
lowRef.current.h = rowsRef.current + 1;
fieldCv.width = lowRef.current.w;
fieldCv.height = lowRef.current.h;
gridRef.current = new Float32Array(lowRef.current.w * lowRef.current.h);
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,
];
}
// ── 필드 굽기 + 픽셀 판독 ──
// 볼마다 r²/d² 감쇠를 회색조 radialGradient 로 오프스크린 캔버스에 그리고 lighter 합성으로
// 겹한다. getImageData 로 읽어 SCALE 로 나누면 이전 fieldAt 판(Σ r²·0.5/d²)과 같은 척도.
function drawField(t: number): void {
const fieldCtx = fieldCtxRef.current;
if (!fieldCtx) return;
const { w: W, h: H } = sizeRef.current;
const cols = colsRef.current;
const rows = rowsRef.current;
const { w: LOWW, h: LOWH } = lowRef.current;
const cw = W / cols;
const ch = H / rows;
fieldCtx.globalCompositeOperation = "source-over";
fieldCtx.fillStyle = "#000";
fieldCtx.fillRect(0, 0, LOWW, LOWH);
fieldCtx.globalCompositeOperation = "lighter";
const balls = ballsRef.current;
for (let i = 0; i < balls.length; i++) {
const p = ballPos(balls[i], t, W, H);
const cx = p[0] / cw;
const cy = p[1] / ch;
const r2 = balls[i].rad * balls[i].rad * 0.5;
const R = (Math.sqrt(r2) * K) / cw;
const g = fieldCtx.createRadialGradient(cx, cy, 0, cx, cy, R);
for (let s = 0; s < STOPS; s++) {
const off = s / STOPS;
const fr = Math.max(off, 1 / (STOPS * 4));
const v = 1 / (fr * fr * K * K);
const px = Math.min(255, Math.round(v * SCALE));
g.addColorStop(off, `rgb(${px},${px},${px})`);
}
g.addColorStop(1, "rgb(0,0,0)"); // 꼬리 절단 — 반경 밖은 0
fieldCtx.fillStyle = g;
fieldCtx.fillRect(0, 0, LOWW, LOWH);
}
fieldCtx.globalCompositeOperation = "source-over";
}
function classify(): void {
const fieldCtx = fieldCtxRef.current;
if (!fieldCtx) return;
const { w: LOWW, h: LOWH } = lowRef.current;
const img = fieldCtx.getImageData(0, 0, LOWW, LOWH);
const d = img.data;
const g = gridRef.current;
for (let gy = 0; gy < LOWH; gy++) {
for (let gx = 0; gx < LOWW; gx++) {
g[gy * LOWW + gx] = d[(gy * LOWW + gx) * 4] / SCALE;
}
}
}
// ambiguous(5·10) 칸의 중심값만 실계산으로 보강 — getImageData 픽셀과 같은 r²/d² 합
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 {
drawField(t);
classify();
}
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 draw(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const { w: W, h: H } = sizeRef.current;
if (!ctx || !col) return;
sampleGrid(t);
const loops = chainSegments(collectSegments(t));
ctx.fillStyle = col.bg;
ctx.fillRect(0, 0, W, H);
const mist = ctx.createRadialGradient(
W * (0.5 + Math.sin(t * 0.12) * 0.32), H * (0.5 + Math.cos(t * 0.1) * 0.3), 0,
W * 0.5, H * 0.5, Math.max(W, H) * 0.8,
);
mist.addColorStop(0, "rgba(255,247,230,0.5)");
mist.addColorStop(1, "rgba(255,247,230,0)");
ctx.fillStyle = mist;
ctx.fillRect(0, 0, W, H);
// 임계값 내부 미리보기 채움(격자 픽셀)
const cw = W / colsRef.current;
const ch = H / rowsRef.current;
const g = gridRef.current;
const stride = colsRef.current + 1;
ctx.fillStyle = col.deep;
ctx.globalAlpha = 0.16;
for (let gy = 0; gy < rowsRef.current; gy++) {
for (let gx = 0; gx < colsRef.current; gx++) {
if (g[gy * stride + gx] > thrRef.current) ctx.fillRect(gx * cw, gy * ch, cw, ch);
}
}
ctx.globalAlpha = 1;
const path = new Path2D();
for (let li = 0; li < loops.length; li++) smoothLoop(path, loops[li]);
ctx.fillStyle = col.fill;
ctx.globalAlpha = 0.9;
ctx.fill(path);
ctx.globalAlpha = 1;
ctx.strokeStyle = col.edge;
ctx.lineWidth = 2;
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;
const cs = getComputedStyle(root);
const tok = (name: string) => cs.getPropertyValue(name).trim();
colRef.current = {
fill: tok("--ms-fill"),
deep: tok("--ms-deep"),
edge: tok("--ms-edge"),
cream: tok("--ms-cream"),
bg: "#ffd23f",
};
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]);
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>네 모서리의 임계 초과 비트로 16가지 경우를 고르고 경계 점을 선형 보간으로 찾으면 매끈한 닫힌 윤곽이 됩니다 — 볼이 다니며 합체와 분리를 반복합니다</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 · Path2D · quadraticCurveTo</span>
</div>
</div>
</main>
);
}02 마칭 스퀘어 — 16경우 테이블로 경계선을 잇는 윤곽 — 측정 누적면적 34.129% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 캔버스 메타볼 효과 9종 — 마칭 스퀘어로 합체하는 액체 덩어리
이 데모가 속한 글
캔버스 메타볼 효과 9종 — 마칭 스퀘어로 합체하는 액체 덩어리메타볼 효과는 여러 덩어리가 가까워지면 하나로 녹아 붙고 멀어지면 갈라지는, 액체처럼 굴러가는 캔버스 장면입니다. 필드 값이 임계값을 넘는 경계를 격자 칸마다 잇는 마칭 스퀘어 하나로 블롭이 스며들듯 합쳐졌다 갈라집 이 글의 데모 전부 보기


