# AI 꾸러미 — 혜성 (이온 꼬리 · 먼지 꼬리) — Comet ion tail / dust tail
> 케플러 궤도를 도는 혜성에서 입자 두 종류를 뿜어 — 이온은 해 반대쪽으로 곧게 빠르게, 먼지는 복사압 β 만큼 약해진 중력으로 궤도 뒤로 휘게 해 두 꼬리를 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i532

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

## 주문서

### 만들어 줘: 혜성 (이온 꼬리 · 먼지 꼬리) — Comet ion tail / dust tail

#### 1. 목표
혜성 학습 체험에 혜성을 만들어 줘 — 이온 꼬리는 해 반대쪽 곧게, 먼지 꼬리는 궤도 뒤로 휘게, 해에 가까울수록 밝게. 느낌은 관측 사진처럼.

#### 2. 핵심 기술 용어
- **Comet ion tail / dust tail** — 태양풍에 밀린 이온 꼬리 · 복사압으로 휘는 먼지 꼬리
- **Kepler orbit (Newton solve of Kepler equation)** — 케플러 방정식을 뉴턴법으로 풀어 궤도 위치
- **Radiation pressure (β)** — 햇빛이 미는 힘 — 중력을 (1 - β) 배로
- **CPU particle pool + dynamic buffer** — 입자 배열을 CPU 에서 움직여 매 프레임 올리기

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

#### 4. 조건
- 궤도 위치는 케플러 방정식을 뉴턴법(8번)으로 — 원 · 타원을 각도로 대충 돌리지 않는다 (근일점에서 빨라져야 한다)
- 먼지는 중력 (1 - β) / r³ 로 매 걸음 속도를 고치고, 이온은 곧게만
- 입자 수는 최대(풀) 안에서, 죽은 입자는 마지막 것과 바꿔 지우기 (splice 금지)
- 버퍼는 DynamicDrawUsage, setDrawRange(0, n) 으로 산 것만 그리기
- 시뮬레이션 시간은 실제 dt × 빠르기 (프레임 수 아님)

#### 5. 완성 기준 (이게 보이면 성공)
- 혜성이 해 가까이 올수록 빨라지고 꼬리가 길고 밝아진다
- 파란 이온 꼬리는 늘 해 반대쪽으로 곧게, 노란 먼지 꼬리는 궤도 뒤쪽으로 휘어 두 꼬리가 갈라진다
- 「이온 꼬리」 · 「먼지 꼬리」를 각각 끄고 켜면 하나씩 사라진다
- 「시간 빠르기」를 3 으로 올려도 꼬리 모양이 무너지지 않는다

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

## 원리
- 궤도: 근일점 거리 1 · 이심률 0.9 타원. 시간 → 평균 근점 이각 M → 케플러 방정식 E - e·sin E = M 을 뉴턴법 8번으로 풀어 위치를 구한다.
- 활동 세기 = min(3, 1.6 / r²) — 해에 가까울수록 입자를 많이 뿜고 코마가 커진다.
- 먼지: 혜성 속도로 조금 흩어지며 나와, 중력을 (1 - β) 배로 받는다 (β 0.15 ~ 1). 중력이 약하니 궤도보다 바깥 · 뒤로 처져 휜 꼬리가 된다.
- 이온: 해 반대쪽(해 → 혜성 방향)으로 빠르게(2.6 ~ 4.2) 곧게, 가는 줄기 7가닥으로 나눠 흔든다. 수명이 짧다(0.6 ~ 1.2).

## 핵심 코드 — 케플러 위치 + 먼지(β) · 이온 입자 움직이기
(발췌: demos/demosSpace.ts i532 make() 의 posAt · update 를 정리)
```ts
import * as THREE from 'three';

const e = 0.9, qd = 1.0;                  // 이심률 · 근일점 거리
const a = qd / (1 - e), bAx = a * Math.sqrt(1 - e * e);
const nMot = Math.sqrt(1 / (a * a * a));  // 평균 운동 (GM = 1)
function posAt(tt: number, M1: number, out: THREE.Vector3): THREE.Vector3 {
  const M = -M1 + nMot * tt;
  let E = M;
  for (let k = 0; k < 8; k++) E -= (E - e * Math.sin(E) - M) / (1 - e * Math.cos(E)); // 뉴턴법
  return out.set(a * (Math.cos(E) - e), 0, -bAx * Math.sin(E));
}

type Pt = { x: number; y: number; z: number; vx: number; vy: number; vz: number; age: number; life: number; beta: number };
function stepDust(dust: Pt[], sdt: number): void {
  for (let i = dust.length - 1; i >= 0; i--) {
    const p = dust[i]!;
    p.age += sdt;
    if (p.age > p.life) { dust[i] = dust[dust.length - 1]!; dust.pop(); continue; } // 바꿔 지우기
    const r2 = p.x * p.x + p.y * p.y + p.z * p.z;
    const g = (1 - p.beta) / (r2 * Math.sqrt(r2));   // 복사압만큼 약한 중력
    p.vx -= p.x * g * sdt; p.vy -= p.y * g * sdt; p.vz -= p.z * g * sdt;
    p.x += p.vx * sdt; p.y += p.vy * sdt; p.z += p.vz * sdt;
  }
}
// 뿜기 (cp = 혜성 위치, (ux, uz) = 해 → 혜성 방향, act = min(3, 1.6 / r²))
//  먼지: 속도 = 혜성 속도 + 흩어짐 0.04 + 바깥 0.05,  beta = 0.15 + random^1.5 × 0.85,  수명 4 ~ 8
//  이온: 속도 = (ux - uz·off, off·0.6, uz + ux·off) × (2.6 ~ 4.2),  off = 7가닥 중 하나 ± 흔들림,  수명 0.6 ~ 1.2
// 그리기: Points 하나에 위치 · 색알파 · 크기를 넣고 upload(n) → setDrawRange(0, n), needsUpdate
```

## 흔한 실수 · 확인 목록
- [ ] **궤도를 각도로 고르게 돌리면 해 가까이서 빨라지지 않는다** — 케플러 방정식을 풀어 위치를 구해야 근일점에서 빠르고 멀리서 느리다.
- [ ] **죽은 입자를 splice 로 지우면 입자 수천 개에서 느려진다** — 마지막 입자와 바꾸고 pop — 순서는 상관없다.
- [ ] **이온 꼬리를 혜성 진행 반대쪽으로 그리면 틀린다** — 이온 꼬리는 늘 해 반대쪽 — 혜성이 해에서 멀어질 때는 꼬리가 앞장선다.

## 완성 기준 체크리스트
- [ ] 혜성이 해 가까이 올수록 빨라지고 꼬리가 길고 밝아진다
- [ ] 파란 이온 꼬리는 늘 해 반대쪽으로 곧게, 노란 먼지 꼬리는 궤도 뒤쪽으로 휘어 두 꼬리가 갈라진다
- [ ] 「이온 꼬리」 · 「먼지 꼬리」를 각각 끄고 켜면 하나씩 사라진다
- [ ] 「시간 빠르기」를 3 으로 올려도 꼬리 모양이 무너지지 않는다

## 이 기술 정보
- id: `i532` · 분류: 그래픽 · 셰이더 › 우주 표현 · 3D · 난이도 어려움 · 폰 부담 보통 (폰 주의) — 입자 최대 1만 1천 개(먼지 7천 + 이온 4천)를 CPU 에서 매 프레임 움직이고 버퍼를 올린다. 폰은 최대 수를 반으로.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i532
- 쓰면 좋을 때: 혜성 · 궤도 · 힘을 보여 주는 학습 / 우주 배경의 움직이는 볼거리 / 입자에 서로 다른 힘을 주는 예시
- 쓰지 말 때: 입자가 수십만 개 필요할 때 — 대신 GPU 입자(i525 처럼 정점 셰이더) / 물리가 필요 없는 장식 꼬리 — 대신 빛 띠 한 장

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i532 견본 항목 — `src/demos/demosSpace.ts:2466`
```ts
  i532: {
    kind: '3d',
    caption: '혜성 — 이온 꼬리(파랑)는 태양풍에 밀려 언제나 해 반대쪽으로 곧게, 먼지 꼬리(노랑)는 복사압으로 궤도 뒤로 휘며 처진다. 해에 가까울수록 길고 밝다',
    make(): Scene3D {
      const bin = new Bin();
      const scene = new THREE.Scene();
      scene.background = new THREE.Color(0x000000);
      const cam = new THREE.PerspectiveCamera(42, 1.6, 0.01, 1000);
      cam.position.set(-0.6, 6.4, 4.6);
      cam.lookAt(-0.55, 0, 0.1);
      const S = makeStars(12000, 300, 111, { magMax: 7, tw: 0.15, gain: 0.8 });
      scene.add(S.pts);
      const gt = bin.add(texOf(glowCanvas()));
      const st = bin.add(texOf(spikeCanvas()));
      scene.add(glowSprite(gt, 0xffe0a8, 3.2, 1.4));
      scene.add(glowSprite(gt, 0xff9a40, 0.6, 5));
      scene.add(glowSprite(st, 0xfff3e0, 1.6, 2.4));
      // 궤도 (q = 1, e = 0.9)
      const e = 0.9;
      const qd = 1.0;
      const a = qd / (1 - e);
      const bAx = a * Math.sqrt(1 - e * e);
      const nMot = Math.sqrt(1 / (a * a * a));
      const nuMax = THREE.MathUtils.degToRad(125);
      const E1 = 2 * Math.atan(Math.sqrt((1 - e) / (1 + e)) * Math.tan(nuMax / 2));
      const M1 = E1 - e * Math.sin(E1);
      const span = (2 * M1) / nMot;
      const orbitPts: THREE.Vector3[] = [];
      for (let i = -150; i <= 150; i++) {
        const nu = THREE.MathUtils.degToRad(i);
        const rr = (a * (1 - e * e)) / (1 + e * Math.cos(nu));
        orbitPts.push(new THREE.Vector3(rr * Math.cos(nu), 0, -rr * Math.sin(nu)));
      }
      const orbitLine = new THREE.Line(new THREE.BufferGeometry().setFromPoints(orbitPts), new THREE.LineBasicMaterial({ color: 0x5a7aa8, transparent: true, opacity: 0.25 }));
      scene.add(orbitLine);
      const posAt = (tt: number, out: THREE.Vector3): THREE.Vector3 => {
        const M = -M1 + nMot * tt;
        let E = M;
        for (let k = 0; k < 8; k++) E -= (E - e * Math.sin(E) - M) / (1 - e * Math.cos(E));
        return out.set(a * (Math.cos(E) - e), 0, -bAx * Math.sin(E));
      };
      const coma = glowSprite(gt, 0x7dffd0, 1.4, 0.5);
      scene.add(coma);
      const nucleus = glowSprite(st, 0xffffff, 1.4, 0.18);
      scene.add(nucleus);
      const MAXD = 7000;
      const MAXI = 4000;
      const P = new Parts(MAXD + MAXI);
      scene.add(P.pts);
      bin.add(P);
      type Pt = { x: number; y: number; z: number; vx: number; vy: number; vz: number; age: number; life: number; beta: number; ion: boolean };
      const dust: Pt[] = [];
      const ions: Pt[] = [];
      const rays = Array.from({ length: 7 }, (_, i) => ({ off: (i - 3) * 0.05, ph: Math.random() * TAU }));
      const cp = new THREE.Vector3();
      const cp2 = new THREE.Vector3();
      let simT = 0;
      let speed = 1;
      let ionOn = true;
      let dustOn = true;
      let accD = 0;
      let accI = 0;
      let last = 0;
      bin.scene(scene);
      return {
        scene,
        camera: cam,
        tone: THREE.ACESFilmicToneMapping,
        update(t) {
          const dt = Math.min(0.05, Math.max(0, t - last));
          last = t;
          S.U.uTime.value = t;
          const sdt = (dt * speed * span) / 15;
          simT += sdt;
          if (simT > span) simT -= span;
          posAt(simT, cp);
          posAt(simT + 0.001, cp2);
          const vx = (cp2.x - cp.x) / 0.001;
          const vz = (cp2.z - cp.z) / 0.001;
          const rS = cp.length();
          const ux = cp.x / rS;
          const uz = cp.z / rS;
          const act = Math.min(3, 1.6 / (rS * rS));
          coma.position.copy(cp);
          nucleus.position.copy(cp);
          coma.scale.setScalar(0.35 + 0.45 * act);
          (coma.material as THREE.SpriteMaterial).opacity = Math.min(1, 0.4 + act * 0.5);
          // 먼지: 혜성 속도 + 조금 흩어짐, 복사압(β) 으로 중력이 줄어 궤도 뒤로 처진다
          if (dustOn) accD += dt * 1150 * act;
          while (accD >= 1 && dust.length < MAXD) {
            accD--;
            dust.push({ x: cp.x, y: (Math.random() - 0.5) * 0.01, z: cp.z, vx: vx + gauss(Math.random) * 0.04 + ux * 0.05, vy: gauss(Math.random) * 0.02, vz: vz + gauss(Math.random) * 0.04 + uz * 0.05, age: 0, life: 4 + Math.random() * 4, beta: 0.15 + Math.pow(Math.random(), 1.5) * 0.85, ion: false });
          }
          accD = Math.min(accD, 20);
          // 이온: 해 반대쪽으로 빠르게, 가는 줄기 몇 가닥
          if (ionOn) accI += dt * 1500 * act;
          while (accI >= 1 && ions.length < MAXI) {
            accI--;
            const ray = rays[Math.floor(Math.random() * rays.length)]!;
            const off = ray.off + Math.sin(t * 0.7 + ray.ph) * 0.02 + gauss(Math.random) * 0.006;
            const sp = 2.6 + Math.random() * 1.6;
            ions.push({ x: cp.x, y: 0, z: cp.z, vx: (ux - uz * off) * sp, vy: off * 0.6 * sp, vz: (uz + ux * off) * sp, age: 0, life: 0.6 + Math.random() * 0.6, beta: 0, ion: true });
          }
          accI = Math.min(accI, 30);
          let n = 0;
          for (let i = dust.length - 1; i >= 0; i--) {
            const p = dust[i]!;
            p.age += sdt;
            if (p.age > p.life) {
              dust[i] = dust[dust.length - 1]!;
              dust.pop();
              continue;
            }
            const r2 = p.x * p.x + p.y * p.y + p.z * p.z;
            const r3 = r2 * Math.sqrt(r2);
            const g = (1 - p.beta) / r3;
            p.vx -= p.x * g * sdt;
            p.vy -= p.y * g * sdt;
            p.vz -= p.z * g * sdt;
            p.x += p.vx * sdt;
            p.y += p.vy * sdt;
            p.z += p.vz * sdt;
          }
          for (let i = ions.length - 1; i >= 0; i--) {
            const p = ions[i]!;
            p.age += sdt;
            if (p.age > p.life) {
              ions[i] = ions[ions.length - 1]!;
              ions.pop();
              continue;
            }
            p.x += p.vx * sdt;
            p.y += p.vy * sdt;
            p.z += p.vz * sdt;
          }
          for (const p of dust) {
            const k = p.age / p.life;
            const fadeK = Math.min(1, k * 8) * (1 - k) * (1 - k);
            const sunK = 1 / Math.max(0.5, p.x * p.x + p.z * p.z);
            P.put(n++, p.x, p.y, p.z, 1.0, 0.82, 0.55, 0.22 * fadeK * sunK * (0.6 + p.beta), 0.12 + k * 0.32);
          }
          for (const p of ions) {
            const k = p.age / p.life;
            const fadeK = Math.min(1, k * 10) * (1 - k);
            P.put(n++, p.x, p.y, p.z, 0.3, 0.6, 1.0, 0.3 * fadeK, 0.07 + k * 0.12);
          }
          P.upload(n);
        },
        resize: (_w, h) => {
          S.U.uScale.value = h / 900;
          P.U.uScale.value = h / 900;
        },
        controls: [
          tog('이온 꼬리 (태양풍)', true, (v) => (ionOn = v)),
          tog('먼지 꼬리 (복사압)', true, (v) => (dustOn = v)),
          tog('궤도 선', true, (v) => (orbitLine.visible = v)),
          range('시간 빠르기', 0.2, 3, 0.05, 1, (v) => (speed = v)),
        ],
        dispose: () => {
          bin.dispose();
          S.dispose();
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [입자 방출기 (수명 곡선)](https://ai-techstudio.web.app/ai/t/i170.md) `i170` · [소행성대 인스턴싱 + 렌즈 플레어](https://ai-techstudio.web.app/ai/t/i531.md) `i531`
- 다음에 해 볼 기술: [오로라 (커튼 · 호수 반사)](https://ai-techstudio.web.app/ai/t/i533.md) `i533`
- 참고 문서: [Wikipedia — Kepler's equation](https://en.wikipedia.org/wiki/Kepler%27s_equation) · [Wikipedia — Comet tail](https://en.wikipedia.org/wiki/Comet_tail)
