# AI 꾸러미 — 빔 · 레이저 (지속 광선)
> 흐르는 무늬 원통 + 시작 · 끝 빛무리 + 가장자리 떨림 + 맞는 곳 불티  
> 견본: https://ai-techstudio.web.app/#t/i189

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

## 주문서

### 만들어 줘: 빔 · 레이저 (지속 광선)

#### 1. 목표
거울 천문대 빛에 「빔 · 레이저 (지속 광선)」 기술을 깔끔한 기본 느낌으로 넣어 줘. — 흐르는 무늬 원통 + 시작 · 끝 빛무리 + 가장자리 떨림 + 맞는 곳 불티

#### 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: `i189` · 분류: 이펙트 · 연출 › 스킬 VFX (마법 · 미사일) · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i189
- 쓰면 좋을 때: 쓰면 좋을 곳 — 거울 천문대 빛 · 연결선

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i189 견본 항목 — `src/demos/demosSkillB.ts:1671`
```ts
  i189: {
    kind: '3d',
    caption: '지팡이에서 지속 광선 — 흐르는 무늬 원통 + 하얀 심 + 둘레 번개 떨림 · 맞는 곳 불티와 그을음',
    make() {
      const A = arena({ fx: 1000, st: 500 });
      const mg = mage(A, 0x5a2a3a, [3, 1.3, 2.4]);
      mg.g.position.set(-3.5, 0, 0.9);
      mg.g.rotation.y = 0.6;
      A.scene.add(mg.g);
      const mons = ([[1.8, 0, -1.6], [2.4, 0, 0.3], [1.6, 0, 2.0]] as V3[]).map((p, i) => {
        const m = monster(A, [0x4a5c6e, 0x5d4a78, 0x4a6a4e][i]!, i);
        m.g.position.set(p[0], 0, p[2]);
        m.g.rotation.y = -0.6;
        A.scene.add(m.g);
        return m;
      });
      const beamMat = (core: boolean): THREE.ShaderMaterial =>
        new THREE.ShaderMaterial({
          uniforms: { uC: { value: new THREE.Color() }, uT: { value: 0 }, uA: { value: 0 }, uLen: { value: 5 }, uCore: { value: core ? 1 : 0 } },
          vertexShader: FRESNEL_V,
          fragmentShader: `${NOISE} uniform vec3 uC; uniform float uT; uniform float uA; uniform float uLen; uniform float uCore;
            varying vec3 vN; varying vec3 vV; varying vec2 vUv; varying vec3 vP;
            void main(){
              float f = abs(dot(normalize(vN), normalize(vV)));
              float s1 = vn(vec2(vUv.x * 6.0, vUv.y * uLen * 1.6 - uT * 10.0));
              float s2 = vn(vec2(vUv.x * 13.0 + 3.0, vUv.y * uLen * 3.2 - uT * 17.0));
              float flow = 0.35 + 1.3 * s1 * s2;
              vec3 col = uCore > 0.5 ? vec3(3.0) * pow(f, 1.5) + uC * 0.5 : uC * pow(f, 1.4) * flow * 1.6;
              float ends = smoothstep(0.0, 0.025, vUv.y) * smoothstep(1.0, 0.985, vUv.y);
              gl_FragColor = vec4(col, uA * ends * (uCore > 0.5 ? 1.0 : pow(f, 0.8)));
              ${TONE} }`,
          transparent: true,
          depthWrite: false,
          blending: THREE.AdditiveBlending,
        });
      const cyl = new THREE.CylinderGeometry(1, 1, 1, 24, 1, true);
      const outer = new THREE.Mesh(cyl, beamMat(false));
      const inner = new THREE.Mesh(cyl, beamMat(true));
      outer.frustumCulled = inner.frustumCulled = false;
      A.scene.add(outer, inner);
      const endG = decal(A.tx.glow(), 2.6, [1, 1, 1], 0);
      A.scene.add(endG);
      const endL = new THREE.PointLight(0xffffff, 0, 7, 2);
      const tipL = new THREE.PointLight(0xffffff, 0, 6, 2);
      A.scene.add(endL, tipL);
      const loop = new Loop(4.2);
      let thick = 1, jitter = true, hue = 0.97;
      const eAcc = { acc: 0 }, sAcc = { acc: 0 };
      const sweepAt = (k: number): V3 => [1.9 + 0.45 * Math.sin(k * 5), 0.45, lerp(-1.9, 2.1, k)];
      return base3d(A, (t, dt) => {
        const lt = loop.tick(t);
        const col = HSL(hue, 1, 0.55, 2);
        const tip = mg.tip();
        const charge = sstep(0, 0.6, lt);
        const on = sstep(0.55, 0.7, lt) * (1 - sstep(3.5, 3.9, lt));
        const k = clamp((lt - 0.6) / 2.9, 0, 1);
        const sw = 0.5 - 0.5 * Math.cos(k * Math.PI);
        const end = sweepAt(sw);
        const flick = jitter ? 1 + (Math.random() - 0.5) * 0.3 : 1;
        const R = 0.17 * thick * on * flick * (1 + 0.5 * Math.exp(-(lt - 0.6) * 8) * (lt > 0.6 ? 1 : 0));
        const L = placeBetween(outer, tip, end, Math.max(R, 1e-4));
        placeBetween(inner, tip, end, Math.max(R * 0.32, 1e-4));
        for (const m of [outer, inner]) {
          const u = (m.material as THREE.ShaderMaterial).uniforms;
          u.uT!.value = t;
          u.uA!.value = on;
          u.uLen!.value = L;
          (u.uC!.value as THREE.Color).setRGB(col[0], col[1], col[2]);
        }
        // 시작 · 끝 빛무리
        orbGlow(A.fx, tip, col, (0.18 + charge * 0.25 + on * 0.15) * thick * flick, 1);
        if (charge > 0 && on < 0.5) for (let i = 0; i < 2; i++) {
          const d = randDir();
          A.fx.emit({ p: add3(tip, sc3(d, 1.2)), v: sc3(d, -3), life: 0.35, size: 0.1, size2: 0.02, c: col, a: charge });
        }
        if (on > 0.02) {
          orbGlow(A.fx, end, col, 0.35 * thick * flick * on, 1);
          // 둘레 번개 떨림
          if (jitter) {
            const r = rng(Math.floor(t * 22));
            for (let j = 0; j < 2; j++) {
              const off: V3 = sc3(randDir(), 0.12 * thick);
              drawBolt(A.st, boltPts(add3(tip, off), add3(end, off), r, 5, 0.06), 0.01, col, 0.7 * on);
            }
          }
          for (let i = rate(sAcc, 50 * on, dt); i > 0; i--) {
            const d = norm3(add3(norm3(sub3(tip, end)), sc3(randDir(), 1.2)));
            A.st.spark({ p: end, v: [d[0] * rr(2, 5), Math.abs(d[1]) * rr(2, 5) + 1, d[2] * rr(2, 5)], life: rr(0.25, 0.5), c: C(0xffe0c0, 3), c2: col, len: 0.05, w: 0.025, grav: 9, drag: 1.5, fadeIn: 0 });
          }
          for (let i = rate(eAcc, 35 * on, dt); i > 0; i--) {
            A.fx.emit({ p: [end[0] + rr(-0.15, 0.15), 0.04, end[2] + rr(-0.15, 0.15)], v: [0, rr(0.05, 0.3), 0], life: rr(0.8, 1.6), size: rr(0.08, 0.16), size2: 0.02, c: C(0xff9a40, 2.5), c2: C(0xff3000, 0.6), a: 1 });
          }
          for (const m of mons) if (len3(sub3(m.center(), end)) < 0.7) m.hit(sc3(col, 0.8), 0.6);
        }
        endG.position.set(end[0], 0.02, end[2]);
        endG.material.color.setRGB(col[0], col[1], col[2]);
        endG.material.opacity = on * (0.8 + Math.random() * 0.2);
        endL.color.setRGB(col[0] / 2, col[1] / 2, col[2] / 2);
        endL.position.set(end[0], 0.8, end[2]);
        endL.intensity = 40 * on * flick;
        tipL.color.copy(endL.color);
        tipL.position.set(tip[0], tip[1], tip[2]);
        tipL.intensity = 8 + 25 * charge;
        for (const m of mons) m.tick(dt, t);
        A.fx.step(dt);
        A.st.step(dt);
        A.tickCam(dt);
      }, [
        { type: 'button', label: '다시 쏘기', on: () => loop.restart() },
        { type: 'range', label: '두께', min: 0.4, max: 2.2, step: 0.1, value: thick, on: (v) => (thick = v) },
        { type: 'toggle', label: '가장자리 떨림', value: jitter, on: (v) => (jitter = v) },
        { type: 'range', label: '색', min: 0, max: 1, step: 0.01, value: hue, on: (v) => (hue = v) },
      ]);
    },
  }
```

## 관련 기술
- 이 효과를 만든 기술: [흐르는 무늬 메시 (UV 스크롤)](https://ai-techstudio.web.app/ai/t/i172.md) `i172`
