WebGLRenderer / PerspectiveCamera / ライティング基礎
GISではLeafletやMapLibreなどのライブラリがScene・Camera・Rendererを内部で処理してくれますが、Three.jsではこれらを自分で組み立てる必要があります。GIS
APIの new Map() に相当する初期化処理を、部品ごとに理解しながら構築していきます。
ライティングは2D地図にはない3D固有の概念です。面の角度と光の方向の組み合わせで立体感が生まれる仕組みを体験し、地球サイズの球体がスクリーンに描画されるまでの流れを掴みます。
WebGISエンジンの描画エンジンとして、生のWebGLではなくThree.jsを選択しています。理由は明確です。
一方で、Three.jsに過度に依存しないよう、座標計算などの数学的ロジックは core/ に分離しています。 この設計により、描画エンジンの差し替えやテストが容易になっています。
Three.jsでは最低限、Scene(3Dオブジェクトの入れ物)、 Camera(どこから見るか)、 Renderer(画面に描画する仕組み)の3つが必要です。 この章ではこれらを順番にセットアップし、地球サイズの球体を表示するところまでを扱います。
本書の実装ではこれらを ThreeRenderer クラスに集約しています。
このクラスが担う責務は、(1) WebGLRendererの生成、(2) Sceneと背景色の設定、(3) PerspectiveCameraの初期配置、(4)
ライティング、(5) OrbitControlsの設定、(6) カスタムズーム、(7) 毎フレームのrender処理です。
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精度を確保しています。
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
logarithmicDepthBuffer: true
});
renderer.setSize(w, h);
renderer.setPixelRatio(window.devicePixelRatio);通常のWebGLの深度バッファ(Zバッファ)は、near〜farの範囲を線形に分割します。 しかし、WebGISでは以下のような極端な距離範囲を扱う必要があります。
固定のnear/farでは、宇宙からの俯瞰時(far ≈ 数万km)と地表近接時(near ≈ 数cm)の両方を1つの比率で扱う必要があり、 線形な深度バッファではZ-fighting(異なる深度の面が交互にちらつく現象)が発生します。 本書ではカメラの表面距離に応じてnear/farを毎フレーム再計算し、near/far比を常に適切な範囲に保っています。
対数深度バッファは、深度値を対数スケールで格納することで解決します。数式で表すと:
Three.jsでは logarithmicDepthBuffer: true を指定するだけで有効になります。
Column: 対数深度バッファのコスト
対数深度バッファにはGPUパフォーマンス上のわずかなコストがあります。 フラグメントシェーダで深度値を対数変換する処理が追加されるためです。 しかし、地球規模のシーンではZ-fightingの回避が不可欠であり、 このコストは十分に許容範囲です。CesiumJSでは対数深度バッファを採用していますが、マルチフラスタムとの併用やGPU倍精度エミュレーションを組み合わせたより高度な実装になっています。
Sceneは3Dオブジェクトの入れ物(コンテナ)です。地球、ライト、カメラなど、
すべてのオブジェクトをSceneに追加していきます。 背景色には暗い紺色(0x000011)を設定し、宇宙空間を表現しています。
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x000011);PerspectiveCamera は人間の目に近い遠近感を持つカメラです。 4つのパラメータを設定します:
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種類のライトを組み合わせることで、立体感のある照明を実現しています。
0x888888 = やや暗め)。 DirectionalLightが当たらない影の部分が完全に黒くならないようにするフィルライト// 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個程度)。
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)繰り返し描画します。 この章では静的なシーンですが、後の章でカメラ移動やアニメーションが入ると、
毎フレーム状態を更新してから描画する形になります。
const animate = () => {
animationId = requestAnimationFrame(animate);
renderer.render(scene, camera);
};
animate();本書の実装では、レンダーループを RenderLoop クラスとして分離しています。 GlobeViewer 側では、コールバック内で各レイヤーの update と ThreeRenderer.render を呼び出します。
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が描画します。
this.loop = new RenderLoop(() => {
const ctx = this.createContext();
for (const layer of this.layers)
layer.update(ctx);
this.threeRenderer.render();
});ブラウザウィンドウのリサイズに対応するため、3つの処理を同期的に行う必要があります。 どれか1つでも欠けると、リサイズ後に描画が歪みます。
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のリソースを確実に解放します。
return () => {
cancelAnimationFrame(animationId);
geometry.dispose(); // GPU上の頂点バッファ解放
material.dispose(); // マテリアル解放
renderer.dispose(); // WebGLコンテキスト解放
};dispose() を呼ぶことで、GPU上に確保されたバッファやテクスチャが解放されます。
JavaScriptのガベージコレクションだけではGPUメモリは解放されないため、明示的な破棄が必要です。
この章では、Three.jsの基本セットアップとして以下を実装しました。
logarithmicDepthBuffer: true で対数深度バッファを有効化requestAnimationFrame による毎フレーム更新次章では、OrbitControls によるカメラ操作と、 地球の形状(楕円体)を考慮したカスタムズームの実装を解説します。