# AI 꾸러미 — 부피 성운 (레이마칭 · 별빛 산란) — Volumetric ray marching
> 상자 안을 빛줄로 최대 48걸음 지나며 3D 잡음 텍스처로 밀도를 읽어 빛을 모으고 가림을 곱해, 가운데 별이 비추는 부피 성운을 그린다.  
> 견본: https://ai-techstudio.web.app/#t/i524

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

## 주문서

### 만들어 줘: 부피 성운 (레이마칭 · 별빛 산란) — Volumetric ray marching

#### 1. 목표
우주 게임 배경에 레이마칭 부피 성운을 넣어 줘 — 가운데 별빛으로 청록 → 빨강, 먼지는 뒤를 가리게, 분위기는 허블 사진처럼 화려하게.

#### 2. 핵심 기술 용어
- **Volumetric ray marching** — 빛줄을 한 걸음씩 나아가며 부피 속 빛을 모으기
- **Data3DTexture (3D noise)** — 미리 구운 64³ 3D 잡음 텍스처
- **Emission-absorption (Beer-Lambert)** — 빛을 더하고 지나온 만큼 exp 로 가림
- **Jittered start (dithering)** — 픽셀마다 시작점을 흔들어 줄무늬 없애기

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

#### 4. 조건
- 밀도는 미리 구운 3D 잡음 텍스처(Data3DTexture 64³, RG, 반복 이음)에서 읽는다 — 셰이더 안 잡음 계산 금지
- 상자는 BackSide 로 그리고, 카메라 위치를 상자 물체 좌표로 바꿔(uCamObj) 넘긴다
- 반복문 상한은 상수(48), 실제 걸음 수는 uniform 으로 그 안에서 break
- 시작점을 픽셀마다 흔든다 (gl_FragCoord 해시 × 한 걸음)
- 섞기는 CustomBlending(One, OneMinusSrcAlpha) — 결과가 미리 곱한 빛 + 투과율

#### 5. 완성 기준 (이게 보이면 성공)
- 카메라가 둘레를 돌면 성운이 진짜 덩어리처럼 겹겹이 보인다 (납작한 그림이 아니다)
- 가운데 별 가까이는 청록, 바깥은 빨강, 가는 실에 주황 테가 있다
- 먼지 덩어리가 뒤의 별 · 빛을 가린다
- 「걸음 수」 8 ~ 48 로 줄이면 빨라지는 대신 거칠어진다 — 폰에서 맞는 값을 찾는다

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

## 원리
- 성운은 상자(크기 2, 4.6배) 하나. 뒷면을 그리며 카메라에서 나온 빛줄이 상자를 들어가고 나오는 거리(boxHit)를 구한다.
- 그 사이를 같은 간격(3.4 / 걸음 수)으로 나아가며 3D 잡음 두 번으로 밀도를 읽는다. 공 껍질 모양 · 문턱(0.74)으로 덩어리를 만든다.
- 각 걸음에서 별까지 거리로 빛 세기를 구하고 (1 / (0.06 + 5r²)), 가까우면 청록(산소) · 멀면 빨강(수소)으로 빛을 더한다.
- 지나온 밀도만큼 투과율 T 를 exp 로 줄인다 — 먼지가 많으면 더 많이 가려 뒤 별이 숨는다. T 가 0.02 아래면 일찍 멈춘다.
- 모든 픽셀이 같은 자리에서 시작하면 줄무늬가 생기므로 픽셀마다 시작점을 한 걸음 안에서 흔든다.

## 핵심 코드 — 상자 안 레이마칭 — 밀도 · 별빛 · 가림
(발췌: demos/demosSpace.ts NEB_FS 를 정리)
```glsl
uniform highp sampler3D uN3;           // 미리 구운 64³ 잡음 (r = fbm, g = 능선)
uniform vec3 uCamObj, uStarPos; uniform float uTime, uDens, uStar; uniform int uSteps;
varying vec3 vObj;
vec2 boxHit(vec3 ro, vec3 rd){
  vec3 inv = 1.0 / rd, t0 = (-1.0 - ro) * inv, t1 = (1.0 - ro) * inv;
  vec3 a = min(t0, t1), b = max(t0, t1);
  return vec2(max(max(a.x, a.y), a.z), min(min(b.x, b.y), b.z));
}
void main(){
  vec3 ro = uCamObj, rd = normalize(vObj - uCamObj);
  vec2 h = boxHit(ro, rd); h.x = max(h.x, 0.0);
  if (h.y <= h.x) discard;
  float ds = 3.4 / float(uSteps);
  float jit = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453);
  float t = h.x + ds * jit;                       // 줄무늬 없애기
  vec3 col = vec3(0.0); float T = 1.0;
  for (int i = 0; i < 48; i++) {
    if (i >= uSteps || t > h.y || T < 0.02) break;
    vec3 p = ro + rd * t; float r = length(p);
    if (r < 1.0) {
      vec2 a = texture(uN3, p * 0.42 + vec3(0.13, uTime * 0.003, 0.41)).rg;
      vec2 b = texture(uN3, p * 1.1 + vec3(a.r * 0.35, 0.17, uTime * 0.005)).rg;
      float shell = smoothstep(0.95, 0.45, r + (a.g - 0.5) * 0.7) * smoothstep(0.08, 0.4, r) * smoothstep(1.0, 0.82, r);
      float d = max(a.r * 0.85 + b.g * 0.6 - 0.74, 0.0) * shell * 7.0 * uDens;
      if (d > 0.001) {
        float rs = length(p - uStarPos);
        float lightI = uStar / (0.06 + rs * rs * 5.0);
        vec3 em = mix(vec3(0.05, 0.7, 1.0), vec3(0.9, 0.06, 0.14), smoothstep(0.2, 0.58, rs)); // 산소 → 수소
        float dust = smoothstep(0.42, 0.72, b.r) * smoothstep(0.35, 0.8, r);
        col += T * em * d * (lightI * (1.0 - dust * 0.85) + 0.04) * ds * 1.8;
        T *= exp(-d * (0.8 + dust * 9.0) * ds * 3.0);       // 먼지는 더 많이 가린다
      }
    }
    t += ds;
  }
  gl_FragColor = vec4(col, 1.0 - T);
}
```

## 흔한 실수 · 확인 목록
- [ ] **셰이더 안에서 3D 잡음을 반복문으로 계산하면 컴파일 · 실행 모두 느리다** — 잡음은 64³ Data3DTexture 로 미리 굽고 읽기만 한다 (보물 동굴의 20초 멈춤과 같은 원인).
- [ ] **시작점을 흔들지 않으면 동심원 줄무늬가 보인다** — 픽셀마다 한 걸음 안에서 시작점을 흔든다.
- [ ] **카메라를 세계 좌표 그대로 넘기면 상자를 키우거나 돌릴 때 어긋난다** — 상자 matrixWorld 의 역행렬로 카메라를 물체 좌표로 바꿔 넘긴다.
- [ ] **반복문 상한을 uniform 으로 쓰면 일부 기기에서 컴파일이 안 된다** — 상한은 상수(48), uniform 은 그 안에서 break.

## 완성 기준 체크리스트
- [ ] 카메라가 둘레를 돌면 성운이 진짜 덩어리처럼 겹겹이 보인다 (납작한 그림이 아니다)
- [ ] 가운데 별 가까이는 청록, 바깥은 빨강, 가는 실에 주황 테가 있다
- [ ] 먼지 덩어리가 뒤의 별 · 빛을 가린다
- [ ] 「걸음 수」 8 ~ 48 로 줄이면 빨라지는 대신 거칠어진다 — 폰에서 맞는 값을 찾는다

## 이 기술 정보
- id: `i524` · 분류: 그래픽 · 셰이더 › 우주 표현 · 3D · 난이도 어려움 · 폰 부담 무거움 — 픽셀마다 최대 48걸음 × 3D 텍스처 2번 = 96번 읽기. 성운이 화면을 많이 덮으면 폰에서 무겁다 — 걸음 수를 24 정도로, 해상도를 반으로.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i524
- 쓰면 좋을 때: 우주 배경의 주인공 성운 / 카메라가 둘레를 도는 컷신 · 메뉴 / 연기 · 구름 덩어리를 부피로 보여 줄 때
- 쓰지 말 때: 화면 전체를 덮는 크기로 폰에서 — 픽셀 수 × 48걸음이 무겁다. 대신 2D 성운 그림(u54) / 셰이더에서 잡음을 계산 — 텍스처로 읽어야 한다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i524 견본 항목 — `src/demos/demosSpace.ts:1905`
```ts
  i524: {
    kind: '3d',
    caption: '레이마칭 부피 성운 — 3D 잡음 덩어리를 한 걸음씩 지나며 빛을 모은다: 가운데 별 가까이는 청록(산소), 바깥은 빨강(수소), 먼지는 뒤 별을 가린다',
    make(): Scene3D {
      const bin = new Bin();
      const scene = new THREE.Scene();
      scene.background = new THREE.Color(0x000000);
      const cam = new THREE.PerspectiveCamera(50, 1.6, 0.1, 1000);
      const S = makeStars(14000, 300, 21, { magMax: 7.5, tw: 0.15 });
      scene.add(S.pts);
      const U = { uN3: { value: noise3D() }, uCamObj: { value: new THREE.Vector3() }, uStarPos: { value: new THREE.Vector3(0.05, 0.02, 0) }, uTime: { value: 0 }, uDens: { value: 1 }, uStar: { value: 1 }, uSteps: { value: 48 } };
      const neb = new THREE.Mesh(
        new THREE.BoxGeometry(2, 2, 2),
        new THREE.ShaderMaterial({ uniforms: U, vertexShader: NEB_VS, fragmentShader: NEB_FS, side: THREE.BackSide, transparent: true, depthWrite: false, blending: THREE.CustomBlending, blendSrc: THREE.OneFactor, blendDst: THREE.OneMinusSrcAlphaFactor }),
      );
      neb.scale.setScalar(4.6);
      neb.renderOrder = 1;
      scene.add(neb);
      const tex = bin.add(texOf(spikeCanvas()));
      const gt = bin.add(texOf(glowCanvas()));
      const star = glowSprite(tex, 0xbfd8ff, 3, 1.4);
      star.position.copy(U.uStarPos.value).multiplyScalar(4.6);
      star.renderOrder = 2;
      scene.add(star);
      const halo = glowSprite(gt, 0x6fb6ff, 0.5, 3.2);
      halo.position.copy(star.position);
      halo.renderOrder = 2;
      scene.add(halo);
      const r = rng(5);
      for (let i = 0; i < 9; i++) {
        const s = glowSprite(tex, i % 3 ? 0xdde8ff : 0xffe2c0, 0.9 + r(), 0.35 + r() * 0.35);
        s.position.set(gauss(r) * 1.1, gauss(r) * 0.8, gauss(r) * 1.1);
        s.renderOrder = 2;
        scene.add(s);
      }
      bin.scene(scene);
      const inv = new THREE.Matrix4();
      return {
        scene,
        camera: cam,
        tone: THREE.ACESFilmicToneMapping,
        update(t) {
          S.U.uTime.value = t;
          U.uTime.value = t;
          const a = t * 0.07;
          cam.position.set(Math.sin(a) * 8.4, 1.6 + Math.sin(t * 0.05) * 1.2, Math.cos(a) * 8.4);
          cam.lookAt(0, 0, 0);
          neb.updateMatrixWorld();
          inv.copy(neb.matrixWorld).invert();
          U.uCamObj.value.copy(cam.position).applyMatrix4(inv);
          star.scale.setScalar(1.4 * (1 + 0.04 * Math.sin(t * 3)));
        },
        resize: (_w, h) => (S.U.uScale.value = h / 900),
        controls: [
          range('밀도', 0.2, 2.5, 0.05, 1, (v) => (U.uDens.value = v)),
          range('가운데 별빛', 0, 3, 0.05, 1, (v) => (U.uStar.value = v)),
          range('걸음 수 (레이마칭)', 8, 48, 1, 48, (v) => (U.uSteps.value = v)),
        ],
        dispose: () => {
          bin.dispose();
          S.dispose();
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [은하수 띠 (먼지 줄 · 밝은 팽대부)](https://ai-techstudio.web.app/ai/t/i523.md) `i523` · [잡음으로 그린 성운 (fbm)](https://ai-techstudio.web.app/ai/t/u54.md) `u54`
- 다음에 해 볼 기술: [나선 은하 (입자 20만 · 밀도파 팔)](https://ai-techstudio.web.app/ai/t/i525.md) `i525` · [SDF 레이마칭](https://ai-techstudio.web.app/ai/t/i56.md) `i56`
- 참고 문서: [three.js 예제 — webgl_volume_cloud](https://threejs.org/examples/#webgl_volume_cloud) · [Wikipedia — Volume ray casting](https://en.wikipedia.org/wiki/Volume_ray_casting)
