GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

캔버스 메타볼 효과 9종 — 마칭 스퀘어로 합체하는 액체 덩어리

메타볼 효과는 여러 덩어리가 가까워지면 하나로 녹아 붙고 멀어지면 갈라지는, 액체처럼 굴러가는 캔버스 장면입니다. 필드 값이 임계값을 넘는 경계를 격자 칸마다 잇는 마칭 스퀘어 하나로 블롭이 스며들듯 합쳐졌다 갈라집니다 — 라이브러리·WebGL 0

데모

아홉 판을 순서대로 돌려 보고, 각 판의 코드 탭에서 HTML·CSS·JS 소스를 그대로 복사해 갈 수 있습니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

왜 필드 한 겹이 아홉 장면으로 번지는가

순서는 화려함이 아니라 경계를 어떻게 다루는가로 갈립니다. 01은 필드 값을 임계값과 비교해 넘는 칸만 채우는 가장 작은 단위이고, 02는 그 경계를 닫힌 윤곽으로 뽑아 내고, 03은 뽑은 경로를 색과 잔광으로 꾸미고, 04는 경계 위아래의 필드 값을 빛으로 읽습니다. 05·06은 같은 윤곽 위에 부력과 인력이라는 물리를 얹고, 07은 임계값 하나를 여러 층으로 쌓고, 08은 필드원을 볼이 아니라 글자 픽셀에서 받고, 09는 착수 충격파라는 외부 사건을 필드에 더해 장면을 닫습니다.

임계값이 먼저고 윤곽은 그 위에 얹힌다

01의 fieldAt 합산과 02의 lerpPt 보간은 뒤의 모든 판에서 같은 역할로 다시 등장합니다. 달라지는 것은 그 결과를 칠할 색·물리·사건뿐이라 판 하나를 이해하면 다음 판은 덧셈으로 읽힙니다.

관찰 판에서 사건 판으로 가는 길

01부터 04까지는 필드 스스로의 모양을 관찰하고, 05·06은 볼의 움직임에 규칙을 주고, 07·08·09는 필드를 지도·글자·날씨로 읽어 바꿉니다. 쓰임이 히어로 배경에서 인터랙션과 데이터 아트로 넓어지는 순서 그대로입니다.

필드 합산과 경계 판정 — 스칼라 필드와 임계값 경계의 셈

스칼라 필드는 더하면 모양이 바뀌는 숫자 지도다

각 볼이 자기 중심에서 거리에 따라 약해지는 값을 주변에 뿌리고 칸마다 그 값을 전부 더한 것이 스칼라 필드입니다. 두 메타볼이 가까워지면 서로의 꼬리가 겹쳐 합이 임계값을 넘는 칸이 사이에 생기는데, 다리가 스며들듯 돋아난다는 인상의 정체는 이 덧셈 하나입니다.

네 비트로 칸을 분류하는 16경우 테이블

마칭 스퀘어는 각 칸의 네 꼭짓점에 대해 임계값 초과·이하를 4비트로 묶어 16가지 경우로 분류합니다. 경우마다 경계선이 지나가는 변이 정해져 있으니 표 하나로 칸 전체의 윤곽을 이을 수 있습니다.

보간이 계단을 지우고 곡선이 윤곽을 닫는다

경계 점을 변의 양끝 값 비율로 선형 보간하면 픽셀 경계가 매끈해지고, 이어진 점들을 quadraticCurveTo 로 부드럽게 잇는 순간 픽셀 판이 벡터 실루엣처럼 보입니다. 계단을 지우는 것은 해상도가 아니라 보간입니다.

임계값을 움직이면 합체 거리가 달라진다

THRESH 를 낮추면 볼이 멀어도 붙고 높이면 가까워야 합쳐집니다. 값 하나로 끈적임의 온도를 정하는 셈이라 장면의 성격을 바꾸는 첫 조절점이 임계값입니다.

항목별 코드

01필드 한 겹 — 임계값을 넘는 칸만 세는 첫 장면

히어로 배경에 액체 덩어리 하나를 얹는 첫 판입니다 — 칸마다 합한 필드 값이 임계값을 넘는지 따져 넘는 칸만 채우고, 측정 기록은 누적면적 33.374% · 강도 평균 95.3 · 23/23 프레임이 움직였습니다.

필드 한 겹 — 픽셀마다 임계값을 넘는지 세는 첫 메타볼
새 창에서 열기

01 · 필드 한 겹 — 픽셀마다 임계값을 넘는지 세는 첫 메타볼 — 측정 누적면적 33.374% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

getImageDataputImageDatarequestAnimationFrame

거리 감쇠를 미리 구운 룩업 테이블에서 읽는 필드

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 로 구운 스프라이트에서 읽은 값)을 볼 반지름으로 스케일
    // 스프라이트 반경(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];
}

감쇠 곡선을 매 칸마다 다시 계산하지 않고 putImageData 로 구운 스프라이트의 표에서 읽어오는 것이 이 판의 속도 비결입니다. N_BALL 5개가 각자 다른 반지름으로 값을 나눠 가지니 두 볼 사이에 다리가 스며들듯 돋아납니다.

02마칭 스퀘어 — 16경우 테이블로 경계선을 잇는 윤곽

로고·캐릭터의 액체 실루엣에 어울리는 판입니다 — 네 꼭짓점의 임계 초과 비트로 16가지 경우를 골라 경계를 닫힌 윤곽으로 잇고, 측정 기록은 누적면적 34.129% · 강도 평균 141.2 · 23/23 프레임이 움직였습니다.

마칭 스퀘어 — 16경우 테이블로 경계선을 잇는 윤곽
새 창에서 열기

02 · 마칭 스퀘어 — 16경우 테이블로 경계선을 잇는 윤곽 — 측정 누적면적 34.129% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

Path2DquadraticCurveTo

네 코너 비트로 경우를 고르고 변 위에서 보간한다

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 [];
}

경계 점을 변의 양끝 값으로 선형 보간해 찾으니 셀 크기 CELL 8px 로도 계단이 보이지 않습니다. 뽑은 점들을 이어 곡선으로 다듬으면 일러스트에 바로 옮길 만큼 매끈한 실루엣이 나옵니다.

03구이 병합 — 필터 없이 진짜 경로가 스민다

버튼 아이콘·내비게이션의 끈적한 병합에 쓰는 판입니다 — 블러 필터 근사가 아니라 계산한 경로 자체를 채우고 두르고, 측정 기록은 누적면적 43.647% · 강도 평균 43.9 · 23/23 프레임이 움직였습니다.

구이 병합 — SVG 필터 없이 진짜 경로가 스밈다
새 창에서 열기

03 · 구이 병합 — SVG 필터 없이 진짜 경로가 스밈다 — 측정 누적면적 43.647% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

Path2DrequestAnimationFramesetAttribute

고리마다 가장 가까운 볼 색을 입히는 채움

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;
}

지나간 자리엔 잔광 캔버스가 볼색 알파 원을 남겨 경로를 따라 색이 흐릅니다. 화면 픽셀에 블러와 콘트라스트를 겹치는 근사는 확대하면 번지지만 이 경로는 어디서든 윤곽이 살아 있습니다.

04빛 번짐 — 필드 값을 명도로 읽는 글로우 블룸

다크 모드 히어로·음악 서비스 배경용 발광 액체판입니다 — 임계값 위아래 어디서나 필드 값을 명도로 읽어 lighter 로 겹치고, 측정 기록은 누적면적 36.467% · 강도 평균 19.8 · 23/23 프레임이 움직였습니다.

빛 번짐 — 필드 값을 명도로 읽는 글로우 블룸
새 창에서 열기

04 · 빛 번짐 — 필드 값을 명도로 읽는 글로우 블룸 — 측정 누적면적 36.467% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

createRadialGradientglobalCompositeOperationrequestAnimationFrame

접근도가 오르면 합체 직전 플레어가 터진다

// 플레어 — 합체 직전 두 볼 사이에서 터지는 빛
for (var p = 0; p < balls.length; p++) {
  for (var q = p + 1; q < balls.length; q++) {
    var a1 = balls[p], b1 = balls[q];
    var d = Math.hypot(a1.x - b1.x, a1.y - b1.y);
    var lim = (a1.r + b1.r) * 1.6;
    if (d >= lim) continue;
    var e = 1 - d / lim;
    e = e * e;
    var mx = (a1.x + b1.x) / 2, my = (a1.y + b1.y) / 2;
    var pulse = 0.72 + 0.28 * Math.sin(t * 8 + p * 2.1);
    var fr = 20 + d * 0.55 + 42 * e;
    var g3 = ctx.createRadialGradient(mx, my, 0, mx, my, fr);
    g3.addColorStop(0, rgba(C_CORE, 0.5 * e * pulse * FLARE + 0.04 * e));
    g3.addColorStop(0.45, rgba(C_MINT, 0.26 * e * pulse));
    g3.addColorStop(1, rgba(C_PINK, 0));
    ctx.fillStyle = g3;
    ctx.fillRect(mx - fr, my - fr, fr * 2, fr * 2);
  }
}

두 볼의 접근도가 1에 가까워질수록 내부가 밝아지다 사이 거리가 반경 합의 1.6배 안으로 들어오면 중점에서 플레어가 터집니다. GLOW 3.0 배의 방사 그라디언트가 겹쳐도 lighter 합성이라 색이 묻히지 않고 더해집니다.

05라바 램프 — 부력과 온도차로 떠오르는 덩어리

무드 조명·로딩 대기 화면에 어울리는 램프판입니다 — 온도 사이클이 뜨거우면 위로 차가우면 아래로 떠오르게 하고, 측정 기록은 누적면적 53.512% · 강도 평균 21.4 · 23/23 프레임이 움직였습니다.

라바 램프 — 부력과 온도차로 떠오르는 굼벵이 덩어리
새 창에서 열기

05 · 라바 램프 — 부력과 온도차로 떠오르는 굼벵이 덩어리 — 측정 누적면적 53.512% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

requestAnimationFramequadraticCurveToPath2D

바닥에서 데워지고 정상에서 식는 온도 사이클

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;
  }
}

부력은 온도에서 0.42 를 빼 크기로 나눈 값이라 큰 볼일수록 느리게 떠오르는데 이 비대칭이 램프 유리 속 굼벵이 움직임의 정체입니다. 볼 9개가 24px 격자 위에서 실시간으로 윤곽을 다시 잡으니 모양이 쉬지 않고 바뀝니다.

06포인터 인력 — 끌려왔다가 찢어지는 당김 다리

인트로·놀이형 랜딩의 손끝 반응용 판입니다 — 포인터 반경 PULL_R 168px 안의 볼만 인력 방향으로 가속하고, 측정 기록은 누적면적 32.608% · 강도 평균 58.6 · 23/23 프레임이 움직였습니다.

포인터 인력 — 끌려왔다가 찢어지는 당김 다리
새 창에서 열기

06 · 포인터 인력 — 끌려왔다가 찢어지는 당김 다리 — 측정 누적면적 32.608% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

addEventListenerrequestAnimationFramequadraticCurveTo

인력 반경 안에서만 잡아당기고 밖은 감쇠로 놓는다

function update(t, dt) {
  // 자동 모드 — pointermove 가 없으면 원운동 순찰 경로를 가상 포인터로 쓴다
  if (!hasPointer) {
    var a = t * 0.7;
    ptrX = W * 0.5 + Math.cos(a) * W * 0.34;
    ptrY = H * 0.5 + Math.sin(a * 1.15) * H * 0.32;
  }
  for (var i = 0; i < balls.length; i++) {
    var b = balls[i];
    var dx = ptrX - b.x, dy = ptrY - b.y;
    var d = Math.hypot(dx, dy) || 1;
    if (d < PULL_R) {
      var pull = (1 - d / PULL_R) * PULL_K;
      b.vx += (dx / d) * pull * 60 * dt;
      b.vy += (dy / d) * pull * 60 * dt;
    } else {
      // 감쇠 — 홈 위치로 서서히 되돌아간다
      b.vx += (b.homeX - b.x) * 0.9 * dt;
      b.vy += (b.homeY - b.y) * 0.9 * dt;
    }
  }
}

다가올 때 두꺼운 다리가 붙고 커서를 치우면 홈 감쇠가 잡아당겨 늘어났다 끊어집니다. 손이 없으면 가상 포인터가 원운동 순찰을 대신해 판이 혼자서도 살아 움직입니다.

07등고 띠 — 임계값을 여러 겹으로 쌓는 위상 지도

데이터 아트·섹션 배경용 위상 지도판입니다 — 임계값 층 BANDS 5개를 동시에 추적해 층마다 다른 파스텔을 채우고, 측정 기록은 누적면적 52.869% · 강도 평균 70.0 · 23/23 프레임이 움직였습니다.

등고 띠 — 임계값을 여러 겹으로 쌓는 위상 지도
새 창에서 열기

07 · 등고 띠 — 임계값을 여러 겹으로 쌓는 위상 지도 — 측정 누적면적 52.869% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

getImageDatacreateLinearGradientrequestAnimationFrame

열원 필드에 지형 노이즈를 섞어 띠를 만든다

// 지형 파동 — 열원과 무관하게 화면 전체를 항상 등고대로 채우는 배경 항(기상도의 "언제나 있는" 등고)
function terrain(nx, ny, t) {
  var a = Math.sin(nx * 5.4 + t * 0.25) * Math.cos(ny * 4.1 - t * 0.19);
  var b = Math.sin((nx + ny) * 3.1 - t * 0.31);
  return (0.5 + 0.32 * a + 0.18 * b);
}

function classify(t) {
  var img = fieldCtx.getImageData(0, 0, LOWW, LOWH);
  var d = img.data;
  var thr = [];
  for (var k = 1; k <= BANDS; k++) thr.push(k / (BANDS + 1));
  for (var y = 0; y < LOWH; y++) {
    for (var x = 0; x < LOWW; x++) {
      var idx = (y * LOWW + x) * 4;
      var v = d[idx] / 255;                 // 열원 필드(0..1)
      var mix = v * 0.72 + terrain(x / LOWW, y / LOWH, t) * 0.28;
      var band = 0;
      for (var k2 = 0; k2 < thr.length; k2++) if (mix >= thr[k2]) band = k2 + 1;
      bandCtx.fillStyle = band === 0 ? C_INK : BAND_COL[band - 1];
      bandCtx.fillRect(x, y, 1, 1);
    }
  }
}

열원 필드에 지형 노이즈를 0.28 만큼 섞으니 띠가 볼 주변만 도는 게 아니라 화면 전체의 능선을 따라 흐릅니다. 같은 필드를 온도 지도·기압 지도처럼 읽는 셈이라 02와 판이 완전히 달라 보입니다.

08액체 글자 — 텍스트 픽셀에서 태어나는 모핑

히어로 헤드라인·글자 모핑용 판입니다 — 문장을 그린 뒤 알파가 있는 픽셀만 필드원으로 샘플링하고, 측정 기록은 누적면적 30.161% · 강도 평균 89.5 · 23/23 프레임이 움직였습니다.

액체 글자 — 텍스트 픽셀에서 태어나 흐르는 모핑
새 창에서 열기

08 · 액체 글자 — 텍스트 픽셀에서 태어나 흐르는 모핑 — 측정 누적면적 30.161% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

measureTextgetImageDataquadraticCurveTo

알파가 있는 픽셀만 걸러 씨앗을 뿌린다

var img = txtCtx.getImageData(0, 0, W, H);
var d = img.data;
seeds = [];
for (var y = 4; y < H - 4; y += STRIDE) {
  for (var x = 4; x < W - 4; x += STRIDE) {
    var a = d[(y * W + x) * 4 + 3];
    if (a > 120) {
      seeds.push({ x: x, y: y, ph: rand() * Math.PI * 2, sp: 0.8 + rand() * 1.6 });
    }
  }
}

STRIDE 7px 간격으로 알파 120 을 넘는 픽셀만 씨앗으로 남기니 문장 전체가 필드원의 무리로 바뀝니다. 문장은 느린 표류로 자리를 바꾸며 모양을 다시 만드는데 이것이 타이포가 액체가 되는 지점입니다.

09빗물 웅덩이 — 착수 충격파가 볼을 밀어내는 장면

날씨·무드 페이지 히어로용 창가 빗물판입니다 — 빗방울이 착수하는 순간 원형 충격파를 필드에 더했다 빼고, 측정 기록은 누적면적 52.152% · 강도 평균 119.5 · 23/23 프레임이 움직였습니다.

빗물 웅덩이 — 낙하 충격파가 볼을 밀어내는 장면
새 창에서 열기

09 · 빗물 웅덩이 — 낙하 충격파가 볼을 밀어내는 장면 — 측정 누적면적 52.152% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

getImageDataputImageDatarequestAnimationFrame

착수 충격파를 사인 링으로 밀어내는 계산

// 충격파 — 얇은 링(가우시안 테두리)을 필드에 더했다 빼고(위상 반전) 재융합 물결을 남긴다
for (k = 0; k < shocks.length; k++) {
  var sh = shocks[k];
  var u = (t - sh.born) / sh.life;
  if (u >= 1) continue;
  var rr = 3 + u * LOWW * 0.42;
  var amp = Math.sin(u * Math.PI) * sh.amp;
  var ringW = 4.5;
  var g2 = fieldCtx.createRadialGradient(sh.x, sh.y, Math.max(0.1, rr - ringW), sh.x, sh.y, rr + ringW);
  var peak = Math.round(Math.max(0, Math.min(255, amp * 210)));
  var dip = Math.round(Math.max(0, Math.min(255, -amp * 160)));
  g2.addColorStop(0, 'rgba(' + dip + ',' + dip + ',' + dip + ',1)');
  g2.addColorStop(0.5, 'rgba(' + peak + ',' + peak + ',' + peak + ',1)');
  g2.addColorStop(1, 'rgba(0,0,0,1)');
  fieldCtx.fillStyle = g2;
  fieldCtx.fillRect(sh.x - rr - ringW, sh.y - rr - ringW, (rr + ringW) * 2, (rr + ringW) * 2);
}

충격파 세기 SHOCK 0.9 는 Math.sin(u * Math.PI) 사인 반파로 살았다 죽는데 u 가 0.5 를 지나는 착수 뒤 반 박자에 가장 세게 밀어내는 이 모양이 잔물결의 결입니다. 웅덩이 덩어리가 출렁이며 갈라졌다 다시 붙는 병합이 프레임마다 반복됩니다.

메타볼을 실제로 쓰는 화면

버튼이나 로고 위에서 커서를 따라다니는 강조 효과가 필요하면 06 포인터 인력처럼 끌려왔다가 찢어지는 다리가 맞습니다 — 클릭 영역을 시각적으로 넓혀 보이면서도 실제 히트 영역은 그대로 둘 수 있습니다. 브랜드 인트로에서 글자가 액체처럼 뭉쳤다 풀리는 연출은 08 액체 글자가 그대로 옮겨 붙습니다.

배경 장식으로는 09 빗물 웅덩이처럼 낮은 강도로 잔잔히 도는 장면이 어울리고, 로딩 인디케이터에는 01 필드 한 겹처럼 가장 단순한 형태 하나만 있으면 충분합니다.

임계값을 살짝만 틀어도 경계가 끊기는 함정

세 곳에서 실제로 부딪혔습니다 — 안장 경우의 판정, 필드 계산 비용, 언어팩 넘침입니다.

안장 경우 5번과 10번은 중심값으로 갈라야 한다

네 비트가 대각선으로 두 개씩 걸리는 경우 5번·10번은 어느 쪽 대각선으로 경계를 긋느냐에 따라 두 덩어리가 붙었는지 갈라졌는지가 달라지는 안장입니다. 02는 이 경우를 'AMB-x'·'AMB-+' 로 표시해 셀 중심값을 다시 계산해 판정하는데, 이 판정이 없으면 경계가 프레임마다 뒤집혀 윤곽이 떨립니다.

픽셀 단위 계산은 칸 수만큼 볼 수가 곱해진다

필드를 화면 픽셀마다 다시 계산하면 칸 수와 볼 수가 곱해져 프레임이 급격히 무거워집니다. 01은 감쇠 곡선을 미리 구운 표에서 읽어 이 곱셈을 피했고, 02·07·08·09는 저해상 필드 캔버스를 계산한 뒤 확대해 올립니다.

언어팩 번역문이 패널을 넘쳐 측정을 다시 찍은 자리

02의 패널 문구는 ko 에서 한 줄이지만 en·ja 번역문은 더 길어 480×300 판에서 세 줄로 넘쳤고 넘침 판정이 떴습니다. 폭과 배치는 그대로 두고 글자만 clamp(9px, 2.5vw, 11px) 로 줄여 고친 뒤 다시 측정해, 이 글이 인용하는 값은 누적면적 34.129% · 23/23 프레임의 현재 기록입니다. 데모를 고치면 측정이 따라 바뀌니 글의 수치도 함께 갱신해야 한다는 교훈이 남은 안건입니다.

안건의 전후 기록과 아홉 판의 바닐라 소스·React 판은 암호 zip 에 묶어 두었고, 압축을 풀 때 넣는 암호는 nxvdqxgd 입니다.

필드 반경 하나로 갈리는 세 변형과 접근성 처리

값 하나를 바꾼 세 변형

변형 바꾸는 값 결과
색 수 줄이기 07 BANDS 5 → 2 · 파스텔 두 색 온도 지도가 단정한 두 색 띠 판으로
칸 절약 02 CELL 8 → 16 (격자 간격 두 배) 계산이 절반으로 떨어지고 윤곽이 두툼해진다
정적 버전 난수 시드 고정 · 정지 한 장 포스터·표지로 쓰는 한 프레임

세 변형 모두 각 판 소스 맨 위에 몰아 둔 상수 하나만 값을 바꾸는 축이라 파일을 새로 짜지 않아도 됩니다.

움직임을 줄인 사용자에게 남는 한 장

아홉 판 전부 prefers-reduced-motion: reduce 를 검사해 루프를 시작하지 않고 계산만 한 번 돌려 그린 정지 프레임을 보여 줍니다. 02는 0.6초, 07은 0.4초, 08은 0.35초 시점이고, 05는 STILL_T 1.2초까지 update 를 90스텝 미리 돌려 떠오른 모양이 자리 잡은 장면을, 09는 착수 물결이 퍼진 직후 한 장을 고릅니다. 계산은 애니메이션과 같으니 정지 화면의 품질도 같습니다.

탭으로 초점을 받는 아홉 판, 실제로 손끝에 반응하는 건 06

아홉 판 모두 캔버스 컨테이너에 role="application" 과 tabindex="0", aria-label 을 달아 탭으로 초점을 옮길 수 있습니다. 그중 포인터 입력으로 실제 움직임이 바뀌는 판은 06 하나뿐입니다 — pointermove 로 좌표를 추적해 다리를 당기고 pointerleave 가 뜨면 자동 순찰 경로로 되돌립니다. 나머지 여덟 판은 초점만 받을 뿐 포인터·키 입력으로 장면이 바뀌지 않고, 움직임을 줄인 사용자에게는 로드·리사이즈 시점에 한 번 계산한 정지 프레임이 그대로 유지됩니다.

메타볼을 처음 붙일 때 자주 묻는 질문

CSS gooey 필터로도 되는데 경계를 왜 뽑아 내나요?

gooey 필터는 블러와 콘트라스트를 겹쳐 번짐을 재현하는 화면 근사라 확대하면 윤곽이 흐려지고 경계 좌표를 코드에서 읽을 수 없습니다. 여기서는 경계가 Path2D 좌표로 나오니 채움·선·히트 판정을 경로 단위로 제어할 수 있고 화면을 키워도 윤곽이 깨지지 않습니다.

metaball animation blender 로 구운 결과와 다른 점은 뭔가요?

blender 의 metaball animation 은 툴 안에서 결과를 미리 구워 파일로 내보내는 방식이고, 이 아홉 판은 매 프레임 스칼라 필드를 다시 계산하는 살아 있는 구현입니다. 미리 구운 결과는 손끝에 반응하지 않지만 여기는 인력·충격파·글자 모핑처럼 입력에 그 자리에서 답합니다.

일러스트용 매끈한 실루엣을 찾는 디자이너도 쓸 수 있나요?

쓸 수 있습니다. 02의 윤곽이 Path2D 경로 좌표로 매끈하게 나오므로 로고 실루엣의 바탕으로 그대로 옮길 수 있고, 08은 텍스트 픽셀을 씨앗으로 한 액체 타이포용이라 같은 용도로 쓸 수 있습니다. 색은 각 판 scss 맨 위의 파스텔 토큰 변수만 바꾸면 됩니다.

더 읽을거리

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.