05라바 램프 — 부력과 온도차로 떠오르는 굼벵이 덩어리
각 볼에 목표 고도와 온도 사이클을 주어 가열되면 위로·식으면 아래로 떠오르게 하고 마칭 스퀘어가 그 움직임을 실시간 윤곽으로 잡으면 쉬지 않고 모양이 바뀌는 램프가 됩니다.
쓰임무드 조명·로딩 대기 화면
requestAnimationFramequadraticCurveToPath2D

<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">7</b>개 · 격자 <b class="cd__val">40</b>px</span>
<span class="cd__meta cd__meta--dim">canvas · Path2D · quadraticCurveTo</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 라바 램프(05). @keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(온도 사이클 부력 + 마칭 스퀘어 윤곽)가 만든다.
// 무대는 오렌지 계열(05 = orange). 램프 유리·하부 광원·기포로 장면을 잡는다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-pink;
@include stage($stage-orange);
// ── 편 공통 파스텔 팔레트(317 전 항목 동일 — JS 가 --lamp-* 로 읽는다) ──
$lamp-glass: #f0c9ad; // 램프 유리 상부 — 오렌지 무대 위 밝은 유리
$lamp-deep: #b96a4d; // 유리 깊은 곳 — 아래로 갈수록 짙어지는 호박
$lamp-heat: #ffd9a8; // 하부 광원 — 따뜻한 크림 오렌지
$lamp-blob: #ff9ebb; // 액체 덩어리 본체 — 캔디 핑크
$lamp-blobhi: #ffd9e2; // 뜨거운 볼 하이라이트 — 연핑크
$lamp-glow: #ffe4c4; // 윤곽 글로우·기포 — 크림
$lamp-label: #ffe4c4; // 유리 테두리 — 크림
.cd {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --lamp-* 를 받아 쓴다
--lamp-glass: #{$lamp-glass};
--lamp-deep: #{$lamp-deep};
--lamp-heat: #{$lamp-heat};
--lamp-blob: #{$lamp-blob};
--lamp-blobhi: #{$lamp-blobhi};
--lamp-glow: #{$lamp-glow};
--lamp-label: #{$lamp-label};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 464px;
justify-content: center;
flex: 1;
}
.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: $subject-ink; // 오렌지 무대 위 — 크림·흰색은 3.11:1 미달(대비_금지조합), 잉크는 5.50:1
}
.cd__field {
position: relative; height: 192px;
border-radius: $r-card; overflow: hidden;
background: $lamp-glass;
box-shadow: inset 0 0 0 1px rgba($lamp-glow, .3);
}
.cd__field:focus-visible { outline: 2px solid $lamp-blobhi; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($subject-ink, .88); }
.cd__val { color: $subject-ink; font-weight: 700; } // 오렌지 위 잉크 라벨(5.50:1)
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
// reduce — rAF 없이 시뮬레이션 몇 걸음 뒤의 한 장.
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--lamp-glass: #f0c9ad;
--lamp-deep: #b96a4d;
--lamp-heat: #ffd9a8;
--lamp-blob: #ff9ebb;
--lamp-blobhi: #ffd9e2;
--lamp-glow: #ffe4c4;
--lamp-label: #ffe4c4;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 464px;
justify-content: center;
flex: 1;
}
.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: #17141a;
}
.cd__field {
position: relative;
height: 192px;
border-radius: 16px;
overflow: hidden;
background: #f0c9ad;
box-shadow: inset 0 0 0 1px rgba(255, 228, 196, 0.3);
}
.cd__field:focus-visible {
outline: 2px solid #ffd9e2;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(23, 20, 26, 0.88);
}
.cd__val {
color: #17141a;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 라바 램프(05). 각 볼에 온도 T(0..1) 사이클 — 바닥에서 가열→상승, 정상에서 냉각→하강 — 을 주고
// 부력 가속 = (T - 평균밀도 보정) 으로만 움직인다. 필드 r²/(r²+d²) 를 격자 위로 샘플링해
// 마칭 스퀘어 16경우 테이블로 경계를 추출, quadraticCurveTo 로 매끈한 닫힌 윤곽을 그린다.
// 유리 램프 몸통·하부 광원·기포가 장면을 완성한다. 무대는 오렌지 계열 램프 유리.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var N_BALLS = 9; // 볼 수
var GRID = 24; // 격자 칸(px) — 작게 하면 윤곽이 더 매끈하다
var THR = 1.0; // 임계값 — 필드 합이 이 값을 넘는 경계를 추적한다
// ────────────────────────────────────────────────────
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');
// 시드 고정 PRNG — mulberry32
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(0x317A05);
// 색은 CSS 커스텀 프로퍼티(--lamp-*)에서 getComputedStyle 로 읽는다
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_GLASS = tokRGB('--lamp-glass');
var C_DEEP = tokRGB('--lamp-deep');
var C_HEAT = tokRGB('--lamp-heat');
var C_BLOB = tokRGB('--lamp-blob');
var C_BLOBHI = tokRGB('--lamp-blobhi');
var C_GLOW = tokRGB('--lamp-glow');
var C_LABEL = tokRGB('--lamp-label');
var TAU = Math.PI * 2;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0;
var raf = 0, t0 = -1;
var STILL_T = 1.2;
var balls = [], bubbles = [];
var cols = 0, rows = 0;
var fieldGrid = null;
function build() {
balls = [];
for (var i = 0; i < N_BALLS; i++) {
balls.push({
x: W * 0.5 + (rand() - 0.5) * W * 0.66,
y: H * (0.2 + rand() * 0.65),
r: (i < 4 ? 36 : 21) + rand() * 9,
vx: (rand() - 0.5) * 10,
vy: 0,
T: rand(),
rate: 0.14 + rand() * 0.1,
ph: rand() * TAU
});
}
bubbles = [];
for (var b = 0; b < 14; b++) {
bubbles.push({ x: W * 0.5 + (rand() - 0.5) * W * 0.66, y: rand() * H, r: 1.5 + rand() * 2.5, v: 9 + rand() * 14, ph: rand() * TAU });
}
cols = Math.ceil(W / GRID) + 1;
rows = Math.ceil(H / GRID) + 1;
fieldGrid = new Float32Array(cols * rows);
}
// 필드 값 — 스무스 커널 r²/(r²+d²)
function fieldAt(px, py) {
var s = 0;
for (var i = 0; i < balls.length; i++) {
var b = balls[i];
var dx = px - b.x, dy = py - b.y;
var q = (dx * dx + dy * dy) / (b.r * b.r);
s += 1 / (1 + q);
}
return s;
}
function sampleGrid() {
for (var j = 0; j < rows; j++) {
for (var i = 0; i < cols; i++) {
fieldGrid[j * cols + i] = fieldAt(i * GRID, j * GRID);
}
}
}
// ── 마칭 스퀘어 — 16경우 테이블로 경계 세그먼트를 뽑는다 ──
// 꼭짓점 순서: 좌상(8) 우상(4) 우하(2) 좌하(1). 셀이 경계를 가로지르는 변마다
// 선형 보간 t=(THR-a)/(b-a) 으로 교점을 찾고 경우 수에 따라 잇는다.
function lerpPt(x1, y1, v1, x2, y2, v2) {
var t = (THR - v1) / (v2 - v1 || 1e-6);
t = Math.max(0, Math.min(1, t));
return [x1 + (x2 - x1) * t, y1 + (y2 - y1) * t];
}
function march() {
var segs = [];
for (var j = 0; j < rows - 1; j++) {
for (var i = 0; i < cols - 1; i++) {
var x = i * GRID, y = j * GRID;
var vTL = fieldGrid[j * cols + i];
var vTR = fieldGrid[j * cols + i + 1];
var vBR = fieldGrid[(j + 1) * cols + i + 1];
var vBL = fieldGrid[(j + 1) * cols + i];
var code = (vTL > THR ? 8 : 0) | (vTR > THR ? 4 : 0) | (vBR > THR ? 2 : 0) | (vBL > THR ? 1 : 0);
if (code === 0 || code === 15) continue;
var top = lerpPt(x, y, vTL, x + GRID, y, vTR); // 상변
var right = lerpPt(x + GRID, y, vTR, x + GRID, y + GRID, vBR); // 우변
var bottom = lerpPt(x + GRID, y + GRID, vBR, x, y + GRID, vBL); // 하변
var left = lerpPt(x, y + GRID, vBL, x, y, vTL); // 좌변
switch (code) {
case 1: case 14: segs.push([left, bottom]); break;
case 2: case 13: segs.push([bottom, right]); break;
case 3: case 12: segs.push([left, right]); break;
case 4: case 11: segs.push([top, right]); break;
case 6: case 9: segs.push([top, bottom]); break;
case 7: case 8: segs.push([left, top]); break;
case 5: segs.push([left, top]); segs.push([bottom, right]); break; // 모호 — 두 갈래
case 10: segs.push([top, right]); segs.push([left, bottom]); break;
}
}
}
return segs;
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
function mix(c1, c2, k) {
return [c1[0] + (c2[0] - c1[0]) * k, c1[1] + (c2[1] - c1[1]) * k, c1[2] + (c2[2] - c1[2]) * k];
}
function update(t, dt) {
var floorY = H * 0.84, ceilY = H * 0.16;
for (var i = 0; i < balls.length; i++) {
var b = balls[i];
// 온도 사이클 — 바닥 근처에서 가열, 정상 근처에서 냉각. 위상으로 서로 엇갈리게 한다
var nearBottom = Math.max(0, 1 - (b.y - floorY) / (H * 0.34));
var nearTop = Math.max(0, 1 - (ceilY - b.y) / (H * 0.3));
b.T += (nearBottom * b.rate * 1.5 + 0.02 - nearTop * b.rate * 1.9 - 0.012) * dt * 2.2;
b.T = Math.max(0, Math.min(1, b.T + Math.sin(t * 0.3 + b.ph) * 0.006));
// 부력 — 뜨거울수록(가벼울수록) 위로, 차가우면 아래로. 큰 볼은 느리게
var buoy = (b.T - 0.42) * 62 / (b.r * 0.1);
b.vy -= buoy * dt;
// 수평 배회 — 벽 유리에 부딪히면 돌아온다
b.vx += Math.sin(t * 0.5 + b.ph) * 7 * dt;
b.vx *= 0.995; b.vy *= 0.988;
b.x += b.vx * dt; b.y += b.vy * dt;
var half = b.r * 0.7;
if (b.x < half) { b.x = half; b.vx = Math.abs(b.vx) * 0.5; }
if (b.x > W - half) { b.x = W - half; b.vx = -Math.abs(b.vx) * 0.5; }
if (b.y > floorY) { b.y = floorY; b.vy = -Math.abs(b.vy) * 0.3; }
if (b.y < ceilY) { b.y = ceilY; b.vy = Math.abs(b.vy) * 0.3; }
}
for (var u = 0; u < bubbles.length; u++) {
var bb = bubbles[u];
bb.y -= bb.v * dt;
if (bb.y < -4) { bb.y = H + 4; bb.x = W * 0.5 + (rand() - 0.5) * W * 0.62; }
}
}
function draw(t) {
// 램프 유리 — 세로 그라디언트(하부 광원)
var bg = ctx.createLinearGradient(0, 0, 0, H);
bg.addColorStop(0, rgba(C_GLASS, 1));
bg.addColorStop(0.62, rgba(mix(C_GLASS, C_DEEP, 0.5), 1));
bg.addColorStop(1, rgba(mix(C_DEEP, C_HEAT, 0.35), 1));
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
// 하부 광원 — 램프 베이스의 따뜻한 빛(전체 캔버스에 닿을 만큼 크게, 깜빡임 폭도 크게)
var hg = ctx.createRadialGradient(W * 0.5, H * 1.05, 0, W * 0.5, H * 1.05, H * 1.3);
var flick = 0.6 + 0.4 * Math.sin(t * 1.7);
hg.addColorStop(0, rgba(C_HEAT, 0.6 * flick));
hg.addColorStop(0.5, rgba(C_HEAT, 0.32 * flick));
hg.addColorStop(1, rgba(C_HEAT, 0.06 * flick));
ctx.fillStyle = hg;
ctx.fillRect(0, 0, W, H);
// 열기 아지랑이 — 대류를 표현하는 수평 밴드가 천천히 위로 흐르며 캔버스 전체를 은은히 흔든다
ctx.globalCompositeOperation = 'lighter';
for (var hb = 0; hb < 5; hb++) {
var by = H - ((t * 26 + hb * (H / 5)) % (H + 60)) + 30;
var bw = H * 0.5;
var ha = 0.16 + 0.08 * Math.sin(t * 1.3 + hb);
var hgB = ctx.createRadialGradient(W * 0.5, by, 0, W * 0.5, by, W * 0.62);
hgB.addColorStop(0, rgba(C_GLOW, ha));
hgB.addColorStop(1, rgba(C_GLOW, 0));
ctx.fillStyle = hgB;
ctx.fillRect(0, by - bw * 0.4, W, bw * 0.8);
}
ctx.globalCompositeOperation = 'source-over';
// 기포 — 뒤쪽 배경층
for (var u = 0; u < bubbles.length; u++) {
var bb = bubbles[u];
ctx.beginPath();
ctx.arc(bb.x + Math.sin(t * 1.4 + bb.ph) * 3, bb.y, bb.r, 0, TAU);
ctx.fillStyle = rgba(C_GLOW, 0.24);
ctx.fill();
}
// 마칭 스퀘어 윤곽 — 두 겹(글로우 스트로크 + 본체)
sampleGrid();
var segs = march();
var path = new Path2D();
for (var s = 0; s < segs.length; s++) {
var sg = segs[s];
path.moveTo(sg[0][0], sg[0][1]);
path.quadraticCurveTo(sg[0][0], sg[0][1], (sg[0][0] + sg[1][0]) / 2, (sg[0][1] + sg[1][1]) / 2);
path.lineTo(sg[1][0], sg[1][1]);
}
// 글로우
ctx.strokeStyle = rgba(C_GLOW, 0.34);
ctx.lineWidth = 8;
ctx.lineCap = 'round';
ctx.stroke(path);
// 본체 — 뜨거운 볼이 있던 자리일수록 밝은 색
var hot = 0;
for (var i = 0; i < balls.length; i++) hot = Math.max(hot, balls[i].T);
ctx.strokeStyle = rgba(mix(C_BLOB, C_BLOBHI, hot * 0.8), 0.95);
ctx.lineWidth = 4;
ctx.stroke(path);
// 볼 중심 하이라이트 — 온도가 보인다
for (var i2 = 0; i2 < balls.length; i2++) {
var b = balls[i2];
var rr = b.r * 0.6;
var g2 = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, rr);
g2.addColorStop(0, rgba(C_BLOBHI, 0.18 + 0.24 * b.T));
g2.addColorStop(1, rgba(C_BLOBHI, 0));
ctx.fillStyle = g2;
ctx.fillRect(b.x - rr, b.y - rr, rr * 2, rr * 2);
}
// 유리 테두리
ctx.strokeStyle = rgba(C_LABEL, 0.28);
ctx.lineWidth = 1.4;
ctx.strokeRect(0.7, 0.7, W - 1.4, H - 1.4);
}
function fit() {
var r = field.getBoundingClientRect();
W = Math.max(2, Math.round(r.width));
H = Math.max(2, Math.round(r.height));
cv.width = W; cv.height = H;
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
var dt = Math.min(0.05, (now - (frame.prev || now)) / 1000);
frame.prev = now;
update(t, dt);
draw(t);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit(); build();
if (reduce) draw(STILL_T);
});
fit();
build();
nOut.textContent = String(N_BALLS);
if (reduce) {
// 몇 걸음 미리 시뮬레이션한 뒤 한 장
for (var k = 0; k < 90; k++) update(STILL_T, 1 / 60);
draw(STILL_T);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// LavaLampFloat — vanilla 판(run/317/demo/05-metaball-lava-lamp-float/index.html) 의 React 포트.
// 라바 램프 — 각 볼에 온도 T(0..1) 사이클(바닥에서 가열→상승, 정상에서 냉각→하강)을 주고
// 부력 가속 = (T - 평균밀도 보정) 으로만 움직인다. 필드 r²/(r²+d²) 를 격자 위로 샘플링해
// 마칭 스퀘어 16경우 테이블로 경계를 추출, quadraticCurveTo 로 매끈한 닫힌 윤곽을 그린다.
// 유리 램프 몸통·하부 광원·열기 아지랑이·기포가 장면을 완성한다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LavaLampFloat.module.scss";
export type LavaLampFloatProps = {
duration?: string;
easing?: string;
color?: string;
count?: number; // 볼 수
grid?: number; // 격자 칸(px) — 작게 하면 윤곽이 더 매끈하다
heat?: number; // 부력 세기 배율
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Pt = [number, number];
type Ball = { x: number; y: number; r: number; vx: number; vy: number; T: number; rate: number; ph: number };
type Bubble = { x: number; y: number; r: number; v: number; ph: number };
const FIELD_LABEL = "라바 램프 부력 실험판";
const TAU = Math.PI * 2;
const THR = 1.0;
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 LavaLampFloat({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#ff9ebb",
count = 9,
grid = 24,
heat = 1.0,
}: LavaLampFloatProps) {
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 colRef = useRef<Record<string, RGB> | null>(null);
const ballsRef = useRef<Ball[]>([]) as Box<Ball[]>;
const bubblesRef = useRef<Bubble[]>([]) as Box<Bubble[]>;
const gridRef = useRef({ cols: 0, rows: 0, g: grid }) as Box<{ cols: number; rows: number; g: number }>;
const fieldGridRef = useRef<Float32Array | null>(null);
const countRef = useRef(count) as Box<number>;
const heatRef = useRef(heat) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [label, setLabel] = useState(String(count));
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 build(): void {
const { w: W, h: H } = sizeRef.current;
if (W <= 0) return;
const rand = makePRNG(0x317a05);
const balls: Ball[] = [];
for (let i = 0; i < countRef.current; i++) {
balls.push({
x: W * 0.5 + (rand() - 0.5) * W * 0.66,
y: H * (0.2 + rand() * 0.65),
r: (i < 4 ? 36 : 21) + rand() * 9,
vx: (rand() - 0.5) * 10,
vy: 0,
T: rand(),
rate: 0.14 + rand() * 0.1,
ph: rand() * TAU,
});
}
const bubbles: Bubble[] = [];
for (let b = 0; b < 14; b++) {
bubbles.push({ x: W * 0.5 + (rand() - 0.5) * W * 0.66, y: rand() * H, r: 1.5 + rand() * 2.5, v: 9 + rand() * 14, ph: rand() * TAU });
}
ballsRef.current = balls;
bubblesRef.current = bubbles;
const G = gridRef.current.g;
gridRef.current.cols = Math.ceil(W / G) + 1;
gridRef.current.rows = Math.ceil(H / G) + 1;
fieldGridRef.current = new Float32Array(gridRef.current.cols * gridRef.current.rows);
}
// 필드 값 — 스무스 커널 r²/(r²+d²)
function fieldAt(px: number, py: number): number {
const balls = ballsRef.current;
let s = 0;
for (let i = 0; i < balls.length; i++) {
const b = balls[i];
const dx = px - b.x, dy = py - b.y;
const q = (dx * dx + dy * dy) / (b.r * b.r);
s += 1 / (1 + q);
}
return s;
}
function sampleGrid(): void {
const fg = fieldGridRef.current;
const { cols, rows, g: G } = gridRef.current;
if (!fg) return;
for (let j = 0; j < rows; j++) {
for (let i = 0; i < cols; i++) {
fg[j * cols + i] = fieldAt(i * G, j * G);
}
}
}
// ── 마칭 스퀘어 — 16경우 테이블로 경계 세그먼트를 뽑는다 ──
// 꼭짓점 순서: 좌상(8) 우상(4) 우하(2) 좌하(1). 셀이 경계를 가로지르는 변마다
// 선형 보간 t=(THR-a)/(b-a) 으로 교점을 찾고 경우 수에 따라 잇는다.
function lerpPt(x1: number, y1: number, v1: number, x2: number, y2: number, v2: number): Pt {
let t = (THR - v1) / (v2 - v1 || 1e-6);
t = Math.max(0, Math.min(1, t));
return [x1 + (x2 - x1) * t, y1 + (y2 - y1) * t];
}
function march(): Pt[][] {
const fg = fieldGridRef.current;
const { cols, rows, g: G } = gridRef.current;
if (!fg) return [];
const segs: Pt[][] = [];
for (let j = 0; j < rows - 1; j++) {
for (let i = 0; i < cols - 1; i++) {
const x = i * G, y = j * G;
const vTL = fg[j * cols + i];
const vTR = fg[j * cols + i + 1];
const vBR = fg[(j + 1) * cols + i + 1];
const vBL = fg[(j + 1) * cols + i];
const code = (vTL > THR ? 8 : 0) | (vTR > THR ? 4 : 0) | (vBR > THR ? 2 : 0) | (vBL > THR ? 1 : 0);
if (code === 0 || code === 15) continue;
const top = lerpPt(x, y, vTL, x + G, y, vTR); // 상변
const right = lerpPt(x + G, y, vTR, x + G, y + G, vBR); // 우변
const bottom = lerpPt(x + G, y + G, vBR, x, y + G, vBL); // 하변
const left = lerpPt(x, y + G, vBL, x, y, vTL); // 좌변
switch (code) {
case 1: case 14: segs.push([left, bottom]); break;
case 2: case 13: segs.push([bottom, right]); break;
case 3: case 12: segs.push([left, right]); break;
case 4: case 11: segs.push([top, right]); break;
case 6: case 9: segs.push([top, bottom]); break;
case 7: case 8: segs.push([left, top]); break;
case 5: segs.push([left, top]); segs.push([bottom, right]); break; // 모호 — 두 갈래
case 10: segs.push([top, right]); segs.push([left, bottom]); break;
}
}
}
return segs;
}
function rgba(c: RGB, a: number): string {
return `rgba(${c[0]},${c[1]},${c[2]},${a})`;
}
function mix(c1: RGB, c2: RGB, k: number): RGB {
return [c1[0] + (c2[0] - c1[0]) * k, c1[1] + (c2[1] - c1[1]) * k, c1[2] + (c2[2] - c1[2]) * k];
}
function update(t: number, dt: number): void {
const { w: W, h: H } = sizeRef.current;
const floorY = H * 0.84, ceilY = H * 0.16;
const balls = ballsRef.current;
for (let i = 0; i < balls.length; i++) {
const b = balls[i];
// 온도 사이클 — 바닥 근처에서 가열, 정상 근처에서 냉각. 위상으로 서로 엇갈리게 한다
const nearBottom = Math.max(0, 1 - (b.y - floorY) / (H * 0.34));
const nearTop = Math.max(0, 1 - (ceilY - b.y) / (H * 0.3));
b.T += (nearBottom * b.rate * 1.5 + 0.02 - nearTop * b.rate * 1.9 - 0.012) * dt * 2.2;
b.T = Math.max(0, Math.min(1, b.T + Math.sin(t * 0.3 + b.ph) * 0.006));
// 부력 — 뜨거울수록(가벼울수록) 위로, 차가우면 아래로. 큰 볼은 느리게
const buoy = (b.T - 0.42) * 62 * heatRef.current / (b.r * 0.1);
b.vy -= buoy * dt;
// 수평 배회 — 벽 유리에 부딪히면 돌아온다
b.vx += Math.sin(t * 0.5 + b.ph) * 7 * dt;
b.vx *= 0.995; b.vy *= 0.988;
b.x += b.vx * dt; b.y += b.vy * dt;
const half = b.r * 0.7;
if (b.x < half) { b.x = half; b.vx = Math.abs(b.vx) * 0.5; }
if (b.x > W - half) { b.x = W - half; b.vx = -Math.abs(b.vx) * 0.5; }
if (b.y > floorY) { b.y = floorY; b.vy = -Math.abs(b.vy) * 0.3; }
if (b.y < ceilY) { b.y = ceilY; b.vy = Math.abs(b.vy) * 0.3; }
}
const rand = makePRNG(0x317a05);
const bubbles = bubblesRef.current;
for (let u = 0; u < bubbles.length; u++) {
const bb = bubbles[u];
bb.y -= bb.v * dt;
if (bb.y < -4) { bb.y = H + 4; bb.x = W * 0.5 + (rand() - 0.5) * W * 0.62; }
}
}
function draw(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const { w: W, h: H } = sizeRef.current;
if (!ctx || !col) return;
const C_GLASS = col.glass, C_DEEP = col.deep, C_HEAT = col.heat;
const C_BLOB = col.blob, C_BLOBHI = col.blobhi, C_GLOW = col.glow, C_LABEL = col.label;
// 램프 유리 — 세로 그라디언트(하부 광원)
const bg = ctx.createLinearGradient(0, 0, 0, H);
bg.addColorStop(0, rgba(C_GLASS, 1));
bg.addColorStop(0.62, rgba(mix(C_GLASS, C_DEEP, 0.5), 1));
bg.addColorStop(1, rgba(mix(C_DEEP, C_HEAT, 0.35), 1));
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
// 하부 광원 — 램프 베이스의 따뜻한 빛(깜빡임)
const hg = ctx.createRadialGradient(W * 0.5, H * 1.05, 0, W * 0.5, H * 1.05, H * 1.3);
const flick = 0.6 + 0.4 * Math.sin(t * 1.7);
hg.addColorStop(0, rgba(C_HEAT, 0.6 * flick));
hg.addColorStop(0.5, rgba(C_HEAT, 0.32 * flick));
hg.addColorStop(1, rgba(C_HEAT, 0.06 * flick));
ctx.fillStyle = hg;
ctx.fillRect(0, 0, W, H);
// 열기 아지랑이 — 대류 밴드가 천천히 위로 흐른다
ctx.globalCompositeOperation = "lighter";
for (let hb = 0; hb < 5; hb++) {
const by = H - ((t * 26 + hb * (H / 5)) % (H + 60)) + 30;
const bw = H * 0.5;
const ha = 0.16 + 0.08 * Math.sin(t * 1.3 + hb);
const hgB = ctx.createRadialGradient(W * 0.5, by, 0, W * 0.5, by, W * 0.62);
hgB.addColorStop(0, rgba(C_GLOW, ha));
hgB.addColorStop(1, rgba(C_GLOW, 0));
ctx.fillStyle = hgB;
ctx.fillRect(0, by - bw * 0.4, W, bw * 0.8);
}
ctx.globalCompositeOperation = "source-over";
// 기포 — 뒤쪽 배경층
const bubbles = bubblesRef.current;
for (let u = 0; u < bubbles.length; u++) {
const bb = bubbles[u];
ctx.beginPath();
ctx.arc(bb.x + Math.sin(t * 1.4 + bb.ph) * 3, bb.y, bb.r, 0, TAU);
ctx.fillStyle = rgba(C_GLOW, 0.24);
ctx.fill();
}
// 마칭 스퀘어 윤곽 — 두 겹(글로우 스트로크 + 본체)
sampleGrid();
const segs = march();
const path = new Path2D();
for (let s = 0; s < segs.length; s++) {
const sg = segs[s];
path.moveTo(sg[0][0], sg[0][1]);
path.quadraticCurveTo(sg[0][0], sg[0][1], (sg[0][0] + sg[1][0]) / 2, (sg[0][1] + sg[1][1]) / 2);
path.lineTo(sg[1][0], sg[1][1]);
}
// 글로우
ctx.strokeStyle = rgba(C_GLOW, 0.34);
ctx.lineWidth = 8;
ctx.lineCap = "round";
ctx.stroke(path);
// 본체 — 뜨거운 볼이 있던 자리일수록 밝은 색
const balls = ballsRef.current;
let hot = 0;
for (let i = 0; i < balls.length; i++) hot = Math.max(hot, balls[i].T);
ctx.strokeStyle = rgba(mix(C_BLOB, C_BLOBHI, hot * 0.8), 0.95);
ctx.lineWidth = 4;
ctx.stroke(path);
// 볼 중심 하이라이트 — 온도가 보인다
for (let i2 = 0; i2 < balls.length; i2++) {
const b = balls[i2];
const rr = b.r * 0.6;
const g2 = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, rr);
g2.addColorStop(0, rgba(C_BLOBHI, 0.18 + 0.24 * b.T));
g2.addColorStop(1, rgba(C_BLOBHI, 0));
ctx.fillStyle = g2;
ctx.fillRect(b.x - rr, b.y - rr, rr * 2, rr * 2);
}
// 유리 테두리
ctx.strokeStyle = rgba(C_LABEL, 0.28);
ctx.lineWidth = 1.4;
ctx.strokeRect(0.7, 0.7, W - 1.4, H - 1.4);
}
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: Math.max(2, Math.round(r.width)), h: Math.max(2, Math.round(r.height)) };
cv.width = sizeRef.current.w;
cv.height = sizeRef.current.h;
ctxRef.current = ctx;
}
let t0 = -1;
let prev = 0;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
const dt = Math.min(0.05, (now - (prev || now)) / 1000);
prev = now;
update(t, dt);
draw(t);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
countRef.current = count;
heatRef.current = heat;
gridRef.current.g = grid;
const cs = getComputedStyle(root);
const tokRGB = (name: string): RGB => {
const s = cs.getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
};
colRef.current = {
glass: tokRGB("--lamp-glass"),
deep: tokRGB("--lamp-deep"),
heat: tokRGB("--lamp-heat"),
blob: tokRGB("--lamp-blob"),
blobhi: tokRGB("--lamp-blobhi"),
glow: tokRGB("--lamp-glow"),
label: tokRGB("--lamp-label"),
};
fit();
build();
setLabel(String(count));
function onResize(): void {
fit();
build();
if (reduced()) draw(1.2);
}
window.addEventListener("resize", onResize);
if (reduced()) {
// 몇 걸음 미리 시뮬레이션한 뒤 한 장
for (let k = 0; k < 90; k++) update(1.2, 1 / 60);
draw(1.2);
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [count, grid, heat]);
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}>{label}</b>개 · 격자 <b className={styles.val}>{grid}</b>px</span>
<span className={styles.metaDim}>canvas · Path2D · quadraticCurveTo</span>
</div>
</div>
</main>
);
}05 라바 램프 — 부력과 온도차로 떠오르는 굼벵이 덩어리 — 측정 누적면적 53.512% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 캔버스 메타볼 효과 9종 — 마칭 스퀘어로 합체하는 액체 덩어리
이 데모가 속한 글
캔버스 메타볼 효과 9종 — 마칭 스퀘어로 합체하는 액체 덩어리메타볼 효과는 여러 덩어리가 가까워지면 하나로 녹아 붙고 멀어지면 갈라지는, 액체처럼 굴러가는 캔버스 장면입니다. 필드 값이 임계값을 넘는 경계를 격자 칸마다 잇는 마칭 스퀘어 하나로 블롭이 스며들듯 합쳐졌다 갈라집 이 글의 데모 전부 보기


