# AI 꾸러미 — 스포트라이트 · 움직이는 점광 — SpotLight
> 원뿔 스포트라이트로 탁자를 훑고, 촛불 점광은 사인 두세 개로 일렁이고, 가끔 번쩍 하는 빛으로 장면에 숨결을 준다.  
> 견본: https://ai-techstudio.web.app/#t/u15

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

## 주문서

### 만들어 줘: 스포트라이트 · 움직이는 점광 — SpotLight

#### 1. 목표
어두운 방 탁자 위 동전 · 주사위에 움직이는 원뿔 스포트라이트 · 일렁이는 촛불 · 가끔 번쩍 빛을 넣어 어둡고 신비한 보물 방 분위기로 만들어 줘.

#### 2. 핵심 기술 용어
- **SpotLight** — 원뿔 모양으로 비추는 빛 (angle · penumbra · target)
- **PointLight flicker** — 점광 세기를 흔들어 촛불 · 횃불처럼
- **Visible light cone (additive volume fake)** — 빛 원뿔을 옅은 더하기 메시로 보이게
- **Light flash (exponential decay)** — 번쩍 — 세기를 지수로 빠르게 줄이기

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

#### 4. 조건
- 그림자는 스포트라이트 하나에만 (shadow.mapSize 512, bias -0.0005) — 촛불 · 번쩍 점광은 그림자 끔
- 보이는 원뿔은 MeshBasicMaterial · AdditiveBlending · opacity 0.045 · depthWrite false · DoubleSide
- 원뿔 각도를 바꾸면 보이는 원뿔 크기도 tan(각)/tan(처음 각) 비율로 함께 바꾼다
- spot.target 을 scene 에 넣어야 과녁 위치가 반영된다
- 빛 개수는 늘 같게 — 번쩍은 세기 0 으로 두었다 올리기 (빛을 넣었다 뺐다 하면 셰이더를 다시 굽는다)

#### 5. 완성 기준 (이게 보이면 성공)
- 원뿔 빛 무늬가 탁자 위를 천천히 훑고 그 아래 동전 · 주사위 그림자가 따라 움직인다
- 촛불 주변 탁자가 불규칙하게 일렁이고 불꽃 모양도 같이 늘었다 줄었다 한다
- 5초마다 장면 전체가 한 번 번쩍했다가 빠르게 어두워진다
- 「원뿔 넓이」 · 「촛불 일렁임」 조절이 바로 바뀌고, 폰에서도 부드럽다

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

## 원리
- 스포트라이트는 위치 · 과녁(target) · 원뿔 반각(angle) · 가장자리 흐림(penumbra) 넷으로 정한다. 과녁만 옮기면 빛이 훑는다.
- 빛 원뿔은 원래 안 보인다. 같은 각도의 열린 원뿔 메시를 아주 옅게(0.045) 더하기로 그려 공기 속 빛처럼 보이게 한다.
- 촛불은 빠른 사인 몇 개를 곱하고 더해 불규칙하게: 0.75 + 0.25·sin(13t)·sin(7.3t+1) + 0.1·sin(29t).
- 번쩍은 5초마다 한 번, 마지막 1초 동안 exp(-(경과)·7) 로 세기를 빠르게 줄인다.

## 핵심 코드 — 훑는 스포트 + 보이는 원뿔 + 촛불 일렁임 + 번쩍
(발췌: demos/demosLight.ts u15 make() 를 정리)
```ts
import * as THREE from 'three';

const spot = new THREE.SpotLight(0xfff3d0, 45, 12, 0.32, 0.45, 1.4); // 세기 · 거리 · 반각 · 흐림 · 감쇠
spot.position.set(0, 4, 0.6);
spot.castShadow = true;
spot.shadow.mapSize.set(512, 512);
spot.shadow.bias = -0.0005;
scene.add(spot, spot.target); // target 도 장면에

// 보이는 원뿔 — 꼭짓점이 빛 자리, 열린 원뿔을 아주 옅게 더하기
const len = 4.4;
const coneGeo = new THREE.ConeGeometry(Math.tan(0.32) * len, len, 40, 1, true).translate(0, -len / 2, 0).rotateX(-Math.PI / 2);
const cone = new THREE.Mesh(coneGeo, new THREE.MeshBasicMaterial({ color: 0xfff0c0, transparent: true, opacity: 0.045, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide }));
cone.position.copy(spot.position);
scene.add(cone);

const candleL = new THREE.PointLight(0xff9a40, 2, 4.5, 1.6);
candleL.position.set(-1.6, 0.85, 0.7);
const flash = new THREE.PointLight(0xe8f0ff, 0, 10, 1.2); // 처음부터 넣어 두고 세기만
flash.position.set(0.5, 2.2, 1.5);
scene.add(candleL, flash);

function update(t: number, ang = 0.32): void {
  spot.target.position.set(Math.sin(t * 0.8) * 1.5, 0, Math.cos(t * 0.55) * 0.7);
  spot.angle = ang;
  cone.lookAt(spot.target.position);
  const ck = spot.position.distanceTo(spot.target.position) / len;
  const cr = (ck * Math.tan(ang)) / Math.tan(0.32);
  cone.scale.set(cr, cr, ck);
  const f = 0.75 + 0.25 * Math.sin(t * 13) * Math.sin(t * 7.3 + 1) + 0.1 * Math.sin(t * 29);
  candleL.intensity = 2.2 * f;
  const ph = t % 5;
  flash.intensity = 60 * (ph > 4 ? Math.exp(-(ph - 4) * 7) : 0);
}
```

## 흔한 실수 · 확인 목록
- [ ] **그림자 켠 점광을 여러 개 두면 프레임이 무너진다** — 점광 그림자 하나는 그림자 지도 6장이다. 그림자는 스포트 하나만.
- [ ] **spot.target 을 장면에 안 넣으면 과녁을 옮겨도 빛이 안 움직인다** — scene.add(spot.target) 또는 target 을 장면 안 물체로.
- [ ] **빛을 그때그때 만들었다 지우면 화면이 멈칫한다** — 빛 개수가 바뀌면 모든 재질 셰이더를 다시 굽는다. 번쩍 빛도 처음부터 두고 세기만 바꾼다 (보물 동굴은 점광을 늘 12개로 채웠다).
- [ ] **보이는 원뿔을 진하게 하면 뿌연 원뿔 덩어리가 된다** — 더하기 섞기에 opacity 0.04 ~ 0.06 정도로 아주 옅게.

## 완성 기준 체크리스트
- [ ] 원뿔 빛 무늬가 탁자 위를 천천히 훑고 그 아래 동전 · 주사위 그림자가 따라 움직인다
- [ ] 촛불 주변 탁자가 불규칙하게 일렁이고 불꽃 모양도 같이 늘었다 줄었다 한다
- [ ] 5초마다 장면 전체가 한 번 번쩍했다가 빠르게 어두워진다
- [ ] 「원뿔 넓이」 · 「촛불 일렁임」 조절이 바로 바뀌고, 폰에서도 부드럽다

## 이 기술 정보
- id: `u15` · 분류: 그래픽 · 셰이더 › 빛 · 환경 · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의) — 스포트 그림자 지도 512² 한 장 + 점광 2개(그림자 없음). 점광에 그림자를 켜면 하나에 6장이라 폰에서 무겁다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u15
- 쓰면 좋을 때: 어두운 장면에서 시선을 한곳으로 모을 때 / 촛불 · 횃불 · 등불처럼 살아 있는 빛이 필요할 때 / 번개 · 사진기 플래시 같은 순간 연출
- 쓰지 말 때: 밝은 낮 장면 — 해 하나로 충분하다. 대신 3점 조명(u14) / 그림자 켠 점광 여러 개 — 점광 그림자 하나 = 지도 6장. 그림자는 스포트 하나만

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### u15 견본 항목 — `src/demos/demosLight.ts:940`
```ts
  u15: {
    kind: '3d',
    caption: '탁자 위를 훑는 원뿔 스포트라이트 · 일렁이는 촛불 점광 · 가끔 번쩍!',
    make() {
      const scene = new THREE.Scene();
      scene.background = gradTex('#141a3a', '#06070f');
      scene.add(new THREE.HemisphereLight(0x8090c0, 0x201810, 0.35));
      const table = mesh(new THREE.BoxGeometry(4.4, 0.2, 2.8), std(0x8a5530, 0.75), 0, -0.1, 0);
      table.receiveShadow = true;
      scene.add(table);
      const items = new THREE.Group();
      const coinM = std(0xffc24a, 0.35, 0.35);
      const coinG = new THREE.CylinderGeometry(0.22, 0.22, 0.06, 32);
      for (let i = 0; i < 9; i++) {
        const c = mesh(coinG, coinM, 0.9 + (i % 3) * 0.32 - 0.3, 0.03 + Math.floor(i / 3) * 0.062, 0.4 - (i % 2) * 0.2);
        items.add(c);
      }
      const diceM = std(0xfaf7f0, 0.35);
      const pipM = std(0x222233, 0.4);
      for (const [x, z, ry] of [
        [-0.4, 0.3, 0.4],
        [0.2, -0.5, -0.3],
      ] as const) {
        const d = mesh(new RoundedBoxGeometry(0.42, 0.42, 0.42, 3, 0.07), diceM, x, 0.21, z);
        d.rotation.y = ry;
        for (const [px, pz] of [
          [-0.1, -0.1],
          [0.1, 0.1],
          [0, 0],
          [0.1, -0.1],
          [-0.1, 0.1],
        ] as const)
          d.add(mesh(new THREE.SphereGeometry(0.035, 10, 8), pipM, px, 0.21, pz));
        items.add(d);
      }
      const ball = mesh(new THREE.SphereGeometry(0.28, 32, 24), std(0x4aa3ff, 0.35), -1.2, 0.28, -0.4);
      items.add(ball);
      // 촛불
      const candle = mesh(new THREE.CylinderGeometry(0.11, 0.12, 0.55, 24), std(0xfff4dc, 0.6), -1.6, 0.275, 0.7);
      const flame = mesh(new THREE.ConeGeometry(0.06, 0.18, 16), new THREE.MeshBasicMaterial({ color: 0xffc060 }), -1.6, 0.66, 0.7);
      items.add(candle, flame);
      items.traverse((o) => {
        o.castShadow = true;
        o.receiveShadow = true;
      });
      scene.add(items);
      const candleL = new THREE.PointLight(0xff9a40, 2, 4.5, 1.6);
      candleL.position.set(-1.6, 0.85, 0.7);
      scene.add(candleL);
      const spot = new THREE.SpotLight(0xfff3d0, 45, 12, 0.32, 0.45, 1.4);
      spot.position.set(0, 4, 0.6);
      spot.castShadow = true;
      spot.shadow.mapSize.set(512, 512);
      spot.shadow.bias = -0.0005;
      scene.add(spot, spot.target);
      // 보이는 원뿔
      const len = 4.4;
      const coneGeo = new THREE.ConeGeometry(Math.tan(0.32) * len, len, 40, 1, true);
      coneGeo.translate(0, -len / 2, 0);
      coneGeo.rotateX(-Math.PI / 2);
      const coneM = new THREE.MeshBasicMaterial({ color: 0xfff0c0, transparent: true, opacity: 0.045, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide });
      const cone = new THREE.Mesh(coneGeo, coneM);
      cone.position.copy(spot.position);
      scene.add(cone);
      const flash = new THREE.PointLight(0xe8f0ff, 0, 10, 1.2);
      flash.position.set(0.5, 2.2, 1.5);
      scene.add(flash);
      const cam = new THREE.PerspectiveCamera(36, 1, 0.1, 50);
      const hud = new Hud();
      hud.label('원뿔 빛 · 촛불 · 번쩍', 0.04, 0.95, 0, 1);
      const pop = hud.label('번쩍!', 0.5, 0.62, 0.5, 0.5, 'pop', 1.6);
      let flicker = true;
      let ang = 0.32;
      return {
        scene,
        camera: cam,
        update(t) {
          spot.target.position.set(Math.sin(t * 0.8) * 1.5, 0, Math.cos(t * 0.55) * 0.7);
          spot.angle = ang;
          cone.lookAt(spot.target.position);
          const ck = spot.position.distanceTo(spot.target.position) / len;
          const cr = (ck * Math.tan(ang)) / Math.tan(0.32);
          cone.scale.set(cr, cr, ck);
          const f = flicker ? 0.75 + 0.25 * Math.sin(t * 13) * Math.sin(t * 7.3 + 1) + 0.1 * Math.sin(t * 29) : 1;
          candleL.intensity = 2.2 * f;
          flame.scale.set(1, 0.8 + f * 0.35, 1);
          flame.rotation.z = Math.sin(t * 9) * 0.12;
          const ph = t % 5;
          const fl = ph > 4 ? Math.exp(-(ph - 4) * 7) : 0;
          flash.intensity = 60 * fl;
          pop.visible = fl > 0.25;
          ball.position.x = -1.2 + Math.sin(t * 0.9) * 0.3;
        },
        render(r, w, h) {
          fit(cam, w / h, V3(0, 0.1, 0.1), 2.35, V3(0, 1.05, 1.25));
          withShadows(r, () => r.render(scene, cam));
          hud.draw(r, w, h);
        },
        controls: [
          { type: 'range', label: '원뿔 넓이', min: 0.12, max: 0.7, step: 0.01, value: 0.32, on: (v) => (ang = v) },
          { type: 'toggle', label: '촛불 일렁임', value: true, on: (v) => (flicker = v) },
        ],
        dispose() {
          hud.dispose();
          disposeTree(scene);
          spot.shadow.map?.dispose();
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [3점 조명 (반구 + 해 + 테두리)](https://ai-techstudio.web.app/ai/t/u14.md) `u14` · [그림자 맵 (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/i433.md) `i433`
- 참고 문서: [three.js 예제 — webgl_lights_spotlight](https://threejs.org/examples/#webgl_lights_spotlight) · [three.js 문서 — SpotLight](https://threejs.org/docs/#api/en/lights/SpotLight)
