# AI 꾸러미 — GPU 계산 입자 (수십만 개) — GPU compute particles
> 입자 수십만 개의 위치 · 속도를 계산 셰이더로 GPU 에서 바꿔, CPU 는 명령 두 줄만 보내고도 눈보라 · 불꽃놀이를 띄운다.  
> 견본: https://ai-techstudio.web.app/#t/i495

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

## 주문서

### 만들어 줘: GPU 계산 입자 (수십만 개) — GPU compute particles

#### 1. 목표
소용돌이 · 끌어당김 · 불꽃놀이 입자를 GPU 계산 입자 수십만 개로 만들어 줘 — 위치 · 속도는 계산 셰이더에서, CPU 는 명령만. 분위기는 어두운 밤하늘에 빛나는.

#### 2. 핵심 기술 용어
- **GPU compute particles** — 계산 셰이더로 움직이는 입자
- **Compute shader (renderer.compute)** — 그리기 없이 계산만 하는 GPU 프로그램
- **instancedArray · instanceIndex (TSL)** — GPU 에 사는 입자별 배열 · 지금 입자 번호
- **SpriteNodeMaterial** — 배열 값을 위치 · 색으로 받는 화면 향한 판

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

#### 4. 조건
- three/webgpu WebGPURenderer + three/tsl — 위치 · 속도는 instancedArray, 계산은 Fn(...)().compute(N)
- CPU 쪽 입자 배열을 만들지 않는다 — 매 프레임 할 일은 uniform 값 바꾸기와 renderer.compute(update) 뿐
- 입자 수를 바꿀 땐 슬라이더를 멈춘 뒤(0.25초) 한 번만 다시 만든다 — 끄는 동안 매번 만들면 멈춘다
- Sprite.frustumCulled = false (위치가 GPU 에만 있어 경계 상자가 틀린다)
- 입자가 많을수록 투명도를 낮춰(견본: 0.7 × (6만/N)^0.6) 겹쳐도 하얗게 타지 않게

#### 5. 완성 기준 (이게 보이면 성공)
- 25만 개 입자가 끊김 없이 돌고, 화면 구석에 입자 수와 백엔드(WebGPU / WebGL2)가 보인다
- 「소용돌이 · 끌어당김 · 불꽃놀이」 단추로 같은 입자가 다른 움직임으로 바로 바뀐다
- 「입자 수」를 2만 ~ 50만으로 바꿔도 CPU 시간은 거의 그대로다
- WebGL2 로 강제하면 개수가 8만 개로 줄었다는 안내가 나온다

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

## 원리
- 입자마다 위치 · 속도 · 수명 · 색을 instancedArray 로 GPU 메모리에 만든다 (CPU 배열이 없다).
- 처음 한 번 init 계산으로 자리를 깔고, 매 프레임 update 계산이 instanceIndex 번째 입자의 값을 읽어 고쳐 쓴다.
- 새 위치 = 위치 + 속도 × dt. 힘(끌림 · 중력 · 공기 저항)은 속도에 더한다.
- 그리기는 Sprite 하나에 count = N, 재질 positionNode = pos.toAttribute() 로 — 그리기 호출 한 번.
- 모드 · 시간 · 끄는 점 같은 바뀌는 값은 모두 uniform 으로 넘긴다.

## 핵심 코드 — 입자 배열 + 계산 셰이더 + 스프라이트 하나로 그리기 (끌어당김 모드)
(발췌: demos/demosWebGPU.ts i495 build() 를 줄여 정리)
```ts
import * as GPU from 'three/webgpu';
import { Fn, instancedArray, instanceIndex, hash, uniform, vec3, float, dot, cos, smoothstep, uv } from 'three/tsl';

const N = 250000;
const uDt = uniform(0.016);
const A = uniform(new GPU.Vector3()); // 끌어당기는 점
const pos = instancedArray(N, 'vec3');
const vel = instancedArray(N, 'vec3');

const init = Fn(() => {
  const a = hash(instanceIndex).mul(Math.PI * 2);
  const r = hash(instanceIndex.add(7919)).sqrt().mul(6.3).add(0.35);
  pos.element(instanceIndex).assign(vec3(cos(a).mul(r), 0, a.sin().mul(r)));
})().compute(N);

const update = Fn(() => {
  const p = pos.element(instanceIndex).toVar();
  const v = vel.element(instanceIndex).toVar();
  const d = A.sub(p);
  const r2 = dot(d, d).add(0.3);
  v.addAssign(d.mul(float(2.4).div(r2.mul(r2.sqrt()))).mul(uDt).mul(4)); // 1/r² 끌림
  v.mulAssign(float(1).sub(uDt.mul(0.3))); // 공기 저항
  p.addAssign(v.mul(uDt));
  pos.element(instanceIndex).assign(p);
  vel.element(instanceIndex).assign(v);
})().compute(N);

const mat = new GPU.SpriteNodeMaterial({ transparent: true, depthWrite: false, blending: GPU.AdditiveBlending });
mat.positionNode = pos.toAttribute();
mat.opacityNode = smoothstep(0.5, 0.0, uv().sub(0.5).length()).mul(0.5);
mat.scaleNode = uniform(0.04);
const sprite = new GPU.Sprite(mat);
sprite.count = N;
sprite.frustumCulled = false;
scene.add(sprite);
renderer.compute(init);

// 매 프레임: uDt.value = dt; A.value.set(...); renderer.compute(update); renderer.render(scene, cam);
```

## 흔한 실수 · 확인 목록
- [ ] **frustumCulled 를 켜 두면 입자가 통째로 사라진다** — 위치가 GPU 배열에만 있어 three 가 경계 상자를 모른다. Sprite.frustumCulled = false.
- [ ] **입자 수 슬라이더를 끄는 동안 매번 다시 만들면 화면이 멈춘다** — 마지막으로 바꾼 지 0.25초가 지난 뒤에 한 번만 build(N) 한다. 옛 계산 · 재질은 dispose.
- [ ] **입자를 늘리면 더하기 섞기로 화면이 하얗게 탄다** — 입자 수에 맞춰 투명도(uAlpha)를 낮춘다. 견본은 0.7 × min(1, (60000/N)^0.6).
- [ ] **WebGL2 대체에서 50만 개를 그대로 두면 느려진다** — WebGL2 는 계산 셰이더 대신 변환 피드백이라 견본은 최대 8만 개로 묶었다.

## 완성 기준 체크리스트
- [ ] 25만 개 입자가 끊김 없이 돌고, 화면 구석에 입자 수와 백엔드(WebGPU / WebGL2)가 보인다
- [ ] 「소용돌이 · 끌어당김 · 불꽃놀이」 단추로 같은 입자가 다른 움직임으로 바로 바뀐다
- [ ] 「입자 수」를 2만 ~ 50만으로 바꿔도 CPU 시간은 거의 그대로다
- [ ] WebGL2 로 강제하면 개수가 8만 개로 줄었다는 안내가 나온다

## 이 기술 정보
- id: `i495` · 분류: 그래픽 · 셰이더 › WebGPU · 최신 렌더링 · 3D · 난이도 어려움 · 폰 부담 무거움 — CPU 는 거의 0 이지만 GPU 는 입자 수만큼 계산 + 겹쳐 그리기(더하기 섞기)를 한다. 견본은 WebGPU 50만 개, WebGL2 대체는 8만 개까지.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i495
- 쓰면 좋을 때: 입자가 수만 개 이상이라 CPU 로 매 프레임 옮기기 힘들 때 / 불꽃놀이 · 은하 · 눈보라처럼 숫자가 곧 볼거리일 때
- 쓰지 말 때: 입자가 수천 개 이하 — 보통 Points 입자 방출기(i170)에 CPU 갱신이 더 간단하다 / WebGPU 가 없는 기기가 많은 곳 — WebGL2 대체에선 계산 셰이더가 없어 개수를 크게 줄여야 한다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i495 — `src/demos/demosWebGPU.ts:1026`
```ts
const i495 = gpuDemo<{ want: number; mode: number; changedAt: number }>({
  caption: '입자 수십만 개의 위치 · 속도를 계산 셰이더(compute)로 GPU 에서 — CPU 는 명령만 보낸다 (크게 보기에서 실제 실행)',
  state: () => ({ want: 250000, mode: 0, changedAt: 0 }),
  card: card495,
  controls: (st) => [
    {
      type: 'range',
      label: '입자 수 (천 개)',
      min: 20,
      max: 500,
      step: 10,
      value: 250,
      on: (v) => {
        st.want = v * 1000;
        st.changedAt = performance.now();
      },
    },
    { type: 'button', label: '소용돌이', on: () => (st.mode = 0) },
    { type: 'button', label: '끌어당김', on: () => (st.mode = 1) },
    { type: 'button', label: '불꽃놀이', on: () => (st.mode = 2) },
  ],
  async build(ctx, st) {
    const { GPU, TSL, renderer } = ctx;
    const { Fn, If, instancedArray, instanceIndex, hash, uniform, vec3, float, mix, smoothstep, dot, cos, sin, abs, uv } = TSL;
    renderer.toneMapping = GPU.ACESFilmicToneMapping;
    const scene = new GPU.Scene();
    scene.background = new GPU.Color(0x030308);
    const cam = new GPU.PerspectiveCamera(45, ctx.w / ctx.h, 0.1, 200);
    const cap = ctx.webgpu ? 500000 : 80000;
    // 바닥 격자 (크기 느낌)
    const grid = new GPU.GridHelper(24, 24, 0x2a2450, 0x15122a);
    grid.position.y = -2.2;
    scene.add(grid);

    const uDt = uniform(0.016);
    const uT = uniform(0);
    const uMode = uniform(0);
    const uSize = uniform(0.04);
    const uAlpha = uniform(0.5);
    const A1 = uniform(new GPU.Vector3());
    const A2 = uniform(new GPU.Vector3());
    const A3 = uniform(new GPU.Vector3());
    const uBurst = uniform(0);
    const uGroup = uniform(0);
    const uCenter = uniform(new GPU.Vector3());
    const uSeed = uniform(0);

    let sys: { N: number; sprite: Any; mat: Any; update: Any; init: Any } | null = null;
    const build = (N: number): void => {
      if (sys) {
        scene.remove(sys.sprite);
        sys.mat.dispose();
        sys.update.dispose?.();
        sys.init.dispose?.();
      }
      const pos = instancedArray(N, 'vec3');
      const vel = instancedArray(N, 'vec3');
      const life = instancedArray(N, 'float');
      const col = instancedArray(N, 'vec3');
      const init = Fn(() => {
        const i = instanceIndex;
        const a = hash(i).mul(TAU);
        const r = hash(i.add(7919)).sqrt().mul(6.3).add(0.35);
        const y0 = float(1).sub(r.div(6.5)).max(0).pow(2).mul(4.2).sub(0.6).add(hash(i.add(104729)).sub(0.5).mul(0.5));
        pos.element(i).assign(vec3(cos(a).mul(r), y0, a.sin().mul(r)));
        vel.element(i).assign(vec3(0));
        life.element(i).assign(1);
        col.element(i).assign(vec3(0.3, 0.4, 1));
      })().compute(N);
      const update = Fn(() => {
        const i = instanceIndex;
        const p = pos.element(i).toVar();
        const v = vel.element(i).toVar();
        const L = life.element(i).toVar();
        const c = vec3(0).toVar();
        const h1 = hash(i);
        const h2 = hash(i.add(7919));
        const h3 = hash(i.add(104729));
        If(uMode.equal(0), () => {
          // 입자마다 고리 하나를 도는 시간 u (0→1): 바깥에서 빨려 들어 가운데로 솟는다. 각은 지금 방향을 돌려서 쌓는다.
          const u = uT.mul(h2.mul(0.08).add(0.06)).add(h3).fract();
          const r1 = float(1).sub(u).pow(1.4).mul(6.4).add(0.25);
          const r0 = vec3(p.x, 0, p.z).length();
          const dth = float(2.6).div(r1.add(0.45)).mul(uDt);
          const cd = cos(dth);
          const sd = sin(dth);
          const dx = r0.greaterThan(0.001).select(p.x.div(r0), cos(h1.mul(TAU)));
          const dz = r0.greaterThan(0.001).select(p.z.div(r0), h1.mul(TAU).sin());
          const wob = sin(uT.mul(1.3).add(h1.mul(40))).mul(0.18).mul(r1.div(6.5));
          const rr = r1.add(wob);
          const y1 = float(1).sub(r1.div(6.8)).max(0).pow(2.2).mul(4.4).sub(0.7).add(h3.sub(0.5).mul(float(0.25).add(r1.mul(0.08))));
          const np = vec3(dx.mul(cd).sub(dz.mul(sd)).mul(rr), y1, dx.mul(sd).add(dz.mul(cd)).mul(rr));
          const jump = abs(r1.sub(r0)).greaterThan(1.0);
          v.assign(jump.select(vec3(0), np.sub(p).div(uDt.max(0.0001))));
          p.assign(jump.select(np, p));
          const s = v.length();
          c.assign(mix(vec3(0.08, 0.25, 1.0), vec3(1.0, 0.25, 0.75), smoothstep(5.8, 2.2, r1)).add(vec3(0.7, 0.75, 1).mul(smoothstep(1.4, 0.3, r1))).mul(s.mul(0.08).add(0.75)));
          L.assign(1);
        })
          .ElseIf(uMode.equal(1), () => {
            const acc = vec3(0).toVar();
            for (const A of [A1, A2, A3]) {
              const dd = A.sub(p);
              const r2 = dot(dd, dd).add(0.3);
              acc.addAssign(dd.mul(float(2.4).div(r2.mul(r2.sqrt()))));
            }
            v.addAssign(acc.mul(uDt).mul(4));
            v.mulAssign(float(1).sub(uDt.mul(0.3)));
            const s = v.length();
            If(s.greaterThan(9), () => {
              v.mulAssign(float(9).div(s));
            });
            c.assign(mix(vec3(1, 0.5, 0.12), vec3(0.35, 0.85, 1), smoothstep(1, 7, s)));
            L.assign(1);
          })
          .Else(() => {
            const grp = h1.mul(8).floor();
            If(uBurst.greaterThan(0.5).and(grp.equal(uGroup)), () => {
              const z = hash(i.toFloat().add(uSeed)).mul(2).sub(1);
              const a = hash(i.toFloat().add(uSeed).add(31.7)).mul(TAU);
              const rr = float(1).sub(z.mul(z)).max(0).sqrt();
              const dir = vec3(rr.mul(cos(a)), z, rr.mul(a.sin()));
              p.assign(uCenter);
              v.assign(dir.mul(h2.pow(0.15).mul(2.3)).add(vec3(0, 0.4, 0)));
              L.assign(1);
            });
            v.addAssign(vec3(0, -2.2, 0).mul(uDt));
            v.mulAssign(float(1).sub(uDt.mul(0.9)));
            L.assign(L.sub(uDt.mul(0.4)).max(0));
            const base = vec3(0.5).add(vec3(0.5).mul(cos(vec3(0, 0.33, 0.67).add(grp.mul(0.137)).mul(TAU))));
            c.assign(mix(base, vec3(1, 0.95, 0.8), L.pow(8)).mul(L.pow(1.4)).mul(1.6));
          });
        p.addAssign(v.mul(uDt));
        pos.element(i).assign(p);
        vel.element(i).assign(v);
        life.element(i).assign(L);
        col.element(i).assign(c);
      })().compute(N);
      const mat = new GPU.SpriteNodeMaterial({ transparent: true, depthWrite: false, blending: GPU.AdditiveBlending });
      mat.positionNode = pos.toAttribute();
      mat.colorNode = col.toAttribute();
      mat.opacityNode = smoothstep(0.5, 0.0, uv().sub(0.5).length()).mul(uAlpha);
      mat.scaleNode = uSize;
      const sprite = new GPU.Sprite(mat);
      sprite.count = N;
      sprite.frustumCulled = false;
      scene.add(sprite);
      renderer.compute(init);
      sys = { N, sprite, mat, update, init };
      uAlpha.value = 0.7 * Math.min(1, Math.pow(60000 / N, 0.6));
    };
    build(Math.min(st.want, cap));
    let burstT = 0;
    let k = 0;
    return {
      scene,
      camera: cam,
      frame(t, dt) {
        const want = Math.min(st.want, cap);
        if (sys && want !== sys.N && performance.now() - st.changedAt > 250) build(want);
        if (!sys) return;
        uDt.value = dt;
        uT.value = t;
        uMode.value = st.mode;
        uSize.value = st.mode === 2 ? 0.035 : 0.04;
        A1.value.set(Math.sin(t * 0.7) * 4, Math.sin(t * 1.1) * 1.5 + 0.5, Math.cos(t * 0.5) * 2.5);
        A2.value.set(Math.sin(t * 0.5 + 2) * 4, Math.cos(t * 0.8) * 1.5 + 0.5, Math.cos(t * 0.9 + 1) * 2.5);
        A3.value.set(Math.cos(t * 0.6 + 4) * 3, Math.sin(t * 0.4 + 3) * 1.2 + 0.5, Math.sin(t * 0.7 + 2) * 2.5);
        uBurst.value = 0;
        if (st.mode === 2) {
          burstT -= dt;
          if (burstT <= 0) {
            burstT = 0.32;
            uBurst.value = 1;
            uGroup.value = k++ % 8;
            uCenter.value.set((Math.random() - 0.5) * 9, 1 + Math.random() * 3, (Math.random() - 0.5) * 4);
            uSeed.value = Math.floor(Math.random() * 100000);
          }
        }
        renderer.compute(sys.update);
        const ang = t * 0.08;
        cam.position.set(Math.sin(ang) * 12.5, 4.2, Math.cos(ang) * 12.5);
        cam.lookAt(0, 0.4, 0);
      },
      dispose() {
        sys?.update.dispose?.();
        sys?.init.dispose?.();
      },
      note: () => {
        const n = sys ? sys.N.toLocaleString('ko-KR') : '-';
        const base = `입자 ${n}개 · ${MODE495[st.mode]} — 매 프레임 GPU 가 위치 · 속도를 계산 (CPU 는 명령 두 줄만 보냄)`;
        return ctx.webgpu ? base : base + '\nWebGL2 대체: 계산 셰이더 대신 변환 피드백이라 최대 8만 개로 줄였어요';
      },
    };
  },
});
```

## 관련 기술
- 같은 방법으로 만든 효과 예시: [GPU 입자 (수만 개)](https://ai-techstudio.web.app/ai/t/i22.md) `i22`
- 먼저 알면 좋은 기술: [노드 재질 (TSL — 코드로 셰이더 짜기)](https://ai-techstudio.web.app/ai/t/i494.md) `i494` · [입자 방출기 (수명 곡선)](https://ai-techstudio.web.app/ai/t/i170.md) `i170`
- 다음에 해 볼 기술: [수천 캐릭터 군중 (구운 뼈 애니메이션 텍스처)](https://ai-techstudio.web.app/ai/t/i498.md) `i498`
- 참고 문서: [three.js 예제 — webgpu_compute_particles](https://threejs.org/examples/#webgpu_compute_particles)
