OrbitControls + 表面距離ベースのカスタムズーム
Web地図のドラッグ・ズームは地図ライブラリが標準提供しますが、3Dでは地球儀UIに適したカスタマイズが必要です。OrbitControlsの組み込みズームは単純なカメラ距離の増減で、宇宙視点と地表近くの操作で体感が大きく違ってしまいます。
Web地図のズームレベル切替が3D空間では「カメラと楕円体表面の距離制御」として実装されます。表面距離ベースの指数関数ズームにより、どの高度でも一貫した操作感が得られる仕組みを体験します。
ドラッグ — 地球を回転
ホイール — ズーム(30m〜地球半径x5)
カメラ操作の実装に入る前に、ThreeRenderer のコンストラクタ引数を確認します。 最新の実装では、単に canvas を受け取るのではなく、 ThreeRendererOptions インターフェースを通じてオプションを受け取る設計になっています。
export interface ThreeRendererOptions {
canvas: HTMLCanvasElement;
initialPosition?: {
lat: number;
lon: number;
altitude?: number;
};
}initialPosition オプションで、カメラの初期位置を緯度・経度・高度で指定できます。
省略した場合のデフォルトは東京(lat=35.68, lon=139.75)です。 コンストラクタ引数をオブジェクト形式にした設計理由は2つあります。
Three.jsの OrbitControls はマウスやタッチでカメラを操作するためのヘルパーです。
原点を中心にカメラを軌道(orbit)回転させるのが基本動作ですが、 デフォルトのズームとパン操作は地球儀的なUIには不向きなため、無効化しています。
const controls = new OrbitControls(
camera,
renderer.domElement
);
controls.enableDamping = true; // 慣性による滑らかな回転
controls.enableZoom = false; // 標準ズームを無効化
controls.enablePan = false; // パン操作も無効化ポイントは3つです。
controls.update() を呼ぶ必要があります。OrbitControlsの標準ズームには、地球規模のシーンで2つの問題があります。
問題1: 一定速度のズーム
標準ズームはカメラ距離に対して一定の割合で移動します。 宇宙空間では快適でも、地表付近では速すぎて地面を突き抜けてしまいます。
問題2: 球体が考慮されない
標準ズームは原点からの距離だけを見ています。 しかし地球は楕円体であるため、極方向と赤道方向で「表面」までの距離が異なります。
例えば、地球半径が約6,378kmの場合、カメラ距離が12,756km(=半径x2)と6,378.167km(=半径+30m)では 表面距離はそれぞれ6,378kmと30mですが、カメラ距離の変化は約50%です。 一方、表面距離の変化は20万倍以上。デフォルトのリニアなズームではこの差を吸収できません。
カスタムズームを実装するにはまず、カメラの方向における楕円体表面までの距離を求める必要があります。 地球は完全な球ではなく、赤道方向に膨らんだ扁平楕円体です。 WGS84の定義では赤道半径(a)が6,378,137m、極半径(b)が6,356,752mで、 約21km(0.3%)の差があります。
WGS84楕円体は以下の方程式で表されます:
方向ベクトル d = (dx, dy, dz) の方向での楕円体の半径 r は、 r * d が楕円体上にある条件から求まります:
シーン座標ではY軸が極方向(ECEFのZ)に対応しているため、 式中で dir.y が b の項に入ります。
function ellipsoidRadiusInDirection(
dir: THREE.Vector3
): number {
const a = WGS84.a;
const b = WGS84.b;
return 1 / Math.sqrt(
(dir.x * dir.x + dir.z * dir.z)
/ (a * a)
+ (dir.y * dir.y)
/ (b * b)
);
}上記の公式がどのように導かれるかを補足します。 方向ベクトル d の方向で楕円体表面に達する点は、原点から距離 r の位置にある点 r*d = (r*dx, r*dy, r*dz) です。 この点が楕円体上にあるための条件は、楕円体方程式に代入すると:
(r*dx)²/a² + (r*dz)²/a² + (r*dy)²/b² = 1
r² * ( (dx²+dz²)/a² + dy²/b² ) = 1
r = 1 / sqrt( (dx²+dz²)/a² + dy²/b² )
シーン座標系(第3章参照)ではY軸が極方向(ECEF Z軸)に対応するため、 赤道面の成分は dx² + dz²(シーンXとシーンZ)となり、これが長半径 a の項に入ります。 一方、dy(シーンY = 極方向)は短半径 b の項に入ります。
表面距離ベースの指数関数ズームでは、 ホイール1回の操作で表面距離が一定割合(ZOOM_FACTOR=0.25、つまり25%)変化します。 宇宙からでも地表近くでも、同じ操作量で同じ「倍率感」のズームが得られます。
const MIN_SURFACE_DISTANCE = 30; // 30m(最接近距離)
const MAX_SURFACE_DISTANCE = WGS84.a * 5; // 最遠距離
const ZOOM_FACTOR = 0.25; // ズーム感度
const ZOOM_SMOOTH = 0.15; // 補間係数処理の流れは4ステップです。
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
const intensity =
Math.min(Math.abs(e.deltaY), 200) / 100;
const factor =
Math.pow(1 + ZOOM_FACTOR, intensity);
const direction =
e.deltaY > 0 ? factor : 1 / factor;
targetSurfaceDistance = Math.max(
MIN_SURFACE_DISTANCE,
Math.min(
MAX_SURFACE_DISTANCE,
targetSurfaceDistance * direction
)
);
};
canvas.addEventListener(
'wheel', handleWheel,
{ passive: false }
);重要なのは「乗算」でズームしている点です。 表面距離が大きいときは大きく動き、小さいときは小さく動くため、 宇宙からでも地表近くでも一貫した操作感が得られます。
ホイール操作で targetSurfaceDistance が即座に変わりますが、
カメラは毎フレーム目標に向かって15%ずつ移動する(ZOOM_SMOOTH=0.15)ことで、 急なジャンプを避けた滑らかなアニメーションになります。
これは線形補間(lerp)の簡易形です。毎フレーム、現在値と目標値の差の15%だけ移動します。 差が大きいほど速く、差が小さいほどゆっくり近づく、指数的な減衰(exponential decay)になります。
距離
| \
| \
| \________
|
+------------------ フレーム
急速に近づき、目標付近でゆっくり収束
// カメラ方向の楕円体半径を動的に計算
const dir = camera.position.clone().normalize();
const globeRadius =
ellipsoidRadiusInDirection(dir);
// ズーム: 楕円体半径 + 目標表面距離へ補間
const targetDist =
globeRadius + targetSurfaceDistance;
const currentDist = camera.position.length();
const smoothed = currentDist
+ (targetDist - currentDist) * ZOOM_SMOOTH;
camera.position.setLength(smoothed);地表に近いときは少しのドラッグで大きく地図が動き、 宇宙からは大きなドラッグで地球全体を回すのが自然です。 回転速度を表面距離に比例させることで、ズームレベルに関わらず 「指で動かした分だけ地面が動く」感覚を維持できます。
// 実際の表面距離に線形比例
const surfaceDistance =
Math.max(0, smoothed - globeRadius);
controls.rotateSpeed =
0.25 * (surfaceDistance / globeRadius);| 表面距離 | 比率 | rotateSpeed |
|---|---|---|
| 30m(最接近) | 0.0000047 | 0.0000012 |
| 6,378km(半径分) | 1.0 | 0.25 |
| 31,890km(半径x5) | 5.0 | 1.25 |
カメラ操作のイベントリスナーは、dispose時に確実に解除します。
Three.jsのOrbitControlsは内部でイベントリスナーを登録しているため、 controls.dispose() を忘れるとメモリリークの原因になります。
return () => {
cancelAnimationFrame(animationId);
canvas.removeEventListener(
'wheel', handleWheel
);
controls.dispose();
geometry.dispose();
material.dispose();
renderer.dispose();
};この章では、地球儀UIに適したカメラ操作を実装しました。
次章では、WGS84座標系とECEF・ENU座標系について、座標変換パイプラインを自前で実装します。
Column: なぜOrbitControlsのズームを無効化するのか
OrbitControlsの標準ズームは camera.position を原点に向かって直線的に移動させます。
これは楕円体を考慮しないため、赤道方向と極方向で「地表に見える」距離が異なります。 本書のカスタムズームは、カメラの向いている方向の楕円体半径を毎フレーム再計算し、
常に正しい「表面からの距離」を基準にしています。