# AI 꾸러미 — 천 시뮬레이션 (망토 · 깃발) — Verlet cloth (position-based dynamics)
> 천을 점 격자로 두고 지난 위치와의 차이로 움직인 뒤(베를레) 이웃 점 사이 거리를 여러 번 되맞춰, 망토 · 깃발이 바람과 걸음에 펄럭이게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i485

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

## 주문서

### 만들어 줘: 천 시뮬레이션 (망토 · 깃발) — Verlet cloth (position-based dynamics)

#### 1. 목표
걷는 기사의 망토를 베를레 점 격자 천으로 만들어 줘 — 바람에 펄럭이고 몸에 부딪히게. 분위기는 밤 성벽 · 횃불.

#### 2. 핵심 기술 용어
- **Verlet cloth (position-based dynamics)** — 위치 · 지난 위치만으로 움직이고 거리 제약을 되맞추는 천
- **Distance constraints (structural · shear · bend)** — 가로세로 이웃 · 대각선 · 한 칸 건너 이웃 거리 지키기
- **Pinned vertices** — 어깨 · 깃대에 고정한 점
- **Wind on normals · capsule collision** — 천 법선 방향으로 받는 바람 · 몸 캡슐 밖으로 밀기

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

#### 4. 조건
- 고정 시간 걸음(1/60초), 한 장면에 쌓이는 시간은 3걸음까지
- 제약 3종(이웃 · 대각선 · 한 칸 건너)을 강도를 달리해서 — 대각선이 없으면 마름모로 찌그러진다
- 어깨 고정 점은 매 걸음 몸 뼈 위치로 옮긴다 (지난 위치도 같이 갱신)
- 정점 버퍼는 DynamicDrawUsage, 매 걸음 뒤 computeVertexNormals
- 격자 크기 · 바람 · 반복 횟수 슬라이더 + 계산 시간(ms) · 점 · 제약 수 표시

#### 5. 완성 기준 (이게 보이면 성공)
- 기사가 걸으면 망토가 뒤로 날리고, 멈추면 천천히 내려와 등에 붙는다
- 망토가 몸을 뚫고 앞으로 넘어오지 않는다
- 반복 횟수를 1 로 낮추면 천이 고무처럼 늘어나고, 20 이면 빳빳해진다
- 바람을 0 으로 하면 깃발이 축 처진다

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

## 원리
- 점마다 지금 위치 p 와 지난 위치 o 만 둔다. 속도 = (p − o) × 0.988(감쇠), 새 p = p + 속도 + 가속 × h².
- 바람은 「바람 − 점 속도」(상대 기류)를 천 법선에 투영한 만큼 받는다 — 천이 바람을 마주 볼수록 세게 밀린다. 중력 9.8.
- 제약: 가로세로 이웃(강도 1) · 대각선(0.6) · 한 칸 건너(0.25, 접힘 저항). 길이가 처음과 다르면 차이만큼 두 점을 반씩 당기거나 민다 — 고정 점은 움직이지 않는다.
- 제약 풀기를 8번 되풀이하며 그때마다 몸 캡슐 안으로 들어간 점을 밖으로 밀고, 바닥(0.02) 아래로는 못 가게.
- 고정 1/60초 걸음, 끝나면 법선을 다시 계산해 그림자 · 빛이 펄럭임을 따라간다.

## 핵심 코드 — 베를레 적분 + 거리 제약 되맞추기
(발췌: demos/demosGame3dB.ts Cloth.step() 을 정리)
```ts
// p · o = 지금 · 지난 위치 (점 n 개 × 3), ca · cb · rest · stiff = 제약 (두 점 · 원래 길이 · 강도)
function step(h: number, iters: number, wind: THREE.Vector3, windK: number, drag: number): void {
  const h2 = h * h, damp = 0.988;
  for (let k = 0; k < n; k++) {
    const j = k * 3;
    if (pin[k]) { o[j] = p[j]; o[j+1] = p[j+1]; o[j+2] = p[j+2]; p[j] = target[j]; p[j+1] = target[j+1]; p[j+2] = target[j+2]; continue; }
    const vx = (p[j] - o[j]) * damp, vy = (p[j+1] - o[j+1]) * damp, vz = (p[j+2] - o[j+2]) * damp;
    const rx = wind.x - vx / h, ry = wind.y - vy / h, rz = wind.z - vz / h;      // 상대 기류
    const dn = (nrm[j] * rx + nrm[j+1] * ry + nrm[j+2] * rz) * windK;          // 천 법선 쪽으로 받는 만큼
    o[j] = p[j]; o[j+1] = p[j+1]; o[j+2] = p[j+2];
    p[j]   += vx + (nrm[j] * dn + rx * drag) * h2;
    p[j+1] += vy + (nrm[j+1] * dn + ry * drag - 9.8) * h2;
    p[j+2] += vz + (nrm[j+2] * dn + rz * drag) * h2;
  }
  for (let it = 0; it < iters; it++) {                                        // 견본 8번
    for (let c = 0; c < cons; c++) {
      const a = ca[c] * 3, b = cb[c] * 3;
      const dx = p[b] - p[a], dy = p[b+1] - p[a+1], dz = p[b+2] - p[a+2];
      const d = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1e-6;
      const wa = pin[ca[c]] ? 0 : 1, wb = pin[cb[c]] ? 0 : 1, ws = wa + wb;
      if (!ws) continue;
      const f = ((d - rest[c]) / d) * stiff[c];                                // 이웃 1 · 대각 0.6 · 건너 0.25
      p[a] += dx * f * wa / ws; p[a+1] += dy * f * wa / ws; p[a+2] += dz * f * wa / ws;
      p[b] -= dx * f * wb / ws; p[b+1] -= dy * f * wb / ws; p[b+2] -= dz * f * wb / ws;
    }
    pushOutOfCapsules(p);                                                      // 몸 캡슐 · 바닥 밖으로
  }
}
```

## 흔한 실수 · 확인 목록
- [ ] **이웃 제약만 두면 천이 마름모로 찌그러지고 구겨진다** — 대각선(0.6)과 한 칸 건너(0.25) 제약을 더한다.
- [ ] **고정 점의 지난 위치를 안 바꾸면 걸을 때 망토가 튕긴다** — 고정 점은 o = p, p = 목표 로 함께 옮긴다.
- [ ] **장면 시간으로 바로 적분하면 끊길 때 천이 폭발한다** — 1/60초 고정 걸음, 쌓인 시간은 3걸음까지로 자른다.
- [ ] **바람을 그냥 더하면 천이 한 덩어리로 날아간다** — 바람 − 점 속도를 천 법선에 투영해 받는다 — 바람과 나란한 천은 거의 안 밀린다.

## 완성 기준 체크리스트
- [ ] 기사가 걸으면 망토가 뒤로 날리고, 멈추면 천천히 내려와 등에 붙는다
- [ ] 망토가 몸을 뚫고 앞으로 넘어오지 않는다
- [ ] 반복 횟수를 1 로 낮추면 천이 고무처럼 늘어나고, 20 이면 빳빳해진다
- [ ] 바람을 0 으로 하면 깃발이 축 처진다

## 이 기술 정보
- id: `i485` · 분류: 게임 시스템 · AI › 3D 게임 기본기 · 3D · 난이도 어려움 · 폰 부담 보통 (폰 주의) — 견본 망토 12×17 + 깃발 16×10 ≈ 360 점 · 제약 약 2,000 × 8번 = 한 걸음 1.6만 번 계산 (0.2~0.5ms). 격자를 22 로 키우면 4배.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i485
- 쓰면 좋을 때: 망토 · 깃발 · 리본처럼 가볍게 펄럭이는 천 하나둘 / 바람 · 탄성 원리를 보여 주는 체험
- 쓰지 말 때: 옷 전체 · 치마처럼 몸에 꼭 붙는 천 — 뼈대 애니메이션 + 스프링 뼈(u45)가 싸고 안정적 / 천이 수십 장 — 점 수 × 제약 × 반복이라 무겁다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### Cloth — `src/demos/demosGame3dB.ts:1258`
```ts
class Cloth {
  readonly geo: THREE.BufferGeometry;
  readonly n: number;
  readonly p: Float32Array;
  private o: Float32Array;
  readonly pin: Uint8Array;
  readonly target: Float32Array;
  private ca: Uint16Array;
  private cb: Uint16Array;
  private rest: Float32Array;
  private stiff: Float32Array;
  readonly cons: number;
  constructor(
    readonly nx: number,
    readonly ny: number,
    init: (i: number, j: number, out: THREE.Vector3) => void,
    pinned: (i: number, j: number) => boolean,
  ) {
    const n = (this.n = nx * ny);
    this.p = new Float32Array(n * 3);
    this.o = new Float32Array(n * 3);
    this.target = new Float32Array(n * 3);
    this.pin = new Uint8Array(n);
    const v = new THREE.Vector3();
    const uv = new Float32Array(n * 2);
    for (let j = 0; j < ny; j++)
      for (let i = 0; i < nx; i++) {
        const k = j * nx + i;
        init(i, j, v);
        this.p.set([v.x, v.y, v.z], k * 3);
        this.pin[k] = pinned(i, j) ? 1 : 0;
        uv.set([i / (nx - 1), 1 - j / (ny - 1)], k * 2);
      }
    this.o.set(this.p);
    this.target.set(this.p);
    const A: number[] = [];
    const B: number[] = [];
    const S: number[] = [];
    const add = (a: number, b: number, s: number): void => {
      A.push(a);
      B.push(b);
      S.push(s);
    };
    const id = (i: number, j: number): number => j * nx + i;
    for (let j = 0; j < ny; j++)
      for (let i = 0; i < nx; i++) {
        if (i + 1 < nx) add(id(i, j), id(i + 1, j), 1);
        if (j + 1 < ny) add(id(i, j), id(i, j + 1), 1);
        if (i + 1 < nx && j + 1 < ny) {
          add(id(i, j), id(i + 1, j + 1), 0.6);
          add(id(i + 1, j), id(i, j + 1), 0.6);
        }
        if (i + 2 < nx) add(id(i, j), id(i + 2, j), 0.25);
        if (j + 2 < ny) add(id(i, j), id(i, j + 2), 0.25);
      }
    this.cons = A.length;
    this.ca = Uint16Array.from(A);
    this.cb = Uint16Array.from(B);
    this.stiff = Float32Array.from(S);
    this.rest = new Float32Array(A.length);
    for (let c = 0; c < A.length; c++) {
      const a = A[c]! * 3;
      const b = B[c]! * 3;
      this.rest[c] = Math.hypot(this.p[b]! - this.p[a]!, this.p[b + 1]! - this.p[a + 1]!, this.p[b + 2]! - this.p[a + 2]!);
    }
    const idx: number[] = [];
    for (let j = 0; j < ny - 1; j++)
      for (let i = 0; i < nx - 1; i++) {
        const a = id(i, j);
        const b = id(i + 1, j);
        const c = id(i, j + 1);
        const d = id(i + 1, j + 1);
        idx.push(a, c, b, b, c, d);
      }
    this.geo = new THREE.BufferGeometry();
    this.geo.setAttribute('position', new THREE.BufferAttribute(this.p, 3).setUsage(THREE.DynamicDrawUsage));
    this.geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
    this.geo.setIndex(idx);
    this.geo.computeVertexNormals();
    this.geo.computeBoundingSphere();
  }
  /** h 초 한 걸음. wind = 바람 (세계), caps = 몸 캡슐 */
  step(h: number, iters: number, wind: THREE.Vector3, windK: number, drag: number, caps: Capsule[]): void {
    const p = this.p;
    const o = this.o;
    const nrm = this.geo.attributes.normal!.array as Float32Array;
    const h2 = h * h;
    const damp = 0.988;
    for (let k = 0; k < this.n; k++) {
      const j = k * 3;
      if (this.pin[k]) {
        o[j] = p[j]!;
        o[j + 1] = p[j + 1]!;
        o[j + 2] = p[j + 2]!;
        p[j] = this.target[j]!;
        p[j + 1] = this.target[j + 1]!;
        p[j + 2] = this.target[j + 2]!;
        continue;
      }
      const vx = (p[j]! - o[j]!) * damp;
      const vy = (p[j + 1]! - o[j + 1]!) * damp;
      const vz = (p[j + 2]! - o[j + 2]!) * damp;
      // 바람: 상대 기류를 천 법선 방향으로 받는다
      const rx = wind.x - vx / h;
      const ry = wind.y - vy / h;
      const rz = wind.z - vz / h;
      const nx = nrm[j]!;
      const ny = nrm[j + 1]!;
      const nz = nrm[j + 2]!;
      const dn = (nx * rx + ny * ry + nz * rz) * windK;
      const ax = nx * dn + rx * drag;
      const ay = ny * dn + ry * drag - 9.8;
      const az = nz * dn + rz * drag;
      o[j] = p[j]!;
      o[j + 1] = p[j + 1]!;
      o[j + 2] = p[j + 2]!;
      p[j] = p[j]! + vx + ax * h2;
      p[j + 1] = p[j + 1]! + vy + ay * h2;
      p[j + 2] = p[j + 2]! + vz + az * h2;
    }
    const ca = this.ca;
    const cb = this.cb;
    const rest = this.rest;
    const sf = this.stiff;
    const pin = this.pin;
    for (let it = 0; it < iters; it++) {
      for (let c = 0; c < this.cons; c++) {
        const ia = ca[c]!;
        const ib = cb[c]!;
        const a = ia * 3;
        const b = ib * 3;
        const dx = p[b]! - p[a]!;
        const dy = p[b + 1]! - p[a + 1]!;
        const dz = p[b + 2]! - p[a + 2]!;
        const d = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1e-6;
        const wa = pin[ia] ? 0 : 1;
        const wb = pin[ib] ? 0 : 1;
        const ws = wa + wb;
        if (!ws) continue;
        const f = ((d - rest[c]!) / d) * sf[c]!;
        const fa = (f * wa) / ws;
        const fb = (f * wb) / ws;
        p[a] = p[a]! + dx * fa;
        p[a + 1] = p[a + 1]! + dy * fa;
        p[a + 2] = p[a + 2]! + dz * fa;
        p[b] = p[b]! - dx * fb;
        p[b + 1] = p[b + 1]! - dy * fb;
        p[b + 2] = p[b + 2]! - dz * fb;
      }
      // 몸 캡슐 · 바닥
      for (let k = 0; k < this.n; k++) {
        if (pin[k]) continue;
        const j = k * 3;
        for (const cp of caps) {
          const abx = cp.b.x - cp.a.x;
          const aby = cp.b.y - cp.a.y;
          const abz = cp.b.z - cp.a.z;
          const t = clamp(((p[j]! - cp.a.x) * abx + (p[j + 1]! - cp.a.y) * aby + (p[j + 2]! - cp.a.z) * abz) / (abx * abx + aby * aby + abz * abz), 0, 1);
          const qx = p[j]! - (cp.a.x + abx * t);
          const qy = p[j + 1]! - (cp.a.y + aby * t);
          const qz = p[j + 2]! - (cp.a.z + abz * t);
          const d2 = qx * qx + qy * qy + qz * qz;
          if (d2 < cp.r * cp.r) {
            const d = Math.sqrt(d2) || 1e-6;
            const s = (cp.r - d) / d;
            p[j] = p[j]! + qx * s;
            p[j + 1] = p[j + 1]! + qy * s;
            p[j + 2] = p[j + 2]! + qz * s;
          }
        }
        if (p[j + 1]! < 0.02) {
          p[j + 1] = 0.02;
          o[j] = lerp(o[j]!, p[j]!, 0.5);
          o[j + 2] = lerp(o[j + 2]!, p[j + 2]!, 0.5);
        }
      }
    }
  }
  finish(): void {
    this.geo.attributes.position!.needsUpdate = true;
    this.geo.computeVertexNormals();
    this.geo.computeBoundingSphere();
  }
  dispose(): void {
    this.geo.dispose();
  }
}
```

## 관련 기술
- 다음에 해 볼 기술: [스프링 리그](https://ai-techstudio.web.app/ai/t/u45.md) `u45`
- 참고 문서: [Wikipedia — Verlet integration](https://en.wikipedia.org/wiki/Verlet_integration)
