# AI 꾸러미 — 빌보드 대역 (impostor) — Impostor (billboard impostor)
> 먼 나무를 8방향에서 한 번 찍어 그림 한 장(아틀라스)에 구워 두고, 보는 각도에 맞는 그림을 골라 섞은 판 하나로 그려 숲 수백 그루를 가볍게 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i232

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

## 주문서

### 만들어 줘: 빌보드 대역 (impostor) — Impostor (billboard impostor)

#### 1. 목표
먼 숲의 나무를 빌보드 대역으로 그려 줘 — 진짜 모델을 8방향에서 한 번 찍어 한 장에 굽고, 판 수백 개가 보는 각도에 맞는 그림 두 장을 골라 섞기. 가까운 몇 개만 진짜 모델로. 분위기는 밝은 낮 들판.

#### 2. 핵심 기술 용어
- **Impostor (billboard impostor)** — 빌보드 대역 — 입체처럼 보이는 그림 판
- **Render to texture atlas (WebGLRenderTarget + viewport)** — 여러 방향 그림을 한 장에 굽기
- **Cylindrical (Y-axis) billboard** — 세로축만 돌아 카메라를 보는 판
- **InstancedBufferGeometry** — 판 하나를 수백 번 — 위치 · 돌림 · 크기는 인스턴스 속성

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

#### 4. 조건
- 굽기는 처음 render 때 한 번 — 굽는 장면의 빛을 본 장면과 같게 (안 그러면 대역만 밝기가 다르다)
- 굽는 동안 clearColor 알파 0, autoClear 끄고 viewport 를 칸마다 바꿔 한 장에
- 판은 세로축만 돈다 (위아래로 기울지 않게) — right = normalize(toC.z, 0, −toC.x)
- 이웃 두 방향 그림을 섞어 바뀌는 순간이 안 보이게, 섞기 켬/끔 조절
- 대역 판의 frustumCulled = false (인스턴스가 넓게 퍼져 경계 상자가 맞지 않음)

#### 5. 완성 기준 (이게 보이면 성공)
- 카메라가 숲을 돌아도 먼 나무가 입체처럼 옆모습이 바뀌어 보인다
- 「대역 판 드러내기」를 켜면 분홍 사각형 판이 보여 그림 한 장임을 알 수 있다
- 「각도 사이 섞기」를 끄면 그림이 툭툭 바뀌고, 켜면 부드럽다
- 대역 나무 수 슬라이더를 1,400 까지 올려도 프레임이 거의 그대로다

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

## 원리
- 진짜 나무 하나는 삼각형 수백~수천 개, 대역 판 하나는 삼각형 2개.
- 처음 그릴 때 한 번, 직교 카메라로 나무를 8방향(45°마다)에서 찍어 128×256 칸 8개(1024×256 한 장)에 굽는다.
- 정점 셰이더가 판을 세로축으로 돌려 카메라를 보게 하고, 카메라 쪽 각도 − 나무마다 돌림을 8칸으로 나눠 어느 그림을 쓸지 고른다.
- 사이 각도는 이웃 두 그림을 fract 만큼 섞는다 — 끄면 걸을 때 그림이 툭툭 바뀐다.
- 알파가 0.5 미만이면 discard, 안개는 셰이더에서 거리로 직접 섞는다.

## 핵심 코드 — 8방향 굽기 + 각도로 그림 고르는 정점 셰이더
(발췌: demos/demosLook2.ts makeImpostor() bake() · vertexShader)
```ts
const FR = 8; // 방향 수
const atlas = new THREE.WebGLRenderTarget(FR * 128, 256, { generateMipmaps: true, minFilter: THREE.LinearMipmapLinearFilter });
const bakeCam = new THREE.OrthographicCamera(-0.75, 0.75, 2.6, -0.4, 0.1, 20);

function bake(r: THREE.WebGLRenderer, bakeScene: THREE.Scene): void {
  r.setRenderTarget(atlas);
  r.setClearColor(0x000000, 0); // 빈 곳은 투명
  r.clear();
  const ac = r.autoClear;
  r.autoClear = false;
  for (let k = 0; k < FR; k++) {
    const a = (k / FR) * Math.PI * 2;
    bakeCam.position.set(Math.sin(a) * 6, 1.1, Math.cos(a) * 6);
    bakeCam.lookAt(0, 1.1, 0);
    atlas.viewport.set(k * 128, 0, 128, 256); // k 번째 칸에
    r.setRenderTarget(atlas);
    r.render(bakeScene, bakeCam);
  }
  r.autoClear = ac;
  atlas.viewport.set(0, 0, FR * 128, 256);
  r.setRenderTarget(null);
}

// 인스턴스 속성 aI = (x, z, 돌림, 크기)
const vertexShader = [
  'attribute vec4 aI; varying vec2 vUv; varying float vF;',
  'void main(){',
  '  vec3 base = vec3(aI.x, 0.0, aI.y);',
  '  vec3 toC = cameraPosition - base;',
  '  float ang = atan(toC.x, toC.z) - aI.z;',
  '  vF = mod(ang / 6.2831853 * 8.0, 8.0);            // 몇 번째 그림인가 (소수 = 섞기)',
  '  vec3 right = normalize(vec3(toC.z, 0.0, -toC.x)); // 세로축만 돌기',
  '  vec3 p = base + right * (position.x * 1.5 * aI.w) + vec3(0.0, (position.y * 3.0 - 0.4) * aI.w, 0.0);',
  '  vUv = uv;',
  '  gl_Position = projectionMatrix * viewMatrix * vec4(p, 1.0);',
  '}',
].join('\n');
// 조각 셰이더: f0 = floor(vF), f1 = f0 + 1 칸을 texture2D 로 읽어 mix(a, b, fract(vF)), 알파 0.5 미만 discard
```

## 흔한 실수 · 확인 목록
- [ ] **굽기 장면의 빛이 본 장면과 다르면 대역만 어둡거나 밝다** — 굽는 장면에 같은 반구광 · 해(같은 세기 · 방향)를 넣는다. 견본은 둘 다 반구광 1.3 · 해 2.4.
- [ ] **칸 가장자리까지 읽으면 이웃 칸 그림이 번져 보인다** — u 좌표를 0.02 ~ 0.98 로 잘라 읽는다 (밉맵이 이웃 칸을 섞기 때문).
- [ ] **판이 카메라를 완전히 바라보게(구형 빌보드) 하면 내려다볼 때 나무가 눕는다** — 세로축만 돌게 하고, 너무 위에서 보는 구도는 진짜 모델 · 인스턴싱으로.
- [ ] **렌더러 clearColor 를 되돌리지 않으면 본 장면 배경이 투명해진다** — 굽기 전 getClearColor · getClearAlpha 를 기억했다가 끝나면 setClearColor 로 되돌린다.

## 완성 기준 체크리스트
- [ ] 카메라가 숲을 돌아도 먼 나무가 입체처럼 옆모습이 바뀌어 보인다
- [ ] 「대역 판 드러내기」를 켜면 분홍 사각형 판이 보여 그림 한 장임을 알 수 있다
- [ ] 「각도 사이 섞기」를 끄면 그림이 툭툭 바뀌고, 켜면 부드럽다
- [ ] 대역 나무 수 슬라이더를 1,400 까지 올려도 프레임이 거의 그대로다

## 이 기술 정보
- id: `i232` · 분류: 게임 시스템 · AI › 속도 기법 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 대역 900그루 = 삼각형 1,800개 · 그리기 1번. 굽기는 처음 한 번(1024×256 렌더 타깃 하나, 약 1MB).
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i232
- 쓰면 좋을 때: 멀리 보이는 같은 모양이 수백 그루 넘을 때 (숲 · 관중) / 카메라가 땅 높이에서 수평으로 돌아보는 장면
- 쓰지 말 때: 바로 위에서 내려다보는 구도 — 옆에서 찍은 판이 납작하게 보인다 (대신 인스턴싱 진짜 모델) / 가까이 다가가는 물체 — 가까운 것은 진짜 모델로 (견본도 6그루는 진짜)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeImpostor — `src/demos/demosLook2.ts:2104`
```ts
function makeImpostor(): Scene3D {
  const scene = new THREE.Scene();
  scene.background = gradBg([
    [0, '#78b8f0'],
    [0.8, '#d8ecf5'],
    [1, '#eef3e0'],
  ]);
  scene.fog = new THREE.Fog(0xd8ecf5, 14, 40);
  const cam = new THREE.PerspectiveCamera(45, 1.6, 0.1, 100);
  const hemi = new THREE.HemisphereLight(0xe8f4ff, 0x5a7040, 1.3);
  const dl = new THREE.DirectionalLight(0xfff0d8, 2.4);
  dl.position.set(4, 8, 6);
  scene.add(hemi, dl);
  const root = new THREE.Group();
  scene.add(root);
  root.add(new THREE.Mesh(new THREE.CircleGeometry(60, 48).rotateX(-Math.PI / 2), new THREE.MeshLambertMaterial({ color: 0x7fb257 })));
  const proto = buildTree();
  const tris = triCount(proto);
  const near: THREE.Object3D[] = [];
  for (let i = 0; i < 6; i++) {
    const t = proto.clone();
    const a = (i / 6) * Math.PI * 2 + 0.3;
    t.position.set(Math.cos(a) * 2.2, 0, Math.sin(a) * 2.2);
    t.rotation.y = a * 2.3;
    root.add(t);
    near.push(t);
  }
  // 굽기 (처음 그릴 때 한 번): 8 방향 × 128×256
  const FR = 8;
  const atlas = new THREE.WebGLRenderTarget(FR * 128, 256, { generateMipmaps: true, minFilter: THREE.LinearMipmapLinearFilter, magFilter: THREE.LinearFilter });
  const bakeScene = new THREE.Scene();
  const bakeTree = proto.clone();
  bakeScene.add(bakeTree, new THREE.HemisphereLight(0xe8f4ff, 0x5a7040, 1.3));
  const bdl = new THREE.DirectionalLight(0xfff0d8, 2.4);
  bdl.position.set(4, 8, 6);
  bakeScene.add(bdl);
  const bakeCam = new THREE.OrthographicCamera(-0.75, 0.75, 2.6, -0.4, 0.1, 20);
  let baked = false;
  const bake = (r: THREE.WebGLRenderer) => {
    const cc = r.getClearColor(new THREE.Color());
    const ca = r.getClearAlpha();
    atlas.scissorTest = false;
    atlas.viewport.set(0, 0, FR * 128, 256);
    r.setRenderTarget(atlas);
    r.setClearColor(0x000000, 0);
    r.clear();
    const ac = r.autoClear;
    r.autoClear = false;
    for (let k = 0; k < FR; k++) {
      const a = (k / FR) * Math.PI * 2;
      bakeCam.position.set(Math.sin(a) * 6, 1.1, Math.cos(a) * 6);
      bakeCam.lookAt(0, 1.1, 0);
      atlas.viewport.set(k * 128, 0, 128, 256);
      r.setRenderTarget(atlas);
      r.render(bakeScene, bakeCam);
    }
    r.autoClear = ac;
    atlas.viewport.set(0, 0, FR * 128, 256);
    r.setRenderTarget(null);
    r.setClearColor(cc, ca);
    baked = true;
  };
  // 대역 숲
  const MAX = 1400;
  const qg = new THREE.PlaneGeometry(1, 1).translate(0, 0.5, 0);
  const geo = new THREE.InstancedBufferGeometry();
  geo.index = qg.index;
  geo.setAttribute('position', qg.attributes.position!);
  geo.setAttribute('uv', qg.attributes.uv!);
  const inst = new Float32Array(MAX * 4);
  const r0 = rng(21);
  for (let i = 0; i < MAX; i++) {
    const a = r0() * Math.PI * 2;
    const rr = 8.5 + Math.sqrt(r0()) * 24;
    inst[i * 4] = Math.cos(a) * rr;
    inst[i * 4 + 1] = Math.sin(a) * rr;
    inst[i * 4 + 2] = r0() * Math.PI * 2;
    inst[i * 4 + 3] = 0.8 + r0() * 0.5;
  }
  geo.setAttribute('aI', new THREE.InstancedBufferAttribute(inst, 4));
  geo.instanceCount = 900;
  const uni = { uAtlas: { value: atlas.texture }, uShow: { value: 0 }, uBlend: { value: 1 }, fogColor: { value: new THREE.Color(0xd8ecf5) } };
  const imp = new THREE.Mesh(
    geo,
    new THREE.ShaderMaterial({
      uniforms: uni,
      vertexShader: /* glsl */ `
        attribute vec4 aI; varying vec2 vUv; varying float vF; varying float vD;
        void main(){
          vec3 base = vec3(aI.x, 0.0, aI.y);
          vec3 toC = cameraPosition - base;
          float ang = atan(toC.x, toC.z) - aI.z;
          vF = mod(ang / 6.2831853 * 8.0, 8.0);
          vec3 right = normalize(vec3(toC.z, 0.0, -toC.x));
          vec3 p = base + right * (position.x * 1.5 * aI.w) + vec3(0.0, (position.y * 3.0 - 0.4) * aI.w, 0.0);
          vUv = uv;
          vec4 mv = viewMatrix * vec4(p, 1.0); vD = -mv.z;
          gl_Position = projectionMatrix * mv;
        }`,
      fragmentShader: /* glsl */ `
        uniform sampler2D uAtlas; uniform float uShow, uBlend; uniform vec3 fogColor;
        varying vec2 vUv; varying float vF; varying float vD;
        void main(){
          float f0 = floor(vF); float f1 = mod(f0 + 1.0, 8.0); float k = fract(vF) * uBlend;
          if (uBlend < 0.5) { f0 = mod(floor(vF + 0.5), 8.0); }
          float ux = clamp(vUv.x, 0.02, 0.98);
          vec4 a = texture2D(uAtlas, vec2((f0 + ux) / 8.0, vUv.y));
          vec4 b = texture2D(uAtlas, vec2((f1 + ux) / 8.0, vUv.y));
          vec4 c = mix(a, b, k);
          float edge = 1.0 - step(0.03, min(min(vUv.x, 1.0 - vUv.x), min(vUv.y, 1.0 - vUv.y)));
          if (uShow > 0.5){
            if (c.a < 0.5) c = vec4(1.0, 0.3, 0.6, 0.0);
            c.rgb = mix(c.rgb, vec3(1.0, 0.2, 0.55), max(edge, 0.25));
          } else if (c.a < 0.5) discard;
          vec3 col = mix(c.rgb, fogColor, smoothstep(14.0, 40.0, vD));
          gl_FragColor = vec4(col, 1.0);
          ${OUT}
        }`,
    }),
  );
  imp.frustumCulled = false;
  root.add(imp);
  const hud = new Hud();
  const a = hud.add(`진짜 나무 1그루 = 삼각형 ${tris}개`);
  const b = hud.add('대역 1그루 = 삼각형 2개 (그림 8장 중 골라 섞기)', 'rgba(140,30,80,0.75)');
  hud.at(a, 0.03, 0.05, 0, 0);
  hud.at(b, 0.03, 0.95, 0, 1);
  return {
    scene,
    camera: cam,
    update(t) {
      const a2 = t * 0.22;
      cam.position.set(Math.sin(a2) * 6.2, 3.0, Math.cos(a2) * 6.2);
      cam.lookAt(0, 1.3, 0);
    },
    render(r, w, h) {
      if (!baked) bake(r);
      r.render(scene, cam);
      hud.draw(r, w, h);
    },
    controls: [
      { type: 'toggle', label: '대역 판 드러내기', value: false, on: (v) => (uni.uShow.value = v ? 1 : 0) },
      { type: 'toggle', label: '각도 사이 섞기 (끄면 툭툭 바뀜)', value: true, on: (v) => (uni.uBlend.value = v ? 1 : 0) },
      { type: 'range', label: '대역 나무 수', min: 100, max: MAX, step: 50, value: 900, on: (v) => (geo.instanceCount = v) },
    ],
    dispose() {
      freeAll(root, bakeScene);
      qg.dispose();
      atlas.dispose();
      hud.dispose();
      (scene.background as THREE.Texture).dispose();
      void near;
    },
  };
}
```

### i232 견본 항목 — `src/demos/demosLook2.ts:4516`
```ts
  i232: { kind: '3d', caption: '가까운 6그루만 진짜, 먼 숲 900그루는 8방향에서 구운 그림 판 — 보는 각도에 맞는 그림을 골라 섞음', make: () => makeImpostor() }
```

## 관련 기술
- 먼저 알면 좋은 기술: [인스턴싱 (InstancedMesh)](https://ai-techstudio.web.app/ai/t/u36.md) `u36`
- 다음에 해 볼 기술: [LOD · 텍스처 예산 (폰에서도)](https://ai-techstudio.web.app/ai/t/i478.md) `i478`
