# AI 꾸러미 — 인스턴싱 (InstancedMesh) — GPU instancing (THREE.InstancedMesh)
> 같은 모양 수천 개를 InstancedMesh 하나로 묶고 행렬 · 색만 칸마다 넣어서, 관중 4000명을 그리기 호출 2번으로 그리고 파도타기도 행렬만 바꿔 움직인다.  
> 견본: https://ai-techstudio.web.app/#t/u36

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

## 주문서

### 만들어 줘: 인스턴싱 (InstancedMesh) — GPU instancing (THREE.InstancedMesh)

#### 1. 목표
경기장 관중을 InstancedMesh 로 수천 개 그려 줘 — 그리기 호출은 모양 종류 수만큼만, 움직임은 행렬만 바꿔서. 분위기는 밤 경기장 조명.

#### 2. 핵심 기술 용어
- **GPU instancing (THREE.InstancedMesh)** — 같은 모양을 한 번에 여러 번 그리기
- **setMatrixAt / setColorAt** — 하나하나의 자리 · 회전 · 크기 · 색 넣기
- **Draw call** — CPU 가 GPU 에 「그려」라고 하는 한 번
- **InstancedMesh.count** — 앞에서부터 몇 개만 그릴지

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

#### 4. 조건
- 같은 모양 · 같은 재질은 InstancedMesh 하나로 — 칸마다 Mesh 를 만들지 않는다
- 행렬은 Matrix4.compose(자리, 회전, 크기) 하나를 돌려 쓰고, 다 넣은 뒤 instanceMatrix.needsUpdate = true 한 번
- 색은 setColorAt, 바꿨으면 instanceColor.needsUpdate = true
- 움직이지 않는 프레임엔 행렬을 다시 넣지 않는다
- 개수(count) 조절 · 파도타기 켬/끔

#### 5. 완성 기준 (이게 보이면 성공)
- 관중 수 슬라이더(200 ~ 4000)를 올려도 그리기 호출은 「2번」 그대로이고 부드럽다
- 파도타기가 옆으로 흐르며 관중이 차례로 일어난다
- 관중 옷 색이 사람마다 다르다 (setColorAt)
- 폰에서 4000명도 60fps

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

## 원리
- 물체 하나하나를 Mesh 로 만들면 물체마다 행렬 계산 · 화면 안 검사 · 그리기 호출이 붙어 CPU 가 막힌다.
- InstancedMesh(모양, 재질, 최대 개수) 하나에 칸마다 행렬(setMatrixAt)과 색(setColorAt)만 넣으면 GPU 가 한 번에 다 그린다.
- 관중 = 몸(상자) InstancedMesh + 머리(공) InstancedMesh, 80 × 50 = 4000칸. 그리기 호출 2번.
- 파도타기: 칸마다 위상 ph 를 두고 들림 = max(0, sin(ph − 3.2t))³ × 0.45. 매 프레임 행렬만 다시 넣고 instanceMatrix.needsUpdate = true.
- count 를 바꾸면 앞에서부터 그만큼만 그린다 — 줄 순서로 정렬해 두면 앞줄부터 채워진다.

## 핵심 코드 — 관중 4000명 — 몸 · 머리 InstancedMesh 두 개와 파도타기
(발췌: demos/demosSim.ts u36 make() · update() 를 정리)
```ts
const COLS = 80, ROWS = 50, MAX = COLS * ROWS;
const body = new THREE.InstancedMesh(new THREE.BoxGeometry(0.34, 0.42, 0.24), new THREE.MeshLambertMaterial(), MAX);
const head = new THREE.InstancedMesh(new THREE.SphereGeometry(0.15, 10, 8), new THREE.MeshLambertMaterial({ color: 0xffd2a8 }), MAX);
const pal = [0xff4a5a, 0xffd23a, 0x3ab0ff, 0x4ae07a, 0xffffff, 0xff8a3a, 0xb07aff];
const col = new THREE.Color();
const seats: { x: number; y: number; z: number; ry: number; ph: number }[] = [];
for (let r = 0; r < ROWS; r++)
  for (let c = 0; c < COLS; c++) {
    const ang = (c / (COLS - 1) - 0.5) * 2.0, R = 9 + r * 0.5;   // 둥근 관중석
    seats.push({ x: Math.sin(ang) * R, y: r * 0.36, z: -Math.cos(ang) * R + 6, ry: -ang, ph: c * 0.16 });
    body.setColorAt(seats.length - 1, col.setHex(pal[Math.floor(Math.random() * pal.length)]));
  }
scene.add(body, head);

const m = new THREE.Matrix4(), q = new THREE.Quaternion(), e = new THREE.Euler();
const v = new THREE.Vector3(), one = new THREE.Vector3(1, 1, 1);
function update(t: number, count: number, waveOn: boolean): void {
  body.count = head.count = count;                 // 앞에서부터 count 명만
  for (let k = 0; k < count; k++) {
    const s = seats[k];
    const w = waveOn ? Math.max(0, Math.sin(s.ph - t * 3.2)) : 0;
    const up = Math.pow(w, 3) * 0.45;              // 파도타기
    q.setFromEuler(e.set(0, s.ry, 0));
    body.setMatrixAt(k, m.compose(v.set(s.x, s.y + 0.21 + up, s.z), q, one));
    head.setMatrixAt(k, m.compose(v.set(s.x, s.y + 0.55 + up, s.z), q, one));
  }
  body.instanceMatrix.needsUpdate = true;
  head.instanceMatrix.needsUpdate = true;
}
```

## 흔한 실수 · 확인 목록
- [ ] **같은 모양을 Mesh 로 하나씩 만들면 수백 개에서 CPU 가 막힌다** — 이 사이트 3D 퍼즐이 물체 1,000 ~ 1,700개를 하나씩 그려 느렸다. 움직이는 같은 모양은 인스턴싱, 고정된 것은 mergeStatic 으로 합쳤다.
- [ ] **인스턴스가 처음 자리 밖으로 움직이면 통째로 사라진다** — three.js 는 묶음 전체의 경계로 화면 밖 검사를 한다. 넓게 움직이면 computeBoundingSphere() 를 다시 하거나 frustumCulled = false.
- [ ] **needsUpdate 를 칸마다 켜면 헛일이 많다** — 모든 칸을 넣은 뒤 한 번만 켠다.

## 완성 기준 체크리스트
- [ ] 관중 수 슬라이더(200 ~ 4000)를 올려도 그리기 호출은 「2번」 그대로이고 부드럽다
- [ ] 파도타기가 옆으로 흐르며 관중이 차례로 일어난다
- [ ] 관중 옷 색이 사람마다 다르다 (setColorAt)
- [ ] 폰에서 4000명도 60fps

## 이 기술 정보
- id: `u36` · 분류: 이펙트 · 연출 › 입자 · 연출 · 3D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 그리기 호출은 모양 종류 수(관중 2번). 매 프레임 행렬 4000개를 다시 넣는 CPU 계산이 남는다 — 안 움직이면 넣지 않는다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u36
- 쓰면 좋을 때: 같은 모양이 수십 개 넘을 때 (풀 · 관중 · 블록 · 구슬) / 프레임이 CPU 에서 막힐 때 — 물체 수(1000개 넘음)가 원인일 때
- 쓰지 말 때: 하나하나 다른 모양 · 다른 재질 — 인스턴싱은 같은 모양 · 같은 재질끼리만. 고정 물체는 합치기(mergeStatic) / 몇 개뿐인 물체 — 그냥 Mesh 가 단순하다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### u36 견본 항목 — `src/demos/demosSim.ts:2051`
```ts
  u36: {
    kind: '3d',
    caption: '관중 수천 명을 InstancedMesh 두 개(몸 · 머리)로 — 그리기 호출 2번, 파도타기는 행렬만 바꿔요',
    make() {
      const scene = new THREE.Scene();
      scene.background = skyTexture('#0e1a44', '#3a4c8e');
      scene.add(new THREE.HemisphereLight(0xdfe8ff, 0x303050, 1.4));
      const sun = new THREE.DirectionalLight(0xffffff, 1.4);
      sun.position.set(2, 8, 6);
      scene.add(sun);
      const cam = new THREE.PerspectiveCamera(42, 1.6, 0.1, 200);
      const COLS = 80;
      const ROWS = 50;
      const MAX = COLS * ROWS;
      const body = new THREE.InstancedMesh(new THREE.BoxGeometry(0.34, 0.42, 0.24), new THREE.MeshLambertMaterial(), MAX);
      const head = new THREE.InstancedMesh(new THREE.SphereGeometry(0.15, 10, 8), new THREE.MeshLambertMaterial({ color: 0xffd2a8 }), MAX);
      const r = rng(3);
      const pal = [0xff4a5a, 0xffd23a, 0x3ab0ff, 0x4ae07a, 0xffffff, 0xff8a3a, 0xb07aff];
      const col = new THREE.Color();
      const seats: { x: number; y: number; z: number; ry: number; ph: number }[] = [];
      for (let rr = 0; rr < ROWS; rr++)
        for (let cc = 0; cc < COLS; cc++) {
          const ang = (cc / (COLS - 1) - 0.5) * 2.0;
          const R = 9 + rr * 0.5;
          seats.push({ x: Math.sin(ang) * R, y: rr * 0.36, z: -Math.cos(ang) * R + 6, ry: -ang, ph: cc * 0.16 });
          body.setColorAt(seats.length - 1, col.setHex(pal[Math.floor(r() * pal.length)]!));
        }
      // 앞 사람부터 count 만큼 보이게 (가운데부터)
      const order = seats.map((_, i) => i).sort((a, b) => Math.floor(a / COLS) - Math.floor(b / COLS));
      for (let k = 0; k < MAX; k++) body.setColorAt(k, col.setHex(pal[Math.floor(r() * pal.length)]!));
      scene.add(body, head);
      const field = new THREE.Mesh(new THREE.CircleGeometry(8, 40), new THREE.MeshLambertMaterial({ color: 0x3aa04a }));
      field.rotation.x = -Math.PI / 2;
      field.position.set(0, -0.3, 6);
      scene.add(field);
      const stand = new THREE.Mesh(new THREE.CylinderGeometry(34, 9, 18, 48, 1, true, Math.PI - 1.1, 2.2), new THREE.MeshLambertMaterial({ color: 0x2a3050, side: THREE.DoubleSide }));
      stand.position.set(0, 8.5, 6);
      scene.add(stand);
      let count = 2400;
      let label = textSprite(`${count.toLocaleString()}명 · 그리기 2번`, { size: 56, color: '#fff', bg: 'rgba(10,16,40,0.75)', h: 0.9 });
      label.position.set(0, 0.6, 8);
      scene.add(label);
      const setLabel = (): void => {
        scene.remove(label);
        label.material.map?.dispose();
        label.material.dispose();
        label = textSprite(`${count.toLocaleString()}명 · 그리기 2번`, { size: 56, color: '#fff', bg: 'rgba(10,16,40,0.75)', h: 0.9 });
        label.position.set(0, 0.6, 8);
        scene.add(label);
      };
      const m = new THREE.Matrix4();
      const q = new THREE.Quaternion();
      const e = new THREE.Euler();
      const v = new THREE.Vector3();
      const one = new THREE.Vector3(1, 1, 1);
      let waveOn = true;
      return {
        scene,
        camera: cam,
        update(t) {
          cam.position.set(Math.sin(t * 0.2) * 3, 6, 23);
          cam.lookAt(0, 4.6, 0);
          body.count = head.count = count;
          for (let k = 0; k < count; k++) {
            const i = order[k]!;
            const s = seats[i]!;
            const w = waveOn ? Math.max(0, Math.sin(s.ph - t * 3.2)) : 0;
            const up = Math.pow(w, 3) * 0.45;
            e.set(0, s.ry, 0);
            q.setFromEuler(e);
            m.compose(v.set(s.x, s.y + 0.21 + up, s.z), q, one);
            body.setMatrixAt(k, m);
            m.compose(v.set(s.x, s.y + 0.55 + up, s.z), q, one);
            head.setMatrixAt(k, m);
          }
          body.instanceMatrix.needsUpdate = true;
          head.instanceMatrix.needsUpdate = true;
          if (body.instanceColor) body.instanceColor.needsUpdate = true;
        },
        controls: [
          {
            type: 'range',
            label: '관중 수',
            min: 200,
            max: MAX,
            step: 200,
            value: count,
            on: (x) => {
              count = x;
              setLabel();
            },
          },
          { type: 'toggle', label: '파도타기', value: true, on: (x) => (waveOn = x) },
        ],
        dispose() {
          disposeScene(scene);
          (scene.background as THREE.Texture).dispose();
          body.dispose();
          head.dispose();
        },
      };
    },
  }
```

## 관련 기술
- 다음에 해 볼 기술: [입자 방출기 (수명 곡선)](https://ai-techstudio.web.app/ai/t/i170.md) `i170`
- 참고 문서: [three.js 문서 — InstancedMesh](https://threejs.org/docs/#api/en/objects/InstancedMesh) · [three.js 예제 — webgl_instancing_performance](https://threejs.org/examples/#webgl_instancing_performance)
