# AI 꾸러미 — 부피 불 · 연기 · 구름 — Volumetric ray marching
> 3D 잡음으로 만든 밀도를 화면 픽셀마다 광선으로 40걸음 훑으며 빛을 모아, 속이 꽉 찬 불 · 구름을 그린다.  
> 견본: https://ai-techstudio.web.app/#t/i57

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

## 주문서

### 만들어 줘: 부피 불 · 연기 · 구름 — Volumetric ray marching

#### 1. 목표
모닥불 · 횃불 불꽃을 3D 잡음 밀도를 광선으로 훑는 부피 렌더링으로 그려 줘. 분위기는 밤하늘 아래 따뜻한 불빛.

#### 2. 핵심 기술 용어
- **Volumetric ray marching** — 광선을 조금씩 나아가며 부피 속 밀도를 더하기
- **Beer-Lambert transmittance** — 지나온 밀도만큼 빛이 줄어드는 비율 T = exp(−밀도 × 거리)
- **fbm 3D noise density** — 여러 겹 3D 잡음으로 만든 불 · 구름 모양
- **Ray-sphere bounding** — 부피가 있는 구 안에서만 걸음을 쓰기

#### 3. 환경
- 플랫폼: three.js r186 (ES 모듈 · TypeScript, `import * as THREE from "three"`), WebGL2, 외부 라이브러리 추가 없이
- 화면: 3D · 브라우저 — PC 와 폰(가로 844×390 · 세로 390×844) 모두, 60fps 목표

#### 4. 조건
- 부피는 구(또는 상자)로 감싸 그 안에서만 걸음을 쓴다 — 빈 하늘에 걸음을 낭비하지 않기
- 걸음 시작점을 픽셀마다 무작위로 조금 밀어(jitter) 줄무늬(banding)를 없앤다
- T < 0.02 이면 반복문을 빠져나온다 (앞이 꽉 막히면 뒤는 계산하지 않기)
- 잡음은 반복문 안에서 여러 번 부르지 말고 미리 구운 3D 잡음 텍스처(Data3DTexture)를 읽는다 — 윈도 셰이더 컴파일 멈춤 방지
- 걸음 수는 uniform 이 아닌 상수로 (GLSL 반복문 한계) — 품질 단계는 셰이더를 따로 만들어

#### 5. 완성 기준 (이게 보이면 성공)
- 불은 아래가 넓고 위로 갈수록 좁아지며 위로 흘러 일렁이고, 가운데는 흰빛 · 가장자리는 빨갛게 보인다
- 구름은 해 쪽이 밝고 반대쪽 아래가 푸르스름하게 어둡다 (속이 꽉 찬 덩어리)
- 걸음 수를 바꿔 보면 적을수록 줄무늬가 생기는데, jitter 를 켜면 사라진다
- 켤 때 셰이더 컴파일 멈춤이 1초를 넘지 않는다

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

## 원리
- 화면 픽셀마다 카메라에서 광선을 쏘고, 부피를 감싼 구와 만나는 구간만 40 걸음으로 나눈다.
- 걸음마다 3D 잡음(fbm)으로 밀도 d 를 구하고, 밝기 = 색 × d × 걸음 길이 를 「아직 남은 빛 T」만큼 곱해 더한다.
- T 는 걸음마다 exp(−d × dt × k) 로 줄어든다 — T 가 0.02 아래면 뒤는 안 보이니 멈춘다.
- 불은 높이에 따라 좁아지는 기둥 모양에서 잡음을 빼 깎고, 색은 밀도에 따라 검정 → 빨강 → 주황 → 노랑 → 흰색 띠로.
- 구름은 해 쪽으로 0.22 만큼 한 번 더 밀도를 읽어 그늘(li = exp(−dl × 2.6))을 만든다.

## 핵심 코드 — 부피 불 — 구 안에서 40걸음 훑으며 빛 모으기
(발췌: demos/demosShader.ts volumeDemo() 의 fire() 를 정리)
```glsl
bool sph(vec3 ro, vec3 rd, vec3 c, float r, out float t0, out float t1){
  vec3 oc = ro - c; float b = dot(oc, rd); float h = b * b - (dot(oc, oc) - r * r);
  if (h < 0.0) return false; h = sqrt(h); t0 = -b - h; t1 = -b + h; return t1 > 0.0;
}
vec3 fire(vec3 ro, vec3 rd, vec3 bg, vec2 p){
  float t0, t1; vec3 acc = vec3(0.0); float T = 1.0;          // T = 아직 남은 빛
  if (sph(ro, rd, vec3(0.0, 0.2, 0.0), 1.15, t0, t1)){
    t0 = max(t0, 0.0); float dt = (t1 - t0) / 40.0;
    float t = t0 + dt * rnd(p * 91.7);                          // 시작점 흔들기 — 줄무늬 없애기
    for (int i = 0; i < 40; i++){
      vec3 x = ro + rd * t;
      float hgt = clamp((x.y + 0.75) / 1.9, 0.0, 1.0);
      float w = 0.5 * (1.0 - hgt * 0.8) + 0.03;                // 위로 갈수록 좁은 기둥
      float n = fbm3(x * 2.4 - vec3(0.0, uTime * 2.0, 0.0));    // 위로 흐르는 잡음
      float d = clamp((1.0 - length(x.xz) / w) * 1.3 + (n - 0.5) * 1.7 - hgt * 0.55, 0.0, 1.0) * step(-0.75, x.y);
      if (d > 0.001){
        acc += T * fireRamp(d * (1.2 - hgt * 0.75)) * d * dt * 9.0;
        T *= exp(-d * dt * 4.0);                                // 비어-람베르트
        if (T < 0.02) break;
      }
      t += dt;
    }
  }
  return bg * T + acc;                                          // 남은 빛만큼 뒤 배경이 비친다
}
```

## 흔한 실수 · 확인 목록
- [ ] **반복문 안에서 fbm 잡음을 부르면 셰이더 컴파일이 수십 초 멈춘다** — 윈도 D3D 는 반복문 × 잡음을 펼쳐서 컴파일한다. 잡음은 미리 구운 3D 텍스처로 읽고, 첫 사용 전 compileAsync 로 데운다.
- [ ] **걸음 시작점이 모두 같으면 동심원 줄무늬가 보인다** — 픽셀마다 무작위(0~1) × dt 만큼 시작을 민다. 견본은 rnd(p · 91.7).
- [ ] **부피 밖 빈 공간까지 걸으면 같은 걸음 수로 흐릿해진다** — 구와 광선의 교점(t0 · t1) 사이만 나눈다 — 같은 40걸음이 부피 안에 다 쓰인다.
- [ ] **밀도에 곱하는 수를 크게 하면 하얗게 타 버린다** — 빛 모으기(× 9)와 막힘(× 4)을 따로 조절한다. 구름은 둘 다 7 로 맞췄다.

## 완성 기준 체크리스트
- [ ] 불은 아래가 넓고 위로 갈수록 좁아지며 위로 흘러 일렁이고, 가운데는 흰빛 · 가장자리는 빨갛게 보인다
- [ ] 구름은 해 쪽이 밝고 반대쪽 아래가 푸르스름하게 어둡다 (속이 꽉 찬 덩어리)
- [ ] 걸음 수를 바꿔 보면 적을수록 줄무늬가 생기는데, jitter 를 켜면 사라진다
- [ ] 켤 때 셰이더 컴파일 멈춤이 1초를 넘지 않는다

## 이 기술 정보
- id: `i57` · 분류: 이펙트 · 연출 › 화려한 효과 (VFX) · 3D · 난이도 어려움 · 폰 부담 무거움 — 픽셀마다 40걸음 × fbm 잡음 여러 겹. 1080p 전체 화면이면 한 장면에 수억 번 잡음 계산 — 폰은 해상도를 반으로 낮춰 그리거나 부피를 화면 일부로.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i57
- 쓰면 좋을 때: 불 · 연기 · 구름이 화면 가운데서 크게 보이는 주인공일 때 / 돌아가며 봐도 속이 꽉 찬 덩어리로 보여야 할 때
- 쓰지 말 때: 작게 여러 개 나오는 불씨 · 연기 — 2D 불 셰이더(i58)나 플립북(i59)이 훨씬 싸다 / 폰에서 화면 전체를 덮는 구름 — 구름 그림을 붙인 판(빌보드)으로

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### volumeDemo — `src/demos/demosShader.ts:1064`
```ts
function volumeDemo(): Scene3D {
  const u = { uRes: { value: new THREE.Vector2(1, 1) }, uTime: { value: 0 }, uSteps: { value: 40 } };
  const q = fsq(
    /* glsl */ `uniform vec2 uRes; uniform float uTime; uniform float uSteps; varying vec2 vUv;
    ${NOISE3}
    bool sph(vec3 ro, vec3 rd, vec3 c, float r, out float t0, out float t1){
      vec3 oc = ro - c; float b = dot(oc, rd); float cc = dot(oc, oc) - r * r; float h = b * b - cc;
      if (h < 0.0) return false; h = sqrt(h); t0 = -b - h; t1 = -b + h; return t1 > 0.0;
    }
    vec3 fireRamp(float x){
      vec3 c = mix(vec3(0.0), vec3(0.75, 0.05, 0.02), smoothstep(0.0, 0.25, x));
      c = mix(c, vec3(1.0, 0.42, 0.04), smoothstep(0.25, 0.55, x));
      c = mix(c, vec3(1.0, 0.82, 0.3), smoothstep(0.55, 0.8, x));
      return mix(c, vec3(1.0, 1.0, 0.9), smoothstep(0.8, 1.0, x));
    }
    float rnd(vec2 p){ return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
    vec3 fire(vec2 p){
      vec3 ro = vec3(0.0, 0.2, 3.2); vec3 rd = normalize(vec3(p, -1.7));
      vec3 bg = mix(vec3(0.16, 0.05, 0.08), vec3(0.03, 0.03, 0.09), smoothstep(-0.5, 0.5, p.y));
      bg += vec3(1.0, 0.35, 0.08) * 0.25 * exp(-length(p - vec2(0.0, -0.32)) * 3.0);
      float t0, t1; vec3 acc = vec3(0.0); float T = 1.0;
      if (sph(ro, rd, vec3(0.0, 0.2, 0.0), 1.15, t0, t1)){
        t0 = max(t0, 0.0); float dt = (t1 - t0) / 40.0; float t = t0 + dt * rnd(p * 91.7);
        for (int i = 0; i < 40; i++){
          vec3 x = ro + rd * t;
          float hgt = clamp((x.y + 0.75) / 1.9, 0.0, 1.0);
          float w = 0.5 * (1.0 - hgt * 0.8) + 0.03;
          float n = fbm3(x * 2.4 - vec3(0.0, uTime * 2.0, 0.0) + vec3(0.0, 0.0, uTime * 0.2));
          float d = clamp((1.0 - length(x.xz) / w) * 1.3 + (n - 0.5) * 1.7 - hgt * 0.55, 0.0, 1.0) * step(-0.75, x.y);
          if (d > 0.001){
            acc += T * fireRamp(d * (1.2 - hgt * 0.75)) * d * dt * 9.0;
            T *= exp(-d * dt * 4.0);
            if (T < 0.02) break;
          }
          t += dt;
        }
      }
      return bg * T + acc;
    }
    mat2 rot(float a){ float c = cos(a), s = sin(a); return mat2(c, -s, s, c); }
    float cden(vec3 x){
      x.xz = rot(uTime * 0.15) * x.xz;
      float s = 1.0 - length(x / vec3(1.05, 0.5, 0.75));
      s = max(s, 1.0 - length((x - vec3(0.45, 0.22, 0.0)) / vec3(0.55, 0.48, 0.5)));
      s = max(s, 1.0 - length((x - vec3(-0.42, 0.14, 0.1)) / vec3(0.5, 0.42, 0.48)));
      float n = fbm3(x * 2.3 + vec3(uTime * 0.12, -uTime * 0.05, 0.0));
      return clamp(s * 2.4 + (n - 0.5) * 2.0 - 0.15, 0.0, 1.0);
    }
    vec3 cloud(vec2 p){
      vec3 ro = vec3(0.0, 0.1, 3.2); vec3 rd = normalize(vec3(p, -1.7));
      vec3 bg = mix(vec3(0.78, 0.9, 1.0), vec3(0.32, 0.6, 0.95), smoothstep(-0.5, 0.6, p.y));
      vec3 L = normalize(vec3(0.6, 0.7, 0.3));
      float t0, t1; vec3 acc = vec3(0.0); float T = 1.0;
      if (sph(ro, rd, vec3(0.0), 1.3, t0, t1)){
        t0 = max(t0, 0.0); float dt = (t1 - t0) / 36.0; float t = t0 + dt * rnd(p * 53.1);
        for (int i = 0; i < 36; i++){
          vec3 x = ro + rd * t;
          float d = cden(x);
          if (d > 0.01){
            float dl = cden(x + L * 0.22);
            float li = exp(-dl * 2.6);
            vec3 c = mix(vec3(0.5, 0.58, 0.76), vec3(1.0, 0.98, 0.93), li);
            acc += T * c * d * dt * 7.0;
            T *= exp(-d * dt * 7.0);
            if (T < 0.02) break;
          }
          t += dt;
        }
      }
      return bg * T + acc;
    }
    void main(){
      bool right = gl_FragCoord.x > uRes.x * 0.5;
      float hw = uRes.x * 0.5;
      vec2 c = vec2(right ? hw * 1.5 : hw * 0.5, uRes.y * 0.5);
      vec2 p = (gl_FragCoord.xy - c) / min(hw * 1.35, uRes.y);
      vec3 col = right ? cloud(p) : fire(p);
      if (abs(gl_FragCoord.x - hw) < 1.0) col = vec3(1.0);
      gl_FragColor = vec4(col, 1.0);
    }`,
    u,
  );
  const tags = new Tags(['부피 불', '부피 구름']);
  return {
    scene: q.scene,
    camera: q.camera,
    tone: THREE.NoToneMapping,
    update(t) {
      u.uTime.value = t;
    },
    render(r, w, h) {
      u.uRes.value.set(w, h);
      r.render(q.scene, q.camera);
      tags.draw(r, w, h, [
        [0.25, 0.08],
        [0.75, 0.08],
      ]);
    },
    dispose() {
      q.dispose();
      tags.dispose();
    },
  };
}
```

## 관련 기술
- 먼저 알면 좋은 기술: [SDF 레이마칭](https://ai-techstudio.web.app/ai/t/i56.md) `i56` · [잡음으로 그린 성운 (fbm)](https://ai-techstudio.web.app/ai/t/u54.md) `u54`
- 다음에 해 볼 기술: [2D 불 셰이더](https://ai-techstudio.web.app/ai/t/i58.md) `i58` · [빛줄기 (볼류메트릭)](https://ai-techstudio.web.app/ai/t/i497.md) `i497`
- 참고 문서: [Wikipedia — Volume ray casting](https://en.wikipedia.org/wiki/Volume_ray_casting)
