# AI 꾸러미 — 연기 기둥 · 증기 분출
> 입체 연기 뭉게(빛 받은 쪽을 구운 그림)가 굴뚝에서 커지며 바람에 휨 vs 하얀 증기가 관에서 쉭 뿜어져 빠르게 퍼지고 사라짐 — 수명 · 밝기 · 사라지는 속도로 구별  
> 견본: https://ai-techstudio.web.app/#t/i504

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

## 주문서

### 만들어 줘: 연기 기둥 · 증기 분출

#### 1. 목표
공장에 「연기 기둥 · 증기 분출」 기술을 깔끔한 기본 느낌으로 넣어 줘. — 입체 연기 뭉게(빛 받은 쪽을 구운 그림)가 굴뚝에서 커지며 바람에 휨 vs 하얀 증기가 관에서 쉭 뿜어져 빠르게 퍼지고 사라짐 — 수명 · 밝기 · 사라지는 속도로 구별

#### 2. 핵심 기술 용어
- 연기 기둥 · 증기 분출 — 입체 연기 뭉게(빛 받은 쪽을 구운 그림)가 굴뚝에서 커지며 바람에 휨 vs 하얀 증기가 관에서 쉭 뿜어져 빠르게 퍼지고 사라짐 — 수명 · 밝기 · 사라지는 속도로 구별

#### 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. 완성 기준 (이게 보이면 성공)
- 견본처럼 「입체 연기 뭉게(빛 받은 쪽을 구운 그림)가 굴뚝에서 커지며 바람에 휨 vs 하얀 증기가 관에서 쉭 뿜어져 빠르게 퍼지고 사라짐 — 수명 · 밝기 · 사라지는 속도로 구별」 이(가) 첫 화면에서 바로 보인다
- 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

#### 6. 진행 방식
- 핵심 코드 위주로, 설명은 짧게. 내 프로젝트에 끼워 넣기 쉬운 함수 · 클래스로 나눠 줘.
- 처음 화면에 바로 결과가 보이게, 그리고 켬/끔(또는 전/후) 비교를 할 수 있게 만들어 줘.
- 그림 · 소리 · 모델 파일이 필요하면 코드로 만든 임시 대체물로 먼저 돌아가게 하고, 진짜 파일로 바꿀 자리를 표시해 줘.
- 마지막에 「확인 방법」(무엇을 보면 성공인지)과 「조절할 값」 목록을 짧게 정리해 줘.
- 답변과 코드 주석은 한국어로 해 줘.

## 원리
- 입체 연기 뭉게(빛 받은 쪽을 구운 그림)가 굴뚝에서 커지며 바람에 휨 vs 하얀 증기가 관에서 쉭 뿜어져 빠르게 퍼지고 사라짐 — 수명 · 밝기 · 사라지는 속도로 구별


## 흔한 실수 · 확인 목록
- (아직 정리된 것이 없어요)

## 완성 기준 체크리스트
- [ ] 견본처럼 「입체 연기 뭉게(빛 받은 쪽을 구운 그림)가 굴뚝에서 커지며 바람에 휨 vs 하얀 증기가 관에서 쉭 뿜어져 빠르게 퍼지고 사라짐 — 수명 · 밝기 · 사라지는 속도로 구별」 이(가) 첫 화면에서 바로 보인다
- [ ] 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- [ ] 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- [ ] 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

## 이 기술 정보
- id: `i504` · 분류: 이펙트 · 연출 › 입자 · 연출 · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i504
- 쓰면 좋을 때: 쓰면 좋을 곳 — 공장 · 기차 · 요리 · 뜨거운 물

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i504 견본 항목 — `src/demos/demosVfx3.ts:1295`
```ts
  i504: {
    kind: '3d',
    caption: '굴뚝 연기는 짙고 무겁게 오래 남아 바람에 휘고, 관에서 뿜는 증기는 하얗게 빠르게 퍼지다 금방 사라진다',
    make(): Scene3D {
      const S = makeStage({ bg: 0xc87a56, cam: [1.6, 2.1, 10.5], look: [0.2, 3.2, 0], fov: 50, fog: [12, 34] });
      S.scene.add(skyDome(0x1c2240, 0xe8905a, 0x1a1410, 0, { dir: [-0.9, 0.1, -0.45], col: 0xffb070, pow: 5, I: 0.7 }));
      const lab = S.hud.label('tl');
      S.scene.add(new THREE.HemisphereLight(0xffc8a0, 0x201810, 0.7));
      const sun = new THREE.DirectionalLight(0xffa060, 2.2);
      sun.position.set(-6, 4, -2);
      S.scene.add(sun);
      const con = groundCanvas('v3con', '#5a544e', [['rgba(0,0,0,0.25)', 5000, 1.5], ['rgba(255,255,255,0.06)', 3000, 1.2]], 41, (g, s) => {
        g.strokeStyle = 'rgba(0,0,0,0.35)';
        g.lineWidth = 2;
        for (let i = 0; i <= 4; i++) {
          g.beginPath();
          g.moveTo((i * s) / 4, 0);
          g.lineTo((i * s) / 4, s);
          g.moveTo(0, (i * s) / 4);
          g.lineTo(s, (i * s) / 4);
          g.stroke();
        }
      });
      const ground = new THREE.Mesh(new THREE.PlaneGeometry(40, 40), new THREE.MeshStandardMaterial({ map: texFrom(con, true, 8), roughness: 0.95, color: 0x8a7a70 }));
      ground.rotation.x = -Math.PI / 2;
      S.scene.add(ground);
      const brick = cachedCanvas('v3brick', 256, 256, (g, s) => {
        const r = rng(8);
        g.fillStyle = '#3a2620';
        g.fillRect(0, 0, s, s);
        const bh = s / 12;
        for (let row = 0; row < 12; row++) {
          const off = (row % 2) * (s / 12);
          for (let x = -s / 6; x < s; x += s / 6) {
            g.fillStyle = `hsl(${10 + r() * 12}, ${35 + r() * 15}%, ${26 + r() * 12}%)`;
            g.fillRect(x + off + 2, row * bh + 2, s / 6 - 4, bh - 4);
          }
        }
        const gr = g.createLinearGradient(0, 0, 0, s * 0.25);
        gr.addColorStop(0, 'rgba(10,8,8,0.7)');
        gr.addColorStop(1, 'rgba(10,8,8,0)');
        g.fillStyle = gr;
        g.fillRect(0, 0, s, s * 0.25);
      });
      const bt = texFrom(brick, true);
      bt.wrapS = bt.wrapT = THREE.RepeatWrapping;
      bt.repeat.set(3, 4);
      const CH = new THREE.Vector3(-2.2, 3.4, -1.4);
      const chim = new THREE.Mesh(new THREE.CylinderGeometry(0.42, 0.55, 3.4, 24), new THREE.MeshStandardMaterial({ map: bt, roughness: 0.9 }));
      chim.position.set(CH.x, 1.7, CH.z);
      const rim = new THREE.Mesh(new THREE.CylinderGeometry(0.52, 0.5, 0.22, 24), new THREE.MeshStandardMaterial({ color: 0x2a2624, roughness: 0.8 }));
      rim.position.set(CH.x, 3.4, CH.z);
      const hole = new THREE.Mesh(new THREE.CircleGeometry(0.4, 24), new THREE.MeshBasicMaterial({ color: 0x050403 }));
      hole.rotation.x = -Math.PI / 2;
      hole.position.set(CH.x, 3.515, CH.z);
      const shed = new THREE.Mesh(new THREE.BoxGeometry(3.2, 1.7, 2.2), new THREE.MeshStandardMaterial({ color: 0x3c3a3e, roughness: 0.85 }));
      shed.position.set(-3.6, 0.85, -2.6);
      const roof = new THREE.Mesh(new THREE.BoxGeometry(3.4, 0.12, 2.4), new THREE.MeshStandardMaterial({ color: 0x24262a, roughness: 0.6 }));
      roof.position.set(-3.6, 1.76, -2.6);
      S.scene.add(chim, rim, hole, shed, roof);
      // 증기 관
      const metal = new THREE.MeshStandardMaterial({ color: 0x9aa0a8, roughness: 0.32, metalness: 0.85 });
      const pipe = new THREE.Group();
      const tube = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.12, 2.6, 18), metal);
      tube.rotation.z = Math.PI / 2;
      tube.position.x = 1.3;
      const fl = new THREE.Mesh(new THREE.CylinderGeometry(0.19, 0.19, 0.08, 18), metal);
      fl.rotation.z = Math.PI / 2;
      fl.position.x = 0.02;
      const noz = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.11, 0.18, 16), metal);
      noz.rotation.z = Math.PI / 2;
      noz.position.x = -0.1;
      const wheel = new THREE.Mesh(new THREE.TorusGeometry(0.16, 0.025, 8, 24), new THREE.MeshStandardMaterial({ color: 0xc03020, roughness: 0.5, metalness: 0.3 }));
      wheel.position.set(0.9, 0.32, 0);
      wheel.rotation.x = Math.PI / 2;
      const stem = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.03, 0.2, 8), metal);
      stem.position.set(0.9, 0.2, 0);
      for (const px of [0.6, 2.2]) {
        const leg = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.9, 0.12), metal);
        leg.position.set(px, -0.45, 0);
        pipe.add(leg);
      }
      pipe.add(tube, fl, noz, wheel, stem);
      pipe.position.set(1.4, 0.9, 0.8);
      S.scene.add(pipe);
      const grate = new THREE.Mesh(new THREE.CircleGeometry(0.42, 24), new THREE.MeshStandardMaterial({ color: 0x1c1c1e, roughness: 0.6, metalness: 0.6 }));
      grate.rotation.x = -Math.PI / 2;
      grate.position.set(-0.3, 0.01, 2.4);
      S.scene.add(grate);
      const smoke = S.pool(900, 2);
      const steam = S.pool(900, 3);
      const sparks = S.pool(200, 4);
      const SMOKE = ramp([[0, 0x2a2622, 1, 0, 0], [0.05, 0x2c2826, 1, 0.9, 0], [0.35, 0x4c4642, 1.05, 0.72, 0], [0.75, 0x7a7470, 1.1, 0.32, 0], [1, 0x9a9490, 1.1, 0, 0]]);
      const STEAM = ramp([[0, 0xffffff, 1.7, 0, 0.12], [0.05, 0xf6f8fa, 1.5, 0.7, 0.1], [0.4, 0xeef2f6, 1.4, 0.36, 0.06], [1, 0xe4e8ee, 1.4, 0, 0]]);
      const GSTEAM = ramp([[0, 0xffffff, 1.5, 0, 0.1], [0.2, 0xf0f4f8, 1.3, 0.2, 0.08], [1, 0xe0e6ec, 1.3, 0, 0]]);
      const JET = ramp([[0, 0xffffff, 2.4, 0.7, 0.3], [1, 0xffffff, 1.6, 0, 0.2]]);
      const EMB = ramp([[0, 0xffe0a0, 5, 1, 1], [0.5, 0xff6a10, 3, 0.85, 1], [1, 0x501000, 1, 0, 1]]);
      let wind = 1.1;
      let amount = 1;
      let burst = 99;
      let nextBurst = 2.2;
      let acc = 0;
      let accS = 0;
      let accG = 0;
      smoke.onStep = (p, i, dt) => {
        const lift = clamp((p.y[i]! - 3.5) * 0.4, 0, 1.6);
        p.x[i] = p.x[i]! + wind * dt * lift;
        p.z[i] = p.z[i]! - wind * dt * lift * 0.25;
      };
      // 처음부터 기둥이 서 있게 미리 돌리기
      const emitSmoke = (dt: number): void => {
        acc += dt * 13 * amount;
        while (acc >= 1) {
          acc -= 1;
          smoke.spawn({ x: CH.x + R(-0.15, 0.15), y: CH.y + 0.15, z: CH.z + R(-0.15, 0.15), vx: R(-0.15, 0.15), vy: R(1.0, 1.4), vz: R(-0.15, 0.15), life: R(5, 6.5), s0: R(0.45, 0.6), s1: R(2.6, 3.4), ramp: SMOKE, shape: 1, rot: R(-0.35, 0.35), rv: R(-0.06, 0.06), drag: 0.32, grav: -0.12, tag: 1 });
        }
      };
      for (let k = 0; k < 140; k++) {
        emitSmoke(0.05);
        smoke.step(0.05);
      }
      const controls: Control[] = [
        ctrlRange('바람', -1.5, 1.5, 0.05, 1.1, (v) => (wind = v)),
        ctrlRange('연기 양', 0.3, 2, 0.05, 1, (v) => (amount = v)),
        ctrlBtn('증기 뿜기', () => (burst = 0)),
      ];
      const NZ = new THREE.Vector3();
      return {
        scene: S.scene,
        camera: S.cam,
        controls,
        update(t, dt0) {
          const dt = Math.min(dt0, 0.05);
          emitSmoke(dt);
          if (Math.random() < dt * 2.5) sparks.spawn({ x: CH.x + R(-0.2, 0.2), y: CH.y + 0.2, z: CH.z, vx: R(-0.4, 0.4) + wind * 0.3, vy: R(1.5, 2.5), vz: R(-0.4, 0.4), life: R(0.6, 1.2), s0: 0.05, s1: 0.02, ramp: EMB, shape: 2, st: 0.05, grav: 0.8 });
          nextBurst -= dt;
          if (nextBurst <= 0) {
            burst = 0;
            nextBurst = 4.2;
          }
          burst += dt;
          noz.getWorldPosition(NZ);
          NZ.x -= 0.1;
          const on = burst < 1.4;
          if (on) {
            const k = burst / 1.4;
            const rate = 75 * (k < 0.15 ? 1.4 : 1 - k * 0.5);
            accS += dt * rate;
            while (accS >= 1) {
              accS -= 1;
              steam.spawn({ x: NZ.x, y: NZ.y + R(-0.03, 0.03), z: NZ.z + R(-0.03, 0.03), vx: -R(5, 7.5), vy: R(-0.3, 0.6), vz: R(-0.6, 0.6), life: R(1.0, 1.5), s0: 0.1, s1: R(1.0, 1.5), ramp: STEAM, shape: 1, rot: R(-0.4, 0.4), rv: R(-0.3, 0.3), drag: 2.7, grav: -1.1 });
            }
            if (burst < 0.35) for (let j = 0; j < 3; j++) steam.spawn({ x: NZ.x, y: NZ.y, z: NZ.z, vx: -R(9, 13), vy: R(-0.4, 0.4), vz: R(-0.6, 0.6), life: R(0.25, 0.4), s0: 0.05, ramp: JET, shape: 2, st: 0.05, drag: 3 });
            wheel.rotation.z += dt * 9;
            pipe.position.y = 0.9 + Math.sin(t * 90) * 0.006;
            lab.set('증기 — 하얗고 빠르게 퍼지다 금방 사라짐', '#e8f4ff');
          } else lab.set('연기 — 짙고 무겁게 오래 남아 바람에 휨', '#a89a90');
          accG += dt * 7;
          while (accG >= 1) {
            accG -= 1;
            steam.spawn({ x: -0.3 + R(-0.3, 0.3), y: 0.05, z: 2.4 + R(-0.3, 0.3), vx: wind * 0.2, vy: R(0.3, 0.5), life: R(2.5, 3.5), s0: 0.25, s1: 1.1, ramp: GSTEAM, shape: 1, rot: R(-0.4, 0.4), drag: 0.4, grav: -0.1 });
          }
          smoke.tick(dt);
          steam.tick(dt);
          sparks.tick(dt);
          S.tick(dt);
        },
        resize: (w, h) => S.resize(w, h),
        dispose: () => S.dispose(),
      };
    },
  }
```

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