# AI 꾸러미 — 점 입자 (Points)
> 꽃가루 · 불똥 · 눈 · 반딧불 · 폭죽  
> 견본: https://ai-techstudio.web.app/#t/u34

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

## 주문서

### 만들어 줘: 점 입자 (Points)

#### 1. 목표
3D 장면의 물체에 「점 입자 (Points)」 기술을 깔끔한 기본 느낌으로 넣어 줘. — 꽃가루 · 불똥 · 눈 · 반딧불 · 폭죽

#### 2. 핵심 기술 용어
- **Points**

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

#### 4. 조건
- 한 프레임 16ms 안 (폰 포함) — 무거운 계산은 처음에 한 번 또는 여러 프레임에 나눠서
- 새 재질 · 셰이더는 첫 화면 전에 미리 컴파일 (renderer.compileAsync)
- 화면 가장자리 흐림 · 비네트로 얼버무리지 않기
- 외부 라이브러리 추가 없이 three.js 와 three/examples 만

#### 5. 완성 기준 (이게 보이면 성공)
- 견본처럼 「꽃가루 · 불똥 · 눈 · 반딧불 · 폭죽」 이(가) 첫 화면에서 바로 보인다
- 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

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

## 원리
- 꽃가루 · 불똥 · 눈 · 반딧불 · 폭죽


## 흔한 실수 · 확인 목록
- (아직 정리된 것이 없어요)

## 완성 기준 체크리스트
- [ ] 견본처럼 「꽃가루 · 불똥 · 눈 · 반딧불 · 폭죽」 이(가) 첫 화면에서 바로 보인다
- [ ] 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- [ ] 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- [ ] 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

## 이 기술 정보
- id: `u34` · 분류: 이펙트 · 연출 › 입자 · 연출 · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u34

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### u34 견본 항목 — `src/demos/demosSim.ts:1805`
```ts
  u34: {
    kind: '3d',
    caption: '점(Points) 하나로 눈 · 반딧불 · 모닥불 불똥 · 꽃가루 · 폭죽을 한꺼번에 — 점 1,400개',
    make() {
      const scene = new THREE.Scene();
      scene.background = skyTexture('#0b1240', '#2a3a7a');
      const cam = new THREE.PerspectiveCamera(48, 1.6, 0.1, 100);
      cam.position.set(0, 2.2, 10);
      cam.lookAt(0, 2.2, 0);
      scene.add(new THREE.HemisphereLight(0x9ab0ff, 0x203018, 1.2));
      const ground = new THREE.Mesh(new THREE.CircleGeometry(14, 40), new THREE.MeshLambertMaterial({ color: 0x1d3a2a }));
      ground.rotation.x = -Math.PI / 2;
      scene.add(ground);
      // 모닥불
      const fire = new THREE.Group();
      for (let k = 0; k < 3; k++) {
        const log = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.1, 1, 8), new THREE.MeshLambertMaterial({ color: 0x6a3a1a, emissive: 0x401000 }));
        log.rotation.z = Math.PI / 2;
        log.rotation.y = (k * Math.PI) / 3;
        log.position.y = 0.1;
        fire.add(log);
      }
      const glow = new THREE.PointLight(0xff8a3a, 6, 6);
      glow.position.y = 0.6;
      fire.add(glow);
      fire.position.set(-3, 0, 1);
      scene.add(fire);
      const N = 1400;
      const pool = new PointPool(N);
      scene.add(pool.obj);
      const r = rng(7);
      // 종류별 칸: 눈 0~499 · 반딧불 500~559 · 불똥 560~799 · 꽃가루 800~949 · 폭죽 950~1399
      const vx = new Float32Array(N * 3);
      const life = new Float32Array(N);
      const seed = new Float32Array(N);
      for (let i = 0; i < N; i++) seed[i] = r();
      const P = pool.pos;
      for (let i = 0; i < 500; i++) {
        P[i * 3] = (r() - 0.5) * 18;
        P[i * 3 + 1] = r() * 9;
        P[i * 3 + 2] = (r() - 0.5) * 8;
      }
      for (let i = 500; i < 560; i++) {
        P[i * 3] = 1 + r() * 5;
        P[i * 3 + 1] = 0.3 + r() * 1.5;
        P[i * 3 + 2] = (r() - 0.5) * 4;
      }
      for (let i = 560; i < 800; i++) life[i] = -r() * 1.2;
      for (let i = 800; i < 950; i++) {
        P[i * 3] = (r() - 0.5) * 16;
        P[i * 3 + 1] = 1 + r() * 7;
        P[i * 3 + 2] = (r() - 0.5) * 6;
      }
      for (let i = 950; i < N; i++) life[i] = 0;
      const c = new THREE.Color();
      const BURSTS = 3;
      const PER = 150;
      const burstAt = [0.2, 0.9, 1.6];
      const burstHue = [0.95, 0.12, 0.55];
      let sizeK = 1;
      return {
        scene,
        camera: cam,
        resize(_w, h) {
          pool.resize(h);
        },
        update(t, dt) {
          dt = Math.min(dt, 0.05);
          glow.intensity = 5 + Math.sin(t * 17) * 1.2 + Math.sin(t * 7) * 1.4;
          // 눈
          for (let i = 0; i < 500; i++) {
            P[i * 3 + 1] = P[i * 3 + 1]! - (dt * (0.5 + seed[i]! * 0.5));
            P[i * 3] = P[i * 3]! + (Math.sin(t * 1.3 + seed[i]! * 20) * dt * 0.3);
            if (P[i * 3 + 1]! < 0) P[i * 3 + 1] = 9;
            c.setRGB(0.85, 0.92, 1);
            pool.set(i, P[i * 3]!, P[i * 3 + 1]!, P[i * 3 + 2]!, c, 0.14 * sizeK, 0.75);
          }
          // 반딧불 — 깜빡이며 떠돈다
          for (let i = 500; i < 560; i++) {
            const s = seed[i]! * 50;
            const x = P[i * 3]! + Math.sin(t * 0.7 + s) * 0.004;
            const y = P[i * 3 + 1]! + Math.cos(t * 0.9 + s) * 0.003;
            P[i * 3] = x;
            P[i * 3 + 1] = y;
            const blink = Math.max(0, Math.sin(t * 2.2 + s));
            c.setRGB(0.75, 1, 0.3);
            pool.set(i, x, y, P[i * 3 + 2]!, c, (0.18 + blink * 0.25) * sizeK, 0.2 + blink * 0.9);
          }
          // 모닥불 불똥 — 솟아오르며 식는다
          for (let i = 560; i < 800; i++) {
            const before = life[i]!;
            life[i] = life[i]! + (dt);
            if (life[i]! > 1.4 || (before < 0 && life[i]! >= 0)) {
              life[i] = 0;
              P[i * 3] = fire.position.x + (r() - 0.5) * 0.5;
              P[i * 3 + 1] = 0.25;
              P[i * 3 + 2] = fire.position.z + (r() - 0.5) * 0.5;
              vx[i * 3] = (r() - 0.5) * 0.6;
              vx[i * 3 + 1] = 1.2 + r() * 1.6;
              vx[i * 3 + 2] = (r() - 0.5) * 0.6;
            }
            const k = clamp(life[i]! / 1.4, 0, 1);
            if (life[i]! >= 0) for (let a = 0; a < 3; a++) P[i * 3 + a] = P[i * 3 + a]! + (vx[i * 3 + a]! * dt);
            P[i * 3] = P[i * 3]! + (Math.sin(t * 6 + seed[i]! * 30) * dt * 0.4);
            c.setRGB(1, 0.8 - k * 0.6, 0.3 - k * 0.3);
            pool.set(i, P[i * 3]!, P[i * 3 + 1]!, P[i * 3 + 2]!, c, (0.16 - k * 0.1) * sizeK, life[i]! < 0 ? 0 : 1 - k);
          }
          // 꽃가루 — 바람 따라 비스듬히
          for (let i = 800; i < 950; i++) {
            P[i * 3] = P[i * 3]! + (dt * (0.7 + seed[i]! * 0.4));
            P[i * 3 + 1] = P[i * 3 + 1]! + (Math.sin(t * 2 + seed[i]! * 40) * dt * 0.4 - dt * 0.15);
            if (P[i * 3]! > 9) P[i * 3] = -9;
            if (P[i * 3 + 1]! < 0.5) P[i * 3 + 1] = 8;
            c.setRGB(1, 0.6 + seed[i]! * 0.2, 0.78);
            pool.set(i, P[i * 3]!, P[i * 3 + 1]!, P[i * 3 + 2]!, c, 0.16 * sizeK, 0.85);
          }
          // 폭죽 — 2.1초마다 세 송이
          const cyc = t % 2.1;
          const round = Math.floor(t / 2.1);
          for (let b = 0; b < BURSTS; b++) {
            const age = cyc - burstAt[b]!;
            const br = rng(round * 7 + b * 13 + 1);
            const cx = (br() - 0.5) * 9 + 1.5;
            const cy = 3.7 + br() * 1.5;
            for (let k = 0; k < PER; k++) {
              const i = 950 + b * PER + k;
              if (age < 0 || age > 1.5) {
                pool.alpha[i] = 0;
                continue;
              }
              const s = seed[i]!;
              const th = s * TAU * 13.7;
              const ph = Math.acos(1 - 2 * ((k + 0.5) / PER));
              const sp = 2.0 * (0.8 + (s * 7) % 0.3);
              const dx = Math.sin(ph) * Math.cos(th);
              const dy = Math.cos(ph);
              const dz = Math.sin(ph) * Math.sin(th);
              const d = sp * (1 - Math.exp(-age * 3)) / 1.0;
              c.setHSL((burstHue[b]! + round * 0.17) % 1, 0.95, 0.62);
              pool.set(i, cx + dx * d, cy + dy * d - age * age * 0.9, dz * d, c, 0.26 * sizeK, Math.max(0, 1 - age / 1.5) * (0.7 + 0.3 * Math.sin(t * 40 + k)));
            }
          }
          pool.flush();
        },
        controls: [
          { type: 'range', label: '입자 크기', min: 0.4, max: 2.5, step: 0.1, value: 1, on: (v) => (sizeK = v) },
          {
            type: 'toggle',
            label: '빛 더하기 섞기 (Additive)',
            value: true,
            on: (v) => {
              pool.mat.blending = v ? THREE.AdditiveBlending : THREE.NormalBlending;
              pool.mat.needsUpdate = true;
            },
          },
        ],
        dispose() {
          disposeScene(scene);
          (scene.background as THREE.Texture).dispose();
        },
      };
    },
  }
```

## 관련 기술
- 이 효과를 만든 기술: [입자 방출기 (수명 곡선)](https://ai-techstudio.web.app/ai/t/i170.md) `i170`
