# AI 꾸러미 — 빛줄기 (볼류메트릭)
> 창 · 횃불에서 안개 속으로 퍼지는 빛 기둥 — 빛이 공기 중에 보이는 효과  
> 견본: https://ai-techstudio.web.app/#t/i497

이 문서를 코드 도우미(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: `i497` · 분류: 그래픽 · 셰이더 › WebGPU · 최신 렌더링 · 3D · 난이도 어려움 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i497
- 쓰면 좋을 때: 쓰면 좋을 곳 — 던전 · 성당 · 숲

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i497 — `src/demos/demosWebGPU.ts:1493`
```ts
const i497 = gpuDemo<{ on: boolean; density: number; steps: number; sun: number }>({
  caption: '창으로 들어온 햇빛이 먼지 낀 공기 속에 기둥처럼 보이는 볼류메트릭 빛줄기 (그림자 지도 레이마칭, 반복 16번 이하)',
  state: () => ({ on: true, density: 0.75, steps: 16, sun: 0 }),
  card: card497,
  controls: (st, api) => [
    {
      type: 'toggle',
      label: '빛줄기 켜기',
      value: true,
      on: (v) => {
        st.on = v;
        (api.ctx() as Any)?.__grSet?.();
      },
    },
    { type: 'range', label: '공기 밀도', min: 0.1, max: 1.6, step: 0.05, value: 0.75, on: (v) => (st.density = v) },
    { type: 'range', label: '레이마칭 반복 (최대 16)', min: 4, max: 16, step: 1, value: 16, on: (v) => (st.steps = v) },
    { type: 'range', label: '해 방향', min: -1, max: 1, step: 0.02, value: 0, on: (v) => (st.sun = v) },
  ],
  async build(ctx, st) {
    const { GPU, TSL, renderer } = ctx;
    const { pass, color, instanceIndex, hash, vec3, float, sin, cos, time, smoothstep, uv } = TSL;
    const { godrays } = await import('three/examples/jsm/tsl/display/GodraysNode.js');
    const { bilateralBlur } = await import('three/examples/jsm/tsl/display/BilateralBlurNode.js');
    const { depthAwareBlend } = await import('three/examples/jsm/tsl/display/depthAwareBlend.js');
    renderer.toneMapping = GPU.ACESFilmicToneMapping;
    renderer.toneMappingExposure = 1.1;
    renderer.shadowMap.enabled = true;
    const scene = new GPU.Scene();
    scene.background = new GPU.Color(0x07060a);
    const cam = new GPU.PerspectiveCamera(52, ctx.w / ctx.h, 0.1, 80);

    const wallMat = new GPU.MeshStandardMaterial({ color: 0x6a5f58, roughness: 0.95 });
    const darkMat = new GPU.MeshStandardMaterial({ color: 0x3a2c22, roughness: 0.8 });
    const box = (w: number, h: number, d: number, x: number, y: number, z: number, m: Any = wallMat): Any => {
      const o = new GPU.Mesh(new GPU.BoxGeometry(w, h, d), m);
      o.position.set(x, y, z);
      o.castShadow = true;
      o.receiveShadow = true;
      scene.add(o);
      return o;
    };
    const ptex = new GPU.CanvasTexture(plankCanvas());
    ptex.colorSpace = GPU.SRGBColorSpace;
    ptex.wrapS = ptex.wrapT = GPU.RepeatWrapping;
    ptex.repeat.set(3, 4);
    ptex.anisotropy = 8;
    const floor = new GPU.Mesh(new GPU.PlaneGeometry(10, 16), new GPU.MeshStandardMaterial({ map: ptex, roughness: 0.75 }));
    floor.rotation.x = -Math.PI / 2;
    floor.receiveShadow = true;
    scene.add(floor);
    // 왼쪽 벽 — 창 두 개 (z: -4.4~-2.4, 0.4~2.4 / y: 1.6~4.6)
    const WX = -5;
    box(0.4, 1.6, 16, WX, 0.8, 0);
    box(0.4, 1.6, 16, WX, 5.4, 0);
    box(0.4, 3, 3.6, WX, 3.1, -6.2);
    box(0.4, 3, 2.8, WX, 3.1, -1);
    box(0.4, 3, 5.6, WX, 3.1, 5.2);
    for (const zc of [-3.4, 1.4]) {
      box(0.25, 3, 0.12, WX, 3.1, zc, darkMat);
      box(0.25, 0.12, 2, WX, 3.1, zc, darkMat);
      box(0.6, 0.12, 2.2, WX + 0.15, 1.58, zc, darkMat);
    }
    box(10.4, 6.2, 0.4, 0, 3.1, -8);
    box(0.4, 6.2, 16, 5, 3.1, 0);
    box(10.4, 0.4, 16, 0, 6.3, 0);
    // 들보
    for (const z of [-5, -1, 3]) box(10, 0.35, 0.35, 0, 5.9, z, darkMat);
    // 소품: 탁자 · 의자 · 통 · 책장
    box(2.2, 0.12, 1.2, 1, 0.95, -1.2, darkMat);
    for (const [dx, dz] of [
      [-1, -0.5],
      [1, -0.5],
      [-1, 0.5],
      [1, 0.5],
    ] as const)
      box(0.1, 0.9, 0.1, 1 + dx * 0.95, 0.45, -1.2 + dz * 0.95, darkMat);
    box(0.6, 0.08, 0.6, 2.6, 0.55, -1.0, darkMat);
    box(0.6, 0.8, 0.08, 2.6, 0.95, -0.72, darkMat);
    const barrelGeo = new GPU.CylinderGeometry(0.45, 0.45, 1.1, 20);
    const barrelMat = new GPU.MeshStandardMaterial({ color: 0x5a3a20, roughness: 0.7 });
    for (const [x, z] of [
      [3.6, -6.4],
      [2.6, -6.6],
      [3.3, -5.4],
    ] as const) {
      const b = new GPU.Mesh(barrelGeo, barrelMat);
      b.position.set(x, 0.55, z);
      b.castShadow = b.receiveShadow = true;
      scene.add(b);
    }
    box(2.4, 3.2, 0.5, -1.6, 1.6, -7.5, darkMat);
    // 바깥 하늘 (창으로 보이는 밝은 빛)
    const sky = new GPU.Mesh(new GPU.PlaneGeometry(30, 14), new GPU.MeshBasicMaterial({ color: new GPU.Color(0xfff0d0).multiplyScalar(2.2) }));
    sky.position.set(-14, 4, 0);
    sky.rotation.y = Math.PI / 2;
    scene.add(sky);

    const sun = new GPU.DirectionalLight(0xffdcae, 7);
    sun.castShadow = true;
    sun.shadow.mapSize.set(2048, 2048);
    const sc = sun.shadow.camera;
    sc.left = -12;
    sc.right = 12;
    sc.top = 12;
    sc.bottom = -12;
    sc.near = 1;
    sc.far = 45;
    sun.shadow.bias = -0.0006;
    sun.shadow.normalBias = 0.03;
    scene.add(sun, sun.target);
    scene.add(new GPU.HemisphereLight(0x8090b8, 0x3a2a1c, 1.1));
    const bounce = new GPU.PointLight(0xffc890, 12, 11, 2);
    bounce.position.set(0, 0.6, 0);
    scene.add(bounce);

    // 떠도는 먼지 (계산 없이 정점 단계에서 시간으로)
    const dm = new GPU.SpriteNodeMaterial({ transparent: true, depthWrite: false, blending: GPU.AdditiveBlending });
    const i = instanceIndex;
    const base = vec3(hash(i).mul(9).sub(4.6), hash(i.add(11)).mul(5.4).add(0.3), hash(i.add(23)).mul(14).sub(7));
    const drift = vec3(
      sin(time.mul(0.3).add(hash(i.add(3)).mul(TAU))).mul(0.35),
      sin(time.mul(0.2).add(hash(i.add(5)).mul(TAU))).mul(0.3),
      cos(time.mul(0.25).add(hash(i.add(7)).mul(TAU))).mul(0.35),
    );
    dm.positionNode = base.add(drift);
    dm.colorNode = color(0xfff0d0);
    dm.opacityNode = smoothstep(0.5, 0.0, uv().sub(0.5).length()).mul(0.14);
    dm.scaleNode = float(0.022);
    const dust = new GPU.Sprite(dm);
    dust.count = 1500;
    dust.frustumCulled = false;
    scene.add(dust);

    const pipe = new GPU.RenderPipeline(renderer);
    const sp = pass(scene, cam);
    const col = sp.getTextureNode('output');
    const dep = sp.getTextureNode('depth');
    const gr: Any = godrays(dep, cam, sun);
    gr.raymarchSteps.value = st.steps;
    gr.density.value = st.density * 7;
    gr.maxDensity.value = 0.7;
    gr.distanceAttenuation.value = 0.6;
    const blur: Any = bilateralBlur(gr.getTextureNode());
    const out = depthAwareBlend(col, blur.getTextureNode(), dep, cam, { blendColor: color(0xffd9a6), edgeRadius: 2, edgeStrength: 2 });
    const setOut = (): void => {
      pipe.outputNode = st.on ? out : col;
      pipe.needsUpdate = true;
    };
    setOut();
    (ctx as Any).__grSet = setOut;
    return {
      scene,
      camera: cam,
      frame(t) {
        gr.raymarchSteps.value = Math.max(1, Math.min(16, Math.round(st.steps)));
        gr.density.value = st.density * 7;
        const z = st.sun * 7 + Math.sin(t * 0.25) * 0.6;
        sun.position.set(-15, 10, z);
        sun.target.position.set(0, 0, z * 0.2);
        bounce.position.set(-0.6, 0.5, z * 0.35 - 1);
        cam.position.set(4.1 + Math.sin(t * 0.17) * 0.4, 2.2 + Math.sin(t * 0.23) * 0.1, 7.2);
        cam.lookAt(-1.6, 2.1, -1.4);
      },
      render() {
        pipe.render();
      },
      dispose() {
        pipe.dispose?.();
        delete (ctx as Any).__grSet;
      },
      note: () => (st.on ? `빛줄기 켬 — 그림자 지도를 따라 ${Math.round(st.steps)}걸음 레이마칭 (창틀 그림자가 빛 기둥을 가른다)` : '빛줄기 끔 — 바닥의 햇빛 자국만 남는다'),
    };
  },
});
```

## 관련 기술
- 이 효과를 만든 기술: [빛살 (God rays)](https://ai-techstudio.web.app/ai/t/i06.md) `i06`
