# AI 꾸러미 — 자동 스킨 가중치 (뼈까지 거리) — Automatic skin weights (distance-based)
> 정점마다 뼈 막대까지 거리로 가중치를 주고 표면 이웃과 열 번 평균 내 퍼뜨린 뒤 큰 넷만 남겨, 팔꿈치 · 무릎이 접혀도 덜 찌그러지게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i457

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

## 주문서

### 만들어 줘: 자동 스킨 가중치 (뼈까지 거리) — Automatic skin weights (distance-based)

#### 1. 목표
점토 곰 (팔 · 다리 접기)에 스킨 가중치를 자동으로 줘 — 뼈 막대까지 거리로, 표면 이웃과 평균 내 부드럽게, 정점당 뼈 4개까지. 분위기는 말랑한 점토.

#### 2. 핵심 기술 용어
- **Automatic skin weights (distance-based)** — 뼈까지 거리로 정하는 자동 스킨 가중치
- **Weight smoothing (Laplacian diffusion)** — 표면 이웃과 평균 내 가중치 퍼뜨리기
- **Top-4 influences, normalized** — 정점당 가장 큰 뼈 4개, 합 1
- **Weight heat map** — 뼈 하나의 가중치를 색으로 보기

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

#### 4. 조건
- 거리는 뼈 「점」이 아니라 뼈 「선분」까지, 살 두께를 빼고
- 평균 내기는 3D 거리 이웃이 아니라 표면 이웃(삼각형 연결)으로 — 붙어 있는 다리끼리 가중치가 새지 않게
- 정점당 큰 넷만 남기고 합 1로 다시 나누기
- 굽기는 프레임 예산(2ms)으로 나눠 — 화면 멈춤 없이 「만드는 중」 표시
- 뼈 하나를 고르면 가중치를 열 지도 색으로 보여 준다

#### 5. 완성 기준 (이게 보이면 성공)
- 왼쪽 「가까운 뼈 하나만」은 팔꿈치 · 무릎에서 살이 꺾여 찌그러지고, 오른쪽 「여러 뼈 + 부드럽게」는 둥글게 굽는다
- 뼈를 고르면 그 뼈 가중치가 빨강(1) → 파랑(0) 열 지도로 칠해지고, 관절 둘레가 부드럽게 넘어간다
- 같은 동작을 두 곰이 함께 하므로 차이가 한눈에 보인다

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

## 원리
- 정점에서 각 뼈 막대(머리 ~ 꼬리 선분)까지 거리를 재고, 그 뼈의 살 두께(r × 0.85)를 뺀다 (+0.025 로 0 나누기 막기).
- 가중치 = 1 / 거리⁴ 를 모든 뼈에 대해 계산하고 합 1로 나눈다 — 가까운 뼈가 압도적으로 크다.
- 표면 이웃(삼각형으로 이어진 정점)과 평균 내기를 10번: 새 값 = 자기 0.4 + 이웃 평균 0.6. 관절 둘레로 고르게 번진다.
- 정점마다 가장 큰 넷만 남기고 다시 합 1로 — GPU 스키닝은 4개까지다.
- 「가까운 뼈 하나만(가중치 1)」과 나란히 비교하면 팔꿈치가 꺾여 찌그러지는 차이가 보인다.

## 핵심 코드 — 거리⁻⁴ 가중치 → 표면 평균 10번 → 큰 넷만
(발췌: demos/demosRigA.ts bear 굽기 6 ~ 8 단계를 정리)
```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 예산으로 나눠 굽고, 결과는 캐시해 다른 견본도 같이 쓴다.

## 완성 기준 체크리스트
- [ ] 왼쪽 「가까운 뼈 하나만」은 팔꿈치 · 무릎에서 살이 꺾여 찌그러지고, 오른쪽 「여러 뼈 + 부드럽게」는 둥글게 굽는다
- [ ] 뼈를 고르면 그 뼈 가중치가 빨강(1) → 파랑(0) 열 지도로 칠해지고, 관절 둘레가 부드럽게 넘어간다
- [ ] 같은 동작을 두 곰이 함께 하므로 차이가 한눈에 보인다

## 이 기술 정보
- id: `i457` · 분류: 3D 모델 · 캐릭터 › 캐릭터 · 리깅 · 3D · 난이도 어려움 · 폰 부담 보통 (폰 주의) — 정점 n × 뼈 16 × 평균 10번 — 수만 정점이면 수백 ms. 견본은 시작 때 한 번, 프레임당 2ms 로 나눠 굽고 캐시한다. 실행 중 비용은 0.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i457
- 쓰면 좋을 때: 코드로 만든 메시(SDF · 서브디비전 등)에 뼈를 묶을 때 / 관절이 많이 접히는 팔 · 다리 · 꼬리
- 쓰지 말 때: 이미 가중치가 들어 있는 GLB — 다시 계산하지 않는다 / 정밀한 얼굴 · 손가락 — 거리만으론 부족해 손으로 다듬어야 한다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i457 견본 항목 — `src/demos/demosRigA.ts:1167`
```ts
  i457: {
    kind: '3d',
    caption: '왼쪽 「가까운 뼈 하나만」은 팔꿈치 · 무릎이 꺾여 찌그러지고, 오른쪽 「여러 뼈 + 부드럽게」는 둥글게 굽어요',
    make() {
      const st = makeStage([0.9, 1.45, 5.6], [0, 1.08, 0], 30, 2.3);
      let AA: Char | null = null;
      let BB: Char | null = null;
      let VA: BoneViz | null = null;
      let VB: BoneViz | null = null;
      const loader = makeLoader(st.scene, [0, 1.1, 0]);
      const bt = blobTex();
      const sA = blob(bt, 1.2);
      sA.position.x = -0.82;
      const sB = blob(bt, 1.2);
      sB.position.x = 0.82;
      st.scene.add(sA, sB);
      const lA = makeLabel('가까운 뼈 하나만', '#ffb4a8', 'rgba(14,18,40,0.72)', 0.21);
      lA.position.set(-0.82, 2.42, 0);
      const lB = makeLabel('여러 뼈 + 부드럽게', '#b8f5c8', 'rgba(14,18,40,0.72)', 0.21);
      lB.position.set(0.82, 2.42, 0);
      st.scene.add(lA, lB);
      const names = new Map<number, THREE.Sprite>();
      const nameOf = (b: number): THREE.Sprite => {
        let s = names.get(b);
        if (!s) {
          s = makeLabel('색 = 「' + BONES[b]!.ko + '」 뼈의 영향 (빨강 1 → 파랑 0)', '#ffffff', 'rgba(14,18,40,0.8)', 0.17);
          s.position.set(0, 0.12, 1.0);
          names.set(b, s);
          st.scene.add(s);
        }
        return s;
      };
      const AUTO = [BI['foreArm.L']!, BI['upperArm.L']!, BI['shin.L']!, BI['thigh.L']!, BI['chest']!, BI['head']!];
      let heat = true;
      let pick = 0; // 0 = 자동
      let bend = 1;
      let bones = false;
      let shown = -2;
      const feetA = [new THREE.Vector3(), new THREE.Vector3()];
      const pose = (c: Char, k: number): void => {
        c.rest();
        // 두 팔을 앞으로 들어 팔꿈치를 위로 접고(먹는 몸짓), 왼 무릎을 높이 든다
        const axL = new THREE.Vector3(AD[1], -AD[0], 0).normalize();
        const axR = new THREE.Vector3(AD[1], AD[0], 0).normalize();
        c.b('upperArm.L').quaternion.copy(qX(-0.8 * k).multiply(qZ(-0.12 * k)));
        c.b('foreArm.L').quaternion.setFromAxisAngle(axL, 2.35 * k * bend);
        c.b('upperArm.R').quaternion.copy(qX(-0.8 * k).multiply(qZ(0.12 * k)));
        c.b('foreArm.R').quaternion.setFromAxisAngle(axR, 2.35 * k * bend);
        c.b('thigh.L').quaternion.copy(qX(-1.35 * k * bend));
        c.b('shin.L').quaternion.copy(qX(2.2 * k * bend));
        c.b('foot.L').quaternion.copy(qX(-0.5 * k));
        c.b('head').quaternion.copy(qZ(0.12 * k));
        c.b('chest').quaternion.copy(qX(-0.06 * k));
        c.b('hips').position.y = 0.8 - 0.02 * k;
        c.group.updateMatrixWorld(true);
        // 디딘 발은 땅에
        const gq = c.group;
        feetA[1]!.set(-AN[0], AN[1], 0.0);
        gq.localToWorld(feetA[1]!);
        const pole = new THREE.Vector3(0, 0, 1).applyQuaternion(gq.quaternion);
        twoBoneIK(c.b('thigh.R'), c.b('shin.R'), ANK_L, feetA[1]!, pole);
        setWorldQuat(c.b('foot.R'), gq.quaternion.clone());
        c.group.updateMatrixWorld(true);
      };
      return {
        scene: st.scene,
        camera: st.cam,
        update(t) {
          if (!AA || !BB || !VA || !VB) {
            if (!bearReady() || !createSlot()) {
              loader.set(PROG, t);
              return;
            }
            if (!AA) {
              AA = makeChar('nearest');
              loader.set(PROG, t);
              return;
            }
            BB = makeChar('smooth');
            AA.group.position.x = -0.82;
            BB.group.position.x = 0.82;
            AA.group.rotation.y = 0.6;
            BB.group.rotation.y = 0.6;
            VA = new BoneViz(AA);
            VB = new BoneViz(BB);
            st.later([AA.group, BB.group, VA.group, VB.group]);
            shown = -2;
            st.onShown.push(() => loader.done());
          }
          const A = AA;
          const Bc = BB;
          const vizA = VA;
          const vizB = VB;
          const lt = t % 4;
          const k = sstep(0.2, 1.6, lt) * (1 - sstep(3.0, 3.9, lt));
          pose(A, k);
          pose(Bc, k);
          const want = heat ? (pick > 0 ? pick : AUTO[Math.floor(t / 4) % AUTO.length]!) : -1;
          if (want !== shown) {
            A.setHeat(want < 0 ? null : want);
            Bc.setHeat(want < 0 ? null : want);
            names.forEach((s) => (s.visible = false));
            if (want >= 0) nameOf(want).visible = true;
            shown = want;
          }
          vizA.group.visible = vizB.group.visible = bones;
          if (bones) {
            vizA.update();
            vizB.update();
          }
        },
        render(r, w, h) {
          void w;
          void h;
          stageRender(st, r);
        },
        controls: [
          { type: 'toggle', label: '가중치 색 보기', value: true, on: (v) => (heat = v) },
          { type: 'range', label: '뼈 고르기 (0 = 자동으로 돌기)', min: 0, max: NB - 1, step: 1, value: 0, on: (v) => (pick = v) },
          { type: 'range', label: '굽힘 세기', min: 0.3, max: 1.15, step: 0.05, value: 1, on: (v) => (bend = v) },
          { type: 'toggle', label: '뼈 보기', value: false, on: (v) => (bones = v) },
        ] as Control[],
        dispose() {
          AA?.dispose();
          BB?.dispose();
          VA?.dispose();
          VB?.dispose();
          loader.dispose();
          disposeMesh(sA);
          (sB.geometry as THREE.BufferGeometry).dispose();
          (sB.material as THREE.Material).dispose();
          bt.dispose();
          disposeSprite(lA);
          disposeSprite(lB);
          names.forEach(disposeSprite);
          st.own.forEach((o) => o.dispose());
        },
      } satisfies Scene3D;
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [코드로 뼈대 만들기 (Bone · SkinnedMesh)](https://ai-techstudio.web.app/ai/t/i456.md) `i456`
- 다음에 해 볼 기술: [두 뼈 IK (팔 · 다리가 목표를 잡기)](https://ai-techstudio.web.app/ai/t/i459.md) `i459` · [흔들리는 뼈 (귀 · 머리카락 · 꼬리 2차 움직임)](https://ai-techstudio.web.app/ai/t/i462.md) `i462`
