# AI 꾸러미 — 래그돌 흉내 (넘어지는 몸) — Ragdoll (Verlet particles + distance constraints)
> 몸의 뼈 끝 · 관절을 베를레 점으로 바꾸고 점 사이 거리 제약을 되풀이해 맞춰, 공에 맞으면 흐물흐물 넘어졌다가 원래 자세로 섞으며 일어나게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i466

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

## 주문서

### 만들어 줘: 래그돌 흉내 (넘어지는 몸) — Ragdoll (Verlet particles + distance constraints)

#### 1. 목표
공에 맞는 점토 토끼에 래그돌 흉내를 넣어 줘 — 뼈 끝을 베를레 점과 거리 제약으로, 맞으면 흐물흐물 넘어지고 잠시 뒤 원래 자세로 섞으며 일어나게. 분위기는 코믹한 슬랩스틱.

#### 2. 핵심 기술 용어
- **Ragdoll (Verlet particles + distance constraints)** — 베를레 점 + 거리 제약 래그돌 흉내
- **Position-based constraint relaxation** — 위치를 직접 옮겨 제약을 맞추기 (되풀이)
- **Min-distance (one-sided) constraint** — 「이보다 가까워지지만 마」 한쪽 제약 — 접힘 막기
- **Points → bone rotations (basis from points)** — 점 위치에서 뼈 회전 되찾기

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

#### 4. 조건
- 베를레 적분 (cur · prev) + 고정 간격 1/120초, 한 프레임 최대 6단계
- 몸통 · 머리는 모든 점 쌍을 이어 단단하게, 팔다리는 사슬, 접힘 막기는 최소 거리 제약
- 거리 제약은 질량 역수(invM) 비율로 두 점을 나눠 옮기기
- 바닥 아래 점은 반지름 높이로 올리고 옆 속도를 줄인다 (마찰)
- 일어날 땐 넘어진 회전에서 쉬는 회전으로 slerp — 순간이동 금지

#### 5. 완성 기준 (이게 보이면 성공)
- 공이 날아와 맞으면 토끼가 맞은 쪽 반대로 흐물흐물 넘어진다
- 넘어진 몸이 땅에서 미끄러지다 멈추고, 팔다리가 몸을 뚫거나 이상하게 접히지 않는다
- 잠시 뒤 원래 자세로 부드럽게 섞으며 일어나 제자리로 깡충 돌아온다
- 「공 세기」 1 ~ 6 · 「제약 반복」 1 ~ 20 을 바꾸면 넘어지는 세기 · 몸의 단단함이 달라지고, 「점 · 막대 보기」로 베를레 점과 제약이 보인다

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

## 원리
- 맞는 순간 지금 자세에서 몸의 점(골반 · 목 · 어깨 · 팔끝 · 무릎 · 발 · 귀 등)의 세계 위치를 가져와 cur 로 둔다.
- 맞은 곳에 가까운 점일수록 prev 를 반대쪽으로 밀어 두면 첫 프레임에 그만큼 속도가 생긴다 (베를레: 속도 = cur − prev).
- 1/120초 간격: 속도 × 0.995 로 움직이고 중력 9.8 을 더한 뒤, 거리 제약을 10번 되풀이해 맞추고, 바닥 아래 점은 위로 올리며 미끄럼을 줄인다(× 0.6).
- 몸통 · 머리 점은 서로 모두 이어 단단한 덩어리로, 팔 · 다리는 사슬로, 접히지 말아야 할 곳은 「최소 거리」 한쪽 제약으로 막는다.
- 점 위치에서 뼈 회전을 되찾고(몸통 = 목 − 골반 · 어깨 방향으로 기저), 일어날 땐 넘어진 자세에서 쉬는 자세로 0.9초 동안 slerp 로 섞는다.

## 핵심 코드 — 베를레 + 거리 제약 + 바닥 (한 프레임)
(발췌: demos/demosRigB.ts i466 simulate() 를 정리)
```ts
// cur · prev: 점 N 개 위치, links: [a, b, 쉬는 거리, 최소만?], invM: 점 질량 역수, R[i]: 점 반지름
function simulate(dt: number, iters = 10): void {
  const n = THREE.MathUtils.clamp(Math.round(dt * 120), 1, 6);
  const h = 1 / 120, tmp = new THREE.Vector3();
  for (let s = 0; s < n; s++) {
    for (let i = 0; i < N; i++) {                    // 베를레 — 속도 = cur − prev
      const p = cur[i]!, q = prev[i]!;
      tmp.subVectors(p, q).multiplyScalar(0.995);    // 공기 저항
      q.copy(p);
      p.add(tmp);
      p.y -= 9.8 * h * h;                            // 중력
    }
    for (let it = 0; it < iters; it++) {
      for (const [a, b, rest, minOnly] of links) {   // 거리 제약
        const pa = cur[a]!, pb = cur[b]!;
        tmp.subVectors(pb, pa);
        const dd = tmp.length() || 1e-6;
        if (minOnly && dd >= rest) continue;         // 접힘 막기: 가까워질 때만
        const k = (dd - rest) / dd / (invM[a]! + invM[b]!);
        pa.addScaledVector(tmp, k * invM[a]!);
        pb.addScaledVector(tmp, -k * invM[b]!);
      }
      for (let i = 0; i < N; i++) {                  // 바닥 + 마찰
        const p = cur[i]!;
        if (p.y < R[i]!) {
          p.y = R[i]!;
          const q = prev[i]!;
          q.x = p.x - (p.x - q.x) * 0.6;
          q.z = p.z - (p.z - q.z) * 0.6;
        }
      }
    }
  }
}
// 맞는 순간: w = exp(−|점 − 맞은 곳|² / 0.3) 로 가까운 점일수록 prev 를 반대로 밀어 첫 속도를 준다
// 그 뒤: 점 → 뼈 회전 (몸통 기저 = makeBasis(어깨 방향, 목 − 골반, 외적)), 일어날 땐 slerpQuaternions(넘어진, 쉬는, k)
```

## 흔한 실수 · 확인 목록
- [ ] **팔다리를 사슬로만 이으면 무릎 · 팔꿈치가 반대로 접히거나 몸을 뚫는다** — 엉덩이 ↔ 발, 골반 ↔ 무릎 같은 「최소 거리」 한쪽 제약을 더한다.
- [ ] **되풀이 수가 적으면 몸이 고무처럼 늘어난다** — 제약을 한 단계에 10번 되풀이한다 (슬라이더로 조절).
- [ ] **넘어진 자세에서 바로 쉬는 자세로 바꾸면 순간이동한다** — 넘어진 뼈 회전 · 골반 위치를 기억해 두고 0.9초 동안 slerp · lerp 로 섞는다.
- [ ] **바닥에서 계속 미끄러진다** — 바닥에 닿은 점의 prev 를 옮겨 옆 속도를 0.6배로 줄인다 (마찰).

## 완성 기준 체크리스트
- [ ] 공이 날아와 맞으면 토끼가 맞은 쪽 반대로 흐물흐물 넘어진다
- [ ] 넘어진 몸이 땅에서 미끄러지다 멈추고, 팔다리가 몸을 뚫거나 이상하게 접히지 않는다
- [ ] 잠시 뒤 원래 자세로 부드럽게 섞으며 일어나 제자리로 깡충 돌아온다
- [ ] 「공 세기」 1 ~ 6 · 「제약 반복」 1 ~ 20 을 바꾸면 넘어지는 세기 · 몸의 단단함이 달라지고, 「점 · 막대 보기」로 베를레 점과 제약이 보인다

## 이 기술 정보
- id: `i466` · 분류: 3D 모델 · 캐릭터 › 캐릭터 · 리깅 · 3D · 난이도 어려움 · 폰 부담 가벼움 (폰 OK) — 점 수십 개 × 제약 수십 개 × 10번 × 1 ~ 6 단계 — 1ms 미만.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i466
- 쓰면 좋을 때: 맞음 · 넘어짐 · 날아감 연출에 매번 다른 넘어지는 모습이 필요할 때 / 물리 엔진 없이 가볍게 흐물거리는 몸
- 쓰지 말 때: 정확한 충돌 · 무게가 중요한 물리 게임 — 대신 진짜 물리 엔진(i24) / 귀 · 꼬리만 출렁이면 될 때 — 흔들리는 뼈(i462)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i466 — `src/demos/demosRigB.ts:1696`
```ts
const i466: DemoMap[string] = {
  kind: '3d',
  caption: '뼈 끝을 베를레 점 + 거리 제약으로 — 공에 맞으면 흐물흐물 넘어지고, 잠시 뒤 원래 자세로 부드럽게 섞으며 일어남',
  make() {
    const st = makeStage([0.45, 1.45, 5.3], [0.55, 0.72, 0], 34, ['#3b3566', '#171530']);
    st.scene.add(pedestal(2.5, '#a8d8c0', '#5d8d7a'));
    const bn = makeBunny();
    st.scene.add(bn.group);
    const N = RAG_PTS.length;
    const cur = RAG_PTS.map(() => new THREE.Vector3()), prev = RAG_PTS.map(() => new THREE.Vector3());
    const links = ragLinks();
    const invM = RAG_PTS.map((p) => (p[0] === 'HC' ? 0.5 : 1));
    // 점 · 막대 보기
    const lgeo = new THREE.BufferGeometry();
    lgeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(links.length * 6), 3));
    const lines = new THREE.LineSegments(lgeo, new THREE.LineBasicMaterial({ color: '#ffe27a', transparent: true, opacity: 0.55, depthTest: false, toneMapped: false }));
    const pgeo = new THREE.BufferGeometry();
    pgeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(N * 3), 3));
    const pts = new THREE.Points(pgeo, new THREE.PointsMaterial({ color: '#ffef9a', size: 5, sizeAttenuation: false, depthTest: false, toneMapped: false }));
    lines.renderOrder = pts.renderOrder = 5;
    lines.frustumCulled = pts.frustumCulled = false;
    st.scene.add(lines, pts);
    const ball = new THREE.Mesh(ballGeo(), new THREE.MeshPhysicalMaterial({ vertexColors: true, roughness: 0.3, clearcoat: 0.8 }));
    ball.castShadow = true;
    st.scene.add(ball);
    const ballV = new THREE.Vector3();
    let c = 0, state: 'stand' | 'rag' | 'getup' | 'back' = 'stand';
    let power = 3.2, iters = 10, showRig = true, auto = true;
    const fallen = new THREE.Vector3();
    const from = { hp: new THREE.Vector3(), hq: new THREE.Quaternion(), q: {} as Record<string, THREE.Quaternion> };
    const HIT = new THREE.Vector3(-0.28, 0.85, 0.12), B0 = new THREE.Vector3(-3.3, 1.7, 0.5);
    const tmp = new THREE.Vector3(), m4 = new THREE.Matrix4(), bx = new THREE.Vector3(), by = new THREE.Vector3(), bz = new THREE.Vector3();
    const Qt = new THREE.Quaternion(), Qh = new THREE.Quaternion(), W1 = new THREE.Quaternion(), W2 = new THREE.Quaternion(), W3 = new THREE.Quaternion(), qi = new THREE.Quaternion();
    const restDir = (a: string, b: string): THREE.Vector3 => {
      const pa = RAG_PTS[RI[a]!]![3], pb = RAG_PTS[RI[b]!]![3];
      return new THREE.Vector3(pb[0] - pa[0], pb[1] - pa[1], pb[2] - pa[2]).normalize();
    };
    const RD: Record<string, THREE.Vector3> = {};
    for (const s of ['L', 'R']) {
      RD['arm' + s] = restDir('S' + s, 'A' + s);
      RD['thigh' + s] = restDir('H' + s, 'K' + s);
      RD['shin' + s] = restDir('K' + s, 'F' + s);
      RD['foot' + s] = restDir('F' + s, 'T' + s);
      RD['ear' + s + '1'] = restDir('EB' + s, 'EM' + s);
      RD['ear' + s + '2'] = restDir('EM' + s, 'ET' + s);
    }
    const P = (n: string): THREE.Vector3 => cur[RI[n]!]!;
    /** 부모 세계 회전 Wp 에서, 쉬는 방향 → 지금 방향 으로 휘두른 세계 회전 */
    const swing = (Wp: THREE.Quaternion, rest: THREE.Vector3, a: string, b: string, out: THREE.Quaternion): THREE.Quaternion => {
      const r = rest.clone().applyQuaternion(Wp);
      const d = tmp.subVectors(P(b), P(a)).normalize();
      return out.setFromUnitVectors(r, d).multiply(Wp);
    };
    const setLocal = (bone: string, Wp: THREE.Quaternion, W: THREE.Quaternion): void => {
      bn.b[bone]!.quaternion.copy(qi.copy(Wp).invert().multiply(W));
    };
    const startRag = (): void => {
      bn.group.updateMatrixWorld(true);
      RAG_PTS.forEach(([, bone, , at], i) => {
        const bb = BIND[bone]!;
        cur[i]!.set(at[0] - bb[0], at[1] - bb[1], at[2] - bb[2]).applyMatrix4(bn.b[bone]!.matrixWorld);
        const w = Math.exp(-cur[i]!.distanceToSquared(HIT) / 0.3);
        const hk = clamp(cur[i]!.y / 1.3, 0.05, 1.3);
        prev[i]!.copy(cur[i]!).sub(tmp.set(power * (0.15 + w) * hk, power * 0.1 * w, -0.15 * power * w).multiplyScalar(1 / 120));
      });
      state = 'rag';
      ballV.set(-1.3, 2.2, 0.7);
    };
    const simulate = (d: number): void => {
      const n = clamp(Math.round(d * 120), 1, 6);
      const h = 1 / 120;
      for (let s = 0; s < n; s++) {
        for (let i = 0; i < N; i++) {
          const p = cur[i]!, q = prev[i]!;
          tmp.subVectors(p, q).multiplyScalar(0.995);
          q.copy(p);
          p.add(tmp);
          p.y -= 9.8 * h * h;
        }
        for (let it = 0; it < iters; it++) {
          for (const [a, b, rest, minOnly] of links) {
            const pa = cur[a]!, pb = cur[b]!;
            tmp.subVectors(pb, pa);
            const dd = tmp.length() || 1e-6;
            if (minOnly && dd >= rest) continue;
            const wa = invM[a]!, wb = invM[b]!;
            const k = (dd - rest) / dd / (wa + wb);
            pa.addScaledVector(tmp, k * wa);
            pb.addScaledVector(tmp, -k * wb);
          }
          for (let i = 0; i < N; i++) {
            const r = RAG_PTS[i]![2];
            const p = cur[i]!;
            if (p.y < r) {
              p.y = r;
              const q = prev[i]!;
              q.x = p.x - (p.x - q.x) * 0.6;
              q.z = p.z - (p.z - q.z) * 0.6;
            }
          }
        }
      }
    };
    const mapBones = (): void => {
      by.subVectors(P('N'), P('P')).normalize();
      bx.subVectors(P('SR'), P('SL'));
      bx.addScaledVector(by, -bx.dot(by)).normalize();
      bz.crossVectors(bx, by);
      Qt.setFromRotationMatrix(m4.makeBasis(bx, by, bz));
      bn.b.hips!.position.copy(P('P'));
      bn.b.hips!.quaternion.copy(Qt);
      bn.b.spine!.quaternion.identity();
      by.subVectors(P('HT'), P('HC')).normalize();
      bz.subVectors(P('HF'), P('HC'));
      bz.addScaledVector(by, -bz.dot(by)).normalize();
      bx.crossVectors(by, bz);
      Qh.setFromRotationMatrix(m4.makeBasis(bx, by, bz));
      setLocal('head', Qt, Qh);
      for (const s of ['L', 'R']) {
        setLocal('arm' + s, Qt, swing(Qt, RD['arm' + s]!, 'S' + s, 'A' + s, W1));
        swing(Qt, RD['thigh' + s]!, 'H' + s, 'K' + s, W1);
        setLocal('thigh' + s, Qt, W1);
        swing(W1, RD['shin' + s]!, 'K' + s, 'F' + s, W2);
        setLocal('shin' + s, W1, W2);
        swing(W2, RD['foot' + s]!, 'F' + s, 'T' + s, W3);
        setLocal('foot' + s, W2, W3);
        swing(Qh, RD['ear' + s + '1']!, 'EB' + s, 'EM' + s, W1);
        setLocal('ear' + s + '1', Qh, W1);
        swing(W1, RD['ear' + s + '2']!, 'EM' + s, 'ET' + s, W2);
        setLocal('ear' + s + '2', W1, W2);
      }
    };
    const restPose = (t: number): void => {
      for (const b of Object.values(bn.b)) b.quaternion.identity();
      bn.b.hips!.position.set(0, 0.55 + 0.006 * Math.sin(t * 2.4), 0);
      bn.b.spine!.rotation.x = 0.02 * Math.sin(t * 2.4);
    };
    const label = { s: '서 있기' };
    st.hud.add(196, 20, 'tl', (g, w, h) => {
      plate(g, w, h);
      txt(g, label.s, 8, h / 2 + 0.5, 8.5, '#fff', 'left', 800);
    }, 0.08);
    return {
      scene: st.scene,
      camera: st.camera,
      update(t, dt) {
        const d = Math.min(dt, 0.05);
        if (state !== 'stand' || auto || c > 0.01) c += d;
        // 공
        if (state === 'stand') {
          if (!auto && c < 0.45) c = Math.min(c, 0);
          const k = clamp((c - 0.45) / 0.55, 0, 1);
          ball.visible = c > 0.45;
          ball.position.lerpVectors(B0, HIT, k);
          ball.position.y += Math.sin(Math.PI * k) * 0.5;
          ball.position.x -= 0.2;
          ball.scale.setScalar(1);
          ball.rotation.z -= d * 8;
          restPose(t);
          bn.expr[2] = sstep((c - 0.6) / 0.3);
          bn.expr[1] = 0;
          label.s = c > 0.45 ? '공이 날아온다!' : '서 있기';
          if (c >= 1.0) startRag();
        } else {
          ballV.y -= 9.8 * d;
          ball.position.addScaledVector(ballV, d);
          if (ball.position.y < 0.2) {
            ball.position.y = 0.2;
            ballV.y = Math.abs(ballV.y) * 0.55;
            ballV.x *= 0.8;
          }
          ball.rotation.z += ballV.x * d * 4;
          ball.scale.setScalar(clamp(1 - (c - 2.4) / 0.4, 0.001, 1));
        }
        if (state === 'rag') {
          simulate(d);
          mapBones();
          bn.expr[2] = Math.max(0, 1 - (c - 1.0) * 2);
          bn.expr[0] = sstep((c - 1.4) / 0.3);
          label.s = '맞았다! 흐물흐물 — 베를레 점 ' + N + '개 · 제약 ' + links.length + '개';
          if (c >= 3.3) {
            state = 'getup';
            fallen.set(P('P').x, 0, P('P').z);
            from.hp.copy(bn.b.hips!.position);
            from.hq.copy(bn.b.hips!.quaternion);
            for (const [k, b] of Object.entries(bn.b)) from.q[k] = b.quaternion.clone();
          }
        }
        if (state === 'getup') {
          const k = sstep((c - 3.3) / 0.9);
          for (const [kk, b] of Object.entries(bn.b)) b.quaternion.slerpQuaternions(from.q[kk]!, qi.identity(), k);
          bn.b.hips!.position.lerpVectors(from.hp, tmp.set(fallen.x, 0.55, fallen.z), k);
          bn.b.hips!.position.y += Math.sin(Math.PI * k) * 0.12;
          bn.expr[0] = 1 - k;
          bn.expr[1] = k;
          label.s = '일어나기 — 원래 자세로 섞기 ' + Math.round(k * 100) + '%';
          if (c >= 4.2) state = 'back';
        }
        if (state === 'back') {
          const k = clamp((c - 4.2) / 1.0, 0, 1);
          const hop = (k * 2) % 1;
          restPose(t);
          bn.b.hips!.position.set(lerp(fallen.x, 0, sstep(k)), 0.55 + 0.25 * Math.sin(Math.PI * hop), lerp(fallen.z, 0, sstep(k)));
          bn.b.thighL!.rotation.x = bn.b.thighR!.rotation.x = 0.4 * Math.sin(Math.PI * hop);
          bn.expr[0] = 0;
          bn.expr[1] = 1 - k * 0.5;
          label.s = '제자리로 폴짝';
          if (c >= 5.4) {
            state = 'stand';
            c = auto ? 0 : -1;
          }
        }
        if (state === 'stand') bn.expr[0] = Math.max(bn.expr[0]!, blinkAt(t));
        bn.applyFace();
        const on = showRig && (state === 'rag' || state === 'getup');
        lines.visible = pts.visible = on;
        if (on) {
          const op = state === 'getup' ? 1 - sstep((c - 3.3) / 0.5) : 1;
          (lines.material as THREE.LineBasicMaterial).opacity = 0.55 * op;
          (pts.material as THREE.PointsMaterial).opacity = op;
          (pts.material as THREE.PointsMaterial).transparent = true;
          const la = lgeo.attributes.position as THREE.BufferAttribute;
          links.forEach(([a, b], i) => {
            la.setXYZ(i * 2, cur[a]!.x, cur[a]!.y, cur[a]!.z);
            la.setXYZ(i * 2 + 1, cur[b]!.x, cur[b]!.y, cur[b]!.z);
          });
          la.needsUpdate = true;
          const pa = pgeo.attributes.position as THREE.BufferAttribute;
          cur.forEach((p, i) => pa.setXYZ(i, p.x, p.y, p.z));
          pa.needsUpdate = true;
        }
      },
      render: st.render,
      controls: [
        { type: 'button', label: '공 던지기', on: () => {
            if (state === 'stand') c = Math.max(c, 0.45);
          },
        },
        { type: 'range', label: '공 세기', min: 1, max: 6, step: 0.1, value: power, on: (v) => (power = v) },
        { type: 'range', label: '제약 반복 (적을수록 흐물)', min: 1, max: 20, step: 1, value: iters, on: (v) => (iters = v) },
        { type: 'toggle', label: '점 · 막대 보기', value: true, on: (v) => (showRig = v) },
        { type: 'toggle', label: '자동 반복', value: true, on: (v) => (auto = v) },
      ] as Control[],
      dispose() {
        bn.dispose();
        st.disposeAll();
      },
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [흔들리는 뼈 (귀 · 머리카락 · 꼬리 2차 움직임)](https://ai-techstudio.web.app/ai/t/i462.md) `i462` · [코드로 뼈대 만들기 (Bone · SkinnedMesh)](https://ai-techstudio.web.app/ai/t/i456.md) `i456`
- 다음에 해 볼 기술: [천 시뮬레이션 (망토 · 깃발)](https://ai-techstudio.web.app/ai/t/i485.md) `i485` · [전투 판정 (휘두르기 · 히트스톱 · 넉백)](https://ai-techstudio.web.app/ai/t/i484.md) `i484`
