# AI 꾸러미 — 회오리 · 토네이도
> 높이에 따라 넓어지는 깔때기 메시 세 겹에 잡음을 돌려 흘림(아래일수록 빠름) + 빨려 올라가는 잔해 · 바닥 먼지 고리 + 소용돌이 구름 · 번개 번쩍  
> 견본: https://ai-techstudio.web.app/#t/i512

이 문서를 코드 도우미(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: `i512` · 분류: 이펙트 · 연출 › 화려한 효과 (VFX) · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i512
- 쓰면 좋을 때: 쓰면 좋을 곳 — 날씨 재해 · 바람 마법 · 소용돌이 함정

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i512 견본 항목 — `src/demos/demosVfx3.ts:3250`
```ts
  i512: {
    kind: '3d',
    caption: '위로 넓어지는 깔때기 세 겹에 잡음이 돌며 흐름(아래일수록 빠름) + 빨려 올라가는 잔해 · 바닥 먼지 고리 · 소용돌이 구름 · 번개',
    make(): Scene3D {
      const S = makeStage({ bg: 0x7c847a, cam: [0, 1.5, 12.5], look: [0, 3.5, 0], fov: 50, fog: [14, 42] });
      S.scene.add(skyDome(0x2a322e, 0x8a9286, 0x3a3a30));
      const lab = S.hud.label('tl');
      const hemi = new THREE.HemisphereLight(0xa8b8b8, 0x2a2a20, 0.9);
      S.scene.add(hemi);
      const sunL = new THREE.DirectionalLight(0xd0d8e0, 0.8);
      sunL.position.set(-4, 8, 5);
      S.scene.add(sunL);
      const field = groundCanvas('v3field', '#4c5a36', [['rgba(30,50,10,0.35)', 9000, 2], ['rgba(150,170,90,0.25)', 5000, 1.5], ['rgba(90,70,40,0.25)', 2000, 3]], 81);
      const ground = new THREE.Mesh(new THREE.PlaneGeometry(80, 80), new THREE.MeshStandardMaterial({ map: texFrom(field, true, 12), roughness: 1, color: 0xffffff, emissive: 0x1a2010 }));
      ground.rotation.x = -Math.PI / 2;
      S.scene.add(ground);
      const H = 8.6;
      const base = new THREE.Vector3();
      let spin = 1;
      const flash = { v: 0 };
      // 소용돌이 구름
      const cloudMat = new THREE.ShaderMaterial({
        uniforms: { uTime: { value: 0 }, uNoise: { value: S.noise }, uC: { value: new THREE.Vector2() }, uFlash: { value: 0 }, uSpin: { value: 1 } },
        vertexShader: WORLD_VS,
        fragmentShader: /* glsl */ `uniform sampler2D uNoise; uniform float uTime, uFlash, uSpin; uniform vec2 uC; varying vec3 vW;
          void main(){ vec2 d = vW.xz - uC; float r = length(d); float a = atan(d.y, d.x) / 6.2831853;
            float sw = a * 2.0 + r * 0.09 - uTime * 0.05 * uSpin * 3.0 / (1.0 + r * 0.3);
            float n = texture2D(uNoise, vec2(sw, r * 0.05 - uTime * 0.01)).r * 0.65 + texture2D(uNoise, vec2(sw * 3.0, r * 0.13)).g * 0.45;
            vec3 col = mix(vec3(0.12, 0.14, 0.13), vec3(0.42, 0.46, 0.43), smoothstep(0.3, 0.95, n));
            col *= mix(0.5, 1.0, smoothstep(1.0, 6.0, r));
            col += vec3(0.6, 0.65, 0.8) * uFlash * (0.4 + n);
            float edge = 1.0 - smoothstep(18.0, 30.0, r);
            gl_FragColor = vec4(col, 1.0); ${TONE} gl_FragColor.a = edge; }`,
        transparent: true,
        depthWrite: false,
        side: THREE.DoubleSide,
        fog: false,
      });
      const clouds = new THREE.Mesh(new THREE.PlaneGeometry(70, 70), cloudMat);
      clouds.rotation.x = Math.PI / 2;
      clouds.position.y = H;
      S.scene.add(clouds);
      // 깔때기
      const prof: THREE.Vector2[] = [];
      for (let i = 0; i <= 30; i++) {
        const y = (i / 30) * H;
        prof.push(new THREE.Vector2(0.22 + 0.03 * y + 0.027 * y * y, y));
      }
      const funnelGeo = new THREE.LatheGeometry(prof, 64);
      const FN = /* glsl */ `
        uniform float uTime, uSpin, uA, uScale; uniform vec3 uBase; uniform sampler2D uNoise; uniform vec3 uCol;
        varying vec2 vUv; varying float vH; varying vec3 vW; varying vec3 vN;`;
      const funnelMat = (scale: number, A: number, col: number, order: number): THREE.Mesh => {
        const m = new THREE.ShaderMaterial({
          uniforms: { uTime: { value: 0 }, uSpin: { value: 1 }, uA: { value: A }, uScale: { value: scale }, uBase: { value: base }, uNoise: { value: S.noise }, uCol: { value: new THREE.Color(col) } },
          vertexShader: /* glsl */ `${FN}
            void main(){ vec3 p = position; float h = p.y / ${H.toFixed(1)}; vH = h; vUv = uv;
              float ang = atan(p.z, p.x);
              float wob = 1.0 + 0.06 * sin(ang * 3.0 + uTime * 2.0 + h * 8.0) + 0.04 * sin(ang * 5.0 - uTime * 3.0);
              p.xz *= uScale * wob;
              p.x += sin(h * 2.6 + uTime * 0.7) * 0.55 * h + 0.9 * h * h;
              p.z += cos(h * 2.1 + uTime * 0.5) * 0.3 * h;
              p += uBase;
              vW = p; vN = normalize(vec3(position.x, -0.25, position.z));
              gl_Position = projectionMatrix * viewMatrix * vec4(p, 1.0); }`,
          fragmentShader: /* glsl */ `${FN}
            void main(){ float h = vH; float sp = uSpin * (1.7 - h);
              vec2 q = vec2(vUv.x * 3.0 + uTime * sp * 0.55 + h * 1.6, h * 2.2 - uTime * 0.35);
              float n = texture2D(uNoise, q).r * 0.6 + texture2D(uNoise, q * vec2(2.0, 2.6) + vec2(0.3, -uTime * 0.25)).g * 0.5;
              float dens = smoothstep(0.32, 0.9, n);
              vec3 N = normalize(vN); vec3 V = normalize(cameraPosition - vW);
              float rim = 1.0 - abs(dot(N, V));
              float lit = 0.55 + 0.45 * max(dot(N, normalize(vec3(-0.5, 0.6, 0.6))), 0.0);
              vec3 col = uCol * lit * (0.75 + 0.5 * dens);
              col = mix(col, vec3(0.42, 0.34, 0.24), (1.0 - smoothstep(0.0, 0.22, h)) * 0.7);
              float a = (0.25 + 0.75 * dens) * (0.4 + 0.6 * rim) * uA * smoothstep(0.0, 0.03, h) * (1.0 - smoothstep(0.88, 1.0, h));
              gl_FragColor = vec4(col, 1.0); ${TONE} gl_FragColor.a = clamp(a, 0.0, 1.0); }`,
          transparent: true,
          depthWrite: false,
          side: THREE.DoubleSide,
        });
        const mesh = new THREE.Mesh(funnelGeo, m);
        mesh.renderOrder = order;
        mesh.frustumCulled = false;
        S.scene.add(mesh);
        return mesh;
      };
      const layers = [funnelMat(0.75, 0.9, 0x4a4e4a, 3), funnelMat(1.0, 0.65, 0x6a6e66, 4), funnelMat(1.32, 0.35, 0x8a8a80, 5)];
      // 잔해
      const ND = 90;
      const deb = new THREE.InstancedMesh(new THREE.BoxGeometry(0.28, 0.035, 0.09), new THREE.MeshStandardMaterial({ color: 0x4a3a2a, roughness: 0.9 }), ND);
      deb.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
      deb.frustumCulled = false;
      S.scene.add(deb);
      const D = Array.from({ length: ND }, () => ({ a: R(0, TAU), h: Math.random(), ro: R(-0.2, 0.6), w: R(2.5, 4), up: R(0.06, 0.14), ax: new THREE.Vector3(R(-1, 1), R(-1, 1), R(-1, 1)).normalize(), rs: R(3, 9), s: R(0.5, 1.4) }));
      let nDeb = 60;
      let bolts = true;
      const dust = S.pool(900, 2);
      const fine = S.pool(600, 6);
      const boltP = S.pool(600, 8);
      const DUST = ramp([[0, 0x8a7458, 1.1, 0, 0], [0.12, 0x7a6a54, 1.1, 0.55, 0], [0.6, 0x7a7262, 1.05, 0.32, 0], [1, 0x8a8478, 1, 0, 0]]);
      const BITS = ramp([[0, 0x2a2018, 1, 1, 0], [1, 0x3a3020, 1, 0, 0]]);
      const BOLT = flat(0xe8f0ff, 6, 1);
      const BOLTH = flat(0x8aa8ff, 1.2, 0.5);
      const axisAt = (h: number, t: number, out: THREE.Vector3): THREE.Vector3 => out.set(base.x + Math.sin(h * 2.6 + t * 0.7) * 0.55 * h + 0.9 * h * h, h * H, base.z + Math.cos(h * 2.1 + t * 0.5) * 0.3 * h);
      const rAt = (h: number): number => {
        const y = h * H;
        return 0.22 + 0.03 * y + 0.027 * y * y;
      };
      const M = new THREE.Matrix4();
      const Q = new THREE.Quaternion();
      const P3 = new THREE.Vector3();
      const SC = new THREE.Vector3();
      let nextBolt = 2;
      let boltAge = 9;
      const boltPts: THREE.Vector3[] = [];
      const makeBolt = (): void => {
        boltPts.length = 0;
        const x0 = (Math.random() < 0.5 ? -1 : 1) * R(3.5, 7);
        const z0 = R(-5, -2);
        const a = new THREE.Vector3(x0 + R(-2, 2), H - 0.1, z0);
        const b = new THREE.Vector3(x0, 0, z0);
        const pts = [a, b];
        for (let it = 0; it < 6; it++) {
          const np: THREE.Vector3[] = [pts[0]!];
          for (let i = 0; i < pts.length - 1; i++) {
            const p = pts[i]!;
            const q = pts[i + 1]!;
            const L = p.distanceTo(q);
            np.push(new THREE.Vector3().addVectors(p, q).multiplyScalar(0.5).add(new THREE.Vector3(R(-0.25, 0.25) * L, R(-0.1, 0.1) * L, R(-0.15, 0.15) * L)), q);
          }
          pts.splice(0, pts.length, ...np);
        }
        boltPts.push(...pts);
        boltAge = 0;
        flash.v = 1;
      };
      const controls: Control[] = [
        ctrlRange('회전 속도', 0.4, 2, 0.05, 1, (v) => (spin = v)),
        ctrlRange('잔해 양', 0, ND, 1, 60, (v) => (nDeb = v)),
        ctrlTog('번개', true, (v) => (bolts = v)),
      ];
      let acc = 0;
      return {
        scene: S.scene,
        camera: S.cam,
        controls,
        update(t, dt0) {
          const dt = Math.min(dt0, 0.05);
          base.set(Math.sin(t * 0.2) * 2.2, 0, Math.cos(t * 0.13) * 1.2 - 1);
          for (const l of layers) {
            const u = (l.material as THREE.ShaderMaterial).uniforms;
            u.uTime!.value = t;
            u.uSpin!.value = spin;
          }
          cloudMat.uniforms.uTime!.value = t;
          cloudMat.uniforms.uSpin!.value = spin;
          (cloudMat.uniforms.uC!.value as THREE.Vector2).set(base.x + 1.4, base.z);
          // 잔해
          let n = 0;
          for (let i = 0; i < nDeb; i++) {
            const d = D[i]!;
            d.h += d.up * dt * spin;
            if (d.h > 0.85) d.h = 0;
            const r = rAt(d.h) * 1.1 + d.ro;
            d.a += ((d.w * spin) / Math.max(0.4, r)) * dt;
            axisAt(d.h, t, P3);
            P3.x += Math.cos(d.a) * r;
            P3.z += Math.sin(d.a) * r;
            Q.setFromAxisAngle(d.ax, t * d.rs);
            M.compose(P3, Q, SC.setScalar(d.s));
            deb.setMatrixAt(n++, M);
          }
          deb.count = n;
          deb.instanceMatrix.needsUpdate = true;
          // 바닥 먼지 고리
          acc += dt * 50;
          while (acc >= 1) {
            acc -= 1;
            const a = R(0, TAU);
            const r = R(0.4, 1.3);
            const tx = -Math.sin(a);
            const tz = Math.cos(a);
            dust.spawn({ x: base.x + Math.cos(a) * r, y: R(0.05, 0.4), z: base.z + Math.sin(a) * r, vx: tx * 3 * spin + Math.cos(a) * 1.1, vy: R(0.3, 1.2), vz: tz * 3 * spin + Math.sin(a) * 1.1, life: R(1.3, 2.2), s0: R(0.4, 0.6), s1: R(1.8, 2.6), ramp: DUST, shape: 1, rot: R(-0.4, 0.4), rv: R(-1, 1), drag: 1.4, grav: -0.3 });
            if (Math.random() < 0.6) {
              const h = R(0, 0.3);
              axisAt(h, t, P3);
              const rr = rAt(h) * R(1.1, 1.5);
              fine.spawn({ x: P3.x + Math.cos(a) * rr, y: P3.y, z: P3.z + Math.sin(a) * rr, vx: tx * 5 * spin, vy: R(1, 3), vz: tz * 5 * spin, life: R(0.5, 0.9), s0: R(0.03, 0.06), ramp: BITS, shape: 3, rot: R(0, TAU), rv: R(-8, 8), st: 0.02 });
            }
          }
          // 번개
          nextBolt -= dt;
          if (bolts && nextBolt <= 0) {
            makeBolt();
            nextBolt = R(2.5, 4.5);
          }
          boltAge += dt;
          boltP.clear();
          if (boltAge < 0.22 && boltPts.length) {
            const on = boltAge < 0.06 || (boltAge > 0.1 && boltAge < 0.16);
            if (on)
              for (let i = 0; i < boltPts.length - 1; i++) {
                const p = boltPts[i]!;
                const q = boltPts[i + 1]!;
                const vx = q.x - p.x;
                const vy = q.y - p.y;
                const vz = q.z - p.z;
                boltP.spawn({ x: (p.x + q.x) / 2, y: (p.y + q.y) / 2, z: (p.z + q.z) / 2, vx: vx * 1.1, vy: vy * 1.1, vz: vz * 1.1, life: 1, s0: 0.09, ramp: BOLT, st: 1, shape: 0 });
                if (i % 2 === 0) boltP.spawn({ x: (p.x + q.x) / 2, y: (p.y + q.y) / 2, z: (p.z + q.z) / 2, life: 1, s0: 0.6, ramp: BOLTH, shape: 0 });
              }
          }
          boltP.upload();
          flash.v *= Math.exp(-dt * 6);
          cloudMat.uniforms.uFlash!.value = flash.v;
          hemi.intensity = 0.9 + flash.v * 1.4;
          dust.tick(dt);
          fine.tick(dt);
          lab.set(boltAge < 0.4 ? '번개 — 구름이 번쩍' : '깔때기 구름 + 빨려 올라가는 잔해 + 바닥 먼지 고리', '#c8d0b0');
          S.tick(dt);
        },
        resize: (w, h) => S.resize(w, h),
        dispose: () => S.dispose(),
      };
    },
  }
```

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