01One field layer — counting threshold-crossing pixels as a first metaball
Sum a scalar field of inverse-distance falloff from each ball center per pixel and fill only cells over the threshold — a bridge seeps between two balls though no circle was ever drawn.
Best forA single liquid mass for a hero background
getImageDataputImageDatarequestAnimationFrame

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<span>Add each ball's distance falloff (radius² ÷ distance²) into a grid to build a scalar field, then fill only the cells above threshold — a bridge grows between two balls as they draw close</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="Field-Threshold Layer 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">5</b>balls · falloff field threshold</span>
<span class="cd__meta cd__meta--dim">canvas · getImageData · putImageData</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 필드 한 겹(01). @keyframes 를 아예 두지 않는다 — 움직임은 전부 캔버스 rAF 루프
// (스프라이트 굽기 putImageData → 필드 합산 getImageData → 임계값 칸 채움)가 만든다.
// 무대는 잉크. 파스텔 팔레트를 --ft-* 로 편다(편 공통 규격).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-ink);
// ── 파스텔 팔레트(BD26: vivid hex 잔존 0 — 전부 토큰·뮤트 파생) ──
$ft-mint: $subject-mint; // 임계값 직후 밴드
$ft-sky: $subject-sky; // 깊은 밴드
$ft-lilac: $subject-lilac; // 더 깊은 밴드
$ft-pink: $subject-pink; // 피크 밴드
$ft-cream: $subject-cream; // 코어 하이라이트·글자
$ft-line: #3a3542; // 어두운 무대 위 패널 경계(뮤트)
$ft-text: #e8e4ef; // 잉크 위 본문(밝은 라벤더 그레이·뮤트)
.cd {
--ft-mint: #{$ft-mint};
--ft-sky: #{$ft-sky};
--ft-lilac: #{$ft-lilac};
--ft-pink: #{$ft-pink};
--ft-cream: #{$ft-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: $ft-text;
}
.cd__field {
position: relative; height: 188px;
border-radius: $r-card; overflow: hidden;
background: $stage-ink;
box-shadow: inset 0 0 0 1px $ft-line;
}
.cd__field:focus-visible { outline: 2px solid $ft-mint; 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($ft-text, .72); }
.cd__val { color: $ft-mint; 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 {
--ft-mint: #4cd4a6;
--ft-sky: #6ec8ff;
--ft-lilac: #b9a0ff;
--ft-pink: #ff9ebb;
--ft-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;
}
}// 필드 한 겹(01). 오프스크린 캔버스에 ImageData 를 직접 채워(putImageData) 부드러운 radial 스프라이트를
// 한 장 굽고, 그 스프라이트의 밝기를 getImageData 로 읽어 "거리 → 감쇠" 룩업 테이블로 쓴다.
// 매 프레임 볼마다 격자 칸에 그 감쇠값(반지름² / 거리² 꼴, 스프라이트 그대로 근사)을 더해
// 스칼라 필드를 합산하고, 합이 임계값을 넘는 칸만 색을 채운다 — 마칭 스퀘어 경계 추출 없이
// 칸 단위(복셀식) 채움이라 경계가 계단 모양이다(02 가 이 경계를 매끈하게 뽑는다).
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var N_BALL = 5; // balls 개수 — 3 은 성긴 장면, 7 은 붐비는 장면
var THRESH = 0.92; // 필드 임계값 — 낮추면 다리가 더 굵고 자주 이어진다
var CELL = 6; // 격자 셀 픽셀 크기 — 작을수록 매끈하지만 계산량 증가
// ────────────────────────────────────────────────────
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 COL = [tok('--ft-mint'), tok('--ft-sky'), tok('--ft-lilac'), tok('--ft-pink')];
var C_CREAM = tok('--ft-cream');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, COLS = 0, ROWS = 0;
var raf = 0, t0 = -1;
// 스프라이트 캔버스 — putImageData 로 부드러운 radial 감쇠를 픽셀 단위로 직접 굽는다
var SPR_R = 64;
var sprCv = document.createElement('canvas');
sprCv.width = SPR_R * 2; sprCv.height = SPR_R * 2;
var sprCtx = sprCv.getContext('2d');
var lut = new Float32Array(SPR_R + 1); // 거리 → 감쇠(0..1) 룩업
function bakeSprite() {
var img = sprCtx.createImageData(SPR_R * 2, SPR_R * 2);
var d = img.data;
for (var y = 0; y < SPR_R * 2; y++) {
for (var x = 0; x < SPR_R * 2; x++) {
var dx = x - SPR_R, dy = y - SPR_R;
var dist = Math.max(1, Math.sqrt(dx * dx + dy * dy));
// r² / d² 감쇠(메타볼 표준식) — 스프라이트 반경에서 정확히 1(=임계값 경계)
var v = Math.min(1, (SPR_R * SPR_R) / (dist * dist));
var idx = (y * SPR_R * 2 + x) * 4;
var a = Math.round(v * 255);
d[idx] = 255; d[idx + 1] = 255; d[idx + 2] = 255; d[idx + 3] = a;
}
}
sprCtx.putImageData(img, 0, 0);
// 룩업 테이블을 구운 스프라이트에서 getImageData 로 다시 읽는다 — 실사용값과 일치
var check = sprCtx.getImageData(SPR_R, 0, SPR_R, 1).data;
for (var i = 0; i < SPR_R; i++) lut[i] = check[i * 4 + 3] / 255;
}
// 시드 고정 PRNG — 미리보기가 항상 같은 balls 궤도로 흐른다
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(0x317A01);
var balls = [];
function buildBalls() {
balls = [];
for (var i = 0; i < N_BALL; i++) {
balls.push({
fx: 0.32 + rand() * 0.5, fy: 0.26 + rand() * 0.46,
ph: rand() * Math.PI * 2, ax: 0.3 + rand() * 0.22, ay: 0.28 + rand() * 0.22,
cx: 0.5, cy: 0.5, rad: 52 + rand() * 26
});
}
}
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(24, Math.round(W / CELL));
ROWS = Math.max(16, Math.round(H / CELL));
}
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, closest = -1, closestD = 1e9;
for (var i = 0; i < balls.length; i++) {
var p = ballPos(balls[i], t);
var dx = px - p[0], dy = py - p[1];
var dist = Math.sqrt(dx * dx + dy * dy);
// 감쇠 = 룩업 테이블(putImageData 로 구운 스프라이트에서 읽은 값)을 balls 반지름으로 스케일
// 스프라이트 반경(64)에서 감쇠 1이므로 scaled = dist * 64 / rad 이 거리에서 정확히 경계
var scaled = dist * (SPR_R / balls[i].rad);
var v = scaled >= SPR_R ? 0 : lut[Math.min(SPR_R - 1, Math.round(scaled))];
sum += v;
if (dist < closestD) { closestD = dist; closest = i; }
}
return [sum, closest];
}
function draw(t) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#17141a';
ctx.fillRect(0, 0, W, H);
// 배경 잔광 드리프트 — 느린 안개(누적면적 확보, 077 기준 "화면이 살아 있음")
var mist = ctx.createRadialGradient(
W * (0.5 + Math.sin(t * 0.13) * 0.3), H * (0.5 + Math.cos(t * 0.11) * 0.3), 0,
W * 0.5, H * 0.5, Math.max(W, H) * 0.75
);
mist.addColorStop(0, 'rgba(185,160,255,0.22)');
mist.addColorStop(0.55, 'rgba(110,200,255,0.10)');
mist.addColorStop(1, 'rgba(23,20,26,0)');
ctx.fillStyle = mist;
ctx.fillRect(0, 0, W, H);
var cw = W / COLS, ch = H / ROWS;
for (var gy = 0; gy < ROWS; gy++) {
var py = (gy + 0.5) * ch;
for (var gx = 0; gx < COLS; gx++) {
var px = (gx + 0.5) * cw;
var fc = fieldAt(px, py, t);
if (fc[0] >= THRESH) {
var over = Math.min(1, (fc[0] - THRESH) / THRESH);
ctx.fillStyle = COL[fc[1] % COL.length];
ctx.globalAlpha = 0.55 + over * 0.45;
ctx.fillRect(gx * cw - 0.5, gy * ch - 0.5, cw + 1, ch + 1);
}
}
}
ctx.globalAlpha = 1;
// balls 코어 — 크림 하이라이트 점(중심을 눈으로 짚을 수 있게)
for (var i = 0; i < balls.length; i++) {
var p = ballPos(balls[i], t);
ctx.fillStyle = C_CREAM;
ctx.globalAlpha = 0.85;
ctx.beginPath(); ctx.arc(p[0], p[1], 2.4, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
}
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();
});
bakeSprite();
fit();
buildBalls();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// FieldThreshold — vanilla 판(run/317/demo/01-metaball-field-threshold/index.html) 의 React 포트.
// 필드 한 겹 — 오프스크린 캔버스에 putImageData 로 부드러운 radial 감쇠 스프라이트를 굽고
// getImageData 로 읽어 룩업 테이블로 쓴다. 볼마다 r²/d² 감쇠를 격자 칸에 합산하고
// 임계값을 넘는 칸만 채운다 — 두 볼이 가까워지면 다리가 돋는다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FieldThreshold.module.scss";
export type FieldThresholdProps = {
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 SPR_R = 64;
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;
};
}
export default function FieldThreshold({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#4cd4a6",
balls: nBall = 5,
threshold = 0.92,
cell = 6,
}: FieldThresholdProps) {
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 lutRef = useRef(new Float32Array(SPR_R)) as Box<Float32Array>;
const colRef = useRef<{ bands: 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 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;
}
// 스프라이트 굽기 — putImageData 로 r²/d² 감쇠를 픽셀 단위로 쓰고 getImageData 로 룩업을 읽는다
function bakeSprite(): void {
const c = document.createElement("canvas");
c.width = SPR_R * 2;
c.height = SPR_R * 2;
const sc = c.getContext("2d");
if (!sc) return;
const img = sc.createImageData(SPR_R * 2, SPR_R * 2);
const d = img.data;
for (let y = 0; y < SPR_R * 2; y++) {
for (let x = 0; x < SPR_R * 2; x++) {
const dx = x - SPR_R;
const dy = y - SPR_R;
const dist = Math.max(1, Math.sqrt(dx * dx + dy * dy));
const v = Math.min(1, (SPR_R * SPR_R) / (dist * dist));
const idx = (y * SPR_R * 2 + x) * 4;
d[idx] = 255; d[idx + 1] = 255; d[idx + 2] = 255;
d[idx + 3] = Math.round(v * 255);
}
}
sc.putImageData(img, 0, 0);
const check = sc.getImageData(SPR_R, 0, SPR_R, 1).data;
for (let i = 0; i < SPR_R; i++) lutRef.current[i] = check[i * 4 + 3] / 255;
}
function buildBalls(): void {
const rand = makePRNG(0x317a01);
const arr: Ball[] = [];
for (let i = 0; i < ballNRef.current; i++) {
arr.push({
fx: 0.32 + rand() * 0.5, fy: 0.26 + rand() * 0.46,
ph: rand() * Math.PI * 2, ax: 0.3 + rand() * 0.22, ay: 0.28 + rand() * 0.22,
cx: 0.5, cy: 0.5, rad: 52 + rand() * 26,
});
}
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(24, Math.round(r.width / cellRef.current));
rowsRef.current = Math.max(16, Math.round(r.height / cellRef.current));
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, number] {
const { w, h } = sizeRef.current;
let sum = 0;
let closest = 0;
let closestD = Infinity;
const balls = ballsRef.current;
const lut = lutRef.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];
const dist = Math.sqrt(dx * dx + dy * dy);
const scaled = dist * (SPR_R / balls[i].rad);
const v = scaled >= SPR_R ? 0 : lut[Math.min(SPR_R - 1, Math.round(scaled))];
sum += v;
if (dist < closestD) { closestD = dist; closest = i; }
}
return [sum, closest];
}
function draw(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const { w: W, h: H } = sizeRef.current;
if (!ctx || !col) return;
ctx.fillStyle = "#17141a";
ctx.fillRect(0, 0, W, H);
const mist = ctx.createRadialGradient(
W * (0.5 + Math.sin(t * 0.13) * 0.3), H * (0.5 + Math.cos(t * 0.11) * 0.3), 0,
W * 0.5, H * 0.5, Math.max(W, H) * 0.75,
);
mist.addColorStop(0, "rgba(185,160,255,0.22)");
mist.addColorStop(0.55, "rgba(110,200,255,0.10)");
mist.addColorStop(1, "rgba(23,20,26,0)");
ctx.fillStyle = mist;
ctx.fillRect(0, 0, W, H);
const cw = W / colsRef.current;
const ch = H / rowsRef.current;
const thr = thrRef.current;
for (let gy = 0; gy < rowsRef.current; gy++) {
const py = (gy + 0.5) * ch;
for (let gx = 0; gx < colsRef.current; gx++) {
const px = (gx + 0.5) * cw;
const fc = fieldAt(px, py, t);
if (fc[0] >= thr) {
const over = Math.min(1, (fc[0] - thr) / thr);
ctx.fillStyle = col.bands[fc[1] % col.bands.length];
ctx.globalAlpha = 0.55 + over * 0.45;
ctx.fillRect(gx * cw - 0.5, gy * ch - 0.5, cw + 1, ch + 1);
}
}
}
ctx.globalAlpha = 1;
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.globalAlpha = 0.85;
ctx.beginPath();
ctx.arc(p[0], p[1], 2.4, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
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 = {
bands: [tok("--ft-mint"), tok("--ft-sky"), tok("--ft-lilac"), tok("--ft-pink")],
cream: tok("--ft-cream"),
};
bakeSprite();
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>볼마다 거리 감쇠(반지름² ÷ 거리²)를 격자 칸에 더해 스칼라 필드를 만들고 임계값을 넘는 칸만 채우면, 두 볼이 가까워질 때 사이에 다리가 돋아납니다</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>
);
}01 One field layer — counting threshold-crossing pixels as a first metaball — Measured area 33.374% · 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 UI03 Cloud field — drifting fbm cloud layer9 Perlin Noise Animations, No Libraries, Pure Math
Creative UI01 One sphere — the first 3D a minimal marcher makes9 Ray Marching Scenes in Canvas (No WebGL)
Creative UI01 The basic hex cluster — one layer of DLA, random-walk particles sticking where they touchSnowflake Generator, 9 Ways (DLA, No Library)