# AI 꾸러미 — 여러 화면 동시에 (정사영 카메라 여럿) — Multiple viewports (scissor test)
> 렌더러 한 대로 화면을 칸으로 나눠 setViewport · setScissor 로 칸마다 다른 카메라를 그려서, 한 3D 물체를 원근 큰 화면 + 정면 · 옆 · 위 정사영 작은 창으로 나란히 보여 준다.  
> 견본: https://ai-techstudio.web.app/#t/u43

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

## 주문서

### 만들어 줘: 여러 화면 동시에 (정사영 카메라 여럿) — Multiple viewports (scissor test)

#### 1. 목표
쌓기나무를 큰 원근 화면 하나와 정면 · 옆 · 위 정사영 작은 창 셋으로 동시에 보여 줘 — 렌더러 하나로 칸을 나눠서. 분위기는 밝은 모눈 설계도.

#### 2. 핵심 기술 용어
- **Multiple viewports (scissor test)** — 한 화면을 칸으로 나눠 여러 카메라 그리기
- **OrthographicCamera** — 정사영 카메라 — 멀어도 크기가 같은 설계도 시점
- **Orthographic projection views (front · side · top)** — 정면 · 옆 · 위에서 본 모양 (투영도)
- **setViewport / setScissor** — 그릴 칸 정하기 · 그 밖은 안 지우기

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

#### 4. 조건
- 렌더러 하나로 setScissorTest(true) + 칸마다 setViewport · setScissor 둘 다
- 정면 · 옆 · 위는 OrthographicCamera, 위 카메라는 up 을 바꿔 방향을 고정
- 넓은 화면 / 좁은 화면(세로 폰)에서 칸 배치를 바꾼다
- 뷰포트 y 는 아래에서부터 — HTML 좌표와 섞지 않기
- 다 그린 뒤 scissor 끄고 뷰포트를 화면 전체로 되돌린다

#### 5. 완성 기준 (이게 보이면 성공)
- 큰 화면에서 쌓기나무가 천천히 돌고, 오른쪽 작은 창 셋에 정면 · 옆 · 위 모양이 보인다
- 4초마다 쌓기나무가 바뀌면 세 창의 모양도 바로 바뀐다
- 세로 폰에서는 큰 화면이 위, 작은 창 셋이 아래 한 줄로 바뀐다
- 작은 창에서 멀고 가까운 블록이 같은 크기로 보인다 (정사영)

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

## 원리
- 렌더러 하나에 setScissorTest(true) 를 켜고, 칸마다 setViewport · setScissor 로 그릴 사각형을 정한 뒤 그 칸의 카메라로 render 한다.
- 큰 화면(넓으면 왼쪽 70%, 좁으면 위 70%)은 원근 카메라로 천천히 돌며 본다.
- 작은 창 셋은 OrthographicCamera(−2.3 ~ 2.3): 정면 (0, 1, 10) · 옆 (10, 1, 0) · 위 (0, 10, 0) 에서 가운데를 본다. 위 카메라는 up 을 (0, 0, −1) 로 바꿔 앞이 아래로 오게.
- 정사영이라 멀고 가까운 것이 같은 크기 — 쌓기나무 투영도가 그대로 나온다. 창마다 6% 여백 · 테두리 · 이름표.
- 뷰포트 좌표는 왼쪽 아래가 (0, 0) 이다 (HTML 은 왼쪽 위). 다 그린 뒤 setScissorTest(false) · setViewport(0, 0, w, h) 로 되돌린다.

## 핵심 코드 — 큰 원근 화면 + 정사영 작은 창 셋 (렌더러 하나)
(발췌: demos/demosStructure.ts u43 make() · render() 를 정리)
```ts
const S = 2.3;
const ortho = [0, 1, 2].map(() => new THREE.OrthographicCamera(-S, S, S, -S, 0.1, 50));
ortho[0].position.set(0, 1, 10);   // 정면
ortho[1].position.set(10, 1, 0);   // 옆
ortho[2].position.set(0, 10, 0);   // 위
ortho[2].up.set(0, 0, -1);         // 위에서 볼 때 앞이 아래로
ortho.forEach((c) => c.lookAt(0, 1, 0));
ortho[2].lookAt(0, 0, 0);

function render(r: THREE.WebGLRenderer, w: number, h: number): void {
  const wide = w >= h;
  const mw = wide ? w * 0.7 : w, mh = wide ? h : h * 0.7;
  main.aspect = mw / mh;
  main.updateProjectionMatrix();
  r.setScissorTest(true);
  r.setViewport(0, wide ? 0 : h - mh, mw, mh);   // y 는 아래에서부터
  r.setScissor(0, wide ? 0 : h - mh, mw, mh);
  r.render(scene, main);
  for (let i = 0; i < 3; i++) {
    const size = wide ? Math.min(w - mw, h / 3) : Math.min(h - mh, w / 3);
    const x = wide ? mw + (w - mw - size) / 2 : (w / 3) * i + (w / 3 - size) / 2;
    const y = wide ? h - (i + 1) * (h / 3) + (h / 3 - size) / 2 : (h - mh - size) / 2;
    const pad = size * 0.06;
    r.setViewport(x + pad, y + pad, size - pad * 2, size - pad * 2);
    r.setScissor(x + pad, y + pad, size - pad * 2, size - pad * 2);
    r.render(scene, ortho[i]);
  }
  r.setScissorTest(false);
  r.setViewport(0, 0, w, h);                     // 되돌리기
}
```

## 흔한 실수 · 확인 목록
- [ ] **setViewport 만 하고 scissor 를 안 켜면 칸마다 화면 전체가 지워진다** — setScissorTest(true) + 같은 사각형으로 setScissor — 지우기도 그 칸에만.
- [ ] **뷰포트 y 를 HTML 처럼 위에서부터 잡으면 칸이 뒤집혀 놓인다** — WebGL 뷰포트는 왼쪽 아래가 (0, 0). 테두리를 HTML/캔버스로 그릴 땐 top = h − y − size 로 바꾼다.
- [ ] **위에서 보는 정사영 카메라가 화면마다 돌아가 보인다** — lookAt 전에 up 을 (0, 0, −1) 로 정해 방향을 고정한다.

## 완성 기준 체크리스트
- [ ] 큰 화면에서 쌓기나무가 천천히 돌고, 오른쪽 작은 창 셋에 정면 · 옆 · 위 모양이 보인다
- [ ] 4초마다 쌓기나무가 바뀌면 세 창의 모양도 바로 바뀐다
- [ ] 세로 폰에서는 큰 화면이 위, 작은 창 셋이 아래 한 줄로 바뀐다
- [ ] 작은 창에서 멀고 가까운 블록이 같은 크기로 보인다 (정사영)

## 이 기술 정보
- id: `u43` · 분류: 이펙트 · 연출 › 카메라 · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의) — 칸 수만큼 장면을 다시 그린다 (견본 4번). 물체가 적은 쌓기나무는 가볍지만 무거운 장면이면 작은 창 해상도 · 수를 줄인다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u43
- 쓰면 좋을 때: 쌓기나무 · 입체도형의 정면 · 옆 · 위 모양을 보여 줄 때 / 미니맵 · 뒤에서 본 모습 같은 작은 보조 화면
- 쓰지 말 때: 보조 화면이 많아질 때(4개 넘게) — 칸마다 장면을 한 번씩 다시 그린다. 미니맵은 2D 로 그리는 편이 싸다 / 창마다 다른 장면이면 — 장면을 따로 두거나 레이어(camera.layers)로 나눈다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### std — `src/demos/demosStructure.ts:92`
```ts
function std(color: number, rough = 0.5, metal = 0, extra: THREE.MeshStandardMaterialParameters = {}): THREE.MeshStandardMaterial {
  return new THREE.MeshStandardMaterial({ color, roughness: rough, metalness: metal, ...extra });
}
```

### u43 견본 항목 — `src/demos/demosStructure.ts:1934`
```ts
  u43: {
    kind: '3d',
    caption: '큰 화면은 원근 카메라, 작은 창 셋은 정사영 카메라 — 쌓기나무를 정면 · 옆 · 위에서 본 모양',
    make() {
      const scene = baseScene('#e9f1ff', '#fbfdff');
      const blocks = new THREE.Group();
      scene.add(blocks);
      const geo = new RoundedBoxGeometry(0.96, 0.96, 0.96, 2, 0.06);
      const mats = [0xff7a59, 0xffc93c, 0x4aa8ff, 0x5ad17a].map((c) => std(c, 0.5));
      const grid = new THREE.GridHelper(4, 4, 0x8aa0c8, 0xc6d4ee);
      grid.position.y = -0.5;
      scene.add(grid);
      const build = (k: number): void => {
        blocks.clear();
        let seed = k * 9301 + 49297;
        const rnd = (): number => ((seed = (seed * 9301 + 49297) % 233280) / 233280);
        for (let x = 0; x < 3; x++)
          for (let z = 0; z < 3; z++) {
            const hgt = Math.floor(rnd() * 3.4);
            for (let y = 0; y < hgt; y++) {
              const m = new THREE.Mesh(geo, mats[(x + z + y) % 4]!);
              m.position.set(x - 1, y, z - 1);
              m.userData['shared'] = true;
              blocks.add(m);
            }
          }
      };
      let built = -1;
      const main = new THREE.PerspectiveCamera(35, 1, 0.1, 60);
      const S = 2.3;
      const ortho = [new THREE.OrthographicCamera(-S, S, S, -S, 0.1, 50), new THREE.OrthographicCamera(-S, S, S, -S, 0.1, 50), new THREE.OrthographicCamera(-S, S, S, -S, 0.1, 50)];
      ortho[0]!.position.set(0, 1, 10);
      ortho[1]!.position.set(10, 1, 0);
      ortho[2]!.position.set(0, 10, 0);
      ortho[2]!.up.set(0, 0, -1);
      ortho.forEach((c) => c.lookAt(0, 1, 0));
      ortho[2]!.lookAt(0, 0, 0);
      const hud = new Hud();
      const names = ['정면', '옆', '위'];
      return {
        scene,
        camera: main,
        update(t) {
          const k = Math.floor(t / 4);
          if (k !== built) {
            built = k;
            build(k);
          }
          (main.userData as { a: number })['a'] = t * 0.4 + 0.7;
        },
        render(r, w, h) {
          const wide = w >= h;
          const mw = wide ? w * 0.7 : w;
          const mh = wide ? h : h * 0.7;
          main.aspect = mw / mh;
          main.updateProjectionMatrix();
          const tn = Math.tan((main.fov * Math.PI) / 360);
          const dd = Math.max(2.7 / tn, 2.7 / (tn * main.aspect));
          const an = (main.userData as { a: number })['a'] ?? 0;
          main.position.set(Math.sin(an) * dd * 0.78, dd * 0.55 + 0.8, Math.cos(an) * dd * 0.78);
          main.lookAt(0, 0.7, 0);
          r.setScissorTest(true);
          r.setViewport(0, wide ? 0 : h - mh, mw, mh);
          r.setScissor(0, wide ? 0 : h - mh, mw, mh);
          r.render(scene, main);
          const rects: [number, number, number, number][] = [];
          for (let i = 0; i < 3; i++) {
            const size = wide ? Math.min(w - mw, h / 3) : Math.min(h - mh, w / 3);
            const x = wide ? mw + (w - mw - size) / 2 : (w / 3) * i + (w / 3 - size) / 2;
            const y = wide ? h - (i + 1) * (h / 3) + (h / 3 - size) / 2 : (h - mh - size) / 2;
            rects.push([x, y, size, size]);
            const pad = size * 0.06;
            r.setViewport(x + pad, y + pad, size - pad * 2, size - pad * 2);
            r.setScissor(x + pad, y + pad, size - pad * 2, size - pad * 2);
            r.render(scene, ortho[i]!);
          }
          r.setScissorTest(false);
          r.setViewport(0, 0, w, h);
          hud.draw(r, w, h, `${w}x${h}`, (g) => {
            const s = fsz(w, h, 0.8);
            rects.forEach(([x, y, size], i) => {
              const top = h - y - size;
              g.strokeStyle = '#3a5bb0';
              g.lineWidth = Math.max(1.5, s * 0.15);
              g.strokeRect(x + size * 0.06, top + size * 0.06, size * 0.88, size * 0.88);
              pill(g, x + size * 0.08, top + size * 0.08, names[i]!, s * 0.85, 'rgba(58,91,176,0.92)');
            });
          });
        },
        dispose() {
          geo.dispose();
          mats.forEach((m) => m.dispose());
          disposeScene(scene);
          hud.dispose();
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [화면에 꼭 맞는 카메라 거리](https://ai-techstudio.web.app/ai/t/u41.md) `u41`
- 다음에 해 볼 기술: [비대칭 카메라 (off-axis 투영)](https://ai-techstudio.web.app/ai/t/u40.md) `u40`
- 참고 문서: [three.js 예제 — webgl_multiple_views](https://threejs.org/examples/#webgl_multiple_views)
