# AI 꾸러미 — 점토 캐릭터 걷게 하기

## 주문서

### 한 장면 만들기: 점토 캐릭터 걷게 하기

#### 1. 목표
점토 곰돌이를 블렌더 · 애니메이션 파일 없이 코드만으로 만들어 줘 — 점토처럼 빚은 몸에 뼈를 심고, 걷고, 귀가 출렁이고, 표정이 바뀌게. 느낌은 말랑한 점토 인형.
아래 기술 6개를 한 장면에 엮어 줘. 한 번에 다 만들지 말고 1번부터 차례로 쌓되, 단계마다 화면에서 확인할 수 있게.

#### 2. 들어갈 기술 (쌓는 순서)
##### 1) SDF 조각 모델링 (부드럽게 합치기) — SDF (signed distance field)
- 역할: 몸 모양 — 기본 도형을 녹여 붙인 점토 메시
- 핵심: SDF 는 「이 점에서 표면까지 거리」를 돌려주는 함수다. 구는 |p − c| − r 한 줄.
- 확인: 구 · 캡슐 · 타원체가 하나씩 녹아 붙으며 곰돌이 모양이 완성되는 과정이 보인다
- 확인: 이음매에 각진 선이 없고, 「녹는 정도 k」 슬라이더를 올리면 이음매가 두툼해진다
##### 2) 코드로 뼈대 만들기 (Bone · SkinnedMesh) — Skeletal rigging (Bone · Skeleton · SkinnedMesh)
- 역할: 뼈 계층을 코드로 세우고 SkinnedMesh 로 묶기
- 핵심: 뼈마다 이름 · 부모 · 머리(시작) · 꼬리(끝) 위치를 표로 적는다 (견본 16개: 뿌리 · 골반 · 척추 · 가슴 · 목 · 머리 · 팔 4 · 다리 6).
- 확인: 뼈가 뿌리부터 하나씩 자라나 뼈대가 서고, 그 위에 살이 입혀진다
- 확인: 뼈를 돌리면(인사하는 왼팔 · 흔드는 머리) 살이 함께 굽는다
##### 3) 자동 스킨 가중치 (뼈까지 거리) — Automatic skin weights (distance-based)
- 역할: 정점마다 가까운 뼈로 자동 가중치
- 핵심: 정점에서 각 뼈 막대(머리 ~ 꼬리 선분)까지 거리를 재고, 그 뼈의 살 두께(r × 0.85)를 뺀다 (+0.025 로 0 나누기 막기).
- 확인: 왼쪽 「가까운 뼈 하나만」은 팔꿈치 · 무릎에서 살이 꺾여 찌그러지고, 오른쪽 「여러 뼈 + 부드럽게」는 둥글게 굽는다
- 확인: 뼈를 고르면 그 뼈 가중치가 빨강(1) → 파랑(0) 열 지도로 칠해지고, 관절 둘레가 부드럽게 넘어간다
##### 4) 절차 걷기 · 뛰기 (애니메이션 파일 없이) — Procedural locomotion (gait cycle)
- 역할: 걸음 주기 · 골반 오르내림 · 팔 흔들기를 식으로
- 핵심: 걸음 주기 phase 를 0 → 1 로 돌린다: phase += (속도 ÷ 한 주기 길이 Lc) × dt. 오른발은 반 주기 뒤.
- 확인: 가만히(숨쉬기) → 걷기 → 뛰기 → 걷기 → 가만히가 14초마다 끊김 없이 이어진다
- 확인: 뛸 때 걸음이 넓어지고 박자가 빨라지며, 몸이 숙고, 팔꿈치가 굽어 크게 흔들린다
##### 5) 흔들리는 뼈 (귀 · 머리카락 · 꼬리 2차 움직임) — Jiggle bones (secondary motion)
- 역할: 귀 · 꼬리의 늦게 따라오는 2차 움직임
- 핵심: 뼈마다 쉬는 자세에서의 끝점(tipL, 뼈 기준)을 기억해 두고, 매 프레임 그 끝점의 세계 위치 r 를 구한다 — 「있어야 할 곳」.
- 확인: 두 토끼가 똑같이 폴짝 뛰는데, 오른쪽만 멈출 때 귀 · 머리카락 · 꼬리가 늦게 따라와 출렁인다
- 확인: 「단단함」을 낮추면 흐물흐물 크게, 올리면 짧게 떨린다
##### 6) 표정 모프 (눈 감기 · 웃음 · 놀람) — Morph targets (blend shapes · shape keys)
- 역할: 얼굴 모프로 표정
- 핵심: 모든 모양(기본 + 변형 6)은 정점 수와 순서가 같아야 한다 — 같은 정점이 다른 자리로 갈 뿐이다.
- 확인: 기본 → 웃음 → 놀람 → 화남 → 말하기 → 웃으며 말하기가 2.2초마다 부드럽게 바뀐다
- 확인: 말할 때 음절마다 입이 아 · 오로 열렸다 닫힌다

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

#### 4. 조건 (공통)
- SDF 메시 굽기는 프레임당 1~3ms 로 나눠서 (빚는 중 화면)
- 뼈는 코드로 세운 계층(골반 → 척추 → 머리 · 팔 · 다리 · 귀)
- 걷기는 속도에 따라 걸음 폭 · 박자가 자동으로 바뀌게 (발이 미끄러지지 않게)
- 굽기는 한 프레임에 몰지 말고 프레임당 1~3ms 예산으로 나눠서 (제너레이터 · yield), 그동안 「빚는 중」 표시
- 법선은 거리장 중심 차분으로 계산 (computeVertexNormals 의 각진 면 금지)
- 뼈 위치는 부모 기준 (자기 머리 − 부모 머리) — 세계 좌표를 그대로 넣으면 뼈가 두 배로 밀려난다
- bind 전에 mesh.updateMatrixWorld(true) — 안 하면 쉬는 자세가 틀어진다
- 거리는 뼈 「점」이 아니라 뼈 「선분」까지, 살 두께를 빼고
- 평균 내기는 3D 거리 이웃이 아니라 표면 이웃(삼각형 연결)으로 — 붙어 있는 다리끼리 가중치가 새지 않게
- 속도는 바로 바꾸지 말고 부드럽게 따라가기 (damp — 시간 상수 0.6초)
- 걸음 폭 · 디딤 비율 · 숙임 · 팔 흔들기를 모두 속도 함수로 — 걷기 ↔ 뛰기 사이는 sstep 으로 섞기
- 몸 자세(애니메이션 · 절차)를 먼저 정하고 matrixWorld 갱신 뒤에 흔들림을 계산한다
- 흔들 뼈는 매 프레임 쉬는 회전으로 되돌린 뒤 계산한다 (안 하면 회전이 쌓여 돌아간다)
- 모든 모프 모양은 기본 모양과 정점 수 · 순서가 같게 (같은 함수에서 뽑기)

#### 5. 완성 기준 (전체)
- 점토 곰이 빚어지는 과정이 보이고, 다 빚어지면 뼈 보기로 뼈 계층이 보인다
- 관절이 접혀도 팔꿈치 · 무릎이 찌그러지지 않는다 (가중치 색 보기로 확인)
- 걷기 속도 슬라이더로 걷기 → 뛰기가 자연스럽게 바뀐다
- 멈추면 귀가 몇 번 출렁이다 멈추고, 단추로 눈 감기 · 웃음 · 놀람 표정이 섞인다
- 기술마다 켬/끔 단추가 있어 하나씩 꺼 보며 그 기술이 맡은 차이를 확인할 수 있다
- 콘솔 오류 0 · 폰에서도 끊기지 않는다

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

## 참고 1. SDF 조각 모델링 (부드럽게 합치기) — 거리 함수 + smooth union + 마칭 큐브
```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)을 쓰고, 매우 납작한 타원은 피한다.

## 참고 2. 코드로 뼈대 만들기 (Bone · SkinnedMesh) — 뼈 표 → Bone 계층 → SkinnedMesh 묶기
```ts
type P3 = [number, number, number];
const BONES: { name: string; parent: number; head: P3 }[] = [
  { name: 'root', parent: -1, head: [0, 0, 0] },
  { name: 'hips', parent: 0, head: [0, 0.8, 0] },
  { name: 'spine', parent: 1, head: [0, 0.98, 0] },
  { name: 'chest', parent: 2, head: [0, 1.14, 0] },
  { name: 'neck', parent: 3, head: [0, 1.3, 0] },
  { name: 'head', parent: 4, head: [0, 1.42, 0] },
  { name: 'upperArm.L', parent: 3, head: [0.24, 1.18, 0] },
  // ... foreArm · thigh · shin · foot (양쪽)
];

// geo 에는 position · normal 과 함께 skinIndex(Uint16, 4) · skinWeight(Float32, 4) 가 있어야 한다
const mesh = new THREE.SkinnedMesh(geo, mat);
mesh.frustumCulled = false;
const bones: THREE.Bone[] = [];
BONES.forEach((d, i) => {
  const bn = new THREE.Bone();
  bn.name = d.name;
  const ph = d.parent >= 0 ? BONES[d.parent]!.head : [0, 0, 0];
  bn.position.set(d.head[0] - ph[0], d.head[1] - ph[1], d.head[2] - ph[2]); // 부모 기준!
  if (d.parent >= 0) bones[d.parent]!.add(bn);
  bones[i] = bn;
});
mesh.add(bones[0]!);
mesh.updateMatrixWorld(true);       // 이 자세가 쉬는 자세
mesh.bind(new THREE.Skeleton(bones));
scene.add(mesh, new THREE.SkeletonHelper(mesh)); // 뼈 보기

// 움직이기: 뼈 회전만 바꾼다
const B = (n: string) => bones.find((b) => b.name === n)!;
B('upperArm.L').quaternion.setFromAxisAngle(new THREE.Vector3(0, 0, 1), 1.75); // 팔 들어 인사
// 소품은 뼈에: B('head').add(eyeGroup);
```
- [ ] **뼈 위치에 세계 좌표를 넣으면 자식 뼈가 멀리 날아간다** — 부모 머리 위치를 빼서 부모 기준으로 넣는다.
- [ ] **bind 전에 행렬을 갱신하지 않으면 살이 엉뚱하게 비틀린다** — mesh.add(뿌리) → mesh.updateMatrixWorld(true) → mesh.bind(skeleton) 순서를 지킨다.
- [ ] **캐릭터가 화면 가장자리에서 갑자기 사라진다** — SkinnedMesh 의 경계 상자는 쉬는 자세 기준이다. frustumCulled = false 로 둔다.
- [ ] **가중치 합이 1이 아니면 살이 줄거나 부푼다** — 네 칸 가중치를 합으로 나눠 1로 맞춘다.

## 참고 3. 자동 스킨 가중치 (뼈까지 거리) — 거리⁻⁴ 가중치 → 표면 평균 10번 → 큰 넷만
```ts
// 6) 거리 가중치 (뼈 0 = 뿌리는 제외)
const full = new Float32Array(n * NB);
for (let i = 0; i < n; i++) {
  p.fromArray(pos, i * 3);
  let sum = 0;
  for (let b = 1; b < NB; b++) {
    const B = BONES[b]!;
    const d = Math.max(segDist(p, B.head, B.tail) - B.r * 0.85, 0) + 0.025; // 선분까지 − 살 두께
    const w = 1 / (d * d * d * d);
    full[i * NB + b] = w;
    sum += w;
  }
  for (let b = 1; b < NB; b++) full[i * NB + b] /= sum;
}
// 7) 표면 이웃과 평균 10번 (nbr · deg = 정점별 이웃 목록 CSR)
let cur = full, next = new Float32Array(n * NB);
for (let it = 0; it < 10; it++) {
  for (let i = 0; i < n; i++) {
    const s0 = deg[i], s1 = deg[i + 1], inv = s1 > s0 ? 0.6 / (s1 - s0) : 0;
    for (let b = 1; b < NB; b++) {
      let s = 0;
      for (let k = s0; k < s1; k++) s += cur[nbr[k] * NB + b];
      next[i * NB + b] = s1 > s0 ? cur[i * NB + b] * 0.4 + s * inv : cur[i * NB + b];
    }
  }
  [cur, next] = [next, cur];
}
// 8) 큰 넷만, 합 1
const si = new Uint16Array(n * 4), sw = new Float32Array(n * 4);
for (let i = 0; i < n; i++) {
  const top = [...Array(NB - 1).keys()].map((k) => k + 1).sort((a, b) => cur[i * NB + b] - cur[i * NB + a]).slice(0, 4);
  const s = top.reduce((acc, b) => acc + cur[i * NB + b], 0);
  top.forEach((b, k) => { si[i * 4 + k] = b; sw[i * 4 + k] = s > 0 ? cur[i * NB + b] / s : 0; });
}
geo.setAttribute('skinIndex', new THREE.BufferAttribute(si, 4));
geo.setAttribute('skinWeight', new THREE.BufferAttribute(sw, 4));
```
- [ ] **3D 거리로만 평균 내면 붙어 있는 두 다리 사이로 가중치가 샌다** — 삼각형으로 이어진 표면 이웃끼리만 평균 낸다.
- [ ] **거리⁻¹ · ⁻² 처럼 약하게 주면 먼 뼈가 살을 당겨 몸이 흐물흐물하다** — 견본은 거리⁻⁴ 로 가까운 뼈를 압도적으로 만든 뒤, 평균 내기로 관절 둘레만 부드럽게 한다.
- [ ] **뼈를 5개 넘게 남기면 GPU 스키닝이 버린다** — 정점당 가장 큰 넷만 남기고 합 1로 다시 나눈다.
- [ ] **수만 정점 굽기를 한 번에 돌리면 시작이 멈춘다** — 정점 128개마다 yield — 프레임당 2ms 예산으로 나눠 굽고, 결과는 캐시해 다른 견본도 같이 쓴다.

## 참고 4. 절차 걷기 · 뛰기 (애니메이션 파일 없이) — 속도 → 걸음 주기 → 발목 목표 → 두 뼈 IK
```ts
cur += (want - cur) * (1 - Math.exp(-dt / 0.6));      // 속도는 부드럽게
const v = cur < 0.01 ? 0 : cur;
const run = sstep(1.3, 2.1, v);                          // 0 걷기 → 1 뛰기
const g = sstep(0.02, 0.35, v);                          // 0 가만히 → 1 움직임
const Lc = lerp(0.3 + 0.5 * Math.min(v, 1) + 0.12 * Math.max(v - 1, 0), 0.8 + 0.3 * v, run); // 한 주기 길이
const D = lerp(0.62, 0.34, run);                         // 디딤 비율
phase = frac(phase + (v / Lc) * dt);
groundTex.offset.y -= (v * dt) / TILE;                   // 땅이 흐름

const mid = 4 * Math.PI * (phase - D / 2);
hips.position.y = 0.8 - (0.012 + 0.02 * Math.min(v, 1) + 0.075 * run) * g
  + lerp(0.022 * Math.min(v, 1) * Math.cos(mid), -0.04 * Math.cos(mid), run) * g;

['L', 'R'].forEach((s, i) => {
  const side = i === 0 ? 1 : -1;
  const p = frac(phase + (i === 0 ? 0 : 0.5));
  const half = (Lc * D) / 2;
  let z: number, lift = 0;
  if (p < D) z = Lc * D * (0.5 - p / D);                 // 디딤: 땅과 같은 속도로 뒤로
  else {
    const u = (p - D) / (1 - D);                          // 들어서 앞으로
    z = -half + 2 * half * (0.5 - 0.5 * Math.cos(Math.PI * u));
    lift = (0.05 + 0.05 * Math.min(v, 1) + 0.07 * run) * Math.sin(Math.PI * u) ** 1.2;
  }
  target.set(side * 0.16, ANKLE_Y + lift * g, z * g);
  char.localToWorld(target);
  twoBoneIK(bone('thigh.' + s), bone('shin.' + s), ANKLE_LOCAL, target, new THREE.Vector3(side * 0.12, 0, 1));
});
// 팔: 다리와 반대로 — upperArm.L = qX(armA * cos(2π·phase)), 뛸 땐 팔꿈치 0.22 → 1.5 rad
```
- [ ] **디딤 중 발이 뒤로 가는 속도가 땅과 다르면 발이 스케이트 타듯 미끄러진다** — 한 주기 동안 땅이 가는 거리 = Lc, 디딤 동안 발이 뒤로 가는 거리 = Lc·D 가 되게 박자(v / Lc)를 맞춘다.
- [ ] **속도를 바로 바꾸면 걸음이 툭 끊긴다** — 속도를 damp 로 부드럽게 따라가고, 걷기 ↔ 뛰기 값은 sstep 으로 섞는다.
- [ ] **IK 무릎이 뒤로 꺾인다** — pole 벡터를 캐릭터 앞쪽(+z)으로 준다. 견본은 (±0.12, 0, 1) 을 캐릭터 회전으로 돌려 쓴다.
- [ ] **가만히 있을 때 완전히 멈추면 인형 같다** — 움직임 정도 g 가 0 일 때도 골반 · 가슴을 sin(t × 2.4) 로 아주 조금 숨쉬게 한다.

## 참고 5. 흔들리는 뼈 (귀 · 머리카락 · 꼬리 2차 움직임) — 뼈 끝 베를레 스프링 → 뼈 회전
```ts
const _h = new THREE.Vector3(), _r = new THREE.Vector3(), _d = new THREE.Vector3(), _v = new THREE.Vector3();
const _q = new THREE.Quaternion(), _qw = new THREE.Quaternion(), _qp = new THREE.Quaternion();
type Item = { bone: THREE.Bone; tipL: THREE.Vector3; tip: THREE.Vector3; prev: THREE.Vector3; k: number; init: boolean };

// 먼저: 몸 자세를 정하고, 흔들 뼈는 bone.quaternion.identity() 로 쉬는 회전에, group.updateMatrixWorld(true)
function stepSprings(items: Item[], dt: number, stiff = 0.028, damp = 0.04): void {
  const n = THREE.MathUtils.clamp(Math.round(dt * 120), 1, 8); // 120Hz 고정 간격
  for (const it of items) {
    const bone = it.bone;
    bone.updateMatrixWorld(true);
    _h.setFromMatrixPosition(bone.matrixWorld);           // 뼈 머리
    _r.copy(it.tipL).applyMatrix4(bone.matrixWorld);      // 있어야 할 끝
    if (!it.init) { it.tip.copy(_r); it.prev.copy(_r); it.init = true; }
    const len = _r.distanceTo(_h);
    for (let s = 0; s < n; s++) {
      _v.subVectors(it.tip, it.prev).multiplyScalar(1 - damp); // 관성 (감쇠)
      it.prev.copy(it.tip);
      it.tip.add(_v).addScaledVector(_d.subVectors(_r, it.tip), stiff * it.k); // 제자리로 당김
      it.tip.y -= 0.0003;                                   // 살짝 중력
      it.tip.sub(_h).setLength(len).add(_h);                // 뼈 길이 유지
    }
    // 쉬는 방향 → 실제 방향 회전을 세계 회전에 곱하고, 부모 기준으로 바꿔 넣는다
    _d.subVectors(it.tip, _h).normalize();
    _r.sub(_h).normalize();
    _qw.setFromUnitVectors(_r, _d);
    bone.getWorldQuaternion(_q);
    bone.parent!.getWorldQuaternion(_qp);
    bone.quaternion.copy(_qp.invert().multiply(_qw.multiply(_q)));
    bone.updateMatrixWorld(true); // 자식 뼈(귀 끝)가 바로 이어서 쓴다
  }
}
```
- [ ] **흔들 뼈를 쉬는 회전으로 되돌리지 않으면 회전이 쌓여 귀가 빙글빙글 돈다** — 매 프레임 계산 전에 흔들 뼈 quaternion 을 identity(또는 클립 값)로 되돌린다.
- [ ] **dt 를 그대로 쓰면 느린 기기에서 출렁임이 튀거나 폭발한다** — 1/120초 간격으로 1 ~ 8번 나눠 계산한다.
- [ ] **귀 뿌리 · 끝 두 마디를 따로 계산할 때 순서가 틀리면 끝이 늦게 따라온다** — 부모(귀 뿌리)부터 계산하고 updateMatrixWorld 한 뒤 자식(귀 끝)을 계산한다.
- [ ] **캐릭터가 순간 이동하면 귀가 길게 늘어져 날아온다** — 이동만큼 tip · prev 도 함께 옮긴다 (견본 shift).

## 참고 6. 표정 모프 (눈 감기 · 웃음 · 놀람) — 모프 모양 넣기 + 표정 섞기 + 말하는 입
```ts
const EXPR = ['blink', 'smile', 'surprise', 'angry', 'talkA', 'talkO'];

// 기본 모양 pos 와, 같은 정점 수의 변형 모양 6개 morph[k]
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
geo.setAttribute('normal', new THREE.BufferAttribute(nrm, 3));
geo.setIndex(idx);
geo.morphAttributes.position = morph.map((m, k) => {
  const a = new THREE.BufferAttribute(m, 3);
  a.name = EXPR[k]!;
  return a;
});
const face = new THREE.Mesh(geo, mat); // morphTargetInfluences 가 6칸 생긴다
headBone.add(face);

// 매 프레임: 목표 표정으로 부드럽게
const w = [0, 0, 0, 0, 0, 0];
const k = 1 - Math.exp(-dt * 9);
for (let i = 1; i < 4; i++) w[i] += (target[i] - w[i]) * k;  // 웃음 · 놀람 · 화남
// 말하기: 0.17초 음절마다 아 또는 오
const syl = Math.floor(t / 0.17);
const open = talk * Math.max(0, Math.sin(((t % 0.17) / 0.17) * Math.PI));
const isO = (syl * 7919) % 3 === 0;
const kt = 1 - Math.exp(-dt * 25);
w[4] += ((isO ? 0 : open * 0.9) - w[4]) * kt;
w[5] += ((isO ? open : 0) - w[5]) * kt;
// 깜빡: 3.3초마다 0.16초, 웃을 땐 줄임
const p = t % 3.3;
w[0] = (p < 0.16 ? Math.sin((p / 0.16) * Math.PI) : 0) * (1 - w[1]);
for (let i = 0; i < 6; i++) face.morphTargetInfluences![i] = w[i];
```
- [ ] **변형 모양의 정점 수가 하나라도 다르면 얼굴이 깨진다** — 기본과 변형을 같은 함수 · 같은 점 수(M)로 뽑는다. 견본은 띠의 위 · 아래 테 함수만 바꾼다.
- [ ] **모프에 절대 위치를 넣었는데 상대로 읽히면(또는 반대) 얼굴이 두 배로 밀린다** — three.js 기본은 절대 위치(morphTargetsRelative = false). 차이값을 넣었다면 geo.morphTargetsRelative = true.
- [ ] **깜빡임과 웃음(눈 감은 웃음)을 둘 다 1로 섞으면 눈이 파고든다** — 깜빡임에 (1 − 웃음)을 곱한다.

## 들어간 기술 (하나씩 자세히)
- [SDF 조각 모델링 (부드럽게 합치기)](https://ai-techstudio.web.app/ai/t/i446.md) `i446`
- [코드로 뼈대 만들기 (Bone · SkinnedMesh)](https://ai-techstudio.web.app/ai/t/i456.md) `i456`
- [자동 스킨 가중치 (뼈까지 거리)](https://ai-techstudio.web.app/ai/t/i457.md) `i457`
- [절차 걷기 · 뛰기 (애니메이션 파일 없이)](https://ai-techstudio.web.app/ai/t/i458.md) `i458`
- [흔들리는 뼈 (귀 · 머리카락 · 꼬리 2차 움직임)](https://ai-techstudio.web.app/ai/t/i462.md) `i462`
- [표정 모프 (눈 감기 · 웃음 · 놀람)](https://ai-techstudio.web.app/ai/t/i463.md) `i463`
