# AI 꾸러미 — 플라즈마 구 (손 대면 따라오는 빛 줄기)
> 유리 구 가운데 전극에서 흔들리며 갈라지는 빛 줄기(점 입자를 촘촘히 이은 선) — 끝점이 유리 위를 떠돌다 손가락을 대면 한 줄기로 모임 + 유리 테 반사 · 점광  
> 견본: https://ai-techstudio.web.app/#t/i505

이 문서를 코드 도우미(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: `i505` · 분류: 이펙트 · 연출 › 화려한 효과 (VFX) · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i505
- 쓰면 좋을 때: 쓰면 좋을 곳 — 전기 · 정전기 체험 · 과학실

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i505 견본 항목 — `src/demos/demosVfx3.ts:1469`
```ts
  i505: {
    kind: '3d',
    caption: '유리 구 가운데 전극에서 흔들리며 갈라지는 빛 줄기 — 손가락을 대면 줄기가 그쪽으로 모여 굵고 밝아진다',
    make(): Scene3D {
      const S = makeStage({ bg: 0x040308, cam: [0, 1.85, 4.4], look: [0, 1.32, 0], fov: 40, fog: [6, 14] });
      const lab = S.hud.label('tl');
      const wood = groundCanvas('v3wood', '#2a1a12', [], 12, (g, s, r) => {
        for (let y = 0; y < s; y += 2) {
          g.fillStyle = `rgba(${r() < 0.5 ? '0,0,0' : '255,200,150'},${0.03 + r() * 0.05})`;
          g.fillRect(0, y + Math.sin(y * 0.05) * 3, s, 1 + r() * 2);
        }
      });
      S.scene.add(new THREE.HemisphereLight(0x302040, 0x080406, 0.5));
      const table = new THREE.Mesh(new THREE.PlaneGeometry(20, 20), new THREE.MeshStandardMaterial({ map: texFrom(wood, true, 4), roughness: 0.45 }));
      table.rotation.x = -Math.PI / 2;
      S.scene.add(table);
      const baseMat = new THREE.MeshStandardMaterial({ color: 0x141418, roughness: 0.28, metalness: 0.6 });
      const base = new THREE.Mesh(new THREE.CylinderGeometry(0.42, 0.6, 0.5, 48), baseMat);
      base.position.y = 0.25;
      const collar = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.36, 0.1, 40), new THREE.MeshStandardMaterial({ color: 0x6a6a74, roughness: 0.25, metalness: 0.9 }));
      collar.position.y = 0.55;
      S.scene.add(base, collar);
      const C = new THREE.Vector3(0, 1.4, 0);
      const RG = 0.82;
      const stem = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.035, C.y - 0.6, 10), new THREE.MeshStandardMaterial({ color: 0x222228, roughness: 0.4, metalness: 0.8 }));
      stem.position.y = (C.y + 0.6) / 2;
      S.scene.add(stem);
      const glass = new THREE.Mesh(
        new THREE.SphereGeometry(RG, 64, 48),
        new THREE.ShaderMaterial({
          uniforms: { uCol: { value: new THREE.Color(0xd070ff) } },
          vertexShader: WORLD_VS,
          fragmentShader: /* glsl */ `uniform vec3 uCol; varying vec3 vW; varying vec3 vN;
            void main(){ vec3 N = normalize(vN); vec3 V = normalize(cameraPosition - vW); float ndv = max(dot(N, V), 0.0);
              float rim = pow(1.0 - ndv, 3.0);
              vec3 R1 = reflect(-V, N);
              float s1 = pow(max(dot(R1, normalize(vec3(-0.5, 0.8, 0.4))), 0.0), 60.0);
              float s2 = pow(max(dot(R1, normalize(vec3(0.7, 0.3, 0.6))), 0.0), 18.0) * 0.25;
              vec3 col = uCol * rim * 0.9 + vec3(1.0) * (s1 * 1.3 + s2) + uCol * 0.025;
              gl_FragColor = vec4(col, 1.0); ${TONE}
              gl_FragColor.a = 1.0; }`,
          transparent: true,
          depthWrite: false,
          blending: THREE.AdditiveBlending,
        }),
      );
      glass.position.copy(C);
      glass.renderOrder = 6;
      S.scene.add(glass);
      const elecMat = new THREE.ShaderMaterial({
        uniforms: { uCol: { value: new THREE.Color(0xffc0ff).multiplyScalar(3) } },
        vertexShader: WORLD_VS,
        fragmentShader: /* glsl */ `uniform vec3 uCol; varying vec3 vW; varying vec3 vN;
          void main(){ vec3 V = normalize(cameraPosition - vW); float f = max(dot(normalize(vN), V), 0.0);
            gl_FragColor = vec4(uCol * (0.5 + 1.2 * f * f), 1.0); ${TONE} }`,
      });
      const elec = new THREE.Mesh(new THREE.SphereGeometry(0.12, 24, 16), elecMat);
      elec.position.copy(C);
      S.scene.add(elec);
      const fill = new THREE.DirectionalLight(0xffe0d0, 0.5);
      fill.position.set(2, 3, 5);
      S.scene.add(fill);
      const light = new THREE.PointLight(0xc060ff, 3, 8, 1.6);
      light.position.copy(C);
      S.scene.add(light);
      const pad = glowDisc(0xa040ff, 0.9, 2.6, 3.5);
      S.scene.add(pad.mesh);
      // 손가락
      const skin = new THREE.MeshStandardMaterial({ color: 0xd8a080, roughness: 0.6 });
      const finger = new THREE.Group();
      const f1 = new THREE.Mesh(new THREE.CapsuleGeometry(0.075, 0.55, 6, 14), skin);
      f1.rotation.x = Math.PI / 2;
      f1.position.z = 0.33;
      const nail = new THREE.Mesh(new THREE.SphereGeometry(0.06, 12, 8), new THREE.MeshStandardMaterial({ color: 0xf0c8b8, roughness: 0.3 }));
      nail.scale.set(1, 0.4, 1.3);
      nail.position.set(0, 0.06, 0.1);
      const hand = new THREE.Mesh(new THREE.SphereGeometry(0.22, 18, 12), skin);
      hand.scale.set(1.1, 0.7, 1.4);
      hand.position.set(0, -0.05, 0.85);
      finger.add(f1, nail, hand);
      S.scene.add(finger);
      const P = S.pool(4000, 5);
      const COLS = [
        [0xffd8ff, 0xc040ff, 0xff60e0],
        [0xd8f0ff, 0x3a70ff, 0x60c0ff],
        [0xe0ffe8, 0x20d060, 0x80ffb0],
      ];
      let pal = 0;
      let CORE = flat(COLS[0]![0]!, 3.2, 1);
      let HALO = flat(COLS[0]![1]!, 0.9, 0.45);
      let TIP = flat(COLS[0]![2]!, 4, 1);
      const setPal = (i: number): void => {
        pal = i;
        const c = COLS[i]!;
        CORE = flat(c[0]!, 3.2, 1);
        HALO = flat(c[1]!, 0.9, 0.45);
        TIP = flat(c[2]!, 4, 1);
        (glass.material as THREE.ShaderMaterial).uniforms.uCol!.value.setHex(c[2]!);
        light.color.setHex(c[1]!);
        pad.set(0.9, c[1]!);
      };
      let NT = 8;
      let autoTouch = true;
      const phase = Array.from({ length: 14 }, () => [R(0, TAU), R(0, TAU), R(0, TAU), R(0, TAU), R(0, TAU)]);
      const baseDir = Array.from({ length: 14 }, (_, i) => {
        const y = 1 - ((i + 0.5) / 14) * 1.5;
        const r = Math.sqrt(Math.max(0, 1 - y * y));
        const a = i * 2.39996;
        return new THREE.Vector3(Math.cos(a) * r, y, Math.sin(a) * r);
      });
      const ends = baseDir.map((v) => v.clone());
      const e = new THREE.Vector3();
      const u = new THREE.Vector3();
      const w = new THREE.Vector3();
      const p = new THREE.Vector3();
      const q = new THREE.Vector3();
      const tan = new THREE.Vector3();
      const T = new THREE.Vector3();
      const UP = new THREE.Vector3(0, 1, 0);
      const strand = (from: THREE.Vector3, dir: THREE.Vector3, len: number, n: number, ph: number[], t: number, thick: number, bright: number, wob: number): void => {
        u.crossVectors(dir, Math.abs(dir.y) > 0.9 ? new THREE.Vector3(1, 0, 0) : UP).normalize();
        w.crossVectors(dir, u);
        const bend = Math.sin(t * 0.9 + ph[0]!) * 0.1;
        q.copy(from);
        for (let k = 1; k <= n; k++) {
          const s = k / n;
          const env = Math.pow(Math.sin(Math.PI * Math.min(s, 0.98)), 0.7) * wob;
          const a1 = Math.sin(s * 9 + t * 5.3 + ph[1]!) * 0.05 + Math.sin(s * 23 + t * 11 + ph[2]!) * 0.016 + bend * Math.sin(Math.PI * s);
          const a2 = Math.sin(s * 13 - t * 6.1 + ph[3]!) * 0.04 + Math.sin(s * 29 - t * 13 + ph[4]!) * 0.012;
          p.copy(from).addScaledVector(dir, len * s).addScaledVector(u, a1 * env).addScaledVector(w, a2 * env);
          tan.subVectors(p, q);
          const L = tan.length();
          tan.normalize().multiplyScalar(L * 1.25);
          const mx = (p.x + q.x) / 2;
          const my = (p.y + q.y) / 2;
          const mz = (p.z + q.z) / 2;
          P.spawn({ x: mx, y: my, z: mz, vx: tan.x, vy: tan.y, vz: tan.z, life: 1, s0: 0.022 * thick, ramp: CORE, br: bright, st: 1, shape: 0 });
          if (k % 2 === 0) P.spawn({ x: mx, y: my, z: mz, life: 1, s0: 0.12 * thick, ramp: HALO, br: bright, shape: 0 });
          q.copy(p);
        }
      };
      const controls: Control[] = [
        ctrlRange('줄기 수', 3, 14, 1, 8, (v) => (NT = v)),
        ctrlTog('손가락 대기 (자동)', true, (v) => (autoTouch = v)),
        ctrlRange('색 (0 보라 · 1 파랑 · 2 초록)', 0, 2, 1, 0, (v) => setPal(v)),
      ];
      let tc = 0;
      return {
        scene: S.scene,
        camera: S.cam,
        controls,
        update(t, dt0) {
          const dt = Math.min(dt0, 0.05);
          tc = (tc + dt) % 7;
          P.clear();
          // 손가락: 3초 ~ 6초 사이 유리에 닿음
          let touch = 0;
          if (autoTouch) {
            if (tc > 2.6 && tc < 6.4) touch = smooth((tc - 2.6) / 0.6) * (1 - smooth((tc - 5.8) / 0.6));
          }
          T.set(0.75, 0.32 + 0.15 * Math.sin(t * 0.5), 0.6 + 0.2 * Math.sin(t * 0.37)).normalize();
          finger.visible = touch > 0.001;
          finger.position.copy(C).addScaledVector(T, RG + 0.04 + (1 - touch) * 0.8);
          finger.lookAt(C.x + T.x * 10, C.y + T.y * 10, C.z + T.z * 10);
          const contact = touch > 0.95;
          let tot = 0;
          for (let i = 0; i < NT; i++) {
            const ph = phase[i]!;
            e.copy(baseDir[i]!).addScaledVector(new THREE.Vector3(Math.sin(t * 0.7 + ph[0]!), Math.sin(t * 0.53 + ph[1]!) * 0.7, Math.sin(t * 0.61 + ph[2]!)), 0.55);
            if (e.y < -0.45) e.y = -0.45;
            e.normalize();
            const pull = contact ? (i < 2 ? 0.96 : 0.28) : 0;
            e.lerp(T, pull).normalize();
            ends[i]!.lerp(e, 1 - Math.exp(-dt * (contact ? 14 : 6)));
            ends[i]!.normalize();
            const d = ends[i]!;
            const fl = 0.78 + 0.22 * Math.sin(t * 17 + ph[3]! * 3) * Math.sin(t * 7.3 + ph[4]!);
            const main = contact && i < 2;
            const thick = main ? 2.1 : 1;
            const bright = fl * (main ? 1.7 : 1);
            tot += bright;
            const from = new THREE.Vector3().copy(C).addScaledVector(d, 0.12);
            strand(from, d, RG - 0.14, 30, ph, t, thick, bright, 1);
            // 갈래
            for (const fs of [0.55, 0.78]) {
              const bp = new THREE.Vector3().copy(C).addScaledVector(d, 0.12 + (RG - 0.14) * fs);
              const bd = new THREE.Vector3(Math.sin(t * 1.3 + ph[1]! + fs * 7), Math.cos(t * 1.1 + ph[2]!), Math.sin(t * 0.9 + ph[0]! - fs * 5)).multiplyScalar(0.8).add(d).normalize();
              strand(bp, bd, (RG - 0.14) * (1 - fs) * 0.8, 8, ph, t * 1.3, thick * 0.6, bright * 0.7, 0.6);
            }
            const tip = new THREE.Vector3().copy(C).addScaledVector(d, RG - 0.015);
            P.spawn({ x: tip.x, y: tip.y, z: tip.z, life: 1, s0: 0.1 * thick, ramp: TIP, br: bright, shape: 0 });
            P.spawn({ x: tip.x, y: tip.y, z: tip.z, life: 1, s0: 0.34 * thick, ramp: HALO, br: bright * 0.8, shape: 0 });
          }
          P.spawn({ x: C.x, y: C.y, z: C.z, life: 1, s0: 0.55, ramp: HALO, br: 1.2, shape: 0 });
          P.upload();
          light.intensity = 1.2 + tot * 0.25;
          lab.set(contact ? '손가락을 대면 — 빛 줄기가 한 곳으로 모여 굵고 밝아짐' : '빛 줄기 — 전극에서 유리까지 흔들리며 갈라짐', ['#e070ff', '#60a0ff', '#40e090'][pal]!);
          S.tick(dt);
        },
        resize: (w0, h0) => S.resize(w0, h0),
        dispose: () => S.dispose(),
      };
    },
  }
```

## 관련 기술
- 이 효과를 만든 기술: [연쇄 번개 (튀는 번개)](https://ai-techstudio.web.app/ai/t/i184.md) `i184`
