# AI 꾸러미 — GPU 입자 (수만 개)
> 셰이더로 움직이는 입자 — 눈보라 · 불꽃놀이 · 별무리  
> 견본: https://ai-techstudio.web.app/#t/i22

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

## 주문서

### 만들어 줘: GPU 입자 (수만 개)

#### 1. 목표
홈런 폭죽에 「GPU 입자 (수만 개)」 기술을 깔끔한 기본 느낌으로 넣어 줘. — 셰이더로 움직이는 입자 — 눈보라 · 불꽃놀이 · 별무리

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

#### 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: `i22` · 분류: 이펙트 · 연출 › 입자 · 연출 · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i22
- 쓰면 좋을 때: 쓰면 좋을 곳 — 홈런 폭죽 · 얼음 별 눈 · 천문대 별

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i22 견본 항목 — `src/demos/demosSim.ts:3338`
```ts
  i22: {
    kind: '3d',
    caption: '점 4만 개의 자리를 GPU(셰이더)가 매 순간 계산 — 은하 → 눈보라 → 불꽃놀이로 모양이 바뀌어요',
    make() {
      const scene = new THREE.Scene();
      scene.background = skyTexture('#02030c', '#0c1236');
      const cam = new THREE.PerspectiveCamera(50, 1.6, 0.1, 100);
      cam.position.set(0, 3.2, 8.5);
      cam.lookAt(0, 0.6, 0);
      const N = 40000;
      const geo = new THREE.BufferGeometry();
      const seeds = new Float32Array(N * 4);
      const r = rng(21);
      for (let i = 0; i < N * 4; i++) seeds[i] = r();
      geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(N * 3), 3));
      geo.setAttribute('aSeed', new THREE.BufferAttribute(seeds, 4));
      const mat = new THREE.ShaderMaterial({
        uniforms: { uTime: { value: 0 }, uMode: { value: 0 }, uScale: { value: 300 }, uSize: { value: 1 } },
        vertexShader: /* glsl */ `
          attribute vec4 aSeed;
          uniform float uTime; uniform float uMode; uniform float uScale; uniform float uSize;
          varying vec3 vC; varying float vA;
          vec3 hsv(float h, float s, float v){ vec3 p = abs(fract(h + vec3(1., 2./3., 1./3.)) * 6. - 3.); return v * mix(vec3(1.), clamp(p - 1., 0., 1.), s); }
          float hash(float n){ return fract(sin(n) * 43758.5453); }
          void galaxy(out vec3 p, out vec3 c, out float a){
            float r = pow(aSeed.x, 0.7) * 4.6;
            float arm = floor(aSeed.y * 3.) * 2.0944;
            float ang = arm + r * 1.15 - uTime * 0.35 / (0.5 + r * 0.4) + (aSeed.z - 0.5) * 0.5 * (1.0 - r * 0.1);
            p = vec3(cos(ang) * r, (aSeed.w - 0.5) * 0.35 * (1.2 - r / 5.), sin(ang) * r);
            c = mix(vec3(1., 0.85, 0.55), hsv(0.58 + aSeed.w * 0.25, 0.7, 1.), clamp(r / 2.5, 0., 1.)); a = 0.5; }
          void snow(out vec3 p, out vec3 c, out float a){
            float sp = 0.4 + aSeed.w * 0.7;
            float y = 4. - mod(aSeed.y * 8. + uTime * sp * 1.3, 8.);
            float x = (aSeed.x - 0.5) * 13. + sin(uTime * 0.9 + aSeed.z * 20.) * 0.5 + (y - 4.) * 0.35 * sin(uTime * 0.4);
            p = vec3(x, y, (aSeed.z - 0.5) * 8.);
            c = vec3(0.82, 0.92, 1.); a = 0.55; }
          void fire(out vec3 p, out vec3 c, out float a){
            float g = floor(aSeed.x * 7.); float per = 2.6;
            float tt = uTime + g * 0.371 * per; float cyc = floor(tt / per); float age = mod(tt, per);
            vec3 ctr = vec3((hash(g * 7. + cyc * 13.) - 0.5) * 8., 0.6 + hash(g * 3. + cyc * 5.) * 2.6, (hash(g * 11. + cyc) - 0.5) * 3.);
            float th = aSeed.y * 6.2832; float ph = acos(1. - 2. * aSeed.z);
            vec3 d = vec3(sin(ph) * cos(th), cos(ph), sin(ph) * sin(th));
            float sp = 2.3 * (0.8 + aSeed.w * 0.2);
            p = ctr + d * sp * (1. - exp(-age * 2.6)) - vec3(0., age * age * 0.32, 0.);
            c = hsv(hash(g + cyc * 1.7), 0.7, 1.);
            a = clamp(1. - age / (per * 0.85), 0., 1.) * (0.65 + 0.35 * sin(uTime * 30. + aSeed.x * 100.)); }
          void pick(float m, out vec3 p, out vec3 c, out float a){
            if (m < 0.5) galaxy(p, c, a); else if (m < 1.5) snow(p, c, a); else fire(p, c, a); }
          void main(){
            float ma = mod(floor(uMode), 3.); float mb = mod(ma + 1., 3.);
            float f = smoothstep(0.7, 1., fract(uMode));
            vec3 p1, c1, p2, c2; float a1, a2;
            pick(ma, p1, c1, a1); pick(mb, p2, c2, a2);
            float fs = smoothstep(0., 1., clamp(f * 1.3 - aSeed.w * 0.3, 0., 1.));
            vec3 p = mix(p1, p2, fs); vC = mix(c1, c2, fs); vA = mix(a1, a2, fs);
            vec4 mv = modelViewMatrix * vec4(p, 1.);
            gl_PointSize = 0.05 * uSize * uScale / -mv.z;
            gl_Position = projectionMatrix * mv; }`,
        fragmentShader: /* glsl */ `
          varying vec3 vC; varying float vA;
          void main(){ float d = length(gl_PointCoord - 0.5); float a = smoothstep(0.5, 0.0, d) * vA; if (a < 0.01) discard; gl_FragColor = vec4(vC * a, a); }`,
        transparent: true,
        depthWrite: false,
        blending: THREE.AdditiveBlending,
      });
      const pts = new THREE.Points(geo, mat);
      pts.frustumCulled = false;
      scene.add(pts);
      let auto = true;
      let fixed = 0;
      return {
        scene,
        camera: cam,
        resize(_w, h) {
          mat.uniforms['uScale']!.value = h;
        },
        update(t) {
          mat.uniforms['uTime']!.value = t;
          mat.uniforms['uMode']!.value = auto ? t / 5 : fixed;
          pts.rotation.y = t * 0.08;
        },
        controls: [
          { type: 'toggle', label: '저절로 바뀌기', value: true, on: (v) => (auto = v) },
          {
            type: 'range',
            label: '모양 (0 은하 · 1 눈보라 · 2 불꽃놀이)',
            min: 0,
            max: 2,
            step: 1,
            value: 0,
            on: (v) => {
              auto = false;
              fixed = v;
            },
          },
          { type: 'range', label: '점 크기', min: 0.4, max: 3, step: 0.1, value: 1, on: (v) => (mat.uniforms['uSize']!.value = v) },
        ],
        dispose() {
          disposeScene(scene);
          (scene.background as THREE.Texture).dispose();
        },
      };
    },
  }
```

## 관련 기술
- 이 효과를 만든 기술: [GPU 계산 입자 (수십만 개)](https://ai-techstudio.web.app/ai/t/i495.md) `i495`
