STUDIO TAMA


thumbnail

投稿日:2025-12-01

【Three.js】PerspectiveカメラとOrthographicカメラのシームレスな切り替え

  • #Three.js

普段は私は建設・不動産業界で働きつつ、業務で Three.js を使っています。 建物モデルを WebGL で扱うことも多いのですが、真上(トップビュー)に近づいたときは Perspective ではなく Orthographic カメラに切り替えたくなるケースがよくあります。

しかし、この Perspective → Orthographic の切り替えを “シームレス” に行う方法にずっと悩んでいました。

昔の自分はかなり力技で対応していて、 polarAngle が一定以下になったら モデル全体を y 方向に徐々に圧縮して、見た目を強制的に Orthographic っぽく見せるという雑な実装をしていました。

もちろん、このアプローチには多くの問題があります。 圧縮されたモデルに対して処理(例:Marker の設置)を行うと、

実際の座標と見た目の座標がズレる

z-fighting が起きやすくなる など、後でボロが出やすい状態でした。

それでもとりあえず座標系の整合成だけ取って、問題なく動いてはいたので放置していたのですが、 最近になって「さすがにそろそろちゃんと実装するか」と思い立ち、改めて調べてみたところ、 映像/ゲームの世界で使われている “Dolly Zoom” を使用すると、シームレスな切り替えができることがわかり、実際に実装してみました。

この記事ではその手法を紹介します。

Youtube でも解説しております ↓

Code

GitHub - shuya-tamaru/three-seamless-ortho-impl

Contribute to shuya-tamaru/three-seamless-ortho-impl development by creating an account on GitHub.

概要

コード解説に入る前に、まず今回のカメラ切り替えの仕組みをざっくり説明します。 以下の図は、建物モデルを真横から見たときのカメラ構造を示しています。

thumbnail

建物モデルの中央付近に OrbitControls の target があり、 カメラの視線(赤い線)と Y 軸との角度が PolarAngle です。

カメラが建物の「真上」に来るというのは、 PolarAngle = 0 に近づくということです。

このタイミングで Perspective カメラ → Orthographic カメラ に切り替えたいわけですが、 単純にモードを切り替えると 見た目がガクッと変わるため、 どうしても “切り替え感” が出てしまいます。

そこで今回は、以下のアプローチを取ります。

【SWITCH_ANGLE と TRANSITION_ANGLE について】

thumbnail

PolarAngle に応じてカメラを切り替えますが、上の図のように 2 つの閾値 を使います。

【SWITCH_ANGLE】

PolarAngle がこの値を 下回った瞬間に Perspective → Orthographic の切り替えを行います。

本来は “PolarAngle = 0” で切り替えたいのですが、 浮動小数点誤差などの都合で 完全な 0 はなかなか出ないため、 実際には 0.01 などの小さな値を採用しています。

【TRANSITION_ANGLE】

PolarAngle が この値より大きい間は完全に Perspective モード を維持します。

【SWITCH_ANGLE と TRANSITION_ANGLE の間】

— この狭い範囲が今回の 移行区間であり、本記事のポイントです。

この区間では、Perspective カメラに対し Dolly Zoom のテクニック を使います。 これにより、見た目が徐々に Orthographic カメラのようなな映り方 に近づいていきます。

Dolly Zoom とは?

Dolly Zoom は映画などで使われる有名な演出で、 ズームしつつ同時にカメラを後退(セットバック)させ、 被写体の大きさを変えずに背景だけ変える 映像技法です。

数学的には、

FOV を小さくすると視野は狭くなる(普通はズームインする)

しかしそのぶん カメラ距離を伸ばせば、被写体の見かけの大きさは一定に保てる

という原理に基づいています。

参考 ↓

今回の Dolly Zoom で何をしているのか?

今回の実装では、Perspective カメラを Orthographic カメラに近づけるために、以下の処理を行っています:

Perspective カメラと Orthographic カメラの違い

  • Perspective カメラ:近くのものは大きく、遠くのものは小さく見える(遠近感がある)
  • Orthographic カメラ:距離に関係なく、同じ大きさで見える(遠近感がない)

Dolly Zoom による連続的な変化

移行区間(TRANSITION_ANGLE と SWITCH_ANGLE の間)では、以下の 2 つの処理を同時に行います:

1 FOV を徐々に小さくする

  • デフォルトの FOV(45 度)から最小値(7 度)まで線形補間で縮小
  • FOV が小さいほど、遠近感が弱まり Orthographic カメラに近づく

2 カメラを後退(セットバック)させる

  • ターゲット位置での視錐台(Frustum)の断面の高さが一定になるように、カメラ距離を調整
  • Dolly Zoom の数式を用いて距離を逆算し、被写体の見かけの大きさを保つ
thumbnail

この処理によって、 Perspective のまま Orthographic に似た見た目へと連続的に変化していきます。

そして PolarAngle が SWITCH_ANGLE を下回った瞬間に 完全に Orthographic カメラへ切り替えることで、 ガクッとしない “シームレスな移行” を実現しています。

コード解説

ここでは、実際にカメラの切り替えを管理しているコードを簡単に説明します。 Three.js の基本的な初期化部分(シーン・モデル読み込み・レンダラーなど)は省略しており、 カメラ切り替えに関係する箇所だけを抜粋しています。

コード全体を見たい場合は GitHub リポジトリをご参照ください。

【カメラ切り替えの管理クラス】

以下が、Perspective カメラと Orthographic カメラを シームレスに切り替えるためのロジックを全て集約したクラスです。

src/utils/CameraTransitionManager.ts

1import { OrbitControls } from "three/addons/controls/OrbitControls.js";
2import * as THREE from "three";
3
4export interface FrustumUpdateResult {
5  shouldSwitch: boolean;
6  targetType?: "perspective" | "orthographic";
7  overrideParams?: {
8    top: number;
9    bottom: number;
10    left: number;
11    right: number;
12  };
13  overridePosition?: THREE.Vector3;
14  overrideFov?: number;
15}
16
17export class CameraTransitionManager {
18  private static readonly DEFAULT_FOV = 45;
19  private static readonly MIN_FOV = 7;
20  private static readonly TRANSITION_ANGLE = 0.2;
21  private static readonly SWITCH_ANGLE = 0.01;
22
23  private static frustumHeightAtDistance(
24    camera: THREE.PerspectiveCamera,
25    distance: number
26  ) {
27    const vFov = THREE.MathUtils.degToRad(camera.fov);
28    return Math.tan(vFov / 2) * distance * 2;
29  }
30
31  static updateFrustum(
32    orbitControls: OrbitControls,
33    camera: THREE.PerspectiveCamera | THREE.OrthographicCamera
34  ): FrustumUpdateResult {
35    const polarAngle = orbitControls.getPolarAngle();
36    const isPerspective = camera instanceof THREE.PerspectiveCamera;
37
38    // Camera type switching check (early return)
39    if (isPerspective && polarAngle < this.SWITCH_ANGLE) {
40      // Switch from perspective to orthographic
41      return this.switchToOrthographic(camera, orbitControls);
42    }
43
44    if (!isPerspective && polarAngle >= this.SWITCH_ANGLE) {
45      // Switch from orthographic to perspective
46      return this.switchToPerspective(camera, orbitControls);
47    }
48
49    // Perspective camera transition processing
50    if (isPerspective) {
51      if (polarAngle < this.TRANSITION_ANGLE) {
52        // Transition zone: dolly zoom
53        this.applyDollyZoomTransition(camera, orbitControls, polarAngle);
54      } else if (camera.fov !== this.DEFAULT_FOV) {
55        // Normal zone: reset to default FOV
56        this.resetToDefaultFov(camera, orbitControls);
57      }
58    }
59
60    return { shouldSwitch: false };
61  }
62
63  private static switchToOrthographic(
64    camera: THREE.PerspectiveCamera,
65    orbitControls: OrbitControls
66  ): FrustumUpdateResult {
67    const distance = camera.position.distanceTo(orbitControls.target);
68    const targetViewHeight = this.frustumHeightAtDistance(camera, distance);
69    const targetViewWidth = targetViewHeight * camera.aspect;
70
71    return {
72      shouldSwitch: true,
73      targetType: "orthographic",
74      overrideParams: {
75        top: targetViewHeight / 2,
76        bottom: -targetViewHeight / 2,
77        left: -targetViewWidth / 2,
78        right: targetViewWidth / 2,
79      },
80    };
81  }
82
83  private static switchToPerspective(
84    camera: THREE.OrthographicCamera,
85    orbitControls: OrbitControls
86  ): FrustumUpdateResult {
87    const targetFov = this.MIN_FOV;
88    const targetFovHalfRad = THREE.MathUtils.degToRad(targetFov / 2);
89    const targetTan = Math.tan(targetFovHalfRad);
90    const orthoHeight = (camera.top - camera.bottom) / camera.zoom;
91    const newDist = orthoHeight / (2 * targetTan);
92
93    const target = orbitControls.target.clone();
94    const direction = new THREE.Vector3()
95      .subVectors(camera.position, target)
96      .normalize();
97    const newPosition = target.add(direction.multiplyScalar(newDist));
98
99    return {
100      shouldSwitch: true,
101      targetType: "perspective",
102      overridePosition: newPosition,
103      overrideFov: targetFov,
104    };
105  }
106
107  private static applyDollyZoomTransition(
108    camera: THREE.PerspectiveCamera,
109    orbitControls: OrbitControls,
110    polarAngle: number
111  ): void {
112    const t =
113      (polarAngle - this.SWITCH_ANGLE) /
114      (this.TRANSITION_ANGLE - this.SWITCH_ANGLE);
115    const clampedT = Math.max(0, Math.min(1, t));
116    const targetFov = THREE.MathUtils.lerp(
117      this.MIN_FOV,
118      this.DEFAULT_FOV,
119      clampedT
120    );
121
122    const targetFovHalfRad = THREE.MathUtils.degToRad(targetFov / 2);
123    const targetTan = Math.tan(targetFovHalfRad);
124
125    if (targetTan <= 0) return; // Division by zero prevention
126
127    const currentFov = camera.fov;
128    const currentDist = camera.position.distanceTo(orbitControls.target);
129    const currentFovHalfRad = THREE.MathUtils.degToRad(currentFov / 2);
130    const currentTan = Math.tan(currentFovHalfRad);
131
132    const newDist = currentDist * (currentTan / targetTan);
133    const direction = new THREE.Vector3()
134      .subVectors(camera.position, orbitControls.target)
135      .normalize();
136
137    camera.position
138      .copy(orbitControls.target)
139      .add(direction.multiplyScalar(newDist));
140    camera.fov = targetFov;
141    camera.updateProjectionMatrix();
142  }
143
144  private static resetToDefaultFov(
145    camera: THREE.PerspectiveCamera,
146    orbitControls: OrbitControls
147  ): void {
148    const currentFov = camera.fov;
149    const currentDist = camera.position.distanceTo(orbitControls.target);
150    const currentFovHalfRad = THREE.MathUtils.degToRad(currentFov / 2);
151    const currentTan = Math.tan(currentFovHalfRad);
152    const targetFovHalfRad = THREE.MathUtils.degToRad(this.DEFAULT_FOV / 2);
153    const targetTan = Math.tan(targetFovHalfRad);
154
155    const newDist = currentDist * (currentTan / targetTan);
156    const direction = new THREE.Vector3()
157      .subVectors(camera.position, orbitControls.target)
158      .normalize();
159
160    camera.position
161      .copy(orbitControls.target)
162      .add(direction.multiplyScalar(newDist));
163    camera.fov = this.DEFAULT_FOV;
164    camera.updateProjectionMatrix();
165  }
166}

このクラスがやっていること(ざっくり)

・ PolarAngle(カメラの傾き)を確認して、今の視点がどの位置にいるか判断

・ 2 つの閾値 SWITCH_ANGLE(完全切り替えポイント)と TRANSITION_ANGLE(移行開始ポイント)を使って、視点位置に応じた適切な処理を実行

・ Perspective → Orthographic 切り替え時 → 現在の視野(ターゲット位置での視錐台の高さ)と一致する Orthographic の top / bottom / left / right を計算

・ Orthographic → Perspective 復帰時 → 現在の Orthographic の視野と一致するように Perspective の FOV と距離を逆算して復元

・ 移行区間では Dolly Zoom を適用し、連続的に見た目を変化させる → FOV を徐々に縮める → 同時にカメラ距離を伸ばし、ターゲット位置での視野の高さが一定になるよう調整

【メインループでの切り替え処理】

animate() の中では、 毎フレームごとに CameraTransitionManager.updateFrustum() を呼び出して カメラが切り替えポイントにいるかどうかを判定します。

main.ts

1//....three.jsの基本的なコードGitHub参照してください...省略
2function animate() {
3  requestAnimationFrame(animate);
4  controls.update();
5
6  const result = CameraTransitionManager.updateFrustum(controls, camera);
7  if (result.shouldSwitch && result.targetType === "orthographic") {
8    orthographicCamera.left = result.overrideParams!.left;
9    orthographicCamera.right = result.overrideParams!.right;
10    orthographicCamera.top = result.overrideParams!.top;
11    orthographicCamera.bottom = result.overrideParams!.bottom;
12
13    orthographicCamera.position.copy(camera.position);
14    orthographicCamera.quaternion.copy(camera.quaternion);
15    orthographicCamera.updateProjectionMatrix();
16
17    camera = orthographicCamera;
18    controls.object = camera;
19  } else if (result.shouldSwitch && result.targetType === "perspective") {
20    perspectiveCamera.fov = result.overrideFov!;
21    perspectiveCamera.position.copy(result.overridePosition!);
22    perspectiveCamera.quaternion.copy(camera.quaternion);
23    perspectiveCamera.updateProjectionMatrix();
24
25    camera = perspectiveCamera;
26    controls.object = camera;
27  }
28  renderer.render(scene, camera);
29}
30
31animate();

ここでやっていること(ざっくり)

・ updateFrustum() の結果に応じて、Perspective / Orthographic のどちらへ切り替えるか判断 ・ 切り替えが必要な場合は、FOV / position / quaternion(向き)/ orthographic の frustum 設定などを正確に移し替え

動作

thumbnail

まとめ

今回紹介した手法では、

Dolly Zoom を用いて Perspective カメラを徐々に Orthographic の見た目へ近づけ、SWITCH_ANGLE を下回った瞬間に完全に Orthographic へ切り替える

という流れで、Three.js でも “違和感のないシームレスな切り替え” を実現できます。

建築モデルや CAD ビューアのようにトップビューで遠近感を消したい 操作中に急に見た目が切り替わるのは避けたいといったユースケースに非常に向いています

実装はすべて CameraTransitionManager に集約しており、 Three.js の既存コードにも簡単に組み込めます。

以上

目 次