# AI 꾸러미 — 나선 은하 (입자 20만 · 밀도파 팔) — Density wave theory (spiral arms)
> 별 20만 개가 반지름마다 조금씩 돌아간 타원 궤도를 정점 셰이더에서 돌아, 타원이 몰리는 곳에 저절로 나선 팔이 생기는 은하를 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i525

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

## 주문서

### 만들어 줘: 나선 은하 (입자 20만 · 밀도파 팔) — Density wave theory (spiral arms)

#### 1. 목표
우주 배경에 입자 20만 개 나선 은하를 만들어 줘 — 타원 궤도가 몰려 팔이 생기게, 분위기는 사진처럼 화려하게.

#### 2. 핵심 기술 용어
- **Density wave theory (spiral arms)** — 돌아간 타원 궤도가 몰린 곳이 팔로 보인다는 이론
- **GPU orbit animation (vertex shader)** — 궤도 계산을 정점 셰이더에서 — CPU 0
- **HDR accumulation + luminance tone map** — 반정밀 타깃에 더한 뒤 밝기만 눌러 색 지키기

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

#### 4. 조건
- 위치는 정점 셰이더에서 — 속성 aOrbit(a, 처음 각, 높이, 종류) · aCol · aB 만 넣는다
- 타원 방향 th = a × uTwist(0.38), 이심률 uEcc(0.36)는 안쪽(a < 2)에서 0 부터 늘고 바깥으로 줄게
- 각속도 om = (1 - exp(-1.4a)) / a — 바깥일수록 느리게
- 별은 AdditiveBlending · 먼지는 일반 섞기(알파) · renderOrder 를 먼지가 나중에
- HDR 타깃(HalfFloat)에 더한 뒤 휘도만 눌러(L·(1 + L/9)/(1 + L)) 톤 매핑

#### 5. 완성 기준 (이게 보이면 성공)
- 팔을 따로 그리지 않았는데 은하가 돌며 두 갈래 나선 팔이 또렷이 보인다
- 핵은 노랗고 팔에는 파란 별 · 분홍 구름, 팔 안쪽 가장자리에 어두운 먼지 줄이 있다
- 「팔 꼬임」 · 「타원 찌그러짐」을 0 으로 하면 팔이 사라지고 둥근 원반이 된다
- 핵이 하얗게 날아가지 않고 노란빛 그대로 밝다 (「노출」 조절)

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

## 원리
- 별마다 반지름 a · 처음 각 · 높이 · 종류만 저장한다. 위치는 정점 셰이더가 시간으로 계산한다 — CPU 는 아무것도 안 한다.
- 각 별은 찌그러진 타원(이심률 e)을 돈다. 타원의 방향을 반지름에 비례해 조금씩 돌린다 (th = a × 꼬임 0.38).
- 안쪽 타원과 바깥 타원이 조금씩 어긋나 겹치는 곳에 별이 몰린다 — 그것이 팔이다. 팔을 그리지 않았는데 팔이 생긴다.
- 종류별로 다르게: 팽대부(늙은 주황, 둥글게) · 젊은 파랑(팔에 많음) · 분홍 수소 구름(팔 자리에 머묾) · 먼지(일반 섞기로 가림, 조금 늦게).
- 수만 개를 더하면 하얗게 잘리므로 반정밀 타깃에 더한 뒤 밝기(휘도)만 눌러 색을 지킨다.

## 핵심 코드 — 돌아간 타원 궤도 — 정점 셰이더
(발췌: demos/demosSpace.ts GAL_VS 를 정리 (자료는 galaxyData()))
```glsl
attribute vec4 aOrbit;   // x 반지름 a · y 처음 각 · z 높이 · w 종류 (1 핵 · 2 수소 구름 · 3 먼지 · 4 번진 빛)
attribute vec3 aCol; attribute float aB;
uniform float uTime, uTwist, uEcc, uProj, uSpeed, uSize, uLag;
varying vec3 vCol; varying float vB;
void main(){
  float a = aOrbit.x, kind = aOrbit.w;
  // 이심률: 핵은 둥글게, 안쪽에서 늘고 바깥으로 줄게
  float e = kind == 1.0 ? 0.0 : uEcc * (a < 2.0 ? a / 2.0 : 1.0 - 0.75 * clamp((a - 2.0) / 8.0, 0.0, 1.0));
  float th = a * uTwist + (kind == 3.0 ? uLag : 0.0);   // 반지름마다 조금씩 돌린 타원 — 몰린 곳이 팔
  float om = (1.0 - exp(-a * 1.4)) / max(a, 0.2);        // 바깥일수록 느리게
  float ph = aOrbit.y + (kind == 2.0 ? 0.0 : uTime * om * uSpeed); // 수소 구름은 팔 자리에 머문다
  vec2 el = vec2(a * cos(ph), a * (1.0 - e) * sin(ph));
  float c = cos(th), s = sin(th);
  vec3 p = vec3(el.x * c - el.y * s, aOrbit.z, el.x * s + el.y * c);
  vec4 mv = modelViewMatrix * vec4(p, 1.0);
  gl_Position = projectionMatrix * mv;
  float sz = uSize * uProj / -mv.z * (kind == 2.0 ? 2.8 : kind == 3.0 ? 9.0 : kind == 4.0 ? 8.0 : kind == 1.0 ? 1.2 : 1.0);
  vB = aB;
  if (sz < 1.5) { vB *= sz / 1.5; sz = 1.5; }
  gl_PointSize = sz;
  vCol = aCol;
}
```

## 흔한 실수 · 확인 목록
- [ ] **별 위치를 CPU 에서 매 프레임 계산하면 20만 개에서 멈춘다** — 궤도 식은 정점 셰이더로. CPU 는 시간 하나만 넘긴다.
- [ ] **그냥 더하기로 그리면 핵이 하얗게 날아간다** — 반정밀 타깃에 모은 뒤 휘도만 눌러 색을 지킨다 (견본 HdrPass).
- [ ] **먼지도 더하기로 그리면 가리지 않고 밝아진다** — 먼지는 일반 섞기(알파) · 아주 옅게(0.08), 별보다 나중에 그린다.

## 완성 기준 체크리스트
- [ ] 팔을 따로 그리지 않았는데 은하가 돌며 두 갈래 나선 팔이 또렷이 보인다
- [ ] 핵은 노랗고 팔에는 파란 별 · 분홍 구름, 팔 안쪽 가장자리에 어두운 먼지 줄이 있다
- [ ] 「팔 꼬임」 · 「타원 찌그러짐」을 0 으로 하면 팔이 사라지고 둥근 원반이 된다
- [ ] 핵이 하얗게 날아가지 않고 노란빛 그대로 밝다 (「노출」 조절)

## 이 기술 정보
- id: `i525` · 분류: 그래픽 · 셰이더 › 우주 표현 · 3D · 난이도 어려움 · 폰 부담 무거움 — 점 20만 + 먼지 1만 4천을 매 장면 정점 셰이더로 (그리기 2번). 겹쳐 더하는 픽셀이 많고 반정밀 렌더 타깃 1장. 폰은 5만 개 정도로.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i525
- 쓰면 좋을 때: 우주 배경의 은하 / 입자 수십만 개를 CPU 부담 없이 움직이고 싶을 때 / 밀도파 · 궤도 학습
- 쓰지 말 때: 밤하늘에서 본 은하수 — 대신 은하수 띠(i523) / 폰에서 20만 개 그대로 — 입자 수를 반 이하로

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i525 견본 항목 — `src/demos/demosSpace.ts:1972`
```ts
  i525: {
    kind: '3d',
    caption: '입자 20만 개 나선 은하 — 별마다 조금씩 돌아간 타원 궤도를 돌고, 타원이 몰리는 곳이 팔(밀도파). 파란 젊은 별 · 분홍 수소 구름 · 먼지 줄',
    make(): Scene3D {
      const bin = new Bin();
      const scene = new THREE.Scene();
      scene.background = new THREE.Color(0x000000);
      const cam = new THREE.PerspectiveCamera(45, 1.6, 0.1, 1000);
      const bg = makeStars(9000, 300, 33, { magMax: 7, tw: 0.1, gain: 0.7 });
      scene.add(bg.pts);
      const D = galaxyData();
      const U = { uTime: { value: 0 }, uTwist: { value: 0.38 }, uEcc: { value: 0.36 }, uProj: { value: 500 }, uSpeed: { value: 0.35 }, uSize: { value: 0.075 }, uLag: { value: 0.22 }, uGain: { value: 0.2 }, uDust: { value: 1 } };
      const mkGeo = (d: GalData): THREE.BufferGeometry => {
        const g = new THREE.BufferGeometry();
        g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(d.n * 3), 3));
        g.setAttribute('aOrbit', new THREE.BufferAttribute(d.orbit, 4));
        g.setAttribute('aCol', new THREE.BufferAttribute(d.col, 3));
        g.setAttribute('aB', new THREE.BufferAttribute(d.bri, 1));
        return g;
      };
      const gal = new THREE.Group();
      gal.rotation.set(0.55, 0, 0.3);
      scene.add(gal);
      const stars = new THREE.Points(mkGeo(D.stars), new THREE.ShaderMaterial({ uniforms: U, vertexShader: GAL_VS, fragmentShader: GAL_FS, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, toneMapped: false }));
      stars.frustumCulled = false;
      gal.add(stars);
      const dust = new THREE.Points(mkGeo(D.dust), new THREE.ShaderMaterial({ uniforms: U, vertexShader: GAL_VS, fragmentShader: DUST_FS, transparent: true, depthWrite: false, toneMapped: false }));
      dust.frustumCulled = false;
      dust.renderOrder = 2;
      gal.add(dust);
      const gt = bin.add(texOf(glowCanvas()));
      const core = glowSprite(gt, 0xffc890, 0.5, 5.5);
      core.renderOrder = 1;
      gal.add(core);
      const core2 = glowSprite(gt, 0xfff0d8, 0.35, 1.2);
      core2.renderOrder = 1;
      gal.add(core2);
      bin.scene(gal);
      const hdr = bin.add(new HdrPass());
      hdr.U.uExp.value = 1.2;
      return {
        scene,
        camera: cam,
        render: (rr, w, h) => hdr.render(rr, scene, cam, w, h),
        update(t) {
          U.uTime.value = t;
          bg.U.uTime.value = t;
          const a = t * 0.03;
          cam.position.set(Math.sin(a) * 4, 3 + Math.sin(t * 0.04) * 2, 25);
          cam.lookAt(0, 0, 0);
        },
        resize: (_w, h) => {
          U.uProj.value = h / (2 * Math.tan(THREE.MathUtils.degToRad(cam.fov / 2)));
          bg.U.uScale.value = h / 900;
        },
        controls: [
          range('노출 (HDR 톤 매핑)', 0.2, 5, 0.05, 1.2, (v) => (hdr.U.uExp.value = v)),
          range('팔 꼬임 (타원이 반지름마다 돌아간 정도)', 0, 1, 0.01, 0.38, (v) => (U.uTwist.value = v)),
          range('타원 찌그러짐 (밀도파 세기)', 0, 0.5, 0.01, 0.36, (v) => (U.uEcc.value = v)),
          range('먼지 줄', 0, 2, 0.05, 1, (v) => (U.uDust.value = v)),
          range('회전 빠르기', 0, 2, 0.05, 0.35, (v) => (U.uSpeed.value = v)),
        ],
        dispose: () => {
          bin.dispose();
          bg.dispose();
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [별 하늘 (실제 등급 · B-V 색 · 반짝임)](https://ai-techstudio.web.app/ai/t/i522.md) `i522` · [인스턴싱 (InstancedMesh)](https://ai-techstudio.web.app/ai/t/u36.md) `u36`
- 다음에 해 볼 기술: [블랙홀 (중력 렌즈 · 강착 원반 도플러)](https://ai-techstudio.web.app/ai/t/i529.md) `i529` · [은하수 띠 (먼지 줄 · 밝은 팽대부)](https://ai-techstudio.web.app/ai/t/i523.md) `i523`
- 참고 문서: [Wikipedia — Density wave theory](https://en.wikipedia.org/wiki/Density_wave_theory)
