# AI 꾸러미 — 입자 방출기 (수명 곡선) — Particle emitter (emission shape)
> 입자를 점 · 원 · 원뿔 · 구 모양에서 뿜고 수명 동안 크기 · 색 · 투명도를 곡선으로 바꿔서, 모든 마법 효과의 바탕이 되는 입자 방출기를 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i170

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

## 주문서

### 만들어 줘: 입자 방출기 (수명 곡선) — Particle emitter (emission shape)

#### 1. 목표
마법 효과 전부의 바탕에 쓸 입자 방출기를 만들어 줘 — 방출 모양(점 · 원 · 원뿔 · 구)을 고르고, 입자가 수명 동안 커졌다 식으며 사라지게(크기 · 색 · 투명도 곡선). 분위기는 어두운 돌바닥 위 하늘빛 마법.

#### 2. 핵심 기술 용어
- **Particle emitter (emission shape)** — 입자 방출기 — 어디서 · 어느 쪽으로 뿜나
- **Over-lifetime curves (size · color · alpha)** — 수명 곡선 — 나이에 따라 바뀌는 값
- **InstancedBufferGeometry billboards** — 인스턴스 사각형 한 묶음으로 입자 수천 개
- **Premultiplied alpha (additive + alpha in one pass)** — 더하기 · 덮기를 입자마다 섞는 섞기

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

#### 4. 조건
- 입자는 InstancedBufferGeometry 한 묶음 (입자마다 Mesh · Sprite 를 만들지 않는다)
- 방출 모양 4가지(점 · 원 · 원뿔 · 구)를 조절 값 하나로 바꾼다
- 수명 곡선 표(32칸)로 크기 · 색 · 투명도를 나이에 따라 — 켬/끔 비교 단추
- 죽은 입자는 맨 끝과 바꿔 지운다(배열을 새로 만들지 않음), 최대 수를 넘으면 더 만들지 않음
- depthWrite: false, 방출량은 시간 누적(acc += dt × 초당 개수)으로 — 프레임 빠르기와 상관없게

#### 5. 완성 기준 (이게 보이면 성공)
- 3초마다 방출 모양이 점 → 원 → 원뿔 → 구로 바뀌고, 모양을 보여 주는 선(기즈모)도 함께 바뀐다
- 수명 곡선을 켜면 입자가 하얗게 태어나 커지며 하늘색 → 보라로 식고, 끄면 같은 크기 · 같은 색으로 툭 사라진다
- 「입자 수」 0.3 ~ 3배 조절이 바로 먹는다
- 폰에서 입자 1000개 기준 60fps

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

## 원리
- 입자 하나 = 자리 · 속도 · 나이 · 수명 · 시작 크기 s0 · 끝 크기 s1 · 색 곡선. 배열(Float32Array)에 모아 두고 죽으면 맨 끝 것과 바꿔 지운다.
- 방출 모양마다 시작 자리와 방향이 다르다: 점(사방) · 원 둘레(위로) · 원뿔(24° 안, 중력 1.6) · 구 겉면(바깥으로).
- 색 곡선은 32칸 표 [색 × 밝기, 투명도, 더하기 정도] — 처음 하얗게 빛나다 하늘색 → 보라 → 어둡게. 밝기가 1 을 넘는(HDR) 칸이 빛나 보인다.
- 크기는 s0 + (s1 − s0)·easeOut(나이 비율) 로 커지고, 공기 저항은 속도 × e^(−drag·dt).
- 사각형 하나를 인스턴스로 수천 번 그리고, 섞기는 One / OneMinusSrcAlpha 로 둔 채 알파에 (1 − 더하기 정도) 를 곱해 빛(더하기)과 연기(덮기)를 한 번에 그린다.

## 핵심 코드 — 수명 곡선 표 + 입자 한 걸음 + 인스턴스 버퍼 올리기
(발췌: demos/demosSkillA.ts ramp() · Pool.step() · Pool.upload() 를 정리)
```ts
const RN = 32; // 곡선 표 칸 수 — 한 칸 = [r·I, g·I, b·I, 투명도, 더하기 정도]
type Stop = [number, number, number, number, number]; // [나이, 색, 밝기(HDR), 투명도, 더하기]
function ramp(stops: Stop[]): Float32Array {
  const out = new Float32Array(RN * 5), c = new THREE.Color();
  for (let i = 0; i < RN; i++) {
    const k = i / (RN - 1);
    let j = 0;
    while (j < stops.length - 2 && k > stops[j + 1][0]) j++;
    const a = stops[j], b = stops[j + 1] ?? a;
    const f = Math.min(1, Math.max(0, (k - a[0]) / Math.max(1e-6, b[0] - a[0])));
    const ca = c.setHex(a[1]).toArray().map((v) => v * a[2]);
    const cb = c.setHex(b[1]).toArray().map((v) => v * b[2]);
    const A = [...ca, a[3], a[4]], B = [...cb, b[3], b[4]];
    for (let q = 0; q < 5; q++) out[i * 5 + q] = A[q] + (B[q] - A[q]) * f;
  }
  return out;
}
const EMIT = ramp([[0, 0xffffff, 6, 0, 1], [0.06, 0xffffff, 5, 1, 1], [0.3, 0x70e8ff, 3, 1, 1], [0.7, 0x8a5cff, 2, 0.6, 1], [1, 0x3a1a90, 0.8, 0, 1]]);

// 한 걸음: 중력 · 공기 저항 · 이동 (죽으면 맨 끝과 바꿔 지움)
for (let i = 0; i < n; ) {
  age[i] += dt;
  if (age[i] >= life[i]) { kill(i); continue; }
  const f = Math.exp(-drag[i] * dt);
  vx[i] *= f; vy[i] = (vy[i] - grav[i] * dt) * f; vz[i] *= f;
  x[i] += vx[i] * dt; y[i] += vy[i] * dt; z[i] += vz[i] * dt;
  i++;
}
// 올리기: 나이 비율 k 로 곡선 표를 읽어 색 · 크기를 인스턴스 속성에
for (let i = 0; i < n; i++) {
  const k = age[i] / life[i], fj = k * (RN - 1), j = Math.min(RN - 2, fj | 0), u = fj - j, o = j * 5;
  const rp = ramps[i];
  for (let q = 0; q < 4; q++) col[i * 4 + q] = rp[o + q] + (rp[o + 5 + q] - rp[o + q]) * u;
  add[i] = rp[o + 4] + (rp[o + 9] - rp[o + 4]) * u;
  size[i] = s0[i] + (s1[i] - s0[i]) * (1 - Math.pow(1 - k, 3)); // easeOut
}
geo.instanceCount = n; // 그리고 각 속성 needsUpdate = true
```

## 흔한 실수 · 확인 목록
- [ ] **입자마다 Sprite 를 만들면 수백 개에서 멈칫한다** — 사각형 하나를 InstancedBufferGeometry 로 n 번 — 그리기 호출 1번. 같은 모양 수백 개를 하나씩 그린 것이 이 사이트 느림의 1순위 원인이었다.
- [ ] **방출을 프레임마다 정해진 개수로 하면 폰에서 입자가 줄어든다** — acc += dt × 초당 개수, acc ≥ 1 인 동안 하나씩 — 30fps 든 60fps 든 같은 양.
- [ ] **빛과 연기를 따로 섞기 모드로 그리면 묶음이 둘로 갈린다** — 섞기를 One / OneMinusSrcAlpha 로 두고 셰이더에서 알파에 (1 − 더하기) 를 곱하면 한 묶음에서 둘 다 된다.
- [ ] **색 곡선이 1 이하면 빛이 안 나 보인다** — 태어날 때 밝기를 5~9(HDR)로 줘서 하얗게 타오르게. 톤 매핑이 그 값을 받아 준다.

## 완성 기준 체크리스트
- [ ] 3초마다 방출 모양이 점 → 원 → 원뿔 → 구로 바뀌고, 모양을 보여 주는 선(기즈모)도 함께 바뀐다
- [ ] 수명 곡선을 켜면 입자가 하얗게 태어나 커지며 하늘색 → 보라로 식고, 끄면 같은 크기 · 같은 색으로 툭 사라진다
- [ ] 「입자 수」 0.3 ~ 3배 조절이 바로 먹는다
- [ ] 폰에서 입자 1000개 기준 60fps

## 이 기술 정보
- id: `i170` · 분류: 이펙트 · 연출 › 스킬 VFX (마법 · 미사일) · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의) — 그리기는 묶음당 1번이지만 CPU 가 매 프레임 입자마다 이동 · 곡선 계산 · 버퍼 올리기를 한다. 견본은 최대 2200개 · 초당 170개. 폰은 1000개 안쪽으로.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i170
- 쓰면 좋을 때: 마법 · 폭발 · 보상처럼 입자가 필요한 모든 효과의 첫 단추 / 같은 엔진 하나로 여러 효과(불 · 얼음 · 독)를 값만 바꿔 만들 때
- 쓰지 말 때: 입자 몇 개뿐인 단순 반짝임 — Sprite 몇 장이 더 쉽다 / 부드러운 소용돌이 · 빔처럼 이어진 모양 — 입자보다 흐르는 무늬 메시(i172)가 낫다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i170 견본 항목 — `src/demos/demosSkillA.ts:1429`
```ts
  i170: {
    kind: '3d',
    caption: '방출 모양(점 · 원 · 원뿔 · 구)이 바뀌고, 입자는 수명 곡선대로 커졌다 식으며 사라진다',
    make(): Scene3D {
      const S = makeStage();
      const P = new Pool(2200, S.puff);
      P.mesh.renderOrder = 3;
      S.scene.add(P.mesh);
      const lab = S.hud.label('tl');
      const gz = new THREE.Group();
      S.scene.add(gz);
      const lineMat = new THREE.LineBasicMaterial({ color: 0x6fdcff, transparent: true, opacity: 0.5, blending: THREE.AdditiveBlending, depthWrite: false });
      const circle = (pts: number[], r: number, cy: number, axis: 'y' | 'x' | 'z', cx = 0, cz = 0): void => {
        const n = 64;
        for (let i = 0; i < n; i++) {
          const a0 = (i / n) * TAU;
          const a1 = ((i + 1) / n) * TAU;
          for (const a of [a0, a1]) {
            if (axis === 'y') pts.push(cx + Math.cos(a) * r, cy, cz + Math.sin(a) * r);
            else if (axis === 'x') pts.push(cx, cy + Math.cos(a) * r, cz + Math.sin(a) * r);
            else pts.push(cx + Math.cos(a) * r, cy + Math.sin(a) * r, cz);
          }
        }
      };
      const mkLines = (pts: number[]): THREE.LineSegments => {
        const g = new THREE.BufferGeometry();
        g.setAttribute('position', new THREE.Float32BufferAttribute(pts, 3));
        const l = new THREE.LineSegments(g, lineMat);
        gz.add(l);
        return l;
      };
      const CONE = (24 * Math.PI) / 180;
      const giz: THREE.LineSegments[] = [];
      {
        const p: number[] = [];
        circle(p, 0.12, 1.0, 'y');
        p.push(-0.25, 1, 0, 0.25, 1, 0, 0, 0.75, 0, 0, 1.25, 0, 0, 1, -0.25, 0, 1, 0.25);
        giz.push(mkLines(p));
      }
      {
        const p: number[] = [];
        circle(p, 1.4, 0.06, 'y');
        circle(p, 1.32, 0.06, 'y');
        giz.push(mkLines(p));
      }
      {
        const p: number[] = [];
        const top = 1.7;
        const rr = Math.tan(CONE) * top;
        circle(p, rr, top, 'y');
        for (let i = 0; i < 6; i++) {
          const a = (i / 6) * TAU;
          p.push(0, 0.05, 0, Math.cos(a) * rr, top, Math.sin(a) * rr);
        }
        giz.push(mkLines(p));
      }
      {
        const p: number[] = [];
        circle(p, 0.95, 1.25, 'y');
        circle(p, 0.95, 1.25, 'x');
        circle(p, 0.95, 1.25, 'z');
        giz.push(mkLines(p));
      }
      const disc = glowDisc(0x58c8ff, 1.4, 5);
      S.scene.add(disc.mesh);
      const pl = new THREE.PointLight(0x60d0ff, 9, 8, 1.6);
      pl.position.set(0, 0.9, 0);
      S.scene.add(pl);
      let mode = -1;
      let rate = 1;
      let curves = true;
      let acc = 0;
      const NAMES = ['점에서 방출', '원 둘레에서 방출', '원뿔 안으로 방출', '구 겉면에서 방출'];
      const emit = (m: number): void => {
        let x = 0;
        let y = 1;
        let z = 0;
        let vx = 0;
        let vy = 0;
        let vz = 0;
        let grav = 0;
        if (m === 0) {
          const d = sphereDir();
          const sp = R(1.1, 2.0);
          vx = d.x * sp;
          vy = d.y * sp;
          vz = d.z * sp;
        } else if (m === 1) {
          const a = Math.random() * TAU;
          x = Math.cos(a) * 1.36;
          z = Math.sin(a) * 1.36;
          y = 0.08;
          vx = Math.cos(a) * 0.3;
          vz = Math.sin(a) * 0.3;
          vy = R(1.0, 1.8);
        } else if (m === 2) {
          const d = coneDir(CONE);
          const sp = R(2.8, 3.8);
          y = 0.05;
          vx = d.x * sp;
          vy = d.y * sp;
          vz = d.z * sp;
          grav = 1.6;
        } else {
          const d = sphereDir();
          x = d.x * 0.95;
          y = 1.25 + d.y * 0.95;
          z = d.z * 0.95;
          vx = d.x * 0.55;
          vy = d.y * 0.55;
          vz = d.z * 0.55;
        }
        const big = Math.random() < 0.15;
        P.spawn({
          x,
          y,
          z,
          vx,
          vy,
          vz,
          life: R(1.2, 1.8),
          s0: curves ? (big ? 0.1 : 0.03) : big ? 0.3 : 0.12,
          s1: curves ? (big ? 0.55 : 0.2) : big ? 0.3 : 0.12,
          ramp: curves ? RAMP.emit : RAMP.emitFlat,
          br: big ? 0.35 : 1,
          shape: big ? 0 : 2,
          drag: curves ? 0.7 : 0,
          grav,
        });
      };
      const controls: Control[] = [
        ctrlRange('방출 모양 (-1 = 자동, 0 점 · 1 원 · 2 원뿔 · 3 구)', -1, 3, 1, -1, (v) => (mode = v)),
        ctrlTog('수명 곡선 (크기 · 색 · 투명도)', true, (v) => (curves = v)),
        ctrlRange('입자 수 (배)', 0.3, 3, 0.1, 1, (v) => (rate = v)),
      ];
      return {
        scene: S.scene,
        camera: S.cam,
        controls,
        update(t, dt0) {
          const dt = Math.min(dt0, 0.05);
          const m = mode < 0 ? Math.floor(t / 3) % 4 : mode;
          giz.forEach((g, i) => (g.visible = i === m));
          lab.set(NAMES[m]! + (curves ? ' · 수명 곡선' : ' · 곡선 없음'), curves ? '#7fe6ff' : '#888');
          acc += dt * 170 * rate;
          while (acc >= 1) {
            acc--;
            emit(m);
          }
          gz.rotation.y = t * 0.2;
          lineMat.opacity = 0.35 + 0.15 * Math.sin(t * 4);
          P.step(dt);
          P.upload();
          S.tick(dt);
        },
        resize: (w, h) => S.resize(w, h),
        dispose: () => S.dispose(),
      };
    },
  }
```

## 관련 기술
- 같은 방법으로 만든 효과 예시: [점 입자 (Points)](https://ai-techstudio.web.app/ai/t/u34.md) `u34` · [속도 방향으로 늘어나는 입자](https://ai-techstudio.web.app/ai/t/i173.md) `i173` · [하위 방출 (터질 때 또 터짐)](https://ai-techstudio.web.app/ai/t/i176.md) `i176` · [색 사다리 (수명 따라 색)](https://ai-techstudio.web.app/ai/t/i177.md) `i177` · [기 모으기 (차지)](https://ai-techstudio.web.app/ai/t/i194.md) `i194` · [불꽃놀이 (국화 · 버드나무 · 고리)](https://ai-techstudio.web.app/ai/t/i503.md) `i503` · [연기 기둥 · 증기 분출](https://ai-techstudio.web.app/ai/t/i504.md) `i504` · [모래성 무너짐 (덩이 · 모래 줄기 · 먼지 구름)](https://ai-techstudio.web.app/ai/t/i508.md) `i508` · [그라인더 불똥 소나기 (튀며 쪼개지는 불똥 · 뜨거운 자국)](https://ai-techstudio.web.app/ai/t/i509.md) `i509` · [화염 방사 (벽에 퍼짐 · 불붙은 방울)](https://ai-techstudio.web.app/ai/t/i513.md) `i513` · [꽃잎 · 낙엽 바람에 흩날림](https://ai-techstudio.web.app/ai/t/i515.md) `i515`
- 먼저 알면 좋은 기술: [인스턴싱 (InstancedMesh)](https://ai-techstudio.web.app/ai/t/u36.md) `u36`
- 다음에 해 볼 기술: [층 쌓기 (핵 · 빛무리 · 불티 · 연기)](https://ai-techstudio.web.app/ai/t/i171.md) `i171` · [컬 잡음 소용돌이 (curl noise)](https://ai-techstudio.web.app/ai/t/i174.md) `i174`
