# AI 꾸러미 — 비대칭 카메라 (off-axis 투영) — Off-axis projection (lens shift)
> 카메라는 그대로 두고 투영 중심만 setViewOffset 으로 옮겨서, 위 HUD · 아래 단추 · 오른쪽 패널에 가린 화면에서도 판이 남은 빈 자리 가운데에 온다.  
> 견본: https://ai-techstudio.web.app/#t/u40

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

## 주문서

### 만들어 줘: 비대칭 카메라 (off-axis 투영) — Off-axis projection (lens shift)

#### 1. 목표
보드게임 판이 HUD · 패널을 뺀 빈 자리 가운데에 오도록 투영 중심을 옮겨 줘 — 카메라 각도는 그대로. 분위기는 밝은 나무 판.

#### 2. 핵심 기술 용어
- **Off-axis projection (lens shift)** — 비대칭 투영 — 투영 중심 옮기기
- **PerspectiveCamera.setViewOffset** — three.js 투영 창 옮기기
- **Safe area / free rect** — HUD 를 뺀 빈 사각형
- **Lens shift** — 사진기 렌즈를 옆으로 미는 것과 같은 효과

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

#### 4. 조건
- 카메라 자리 · 각도는 그대로, 투영 창만 setViewOffset 으로 옮긴다
- 빈 사각형은 실제 HUD · 패널 크기에서 계산 (화면 크기가 바뀌면 다시)
- 거리는 판이 빈 자리에 들어가게 화면 비율로 계산
- 필요 없을 때(PC 넓은 화면 등)는 clearViewOffset()
- 켬/끔 비교를 부드럽게

#### 5. 완성 기준 (이게 보이면 성공)
- 끔: 판이 화면 한가운데라 오른쪽 패널에 가린다 · 켬: 판이 점선(빈 자리) 가운데로 옮겨 가고 각도는 그대로다
- 빈 자리 가운데 십자 표시와 판 가운데가 겹친다
- 화면 비율을 바꿔도 판이 빈 자리 안에 들어간다
- 폰 가로 화면(844×390)에서도 판이 단추에 안 가린다

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

## 원리
- 판을 화면 한가운데 두면 오른쪽 패널(30%) · 위 HUD(14%) · 아래 단추(16%)에 가린다.
- 카메라를 옆으로 옮기거나 돌리면 판이 비스듬히 보여 구도가 바뀐다. 대신 투영 창만 옮기면 같은 각도 그대로 화면 위 자리만 이동한다.
- 빈 사각형 가운데 (cx, cy) 를 구하고 setViewOffset(w, h, (w/2 − cx), (h/2 − cy), w, h) — 창을 그만큼 밀면 판 가운데가 (cx, cy) 에 그려진다.
- 거리는 판 반폭 half 가 빈 자리(화면의 70%)에 들어가게: d = max(half / (tan(fov/2)·0.7), half / (tan(fov/2)·종횡비·0.7)).
- 켬/끔은 옮기는 양에 m(0 → 1)을 곱해 부드럽게 (m += (목표 − m)·5dt).

## 핵심 코드 — 빈 자리 가운데 맞추기 (매 렌더)
(발췌: demos/demosStructure.ts u40 render() 를 정리)
```ts
let m = 0; // 0 끔 → 1 켬 (update 에서 m += ((on ? 1 : 0) - m) * Math.min(1, dt * 5))

function frame(r: THREE.WebGLRenderer, cam: THREE.PerspectiveCamera, w: number, h: number): void {
  // 빈 자리: 위 HUD 14% · 아래 단추 16% · 오른쪽 패널 30% 를 뺀 곳
  const fx1 = w * 0.7, fy0 = h * 0.14, fy1 = h * 0.84;
  const cx = fx1 / 2, cy = (fy0 + fy1) / 2;
  // 판(반폭 1.85)이 빈 자리 70% 안에 들어가는 거리
  const tn = Math.tan((cam.fov * Math.PI) / 360);
  const half = 1.85;
  const d = Math.max(half / (tn * 0.7), half / (tn * (w / h) * 0.7));
  cam.aspect = w / h;
  cam.position.set(0, d * 0.8, d * 0.6);
  cam.lookAt(0, 0, 0);
  // 카메라는 그대로, 투영 창만 밀기 — 화면 가운데(w/2, h/2)가 (cx, cy) 로 간다
  cam.setViewOffset(w, h, (w / 2 - cx) * m, (h / 2 - cy) * m, w, h);
  r.render(scene, cam);
}
// 이 사이트 공통 도구 (shared/fillFraming.ts): 폰 가로 화면에서만 판을 아래로
//   camera.setViewOffset(w, h, 0, -shift * h, w, h);  아니면 camera.clearViewOffset();
```

## 흔한 실수 · 확인 목록
- [ ] **카메라를 옆으로 옮겨 맞추면 판이 비스듬해진다** — 자리 · 각도는 그대로 두고 투영 창(setViewOffset)만 옮긴다.
- [ ] **setViewOffset 을 한 번 걸고 잊으면 다른 화면에서도 판이 밀려 있다** — 필요 없는 화면 · 연출에선 clearViewOffset(). 이 사이트 phoneBoardFraming 은 PC 에서 늘 지운다.
- [ ] **빈 자리를 고정 비율로만 잡으면 폰에서 단추에 가린다** — 폰 가로 화면은 HUD 몫이 커서 판을 따로 키우고 내린다(fillFraming 의 zoom · shift).

## 완성 기준 체크리스트
- [ ] 끔: 판이 화면 한가운데라 오른쪽 패널에 가린다 · 켬: 판이 점선(빈 자리) 가운데로 옮겨 가고 각도는 그대로다
- [ ] 빈 자리 가운데 십자 표시와 판 가운데가 겹친다
- [ ] 화면 비율을 바꿔도 판이 빈 자리 안에 들어간다
- [ ] 폰 가로 화면(844×390)에서도 판이 단추에 안 가린다

## 이 기술 정보
- id: `u40` · 분류: 이펙트 · 연출 › 카메라 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 투영 행렬만 바뀐다. 비용 없음.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u40
- 쓰면 좋을 때: 오른쪽 정보 칸 · 위 HUD · 아래 단추가 있는 공통 틀 위 3D 판 / 폰 가로 화면처럼 HUD 가 차지하는 몫이 클 때 — 판을 아래로 조금 내리기
- 쓰지 말 때: HUD 가 반투명하게 판 위에 떠 있어도 되는 화면 — 옮길 필요 없다 / 카메라를 자주 크게 움직이는 연출 — 옮긴 투영과 섞이면 계산이 꼬인다. 연출 땐 clearViewOffset

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### u40 견본 항목 — `src/demos/demosStructure.ts:1555`
```ts
  u40: {
    kind: '3d',
    caption: '끔: 판이 화면 한가운데라 오른쪽 패널에 가려요 · 켬: 투영을 옮겨 빈 자리(점선) 가운데로 — 카메라는 그대로',
    make() {
      const scene = baseScene('#bfe3c9', '#eefaf1');
      const board = new THREE.Group();
      const light = std(0xf3e2c4, 0.7);
      const dark = std(0x8a5a3c, 0.7);
      const sq = new THREE.BoxGeometry(0.5, 0.12, 0.5);
      for (let i = 0; i < 6; i++)
        for (let j = 0; j < 6; j++) {
          const m = new THREE.Mesh(sq, (i + j) % 2 ? dark : light);
          m.position.set((i - 2.5) * 0.5, 0, (j - 2.5) * 0.5);
          board.add(m);
        }
      const frameM = new THREE.Mesh(new RoundedBoxGeometry(3.3, 0.14, 3.3, 2, 0.05), std(0x5a3a26, 0.6));
      frameM.position.y = -0.03;
      board.add(frameM);
      const pieceGeo = new THREE.CylinderGeometry(0.17, 0.2, 0.12, 24);
      [[0, 0, 0xffffff], [1, 2, 0x2b2b3a], [3, 1, 0xffffff], [4, 4, 0x2b2b3a], [2, 5, 0xffffff], [5, 3, 0x2b2b3a]].forEach(([i, j, c]) => {
        const p = new THREE.Mesh(pieceGeo, std(c!, 0.35));
        p.position.set((i! - 2.5) * 0.5, 0.12, (j! - 2.5) * 0.5);
        board.add(p);
      });
      scene.add(board);
      const cam = new THREE.PerspectiveCamera(36, 1, 0.1, 60);
      const hud = new Hud();
      let m = 0;
      let on = false;
      let auto = true;
      return {
        scene,
        camera: cam,
        update(t, dt) {
          if (auto) on = Math.floor(t / 3) % 2 === 1;
          m += ((on ? 1 : 0) - m) * Math.min(1, dt * 5);
          board.rotation.y = Math.sin(t * 0.4) * 0.15;
        },
        render(r, w, h) {
          // 빈 자리: 위 HUD 14% · 아래 단추 16% · 오른쪽 패널 30% 를 뺀 곳
          const fx1 = w * 0.7;
          const fy0 = h * 0.14;
          const fy1 = h * 0.84;
          const cx = fx1 / 2;
          const cy = (fy0 + fy1) / 2;
          const tn = Math.tan((cam.fov * Math.PI) / 360);
          const half = 1.85;
          const d = Math.max(half / (tn * 0.7), half / (tn * (w / h) * 0.7));
          cam.position.set(0, d * 0.8, d * 0.6);
          cam.lookAt(0, 0, 0);
          cam.setViewOffset(w, h, (w / 2 - cx) * m, (h / 2 - cy) * m, w, h);
          r.render(scene, cam);
          hud.draw(r, w, h, `${w}x${h}${on}`, (g) => {
            const s = fsz(w, h);
            g.fillStyle = 'rgba(40,60,110,0.86)';
            g.fillRect(0, 0, w, fy0);
            g.fillRect(fx1, fy0, w - fx1, h - fy0);
            g.fillRect(0, fy1, fx1, h - fy1);
            g.fillStyle = '#fff';
            g.font = `700 ${s * 0.85}px ${FONT}`;
            g.textBaseline = 'middle';
            g.textAlign = 'left';
            g.fillText('점수 120   ·   차례: 나', s * 0.6, fy0 / 2);
            g.textAlign = 'center';
            g.fillText('패널', fx1 + (w - fx1) / 2, fy0 + s * 1.2);
            for (let k = 0; k < 3; k++) {
              g.fillStyle = 'rgba(255,255,255,0.18)';
              g.beginPath();
              g.roundRect(fx1 + s * 0.5, fy0 + s * 2.3 + k * s * 2, w - fx1 - s, s * 1.5, s * 0.4);
              g.fill();
            }
            for (let k = 0; k < 3; k++) {
              g.fillStyle = '#ffc94a';
              g.beginPath();
              g.roundRect(fx1 * (0.12 + k * 0.29), fy1 + (h - fy1) * 0.2, fx1 * 0.2, (h - fy1) * 0.6, s * 0.4);
              g.fill();
            }
            g.setLineDash([s * 0.4, s * 0.3]);
            g.strokeStyle = on ? '#ff5a36' : 'rgba(255,90,54,0.5)';
            g.lineWidth = Math.max(1.5, s * 0.12);
            g.strokeRect(s * 0.3, fy0 + s * 0.3, fx1 - s * 0.6, fy1 - fy0 - s * 0.6);
            g.setLineDash([]);
            g.beginPath();
            g.moveTo(cx - s, cy);
            g.lineTo(cx + s, cy);
            g.moveTo(cx, cy - s);
            g.lineTo(cx, cy + s);
            g.stroke();
            pill(g, s * 0.6, fy0 + s * 0.6, on ? '가운데 맞추기 켬' : '가운데 맞추기 끔', s * 0.85, on ? 'rgba(255,90,54,0.95)' : 'rgba(30,40,80,0.75)');
          });
        },
        controls: [
          { type: 'toggle', label: '저절로 켜고 끄기', value: true, on: (v) => (auto = v) },
          { type: 'toggle', label: '빈 자리 가운데 맞추기', value: false, on: (v) => ((auto = false), (on = v)) },
        ],
        dispose() {
          disposeScene(scene);
          hud.dispose();
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [화면에 꼭 맞는 카메라 거리](https://ai-techstudio.web.app/ai/t/u41.md) `u41`
- 다음에 해 볼 기술: [여러 화면 동시에 (정사영 카메라 여럿)](https://ai-techstudio.web.app/ai/t/u43.md) `u43`
- 참고 문서: [three.js 문서 — PerspectiveCamera.setViewOffset](https://threejs.org/docs/#api/en/cameras/PerspectiveCamera.setViewOffset)
