# AI 꾸러미 — 장면 전환 카메라 (궤도 이동) — Orbit camera transition (spherical interpolation)
> 장소마다 바라볼 곳 · 거리 · 방위 · 높이를 정해 두고 그 값들을 구면 좌표로 보간하며 가운데에서 위로 부풀려서, 장면 사이를 원호로 날아가며 바라보는 곳도 함께 옮긴다.  
> 견본: https://ai-techstudio.web.app/#t/i430

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

## 주문서

### 만들어 줘: 장면 전환 카메라 (궤도 이동) — Orbit camera transition (spherical interpolation)

#### 1. 목표
마을 전체 → 집 → 연못 같은 장소 사이를 오갈 때 카메라가 순간 이동 대신 원호로 날아가며 바라보는 곳도 함께 옮기게 해 줘. 분위기는 맑은 마을 하늘.

#### 2. 핵심 기술 용어
- **Orbit camera transition (spherical interpolation)** — 궤도 이동 — 구면 좌표로 카메라 옮기기
- **Spherical coordinates (radius · yaw · elevation)** — 거리 · 방위각 · 올려본 각
- **Shortest-angle wrap** — 각도를 짧은 쪽으로 돌기
- **Arc bump (sin π e)** — 가운데에서 높이 · 거리를 부풀려 둥근 길

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

#### 4. 조건
- 장소는 바라볼 곳 · 거리 · 방위 · 높이각 값으로 정의
- 자리를 직선 lerp 하지 않고 구면 값(r · yaw · el)을 보간, 바라볼 곳도 함께 lerp
- yaw 는 짧은 쪽으로 돌기
- 가운데 부풀림(sin π e)으로 둥근 길
- 순간 · 직선 · 궤도 비교와 빠르기 조절

#### 5. 완성 기준 (이게 보이면 성공)
- 마을 전체 → 집 → 연못 → 다른 집으로 카메라가 위로 떠올랐다 내려앉으며 날아간다
- 날아가는 동안 바라보는 곳이 부드럽게 옮겨 가 화면이 휙 돌지 않는다
- 작은 지도에서 흰 원호(궤도)와 빨간 직선이 비교된다
- 「순간 이동」 · 「직선 이동」으로 바꾸면 차이가 바로 보인다

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

## 원리
- 장소 하나 = 바라볼 곳(target) · 거리 r · 방위 yaw · 높이각 el. 카메라 자리 = target + (sin yaw·cos el, sin el, cos yaw·cos el) × r.
- 이동 중에는 바라볼 곳을 lerp 하고, r · yaw · el 을 따로 보간한다. 자리를 직선으로 lerp 하면 카메라가 물체를 뚫거나 바라보는 방향이 휙 돈다.
- yaw 차이는 ((차 + π) mod 2π) − π 로 짧은 쪽으로 돈다.
- 가운데에서 bump = sin(π·e) 만큼 높이각 +0.32 · 거리 +5 를 더해, 위로 떠올랐다 내려앉는 둥근 길이 된다. e 는 smoothstep, 이동 1.7초.

## 핵심 코드 — 구면 좌표 보간 + 가운데 부풀림
(발췌: demos/demosCamLight.ts demoOrbit() camAt() 을 정리)
```ts
interface Station { name: string; target: THREE.Vector3; r: number; yaw: number; el: number }
const TAU = Math.PI * 2;
const lerp = (a: number, b: number, k: number) => a + (b - a) * k;
const ease = (k: number) => (k <= 0 ? 0 : k >= 1 ? 1 : k * k * (3 - 2 * k));

// k = 0 → 1 (이동 1.7초). out = 카메라 자리, look = 바라볼 곳
function camAt(a: Station, b: Station, k: number, out: THREE.Vector3, look: THREE.Vector3): void {
  const e = ease(k);
  look.lerpVectors(a.target, b.target, e);
  let dy = b.yaw - a.yaw;
  dy = (((dy + Math.PI) % TAU) + TAU) % TAU - Math.PI;   // 짧은 쪽으로
  const yaw = a.yaw + dy * e;
  const bump = Math.sin(Math.PI * e);                    // 가운데에서 부풀림
  const el = lerp(a.el, b.el, e) + bump * 0.32;
  const r = lerp(a.r, b.r, e) + bump * 5;
  out.set(
    look.x + Math.sin(yaw) * Math.cos(el) * r,
    look.y + Math.sin(el) * r,
    look.z + Math.cos(yaw) * Math.cos(el) * r,
  );
}
// 매 프레임: camAt(a, b, k, cam.position, tg); cam.lookAt(tg);
// 장소 예: { name: '마을 전체', target: new THREE.Vector3(0, 0, 0), r: 17, yaw: 0.5, el: 0.72 }
```

## 흔한 실수 · 확인 목록
- [ ] **카메라 자리를 직선으로 lerp 하면 가운데에서 물체를 뚫는다** — 구면 값(r · yaw · el)을 보간하고 가운데에서 거리 · 높이를 부풀린다.
- [ ] **방위각을 그냥 lerp 하면 반대쪽으로 거의 한 바퀴를 돈다** — 차이를 −π ~ π 로 접어 짧은 쪽으로 돈다.
- [ ] **바라볼 곳을 끝에서만 바꾸면 화면이 휙 돈다** — 바라볼 곳도 같은 e 로 lerp 한다.

## 완성 기준 체크리스트
- [ ] 마을 전체 → 집 → 연못 → 다른 집으로 카메라가 위로 떠올랐다 내려앉으며 날아간다
- [ ] 날아가는 동안 바라보는 곳이 부드럽게 옮겨 가 화면이 휙 돌지 않는다
- [ ] 작은 지도에서 흰 원호(궤도)와 빨간 직선이 비교된다
- [ ] 「순간 이동」 · 「직선 이동」으로 바꾸면 차이가 바로 보인다

## 이 기술 정보
- id: `i430` · 분류: 이펙트 · 연출 › 카메라 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 삼각 함수 몇 번. 비용 없음.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i430
- 쓰면 좋을 때: 장소 · 장면이 바뀔 때 어디서 어디로 갔는지 보여 줄 때 / 메뉴에서 판으로 들어가는 첫 장면
- 쓰지 말 때: 자주 오가는 화면 — 1.7초 날기가 답답하다. 짧게(0.6초) 하거나 순간 이동 / 멀미가 나기 쉬운 크게 도는 길 — bump 를 줄인다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### demoOrbit — `src/demos/demosCamLight.ts:1566`
```ts
function demoOrbit(): Scene3D {
  const { scene, cam, vil } = villageWorld();
  const h0 = HOUSES[0]!;
  const h2 = HOUSES[2]!;
  const ST: Station[] = [
    { name: '마을 전체', target: new THREE.Vector3(0, 0, 0), r: 17, yaw: 0.5, el: 0.72 },
    { name: h0.name, target: new THREE.Vector3(h0.x, 0.9, h0.z), r: 5, yaw: h0.rot + 0.25, el: 0.32 },
    { name: '연못', target: new THREE.Vector3(POND.x, 0.2, POND.z), r: 5.5, yaw: 0.9, el: 0.55 },
    { name: h2.name, target: new THREE.Vector3(h2.x, 1.0, h2.z), r: 5.2, yaw: h2.rot - 0.2 + TAU, el: 0.3 },
  ];
  const mm = mapFor(vil);
  const straight = mm.line(0xff6b8a, 0.6, 2);
  const arcLine = mm.line(0xffffff, 0.85, 48);
  const tgtDot = mm.dot(0x7cf0ff, 0.28);
  mm.camera();
  const hud = new Hud();
  const st = { mode: 2, speed: 1, showStraight: true };
  const pa = new THREE.Vector3();
  const pb = new THREE.Vector3();
  const tg = new THREE.Vector3();
  const sph = (s: Station, out: THREE.Vector3): THREE.Vector3 =>
    out.set(s.target.x + Math.sin(s.yaw) * Math.cos(s.el) * s.r, s.target.y + Math.sin(s.el) * s.r, s.target.z + Math.cos(s.yaw) * Math.cos(s.el) * s.r);
  const camAt = (a: Station, b: Station, k: number, out: THREE.Vector3, look: THREE.Vector3): void => {
    const e = ease(k);
    if (st.mode === 0) {
      const s = k < 0.5 ? a : b;
      look.copy(s.target);
      sph(s, out);
      return;
    }
    look.lerpVectors(a.target, b.target, e);
    if (st.mode === 1) {
      sph(a, pa);
      sph(b, pb);
      out.lerpVectors(pa, pb, e);
      return;
    }
    let dy = b.yaw - a.yaw;
    dy = ((dy + Math.PI) % TAU + TAU) % TAU - Math.PI;
    const yaw = a.yaw + dy * e;
    const bump = Math.sin(Math.PI * e);
    const el = lerp(a.el, b.el, e) + bump * 0.32;
    const r = lerp(a.r, b.r, e) + bump * 5;
    out.set(look.x + Math.sin(yaw) * Math.cos(el) * r, look.y + Math.sin(el) * r, look.z + Math.cos(yaw) * Math.cos(el) * r);
  };
  const tmpL = new THREE.Vector3();
  const arcPts: THREE.Vector3[] = Array.from({ length: 48 }, () => new THREE.Vector3());
  let label = '';
  let clock = 0;
  return {
    scene,
    camera: cam,
    update(_t, dt) {
      clock += Math.min(dt, 0.05) * st.speed;
      const seg = 3.2;
      const i = Math.floor(clock / seg) % ST.length;
      const a = ST[i]!;
      const b = ST[(i + 1) % ST.length]!;
      const ph = clock % seg;
      const fly = 1.7;
      const k = clamp((ph - (seg - fly)) / fly, 0, 1);
      camAt(a, b, k, cam.position, tg);
      cam.lookAt(tg);
      label = k > 0 && k < 1 ? `→ ${b.name}` : k >= 1 ? b.name : a.name;
      for (let j = 0; j < 48; j++) camAt(a, b, j / 47, arcPts[j]!, tmpL);
      arcLine.set(arcPts);
      straight.line.visible = st.showStraight && st.mode === 2;
      sph(a, pa);
      sph(b, pb);
      straight.set([pa, pb]);
      tgtDot.position.set(tg.x, 0, tg.z);
      mm.setCamera(cam);
    },
    render(r, w, h) {
      withState(r, () => {
        r.render(scene, cam);
        mm.draw(r, w, h);
        const u = Math.min(1.9, w / 280, h / 175);
        hud.label('a', label, 10 * u, 10 * u, 18 * u, { bg: 'rgba(14,20,44,0.8)' });
        hud.label('b', ['순간 이동', '직선 이동', '궤도 이동'][st.mode]!, 10 * u, 34 * u, 14 * u, { bg: st.mode === 2 ? 'rgba(59,130,246,0.9)' : 'rgba(200,60,80,0.85)' });
        hud.draw(r, w, h);
      });
    },
    controls: [
      { type: 'range', label: '이동 방식 (0 순간 · 1 직선 · 2 궤도)', min: 0, max: 2, step: 1, value: 2, on: (v) => (st.mode = v) },
      { type: 'range', label: '빠르기', min: 0.3, max: 2, step: 0.1, value: 1, on: (v) => (st.speed = v) },
      { type: 'toggle', label: '지도에 직선 경로도 (빨강)', value: true, on: (v) => (st.showStraight = v) },
    ] as Control[],
    dispose() {
      disposeTree(scene);
      mm.dispose();
      hud.dispose();
    },
  };
}
```

### i430 견본 항목 — `src/demos/demosCamLight.ts:2480`
```ts
  i430: { kind: '3d', caption: '장소 사이를 원호로 날아가며 바라보는 곳도 함께 옮김 — 지도: 흰 선 = 궤도, 빨강 = 직선', make: () => demoOrbit() }
```

## 관련 기술
- 먼저 알면 좋은 기술: [따라가는 카메라 (부드러운 추적)](https://ai-techstudio.web.app/ai/t/i426.md) `i426`
- 다음에 해 볼 기술: [극적인 확대 (결정적 순간)](https://ai-techstudio.web.app/ai/t/i428.md) `i428`
