# AI 꾸러미 — LOD · 텍스처 예산 (폰에서도) — Level of detail (THREE.LOD)
> 거리에 따라 정밀 → 중간 → 단순 모델로 바꾸고 텍스처 크기를 정해, 폰에서도 삼각형 수 · GPU 메모리 · 받을 크기를 예산 안에 맞춘다.  
> 견본: https://ai-techstudio.web.app/#t/i478

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

## 주문서

### 만들어 줘: LOD · 텍스처 예산 (폰에서도) — Level of detail (THREE.LOD)

#### 1. 목표
기둥이 늘어선 복도에 LOD 와 텍스처 예산을 넣어 줘 — 모델을 정밀 · 중간 · 단순 3단계로 만들어 거리 0 · 9 · 22 에서 바꾸고, 텍스처는 256 · 512 · 1024 중 고르게. 삼각형 수 · GPU 메모리 · 받을 크기 표를 단계마다 색(초록 · 노랑 · 빨강)(으)로.

#### 2. 핵심 기술 용어
- **Level of detail (THREE.LOD)** — 거리별 모델 단계
- **Texture memory budget** — 텍스처 메모리 예산 — 너비 × 높이 × 4바이트 × 밉맵 4/3
- **renderer.info.render.triangles** — 이번 장면에 그린 삼각형 수
- **Mipmaps** — 멀리 볼 때 쓰는 작은 그림 단계

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

#### 4. 조건
- 단계는 3개 — 정밀 · 중간 · 단순, 바꾸는 거리는 화면에서 크기가 반쯤 줄 때마다
- 모든 단계의 겉모양(크기 · 색 · 위치)이 같아야 바뀌는 순간이 안 보인다
- 텍스처는 폰에서 1024 이하, 한 장면 GPU 메모리를 숫자로 표시 (너비 × 높이 × 4 × 4/3)
- 「LOD 쓰기」 끔이면 모두 정밀 — 전/후 삼각형 수 비교
- 텍스처를 바꿀 때 예전 것을 dispose

#### 5. 완성 기준 (이게 보이면 성공)
- 단계 색 보기를 켜면 가까운 기둥은 초록 · 중간은 노랑 · 먼 것은 빨강으로, 카메라가 움직이면 색이 바뀐다
- 「LOD 쓰기」를 끄면 삼각형 수가 크게 늘어난다 (표에 「LOD 끄면 기둥만 …개」)
- 텍스처 크기 0 · 1 · 2 를 바꾸면 GPU MB · 받을 MB 가 바뀐다

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

## 원리
- 멀리 있는 물체는 화면에서 몇 픽셀뿐 — 삼각형 수천 개가 필요 없다.
- THREE.LOD 에 단계를 addLevel(메시, 거리) 로 넣으면, 렌더러가 카메라 거리로 하나만 보이게 한다 (autoUpdate).
- 견본 기둥: 정밀(둘레 96 · 세로 40 · 홈 16) · 중간(20 · 8) · 단순(6 · 1), 거리 0 · 9 · 22.
- 텍스처 GPU 메모리 = 너비 × 높이 × 4바이트 × 4/3(밉맵). 1024² 한 장 ≈ 5.3MB, 6장이면 32MB — 512² 로 줄이면 1/4.

## 핵심 코드 — 3단계 LOD + 텍스처 메모리 계산
(발췌: demos/demosRender.ts makeLod() 를 정리)
```ts
// 정밀 · 중간 · 단순 기하 (lodColumn(둘레 칸, 세로 칸, 홈 수))
const geos = [lodColumn(96, 40, 16), lodColumn(20, 8, 0), lodColumn(6, 1, 0)];
const DIST = [0, 9, 22];

for (let k = 0; k < 22; k++) {
  const lod = new THREE.LOD();
  lod.position.set(k % 2 ? 3.8 : -3.8, 0, 6 - Math.floor(k / 2) * 5.6);
  for (let i = 0; i < 3; i++) lod.addLevel(new THREE.Mesh(geos[i], mats[i]), DIST[i]);
  scene.add(lod); // 렌더러가 카메라 거리로 단계를 고른다 (lod.autoUpdate = true)
}

// 텍스처 GPU 메모리 — RGBA 4바이트 × 밉맵 4/3
const gpuMB = (size: number, count: number): number => (count * size * size * 4 * (4 / 3)) / 1048576;
console.log(gpuMB(1024, 6).toFixed(1), 'MB'); // 32.0
console.log(gpuMB(512, 6).toFixed(1), 'MB');  // 8.0

// 그린 뒤 확인
renderer.render(scene, camera);
const { triangles, calls } = renderer.info.render;
```

## 흔한 실수 · 확인 목록
- [ ] **단계마다 색 · 크기가 조금씩 다르면 바뀌는 순간 「툭」 튄다** — 같은 재질 · 같은 바깥 크기로 만들고, 바꾸는 거리를 화면 크기가 충분히 작아진 뒤로.
- [ ] **텍스처를 다 받기 전에 그리면 첫 프레임이 멈칫한다** — 받은 뒤 GPU 에 올리고(텍스처 업로드) · 셰이더를 데운 뒤 보이게 (견본은 「텍스처 올리는 중 · 셰이더 굽는 중」 단계).
- [ ] **텍스처 크기만 보고 메모리를 어림하면 밉맵 몫을 빠뜨린다** — × 4/3 을 곱한다 — 1024² 는 4MB 가 아니라 약 5.3MB.

## 완성 기준 체크리스트
- [ ] 단계 색 보기를 켜면 가까운 기둥은 초록 · 중간은 노랑 · 먼 것은 빨강으로, 카메라가 움직이면 색이 바뀐다
- [ ] 「LOD 쓰기」를 끄면 삼각형 수가 크게 늘어난다 (표에 「LOD 끄면 기둥만 …개」)
- [ ] 텍스처 크기 0 · 1 · 2 를 바꾸면 GPU MB · 받을 MB 가 바뀐다

## 이 기술 정보
- id: `i478` · 분류: 게임 시스템 · AI › 속도 기법 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — LOD 는 거리 계산뿐. 메모리는 단계 모델만큼 조금 늘지만, 그리는 삼각형 · 텍스처 메모리는 크게 준다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i478
- 쓰면 좋을 때: 같은 모델이 멀리까지 수십 개 늘어선 장면 / 폰에서 메모리 부족 · 다운로드가 큰 3D 게임
- 쓰지 말 때: 위에서 내려다보는 판 게임 — 모든 물체가 거의 같은 거리라 바뀔 일이 없다 / 단계가 바뀌는 순간이 눈에 띄는 아주 가까운 물체 — 대신 처음부터 가벼운 모델

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeLod — `src/demos/demosRender.ts:1774`
```ts
function makeLod(T: typeof THREE): Scene3D {
  const scene = new T.Scene();
  const BG = 0x0d0f16;
  scene.background = new T.Color(BG);
  scene.fog = new T.Fog(BG, 20, 58);
  const cam = new T.PerspectiveCamera(50, 1.6, 0.1, 120);
  const hud = new Hud();
  const msg = hud.label('재질 받는 중 …', 0.5, 0.5, 0.5, 0.5, 'tag', 0.8);
  const top = hud.label('초록 정밀 · 노랑 중간 · 빨강 단순', 0.5, 0.965, 0.5, 1, 'tag', 0.62);
  top.maxW = 0.9;
  const board = hud.board(0.015, 0.03, 0, 0, 0.95);
  top.visible = false;
  board.spr.visible = false;

  scene.add(new T.HemisphereLight(0xcfdcff, 0x302820, 0.6));
  const sun = new T.DirectionalLight(0xfff0dc, 2.2);
  sun.position.set(-6, 10, 4);
  scene.add(sun);
  const geos = [lodColumn(T, 96, 40, 16), lodColumn(T, 20, 8, 0), lodColumn(T, 6, 1, 0)];
  const triOf = geos.map((g) => g.index!.count / 3);
  const floorG = new T.PlaneGeometry(26, 76);
  const wallG = new T.PlaneGeometry(76, 5.5);
  const lods: THREE.LOD[] = [];
  let mats: THREE.MeshStandardMaterial[] = [];
  let floorM: THREE.MeshStandardMaterial | null = null;
  let wallM: THREE.MeshStandardMaterial | null = null;
  const floor = new T.Mesh(floorG);
  floor.rotation.x = -Math.PI / 2;
  floor.position.z = -22;
  const wl = new T.Mesh(wallG);
  wl.position.set(-7, 2.75, -22);
  wl.rotation.y = Math.PI / 2;
  const wr = new T.Mesh(wallG);
  wr.position.set(7, 2.75, -22);
  wr.rotation.y = -Math.PI / 2;
  const holder = new T.Group();
  holder.visible = false;
  holder.add(floor, wl, wr);
  scene.add(holder);
  const COUNT = 22;
  for (let k = 0; k < COUNT; k++) {
    const lod = new T.LOD();
    lod.position.set(k % 2 ? 3.8 : -3.8, 0, 6 - Math.floor(k / 2) * 5.6);
    for (let i = 0; i < 3; i++) {
      const m = new T.Mesh(geos[i]!);
      lod.addLevel(m, [0, 9, 22][i]!);
    }
    holder.add(lod);
    lods.push(lod);
  }

  let sizeI = 2;
  let cur: { size: number; pbr: { brick: PBR; rock: PBR } } | null = null;
  let tint = true;
  let useLod = true;
  let moving = true;
  let now = 0;
  let dead = false;
  const warm = new Warm();
  const applyMats = (): void => {
    if (!cur) return;
    for (const m of mats) m.dispose();
    floorM?.dispose();
    wallM?.dispose();
    const { brick, rock } = cur.pbr;
    mats = [0, 1, 2].map(() => pbrMat(brick, 1, 1));
    floorM = pbrMat(rock, 8, 24);
    wallM = pbrMat(brick, 22, 1.6);
    floor.material = floorM;
    wl.material = wr.material = wallM;
    for (const lod of lods) lod.levels.forEach((lv, i) => ((lv.object as THREE.Mesh).material = mats[i]!));
    paint();
  };
  const paint = (): void => mats.forEach((m, i) => m.color.set(tint ? LOD_TINT[i]! : 0xffffff));
  const want = (i: number): void => {
    sizeI = i;
    const sz = SIZES[i]!;
    Promise.all([loadPBR('brick', sz), loadPBR('rock', sz)]).then(([brick, rock]) => {
      if (dead || SIZES[sizeI] !== sz) return;
      cur = { size: sz, pbr: { brick, rock } };
      applyMats();
      holder.visible = true;
    });
  };
  want(2);
  const kb1k = KB1K.brick.diff + KB1K.brick.nor + KB1K.brick.arm + KB1K.rock.diff + KB1K.rock.nor + KB1K.rock.arm;
  const dlK = [0.085, 0.28, 1];
  let stat = { tri: 0, calls: 0 };
  let lastBoard = -1;

  return {
    scene,
    camera: cam,
    update(t) {
      if (moving) now = t;
      const k = 0.5 - 0.5 * Math.cos(now * 0.32);
      const z = 9 - k * 34;
      cam.position.set(Math.sin(now * 0.21) * 1.2, 2.3, z);
      cam.lookAt(0, 1.5, z - 12);
      for (const lod of lods) {
        lod.autoUpdate = useLod;
        if (!useLod) lod.levels.forEach((lv, i) => (lv.object.visible = i === 0));
      }
    },
    render(r, w, h) {
      if (!cur) return waiting(r, w, h, hud, msg, '재질 받는 중 …');
      if (!pump(r, sceneTex(holder))) return waiting(r, w, h, hud, msg, 'GPU 에 텍스처 올리는 중 …');
      const ok = warm.ready(r, (rr) => withShadows(rr, false, () => rr.compileAsync(scene, cam)));
      if (!ok) return waiting(r, w, h, hud, msg, '셰이더 굽는 중 …');
      msg.visible = false;
      top.visible = tint;
      board.spr.visible = true;
      withShadows(r, false, () => r.render(scene, cam));
      const inf = r.info.render;
      stat = { tri: inf.triangles, calls: inf.calls };
      const q = Math.floor(performance.now() / 250);
      if (q !== lastBoard) {
        lastBoard = q;
        const full = triOf[0]! * COUNT;
        const rows: [string, string, boolean?][] = [[`삼각형 ${fmt(stat.tri)} · 그리기 ${stat.calls}번`, '#ffffff', true]];
        rows.push([useLod ? `LOD 끄면 기둥만 ${fmt(full)}개` : `LOD 꺼짐 — 모두 정밀 (${fmt(triOf[0]!)}/개)`, '#aab4c8']);
        SIZES.slice()
          .reverse()
          .forEach((s) => {
            const i = SIZES.indexOf(s);
            const mb = (6 * s * s * 4 * (4 / 3)) / 1048576;
            const dl = (kb1k * dlK[i]!) / 1024;
            const on = i === sizeI;
            rows.push([`${on ? '▶ ' : '   '}${s}² 텍스처 6장 · GPU ${mb.toFixed(1)}MB · 받기 ${i === 2 ? '' : '약 '}${dl.toFixed(1)}MB`, on ? '#ffd166' : '#8890a4', on]);
          });
        board.set('LOD · 텍스처 예산', rows);
      }
      hud.draw(r, w, h);
    },
    controls: [
      { type: 'toggle', label: 'LOD 쓰기', value: true, on: (v) => (useLod = v) },
      { type: 'toggle', label: '단계 색 보기', value: true, on: (v) => ((tint = v), paint()) },
      { type: 'range', label: '텍스처 크기 (0=256 · 1=512 · 2=1024)', min: 0, max: 2, step: 1, value: 2, on: (v) => want(v) },
      { type: 'toggle', label: '카메라 움직이기', value: true, on: (v) => (moving = v) },
    ],
    dispose() {
      dead = true;
      for (const g of geos) g.dispose();
      floorG.dispose();
      wallG.dispose();
      for (const m of mats) m.dispose();
      floorM?.dispose();
      wallM?.dispose();
      hud.dispose();
    },
  };
}
```

### i478 견본 항목 — `src/demos/demosRender.ts:2351`
```ts
  i478: { kind: '3d', caption: '기둥 22개가 거리마다 정밀 → 중간 → 단순 모델로 — 삼각형 · 텍스처 메모리 표', make: makeLod }
```

## 관련 기술
- 먼저 알면 좋은 기술: [고정 물체 합치기 (그리기 호출 줄이기)](https://ai-techstudio.web.app/ai/t/i391.md) `i391` · [빌보드 대역 (impostor)](https://ai-techstudio.web.app/ai/t/i232.md) `i232`
- 다음에 해 볼 기술: [바뀔 때만 그리기 (멈춘 화면은 쉬기)](https://ai-techstudio.web.app/ai/t/u81.md) `u81`
- 참고 문서: [three.js 예제 — webgl_lod](https://threejs.org/examples/#webgl_lod) · [three.js 문서 — LOD](https://threejs.org/docs/#api/en/objects/LOD)
