第2章: カメラ操作

OrbitControls + 表面距離ベースのカスタムズーム

はじめに

Web地図のドラッグ・ズームは地図ライブラリが標準提供しますが、3Dでは地球儀UIに適したカスタマイズが必要です。OrbitControlsの組み込みズームは単純なカメラ距離の増減で、宇宙視点と地表近くの操作で体感が大きく違ってしまいます。

Web地図のズームレベル切替が3D空間では「カメラと楕円体表面の距離制御」として実装されます。表面距離ベースの指数関数ズームにより、どの高度でも一貫した操作感が得られる仕組みを体験します。

この章で学ぶこと

  • OrbitControls によるドラッグ回転操作 —— 地球儀を手で回すようなインタラクション
  • 組み込みズームを無効化し、表面距離ベースのカスタムズーム実装 —— Web地図のズームレベルに近い体験を3Dで再現
  • WGS84楕円体の方向別半径計算 —— 扁平楕円体を正しく扱うGIS的配慮
  • ズーム速度の滑らかな補間(exponential smoothing) —— 指数減衰による自然なカメラアニメーション
  • 表面距離に応じた回転速度の動的調整 —— 地表近くでは細かく、宇宙からは大胆に操作

操作方法

ドラッグ — 地球を回転

ホイール — ズーム(30m〜地球半径x5)

ThreeRendererのオプション設計

カメラ操作の実装に入る前に、ThreeRenderer のコンストラクタ引数を確認します。 最新の実装では、単に canvas を受け取るのではなく、 ThreeRendererOptions インターフェースを通じてオプションを受け取る設計になっています。

ThreeRendererOptions
export interface ThreeRendererOptions {
  canvas: HTMLCanvasElement;
  initialPosition?: {
    lat: number;
    lon: number;
    altitude?: number;
  };
}

initialPosition オプションで、カメラの初期位置を緯度・経度・高度で指定できます。 省略した場合のデフォルトは東京(lat=35.68, lon=139.75)です。 コンストラクタ引数をオブジェクト形式にした設計理由は2つあります。

  • 名前付きパラメータの明確化: 何を渡しているかが呼び出し側で明確になる
  • 拡張性: 将来的にオプションが増えても、既存のコードを変更せずにプロパティを追加できる

OrbitControlsの基本設定

Three.jsの OrbitControls はマウスやタッチでカメラを操作するためのヘルパーです。 原点を中心にカメラを軌道(orbit)回転させるのが基本動作ですが、 デフォルトのズームとパン操作は地球儀的なUIには不向きなため、無効化しています。

OrbitControls
const controls = new OrbitControls(
  camera,
  renderer.domElement
);
controls.enableDamping = true;    // 慣性による滑らかな回転
controls.enableZoom = false;      // 標準ズームを無効化
controls.enablePan = false;       // パン操作も無効化

ポイントは3つです。

  • enableZoom = false: OrbitControlsの標準ズームを無効化し、カスタムズームに置き換えます。 標準ズームはカメラと原点の距離に基づく線形な動きで、地球の表面からの距離感が直感的でありません。
  • enablePan = false: 地球儀UIではパン(平行移動)は意味をなしません。 回転操作で代替できます。
  • enableDamping = true: 慣性(ダンピング)を有効にし、 ドラッグ後にゆっくり減速する自然な動きを実現します。 ただし damping を使う場合は毎フレーム 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楕円体は以下の方程式で表されます:

x²/a² + y²/a² + z²/b² = 1

方向ベクトル d = (dx, dy, dz) の方向での楕円体の半径 r は、 r * d が楕円体上にある条件から求まります:

r = 1 / sqrt( (dx² + dz²)/a² + dy²/b² )

シーン座標ではY軸が極方向(ECEFのZ)に対応しているため、 式中で dir.y が b の項に入ります。

ellipsoidRadiusInDirection
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ステップです。

  • スクロール量の正規化: deltaYを0〜2の範囲に正規化し、極端なスクロールを抑制
  • 指数的なファクター計算: (1 + 0.25)^intensity で、スクロール量に比例したズーム倍率を算出
  • 方向の決定: スクロールダウン(deltaY > 0)でズームアウト、スクロールアップでズームイン
  • 表面距離の更新: 現在の表面距離に倍率を乗算し、30m〜最大値の範囲にクランプ
ホイールハンドラ
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)ことで、 急なジャンプを避けた滑らかなアニメーションになります。

smoothed = current + (target - current) x 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);

回転速度の動的調整

地表に近いときは少しのドラッグで大きく地図が動き、 宇宙からは大きなドラッグで地球全体を回すのが自然です。 回転速度を表面距離に比例させることで、ズームレベルに関わらず 「指で動かした分だけ地面が動く」感覚を維持できます。

rotateSpeed
// 実際の表面距離に線形比例
const surfaceDistance =
  Math.max(0, smoothed - globeRadius);
controls.rotateSpeed =
  0.25 * (surfaceDistance / globeRadius);
表面距離比率rotateSpeed
30m(最接近)0.00000470.0000012
6,378km(半径分)1.00.25
31,890km(半径x5)5.01.25

リソースのクリーンアップ

カメラ操作のイベントリスナーは、dispose時に確実に解除します。 Three.jsのOrbitControlsは内部でイベントリスナーを登録しているため、 controls.dispose() を忘れるとメモリリークの原因になります。

dispose
return () => {
  cancelAnimationFrame(animationId);
  canvas.removeEventListener(
    'wheel', handleWheel
  );
  controls.dispose();
  geometry.dispose();
  material.dispose();
  renderer.dispose();
};

まとめ

この章では、地球儀UIに適したカメラ操作を実装しました。

  • OrbitControls: 回転のみ有効化、標準ズームとパンは無効
  • 楕円体半径計算: カメラ方向ごとにWGS84楕円体の表面位置を算出
  • カスタムズーム: 表面距離を乗算で変更し、指数的な操作感を実現
  • 補間: 毎フレーム15%ずつ目標に近づく滑らかなアニメーション
  • 回転速度調整: 表面距離に比例させ、ズームレベルに依存しない操作感を実現

次章では、WGS84座標系とECEF・ENU座標系について、座標変換パイプラインを自前で実装します。

Column: なぜOrbitControlsのズームを無効化するのか

OrbitControlsの標準ズームは camera.position を原点に向かって直線的に移動させます。 これは楕円体を考慮しないため、赤道方向と極方向で「地表に見える」距離が異なります。 本書のカスタムズームは、カメラの向いている方向の楕円体半径を毎フレーム再計算し、 常に正しい「表面からの距離」を基準にしています。