# AI 꾸러미 — 메타볼 몸 (말랑한 덩어리) — Metaballs (implicit surface)
> 공마다 퍼지는 값을 격자에 더하고 일정 값 면을 마칭 큐브로 매 프레임 뽑아, 가까운 공끼리 녹아 붙는 말랑한 슬라임 몸을 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i447

이 문서를 코드 도우미(Claude Code · Cursor · ChatGPT 등)에 그대로 주면 돼요. 「## 주문서」 가 할 일, 나머지는 참고 자료예요.

## 주문서

### 만들어 줘: 메타볼 몸 (말랑한 덩어리) — Metaballs (implicit surface)

#### 1. 목표
얼굴 있는 슬라임 캐릭터를 메타볼로 만들어 줘 — 공들이 가까우면 녹아 붙고, 떨어진 방울이 기어 돌아와 합쳐지게. 분위기는 반들반들 젤리.

#### 2. 핵심 기술 용어
- **Metaballs (implicit surface)** — 공들의 값을 더한 장의 등값면 — 가까우면 녹아 붙음
- **Marching cubes** — 격자 칸마다 등값면을 삼각형으로 뽑는 방법
- **Falloff (1 − r²/R²)³** — 공 하나가 주는 값 — 중심 1, 반지름에서 0
- **Gradient normal** — 장의 기울기로 매끈한 법선

#### 3. 환경
- 플랫폼: three.js r186 (ES 모듈 · TypeScript, `import * as THREE from "three"`), WebGL2, 외부 라이브러리 추가 없이
- 화면: 3D · 브라우저 — PC 와 폰(가로 844×390 · 세로 390×844) 모두, 60fps 목표

#### 4. 조건
- 공의 영향은 반지름 안 칸만 더한다 (공 경계 상자만 돌기) — 격자 전체를 공마다 돌지 않는다
- 모서리마다 꼭짓점 하나를 공유(캐시)해 이음새 없이
- 법선은 장의 기울기에서 — computeVertexNormals 로 하면 결이 보인다
- 버퍼는 넉넉히(1.6배) 잡고 쓴 만큼만 갱신, 경계 구는 고정 — 매 프레임 새 BufferGeometry 만들지 않기
- 해상도 · 방울 수 · 문턱 슬라이더를 두어 무게와 모양을 조절

#### 5. 완성 기준 (이게 보이면 성공)
- 방울이 몸에서 쏙 튀어나가 날아가 떨어지고, 기어 돌아와 몸과 녹아 합쳐진다
- 방울 색(분홍 · 하늘 · 노랑)이 몸에 닿는 곳에서 부드럽게 섞인다
- 「공 중심 보기」를 켜면 공 중심과 영향 범위(선 구)가 보인다
- 면에 계단 · 이음새가 없고 반들반들하다

#### 6. 진행 방식
- 핵심 코드 위주로, 설명은 짧게. 내 프로젝트에 끼워 넣기 쉬운 함수 · 클래스로 나눠 줘.
- 처음 화면에 바로 결과가 보이게, 그리고 켬/끔(또는 전/후) 비교를 할 수 있게 만들어 줘.
- 그림 · 소리 · 모델 파일이 필요하면 코드로 만든 임시 대체물로 먼저 돌아가게 하고, 진짜 파일로 바꿀 자리를 표시해 줘.
- 마지막에 「확인 방법」(무엇을 보면 성공인지)과 「조절할 값」 목록을 짧게 정리해 줘.
- 답변과 코드 주석은 한국어로 해 줘.

## 원리
- 공 하나가 주변에 값 w³ 를 뿌린다 (w = 1 − 거리²/반지름²). 반지름 밖은 0.
- 모든 공의 값을 격자(견본 30칸)에 더한 뒤, 값이 문턱(iso 0.3)인 면을 마칭 큐브로 뽑는다.
- 두 공이 가까우면 사이 값이 더해져 문턱을 넘으므로 다리처럼 녹아 붙는다.
- 정점 법선은 각 공 값의 기울기 합으로, 색은 값 비중으로 섞어 방울 색이 몸에 번지게 한다.
- 공은 몸통 넷 + 방울 넷 — 방울은 튀어나감 → 날아감 → 납작 착지 → 기어 돌아옴을 되풀이한다.

## 핵심 코드 — 공 값을 격자에 더하고 기울기 법선 · 섞인 색
(발췌: demos/demosModelA.ts demoMeta() poly() 를 정리)
```ts
// 1) 격자에 값 더하기 — 공 경계 상자 안 칸만
fld.fill(0);
for (const b of balls) {
  const iR = 1 / (b.R * b.R);
  for (let z = z0(b); z <= z1(b); z++)
    for (let y = y0(b); y <= y1(b); y++)
      for (let x = x0(b); x <= x1(b); x++) {
        const dx = X0 + x * h - b.x, dy = (Y0 + y * h - b.y) / b.sy, dz = X0 + z * h - b.z; // sy = 납작함
        const q = (dx * dx + dy * dy + dz * dz) * iR;
        if (q < 1) { const w = 1 - q; fld[(z * ny + y) * nx + x] += w * w * w; }
      }
}

// 2) 등값면 뽑기 (iso 0.3). 꼭짓점이 생길 때마다 법선 · 색을 공들에서 계산
polygonize(fld, G, 0.3, buf, cache, (x, y, z, i) => {
  let gx = 0, gy = 0, gz = 0, ws = 0, r = 0, g = 0, bl = 0;
  for (const b of balls) {
    const dx = x - b.x, dy = (y - b.y) / b.sy, dz = z - b.z;
    const iR = 1 / (b.R * b.R);
    const q = (dx * dx + dy * dy + dz * dz) * iR;
    if (q >= 1) continue;
    const w = 1 - q, dw = -6 * w * w * iR;  // d(w³)/d(거리²) 비례
    gx += dw * dx; gy += (dw * dy) / b.sy; gz += dw * dz;
    const ww = w * w * w;
    ws += ww; r += b.c.r * ww; g += b.c.g * ww; bl += b.c.b * ww;
  }
  const l = Math.hypot(gx, gy, gz) || 1;
  buf.nor.set([-gx / l, -gy / l, -gz / l], i * 3); // 값이 줄어드는 쪽 = 바깥
  ws = ws || 1;
  buf.col.set([r / ws, g / ws, bl / ws], i * 3);   // 가까운 공 색이 많이
});
```

## 흔한 실수 · 확인 목록
- [ ] **매 프레임 BufferGeometry 를 새로 만들면 메모리가 새고 끊긴다** — 1.6배 넉넉한 버퍼를 DynamicDrawUsage 로 한 번 잡고, 쓴 만큼만 drawRange · needsUpdate. 모자랄 때만 다시 잡는다.
- [ ] **computeVertexNormals 를 쓰면 삼각형 결이 보인다** — 장의 기울기(공마다 dw × 거리)를 더해 법선으로 쓴다.
- [ ] **경계 구를 매 프레임 다시 재면 느리다** — 견본은 경계 구를 고정(중심 0.8 높이, 반지름 3)하고 frustumCulled = false.
- [ ] **삼각형 감는 방향이 뒤집혀 안쪽만 보인다** — 첫 삼각형의 면 방향과 법선을 한 번 비교해 감는 방향을 정한다 (견본 MC_FLIP).

## 완성 기준 체크리스트
- [ ] 방울이 몸에서 쏙 튀어나가 날아가 떨어지고, 기어 돌아와 몸과 녹아 합쳐진다
- [ ] 방울 색(분홍 · 하늘 · 노랑)이 몸에 닿는 곳에서 부드럽게 섞인다
- [ ] 「공 중심 보기」를 켜면 공 중심과 영향 범위(선 구)가 보인다
- [ ] 면에 계단 · 이음새가 없고 반들반들하다

## 이 기술 정보
- id: `i447` · 분류: 3D 모델 · 캐릭터 › 3D 모델링 · 절차 · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의) — 매 프레임 격자 31 × 22 × 31 ≈ 2만 칸을 다시 채우고 면을 뽑는다(수 ms). 해상도를 올리면 세제곱으로 늘어난다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i447
- 쓰면 좋을 때: 슬라임 · 물방울 · 용암처럼 모양이 계속 바뀌는 말랑한 몸 / 덩어리가 쪼개졌다 합쳐지는 장면
- 쓰지 말 때: 모양이 바뀌지 않는 캐릭터 — 한 번만 굽는 SDF 모델링(i446) 이 더 깔끔하다 / 2D 화면 — 대신 2D SDF 메타볼 (셰이더 한 장)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### demoMeta — `src/demos/demosModelA.ts:1586`
```ts
function demoMeta(): Scene3D {
  const st = new Stage({ cam: [0, 1.7, 5.4], look: [0, 0.75, 0], island: 1.75 });
  const mat = new THREE.MeshPhysicalMaterial({ vertexColors: true, roughness: 0.16, clearcoat: 1, clearcoatRoughness: 0.08, sheen: 0.5, sheenColor: new THREE.Color(0xffffff), polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1 });
  let geo = new THREE.BufferGeometry();
  const slime = st.model(new THREE.Mesh(geo, mat));
  slime.frustumCulled = false;
  st.spin.add(slime);
  const S = { res: 30, drops: 4, iso: 0.3, centers: false, seed: 1 };
  const body = new THREE.Color(0x7ee8c4);
  const pal = [0xff9ec7, 0x8fd0ff, 0xffe27a, 0xc5a8ff, 0xffb38a, 0xa8f07a].map((c) => new THREE.Color(c));
  const balls: Ball[] = [];
  const dirs: number[] = [];
  const reseed = (): void => {
    const R0 = rng(S.seed);
    dirs.length = 0;
    for (let i = 0; i < 6; i++) dirs.push((i / 6) * TAU + R0() * 0.8);
    pal.sort(() => R0() - 0.5);
  };
  reseed();
  const X0 = -1.8, Y0 = -0.25, SPAN = 3.6;
  let G: Grid;
  let fld = new Float32Array(1);
  let cache = new Int32Array(1);
  const buf = new MCBuf();
  const setRes = (): void => {
    const h = SPAN / S.res;
    G = { nx: S.res + 1, ny: Math.ceil(2.5 / h) + 1, nz: S.res + 1, ox: X0, oy: Y0, oz: X0, h };
    fld = new Float32Array(G.nx * G.ny * G.nz);
    cache = new Int32Array(fld.length * 3);
  };
  setRes();
  // 공 중심 · 영향 범위 보기
  const dotG = new THREE.SphereGeometry(1, 12, 8);
  const dots = new THREE.InstancedMesh(dotG, new THREE.MeshBasicMaterial({ color: 0xffffff, toneMapped: false, depthTest: false, transparent: true }), 12);
  const halos = new THREE.InstancedMesh(dotG, new THREE.MeshBasicMaterial({ color: 0x9fe8ff, wireframe: true, transparent: true, opacity: 0.18, depthWrite: false, toneMapped: false }), 12);
  dots.renderOrder = 5;
  st.spin.add(dots, halos);
  // 얼굴
  const face = new THREE.Group();
  const black = new THREE.MeshPhysicalMaterial({ color: 0x14202a, roughness: 0.1, clearcoat: 1 });
  const eyes: THREE.Mesh[] = [];
  for (const s of [-1, 1]) {
    const e = new THREE.Mesh(new THREE.SphereGeometry(1, 18, 12), black);
    e.scale.set(0.085, 0.115, 0.05);
    e.position.set(s * 0.2, 0, 0);
    const hl = new THREE.Mesh(new THREE.SphereGeometry(0.22, 8, 6), new THREE.MeshBasicMaterial({ color: 0xffffff }));
    hl.position.set(0.3, 0.35, 0.8);
    e.add(hl);
    const bl = new THREE.Mesh(new THREE.SphereGeometry(1, 12, 8), new THREE.MeshBasicMaterial({ color: 0xff8fb0, transparent: true, opacity: 0.55, depthWrite: false }));
    bl.scale.set(0.08, 0.04, 0.02);
    bl.position.set(s * 0.33, -0.1, -0.01);
    eyes.push(e);
    face.add(e, bl);
  }
  const smile = new THREE.Mesh(new THREE.TorusGeometry(0.06, 0.012, 6, 16, Math.PI), black);
  smile.rotation.z = Math.PI;
  smile.position.set(0, -0.1, 0.05);
  face.add(smile);
  st.spin.add(face);
  const m4 = new THREE.Matrix4();
  const pv = new THREE.Vector3();
  const qv = new THREE.Quaternion();
  const sv = new THREE.Vector3();
  let lastMs = 0;
  const place = (t: number): void => {
    balls.length = 0;
    const br = Math.sin(t * 3.2);
    balls.push({ x: 0, y: 0.42, z: 0, R: 1.08, sy: 0.85 - br * 0.04, c: body });
    balls.push({ x: 0, y: 0.9 + br * 0.03, z: 0.02, R: 0.82, sy: 1, c: body });
    balls.push({ x: -0.42, y: 0.26, z: 0.12, R: 0.6, sy: 0.8, c: body });
    balls.push({ x: 0.42, y: 0.26, z: 0.12, R: 0.6, sy: 0.8, c: body });
    for (let i = 0; i < S.drops; i++) {
      const P = 3.4;
      const tau = (((t + i * (P / Math.max(1, S.drops))) / P) % 1 + 1) % 1;
      const a = dirs[i]!;
      const dx = Math.cos(a);
      const dz = Math.sin(a);
      const L = 1.25;
      let x: number, y: number, z: number, R = 0.5, sy = 1;
      if (tau < 0.15) {
        const k = ease(tau / 0.15);
        x = dx * 0.35 * k;
        z = dz * 0.35 * k;
        y = 1.0 + 0.35 * k;
        R = 0.34 + 0.16 * k;
      } else if (tau < 0.5) {
        const k = (tau - 0.15) / 0.35;
        x = dx * lerp(0.35, L, k);
        z = dz * lerp(0.35, L, k);
        y = lerp(1.35, 0.18, k) + Math.sin(k * Math.PI) * 0.9;
        sy = 1 + Math.sin(k * Math.PI) * 0.15;
      } else if (tau < 0.62) {
        const k = (tau - 0.5) / 0.12;
        x = dx * L;
        z = dz * L;
        y = 0.16 - Math.sin(k * Math.PI) * 0.05;
        sy = 1 - Math.sin(k * Math.PI) * 0.4;
      } else {
        const k = ease((tau - 0.62) / 0.38);
        const r = lerp(L, 0.25, k);
        x = dx * r + Math.sin(k * 18) * 0.03;
        z = dz * r;
        y = 0.18 + Math.abs(Math.sin(k * 9)) * 0.06;
        R = lerp(0.5, 0.38, k);
      }
      balls.push({ x, y, z, R, sy, c: pal[i % pal.length]! });
    }
  };
  const poly = (): void => {
    const t0 = performance.now();
    fld.fill(0);
    const { nx, ny, nz, h } = G;
    for (const b of balls) {
      const rx = b.R, ry = b.R * b.sy;
      const x0 = Math.max(0, Math.floor((b.x - rx - X0) / h)), x1 = Math.min(nx - 1, Math.ceil((b.x + rx - X0) / h));
      const y0 = Math.max(0, Math.floor((b.y - ry - Y0) / h)), y1 = Math.min(ny - 1, Math.ceil((b.y + ry - Y0) / h));
      const z0 = Math.max(0, Math.floor((b.z - rx - X0) / h)), z1 = Math.min(nz - 1, Math.ceil((b.z + rx - X0) / h));
      const iR = 1 / (b.R * b.R);
      for (let z = z0; z <= z1; z++) {
        const dz = X0 + z * h - b.z;
        for (let y = y0; y <= y1; y++) {
          const dy = (Y0 + y * h - b.y) / b.sy;
          const row = (z * ny + y) * nx;
          for (let x = x0; x <= x1; x++) {
            const dx = X0 + x * h - b.x;
            const q = (dx * dx + dy * dy + dz * dz) * iR;
            if (q < 1) {
              const w = 1 - q;
              fld[row + x]! += w * w * w;
            }
          }
        }
      }
    }
    polygonize(fld, G, S.iso, buf, cache, (x, y, z, i) => {
      let gx = 0, gy = 0, gz = 0, ws = 0, r = 0, g = 0, bb = 0;
      for (const b of balls) {
        const dx = x - b.x, dy = (y - b.y) / b.sy, dz = z - b.z;
        const iR = 1 / (b.R * b.R);
        const q = (dx * dx + dy * dy + dz * dz) * iR;
        if (q >= 1) continue;
        const w = 1 - q;
        const dw = -6 * w * w * iR;
        gx += dw * dx;
        gy += (dw * dy) / b.sy;
        gz += dw * dz;
        const ww = w * w * w;
        ws += ww;
        r += b.c.r * ww;
        g += b.c.g * ww;
        bb += b.c.b * ww;
      }
      const l = Math.hypot(gx, gy, gz) || 1;
      buf.nor[i * 3] = -gx / l;
      buf.nor[i * 3 + 1] = -gy / l;
      buf.nor[i * 3 + 2] = -gz / l;
      ws = ws || 1;
      buf.col[i * 3] = r / ws;
      buf.col[i * 3 + 1] = g / ws;
      buf.col[i * 3 + 2] = bb / ws;
    });
    // 미리 넉넉히 잡은 버퍼에 쓴 만큼만 올린다
    const pa = geo.getAttribute('position') as THREE.BufferAttribute | undefined;
    if (!pa || pa.count < buf.nv || (geo.index?.count ?? 0) < buf.ni) {
      geo.dispose();
      geo = new THREE.BufferGeometry();
      const cap = Math.ceil(buf.nv * 1.6) + 1024;
      const capI = Math.ceil(buf.ni * 1.6) + 4096;
      for (const n of ['position', 'normal', 'color']) geo.setAttribute(n, new THREE.BufferAttribute(new Float32Array(cap * 3), 3).setUsage(THREE.DynamicDrawUsage));
      geo.setIndex(new THREE.BufferAttribute(new Uint32Array(capI), 1).setUsage(THREE.DynamicDrawUsage));
      geo.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 0.8, 0), 3);
      slime.geometry = geo;
    }
    for (const [n, src] of [['position', buf.pos], ['normal', buf.nor], ['color', buf.col]] as const) {
      const a = geo.getAttribute(n) as THREE.BufferAttribute;
      (a.array as Float32Array).set(src.subarray(0, buf.nv * 3));
      a.clearUpdateRanges();
      a.addUpdateRange(0, buf.nv * 3);
      a.needsUpdate = true;
    }
    const ix = geo.index!;
    (ix.array as Uint32Array).set(buf.idx.subarray(0, buf.ni));
    ix.clearUpdateRanges();
    ix.addUpdateRange(0, buf.ni);
    ix.needsUpdate = true;
    geo.setDrawRange(0, buf.ni);
    lastMs = performance.now() - t0;
  };
  return st.out({
    update(t) {
      place(t);
      poly();
      // 얼굴은 머리 공 앞면에
      const hb = balls[1]!;
      let fz = 1.7;
      for (; fz > 0; fz -= 0.01) {
        let v = 0;
        for (const b of balls) {
          const q = ((hb.x - b.x) ** 2 + ((hb.y - b.y) / b.sy) ** 2 + (fz - b.z) ** 2) / (b.R * b.R);
          if (q < 1) v += (1 - q) ** 3;
        }
        if (v >= S.iso) break;
      }
      face.position.set(hb.x, hb.y, fz + 0.005);
      const blink = (t % 3.7) > 3.55 ? 0.15 : 1;
      for (const e of eyes) e.scale.y = 0.115 * blink;
      const n = balls.length;
      dots.count = halos.count = n;
      balls.forEach((b, i) => {
        pv.set(b.x, b.y, b.z);
        dots.setMatrixAt(i, m4.compose(pv, qv, sv.setScalar(0.035)));
        halos.setMatrixAt(i, m4.compose(pv, qv, sv.set(b.R, b.R * b.sy, b.R)));
      });
      dots.instanceMatrix.needsUpdate = halos.instanceMatrix.needsUpdate = true;
      dots.visible = halos.visible = S.centers;
      st.spin.rotation.y = Math.sin(t * 0.3) * 0.5;
      st.hud.tl.set(`▲ 삼각형 ${fmt(st.tris())}`);
      st.hud.bl.set(`공 ${n}개가 녹아 붙는 면 · 격자 ${S.res} · 매 장면 ${lastMs.toFixed(1)}ms`);
    },
    controls: [
      { type: 'range', label: '튀어나가는 방울 수', min: 0, max: 6, step: 1, value: 4, on: (v) => (S.drops = v) },
      { type: 'range', label: '끈적임 (면 높이 iso)', min: 0.12, max: 0.55, step: 0.01, value: 0.3, on: (v) => (S.iso = v) },
      { type: 'range', label: '마칭 큐브 격자 (해상도)', min: 16, max: 64, step: 2, value: 30, on: (v) => ((S.res = v), setRes()) },
      { type: 'toggle', label: '공 중심 · 영향 범위 보기', value: false, on: (v) => (S.centers = v) },
      { type: 'toggle', label: '와이어프레임', value: false, on: (v) => (st.wire.on = v) },
      { type: 'button', label: '새로 만들기 (색 · 방향)', on: () => ((S.seed += 1), reseed()) },
    ],
  });
}
```

### i447 견본 항목 — `src/demos/demosModelA.ts:2743`
```ts
  i447: { kind: '3d', caption: '공들이 가까우면 녹아 붙는 면 — 방울이 튀어나갔다 기어 돌아와 색이 섞이며 합쳐지는 슬라임', make: demoMeta }
```

## 관련 기술
- 같은 방법으로 만든 효과 예시: [SDF 메타볼 (물방울 · 슬라임)](https://ai-techstudio.web.app/ai/t/i29.md) `i29`
- 먼저 알면 좋은 기술: [SDF 조각 모델링 (부드럽게 합치기)](https://ai-techstudio.web.app/ai/t/i446.md) `i446`
- 다음에 해 볼 기술: [서브디비전 (각진 모양 → 매끈하게)](https://ai-techstudio.web.app/ai/t/i448.md) `i448`
- 참고 문서: [three.js 예제 — webgl_marchingcubes](https://threejs.org/examples/#webgl_marchingcubes) · [Wikipedia — Marching cubes](https://en.wikipedia.org/wiki/Marching_cubes)
