# AI 꾸러미 — SDF 조각 모델링 (부드럽게 합치기) — SDF (signed distance field)
> 구 · 캡슐 · 타원체의 거리 함수를 부드럽게 녹여 붙인 뒤 메시로 바꿔, 점토로 빚은 듯한 캐릭터를 코드로 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i446

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

## 주문서

### 만들어 줘: SDF 조각 모델링 (부드럽게 합치기) — SDF (signed distance field)

#### 1. 목표
점토 곰돌이 캐릭터를 SDF 조각 모델링으로 만들어 줘 — 기본 도형 거리 함수를 smooth union 으로 붙이고 마칭 큐브로 메시. 느낌은 말랑한 점토 인형.

#### 2. 핵심 기술 용어
- **SDF (signed distance field)** — 부호 있는 거리장 — 표면까지 거리, 안쪽은 음수
- **Smooth union (polynomial smin)** — 부드럽게 합치기 — 이음매가 둥글게 녹아 붙음
- **Marching cubes** — 격자 값에서 표면 삼각형을 뽑는 방법
- **SDF gradient normals** — 거리장 기울기로 구한 매끈한 법선

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

#### 4. 조건
- 굽기는 한 프레임에 몰지 말고 프레임당 1~3ms 예산으로 나눠서 (제너레이터 · yield), 그동안 「빚는 중」 표시
- 법선은 거리장 중심 차분으로 계산 (computeVertexNormals 의 각진 면 금지)
- 부위마다 k(녹는 정도)를 따로 — 귀 · 코는 작게, 머리-몸은 크게
- 외부 라이브러리 없이 (three/examples 의 MarchingCubes 는 사용 가능)

#### 5. 완성 기준 (이게 보이면 성공)
- 구 · 캡슐 · 타원체가 하나씩 녹아 붙으며 곰돌이 모양이 완성되는 과정이 보인다
- 이음매에 각진 선이 없고, 「녹는 정도 k」 슬라이더를 올리면 이음매가 두툼해진다
- 해상도 슬라이더를 바꿔도 화면이 멈추지 않는다 (나눠 굽기)
- 굽는 동안에도 60fps 유지

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

## 원리
- SDF 는 「이 점에서 표면까지 거리」를 돌려주는 함수다. 구는 |p − c| − r 한 줄.
- 두 모양의 합은 min(a, b) — 이음매가 각지다. smin(a, b, k) 은 k 만큼 둥글게 녹여 붙인다.
- 공간을 격자로 나눠 각 점의 거리를 재고, 마칭 큐브가 거리 0 인 곳을 삼각형으로 잇는다.
- 법선은 거리장의 기울기(조금씩 옮겨 잰 차이)로 구하면 계단 없이 매끈하다.
- 색도 smin 의 섞기 비율로 같이 섞으면 부위 사이 색이 자연스럽게 번진다.

## 핵심 코드 — 거리 함수 + smooth union + 마칭 큐브
(발췌: demos/demosModelA.ts sdf() · primD() 의 식을 three/examples MarchingCubes 로 짧게 다시 씀)
```ts
import { MarchingCubes } from 'three/examples/jsm/objects/MarchingCubes.js';

type V = [number, number, number];
const sphere = (p: V, c: V, r: number) => Math.hypot(p[0] - c[0], p[1] - c[1], p[2] - c[2]) - r;
const capsule = (p: V, a: V, b: V, r: number) => {
  const ba = [b[0] - a[0], b[1] - a[1], b[2] - a[2]], pa = [p[0] - a[0], p[1] - a[1], p[2] - a[2]];
  const h = Math.max(0, Math.min(1, (pa[0] * ba[0] + pa[1] * ba[1] + pa[2] * ba[2]) / (ba[0] ** 2 + ba[1] ** 2 + ba[2] ** 2)));
  return Math.hypot(pa[0] - ba[0] * h, pa[1] - ba[1] * h, pa[2] - ba[2] * h) - r;
};
// 부드럽게 합치기 — k 가 클수록 이음매가 두툼하게 녹는다
const smin = (a: number, b: number, k: number) => {
  const h = Math.max(0, Math.min(1, 0.5 + (0.5 * (b - a)) / k));
  return b + (a - b) * h - k * h * (1 - h);
};
const bear = (p: V) => {
  let d = sphere(p, [0, -0.35, 0], 0.42);                     // 몸
  d = smin(d, sphere(p, [0, 0.3, 0], 0.34), 0.16);           // 머리
  d = smin(d, sphere(p, [-0.26, 0.6, 0], 0.11), 0.09);       // 귀
  d = smin(d, sphere(p, [0.26, 0.6, 0], 0.11), 0.09);
  d = smin(d, capsule(p, [-0.34, -0.2, 0.05], [-0.5, -0.45, 0.15], 0.1), 0.1); // 팔
  return smin(d, capsule(p, [0.34, -0.2, 0.05], [0.5, -0.45, 0.15], 0.1), 0.1);
};

const N = 64;
const mc = new MarchingCubes(N, new THREE.MeshPhysicalMaterial({ color: 0xf0a35e, roughness: 0.55, sheen: 0.4 }), false, false, 120000);
mc.isolation = 0; // 값 > 0 = 안쪽
for (let z = 0; z < N; z++) for (let y = 0; y < N; y++) for (let x = 0; x < N; x++)
  mc.field[x + y * N + z * N * N] = -bear([(x * 2) / N - 1, (y * 2) / N - 1, (z * 2) / N - 1]); // 안쪽 좌표 -1..1
mc.update();
scene.add(mc); // 크기는 mc.scale 로
```

## 흔한 실수 · 확인 목록
- [ ] **한 번에 다 구우면 화면이 수백 ms 멈춘다** — 이 사이트 견본은 z 층마다 yield 하는 제너레이터로 프레임당 1~3ms 만 굽고, 그동안 이전 해상도 메시를 보여 준다.
- [ ] **k 를 너무 크게 하면 작은 부위(귀 · 코)가 녹아 사라진다** — 부위 크기의 절반 이하로. 부위마다 k 를 따로 둔다.
- [ ] **각진 법선 (computeVertexNormals) 은 점토 느낌을 망친다** — 거리장을 ±e 만큼 옮겨 잰 차이(중심 차분)로 법선을 만든다.
- [ ] **격자 밖으로 나간 부위는 잘린다** — 모델 전체가 격자 안쪽(테두리 한 칸 안)에 들어오게 범위를 잡는다.
- [ ] **타원체 거리는 정확한 거리가 아니다** — 근사식((k0·(k0−1))/k1)을 쓰고, 매우 납작한 타원은 피한다.

## 완성 기준 체크리스트
- [ ] 구 · 캡슐 · 타원체가 하나씩 녹아 붙으며 곰돌이 모양이 완성되는 과정이 보인다
- [ ] 이음매에 각진 선이 없고, 「녹는 정도 k」 슬라이더를 올리면 이음매가 두툼해진다
- [ ] 해상도 슬라이더를 바꿔도 화면이 멈추지 않는다 (나눠 굽기)
- [ ] 굽는 동안에도 60fps 유지

## 이 기술 정보
- id: `i446` · 분류: 3D 모델 · 캐릭터 › 3D 모델링 · 절차 · 3D · 난이도 어려움 · 폰 부담 무거움 — 격자 해상도의 세제곱만큼 계산한다 (64³ = 26만 점 × 부위 수). 한 번 굽고 나면 보통 메시라 가볍다 — 굽기는 프레임마다 1~3ms 씩 나눠서.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i446
- 쓰면 좋을 때: 블렌더 없이 귀여운 캐릭터 · 생물을 코드로 만들 때 / 부위 크기를 슬라이더로 바꾸는 캐릭터 만들기 화면
- 쓰지 말 때: 각진 하드서피스(기계 · 건물) — 박스 모델링이 낫다 / 매 프레임 모양이 바뀌는 큰 물체 — 굽는 비용이 크다 (메타볼 셰이더를 고려)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### sdf — `src/demos/demosModelA.ts:1347`
```ts
function sdf(P: Float64Array, x: number, y: number, z: number, col: number[] | null): number {
  const n = P.length / PSTRIDE;
  let d = primD(P, 0, x, y, z);
  if (col) [col[0], col[1], col[2]] = [P[9]!, P[10]!, P[11]!];
  for (let i = 1; i < n; i++) {
    const o = i * PSTRIDE;
    const di = primD(P, o, x, y, z);
    const k = P[o + 8]!;
    let hh: number;
    if (k < 1e-4) {
      hh = di < d ? 0 : 1;
      d = Math.min(d, di);
    } else {
      hh = clamp(0.5 + (0.5 * (di - d)) / k, 0, 1);
      d = di + (d - di) * hh - k * hh * (1 - hh);
    }
    if (col) for (let c = 0; c < 3; c++) col[c] = P[o + 9 + c]! + (col[c]! - P[o + 9 + c]!) * hh;
  }
  return d;
}
```

### primD — `src/demos/demosModelA.ts:1330`
```ts
function primD(P: Float64Array, o: number, x: number, y: number, z: number): number {
  const ax = P[o + 1]!, ay = P[o + 2]!, az = P[o + 3]!;
  const k = P[o]!;
  if (k === 0) return Math.hypot(x - ax, y - ay, z - az) - P[o + 7]!;
  if (k === 1) {
    const rx = P[o + 4]!, ry = P[o + 5]!, rz = P[o + 6]!;
    const px = (x - ax) / rx, py = (y - ay) / ry, pz = (z - az) / rz;
    const k0 = Math.hypot(px, py, pz);
    const k1 = Math.hypot(px / rx, py / ry, pz / rz) || 1e-9;
    return (k0 * (k0 - 1)) / k1;
  }
  const bx = P[o + 4]! - ax, by = P[o + 5]! - ay, bz = P[o + 6]! - az;
  const px = x - ax, py = y - ay, pz = z - az;
  const hh = clamp((px * bx + py * by + pz * bz) / (bx * bx + by * by + bz * bz), 0, 1);
  return Math.hypot(px - bx * hh, py - by * hh, pz - bz * hh) - P[o + 7]!;
}
```

### demoSdf — `src/demos/demosModelA.ts:1376`
```ts
function demoSdf(): Scene3D {
  const st = new Stage({ cam: [0, 1.5, 5.6], look: [0, 1.05, 0], island: 1.5 });
  const mat = new THREE.MeshPhysicalMaterial({ vertexColors: true, roughness: 0.55, clearcoat: 0.25, clearcoatRoughness: 0.5, sheen: 0.4, sheenColor: new THREE.Color(0xffe6cc), polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1 });
  const body = st.model(new THREE.Mesh(new THREE.BufferGeometry(), mat));
  const char = new THREE.Group();
  char.add(body);
  st.spin.add(char);
  const S = { res: 30, kmul: 1, auto: true, step: BEAR.length, ghosts: false };
  const SUB = 4;
  const nFrames = 1 + (BEAR.length - 1) * SUB;
  const frameSpec = (f: number): [number, number] => (f === 0 ? [1, 0] : [2 + Math.floor((f - 1) / SUB), 1 - ((f - 1) % SUB) / (SUB - 1)]);
  let frames: (THREE.BufferGeometry | null)[] = [];
  const buf = new MCBuf();
  let cache = new Int32Array(1);
  const tmp = [0, 0, 0];
  const cc = new THREE.Color();
  /** 장면 하나 굽기 — z 층마다 쉬어 가는 생성기 (한 프레임 예산 안에서만 돌린다) */
  function* buildGen(f: number, res: number, kmul: number): Generator<void, THREE.BufferGeometry, void> {
    const [n, off] = frameSpec(f);
    const P = packParts(BEAR, n, off, kmul);
    const h = 2.3 / res;
    const G: Grid = { nx: res + 1, ny: Math.ceil(2.75 / h) + 1, nz: Math.ceil(1.95 / h) + 1, ox: -1.15, oy: -0.12, oz: -0.95, h };
    const N = G.nx * G.ny * G.nz;
    const fld = new Float32Array(N);
    for (let z = 0, i = 0; z < G.nz; z++) {
      if (z) yield;
      for (let y = 0; y < G.ny; y++) for (let x = 0; x < G.nx; x++, i++) fld[i] = -sdf(P, G.ox + x * h, G.oy + y * h, G.oz + z * h, null);
    }
    if (cache.length < N * 3) {
      yield;
      cache = new Int32Array(N * 3);
      yield;
    }
    const e = h * 0.35;
    yield* polygonizeGen(fld, G, 0, buf, cache, (x, y, z, i) => {
      const gx = sdf(P, x + e, y, z, null) - sdf(P, x - e, y, z, null);
      const gy = sdf(P, x, y + e, z, null) - sdf(P, x, y - e, z, null);
      const gz = sdf(P, x, y, z + e, null) - sdf(P, x, y, z - e, null);
      const l = Math.hypot(gx, gy, gz) || 1;
      buf.nor.set([gx / l, gy / l, gz / l], i * 3);
      sdf(P, x, y, z, tmp);
      for (const [px, py, pz, r, c, need] of PATCH) {
        if (n <= need) continue;
        const w = 1 - ease(clamp((Math.hypot(x - px, y - py, z - pz) - r * 0.6) / (r * 0.6), 0, 1));
        if (w <= 0) continue;
        cc.set(c);
        tmp[0] = lerp(tmp[0]!, cc.r, w);
        tmp[1] = lerp(tmp[1]!, cc.g, w);
        tmp[2] = lerp(tmp[2]!, cc.b, w);
      }
      buf.col.set(tmp, i * 3);
    });
    return mcGeometry(buf);
  }
  let prev: (THREE.BufferGeometry | null)[] = [];
  let job: { f: number; it: Generator<void, THREE.BufferGeometry, void> } | null = null;
  let bakedMs = 0;
  const BUDGET = 1.0;
  /** 필요한 장면부터, 그다음은 차례로 미리 — 예산(ms)이 다 되면 멈춘다 */
  const pump = (want: number): void => {
    const t0 = performance.now();
    while (performance.now() - t0 < BUDGET) {
      if (!job) {
        let f = -1;
        for (let k = 0; k < nFrames; k++) {
          const c = (want + k) % nFrames;
          if (!frames[c]) {
            f = c;
            break;
          }
        }
        if (f < 0) {
          for (const g of prev) g?.dispose();
          prev = [];
          return;
        }
        job = { f, it: buildGen(f, S.res, S.kmul) };
      }
      const r = job.it.next();
      if (r.done) {
        frames[job.f] = r.value;
        job = null;
      }
    }
    bakedMs = Math.max(bakedMs, performance.now() - t0);
  };
  /** 준비된 것 중 가장 가까운 앞 장면 (없으면 예전 해상도 장면) */
  const ready = (f: number): THREE.BufferGeometry | null => {
    if (frames[f]) return frames[f]!;
    if (prev[f]) return prev[f]!;
    for (let k = f; k >= 0; k--) if (frames[k]) return frames[k]!;
    for (let k = f; k >= 0; k--) if (prev[k]) return prev[k]!;
    return null;
  };
  const reset = (first = false): void => {
    for (const g of prev) g?.dispose();
    prev = first ? [] : frames;
    frames = new Array<THREE.BufferGeometry | null>(nFrames).fill(null);
    job = null;
    if (first) {
      const it = buildGen(0, S.res, S.kmul);
      let r = it.next();
      while (!r.done) r = it.next();
      frames[0] = r.value;
    }
  };
  reset(true);
  // 얼굴 (따로 만든 반짝이는 눈 · 코 · 입) — 마지막 SDF 면 위에 광선으로 붙인다
  const Pfull = packParts(BEAR, BEAR.length, 0, 1);
  const onSurface = (x: number, y: number): V3 => {
    let z = 1.2;
    for (let i = 0; i < 64; i++) {
      const d = sdf(Pfull, x, y, z, null);
      if (d < 1e-4) break;
      z -= d;
    }
    return new THREE.Vector3(x, y, z);
  };
  const face = new THREE.Group();
  const black = new THREE.MeshPhysicalMaterial({ color: 0x1b1410, roughness: 0.15, clearcoat: 1 });
  const white = new THREE.MeshBasicMaterial({ color: 0xffffff });
  for (const s of [-1, 1]) {
    const p = onSurface(s * 0.2, 1.62);
    const eye = new THREE.Mesh(new THREE.SphereGeometry(1, 20, 14), black);
    eye.scale.set(0.068, 0.088, 0.045);
    eye.position.copy(p);
    const hl = new THREE.Mesh(new THREE.SphereGeometry(0.018, 10, 8), white);
    hl.position.copy(p).add(new THREE.Vector3(0.02, 0.03, 0.04));
    face.add(eye, hl);
  }
  const nose = new THREE.Mesh(new THREE.SphereGeometry(1, 16, 12), black);
  nose.scale.set(0.075, 0.05, 0.05);
  nose.position.copy(onSurface(0, 1.47));
  const mouth = new THREE.Mesh(new THREE.TubeGeometry(new THREE.CatmullRomCurve3([-0.07, -0.035, 0, 0.035, 0.07].map((x) => onSurface(x, 1.36 - 0.03 * Math.cos(x * 22)).add(new THREE.Vector3(0, 0, 0.008)))), 16, 0.008, 6), black);
  face.add(nose, mouth);
  char.add(face);
  // 기본 도형 보기 (반투명 선)
  const ghosts = new THREE.Group();
  for (const p of BEAR) {
    const gm = new THREE.MeshBasicMaterial({ color: 0x8fe3ff, wireframe: true, transparent: true, opacity: 0.35, depthWrite: false, toneMapped: false });
    let m: THREE.Mesh;
    if (p.kind === 2) {
      const a = new THREE.Vector3(...p.a);
      const b = new THREE.Vector3(...p.b);
      m = new THREE.Mesh(new THREE.CapsuleGeometry(p.r, a.distanceTo(b), 6, 12), gm);
      m.position.copy(a).add(b).multiplyScalar(0.5);
      m.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), b.clone().sub(a).normalize());
    } else {
      m = new THREE.Mesh(new THREE.SphereGeometry(1, 16, 10), gm);
      m.position.set(...p.a);
      if (p.kind === 0) m.scale.setScalar(p.r);
      else m.scale.set(...p.b);
    }
    ghosts.add(m);
  }
  char.add(ghosts);
  return st.out({
    update(t) {
      let f: number;
      let faceK = 1;
      let bob = 0;
      if (S.auto) {
        const k = t % 9;
        f = Math.min(nFrames - 1, Math.floor(k / 0.17));
        faceK = easeBack((k - nFrames * 0.17 - 0.1) / 0.45);
        bob = k > nFrames * 0.17 + 0.6 ? Math.sin((k - nFrames * 0.17) * 5) * 0.025 : 0;
      } else {
        f = S.step <= 1 ? 0 : 1 + (S.step - 2) * SUB + SUB - 1;
        faceK = S.step >= BEAR.length ? 1 : 0;
      }
      pump(f);
      const g = ready(f);
      if (g) body.geometry = g;
      for (const c of face.children) {
        const b0 = c.userData['s'] as V3 | undefined;
        if (!b0) c.userData['s'] = c.scale.clone();
        else c.scale.copy(b0).multiplyScalar(Math.max(1e-3, faceK));
      }
      char.scale.set(1 - bob, 1 + bob, 1 - bob);
      ghosts.visible = S.ghosts;
      st.spin.rotation.y = Math.sin(t * 0.45) * 0.55;
      const [n] = frameSpec(f);
      st.hud.tl.set(`▲ 삼각형 ${fmt(st.tris())}`);
      st.hud.bl.set(`부품 ${n} / ${BEAR.length} 녹여 붙이기 · 격자 ${S.res} · 구운 장면 ${frames.filter(Boolean).length}/${nFrames}`);
    },
    controls: [
      { type: 'toggle', label: '자동 재생 (하나씩 붙이기)', value: true, on: (v) => (S.auto = v) },
      { type: 'range', label: '붙인 부품 수', min: 1, max: BEAR.length, step: 1, value: BEAR.length, on: (v) => ((S.step = v), (S.auto = false)) },
      { type: 'range', label: '부드럽게 합치기 k (0 = 딱딱하게)', min: 0, max: 2, step: 0.1, value: 1, on: (v) => ((S.kmul = v), reset()) },
      { type: 'range', label: '마칭 큐브 격자 (해상도)', min: 20, max: 72, step: 2, value: 30, on: (v) => ((S.res = v), reset()) },
      { type: 'toggle', label: '기본 도형 보기 (구 · 캡슐)', value: false, on: (v) => (S.ghosts = v) },
      { type: 'toggle', label: '와이어프레임', value: false, on: (v) => (st.wire.on = v) },
    ],
    dispose() {
      for (const g of frames) g?.dispose();
      for (const g of prev) g?.dispose();
    },
  });
}
```

## 관련 기술
- 같은 방법으로 만든 효과 예시: [SDF 손 모델 굽기 (Surface Nets)](https://ai-techstudio.web.app/ai/t/u30.md) `u30`
- 먼저 알면 좋은 기술: [메타볼 몸 (말랑한 덩어리)](https://ai-techstudio.web.app/ai/t/i447.md) `i447` · [SDF 손 모델 굽기 (Surface Nets)](https://ai-techstudio.web.app/ai/t/u30.md) `u30`
- 다음에 해 볼 기술: [코드로 뼈대 만들기 (Bone · SkinnedMesh)](https://ai-techstudio.web.app/ai/t/i456.md) `i456` · [자동 스킨 가중치 (뼈까지 거리)](https://ai-techstudio.web.app/ai/t/i457.md) `i457`
- 참고 문서: [Inigo Quilez — smooth minimum](https://iquilezles.org/articles/smin/) · [Inigo Quilez — distance functions](https://iquilezles.org/articles/distfunctions/) · [three.js 예제 — webgl_marchingcubes](https://threejs.org/examples/#webgl_marchingcubes)
