# AI 꾸러미 — 흔들리는 뼈 (귀 · 머리카락 · 꼬리 2차 움직임) — Jiggle bones (secondary motion)
> 귀 · 머리카락 · 꼬리 뼈의 끝점을 베를레 점으로 따로 움직여 스프링으로 제자리에 당기고, 그 방향으로 뼈를 돌려 몸이 멈춰도 출렁이게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i462

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

## 주문서

### 만들어 줘: 흔들리는 뼈 (귀 · 머리카락 · 꼬리 2차 움직임) — Jiggle bones (secondary motion)

#### 1. 목표
토끼 귀 · 머리카락 · 꼬리에 흔들리는 뼈를 넣어 줘 — 몸이 멈춰도 늦게 따라와 출렁이게, 단단함 · 감쇠를 조절할 수 있게. 분위기는 말랑하고 귀여운.

#### 2. 핵심 기술 용어
- **Jiggle bones (secondary motion)** — 흔들리는 뼈 — 늦게 따라오는 2차 움직임
- **Verlet spring on bone tip** — 뼈 끝점을 베를레 적분 + 스프링으로
- **Stiffness · damping** — 단단함(제자리로 당기는 힘) · 감쇠(멈추는 빠르기)
- **Fixed-step substeps (120 Hz)** — 프레임과 상관없이 같은 간격으로 나눠 계산

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

#### 4. 조건
- 몸 자세(애니메이션 · 절차)를 먼저 정하고 matrixWorld 갱신 뒤에 흔들림을 계산한다
- 흔들 뼈는 매 프레임 쉬는 회전으로 되돌린 뒤 계산한다 (안 하면 회전이 쌓여 돌아간다)
- 고정 간격(1/120초) 나눠 계산 — 프레임마다 dt 를 그대로 쓰면 느린 폰에서 튄다
- 끝점은 뼈 길이로 다시 맞춘다
- 꼬리 · 머리카락은 단단함을 조금 더(× 1.6 · × 1.3) — 부위마다 다르게

#### 5. 완성 기준 (이게 보이면 성공)
- 두 토끼가 똑같이 폴짝 뛰는데, 오른쪽만 멈출 때 귀 · 머리카락 · 꼬리가 늦게 따라와 출렁인다
- 「단단함」을 낮추면 흐물흐물 크게, 올리면 짧게 떨린다
- 「감쇠」를 낮추면 오래 출렁이고, 올리면 금방 멈춘다
- 「흔들림」 켬/끔으로 왼쪽 토끼와 같아진다

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

## 원리
- 뼈마다 쉬는 자세에서의 끝점(tipL, 뼈 기준)을 기억해 두고, 매 프레임 그 끝점의 세계 위치 r 를 구한다 — 「있어야 할 곳」.
- 실제 끝점 tip 은 따로 움직인다: 속도 = (tip − prev) × (1 − 감쇠), tip += 속도 + (r − tip) × 단단함, 살짝 아래로(중력).
- 끝점은 뼈 길이만큼 떨어지게 다시 맞춘다 (뼈가 늘어나지 않게).
- 「쉬는 방향(r − 뼈 머리)」에서 「실제 방향(tip − 뼈 머리)」으로 돌리는 회전을 뼈에 곱한다.
- 120Hz 고정 간격으로 1 ~ 8번 나눠 계산해 프레임 속도가 달라도 같은 출렁임이 나온다.

## 핵심 코드 — 뼈 끝 베를레 스프링 → 뼈 회전
(발췌: demos/demosRigB.ts Springs.step() 을 정리)
```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).

## 완성 기준 체크리스트
- [ ] 두 토끼가 똑같이 폴짝 뛰는데, 오른쪽만 멈출 때 귀 · 머리카락 · 꼬리가 늦게 따라와 출렁인다
- [ ] 「단단함」을 낮추면 흐물흐물 크게, 올리면 짧게 떨린다
- [ ] 「감쇠」를 낮추면 오래 출렁이고, 올리면 금방 멈춘다
- [ ] 「흔들림」 켬/끔으로 왼쪽 토끼와 같아진다

## 이 기술 정보
- id: `i462` · 분류: 3D 모델 · 캐릭터 › 캐릭터 · 리깅 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 뼈 6개 × 1 ~ 8번 — 0.05ms 미만. 캐릭터 수십 명도 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i462
- 쓰면 좋을 때: 귀 · 꼬리 · 머리카락 · 리본이 몸 움직임에 늦게 따라와야 할 때 / 애니메이션 클립 위에 생기를 더할 때 (클립을 고치지 않고)
- 쓰지 말 때: 천처럼 넓은 면 — 대신 천 시뮬레이션(i485) / 맞고 넘어지는 온몸 — 대신 래그돌(i466)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i462 — `src/demos/demosRigB.ts:1046`
```ts
const i462: DemoMap[string] = {
  kind: '3d',
  caption: '폴짝 뛰다 멈추면 귀 · 머리카락 · 꼬리가 늦게 따라와 출렁 — 왼쪽은 끔, 오른쪽은 켬',
  make() {
    const st = makeStage([0, 1.5, 6.6], [0, 0.98, 0], 32);
    const lanes = [-1.12, 1.12];
    st.scene.add(pedestal(1.0, '#b3acdf', '#6d6497').translateX(lanes[0]!), pedestal(1.0, '#9fdcb9', '#5f8f74').translateX(lanes[1]!));
    const A = makeBunny(), B = makeBunny();
    st.scene.add(A.group, B.group);
    const SA = new Springs(A), SB = new Springs(B);
    let stiff = 0.028, damp = 0.04, on = true;
    const mkLabel = (s: string, c: string): ((g: CanvasRenderingContext2D, w: number, h: number) => void) => (g, w, h) => {
      plate(g, w, h, c);
      txt(g, s, w / 2, h / 2 + 0.5, 10, '#fff', 'center', 800);
    };
    st.hud.add(78, 20, 'bl', mkLabel('흔들림 끔', 'rgba(70,62,120,0.85)'), 1);
    const lb = st.hud.add(78, 20, 'br', mkLabel('흔들림 켬', 'rgba(40,120,90,0.85)'), 0.2);
    return {
      scene: st.scene,
      camera: st.camera,
      update(t, dt) {
        const d = Math.min(dt, 0.05);
        const p = hopPose(t);
        applyHop(A, p, lanes[0]!);
        applyHop(B, p, lanes[1]!);
        for (const bn of [A, B]) {
          bn.expr[0] = blinkAt(t, bn === A ? 0.7 : 0);
          bn.expr[1] = p.tuck * 0.6;
          bn.applyFace();
          bn.group.updateMatrixWorld(true);
        }
        SA.step(d, stiff, damp, false);
        SB.step(d, stiff, damp, on);
        lb.draw = mkLabel(on ? '흔들림 켬' : '흔들림 끔', on ? 'rgba(40,120,90,0.85)' : 'rgba(70,62,120,0.85)');
      },
      render: st.render,
      controls: [
        { type: 'toggle', label: '흔들림 (오른쪽 토끼)', value: true, on: (v) => (on = v) },
        { type: 'range', label: '단단함', min: 0.004, max: 0.1, step: 0.002, value: stiff, on: (v) => (stiff = v) },
        { type: 'range', label: '감쇠 (멈추는 빠르기)', min: 0.005, max: 0.2, step: 0.005, value: damp, on: (v) => (damp = v) },
      ] as Control[],
      dispose() {
        A.dispose();
        B.dispose();
        st.disposeAll();
      },
    };
  },
};
```

## 관련 기술
- 같은 방법으로 만든 효과 예시: [스프링 리그](https://ai-techstudio.web.app/ai/t/u45.md) `u45`
- 먼저 알면 좋은 기술: [코드로 뼈대 만들기 (Bone · SkinnedMesh)](https://ai-techstudio.web.app/ai/t/i456.md) `i456` · [시선 따라가기 (머리 · 눈 돌리기 제한)](https://ai-techstudio.web.app/ai/t/i461.md) `i461`
- 다음에 해 볼 기술: [래그돌 흉내 (넘어지는 몸)](https://ai-techstudio.web.app/ai/t/i466.md) `i466` · [천 시뮬레이션 (망토 · 깃발)](https://ai-techstudio.web.app/ai/t/i485.md) `i485`
