# AI 꾸러미 — 실내 조명 (창 빛 · 전등) — Interior lighting (window light + practical lamp)
> 창으로 드는 해 그림자 · 보이는 빛 기둥 · 그 안을 떠도는 먼지 · 따뜻한 전등 · 바닥에서 튀는 반사광 흉내를 겹쳐 아늑한 방을 밝힌다.  
> 견본: https://ai-techstudio.web.app/#t/i433

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

## 주문서

### 만들어 줘: 실내 조명 (창 빛 · 전등) — Interior lighting (window light + practical lamp)

#### 1. 목표
아이 방 · 공방을 오후 햇살 아늑하게 실내 조명으로 밝혀 줘 — 창 빛 기둥 · 먼지 · 따뜻한 전등 · 반사광 흉내까지.

#### 2. 핵심 기술 용어
- **Interior lighting (window light + practical lamp)** — 창 빛과 방 안 전등으로 꾸민 실내 조명
- **Fake light shaft (additive volume)** — 창에서 바닥까지 이은 상자를 옅게 더해 그린 빛 기둥
- **Dust motes in light beam** — 빛 기둥 안에서만 보이는 먼지 입자
- **Fake bounce light** — 바닥에 빛이 닿은 곳에 약한 점광을 두어 반사광 흉내

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

#### 4. 조건
- 그림자는 창 쪽 방향 빛 하나에만 — 전등 · 반사광 점광은 그림자 끔
- 천장은 colorWrite false · depthWrite false 상자로 두어 보이지 않게, 그림자만 드리우게 (방 안이 위에서 새는 빛으로 밝아지지 않게)
- 빛 기둥은 AdditiveBlending · depthWrite false · DoubleSide, 세기 = 면이 카메라를 볼수록 (pow(|N·V|, 1.6))
- 먼지는 시간에 따라 0 ~ 1 을 돌며 끝에서 사라지게 (smoothstep 양끝)
- NeutralToneMapping 으로 — 밝은 창 빛이 하얗게 날아가지 않게

#### 5. 완성 기준 (이게 보이면 성공)
- 바닥 · 깔개 위에 창 모양 빛이 떨어지고, 창에서 그 빛까지 옅은 빛 기둥이 보인다
- 빛 기둥 안에서만 먼지가 반짝이며 천천히 떠간다
- 해 각도가 바뀌면 창 빛 · 기둥 · 먼지 · 반사광 자리가 함께 옮겨 간다
- 「따뜻한 전등」 · 「반사광 흉내」를 각각 끄고 켜면 방 구석 밝기가 눈에 띄게 달라진다

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

## 원리
- 창으로 드는 빛은 방향 빛 하나(세기 3.5, 그림자 1024²)다. 벽에 창 구멍이 뚫려 있어 바닥에 창 모양 빛이 떨어진다.
- 보이는 빛 기둥: 창 네 귀퉁이에서 빛 방향으로 바닥까지 늘인 점 넷을 이어 상자를 만들고, 옆에서 볼수록 옅게 더하기로 그린다.
- 먼지는 창 면 위 무작위 점에서 빛 방향을 따라 천천히 흐르게 정점 셰이더로 옮긴다 — 기둥 안에만 있다.
- 진짜 반사광 계산 대신, 빛이 바닥에 닿은 가운데 바로 위(0.35)에 약한 주황 점광(2.2)을 두어 둘레를 은은히 밝힌다.

## 핵심 코드 — 창 빛 기둥 꼭짓점 + 반사광 흉내 (해 방향이 바뀔 때마다)
(발췌: demos/demosCamLight.ts demoIndoor() 의 update 를 정리)
```ts
import * as THREE from 'three';

// 창 네 귀퉁이 (뒤 벽 z = zb, 창 x wx0 ~ wx1, y wy0 ~ wy1)
const cs = [[wx0, wy0], [wx1, wy0], [wx1, wy1], [wx0, wy1]];
const shaftGeo = new THREE.BufferGeometry();
const shaftPos = new THREE.BufferAttribute(new Float32Array(8 * 3), 3);
shaftGeo.setAttribute('position', shaftPos);
shaftGeo.setAttribute('aT', new THREE.BufferAttribute(new Float32Array([0, 0, 0, 0, 1, 1, 1, 1]), 1)); // 0 창 · 1 바닥
shaftGeo.setIndex([0, 1, 5, 0, 5, 4, 1, 2, 6, 1, 6, 5, 2, 3, 7, 2, 7, 6, 3, 0, 4, 3, 4, 7]);
const bounce = new THREE.PointLight(0xffc890, 1.5, 5, 2);
scene.add(bounce);
const dir = new THREE.Vector3();

function update(ang: number): void {
  dir.set(-0.55 + 1.2 * ang, -(0.62 + 0.28 * (Math.sin(ang * Math.PI) * 0.5 + 0.25)), 1).normalize();
  sun.target.position.set(wc, 0, 0);
  sun.position.copy(sun.target.position).addScaledVector(dir, -15);
  for (let i = 0; i < 4; i++) {
    const [x, y] = cs[i]!;
    shaftPos.setXYZ(i, x!, y!, zb);                         // 창 귀퉁이
    const L = y! / -dir.y;                                  // 바닥까지 거리
    shaftPos.setXYZ(i + 4, x! + dir.x * L, 0.01, zb + dir.z * L);
  }
  shaftPos.needsUpdate = true;
  shaftGeo.computeVertexNormals();
  // 반사광 흉내 — 창 가운데(높이 1.75) 빛이 바닥에 닿는 곳 바로 위
  const mid = 1.75 / -dir.y;
  bounce.position.set(wc + dir.x * mid, 0.35, zb + dir.z * mid);
  bounce.intensity = 2.2;
}
// 기둥 조각 셰이더: face = pow(abs(dot(N, V)), 1.6);
//   a = face * (0.17 + 0.13 * (1.0 - vT)) * smoothstep(1.0, 0.8, vT);  // 창 쪽이 진하고 바닥 끝은 사라짐
```

## 흔한 실수 · 확인 목록
- [ ] **천장을 안 두면 방 안이 위에서 드는 해빛으로 다 밝아진다** — 보이지 않는 천장(colorWrite false)을 두고 그림자만 드리우게 해 창으로만 빛이 든다.
- [ ] **빛 기둥을 진하게 그리면 뿌연 유리 상자처럼 보인다** — 면이 카메라를 볼수록만 옅게(0.17 ~ 0.3) 더하고, 바닥 끝은 smoothstep 으로 사라지게.
- [ ] **가장자리 흐림 · 비네트로 분위기를 내면 안 된다** — 이 사이트 결정 — 얼버무리는 후처리 대신 빛 배치(창 빛 · 전등 · 반사광)로 분위기를 만든다.

## 완성 기준 체크리스트
- [ ] 바닥 · 깔개 위에 창 모양 빛이 떨어지고, 창에서 그 빛까지 옅은 빛 기둥이 보인다
- [ ] 빛 기둥 안에서만 먼지가 반짝이며 천천히 떠간다
- [ ] 해 각도가 바뀌면 창 빛 · 기둥 · 먼지 · 반사광 자리가 함께 옮겨 간다
- [ ] 「따뜻한 전등」 · 「반사광 흉내」를 각각 끄고 켜면 방 구석 밝기가 눈에 띄게 달라진다

## 이 기술 정보
- id: `i433` · 분류: 그래픽 · 셰이더 › 빛 · 환경 · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의) — 그림자 지도 1024² 하나 + 점광 2개(전등 · 반사광, 그림자 없음) + 빛 기둥 상자 1개 · 먼지 140점. 기둥 꼭짓점 8개를 매 장면 다시 계산.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i433
- 쓰면 좋을 때: 방 · 공방 · 교실 같은 실내 무대 / 따뜻하고 아늑한 분위기가 필요한 장면 / 해가 움직이며 창 빛이 옮겨 가는 연출
- 쓰지 말 때: 넓은 바깥 장면 — 대신 3점 조명(u14) · 하루 흐름(i431) / 진짜 빛 계산이 필요한 건축 시각화 — 대신 미리 구운 빛 지도

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### demoIndoor — `src/demos/demosCamLight.ts:1917`
```ts
function demoIndoor(): Scene3D {
  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0x141826);
  const floorTex = canvasTex(
    512,
    512,
    (g) => {
      const r = rng(3);
      for (let i = 0; i < 8; i++) {
        const base = 150 + r() * 30;
        g.fillStyle = `rgb(${base + 40},${base - 10},${base - 60})`;
        g.fillRect(0, i * 64, 512, 64);
        for (let k = 0; k < 40; k++) {
          g.fillStyle = `rgba(90,50,20,${0.04 + r() * 0.06})`;
          g.fillRect(r() * 512, i * 64 + r() * 60, 40 + r() * 120, 1 + r() * 2);
        }
        g.fillStyle = 'rgba(60,30,10,0.55)';
        g.fillRect(0, i * 64 + 62, 512, 2);
        const cut = r() * 512;
        g.fillRect(cut, i * 64, 2, 64);
      }
    },
    true,
  );
  floorTex.repeat.set(2, 2);
  const wallTex = canvasTex(
    256,
    256,
    (g) => {
      g.fillStyle = '#f2e4d2';
      g.fillRect(0, 0, 256, 256);
      for (let i = 0; i < 8; i++) {
        g.fillStyle = i % 2 ? 'rgba(232,180,170,0.35)' : 'rgba(255,255,255,0.25)';
        g.fillRect(i * 32, 0, 16, 256);
      }
      g.fillStyle = 'rgba(214,140,150,0.5)';
      for (let y = 16; y < 256; y += 48) for (let x = 24; x < 256; x += 64) {
        g.beginPath();
        g.arc(x, y, 3, 0, TAU);
        g.fill();
      }
    },
    true,
  );
  wallTex.repeat.set(3, 1.5);
  const rugTex = canvasTex(256, 256, (g) => {
    const cols = ['#d9534f', '#f6d36b', '#5b8def', '#f6efe2', '#d9534f'];
    for (let i = 0; i < 5; i++) {
      g.fillStyle = cols[i]!;
      g.beginPath();
      g.arc(128, 128, 126 - i * 24, 0, TAU);
      g.fill();
    }
  });
  const floorM = new THREE.MeshStandardMaterial({ map: floorTex, roughness: 0.55 });
  const wallM = new THREE.MeshStandardMaterial({ map: wallTex, roughness: 0.9 });
  const trimM = std(0xfaf6ee, 0.6);
  const woodM = std(0x9a6438, 0.6);
  const darkWood = std(0x6b4128, 0.7);
  const add = (geo: THREE.BufferGeometry, mat: THREE.Material, x: number, y: number, z: number, sx = 1, sy = 1, sz = 1, cast = true): THREE.Mesh => {
    const m = new THREE.Mesh(geo, mat);
    m.position.set(x, y, z);
    m.scale.set(sx, sy, sz);
    m.castShadow = cast;
    m.receiveShadow = true;
    scene.add(m);
    return m;
  };
  const box = new THREE.BoxGeometry(1, 1, 1);
  const rb = new RoundedBoxGeometry(1, 1, 1, 3, 0.15);
  const W = 8;
  const D = 6;
  const H = 3.2;
  add(box, floorM, 0, -0.1, 0, W, 0.2, D, false);
  /* 뒤 벽 (창 구멍) */
  const wx0 = -0.6;
  const wx1 = 1.4;
  const wy0 = 1.0;
  const wy1 = 2.5;
  const zb = -D / 2;
  add(box, wallM, (-W / 2 + wx0) / 2, H / 2, zb, wx0 + W / 2, H, 0.2);
  add(box, wallM, (wx1 + W / 2) / 2, H / 2, zb, W / 2 - wx1, H, 0.2);
  add(box, wallM, (wx0 + wx1) / 2, wy0 / 2, zb, wx1 - wx0, wy0, 0.2);
  add(box, wallM, (wx0 + wx1) / 2, (wy1 + H) / 2, zb, wx1 - wx0, H - wy1, 0.2);
  const wc = (wx0 + wx1) / 2;
  add(box, trimM, wc, wy0 - 0.04, zb + 0.16, wx1 - wx0 + 0.3, 0.08, 0.32);
  add(box, trimM, wc, (wy0 + wy1) / 2, zb, 0.06, wy1 - wy0, 0.12);
  add(box, trimM, wc, (wy0 + wy1) / 2, zb, wx1 - wx0, 0.06, 0.12);
  for (const x of [wx0, wx1]) add(box, trimM, x, (wy0 + wy1) / 2, zb + 0.02, 0.08, wy1 - wy0 + 0.1, 0.24);
  add(box, trimM, wc, wy1 + 0.04, zb + 0.02, wx1 - wx0 + 0.16, 0.08, 0.24);
  /* 왼쪽 벽 */
  add(box, wallM, -W / 2, H / 2, 0, 0.2, H, D);
  add(box, trimM, -W / 2 + 0.12, 0.08, 0, 0.04, 0.16, D);
  add(box, trimM, 0, 0.08, zb + 0.12, W, 0.16, 0.04);
  /* 보이지 않는 천장 (그림자만) */
  const ceil = add(box, new THREE.MeshBasicMaterial({ colorWrite: false, depthWrite: false }), 0, H + 0.1, 0, W + 2, 0.2, D + 2, true);
  ceil.receiveShadow = false;
  /* 창밖 */
  const outTex = canvasTex(256, 192, (g) => {
    const gr = g.createLinearGradient(0, 0, 0, 192);
    gr.addColorStop(0, '#6fb6ff');
    gr.addColorStop(1, '#e6f4ff');
    g.fillStyle = gr;
    g.fillRect(0, 0, 256, 192);
    g.fillStyle = '#ffffff';
    for (const [x, y, r] of [
      [60, 50, 22],
      [85, 45, 28],
      [110, 55, 20],
    ]) {
      g.beginPath();
      g.arc(x!, y!, r!, 0, TAU);
      g.fill();
    }
    g.fillStyle = '#5fbf63';
    for (const [x, r] of [
      [30, 50],
      [200, 60],
      [250, 45],
    ]) {
      g.beginPath();
      g.arc(x!, 192, r!, 0, TAU);
      g.fill();
    }
  });
  const outside = new THREE.Mesh(new THREE.PlaneGeometry(wx1 - wx0 + 0.8, wy1 - wy0 + 0.6), new THREE.MeshBasicMaterial({ map: outTex }));
  outside.position.set(wc, (wy0 + wy1) / 2, zb - 0.45);
  scene.add(outside);
  /* 가구 */
  const rug = new THREE.Mesh(new THREE.CircleGeometry(1.4, 48).rotateX(-Math.PI / 2), new THREE.MeshStandardMaterial({ map: rugTex, roughness: 0.95 }));
  rug.position.set(0.9, 0.012, 0.9);
  rug.receiveShadow = true;
  scene.add(rug);
  /* 책장 */
  add(rb, woodM, -W / 2 + 0.4, 1.0, -1.2, 0.6, 2.0, 1.8);
  const bookC = [0xe8574a, 0x4a8fe8, 0xf2c14e, 0x3fb37f, 0x9b6ae8, 0xff8fb1];
  const r1 = rng(9);
  for (let s = 0; s < 3; s++) {
    add(box, darkWood, -W / 2 + 0.72, 0.45 + s * 0.6, -1.2, 0.06, 0.04, 1.6, false);
    let z = -1.95;
    while (z < -0.5) {
      const bw = 0.08 + r1() * 0.06;
      const bh = 0.32 + r1() * 0.18;
      add(box, std(bookC[Math.floor(r1() * bookC.length)]!, 0.7), -W / 2 + 0.7, 0.47 + s * 0.6 + bh / 2, z + bw / 2, 0.06, bh, bw * 0.9);
      z += bw + 0.01;
    }
  }
  /* 안락의자 */
  const chairM = std(0x5aa0d8, 0.85);
  add(rb, chairM, 2.9, 0.35, -1.3, 1.3, 0.5, 1.2);
  add(rb, chairM, 2.9, 0.85, -1.82, 1.3, 1.0, 0.3);
  add(rb, chairM, 2.32, 0.6, -1.3, 0.22, 0.6, 1.2);
  add(rb, chairM, 3.48, 0.6, -1.3, 0.22, 0.6, 1.2);
  add(rb, std(0xffe08a, 0.9), 2.9, 0.7, -1.25, 0.5, 0.3, 0.2);
  /* 작은 탁자 + 스탠드 */
  const cyl = new THREE.CylinderGeometry(1, 1, 1, 28);
  add(cyl, woodM, -0.9, 0.62, 1.3, 0.55, 0.06, 0.55);
  add(cyl, darkWood, -0.9, 0.31, 1.3, 0.06, 0.62, 0.06);
  add(cyl, darkWood, -0.9, 0.02, 1.3, 0.3, 0.04, 0.3);
  add(cyl, std(0x3a3f55, 0.4, 0.5), -0.9, 0.9, 1.3, 0.03, 0.5, 0.03);
  const shadeM = new THREE.MeshStandardMaterial({ color: 0xfff0c8, roughness: 0.8, emissive: 0xffb050, emissiveIntensity: 0.9, side: THREE.DoubleSide });
  const shade = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.34, 0.34, 28, 1, true), shadeM);
  shade.position.set(-0.9, 1.25, 1.3);
  scene.add(shade);
  const glow = glowTex();
  const bulbHalo = new THREE.Sprite(new THREE.SpriteMaterial({ map: glow, color: 0xffb050, blending: THREE.AdditiveBlending, depthWrite: false, transparent: true, opacity: 0.8 }));
  bulbHalo.position.set(-0.9, 1.2, 1.3);
  bulbHalo.scale.setScalar(1.6);
  scene.add(bulbHalo);
  const lamp = new THREE.PointLight(0xffa850, 6, 7, 2);
  lamp.position.set(-0.9, 1.15, 1.3);
  scene.add(lamp);
  /* 화분 · 액자 · 고양이 쿠션 */
  add(cyl, std(0xd97b4a, 0.8), 2.0, 0.22, -2.5, 0.22, 0.44, 0.22);
  const leaf = std(0x4fae4a, 0.7);
  const sp = new THREE.SphereGeometry(1, 16, 12);
  add(sp, leaf, 2.0, 0.75, -2.5, 0.38, 0.42, 0.38);
  add(sp, leaf, 1.8, 0.95, -2.4, 0.25, 0.25, 0.25);
  add(box, darkWood, -2.2, 1.9, zb + 0.12, 1.0, 0.75, 0.05);
  add(box, std(0xffd27a, 0.8), -2.2, 1.9, zb + 0.15, 0.85, 0.6, 0.02);
  add(sp, std(0xff9bb3, 0.9), 0.5, 0.12, 0.9, 0.35, 0.14, 0.3);
  /* 빛 */
  const hemi = new THREE.HemisphereLight(0xbcd0ff, 0x6b4a30, 0.28);
  scene.add(hemi);
  const sun = new THREE.DirectionalLight(0xfff0d0, 3.5);
  sun.castShadow = true;
  sun.shadow.mapSize.set(1024, 1024);
  const sc = sun.shadow.camera;
  sc.left = sc.bottom = -6;
  sc.right = sc.top = 6;
  sc.near = 1;
  sc.far = 40;
  sun.shadow.bias = -0.0008;
  sun.shadow.normalBias = 0.03;
  scene.add(sun, sun.target);
  const bounce = new THREE.PointLight(0xffc890, 1.5, 5, 2);
  scene.add(bounce);
  /* 빛 기둥 */
  const shaftGeo = new THREE.BufferGeometry();
  const shaftPos = new THREE.BufferAttribute(new Float32Array(8 * 3), 3);
  const shaftT = new THREE.BufferAttribute(new Float32Array([0, 0, 0, 0, 1, 1, 1, 1]), 1);
  shaftGeo.setAttribute('position', shaftPos);
  shaftGeo.setAttribute('aT', shaftT);
  shaftGeo.setIndex([0, 1, 5, 0, 5, 4, 1, 2, 6, 1, 6, 5, 2, 3, 7, 2, 7, 6, 3, 0, 4, 3, 4, 7]);
  const su = { uK: { value: 1 } };
  const shaft = new THREE.Mesh(
    shaftGeo,
    new THREE.ShaderMaterial({
      uniforms: su,
      transparent: true,
      depthWrite: false,
      side: THREE.DoubleSide,
      blending: THREE.AdditiveBlending,
      vertexShader: /* glsl */ `
        attribute float aT; varying float vT; varying vec3 vN; varying vec3 vV;
        void main(){
          vT = aT;
          vec4 wp = modelMatrix * vec4(position,1.0);
          vV = normalize(cameraPosition - wp.xyz);
          vN = normalize(mat3(modelMatrix) * normal);
          gl_Position = projectionMatrix * viewMatrix * wp;
        }`,
      fragmentShader: /* glsl */ `
        uniform float uK; varying float vT; varying vec3 vN; varying vec3 vV;
        void main(){
          float face = pow(abs(dot(normalize(vN), normalize(vV))), 1.6);
          float a = face * (0.17 + 0.13 * (1.0 - vT)) * smoothstep(1.0, 0.8, vT) * uK;
          gl_FragColor = vec4(vec3(1.0, 0.9, 0.7) * a, 1.0);
        }`,
    }),
  );
  shaft.frustumCulled = false;
  scene.add(shaft);
  /* 먼지 */
  const N = 140;
  const dg = new THREE.BufferGeometry();
  const seeds = new Float32Array(N * 3);
  const r2 = rng(33);
  for (let i = 0; i < N * 3; i++) seeds[i] = r2();
  dg.setAttribute('position', new THREE.BufferAttribute(seeds, 3));
  const du = {
    uO: { value: new THREE.Vector3(wx0, wy0, zb) },
    uEx: { value: new THREE.Vector3(wx1 - wx0, 0, 0) },
    uEy: { value: new THREE.Vector3(0, wy1 - wy0, 0) },
    uDir: { value: new THREE.Vector3() },
    uTime: { value: 0 },
    uPx: { value: 300 },
    uK: { value: 1 },
  };
  const motes = new THREE.Points(
    dg,
    new THREE.ShaderMaterial({
      uniforms: du,
      transparent: true,
      depthWrite: false,
      blending: THREE.AdditiveBlending,
      vertexShader: /* glsl */ `
        uniform vec3 uO, uEx, uEy, uDir; uniform float uTime, uPx;
        varying float vA;
        void main(){
          vec3 c = uO + uEx * position.x + uEy * position.y;
          float L = c.y / -uDir.y;
          float s = fract(position.z + uTime * 0.025);
          vec3 p = c + uDir * L * s;
          p += vec3(sin(uTime*0.6 + position.x*40.0), sin(uTime*0.45 + position.y*30.0), cos(uTime*0.5 + position.z*20.0)) * 0.06;
          vA = smoothstep(0.0, 0.12, s) * smoothstep(1.0, 0.8, s) * (0.4 + 0.6 * fract(position.x * 13.7));
          vec4 mv = viewMatrix * vec4(p, 1.0);
          gl_PointSize = uPx * 0.022 / -mv.z;
          gl_Position = projectionMatrix * mv;
        }`,
      fragmentShader: /* glsl */ `
        uniform float uK; varying float vA;
        void main(){
          float d = length(gl_PointCoord - 0.5);
          float a = smoothstep(0.5, 0.1, d) * vA * uK;
          gl_FragColor = vec4(vec3(1.0, 0.92, 0.75) * a, 1.0);
        }`,
    }),
  );
  motes.frustumCulled = false;
  scene.add(motes);
  const cam = new THREE.PerspectiveCamera(40, 1.6, 0.1, 100);
  cam.position.set(5.6, 4.3, 6.6);
  cam.lookAt(-0.3, 0.9, -0.6);
  const hud = new Hud();
  const st = { sunK: 1, auto: true, ang: 0.5, lamp: true, bounce: true };
  const dir = new THREE.Vector3();
  const cs = [
    [wx0, wy0],
    [wx1, wy0],
    [wx1, wy1],
    [wx0, wy1],
  ];
  return {
    scene,
    camera: cam,
    tone: THREE.NeutralToneMapping,
    update(t) {
      const ang = st.auto ? 0.5 + Math.sin(t * 0.45) * 0.5 : st.ang;
      dir.set(lerp(-0.55, 0.65, ang), -lerp(0.62, 0.9, Math.sin(ang * Math.PI) * 0.5 + 0.25), 1).normalize();
      sun.target.position.set(wc, 0, 0);
      sun.position.copy(sun.target.position).addScaledVector(dir, -15);
      sun.intensity = 3.6 * st.sunK;
      for (let i = 0; i < 4; i++) {
        const [x, y] = cs[i]!;
        shaftPos.setXYZ(i, x!, y!, zb);
        const L = y! / -dir.y;
        shaftPos.setXYZ(i + 4, x! + dir.x * L, 0.01, zb + dir.z * L);
      }
      shaftPos.needsUpdate = true;
      shaftGeo.computeVertexNormals();
      su.uK.value = st.sunK;
      du.uDir.value.copy(dir);
      du.uTime.value = t;
      du.uK.value = st.sunK;
      const mid = 1.75 / -dir.y;
      bounce.position.set(wc + dir.x * mid, 0.35, zb + dir.z * mid);
      bounce.intensity = st.bounce ? 2.2 * st.sunK : 0;
      const fl = 1 + Math.sin(t * 13) * 0.02 + Math.sin(t * 7.3) * 0.02;
      lamp.intensity = st.lamp ? 6 * fl : 0;
      shadeM.emissiveIntensity = st.lamp ? 0.9 : 0.05;
      bulbHalo.visible = st.lamp;
      hemi.intensity = st.bounce ? 0.3 : 0.15;
    },
    render(r, w, h) {
      du.uPx.value = h;
      withState(r, () => {
        r.render(scene, cam);
        const u = Math.min(1.9, w / 280, h / 175);
        const parts = [st.sunK > 0.05 ? '창 빛' : '', st.lamp ? '전등' : '', st.bounce ? '반사광' : ''].filter(Boolean).join(' + ');
        hud.label('a', parts || '빛 없음', 10 * u, 10 * u, 16 * u, { bg: 'rgba(14,20,44,0.75)' });
        hud.draw(r, w, h);
      });
    },
    controls: [
      { type: 'range', label: '창 빛 세기', min: 0, max: 2, step: 0.05, value: 1, on: (v) => (st.sunK = v) },
      { type: 'range', label: '해 각도 (움직이면 자동 멈춤)', min: 0, max: 1, step: 0.01, value: 0.5, on: (v) => ((st.ang = v), (st.auto = false)) },
      { type: 'toggle', label: '따뜻한 전등', value: true, on: (v) => (st.lamp = v) },
      { type: 'toggle', label: '반사광 흉내 (바닥에서 튀는 빛)', value: true, on: (v) => (st.bounce = v) },
    ] as Control[],
    dispose() {
      disposeTree(scene);
      box.dispose();
      rb.dispose();
      hud.dispose();
    },
  };
}
```

### i433 견본 항목 — `src/demos/demosCamLight.ts:2483`
```ts
  i433: { kind: '3d', caption: '창으로 드는 빛 기둥 + 떠도는 먼지 + 따뜻한 전등 + 바닥 반사광 흉내', make: () => demoIndoor() }
```

## 관련 기술
- 먼저 알면 좋은 기술: [스포트라이트 · 움직이는 점광](https://ai-techstudio.web.app/ai/t/u15.md) `u15` · [그림자 맵 (PCF · PCF Soft)](https://ai-techstudio.web.app/ai/t/u17.md) `u17`
- 다음에 해 볼 기술: [빛살 (God rays)](https://ai-techstudio.web.app/ai/t/i06.md) `i06` · [밤 장면 (어둠 속 빛나는 것만)](https://ai-techstudio.web.app/ai/t/i432.md) `i432`
