第1章: Three.jsで球体を描く

WebGLRenderer / PerspectiveCamera / ライティング基礎

はじめに

GISではLeafletやMapLibreなどのライブラリがScene・Camera・Rendererを内部で処理してくれますが、Three.jsではこれらを自分で組み立てる必要があります。GIS APIの new Map() に相当する初期化処理を、部品ごとに理解しながら構築していきます。

ライティングは2D地図にはない3D固有の概念です。面の角度と光の方向の組み合わせで立体感が生まれる仕組みを体験し、地球サイズの球体がスクリーンに描画されるまでの流れを掴みます。

この章で学ぶこと

  • WebGLRenderer の初期化と対数深度バッファ —— 地球規模のスケールで描画を安定させる工夫
  • PerspectiveCamera の設定(FOV, near/far) —— GIS APIのMapコンストラクタに相当する視点定義
  • ディレクショナルライト + アンビエントライトの基本照明 —— 2D地図にはない立体感の源泉
  • WGS84楕円体の長半径(6,378,137m)を使った地球サイズの球体 —— GISの基礎パラメータを3Dに持ち込む
  • requestAnimationFrame によるレンダーループ —— 静的な地図表示と異なる、毎フレーム再描画の仕組み

なぜThree.jsか

WebGISエンジンの描画エンジンとして、生のWebGLではなくThree.jsを選択しています。理由は明確です。

  • BufferGeometry / Material / Mesh の抽象化により、頂点バッファやシェーダの管理コードを省略できる
  • OrbitControls などの既成のカメラ操作が利用できる
  • TextureLoader でタイル画像の非同期読み込みが簡潔に書ける
  • WebGPUへの移行パスが用意されている

一方で、Three.jsに過度に依存しないよう、座標計算などの数学的ロジックは core/ に分離しています。 この設計により、描画エンジンの差し替えやテストが容易になっています。

Three.jsの3つの基本要素

Three.jsでは最低限、Scene(3Dオブジェクトの入れ物)、 Camera(どこから見るか)、 Renderer(画面に描画する仕組み)の3つが必要です。 この章ではこれらを順番にセットアップし、地球サイズの球体を表示するところまでを扱います。

本書の実装ではこれらを ThreeRenderer クラスに集約しています。 このクラスが担う責務は、(1) WebGLRendererの生成、(2) Sceneと背景色の設定、(3) PerspectiveCameraの初期配置、(4) ライティング、(5) OrbitControlsの設定、(6) カスタムズーム、(7) 毎フレームのrender処理です。

ThreeRenderer の全体像
export class ThreeRenderer {
  readonly renderer: THREE.WebGLRenderer;
  readonly scene: THREE.Scene;
  readonly camera: THREE.PerspectiveCamera;
  readonly controls: OrbitControls;

  constructor(canvas: HTMLCanvasElement) {
    // Renderer, Scene, Camera,
    // Lighting, Controls を初期化
  }
}

レンダラーの初期化

WebGLRendererはブラウザのWebGL APIを使って3Dシーンを描画します。 初期化時に重要なのが logarithmicDepthBuffer(対数深度バッファ)の有効化です。

通常の深度バッファはnearとfarの間を線形に分割します。 地球規模のシーンではnear/far比が非常に大きくなりやすく、線形バッファでは精度の大部分がカメラ近くに集中し、遠方でZファイティング(面がチラつく現象)が発生します。 対数深度バッファはこの精度を対数的に配分し、広大なスケールでも安定した描画を実現します。 さらに本書では、カメラの表面距離に応じてnear/farを動的に調整し、全ズームレベルで最適なdepth精度を確保しています。

renderer setup
const renderer = new THREE.WebGLRenderer({
  canvas,
  antialias: true,
  logarithmicDepthBuffer: true
});
renderer.setSize(w, h);
renderer.setPixelRatio(window.devicePixelRatio);

対数深度バッファの仕組み

通常のWebGLの深度バッファ(Zバッファ)は、near〜farの範囲を線形に分割します。 しかし、WebGISでは以下のような極端な距離範囲を扱う必要があります。

  • near: 動的に調整(最小0.5m。表面距離 × 0.001)
  • far: 動的に調整(水平線距離 × 2.5、最小で地球半径 × 2)

固定のnear/farでは、宇宙からの俯瞰時(far ≈ 数万km)と地表近接時(near ≈ 数cm)の両方を1つの比率で扱う必要があり、 線形な深度バッファではZ-fighting(異なる深度の面が交互にちらつく現象)が発生します。 本書ではカメラの表面距離に応じてnear/farを毎フレーム再計算し、near/far比を常に適切な範囲に保っています。

対数深度バッファは、深度値を対数スケールで格納することで解決します。数式で表すと:

z_buffer = log(z / near) / log(far / near)

Three.jsでは logarithmicDepthBuffer: true を指定するだけで有効になります。

Column: 対数深度バッファのコスト

対数深度バッファにはGPUパフォーマンス上のわずかなコストがあります。 フラグメントシェーダで深度値を対数変換する処理が追加されるためです。 しかし、地球規模のシーンではZ-fightingの回避が不可欠であり、 このコストは十分に許容範囲です。CesiumJSでは対数深度バッファを採用していますが、マルチフラスタムとの併用やGPU倍精度エミュレーションを組み合わせたより高度な実装になっています。

Sceneの設定

Sceneは3Dオブジェクトの入れ物(コンテナ)です。地球、ライト、カメラなど、 すべてのオブジェクトをSceneに追加していきます。 背景色には暗い紺色(0x000011)を設定し、宇宙空間を表現しています。

scene
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x000011);

カメラ設定

PerspectiveCamera は人間の目に近い遠近感を持つカメラです。 4つのパラメータを設定します:

  • FOV(60°) — 視野角。広いほど広角レンズ的に見える
  • aspect — 画面のアスペクト比(幅÷高さ)
  • near / far — 描画するZ方向の範囲。地球規模のシーンでは、カメラの表面距離に応じて毎フレーム動的に調整する(後述)
camera 初期化
const camera = new THREE.PerspectiveCamera(
  60,              // FOV(度)
  w / h,            // アスペクト比
  1.0,             // near(初期値、render() で動的更新)
  WGS84.a * 10     // far(初期値、render() で動的更新)
);

コンストラクタでの値は初期値に過ぎず、毎フレームのrender()内で表面距離に応じたnear/farに更新します。 nearはmax(surfaceDistance × 0.001, 0.5)、farは水平線距離(√(2Rh))にマージンを乗せた値です。 これにより宇宙からの俯瞰でも地表スレスレでも、深度バッファの精度を最適に保てます。

カメラの初期位置は原点から地球半径の3倍の距離に設定し、原点(地球の中心)を注視しています。 実際のアプリケーションでは、東京上空など具体的な緯度経度をECEF座標に変換して配置しますが(第3章で解説)、 この章ではシンプルにZ軸方向に配置しています。

ライティング

2種類のライトを組み合わせることで、立体感のある照明を実現しています。

  • DirectionalLight — 太陽光のような平行光源。面の角度に応じて明暗が生まれ、球体の立体感が出る。 カメラの子オブジェクトにすることで、カメラがどの方向を向いても常に正面が明るくなる
  • AmbientLight — 全方向からの環境光(0x888888 = やや暗め)。 DirectionalLightが当たらない影の部分が完全に黒くならないようにするフィルライト
lighting
// DirectionalLight: カメラに追従する指向性ライト
const dirLight = new THREE.DirectionalLight(
  0xffffff, 1.0
);
dirLight.position.set(0, 0, 1);
camera.add(dirLight); // カメラの子に
scene.add(camera);    // カメラごとシーンに

// AmbientLight: 全体を一様に照らす環境光
const ambient = new THREE.AmbientLight(
  0x888888
);
scene.add(ambient);

DirectionalLightをカメラの子オブジェクトにする(camera.add(directionalLight))ことで、 カメラがどの方向を向いても、常に「カメラの正面から光が当たる」状態を保ちます。 地球を回転させても、見えている面が常に明るくなります。

球体ジオメトリ

SphereGeometry は球面をポリゴン(三角形)の集合として近似するジオメトリです。 半径にWGS84長半径(約6,378km)を使い、実際の地球と同スケールで描画します。

本書では 1 Three.js単位 = 1メートル としています。 つまり、地球の赤道半径は約6,378,137 Three.js単位です。 CesiumJSも同様に1:1のスケールを採用しています。 スケーリングを導入すると、タイルテクスチャの解像度計算がスケール依存になったり、 3D Tilesなど外部データとの座標整合が複雑になるためです。

セグメント数(64x64)は球面の滑らかさに影響します。 値を大きくするほど滑らかになりますが、頂点数は n x m に比例するため、 パフォーマンスとのバランスで64を選んでいます(頂点数はおよそ4,000個程度)。

sphere
const geometry = new THREE.SphereGeometry(
  WGS84.a,  // 半径 = 6,378,137m
  64, 64    // 水平/垂直セグメント数
);
const material = new THREE.MeshStandardMaterial({
  color: 0x2244aa
});
const sphere = new THREE.Mesh(geometry, material);
scene.add(sphere);

MeshStandardMaterial はライティングに反応するPBR(物理ベースレンダリング)マテリアルです。 DirectionalLightによる陰影が自然に表現されるため、球体に立体感が生まれます。 色は紺色(0x2244aa)を設定し、海のような見た目にしています。

レンダーループ

3Dシーンの描画は1回きりではなく、requestAnimationFrame で 毎フレーム(通常60fps)繰り返し描画します。 この章では静的なシーンですが、後の章でカメラ移動やアニメーションが入ると、 毎フレーム状態を更新してから描画する形になります。

render loop
const animate = () => {
  animationId = requestAnimationFrame(animate);
  renderer.render(scene, camera);
};
animate();

本書の実装では、レンダーループを RenderLoop クラスとして分離しています。 GlobeViewer 側では、コールバック内で各レイヤーの updateThreeRenderer.render を呼び出します。

RenderLoop クラス
export class RenderLoop {
  private animationId = 0;
  private running = false;

  constructor(
    private readonly onFrame: () => void
  ) {
  }

  start(): void {
    if (this.running) return;
    this.running = true;
    const animate = () => {
      if (!this.running) return;
      this.animationId =
        requestAnimationFrame(animate);
      this.onFrame();
    };
    animate();
  }

  stop(): void {
    this.running = false;
    cancelAnimationFrame(this.animationId);
  }
}

update → render の順序が重要です。 レイヤーが先にタイルの追加・削除を行い、その結果をThreeRendererが描画します。

GlobeViewer での使用
this.loop = new RenderLoop(() => {
  const ctx = this.createContext();
  for (const layer of this.layers)
    layer.update(ctx);
  this.threeRenderer.render();
});

リサイズ対応

ブラウザウィンドウのリサイズに対応するため、3つの処理を同期的に行う必要があります。 どれか1つでも欠けると、リサイズ後に描画が歪みます。

resize 処理
function handleResize(w: number, h: number) {
  // 1. アスペクト比を更新
  camera.aspect = w / h;
  // 2. 射影行列を再計算
  camera.updateProjectionMatrix();
  // 3. レンダラーのサイズを変更
  renderer.setSize(w, h);
}

updateProjectionMatrix() を呼ばないと、 内部の射影行列がアスペクト比の変更を反映せず、描画が引き伸ばされたままになります。

クリーンアップ

SPAでページ遷移する場合、前のページのリソースを解放しないとメモリリークが発生します。 SvelteKitでは onMount の戻り値として返した関数がアンマウント時に呼ばれるため、 ここでThree.jsのリソースを確実に解放します。

cleanup
return () => {
  cancelAnimationFrame(animationId);
  geometry.dispose();   // GPU上の頂点バッファ解放
  material.dispose();   // マテリアル解放
  renderer.dispose();   // WebGLコンテキスト解放
};

dispose() を呼ぶことで、GPU上に確保されたバッファやテクスチャが解放されます。 JavaScriptのガベージコレクションだけではGPUメモリは解放されないため、明示的な破棄が必要です。

まとめ

この章では、Three.jsの基本セットアップとして以下を実装しました。

  • WebGLRenderer: logarithmicDepthBuffer: true で対数深度バッファを有効化
  • PerspectiveCamera: near/farを表面距離に応じて動的調整
  • ライティング: カメラ追従のDirectionalLight + 環境光のAmbientLight
  • RenderLoop: requestAnimationFrame による毎フレーム更新
  • リサイズ対応: アスペクト比・射影行列・レンダラーサイズの同期更新

次章では、OrbitControls によるカメラ操作と、 地球の形状(楕円体)を考慮したカスタムズームの実装を解説します。