# AI 꾸러미 — 빛살 (God rays) — God rays (crepuscular rays)
> 해만 하얗고 나머지는 검게 그린 그림을 해 쪽으로 60번 끌어 모아 더해, 풍차 · 나무 사이로 빛줄기가 쏟아지게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i06

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

## 주문서

### 만들어 줘: 빛살 (God rays) — God rays (crepuscular rays)

#### 1. 목표
해 앞의 풍차 · 나무 · 언덕에 빛살(God rays)을 넣어 줘 — 해에서 가리는 물체 사이로 빛줄기가 쏟아지게. 분위기는 따뜻한 노을.

#### 2. 핵심 기술 용어
- **God rays (crepuscular rays)** — 빛살 — 가려진 사이로 쏟아지는 빛줄기
- **Screen-space radial blur** — 화면에서 빛 중심 쪽으로 끌어 모으는 흐림
- **Occlusion pass (light white, occluders black)** — 가림 그림 — 빛은 하양 · 가리는 것은 검정

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

#### 4. 조건
- 가림 그림은 절반 해상도 · 검은 배경 · 모든 물체 검정 재질 · 해만 흰 재질 (그린 뒤 재질 · 배경 되돌리기)
- 해 화면 위치는 매 프레임 sun.position.project(camera) → 0~1 로
- 걸음 수는 상수(60) — 반복 길이를 uniform 으로 두지 않기
- 빛살 세기는 부드럽게 켜고 끄기 (amount 를 dt × 5 로 따라가게)
- 몇 초마다 켬 / 끔 비교, 「자동 켜고 끄기」

#### 5. 완성 기준 (이게 보이면 성공)
- 해 앞 풍차 날개 · 나무 사이로 빛줄기가 방사형으로 쏟아지고, 날개가 돌면 빛줄기도 돈다
- 켬 / 끔이 3초마다 부드럽게 바뀌어 차이가 분명하다
- 카메라를 돌려도 빛살이 해에서 나온다

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

## 원리
- 절반 크기 그림에 「가림 그림」을 그린다: 해는 하양, 나머지 물체는 모두 검정, 배경도 검정.
- 해의 화면 위치(project)를 구한다.
- 화면 각 점에서 해 쪽으로 60걸음 다가가며 가림 그림을 읽어 더한다. 걸음마다 0.975 배씩 약하게.
- 물체가 가린 곳은 어두운 줄, 사이로 빛이 새는 곳은 밝은 줄이 되어 빛줄기가 생긴다.
- 그 값에 따뜻한 색 × 세기(3.2)를 곱해 원래 그림에 더한다.

## 핵심 코드 — 가림 그림 + 해 쪽으로 끌어 모으기
(발췌: demos/demosLight.ts i06 make() 를 정리)
```ts
const rtO = new THREE.WebGLRenderTarget(1, 1);
const black = new THREE.MeshBasicMaterial({ color: 0x000000 });
const white = new THREE.MeshBasicMaterial({ color: 0xffffff });
const GOD = new THREE.ShaderMaterial({
  uniforms: { tDiffuse: { value: null }, tOcc: { value: rtO.texture }, lightPos: { value: new THREE.Vector2() }, amount: { value: 1 }, tint: { value: new THREE.Color(1.0, 0.82, 0.55) } },
  vertexShader: 'varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }',
  fragmentShader: 'uniform sampler2D tDiffuse; uniform sampler2D tOcc; uniform vec2 lightPos; uniform float amount; uniform vec3 tint; varying vec2 vUv;' +
    ' void main(){ vec4 base = texture2D(tDiffuse, vUv); vec2 d = (vUv - lightPos) * (0.92 / 60.0);' +
    ' vec2 uv = vUv; float il = 1.0; vec3 acc = vec3(0.0);' +
    ' for (int i = 0; i < 60; i++){ uv -= d; acc += texture2D(tOcc, uv).rgb * il; il *= 0.975; }' +
    ' acc /= 60.0; gl_FragColor = vec4(base.rgb + acc * tint * amount * 3.2, base.a); }',
});
// comp: RenderPass → new ShaderPass(GOD) → OutputPass

const saved = new Map<THREE.Mesh, THREE.Material | THREE.Material[]>();
const pos = new THREE.Vector3();
function frame(dt: number, w: number, h: number) {
  rtO.setSize(Math.round(w / 2), Math.round(h / 2));     // 절반 해상도면 충분
  const bg = scene.background;
  scene.background = null;
  for (const m of occluders) { saved.set(m, m.material); m.material = black; }
  saved.set(sun, sun.material); sun.material = white;
  renderer.setClearColor(0x000000, 1);
  renderer.setRenderTarget(rtO); renderer.clear(); renderer.render(scene, cam);
  renderer.setRenderTarget(null);
  for (const [m, mat] of saved) m.material = mat;
  saved.clear();
  scene.background = bg;
  pos.copy(sun.position).project(cam);                  // 해의 화면 위치
  GOD.uniforms.lightPos.value.set(pos.x * 0.5 + 0.5, pos.y * 0.5 + 0.5);
  comp.render(dt);
}
```

## 흔한 실수 · 확인 목록
- [ ] **가림 그림을 그린 뒤 재질 · 배경 · 지우기 색을 안 되돌리면 장면이 검게 나온다** — Map 에 저장했다가 바로 되돌린다.
- [ ] **해가 화면 밖으로 나가면 빛살이 엉뚱한 방향으로 뻗는다** — 해가 화면 안 · 카메라 앞(pos.z < 1)일 때만 amount 를 올린다.
- [ ] **빛기둥을 셰이더 반복문 잡음으로 만들면 컴파일이 오래 걸린다** — 이 견본은 텍스처 읽기만 반복한다. 보물 동굴의 빛기둥은 반투명 원뿔 메시(lightShaft)로 만들었다.

## 완성 기준 체크리스트
- [ ] 해 앞 풍차 날개 · 나무 사이로 빛줄기가 방사형으로 쏟아지고, 날개가 돌면 빛줄기도 돈다
- [ ] 켬 / 끔이 3초마다 부드럽게 바뀌어 차이가 분명하다
- [ ] 카메라를 돌려도 빛살이 해에서 나온다

## 이 기술 정보
- id: `i06` · 분류: 그래픽 · 셰이더 › 후처리 · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의) — 장면을 절반 해상도로 한 번 더 그리고, 픽셀마다 60번 읽기. PC OK, 폰은 걸음 수를 줄인다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i06
- 쓰면 좋을 때: 해 · 조명 앞에 가리는 물체가 있는 역광 장면 / 분위기 연출 (노을 · 동굴 · 성당)
- 쓰지 말 때: 빛이 화면 밖에 있거나 등 뒤에 있을 때 — 화면 기법이라 빛살이 사라진다 / 폰 — 픽셀마다 60번 읽기. 대신 반투명 빛기둥 메시(u27 꼴)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i06 견본 항목 — `src/demos/demosLight.ts:1985`
```ts
  i06: {
    kind: '3d',
    caption: '해 앞의 풍차 · 나무 사이로 빛줄기가 쏟아져요 — 몇 초마다 켬 / 끔',
    make() {
      const scene = new THREE.Scene();
      scene.background = gradTex('#3a2a78', '#ff9a5a', '#c0508a');
      scene.add(new THREE.HemisphereLight(0xffc0a0, 0x302040, 0.6));
      const back = new THREE.DirectionalLight(0xffc080, 1.2);
      back.position.set(0, 2, -6);
      scene.add(back);
      const sun = mesh(new THREE.SphereGeometry(0.95, 32, 20), new THREE.MeshBasicMaterial({ color: 0xfff0b8 }), -0.5, 2.9, -9);
      scene.add(sun);
      const occ: THREE.Mesh[] = [];
      const hill = mesh(new THREE.SphereGeometry(6, 48, 24), std(0x4a2a5a, 0.9), 0, -5.6, -3);
      const mill = new THREE.Group();
      mill.add(mesh(new THREE.CylinderGeometry(0.12, 0.2, 2.0, 12), std(0x3a2240, 0.8), 0, 0.5, 0));
      const blades = new THREE.Group();
      for (let i = 0; i < 4; i++) {
        const b = mesh(new THREE.BoxGeometry(0.22, 1.5, 0.04), std(0x3a2240, 0.8), 0, 0.75, 0);
        const holder = new THREE.Group();
        holder.add(b);
        holder.rotation.z = (i * Math.PI) / 2;
        blades.add(holder);
      }
      blades.position.set(0, 1.5, 0.15);
      mill.add(blades);
      mill.position.set(0.15, 0, -4.2);
      const trees = new THREE.Group();
      for (const [x, z, s] of [
        [-2.4, -3.5, 1.2],
        [-1.6, -4.6, 0.9],
        [2.0, -3.8, 1.1],
        [2.9, -4.8, 0.8],
        [-3.3, -4.9, 1.0],
      ] as const) {
        const tr = new THREE.Group();
        tr.add(mesh(new THREE.CylinderGeometry(0.06, 0.08, 0.6, 8), std(0x2a1630), 0, 0.3, 0));
        tr.add(mesh(new THREE.ConeGeometry(0.45, 1.3, 12), std(0x2f1a3c), 0, 1.1, 0));
        tr.position.set(x, 0, z);
        tr.scale.setScalar(s);
        trees.add(tr);
      }
      const birds: THREE.Mesh[] = [];
      for (let i = 0; i < 4; i++) {
        const b = mesh(new THREE.BoxGeometry(0.3, 0.3, 0.3), std(0x3a2240), 0, 0, -5);
        birds.push(b);
        scene.add(b);
      }
      scene.add(hill, mill, trees);
      scene.traverse((o) => {
        if ((o as THREE.Mesh).isMesh && o !== sun) occ.push(o as THREE.Mesh);
      });
      const cam = new THREE.PerspectiveCamera(42, 1, 0.1, 50);
      cam.position.set(0, 0.9, 3.2);
      cam.lookAt(0, 1.3, -6);
      const rtO = new THREE.WebGLRenderTarget(1, 1);
      const black = new THREE.MeshBasicMaterial({ color: 0x000000 });
      const white = new THREE.MeshBasicMaterial({ color: 0xffffff });
      const GOD = new THREE.ShaderMaterial({
        uniforms: { tDiffuse: { value: null }, tOcc: { value: rtO.texture }, lightPos: { value: new THREE.Vector2(0.5, 0.6) }, amount: { value: 1 }, tint: { value: new THREE.Color(1.0, 0.82, 0.55) } },
        vertexShader: VERT,
        fragmentShader: `uniform sampler2D tDiffuse; uniform sampler2D tOcc; uniform vec2 lightPos; uniform float amount; uniform vec3 tint; varying vec2 vUv;
          void main(){
            vec4 base = texture2D(tDiffuse, vUv);
            vec2 d = (vUv - lightPos) * (0.92 / 60.0);
            vec2 uv = vUv; float il = 1.0; vec3 acc = vec3(0.0);
            for (int i = 0; i < 60; i++){ uv -= d; acc += texture2D(tOcc, uv).rgb * il; il *= 0.975; }
            acc /= 60.0;
            gl_FragColor = vec4(base.rgb + acc * tint * amount * 3.2, base.a);
          }`,
      });
      const kit = postKit((r) => {
        const comp = new EffectComposer(r);
        comp.setPixelRatio(1);
        comp.addPass(new RenderPass(scene, cam));
        comp.addPass(new ShaderPass(GOD));
        comp.addPass(new OutputPass());
        return { comp };
      });
      const hud = new Hud();
      const lab = hud.label('빛살 켬', 0.04, 0.95, 0, 1, 'on');
      let amount = 0;
      let auto = true;
      let dt = 0.016;
      let want = 1;
      const pos = new THREE.Vector3();
      const saved = new Map<THREE.Mesh, THREE.Material | THREE.Material[]>();
      const clear = new THREE.Color();
      return {
        scene,
        camera: cam,
        update(t, d) {
          dt = d;
          blades.rotation.z = t * 0.9;
          birds.forEach((b, i) => {
            const a = t * 0.4 + i * 1.6;
            b.position.set(Math.sin(a) * 2.2, 1.6 + Math.sin(a * 2) * 0.5, -6 + Math.cos(a) * 0.5);
            b.rotation.set(t, t * 1.3, 0);
          });
          want = auto ? (Math.floor(t / 3) % 2 === 0 ? 1 : 0) : want;
          amount += (want - amount) * Math.min(1, d * 5);
          lab.set(want ? '빛살 켬' : '빛살 끔', want ? 'on' : 'off');
        },
        render(r, w, h) {
          cam.aspect = w / h;
          cam.fov = w / h < 1 ? 62 : 42;
          cam.updateProjectionMatrix();
          const ow = Math.max(1, Math.round(w / 2));
          const oh = Math.max(1, Math.round(h / 2));
          if (rtO.width !== ow || rtO.height !== oh) rtO.setSize(ow, oh);
          // 가림 그림: 해만 하양, 나머지 검정
          const bg = scene.background;
          scene.background = null;
          for (const m of occ) {
            saved.set(m, m.material);
            m.material = black;
          }
          saved.set(sun, sun.material);
          sun.material = white;
          r.getClearColor(clear);
          const ca = r.getClearAlpha();
          r.setClearColor(0x000000, 1);
          r.setRenderTarget(rtO);
          r.clear();
          r.render(scene, cam);
          r.setRenderTarget(null);
          r.setClearColor(clear, ca);
          for (const [m, mat] of saved) m.material = mat;
          saved.clear();
          scene.background = bg;
          pos.copy(sun.position).project(cam);
          const u = GOD.uniforms;
          (u['lightPos']!.value as THREE.Vector2).set(pos.x * 0.5 + 0.5, pos.y * 0.5 + 0.5);
          u['amount']!.value = amount;
          kit.get(r, w, h).comp.render(dt);
          hud.draw(r, w, h);
        },
        controls: [
          {
            type: 'toggle',
            label: '자동 켜고 끄기',
            value: true,
            on: (v) => {
              auto = v;
              if (!v) want = 1;
            },
          },
          { type: 'range', label: '빛살 세기 (자동 끔일 때)', min: 0, max: 1, step: 1, value: 1, on: (v) => (want = v) },
        ],
        dispose() {
          kit.dispose();
          rtO.dispose();
          black.dispose();
          white.dispose();
          GOD.dispose();
          hud.dispose();
          disposeTree(scene);
        },
      };
    },
  }
```

## 관련 기술
- 같은 방법으로 만든 효과 예시: [빛기둥 · 반짝이 점](https://ai-techstudio.web.app/ai/t/u27.md) `u27` · [빛줄기 (볼류메트릭)](https://ai-techstudio.web.app/ai/t/i497.md) `i497`
- 먼저 알면 좋은 기술: [후처리 사슬 (EffectComposer → OutputPass)](https://ai-techstudio.web.app/ai/t/u21.md) `u21` · [빛 번짐 (UnrealBloom)](https://ai-techstudio.web.app/ai/t/u20.md) `u20`
- 다음에 해 볼 기술: [색 보정 LUT · 색수차 · 필름 결](https://ai-techstudio.web.app/ai/t/i07.md) `i07`
- 참고 문서: [three.js 예제 — webgl_postprocessing_godrays](https://threejs.org/examples/#webgl_postprocessing_godrays)
