# AI 꾸러미 — 필살기 연출 (빛꼬리 · 잔상)
> 불꽃 · 번개 · 도깨비 · 회오리 · 무지개 — 입자 + 번개 줄기 + 잔상  
> 견본: https://ai-techstudio.web.app/#t/u37

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

## 주문서

### 만들어 줘: 필살기 연출 (빛꼬리 · 잔상)

#### 1. 목표
3D 장면의 물체에 「필살기 연출 (빛꼬리 · 잔상)」 기술을 깔끔한 기본 느낌으로 넣어 줘. — 불꽃 · 번개 · 도깨비 · 회오리 · 무지개 — 입자 + 번개 줄기 + 잔상

#### 2. 핵심 기술 용어
- 필살기 연출 (빛꼬리 · 잔상) — 불꽃 · 번개 · 도깨비 · 회오리 · 무지개 — 입자 + 번개 줄기 + 잔상

#### 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: `u37` · 분류: 이펙트 · 연출 › 입자 · 연출 · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u37

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### u37 견본 항목 — `src/demos/demosSim.ts:2156`
```ts
  u37: {
    kind: '3d',
    caption: '숫자 야구 필살 투구 — 불꽃 · 번개 · 도깨비 · 회오리 · 무지개, 공 뒤에 잔상 8개 + 불똥 입자 + 번개 줄기',
    make() {
      const scene = new THREE.Scene();
      scene.background = skyTexture('#060a24', '#2a3a80');
      scene.add(new THREE.HemisphereLight(0xdfe8ff, 0x305030, 2.2));
      const cam = new THREE.PerspectiveCamera(50, 1.6, 0.1, 120);
      cam.position.set(1.2, 2.4, 7.2);
      cam.lookAt(0, 1.3, -7);
      const grass = new THREE.Mesh(new THREE.PlaneGeometry(40, 60), new THREE.MeshLambertMaterial({ color: 0x2f8a48 }));
      grass.rotation.x = -Math.PI / 2;
      grass.position.z = -14;
      scene.add(grass);
      const dirt = new THREE.Mesh(new THREE.CircleGeometry(1.6, 32), new THREE.MeshLambertMaterial({ color: 0x8a5a3a }));
      dirt.rotation.x = -Math.PI / 2;
      dirt.position.set(0, 0.01, -22);
      scene.add(dirt);
      const plate = new THREE.Mesh(new THREE.CircleGeometry(0.45, 5), new THREE.MeshLambertMaterial({ color: 0xffffff }));
      plate.rotation.x = -Math.PI / 2;
      plate.position.set(0, 0.03, 1.4);
      plate.rotation.z = Math.PI / 2;
      scene.add(plate);
      const homeDirt = new THREE.Mesh(new THREE.CircleGeometry(1.1, 32), new THREE.MeshLambertMaterial({ color: 0x9a6a44 }));
      homeDirt.rotation.x = -Math.PI / 2;
      homeDirt.position.set(0, 0.015, 1.4);
      scene.add(homeDirt);
      // 조명탑 빛
      for (const x of [-9, 9]) {
        const l = new THREE.Mesh(new THREE.SphereGeometry(0.6, 12, 8), new THREE.MeshBasicMaterial({ color: 0xffffee }));
        l.position.set(x, 9, -26);
        scene.add(l);
      }
      const ballMat = new THREE.MeshStandardMaterial({ color: 0xf4f1ea, emissive: 0xff7a1a, emissiveIntensity: 0.6, roughness: 0.5 });
      const ball = new THREE.Mesh(new THREE.SphereGeometry(0.22, 24, 16), ballMat);
      scene.add(ball);
      const after: THREE.Mesh[] = [];
      for (let k = 0; k < 8; k++) {
        const m = new THREE.Mesh(new THREE.SphereGeometry(0.22, 16, 10), new THREE.MeshBasicMaterial({ color: 0xff7a1a, transparent: true, opacity: 0.5, blending: THREE.AdditiveBlending, depthWrite: false }));
        scene.add(m);
        after.push(m);
      }
      const boltGeo = new THREE.BufferGeometry();
      boltGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(24 * 3), 3));
      const bolt = new THREE.Line(boltGeo, new THREE.LineBasicMaterial({ color: 0xbfe8ff }));
      bolt.frustumCulled = false;
      scene.add(bolt);
      const boltGeo2 = boltGeo.clone();
      const bolt2 = new THREE.Line(boltGeo2, new THREE.LineBasicMaterial({ color: 0x5ab0ff }));
      bolt2.frustumCulled = false;
      scene.add(bolt2);
      const NP = 500;
      const sparks = new PointPool(NP);
      scene.add(sparks.obj);
      const sv = new Float32Array(NP * 3);
      const sl = new Float32Array(NP);
      let sNext = 0;
      const flash = new THREE.Sprite(new THREE.SpriteMaterial({ map: dotTexture(0.2), color: 0xffffff, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false }));
      scene.add(flash);
      type Sp = 'fire' | 'bolt' | 'ghost' | 'twist' | 'ult';
      const SPS: Sp[] = ['fire', 'bolt', 'ghost', 'twist', 'ult'];
      const NAME: Record<Sp, string> = { fire: '불꽃 강속구!', bolt: '번개 직구!', ghost: '도깨비 공!', twist: '회오리 볼!', ult: '필살! 무지개 유성구!' };
      const COL: Record<Sp, number> = { fire: 0xff7a1a, bolt: 0x8ad8ff, ghost: 0xb06aff, twist: 0x4af0e0, ult: 0xffffff };
      const cut: Record<string, THREE.Sprite> = {};
      for (const s of SPS) {
        const sp = textSprite(NAME[s], { size: 72, color: '#ffffff', stroke: '#10142e', font: 'title', bg: `#${new THREE.Color(COL[s]).multiplyScalar(s === 'ult' ? 0.9 : 0.7).getHexString()}`, h: 0.62 });
        sp.visible = false;
        scene.add(sp);
        cut[s] = sp;
      }
      const from = new THREE.Vector3(0, 1.9, -21);
      const to = new THREE.Vector3(0, 1.2, 1.4);
      const ctrl = new THREE.Vector3(0, 2.6, -10);
      const dir = to.clone().sub(from).normalize();
      const side = new THREE.Vector3().crossVectors(dir, new THREE.Vector3(0, 1, 0)).normalize();
      const up = new THREE.Vector3().crossVectors(side, dir).normalize();
      const DUR = 2.3;
      let showAfter = true;
      let showBolt = true;
      const c = new THREE.Color();
      const pathAt = (sp: Sp, k: number, out: THREE.Vector3): THREE.Vector3 => {
        const e = sp === 'ult' ? k * k : k * k * k * 0.6 + k * 0.4;
        out.copy(from).multiplyScalar((1 - e) * (1 - e)).addScaledVector(ctrl, 2 * (1 - e) * e).addScaledVector(to, e * e);
        if (sp === 'bolt') out.addScaledVector(side, (Math.abs(((k * 5) % 2) - 1) * 2 - 1) * 1.1 * (1 - k));
        if (sp === 'twist') {
          const th = k * Math.PI * 7;
          out.addScaledVector(side, Math.cos(th) * 1.5 * (1 - k)).addScaledVector(up, Math.sin(th) * 1.5 * (1 - k));
        }
        if (sp === 'ghost') out.addScaledVector(side, Math.sin(k * Math.PI * 3) * 1.6 * (1 - k));
        return out;
      };
      const a = new THREE.Vector3();
      const b = new THREE.Vector3();
      return {
        scene,
        camera: cam,
        resize(_w, h) {
          sparks.resize(h);
        },
        update(t, dt) {
          dt = Math.min(dt, 0.05);
          const idx = Math.floor(t / DUR) % SPS.length;
          const sp = SPS[idx]!;
          const lt = t % DUR;
          const col = COL[sp];
          // 컷인 띠 — 화면을 가르며 지나감
          for (const s of SPS) cut[s]!.visible = false;
          const ci = cut[sp]!;
          if (lt < 0.95) {
            ci.visible = true;
            const k = lt / 0.95;
            const x = k < 0.25 ? lerp(-7, 0, easeOut(k / 0.25)) : k < 0.75 ? lerp(0, -0.3, (k - 0.25) / 0.5) : lerp(-0.3, 7, ease((k - 0.75) / 0.25));
            ci.position.set(cam.position.x + x * 0.5 - 0.3, 2.55, 1.0);
            (ci.material as THREE.SpriteMaterial).opacity = 1;
          }
          const k = (lt - 0.35) / 1.15;
          const hue = (t * 1.5) % 1;
          if (sp === 'ult') c.setHSL(hue, 1, 0.6);
          else c.setHex(col);
          ballMat.emissive.copy(c);
          if (k >= 0 && k < 1) {
            pathAt(sp, k, a);
            ball.position.copy(a);
            ball.visible = !(sp === 'ghost' && k > 0.38 && k < 0.7);
            const sc = sp === 'ult' ? 1.8 - 0.6 * k : 1.3 - 0.4 * k;
            ball.scale.setScalar(sc);
            after.forEach((m, ai) => {
              const kk = Math.max(0, k - (ai + 1) * 0.045);
              pathAt(sp, kk, b);
              if (sp === 'ghost') b.addScaledVector(side, (ai % 2 ? 0.5 : -0.5) * (1 - kk));
              m.position.copy(b);
              m.scale.setScalar(sc * (1 - ai * 0.06));
              m.visible = showAfter && kk > 0;
              const mm = m.material as THREE.MeshBasicMaterial;
              if (sp === 'ult') mm.color.setHSL((hue + ai * 0.12) % 1, 1, 0.6);
              else mm.color.setHex(col);
              mm.opacity = 0.55 * (1 - ai / 8);
            });
            // 불똥
            const emit = sp === 'fire' || sp === 'ult' ? 10 : 5;
            for (let e = 0; e < emit; e++) {
              const i = sNext;
              sNext = (sNext + 1) % NP;
              sl[i] = 0.5 + Math.random() * 0.3;
              sparks.pos[i * 3] = a.x + (Math.random() - 0.5) * 0.3;
              sparks.pos[i * 3 + 1] = a.y + (Math.random() - 0.5) * 0.3;
              sparks.pos[i * 3 + 2] = a.z + (Math.random() - 0.5) * 0.3;
              sv[i * 3] = (Math.random() - 0.5) * 1.5;
              sv[i * 3 + 1] = (Math.random() - 0.2) * (sp === 'fire' ? 3 : 1.5);
              sv[i * 3 + 2] = (Math.random() - 0.5) * 1.5;
              const cc = sp === 'ult' ? new THREE.Color().setHSL(Math.random(), 1, 0.6) : c;
              sparks.col[i * 3] = cc.r;
              sparks.col[i * 3 + 1] = cc.g;
              sparks.col[i * 3 + 2] = cc.b;
              sparks.size[i] = 0.12 + Math.random() * 0.14;
            }
            // 번개 줄기
            const showB = showBolt && (sp === 'bolt' || sp === 'ult') && Math.random() < 0.85;
            bolt.visible = bolt2.visible = showB;
            if (showB) {
              for (const L of [bolt, bolt2]) {
                const arr = (L.geometry.attributes['position'] as THREE.BufferAttribute).array as Float32Array;
                const st = Math.max(0, k - 0.35);
                for (let q = 0; q < 24; q++) {
                  pathAt(sp, lerp(st, k, q / 23), b);
                  if (q > 0 && q < 23) b.addScaledVector(side, (Math.random() - 0.5) * 0.7).addScaledVector(up, (Math.random() - 0.5) * 0.7);
                  arr[q * 3] = b.x;
                  arr[q * 3 + 1] = b.y;
                  arr[q * 3 + 2] = b.z;
                }
                L.geometry.attributes['position']!.needsUpdate = true;
              }
            }
            flash.visible = false;
          } else {
            ball.visible = false;
            bolt.visible = bolt2.visible = false;
            for (const m of after) m.visible = false;
            const fk = (lt - 1.5) / 0.5;
            flash.visible = fk >= 0 && fk < 1;
            if (flash.visible) {
              flash.position.copy(to);
              flash.scale.setScalar(1 + fk * 4);
              flash.material.color.copy(c);
              flash.material.opacity = 1 - fk;
            }
          }
          for (let i = 0; i < NP; i++) {
            if (sl[i]! <= 0) {
              sparks.alpha[i] = 0;
              continue;
            }
            sl[i] = sl[i]! - (dt);
            for (let q = 0; q < 3; q++) sparks.pos[i * 3 + q] = sparks.pos[i * 3 + q]! + (sv[i * 3 + q]! * dt);
            sv[i * 3 + 1] = sv[i * 3 + 1]! - (3 * dt);
            sparks.alpha[i] = clamp(sl[i]! / 0.5, 0, 1);
          }
          sparks.flush();
        },
        controls: [
          { type: 'toggle', label: '잔상', value: true, on: (v) => (showAfter = v) },
          { type: 'toggle', label: '번개 줄기', value: true, on: (v) => (showBolt = v) },
        ],
        dispose() {
          disposeScene(scene);
          (scene.background as THREE.Texture).dispose();
        },
      };
    },
  }
```

## 관련 기술
- 이 효과를 만든 기술: [층 쌓기 (핵 · 빛무리 · 불티 · 연기)](https://ai-techstudio.web.app/ai/t/i171.md) `i171`
