# AI 꾸러미 — 코드로 뼈대 만들기 (Bone · SkinnedMesh) — Skeletal rigging (Bone · Skeleton · SkinnedMesh)
> 골반 → 척추 → 머리 · 팔 · 다리 뼈 16개를 코드로 하나씩 세우고 메시에 묶어, 뼈를 돌리면 살이 따라 움직이게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i456

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

## 주문서

### 만들어 줘: 코드로 뼈대 만들기 (Bone · SkinnedMesh) — Skeletal rigging (Bone · Skeleton · SkinnedMesh)

#### 1. 목표
점토 곰 캐릭터에 코드로 뼈대를 세워 줘 — 골반 · 척추 · 가슴 · 목 · 머리 · 팔 · 다리 계층을 만들고 메시에 묶어, 뼈를 돌리면 살이 따라오게. 분위기는 말랑한 점토 인형.

#### 2. 핵심 기술 용어
- **Skeletal rigging (Bone · Skeleton · SkinnedMesh)** — 뼈대 만들고 살(메시)에 묶기
- **Bone hierarchy (parent-relative position)** — 부모 뼈 기준 위치로 쌓는 뼈 계층
- **skinIndex · skinWeight attributes** — 정점마다 어느 뼈를 얼마나 따를지 (4개씩)
- **SkinnedMesh.bind** — 지금 자세를 쉬는 자세로 묶기

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

#### 4. 조건
- 뼈 위치는 부모 기준 (자기 머리 − 부모 머리) — 세계 좌표를 그대로 넣으면 뼈가 두 배로 밀려난다
- bind 전에 mesh.updateMatrixWorld(true) — 안 하면 쉬는 자세가 틀어진다
- 정점마다 skinIndex · skinWeight 4칸, 가중치 합은 1
- 뼈 보기(막대)와 살 반투명 켬/끔으로 뼈와 살의 관계를 보여 준다
- frustumCulled = false (뼈가 움직이면 원래 경계 상자 밖으로 나간다)

#### 5. 완성 기준 (이게 보이면 성공)
- 뼈가 뿌리부터 하나씩 자라나 뼈대가 서고, 그 위에 살이 입혀진다
- 뼈를 돌리면(인사하는 왼팔 · 흔드는 머리) 살이 함께 굽는다
- 「뼈 보기」 · 「살 보기」 · 「살 반투명」으로 안팎을 따로 볼 수 있다
- 「처음부터 뼈 세우기」로 과정을 다시 볼 수 있다

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

## 원리
- 뼈마다 이름 · 부모 · 머리(시작) · 꼬리(끝) 위치를 표로 적는다 (견본 16개: 뿌리 · 골반 · 척추 · 가슴 · 목 · 머리 · 팔 4 · 다리 6).
- three.js 뼈는 부모 기준 위치를 쓰므로, 뼈 위치 = 자기 머리 − 부모 머리.
- 메시 정점에는 skinIndex(뼈 번호 4개) · skinWeight(비율 4개)를 붙인다.
- 뿌리 뼈를 메시에 붙이고 updateMatrixWorld 한 뒤 mesh.bind(new Skeleton(bones)) — 이때 자세가 쉬는 자세가 된다.
- 그 뒤엔 bone.quaternion 만 바꾸면 GPU 가 살을 따라 움직인다. 눈 · 코 같은 소품은 머리뼈에 add 한다.

## 핵심 코드 — 뼈 표 → Bone 계층 → SkinnedMesh 묶기
(발췌: demos/demosRigA.ts BONES · makeChar() 를 정리)
```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로 맞춘다.

## 완성 기준 체크리스트
- [ ] 뼈가 뿌리부터 하나씩 자라나 뼈대가 서고, 그 위에 살이 입혀진다
- [ ] 뼈를 돌리면(인사하는 왼팔 · 흔드는 머리) 살이 함께 굽는다
- [ ] 「뼈 보기」 · 「살 보기」 · 「살 반투명」으로 안팎을 따로 볼 수 있다
- [ ] 「처음부터 뼈 세우기」로 과정을 다시 볼 수 있다

## 이 기술 정보
- id: `i456` · 분류: 3D 모델 · 캐릭터 › 캐릭터 · 리깅 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 뼈 16개 스키닝은 GPU 정점 셰이더에서 — 폰에서도 가볍다. 무거운 건 가중치 계산(한 번)뿐.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i456
- 쓰면 좋을 때: GLB 없이 코드로 만든 캐릭터를 움직여야 할 때 / 뼈 위치 · 수를 게임마다 다르게 정하고 싶을 때 / IK · 절차 걷기 같은 코드 애니메이션의 바탕
- 쓰지 말 때: 이미 뼈가 들어 있는 모델 — GLTFLoader 로 그대로 쓴다(i467) / 모양이 통째로만 움직이는 블록 캐릭터 — 부품을 그룹에 붙여 돌리는 편이 간단

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i456 견본 항목 — `src/demos/demosRigA.ts:1061`
```ts
  i456: {
    kind: '3d',
    caption: '뼈 16개를 코드로 하나씩 세우고 살(메시)에 묶어요 — 뼈를 돌리면 살이 따라와요',
    make() {
      const st = makeStage([2.2, 1.55, 4.6], [0, 1.0, 0], 30);
      let cc: Char | null = null;
      let vv: BoneViz | null = null;
      const loader = makeLoader(st.scene, [0, 1.1, 0]);
      const bt = blobTex();
      const sh = blob(bt, 1.3);
      st.scene.add(sh);
      let showBones = true;
      let showSkin = true;
      let ghost = false;
      let t0 = 0;
      let restart = false;
      const CYCLE = 11;
      const feet = [new THREE.Vector3(AN[0], AN[1], 0), new THREE.Vector3(-AN[0], AN[1], 0)];
      const tag = makeLabel('뼈 세우는 중', '#ffe58a');
      tag.position.set(0, 2.45, 0);
      const tag2 = makeLabel('살 묶기 · 움직이기', '#bff0ff');
      tag2.position.copy(tag.position);
      tag.visible = tag2.visible = false;
      st.scene.add(tag, tag2);
      return {
        scene: st.scene,
        camera: st.cam,
        update(t) {
          if (!cc || !vv) {
            if (!bearReady() || !createSlot()) {
              loader.set(PROG, t);
              return;
            }
            cc = makeChar('smooth');
            vv = new BoneViz(cc);
            st.later([cc.group, vv.group]);
            t0 = t;
            st.onShown.push(() => loader.done());
          }
          const c = cc;
          const viz = vv;
          if (restart) {
            t0 = t;
            restart = false;
          }
          const lt = (t - t0) % CYCLE;
          const grow = lt / 0.14;
          const fade = sstep(2.5, 3.3, lt);
          const dance = sstep(3.0, 3.8, lt) * (1 - sstep(CYCLE - 0.8, CYCLE - 0.1, lt));
          c.rest();
          const ph = t * 2.6;
          const hips = c.b('hips');
          hips.position.y = 0.8 - dance * (0.035 + 0.03 * Math.sin(ph * 2));
          hips.position.x = dance * 0.05 * Math.sin(ph);
          hips.quaternion.copy(qZ(dance * 0.08 * Math.sin(ph)));
          c.b('spine').quaternion.copy(qZ(-dance * 0.1 * Math.sin(ph)));
          c.b('chest').quaternion.copy(qY(dance * 0.15 * Math.sin(ph * 0.5)));
          c.b('head').quaternion.copy(qZ(dance * 0.16 * Math.sin(ph + 0.6)).multiply(qX(dance * 0.06 * Math.sin(ph * 2))));
          // 왼팔 흔들어 인사 · 오른팔은 살랑
          c.b('upperArm.L').quaternion.copy(qZ(dance * 1.75));
          c.b('foreArm.L').quaternion.copy(qZ(dance * (0.55 + 0.45 * Math.sin(ph * 2.2))));
          c.b('upperArm.R').quaternion.copy(qZ(-dance * 0.35).multiply(qX(dance * 0.3 * Math.sin(ph))));
          c.b('foreArm.R').quaternion.copy(qZ(-dance * 0.4));
          c.group.updateMatrixWorld(true);
          plantFeet(c, feet);
          c.group.updateMatrixWorld(true);
          viz.update(grow);
          c.mesh.visible = showSkin && fade > 0.01;
          const tr = ghost || fade < 0.99;
          if (c.mat.transparent !== tr) {
            c.mat.transparent = tr;
            c.mat.needsUpdate = true;
          }
          c.mat.opacity = ghost ? 0.45 * fade : fade;
          c.mat.depthWrite = !ghost;
          viz.group.visible = showBones;
          tag.visible = lt < 3.0;
          tag2.visible = lt >= 3.0 && lt < 6;
          sh.visible = showSkin;
        },
        render(r, w, h) {
          void w;
          void h;
          stageRender(st, r);
        },
        controls: [
          { type: 'toggle', label: '뼈 보기', value: true, on: (v) => (showBones = v) },
          { type: 'toggle', label: '살(메시) 보기', value: true, on: (v) => (showSkin = v) },
          { type: 'toggle', label: '살 반투명', value: false, on: (v) => (ghost = v) },
          { type: 'button', label: '처음부터 뼈 세우기', on: () => (restart = true) },
        ] as Control[],
        dispose() {
          cc?.dispose();
          vv?.dispose();
          loader.dispose();
          disposeMesh(sh);
          bt.dispose();
          disposeSprite(tag);
          disposeSprite(tag2);
          st.own.forEach((o) => o.dispose());
        },
      } satisfies Scene3D;
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [인스턴싱 (InstancedMesh)](https://ai-techstudio.web.app/ai/t/u36.md) `u36`
- 다음에 해 볼 기술: [자동 스킨 가중치 (뼈까지 거리)](https://ai-techstudio.web.app/ai/t/i457.md) `i457` · [두 뼈 IK (팔 · 다리가 목표를 잡기)](https://ai-techstudio.web.app/ai/t/i459.md) `i459` · [절차 걷기 · 뛰기 (애니메이션 파일 없이)](https://ai-techstudio.web.app/ai/t/i458.md) `i458`
- 참고 문서: [three.js 문서 — SkinnedMesh](https://threejs.org/docs/#api/en/objects/SkinnedMesh)
