# AI 꾸러미 — 소행성대 인스턴싱 + 렌즈 플레어 — InstancedMesh asteroid field
> 잡음 · 크레이터로 빚은 바위 5종을 InstancedMesh 로 4천 개 띄우고, 바위마다 회전은 정점 셰이더(축 · 빠르기 속성)로 CPU 0 — 해를 보면 렌즈 플레어.  
> 견본: https://ai-techstudio.web.app/#t/i531

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

## 주문서

### 만들어 줘: 소행성대 인스턴싱 + 렌즈 플레어 — InstancedMesh asteroid field

#### 1. 목표
우주 게임 배경 · 장애물에 소행성대를 만들어 줘 — 바위 5종 인스턴싱 4천 개 · 셰이더 회전 · 해 쪽 먼지 띠 · 렌즈 플레어, 느낌은 사실적인 우주.

#### 2. 핵심 기술 용어
- **InstancedMesh asteroid field** — 같은 바위 모양을 수천 개 한 번에 그리기
- **Per-instance spin in vertex shader** — 인스턴스마다 축 · 빠르기 속성으로 셰이더에서 돌리기
- **Procedural rock (noise + craters)** — 잡음 변위 · 크레이터로 빚은 바위 모양
- **three.js Lensflare** — 해를 볼 때 렌즈 안 빛 반사 무늬 (addons)

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

#### 4. 조건
- 바위 모양은 몇 종(5)만 만들고 InstancedMesh 로 돌려 쓴다 — 바위마다 메시 만들기 금지
- 회전은 onBeforeCompile 로 begin_vertex · beginnormal_vertex 에 같은 회전을 (법선도 돌려야 빛이 맞다)
- 빠르기는 크기가 작을수록 빠르게: (0.05 + 무작위 × 0.5) / √(크기 + 0.2)
- 인스턴스 색(setColorAt)으로 바위마다 살짝 다른 빛깔, 재질은 vertexColors 로 크레이터 그늘
- Lensflare 는 three/examples/jsm/objects/Lensflare.js, 해 빛에 add, 끝나면 dispose

#### 5. 완성 기준 (이게 보이면 성공)
- 바위 수천 개가 띠를 이루고 하나하나 다른 축 · 빠르기로 돈다
- 해 쪽을 보면 먼지 띠가 빛나고, 화면에 렌즈 플레어 무늬가 늘어선다
- 「소행성 수」를 0.1 ~ 1 배로 바꾸면 count 만 바뀌어 바로 반영된다
- CPU 프레임 시간이 바위 수와 거의 상관없다 (회전이 셰이더라서)

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

## 원리
- 바위 모양 5종을 만든다: 정이십면체(4단 분할) 정점을 잡음 5겹으로 밀고, 크레이터 9개를 파고, 축마다 다르게 늘인다. 정점 색으로 크레이터를 어둡게.
- 모양마다 InstancedMesh 820개 — 반지름 80 ± 6.5 띠 둘레에 뿌리고 크기는 작은 것이 많게(r⁴).
- 회전은 CPU 가 안 한다. 인스턴스마다 축 · 빠르기(작을수록 빠르게)를 속성 aSpin 으로 넣고, 정점 셰이더에서 로드리게스 회전으로 돌린다 — 법선도 같이.
- 띠 둘레에 먼지 판(링)을 깔고, 해 쪽을 볼수록 밝게(전방 산란 pow(V·해, 6)).
- 해 점광에 Lensflare 를 붙인다: 큰 빛 1개 + 육각 · 둥근 반사 5개를 화면 중심 반대쪽 줄에 늘어놓는다.

## 핵심 코드 — 인스턴스마다 축 · 빠르기 속성 + 셰이더 회전
(발췌: demos/demosSpace.ts i531 make() · SPIN_GLSL 을 정리 (바위 모양은 rockGeometry()))
```ts
import * as THREE from 'three';

const SPIN = 'vec3 spinR(vec3 p, vec3 ax, float a){ float c = cos(a), s = sin(a);' +
  ' return p * c + cross(ax, p) * s + ax * dot(ax, p) * (1.0 - c); }'; // 로드리게스 회전
const uTime = { value: 0 };
const PER = 820;

function makeBelt(geo: THREE.BufferGeometry): THREE.InstancedMesh {
  const mat = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.93, metalness: 0, vertexColors: true });
  mat.onBeforeCompile = (sh) => {
    sh.uniforms['uTime'] = uTime;
    sh.vertexShader = sh.vertexShader
      .replace('#include <common>', '#include <common>' + String.fromCharCode(10) + 'attribute vec4 aSpin; uniform float uTime; ' + SPIN)
      .replace('#include <beginnormal_vertex>', '#include <beginnormal_vertex>' + String.fromCharCode(10) + 'objectNormal = spinR(objectNormal, aSpin.xyz, uTime * aSpin.w);')
      .replace('#include <begin_vertex>', '#include <begin_vertex>' + String.fromCharCode(10) + 'transformed = spinR(transformed, aSpin.xyz, uTime * aSpin.w);');
  };
  const im = new THREE.InstancedMesh(geo, mat, PER);
  const spin = new Float32Array(PER * 4);
  const m4 = new THREE.Matrix4(), q = new THREE.Quaternion(), v = new THREE.Vector3(), sc = new THREE.Vector3();
  for (let i = 0; i < PER; i++) {
    const a = Math.random() * Math.PI * 2;
    const rad = 80 + gauss() * 6.5;
    v.set(Math.cos(a) * rad, gauss() * 1.6, Math.sin(a) * rad);
    const s = 0.05 + Math.pow(Math.random(), 4) * 1.2;       // 작은 바위가 훨씬 많다
    q.setFromEuler(new THREE.Euler(Math.random() * 6.28, Math.random() * 6.28, Math.random() * 6.28));
    im.setMatrixAt(i, m4.compose(v, q, sc.set(s, s, s)));
    const ax = new THREE.Vector3(Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5).normalize();
    spin.set([ax.x, ax.y, ax.z, (0.05 + Math.random() * 0.5) / Math.sqrt(s + 0.2)], i * 4); // 작을수록 빨리
  }
  geo.setAttribute('aSpin', new THREE.InstancedBufferAttribute(spin, 4));
  im.frustumCulled = false;
  return im;
}
// 매 프레임: uTime.value = t;  — CPU 는 이것뿐
```

## 흔한 실수 · 확인 목록
- [ ] **바위마다 메시를 만들면 그리기 호출이 수천 개라 CPU 가 막힌다** — 모양 몇 종 × InstancedMesh. 이 사이트도 같은 모양 수백 개를 하나씩 그리다 느려진 일이 있다.
- [ ] **정점만 돌리고 법선을 안 돌리면 바위 명암이 돌지 않는다** — beginnormal_vertex 에도 같은 회전을 넣는다.
- [ ] **셰이더에서 돌린 인스턴스는 화면 밖 판정이 틀릴 수 있다** — 띠 전체가 보이는 장면이면 frustumCulled = false.

## 완성 기준 체크리스트
- [ ] 바위 수천 개가 띠를 이루고 하나하나 다른 축 · 빠르기로 돈다
- [ ] 해 쪽을 보면 먼지 띠가 빛나고, 화면에 렌즈 플레어 무늬가 늘어선다
- [ ] 「소행성 수」를 0.1 ~ 1 배로 바꾸면 count 만 바뀌어 바로 반영된다
- [ ] CPU 프레임 시간이 바위 수와 거의 상관없다 (회전이 셰이더라서)

## 이 기술 정보
- id: `i531` · 분류: 그래픽 · 셰이더 › 우주 표현 · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의) — 그리기 호출 5번(모양마다)으로 바위 4,100개. 회전은 정점 셰이더라 CPU 0. 렌즈 플레어는 해가 보일 때 가림 검사 1번 + 판 6장.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i531
- 쓰면 좋을 때: 우주 배경에 수천 개 물체가 필요할 때 / 각자 도는 물체가 많을 때 (CPU 를 아끼며) / 해 쪽을 보는 극적인 구도
- 쓰지 말 때: 바위와 부딪혀야 하는 게임 — 충돌은 따로 (셰이더 회전은 CPU 가 모른다) / 플레어를 남용 — 해를 정면으로 볼 때만

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i531 견본 항목 — `src/demos/demosSpace.ts:2349`
```ts
  i531: {
    kind: '3d',
    caption: '소행성대 — 절차로 빚은 바위 5종(잡음 · 크레이터)을 인스턴싱으로 4천 개, 회전은 정점 셰이더에서. 해를 보면 렌즈 플레어, 먼지 띠는 해 쪽에서 빛난다',
    make(): Scene3D {
      const bin = new Bin();
      const scene = new THREE.Scene();
      scene.background = new THREE.Color(0x000000);
      const cam = new THREE.PerspectiveCamera(55, 1.6, 0.05, 2000);
      const S = makeStars(14000, 900, 99, { magMax: 7, tw: 0.08, gain: 0.8 });
      scene.add(S.pts);
      scene.add(new THREE.HemisphereLight(0x3a4866, 0x0a0806, 0.35));
      const sunL = new THREE.PointLight(0xfff1dc, 3.4, 0, 0);
      scene.add(sunL);
      const gt = bin.add(texOf(glowCanvas()));
      const ht = bin.add(texOf(hexCanvas()));
      const sunG = glowSprite(gt, 0xfff0d0, 3, 30);
      scene.add(sunG);
      const sunG2 = glowSprite(gt, 0xffa860, 0.5, 70);
      scene.add(sunG2);
      const flare = new Lensflare();
      flare.addElement(new LensflareElement(gt, 380, 0, new THREE.Color(0xffe6c0)));
      flare.addElement(new LensflareElement(ht, 50, 0.45, new THREE.Color(0x0a1230)));
      flare.addElement(new LensflareElement(ht, 80, 0.6, new THREE.Color(0x0a2a16)));
      flare.addElement(new LensflareElement(ht, 120, 0.8, new THREE.Color(0x2a120a)));
      flare.addElement(new LensflareElement(gt, 160, 1.0, new THREE.Color(0x101a30)));
      flare.addElement(new LensflareElement(ht, 60, 1.15, new THREE.Color(0x1a1030)));
      sunL.add(flare);
      const belt = new THREE.Group();
      scene.add(belt);
      const Ut = { value: 0 };
      const meshes: THREE.InstancedMesh[] = [];
      const r = rng(123);
      const PER = 820;
      const m4 = new THREE.Matrix4();
      const q = new THREE.Quaternion();
      const v = new THREE.Vector3();
      const sc = new THREE.Vector3();
      const col = new THREE.Color();
      for (let k = 0; k < 5; k++) {
        const geo = rockGeometry(200 + k * 17);
        const spin = new Float32Array(PER * 4);
        const mat = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.93, metalness: 0, vertexColors: true });
        mat.onBeforeCompile = (sh) => {
          sh.uniforms.uTime = Ut;
          sh.vertexShader = sh.vertexShader
            .replace('#include <common>', `#include <common>\nattribute vec4 aSpin;\nuniform float uTime;\n${SPIN_GLSL}`)
            .replace('#include <beginnormal_vertex>', `#include <beginnormal_vertex>\nobjectNormal = spinR(objectNormal, aSpin.xyz, uTime * aSpin.w);`)
            .replace('#include <begin_vertex>', `#include <begin_vertex>\ntransformed = spinR(transformed, aSpin.xyz, uTime * aSpin.w);`);
        };
        const im = new THREE.InstancedMesh(geo, mat, PER);
        for (let i = 0; i < PER; i++) {
          const near = i < 14;
          const a = near ? (r() - 0.5) * 0.35 : r() * TAU;
          const rad = near ? (r() < 0.5 ? 76 + r() * 4 : 88 + r() * 6) : 80 + gauss(r) * 6.5;
          const y = gauss(r) * (near ? 2.2 : 1.6);
          v.set(Math.cos(a) * rad, y, Math.sin(a) * rad);
          const s = near ? 0.2 + Math.pow(r(), 2) * 1.2 : 0.05 + Math.pow(r(), 4) * 1.2;
          sc.set(s, s, s);
          q.setFromEuler(new THREE.Euler(r() * TAU, r() * TAU, r() * TAU));
          m4.compose(v, q, sc);
          im.setMatrixAt(i, m4);
          const tint = r();
          col.setRGB(0.42 + tint * 0.12, 0.38 + tint * 0.08, 0.34 + tint * 0.04 + (r() < 0.2 ? 0.06 : 0));
          im.setColorAt(i, col);
          const ax = new THREE.Vector3(r() - 0.5, r() - 0.5, r() - 0.5).normalize();
          spin[i * 4] = ax.x;
          spin[i * 4 + 1] = ax.y;
          spin[i * 4 + 2] = ax.z;
          spin[i * 4 + 3] = (0.05 + r() * 0.5) / Math.sqrt(s + 0.2);
        }
        geo.setAttribute('aSpin', new THREE.InstancedBufferAttribute(spin, 4));
        im.frustumCulled = false;
        belt.add(im);
        meshes.push(im);
      }
      const UB = { uN2: { value: noise2D() }, uSunV: { value: new THREE.Vector3() }, uDust: { value: 1 } };
      const bg = new THREE.RingGeometry(55, 105, 256, 4);
      bg.rotateX(-Math.PI / 2);
      const haze = new THREE.Mesh(bg, new THREE.ShaderMaterial({ uniforms: UB, vertexShader: ATMO_VS, fragmentShader: BELT_FS, side: THREE.DoubleSide, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending }));
      belt.add(haze);
      bin.scene(scene);
      bin.add({ dispose: () => flare.dispose() });
      let orbit = 1;
      let last = 0;
      let ang = 0;
      return {
        scene,
        camera: cam,
        tone: THREE.ACESFilmicToneMapping,
        update(t) {
          const dt = Math.min(0.05, Math.max(0, t - last));
          last = t;
          Ut.value = t;
          S.U.uTime.value = t;
          ang += dt * 0.004 * orbit;
          belt.rotation.y = ang;
          cam.position.set(84 + Math.sin(t * 0.1) * 0.8, 3.2 + Math.sin(t * 0.07) * 0.8, 3 + Math.sin(t * 0.05) * 2);
          const look = new THREE.Vector3(-0.86, -0.06, -0.5);
          cam.lookAt(cam.position.x + look.x, cam.position.y + look.y, cam.position.z + look.z);
          UB.uSunV.value.copy(cam.position).negate().normalize();
        },
        resize: (_w, h) => (S.U.uScale.value = h / 900),
        controls: [
          range('소행성 수 (배)', 0.1, 1, 0.05, 1, (k) => meshes.forEach((m) => (m.count = Math.round(PER * k)))),
          tog('렌즈 플레어', true, (on) => (flare.visible = on)),
          range('먼지 띠', 0, 3, 0.05, 1, (k) => (UB.uDust.value = k)),
          range('공전 빠르기', 0, 10, 0.1, 1, (k) => (orbit = k)),
        ],
        dispose: () => {
          bin.dispose();
          S.dispose();
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [인스턴싱 (InstancedMesh)](https://ai-techstudio.web.app/ai/t/u36.md) `u36` · [재질에 셰이더 끼워 넣기](https://ai-techstudio.web.app/ai/t/u08.md) `u08`
- 다음에 해 볼 기술: [혜성 (이온 꼬리 · 먼지 꼬리)](https://ai-techstudio.web.app/ai/t/i532.md) `i532` · [고리 행성 (고리 그림자 · 행성 그림자)](https://ai-techstudio.web.app/ai/t/i527.md) `i527`
- 참고 문서: [three.js 예제 — webgl_lensflares](https://threejs.org/examples/#webgl_lensflares) · [three.js 문서 — InstancedMesh](https://threejs.org/docs/#api/en/objects/InstancedMesh)
