投稿日:2025-12-01
#Three.js
普段は私は建設・不動産業界で働きつつ、業務で Three.js を使っています。 建物モデルを WebGL で扱うことも多いのですが、真上(トップビュー)に近づいたときは Perspective ではなく Orthographic カメラに切り替えたくなるケースがよくあります。
しかし、この Perspective → Orthographic の切り替えを “シームレス” に行う方法にずっと悩んでいました。
昔の自分はかなり力技で対応していて、 polarAngle が一定以下になったら モデル全体を y 方向に徐々に圧縮して、見た目を強制的に Orthographic っぽく見せるという雑な実装をしていました。
もちろん、このアプローチには多くの問題があります。 圧縮されたモデルに対して処理(例:Marker の設置)を行うと、
実際の座標と見た目の座標がズレる
z-fighting が起きやすくなる など、後でボロが出やすい状態でした。
それでもとりあえず座標系の整合成だけ取って、問題なく動いてはいたので放置していたのですが、 最近になって「さすがにそろそろちゃんと実装するか」と思い立ち、改めて調べてみたところ、 映像/ゲームの世界で使われている “Dolly Zoom” を使用すると、シームレスな切り替えができることがわかり、実際に実装してみました。
この記事ではその手法を紹介します。
Youtube でも解説しております ↓
コード解説に入る前に、まず今回のカメラ切り替えの仕組みをざっくり説明します。 以下の図は、建物モデルを真横から見たときのカメラ構造を示しています。

建物モデルの中央付近に OrbitControls の target があり、 カメラの視線(赤い線)と Y 軸との角度が PolarAngle です。
カメラが建物の「真上」に来るというのは、 PolarAngle = 0 に近づくということです。
このタイミングで Perspective カメラ → Orthographic カメラ に切り替えたいわけですが、 単純にモードを切り替えると 見た目がガクッと変わるため、 どうしても “切り替え感” が出てしまいます。
そこで今回は、以下のアプローチを取ります。
【SWITCH_ANGLE と TRANSITION_ANGLE について】

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 は映画などで使われる有名な演出で、 ズームしつつ同時にカメラを後退(セットバック)させ、 被写体の大きさを変えずに背景だけ変える 映像技法です。
数学的には、
FOV を小さくすると視野は狭くなる(普通はズームインする)
しかしそのぶん カメラ距離を伸ばせば、被写体の見かけの大きさは一定に保てる
という原理に基づいています。
参考 ↓
今回の実装では、Perspective カメラを Orthographic カメラに近づけるために、以下の処理を行っています:
Perspective カメラと Orthographic カメラの違い
Dolly Zoom による連続的な変化
移行区間(TRANSITION_ANGLE と SWITCH_ANGLE の間)では、以下の 2 つの処理を同時に行います:
1 FOV を徐々に小さくする
2 カメラを後退(セットバック)させる

この処理によって、 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 設定などを正確に移し替え

今回紹介した手法では、
Dolly Zoom を用いて Perspective カメラを徐々に Orthographic の見た目へ近づけ、SWITCH_ANGLE を下回った瞬間に完全に Orthographic へ切り替える
という流れで、Three.js でも “違和感のないシームレスな切り替え” を実現できます。
建築モデルや CAD ビューアのようにトップビューで遠近感を消したい 操作中に急に見た目が切り替わるのは避けたいといったユースケースに非常に向いています
実装はすべて CameraTransitionManager に集約しており、 Three.js の既存コードにも簡単に組み込めます。
以上