# AI 꾸러미 — 오라 불꽃 (실루엣 둘레 타오름 · 기 폭발)
> 몸 모양을 법선으로 부풀린 껍질 두 겹에 위로 흐르는 잡음 불꽃(테두리일수록 진함) + 몸 겉면에서 솟는 불꽃 혀 + 떠오르는 돌 · 바닥 갈라진 빛 · 먼지 고리 — 모으기 → 폭발 → 유지  
> 견본: https://ai-techstudio.web.app/#t/i510

이 문서를 코드 도우미(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: `i510` · 분류: 이펙트 · 연출 › 스킬 VFX (마법 · 미사일) · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i510
- 쓰면 좋을 때: 쓰면 좋을 곳 — 필살기 준비 · 변신 · 강화 상태

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i510 견본 항목 — `src/demos/demosVfx3.ts:2691`
```ts
  i510: {
    kind: '3d',
    caption: '몸을 부풀린 껍질 두 겹에 위로 흐르는 불꽃 + 솟는 불꽃 혀 · 떠오르는 돌 · 바닥 갈라진 빛 — 모으기 → 폭발 → 유지 → 가라앉음',
    make(): Scene3D {
      const S = makeStage({ bg: 0x07060a, cam: [0, 1.45, 4.6], look: [0, 1.0, 0], fov: 42, fog: [8, 18] });
      const lab = S.hud.label('tl');
      S.scene.add(new THREE.HemisphereLight(0x405070, 0x0a0806, 0.35));
      const rock = groundCanvas('v3rock', '#2c2824', [['rgba(0,0,0,0.3)', 6000, 2], ['rgba(255,255,255,0.05)', 3000, 1.5]], 71);
      const rockT = texFrom(rock, true, 5);
      const ground = new THREE.Mesh(new THREE.PlaneGeometry(30, 30), new THREE.MeshStandardMaterial({ map: rockT, bumpMap: rockT, bumpScale: 2, roughness: 0.95, color: 0x8a8076 }));
      ground.rotation.x = -Math.PI / 2;
      S.scene.add(ground);
      const crack = cachedCanvas('v3crack', 512, 512, (g, s) => {
        const r = rng(19);
        g.fillStyle = '#000';
        g.fillRect(0, 0, s, s);
        g.lineCap = 'round';
        const br = (x: number, y: number, a: number, len: number, w: number, d: number): void => {
          let px = x;
          let py = y;
          g.strokeStyle = '#fff';
          g.lineWidth = w;
          g.beginPath();
          g.moveTo(px, py);
          const n = 6;
          for (let k = 0; k < n; k++) {
            a += (r() - 0.5) * 0.7;
            px += (Math.cos(a) * len) / n;
            py += (Math.sin(a) * len) / n;
            g.lineTo(px, py);
          }
          g.stroke();
          if (d > 0) {
            br(px, py, a + 0.5, len * 0.6, w * 0.6, d - 1);
            br(px, py, a - 0.5, len * 0.6, w * 0.6, d - 1);
          }
        };
        for (let i = 0; i < 9; i++) br(s / 2, s / 2, (i / 9) * TAU + r() * 0.3, s * 0.2, 5, 2);
        const gr = g.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s * 0.14);
        gr.addColorStop(0, 'rgba(255,255,255,0.8)');
        gr.addColorStop(1, 'rgba(255,255,255,0)');
        g.fillStyle = gr;
        g.fillRect(0, 0, s, s);
      });
      const auraCol1 = new THREE.Color(0xfff2c0);
      const auraCol2 = new THREE.Color(0xff9a20);
      const crackMat = new THREE.ShaderMaterial({
        uniforms: { uTex: { value: texFrom(crack) }, uCol: { value: auraCol2 }, uP: { value: 0 }, uTime: { value: 0 } },
        vertexShader: FLAT_VS,
        fragmentShader: /* glsl */ `uniform sampler2D uTex; uniform vec3 uCol; uniform float uP, uTime; varying vec2 vUv;
          void main(){ float c = texture2D(uTex, vUv).r; float r = length(vUv * 2.0 - 1.0);
            float reveal = 1.0 - smoothstep(uP * 0.9, uP * 0.9 + 0.08, r);
            float pulse = 0.8 + 0.2 * sin(uTime * 9.0 - r * 12.0);
            gl_FragColor = vec4(uCol * 3.0 * pulse, 1.0); ${TONE} gl_FragColor.a = c * reveal * smoothstep(0.0, 0.2, uP); }`,
        transparent: true,
        depthWrite: false,
        blending: THREE.AdditiveBlending,
      });
      const crackM = new THREE.Mesh(new THREE.PlaneGeometry(3.6, 3.6), crackMat);
      crackM.rotation.x = -Math.PI / 2;
      crackM.position.y = 0.012;
      S.scene.add(crackM);
      const pad = glowDisc(0xffa040, 0, 4, 3);
      S.scene.add(pad.mesh);
      const geo = figureGeo('power');
      const fig = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ color: 0x2a2a32, roughness: 0.45, metalness: 0.35 }));
      S.scene.add(fig);
      const light = new THREE.PointLight(0xffa040, 0, 7, 1.4);
      light.position.set(0, 1.1, 0.5);
      S.scene.add(light);
      const auraMat = (push: number, lift: number, freq: number, A: number): THREE.ShaderMaterial =>
        new THREE.ShaderMaterial({
          uniforms: { uTime: { value: 0 }, uNoise: { value: S.noise }, uPush: { value: push }, uLift: { value: lift }, uFreq: { value: freq }, uA: { value: A }, uP: { value: 0 }, uC1: { value: auraCol1 }, uC2: { value: auraCol2 } },
          vertexShader: /* glsl */ `uniform sampler2D uNoise; uniform float uTime, uPush, uLift, uFreq, uP; varying vec3 vW; varying vec3 vN;
            void main(){ vec3 p = position + normal * uPush * (0.6 + 0.4 * uP);
              vec4 w = modelMatrix * vec4(p, 1.0);
              float n = texture2D(uNoise, vec2(w.x * 0.6 + w.z * 0.45, w.y * 0.35 - uTime * 0.9) * uFreq).r;
              float up = smoothstep(-0.3, 1.0, normal.y);
              w.y += n * uLift * uP * (0.35 + up);
              w.xz += normal.xz * n * uLift * uP * 0.35;
              vW = w.xyz; vN = normalize(mat3(modelMatrix) * normal);
              gl_Position = projectionMatrix * viewMatrix * w; }`,
          fragmentShader: /* glsl */ `uniform sampler2D uNoise; uniform float uTime, uA, uP, uFreq; uniform vec3 uC1, uC2; varying vec3 vW; varying vec3 vN;
            void main(){ vec3 N = normalize(vN); vec3 V = normalize(cameraPosition - vW); float rim = 1.0 - abs(dot(N, V));
              float n1 = texture2D(uNoise, vec2(vW.x * 5.0 + vW.z * 3.5, vW.y * 0.55 - uTime * 1.7) * uFreq).r;
              float n2 = texture2D(uNoise, vec2(vW.x * 9.0 - vW.z * 6.0, vW.y * 1.1 - uTime * 2.9) * uFreq).g;
              float f = n1 * 0.6 + n2 * 0.55;
              float flame = smoothstep(0.56, 0.86, f + rim * 0.12);
              float a = flame * pow(rim, 1.3) * uA * uP;
              vec3 col = mix(uC2, uC1, smoothstep(0.7, 1.05, f + (1.0 - rim) * 0.2)) * (0.6 + 0.7 * uP);
              gl_FragColor = vec4(col, 1.0); ${TONE} gl_FragColor.a = clamp(a, 0.0, 1.0); }`,
          transparent: true,
          depthWrite: false,
          blending: THREE.AdditiveBlending,
          side: THREE.DoubleSide,
        });
      const a1 = auraMat(0.035, 0.07, 1.2, 0.9);
      const a2 = auraMat(0.09, 0.3, 1.0, 0.35);
      const sh1 = new THREE.Mesh(geo, a1);
      const sh2 = new THREE.Mesh(geo, a2);
      sh1.renderOrder = 5;
      sh2.renderOrder = 6;
      S.scene.add(sh1, sh2);
      const samp = surfacePoints(geo, 900);
      // 떠오르는 돌
      const rocks: { m: THREE.Mesh; base: THREE.Vector3; h: number; ph: number; y: number; vy: number }[] = [];
      for (let i = 0; i < 11; i++) {
        const g = new THREE.DodecahedronGeometry(R(0.05, 0.12), 0);
        const p = g.attributes.position!;
        for (let k = 0; k < p.count; k++) p.setXYZ(k, p.getX(k) * R(0.8, 1.2), p.getY(k) * R(0.6, 1), p.getZ(k) * R(0.8, 1.2));
        g.computeVertexNormals();
        const m = new THREE.Mesh(g, new THREE.MeshStandardMaterial({ color: 0x4a4440, roughness: 0.9, flatShading: true }));
        const a = (i / 11) * TAU + R(-0.2, 0.2);
        const r = R(0.7, 1.5);
        const base = new THREE.Vector3(Math.cos(a) * r, 0.05, Math.sin(a) * r * 0.8);
        m.position.copy(base);
        S.scene.add(m);
        rocks.push({ m, base, h: R(0.3, 1.5), ph: R(0, TAU), y: 0.05, vy: 0 });
      }
      const fire = S.pool(1600, 7);
      const dust = S.pool(500, 4);
      const ring = new Ring();
      S.scene.add(ring.mesh);
      let FIRE = ramp([[0, 0xffe8a0, 2.2, 0, 1], [0.15, 0xffd070, 2.2, 0.9, 1], [0.55, 0xff8a20, 1.6, 0.6, 1], [1, 0xb02800, 0.6, 0, 1]]);
      let SUCK = ramp([[0, 0xffd080, 2, 0, 1], [0.5, 0xffe0a0, 3, 1, 1], [1, 0xffffff, 5, 1, 1]]);
      const DUST = ramp([[0, 0x8a7a6a, 1, 0, 0], [0.12, 0x6a5e52, 1, 0.45, 0], [1, 0x3a3632, 1, 0, 0]]);
      const ZAP = ramp([[0, 0xffffff, 8, 1, 1], [1, 0x80c0ff, 3, 0, 1]]);
      let blue = false;
      let size = 1;
      let shakeOn = true;
      const setCol = (b: boolean): void => {
        blue = b;
        auraCol1.setHex(b ? 0xe8f6ff : 0xfff2c0);
        auraCol2.setHex(b ? 0x3a80ff : 0xff9a20);
        light.color.setHex(b ? 0x60a0ff : 0xffa040);
        pad.set(0, b ? 0x4090ff : 0xffa040);
        FIRE = b ? ramp([[0, 0xc0e0ff, 2.2, 0, 1], [0.15, 0x90c8ff, 2.2, 0.9, 1], [0.55, 0x4080ff, 1.6, 0.6, 1], [1, 0x1030c0, 0.6, 0, 1]]) : ramp([[0, 0xffe8a0, 2.2, 0, 1], [0.15, 0xffd070, 2.2, 0.9, 1], [0.55, 0xff8a20, 1.6, 0.6, 1], [1, 0xb02800, 0.6, 0, 1]]);
        SUCK = b ? ramp([[0, 0x80c0ff, 2, 0, 1], [0.5, 0xc0e0ff, 3, 1, 1], [1, 0xffffff, 5, 1, 1]]) : ramp([[0, 0xffd080, 2, 0, 1], [0.5, 0xffe0a0, 3, 1, 1], [1, 0xffffff, 5, 1, 1]]);
      };
      const controls: Control[] = [
        ctrlTog('푸른 오라', false, setCol),
        ctrlRange('오라 크기', 0.5, 1.8, 0.05, 1, (v) => (size = v)),
        ctrlTog('화면 흔들림', true, (v) => (shakeOn = v)),
      ];
      let cyc = 0;
      let power = 0;
      let burst = false;
      return {
        scene: S.scene,
        camera: S.cam,
        controls,
        update(t, dt0) {
          const dt = Math.min(dt0, 0.05);
          const prev = cyc;
          cyc = (cyc + dt) % 7.5;
          if (cyc < prev) burst = false;
          let target: number;
          const acc = blue ? '#60a8ff' : '#ffb040';
          if (cyc < 0.8) {
            target = 0.08;
            lab.set('평소', acc);
          } else if (cyc < 2.7) {
            target = lerp(0.12, 0.55, (cyc - 0.8) / 1.9);
            lab.set('기 모으는 중 — 빛이 빨려 들고 땅이 흔들림', acc);
            if (shakeOn) S.shake = Math.max(S.shake, 0.012 * (cyc - 0.8));
            for (let k = 0; k < 3; k++) {
              const d = sphereDir();
              const r = R(1.2, 2.0);
              const sx = d.x * r;
              const sy = 1 + d.y * r * 0.6;
              const sz = d.z * r;
              const T = R(0.45, 0.7);
              fire.spawn({ x: sx, y: sy, z: sz, vx: -sx / T, vy: (1.05 - sy) / T, vz: -sz / T, life: T, s0: 0.05, s1: 0.03, ramp: SUCK, shape: 2, st: 0.06 });
            }
          } else if (cyc < 5.6) {
            target = 1;
            if (!burst) {
              burst = true;
              power = 1.25;
              ring.fire(0, 0.03, 0, 3.6, 0.7, blue ? 0x80c0ff : 0xffc060, 3);
              fire.spawn({ x: 0, y: 1, z: 0, life: 0.3, s0: 2, s1: 5, ramp: ramp([[0, 0xffffff, 6, 1, 1], [1, auraCol2.getHex(), 1, 0, 1]]), shape: 0 });
              for (let k = 0; k < 30; k++) {
                const a = (k / 30) * TAU;
                const sp = R(3, 4.5);
                dust.spawn({ x: Math.cos(a) * 0.3, y: 0.15, z: Math.sin(a) * 0.3, vx: Math.cos(a) * sp, vy: R(0.1, 0.5), vz: Math.sin(a) * sp, life: R(1.2, 1.8), s0: 0.3, s1: 1.3, ramp: DUST, shape: 1, rot: R(-0.4, 0.4), drag: 2.4 });
              }
              if (shakeOn) S.shake = 0.12;
            }
            lab.set('폭발 → 오라 불꽃 — 실루엣 둘레가 위로 타오름', acc);
          } else {
            target = lerp(1, 0.08, smooth((cyc - 5.6) / 1.5));
            lab.set('가라앉음', acc);
          }
          power += (target - power) * (1 - Math.exp(-dt * (burst ? 5 : 2.5)));
          const P = power * (target >= 1 ? 0.92 + 0.08 * Math.sin(t * 23) : 1);
          for (const m of [a1, a2]) {
            m.uniforms.uTime!.value = t;
            m.uniforms.uP!.value = clamp(P, 0, 1.3) * size;
          }
          crackMat.uniforms.uP!.value = clamp(P, 0, 1);
          crackMat.uniforms.uTime!.value = t;
          light.intensity = 2 + P * 14;
          pad.set(P * 1.1);
          // 불꽃 혀
          const rate = 420 * P * size;
          let nn = rate * dt;
          while (nn > 0) {
            if (Math.random() < nn) {
              const i = Math.floor(Math.random() * 900);
              const nx = samp[i * 6 + 3]!;
              const ny = samp[i * 6 + 4]!;
              const nz = samp[i * 6 + 5]!;
              fire.spawn({ x: samp[i * 6]! + nx * 0.07, y: samp[i * 6 + 1]! + ny * 0.07, z: samp[i * 6 + 2]! + nz * 0.07, vx: nx * 0.25, vy: R(1.4, 2.4) * size, vz: nz * 0.25, life: R(0.3, 0.55), s0: R(0.09, 0.15) * size, s1: 0.03, ramp: FIRE, shape: 0, st: 0.13 });
            }
            nn -= 1;
          }
          if (P > 0.3 && Math.random() < dt * 25 * P) {
            const a = R(0, TAU);
            dust.spawn({ x: Math.cos(a) * 0.4, y: 0.08, z: Math.sin(a) * 0.4, vx: Math.cos(a) * R(1, 2), vy: R(0.1, 0.4), vz: Math.sin(a) * R(1, 2), life: R(1, 1.6), s0: 0.15, s1: 0.7, ramp: DUST, shape: 1, rot: R(-0.4, 0.4), drag: 1.8 });
          }
          if (P > 0.9 && Math.random() < dt * 7) {
            const i = Math.floor(Math.random() * 900);
            const d = sphereDir();
            fire.spawn({ x: samp[i * 6]! + d.x * 0.15, y: samp[i * 6 + 1]! + d.y * 0.15, z: samp[i * 6 + 2]! + d.z * 0.15, vx: d.x * 4, vy: d.y * 4, vz: d.z * 4, life: 0.07, s0: 0.03, ramp: ZAP, shape: 2, st: 0.06 });
          }
          for (const r of rocks) {
            const want = P > 0.45 ? r.h + Math.sin(t * 1.3 + r.ph) * 0.06 : 0.05;
            if (P > 0.45) {
              r.y += (want - r.y) * (1 - Math.exp(-dt * 1.2));
              r.vy = 0;
            } else {
              r.vy -= 9.8 * dt;
              r.y += r.vy * dt;
              if (r.y < 0.05) {
                if (r.vy < -2) dust.spawn({ x: r.base.x, y: 0.1, z: r.base.z, vy: 0.3, life: 1, s0: 0.1, s1: 0.4, ramp: DUST, shape: 1, drag: 1 });
                r.y = 0.05;
                r.vy = 0;
              }
            }
            r.m.position.set(r.base.x, r.y, r.base.z);
            r.m.rotation.set(t * 0.4 + r.ph, t * 0.6, 0);
          }
          ring.update(dt);
          fire.tick(dt);
          dust.tick(dt);
          S.tick(dt);
        },
        resize: (w, h) => S.resize(w, h),
        dispose: () => S.dispose(),
      };
    },
  }
```

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