# AI 꾸러미 — 화염 방사 (벽에 퍼짐 · 불붙은 방울)
> 파란 핵 → 노랑 → 주황 → 연기로 식는 불 뭉게 줄기, 벽에 닿으면 벽을 따라 퍼지고 열 · 그을음이 쌓임 + 떨어진 불방울이 바닥에서 한동안 탐 + 깜박이는 점광  
> 견본: https://ai-techstudio.web.app/#t/i513

이 문서를 코드 도우미(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: `i513` · 분류: 이펙트 · 연출 › 스킬 VFX (마법 · 미사일) · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i513
- 쓰면 좋을 때: 쓰면 좋을 곳 — 화염 무기 · 용 숨결 · 불 함정

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i513 견본 항목 — `src/demos/demosVfx3.ts:3478`
```ts
  i513: {
    kind: '3d',
    caption: '파란 핵 → 노랑 → 주황 → 연기로 식는 불 줄기 — 벽에 닿으면 퍼지며 열 · 그을음이 쌓이고, 떨어진 불방울은 바닥에서 한동안 탄다',
    make(): Scene3D {
      const S = makeStage({ bg: 0x0b0a0a, cam: [0.1, 2.0, 6.2], look: [0.1, 0.95, 0], fov: 46, fog: [8, 20] });
      const lab = S.hud.label('tl');
      S.scene.add(new THREE.HemisphereLight(0x606878, 0x101010, 0.6));
      const key = new THREE.DirectionalLight(0x9ab0d0, 0.8);
      key.position.set(-3, 4, 4);
      S.scene.add(key);
      const con = groundCanvas('v3con', '#5a544e', [['rgba(0,0,0,0.25)', 5000, 1.5], ['rgba(255,255,255,0.06)', 3000, 1.2]], 41);
      const floor = new THREE.Mesh(new THREE.PlaneGeometry(30, 30), new THREE.MeshStandardMaterial({ map: texFrom(con, true, 8), roughness: 0.85, color: 0x6a6a6a }));
      floor.rotation.x = -Math.PI / 2;
      S.scene.add(floor);
      const WX = 2.75;
      const HW = 64;
      const HH = 32;
      const heat = new Float32Array(HW * HH);
      const soot = new Float32Array(HW * HH);
      const hd = new Uint8Array(HW * HH * 4);
      const heatTex = new THREE.DataTexture(hd, HW, HH, THREE.RGBAFormat);
      heatTex.magFilter = THREE.LinearFilter;
      heatTex.minFilter = THREE.LinearFilter;
      heatTex.needsUpdate = true;
      const brick = cachedCanvas('v3brick2', 256, 256, (g, s) => {
        const r = rng(13);
        g.fillStyle = '#2a2420';
        g.fillRect(0, 0, s, s);
        const bh = s / 8;
        for (let row = 0; row < 8; row++) {
          const off = (row % 2) * (s / 8);
          for (let x = -s / 4; x < s; x += s / 4) {
            g.fillStyle = `hsl(${15 + r() * 15}, ${18 + r() * 12}%, ${34 + r() * 12}%)`;
            g.fillRect(x + off + 3, row * bh + 3, s / 4 - 6, bh - 6);
          }
        }
      });
      const fireP = new THREE.Vector3(1.5, 1.0, 0);
      const wallMat = new THREE.ShaderMaterial({
        uniforms: { uBrick: { value: texFrom(brick, true) }, uHeat: { value: heatTex }, uFireP: { value: fireP }, uFireI: { value: 0 } },
        vertexShader: WORLD_VS,
        fragmentShader: /* glsl */ `uniform sampler2D uBrick, uHeat; uniform vec3 uFireP; uniform float uFireI; varying vec3 vW; varying vec3 vN;
          void main(){ vec3 N = normalize(vN);
            vec2 bu = vec2(vW.z * 0.5, vW.y * 0.5);
            vec3 base = texture2D(uBrick, bu).rgb;
            vec2 hu = vec2((vW.z + 2.5) / 5.0, vW.y / 3.2);
            vec4 hs = texture2D(uHeat, hu);
            vec3 L = uFireP - vW; float d = length(L);
            float lam = max(dot(N, L / d), 0.0) * uFireI / (1.0 + d * d * 0.6);
            vec3 c = base * (0.12 + lam * vec3(1.0, 0.55, 0.25));
            c *= 1.0 - hs.g * 0.85;
            float h = hs.r;
            vec3 hc = mix(vec3(0.5, 0.05, 0.0), vec3(1.0, 0.45, 0.05), smoothstep(0.3, 0.7, h));
            hc = mix(hc, vec3(1.0, 0.85, 0.5) * 2.0, smoothstep(0.75, 1.0, h));
            c += hc * smoothstep(0.08, 0.5, h) * 1.6;
            gl_FragColor = vec4(c, 1.0); ${TONE} }`,
      });
      const wall = new THREE.Mesh(new THREE.BoxGeometry(0.4, 3.2, 5), wallMat);
      wall.position.set(WX + 0.2, 1.6, 0);
      S.scene.add(wall);
      // 화염 방사기
      const metal = new THREE.MeshStandardMaterial({ color: 0x6a6e76, roughness: 0.35, metalness: 0.85 });
      const gun = new THREE.Group();
      const barrel = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.065, 1.0, 16), metal);
      barrel.rotation.z = -Math.PI / 2;
      barrel.position.x = 0.5;
      const tipM = new THREE.Mesh(new THREE.CylinderGeometry(0.075, 0.06, 0.12, 16), new THREE.MeshStandardMaterial({ color: 0x2a2a2c, roughness: 0.6, metalness: 0.6 }));
      tipM.rotation.z = -Math.PI / 2;
      tipM.position.x = 1.02;
      const tank = new THREE.Mesh(new THREE.CapsuleGeometry(0.14, 0.5, 6, 16), new THREE.MeshStandardMaterial({ color: 0x9a2a1a, roughness: 0.45, metalness: 0.3 }));
      tank.rotation.z = Math.PI / 2;
      tank.position.set(0.1, -0.24, 0);
      const hose = new THREE.Mesh(new THREE.TorusGeometry(0.16, 0.025, 8, 20, Math.PI), new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.8 }));
      hose.position.set(-0.2, -0.08, 0);
      hose.rotation.z = Math.PI / 2;
      const grip = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.22, 0.06), new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.8 }));
      grip.position.set(0.25, -0.12, 0);
      gun.add(barrel, tipM, tank, hose, grip);
      gun.position.set(-3.2, 0.95, 0.4);
      gun.rotation.y = 0.06;
      S.scene.add(gun);
      const NZ = new THREE.Vector3(-3.2 + 1.08, 0.95, 0.4 - 0.065);
      const DIR = new THREE.Vector3(1, 0.03, -0.06).normalize();
      const light = new THREE.PointLight(0xff8a30, 0, 10, 1.4);
      S.scene.add(light);
      const fire = S.pool(2600, 4);
      const smoke = S.pool(500, 2);
      const FIRE = ramp([[0, 0x6ab0ff, 2.2, 0, 1], [0.05, 0x9ad4ff, 2.4, 0.8, 1], [0.13, 0xffe0a0, 1.7, 0.85, 1], [0.3, 0xffa030, 1.45, 0.8, 1], [0.55, 0xff5a10, 1.1, 0.7, 0.9], [0.78, 0x5a2410, 0.9, 0.5, 0.2], [1, 0x1a1210, 0.6, 0, 0]]);
      const FIRE2 = ramp([[0, 0xfff0c0, 3.5, 0, 1], [0.15, 0xffc050, 3, 0.9, 1], [0.55, 0xff6a10, 2, 0.7, 0.9], [1, 0x3a1a10, 0.6, 0, 0.1]]);
      const DROP = flat(0xffd890, 5, 1);
      const PILOT = ramp([[0, 0x6ab0ff, 3, 0.9, 1], [1, 0x2050ff, 1, 0, 1]]);
      const SMK = ramp([[0, 0x3a3634, 1, 0, 0], [0.15, 0x302c2a, 1, 0.5, 0], [1, 0x4a4644, 1, 0, 0]]);
      interface Burner {
        x: number;
        z: number;
        life: number;
        age: number;
      }
      const burners: Burner[] = [];
      let range = 1;
      let fuel = 1;
      let drops = true;
      const addHeat = (z: number, y: number, h: number, s: number): void => {
        const ix = Math.round(((z + 2.5) / 5) * HW);
        const iy = Math.round((y / 3.2) * HH);
        for (let dy = -2; dy <= 2; dy++)
          for (let dx = -2; dx <= 2; dx++) {
            const x = ix + dx;
            const yy = iy + dy;
            if (x < 0 || yy < 0 || x >= HW || yy >= HH) continue;
            const w = Math.exp(-(dx * dx + dy * dy) / 2.5);
            const k = yy * HW + x;
            heat[k] = Math.min(1.1, heat[k]! + h * w);
            soot[k] = Math.min(1, soot[k]! + s * w);
          }
      };
      fire.onStep = (p, i) => {
        const tg = p.tag[i]!;
        if (tg === 1 && p.x[i]! > WX - 0.12 && p.vx[i]! > 0) {
          const sp = Math.hypot(p.vx[i]!, p.vy[i]!, p.vz[i]!);
          const a = p.rot[i]!;
          p.x[i] = WX - 0.12;
          p.vx[i] = -sp * 0.06;
          p.vy[i] = Math.sin(a) * sp * 0.55 + 0.8;
          p.vz[i] = Math.cos(a) * sp * 0.6;
          p.tag[i] = 2;
          addHeat(p.z[i]!, p.y[i]!, 0.06, 0.012);
        } else if (tg === 3) {
          if (p.y[i]! < 0.03) {
            if (burners.length < 26) burners.push({ x: p.x[i]!, z: p.z[i]!, life: R(2, 3.4), age: 0 });
            p.end(i);
          } else if (Math.random() < 0.5) fire.spawn({ x: p.x[i]!, y: p.y[i]!, z: p.z[i]!, vy: 0.3, life: 0.25, s0: 0.12, s1: 0.04, ramp: FIRE2, shape: 6, rot: R(0, TAU) });
          if (p.x[i]! > WX - 0.05) p.end(i);
        }
      };
      const controls: Control[] = [
        ctrlRange('사거리', 0.6, 1.3, 0.05, 1, (v) => (range = v)),
        ctrlRange('연료 양', 0.4, 1.6, 0.05, 1, (v) => (fuel = v)),
        ctrlTog('불방울 (바닥에서 탐)', true, (v) => (drops = v)),
      ];
      let cyc = 0;
      let acc = 0;
      let accD = 0;
      return {
        scene: S.scene,
        camera: S.cam,
        controls,
        update(t, dt0) {
          const dt = Math.min(dt0, 0.05);
          const prev = cyc;
          cyc = (cyc + dt) % 4.6;
          if (cyc < prev) {
            for (let k = 0; k < soot.length; k++) soot[k] = soot[k]! * 0.5;
          }
          const on = cyc < 2.7;
          if (on) {
            acc += dt * 120 * fuel;
            while (acc >= 1) {
              acc -= 1;
              const sp = R(8, 9.5) * range;
              fire.spawn({ x: NZ.x + R(-0.02, 0.02), y: NZ.y + R(-0.02, 0.02), z: NZ.z, vx: DIR.x * sp, vy: DIR.y * sp + R(-0.35, 0.35), vz: DIR.z * sp + R(-0.4, 0.4), life: R(0.75, 1.0), s0: 0.08, s1: R(0.8, 1.6), ramp: FIRE, shape: 6, rot: R(0, TAU), rv: R(-4, 4), drag: 1.0, grav: -1.6, tag: 1 });
            }
            if (drops) {
              accD += dt * 8 * fuel;
              while (accD >= 1) {
                accD -= 1;
                const sp = R(4.5, 7) * range;
                fire.spawn({ x: NZ.x + 0.3, y: NZ.y, z: NZ.z, vx: DIR.x * sp, vy: R(0, 1.2), vz: R(-0.6, 0.6), life: 2.5, s0: 0.07, ramp: DROP, shape: 0, grav: 7, tag: 3 });
              }
            }
            lab.set('화염 줄기 — 벽에 부딪혀 퍼지고 열 · 그을음이 쌓임', '#ff9a40');
          } else lab.set(burners.length ? '떨어진 불방울이 바닥에서 탐 · 벽이 식어 감' : '대기', '#ff7030');
          // 점화 불꽃
          fire.spawn({ x: NZ.x + 0.02, y: NZ.y - 0.04, z: NZ.z, vx: 0.3, vy: 0.4, life: 0.2, s0: 0.06, s1: 0.02, ramp: PILOT, shape: 0 });
          // 바닥 불
          let bI = 0;
          for (let i = burners.length - 1; i >= 0; i--) {
            const b = burners[i]!;
            b.age += dt;
            const k = b.age / b.life;
            if (k >= 1) {
              burners.splice(i, 1);
              continue;
            }
            const s = Math.sin(Math.PI * Math.min(1, k * 1.5 + 0.2));
            bI += s;
            if (Math.random() < dt * 16) fire.spawn({ x: b.x + R(-0.08, 0.08), y: 0.04, z: b.z + R(-0.08, 0.08), vx: R(-0.1, 0.1), vy: R(0.7, 1.3), vz: R(-0.1, 0.1), life: R(0.4, 0.6), s0: 0.1 * s + 0.04, s1: 0.4 * s + 0.1, ramp: FIRE2, shape: 6, rot: R(0, TAU), rv: R(-2, 2), drag: 1, grav: -0.5 });
            if (Math.random() < dt * 2) smoke.spawn({ x: b.x, y: 0.3, z: b.z, vy: 0.6, life: R(1.5, 2.2), s0: 0.15, s1: 0.8, ramp: SMK, shape: 1, rot: R(-0.4, 0.4), drag: 0.6 });
          }
          if (on && Math.random() < dt * 10) smoke.spawn({ x: WX - 0.3, y: R(0.8, 1.6), z: R(-0.6, 0.6), vx: -0.3, vy: R(0.6, 1), life: R(1.8, 2.6), s0: 0.4, s1: 1.6, ramp: SMK, shape: 1, rot: R(-0.4, 0.4), drag: 0.5, grav: -0.3 });
          const decay = Math.exp(-dt * 0.55);
          for (let k = 0; k < HW * HH; k++) {
            heat[k] = heat[k]! * decay;
            hd[k * 4] = Math.round(clamp(heat[k]!, 0, 1) * 255);
            hd[k * 4 + 1] = Math.round(clamp(soot[k]!, 0, 1) * 255);
            hd[k * 4 + 3] = 255;
          }
          heatTex.needsUpdate = true;
          const fl = 0.8 + 0.2 * Math.sin(t * 37) * Math.sin(t * 23);
          const LI = (on ? 26 * fuel : 0) * fl + bI * 3;
          light.intensity = LI;
          light.position.set(on ? 0.6 : 0, on ? 1.1 : 0.3, 0.4);
          fireP.copy(light.position);
          wallMat.uniforms.uFireI!.value = LI * 0.25;
          fire.tick(dt);
          smoke.tick(dt);
          S.tick(dt);
        },
        resize: (w, h) => S.resize(w, h),
        dispose: () => {
          S.dispose();
          heatTex.dispose();
        },
      };
    },
  }
```

## 관련 기술
- 이 효과를 만든 기술: [입자 방출기 (수명 곡선)](https://ai-techstudio.web.app/ai/t/i170.md) `i170`
