# AI 꾸러미 — 고정 물체 합치기 (그리기 호출 줄이기) — Static mesh merging (geometry batching)
> 움직이지 않는 메시를 같은 재질끼리 한 덩어리로 합쳐, 물체 1,500개 · 그리기 1,500번을 재질 수만큼으로 줄인다 — 모양 · 색은 그대로.  
> 견본: https://ai-techstudio.web.app/#t/i391

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

## 주문서

### 만들어 줘: 고정 물체 합치기 (그리기 호출 줄이기) — Static mesh merging (geometry batching)

#### 1. 목표
3D 퍼즐 무대 (판 · 담 · 장식)의 움직이지 않는 메시를 같은 재질끼리 하나로 합쳐 줘 — 위치는 세계 좌표로 구워 넣고, 이름 · userData 가 있는 움직일 물체는 건드리지 말기. 그리기 호출 수 · 한 장면 CPU ms 를 전/후로 숫자 표로 보여 주기.

#### 2. 핵심 기술 용어
- **Static mesh merging (geometry batching)** — 고정 물체 합치기
- **Draw call** — 그리기 호출 — 물체 · 재질 하나마다 한 번
- **BufferGeometryUtils.mergeGeometries** — three.js 기하 합치기 도구
- **renderer.info.render.calls** — 그리기 호출 수 재기

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

#### 4. 조건
- 합치는 묶음 열쇠 = 재질 + castShadow · receiveShadow + 꼭짓점 속성 종류
- 위치는 matrixWorld 로, 법선은 getNormalMatrix 로 옮겨 넣고 normalize
- 이름 · userData 가 있는 물체 · 인스턴스 · 뼈대 · 투명 · 직접 짠 셰이더 재질은 건드리지 않기
- 전/후 그리기 호출 수와 한 장면 CPU 시간을 재서 보여 주기 (화면은 픽셀까지 같아야)

#### 5. 완성 기준 (이게 보이면 성공)
- 끔 쪽은 칸이 하나씩 차례로 밝아지고(1,500번), 켬 쪽은 재질 묶음째 한 번에 밝아진다(80번)
- 「진짜로 재기」를 누르면 이 기기에서 그리기 호출 1500 → 8, 한 장면 CPU ms 가 줄어든 값이 나온다
- 합친 뒤 화면을 찍어 전과 비교하면 같다

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

## 원리
- three.js 는 물체마다 행렬 갱신 · 화면 안 검사 · 재질 바꾸기 · 그리기 호출을 한다 — 물체 수가 CPU 시간이다.
- 고정된 메시의 꼭짓점을 세계 행렬로 옮겨 하나의 BufferGeometry 에 이어 붙이면 물체 하나가 된다 (법선은 법선 행렬로).
- 재질이 다르면 합칠 수 없다 — 재질(+ 그림자 설정)마다 한 덩어리.
- 견본: 상자 1,500개 · 재질 8가지 → 메시 8개, 그리기 호출 1,500 → 8.
- 이 사이트는 shared/bake.ts mergeStatic(root, keep) 으로 퍼즐 무대마다 합친다.

## 핵심 코드 — 메시 여러 개 → 세계 좌표로 구워 하나로
(발췌: demos/demosPerf.ts mergeMeshes() · measureMerge())
```ts
function mergeMeshes(meshes: THREE.Mesh[]): THREE.BufferGeometry {
  const pos: number[] = [];
  const nor: number[] = [];
  const idx: number[] = [];
  let off = 0;
  const v = new THREE.Vector3();
  const nm = new THREE.Matrix3();
  for (const m of meshes) {
    m.updateMatrixWorld(true);
    nm.getNormalMatrix(m.matrixWorld); // 법선은 법선 행렬로 (늘인 물체도 맞게)
    const p = m.geometry.getAttribute('position');
    const n = m.geometry.getAttribute('normal');
    for (let i = 0; i < p.count; i++) {
      v.fromBufferAttribute(p, i).applyMatrix4(m.matrixWorld);
      pos.push(v.x, v.y, v.z);
      v.fromBufferAttribute(n, i).applyMatrix3(nm).normalize();
      nor.push(v.x, v.y, v.z);
    }
    const ix = m.geometry.getIndex();
    if (ix) for (let j = 0; j < ix.count; j++) idx.push(ix.getX(j) + off);
    off += p.count;
  }
  const out = new THREE.BufferGeometry();
  out.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
  out.setAttribute('normal', new THREE.Float32BufferAttribute(nor, 3));
  out.setIndex(idx);
  return out;
}

// 재질마다 한 덩어리: 1,500개(재질 8) → 메시 8개
for (let k = 0; k < mats.length; k++) {
  const group = meshes.filter((m) => m.material === mats[k]);
  group.forEach((m) => m.removeFromParent());
  scene.add(new THREE.Mesh(mergeMeshes(group), mats[k]));
}
// 확인: renderer.render(scene, cam); console.log(renderer.info.render.calls);
```

## 흔한 실수 · 확인 목록
- [ ] **물체 1,000 ~ 1,700개를 하나씩 그리면 CPU 가 막혀 느리다** — 이 사이트 새 퍼즐 게임 대부분이 그랬다 — mergeStatic 으로 합쳐 그리기 호출이 594 → 324, 887 → 262 처럼 줄었다.
- [ ] **움직일 물체까지 합치면 코드가 찾던 물체가 사라진다** — 이름 · userData 가 있는 물체와 그 아래는 건드리지 않는다. 움직이는 묶음은 userData.dynamic 으로 표시하고 그 안만 따로.
- [ ] **물체 자기 좌표를 쓰는 셰이더 재질을 합치면 무늬가 어긋난다** — 직접 짠 셰이더는 빼고, 세계 좌표만 쓰는 것만 material.userData.mergeSafe 로 표시해 합친다.
- [ ] **합친 기하를 판을 바꿀 때 안 버리면 메모리가 계속 는다** — 새로 만든 기하는 목록에 모았다가 판을 바꿀 때 dispose (mergeStatic 의 keep).

## 완성 기준 체크리스트
- [ ] 끔 쪽은 칸이 하나씩 차례로 밝아지고(1,500번), 켬 쪽은 재질 묶음째 한 번에 밝아진다(80번)
- [ ] 「진짜로 재기」를 누르면 이 기기에서 그리기 호출 1500 → 8, 한 장면 CPU ms 가 줄어든 값이 나온다
- [ ] 합친 뒤 화면을 찍어 전과 비교하면 같다

## 이 기술 정보
- id: `i391` · 분류: 게임 시스템 · AI › 속도 기법 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 합치기는 판을 지을 때 한 번(꼭짓점 복사). 메모리는 같은 모양을 여럿 복사한 만큼 늘어난다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i391
- 쓰면 좋을 때: 같은 재질의 작은 고정 물체가 수백 개 넘는 무대 / renderer.info.render.calls 가 수백 ~ 천을 넘을 때
- 쓰지 말 때: 따로 움직이거나 고를 물체 — 합치면 하나만 움직일 수 없다 (userData.dynamic 으로 빼기) / 같은 모양이 수천 개면 — 대신 인스턴싱(InstancedMesh)이 메모리가 적다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### demo391 — `src/demos/demosPerf.ts:1176`
```ts
function demo391() {
  let res: MergeMeasure | null = null;
  let msg = '';
  const CYC = 3;
  const COLS = 20;
  const ROWS = 14;
  const N = COLS * ROWS;
  const matOf = (i: number): number => {
    const c = i % COLS;
    const r = Math.floor(i / COLS);
    return (Math.floor(c / 5) + Math.floor(r / 7) * 2) % 5;
  };
  const MC = ['#e8b36a', '#7fb4e8', '#86d68a', '#d98ac0', '#c9c2b0'];
  const grid = (g: G, x: number, y: number, cw: number, lit: (i: number) => number): void => {
    for (let i = 0; i < N; i++) {
      const cx = x + (i % COLS) * cw;
      const cy = y + Math.floor(i / COLS) * cw;
      const L = lit(i);
      const col = MC[matOf(i)]!;
      g.fillStyle = shade(col, 0.55 + L * 0.45);
      g.fillRect(cx, cy, cw - 0.7, cw - 0.7);
      g.fillStyle = 'rgba(255,255,255,0.16)';
      g.fillRect(cx, cy, cw - 0.7, 0.8);
      if (L > 0.6) {
        g.fillStyle = `rgba(255,255,255,${(L - 0.6) * 1.6})`;
        g.fillRect(cx, cy, cw - 0.7, cw - 0.7);
      }
    }
  };
  return {
    draw(g: G, w: number, h: number, tt: number) {
      const t = tt % CYC;
      const k = t / CYC;
      view(g, w, h, isBig(w) ? 194 : 175);
      txt(g, '움직이지 않는 메시를 같은 재질끼리 한 덩어리로', 8, 10, 7.2, C.text, 'left', 800);
      const cw = 5.6;
      const cols: [number, Kind][] = [[6, 'bad'], [142, 'good']];
      for (const [x, kind] of cols) {
        const bad = kind === 'bad';
        panel(g, x, 20, 132, 150, kind);
        tag(g, x + 5, 28, bad ? '끔' : '켬', kind);
        txt(g, bad ? '물체마다 따로 그리기' : '재질끼리 합쳐 그리기', x + 19, 28.3, 6.2, C.text, 'left', 700);
        const gx = x + (132 - COLS * cw) / 2;
        const gy = 38;
        if (bad) {
          const cur = Math.floor(k * N * 1.15);
          grid(g, gx, gy, cw, (i) => (i === cur ? 1 : i < cur ? 0.55 : 0.25 + 0.1 * Math.max(0, 1 - (cur - i) / 30)));
        } else {
          const groups = 5;
          const cur = Math.floor(k * groups * 1.4);
          grid(g, gx, gy, cw, (i) => (matOf(i) === cur ? 0.95 : matOf(i) < cur ? 0.6 : 0.3));
        }
        const tot0 = res ? (bad ? res.callsA : res.callsB) : bad ? 1500 : 80;
        const calls = Math.min(tot0, Math.floor(k * (bad ? 1.15 : 1.4) * tot0));
        const total = res ? (bad ? res.callsA : res.callsB) : bad ? 1500 : 80;
        txt(g, '그리기 호출', x + 8, 128, 6, C.dim, 'left', 700);
        txt(g, calls.toLocaleString(), x + 8, 142, 15, bad ? C.red : C.green, 'left', 900);
        txt(g, `/ ${total.toLocaleString()}`, x + 124, 144, 7, C.dim, 'right', 700);
        // CPU 막대
        rr(g, x + 8, 152, 116, 6, 3);
        g.fillStyle = '#232b4c';
        g.fill();
        rr(g, x + 8, 152, 116 * (bad ? 0.82 : 0.08), 6, 3);
        g.fillStyle = bad ? C.red : C.green;
        g.fill();
        const cpu = res ? `${(bad ? res.msA : res.msB).toFixed(2)}ms` : bad ? '약 7ms' : '약 0.6ms';
        txt(g, bad ? `장면 하나 CPU ${cpu}` : `CPU ${cpu} · 화질 그대로`, x + 8, 164, 5.4, bad ? '#ffb0b8' : '#a9f2d2', 'left', 700);
      }
      if (isBig(w)) resultStrip(g, msg || (res ? `실측 (상자 1,500개 · 재질 8) — 그리기 호출 ${res.callsA} → ${res.callsB} · 한 장면 CPU ${res.msA.toFixed(2)}ms → ${res.msB.toFixed(2)}ms` : '「진짜로 재기」 단추 → 이 기기에서 실제로 잰 값이 여기에 나와요'), !!res && !msg);
      g.restore();
    },
    controls: [
      {
        type: 'button', label: '진짜로 재기 (상자 1,500개)', on: () => {
          msg = '재는 중…';
          setTimeout(() => {
            try { res = measureMerge(); msg = ''; } catch { msg = '잴 수 없음 (WebGL)'; }
          }, 50);
        },
      },
    ] as Control[],
  };
}
```

### i391 견본 항목 — `src/demos/demosPerf.ts:1618`
```ts
  i391: { kind: '2d', caption: '물체 하나씩 그리기 1,500번 vs 재질끼리 합쳐 80번 — 화질은 그대로', make: demo391 }
```

## 관련 기술
- 먼저 알면 좋은 기술: [인스턴싱 (InstancedMesh)](https://ai-techstudio.web.app/ai/t/u36.md) `u36`
- 다음에 해 볼 기술: [바뀔 때만 그리기 (멈춘 화면은 쉬기)](https://ai-techstudio.web.app/ai/t/u81.md) `u81` · [LOD · 텍스처 예산 (폰에서도)](https://ai-techstudio.web.app/ai/t/i478.md) `i478`
- 참고 문서: [three.js 문서 — BufferGeometryUtils](https://threejs.org/docs/#examples/en/utils/BufferGeometryUtils)
