OpenStreetMapタイルを球面に貼り付ける
Leafletなら L.tileLayer(url).addTo(map) の1行で済むタイル表示が、3Dエンジンの内部でどのように実現されているかを体験する章です。第1〜6章で積み上げてきた知識がここで結実します。
GlobeViewerはGISのMapクラスに相当するファサードで、レンダラー・カメラ・コントロール・レイヤー管理を1つのインターフェースに統合します。非同期ロード、LRUキャッシュ、親子タイル遷移など、2D地図ライブラリが内部で行っている最適化の仕組みも学びます。
RasterTileLayerは、前章のSSE選択、タイルメッシュ生成、そしてこの章で解説するテクスチャローダーを統合するレイヤーです。
RasterTileLayer
|
+-- selectVisibleTiles() <- tile-tree.ts(第6章)
| | 必要なタイルの一覧
+-- 差分更新ロジック
| +-- 追加すべきタイル --> loadTile()
| +-- 削除すべきタイル --> removeTile()
+-- TileTextureLoader <- tile-loader.ts
| +-- LRUキャッシュ
| +-- 同時リクエスト制限
| +-- キャンセル機構
+-- createTileMesh() <- tile-mesh.ts
| THREE.Mesh
group.add(mesh)ここまでの章では、レンダラー・カメラ・コントロール・描画ループを 各ページで直接組み立てていました。 GlobeViewerはこれらを1つのクラスにまとめた ファサード(facade)パターンの実装です。
利用側は「キャンバスとレイヤーの配列を渡して start() するだけ」で、 内部で ThreeRenderer(カメラ・コントロール・照明)、 RenderLoop(毎フレーム更新)、 LayerContext(レイヤーへの情報提供)が自動的に連携します。
const viewer = new GlobeViewer({
canvas: canvasEl,
layers: [
new RasterTileLayer()
]
});
viewer.start();
// クリーンアップ時
viewer.dispose();テクスチャのロードは TileTextureLoader クラスが担います。WebGISでは大量のHTTPリクエストが発生するため、単純に TextureLoader.load() を呼ぶだけでは問題が生じます:
export class TileTextureLoader {
private readonly loader = new THREE.TextureLoader();
private readonly cache = new Map<string, THREE.Texture>();
private readonly accessOrder: string[] = [];
private activeRequests = 0;
private readonly queue: Array<{ ... }> = [];
private readonly orphaned = new Map<string, THREE.Texture>();
private readonly cancelled = new Set<string>();
constructor(
private readonly urlGenerator = osmTileUrl,
private readonly maxConcurrent = 6,
private readonly maxCacheSize = 256
) {}
}LRU(Least Recently Used)キャッシュは 最近使ったタイルをメモリに保持し、 容量(256枚)を超えたら最も古いものから順に破棄する仕組みです。 キャッシュヒット時は即座にPromiseを解決し、ネットワークアクセスを回避します。
load(z: number, x: number, y: number): Promise<THREE.Texture> {
const key = `${z}/${x}/${y}`;
// キャッシュヒット
const cached = this.cache.get(key);
if (cached) {
this.touchCache(key); // アクセス順を更新
return Promise.resolve(cached);
}
const url = this.urlGenerator(z, x, y);
return new Promise((resolve, reject) => {
this.queue.push({ key, url, resolve, reject });
this.processQueue();
});
}カメラを少し動かした程度では大部分のタイルはキャッシュヒットし、 新規ロードは差分のみで済みます。 ブラウザのHTTPキャッシュとの2段構えにより、 2回目以降のアクセスはほぼ瞬時です。
private touchCache(key: string): void {
const idx = this.accessOrder.indexOf(key);
if (idx !== -1) {
this.accessOrder.splice(idx, 1);
this.accessOrder.push(key); // 末尾に移動 = 最新
}
}
private evict(): void {
while (this.cache.size > this.maxCacheSize) {
const oldKey = this.accessOrder.shift(); // 先頭 = 最古
if (oldKey) {
const tex = this.cache.get(oldKey);
if (tex) {
this.cache.delete(oldKey);
this.orphaned.set(oldKey, tex); // まだメッシュが使っている可能性
}
}
}
}accessOrder 配列で使用順を管理し、maxCacheSize(256)を超えたら最古のエントリを追い出します。
evictされたテクスチャの即時disposeは危険です。以下のシナリオを考えてみてください:
1. タイルAのテクスチャがLRUキャッシュにある
2. キャッシュが満杯になり、タイルAのテクスチャがevictされる
3. しかし、タイルAのMeshはまだsceneに表示されている
4. MeshのmaterialがこのテクスチャをGPUで参照している
--> ここでdisposeすると、表示中のタイルが黒くなるorphaned マップはこの問題を解決します。evict時にテクスチャを orphaned に退避し、メッシュが removeTile() で削除される際に loader.release() を呼び出して初めてdisposeされます。
activeRequests で同時リクエスト数を maxConcurrent(デフォルト6)に制限します。ロード完了時に processQueue() を再帰的に呼ぶことで、キューが順次消化されます。
private processQueue(): void {
while (this.activeRequests < this.maxConcurrent
&& this.queue.length > 0) {
const item = this.queue.shift()!;
// キューに入っている間にキャッシュされた場合
const cached = this.cache.get(item.key);
if (cached) {
this.touchCache(item.key);
item.resolve(cached);
continue;
}
this.activeRequests++;
this.loader.load(
item.url,
(texture) => {
this.activeRequests--;
// キャンセル済みならdispose
if (this.cancelled.has(item.key)) {
this.cancelled.delete(item.key);
texture.dispose();
this.processQueue();
return;
}
texture.colorSpace = THREE.SRGBColorSpace;
this.addToCache(item.key, texture);
item.resolve(texture);
this.processQueue();
},
undefined,
(err) => {
this.activeRequests--;
item.reject(err);
this.processQueue();
}
);
}
}カメラの移動により不要になったタイルのロードを即座にキャンセルし、帯域を節約します。
cancel(key: string): void {
// キューから除去
const idx = this.queue.findIndex((item) => item.key === key);
if (idx !== -1) {
this.queue.splice(idx, 1);
return;
}
// 進行中のロードにはキャンセルフラグを設定
this.cancelled.add(key);
}キューにあるリクエストは直接削除でき、すでに進行中のリクエストにはキャンセルフラグを設定します。ロード完了時にフラグを検出し、テクスチャをdisposeします。
RasterTileLayer.update() は毎フレーム呼ばれますが、タイルの追加・削除は「前フレームからの差分」でのみ行います。まずカメラの変更を検出します。
export function cameraMatrixChanged(
camera: THREE.PerspectiveCamera,
lastCameraMatrix: Float32Array
): boolean {
const current = camera.matrixWorldInverse.elements;
for (let i = 0; i < 16; i++) {
if (lastCameraMatrix[i] !== current[i]) {
lastCameraMatrix.set(current);
return true;
}
}
return false;
}カメラの matrixWorldInverse(ビュー行列)の16要素を前フレームと1つずつ比較します。要素単位の比較で早期リターンする設計は、多くのフレームでカメラが静止している(=
最初の要素で false を返す)場合に効率的です。
差分更新のロジックは tile-layer-utils.ts に共通化されており、RasterTileLayerとVectorTileLayerの両方が使います。
export function buildTileSyncPlan(
params: BuildTileSyncPlanParams
): TileSyncPlan {
const neededKeys = new Set(
params.needed.map((tile) => tileKey(tile.x, tile.y, tile.z))
);
// 前フレームと同じなら差分なし
if (setsEqual(neededKeys, params.previousKeys)) {
return { changed: false, ... };
}
// 追加: neededだがactive/pendingでないタイル(低ズーム優先)
const toAdd = params.needed.filter(...)
toAdd.sort((a, b) => a.z - b.z);
// 削除: activeだがneededでなく、子がpendingでないもの
const toRemove: string[] = [];
// キャンセル: pendingだがneededでないもの
const toCancel: string[] = [];
return { changed: true, neededKeys, toAdd, toRemove, toCancel };
}| TileSyncPlanフィールド | 意味 |
|---|---|
| changed | 差分があったか(falseなら他は空) |
| neededKeys | 今フレームの必要タイルキーセット |
| toAdd | 追加すべきタイル(低ズーム優先ソート済み) |
| toRemove | 削除すべきタイル(子タイルpending考慮済み) |
| toCancel | キャンセルすべきpendingタイル |
cameraMatrixChanged() で16要素を比較。変更なしなら全スキップbuildTileSyncPlan 内で setsEqual() を使い、前フレームと同一なら差分計算もスキップtoAdd.sort((a, b) => a.z - b.z) で低ズーム(画面上で大きい)タイルを先にロードtoCancel で不要になったpendingリクエストを即座にキャンセルし、帯域を節約ズームインすると、親タイルが4つの子タイルに置き換わります。しかし、子タイルのロードには時間がかかるため、ロード完了まで親タイルを表示し続ける必要があります。
export function hasChildrenPending(
parentKeyStr: string,
neededKeys: Set<string>,
isTileActive: (key: string) => boolean
): boolean {
const { x, y, z } = parseTileKey(parentKeyStr);
const childKeys = [
tileKey(x*2, y*2, z+1),
tileKey(x*2+1, y*2, z+1),
tileKey(x*2, y*2+1, z+1),
tileKey(x*2+1, y*2+1, z+1)
];
for (const ck of childKeys) {
if (neededKeys.has(ck) && !isTileActive(ck)) {
return true; // 必要な子がまだアクティブでない
}
}
return false;
}isTileActive をコールバックとして受け取ることで、RasterTileLayerとVectorTileLayerがそれぞれ自身の activeMeshes マップを使って判定できます。
フレーム1: 親タイル(1,0,1)表示中
フレーム2: ズームイン
--> 子タイル(2,0,2),(3,0,2),(2,1,2),(3,1,2)が必要
--> 子をロード開始、親は保持
フレーム3: (2,0,2)ロード完了
--> 親はまだ保持(他の子がpending)
フレーム4: 全子ロード完了
--> 親を削除OSMタイルは以下のURL形式で256x256pxのPNG画像として配信されています:
https://tile.openstreetmap.org/{z}/{x}/{y}.png例えば z=2, x=3, y=1 のタイルは /2/3/1.png でリクエストされます。
private removeTile(key: string): void {
const mesh = this.activeMeshes.get(key);
if (mesh) {
this.group.remove(mesh);
mesh.geometry.dispose(); // 頂点バッファ解放
(mesh.material as THREE.Material).dispose(); // シェーダ解放
this.activeMeshes.delete(key);
this.loader.release(z, x, y); // キャッシュ外テクスチャ解放
}
}タイル削除時には、Groupからの除去、Geometry(頂点バッファ)、Material(シェーダプログラム)をdisposeします。テクスチャはLRUキャッシュに残っている可能性があるため、release() は「キャッシュに無い場合のみ」disposeします。
すべてのレイヤーは共通の Layer 抽象クラスを継承し、 3つのメソッドを実装します:
initialize(ctx) — 初期リソースの確保update(ctx) — 毎フレーム呼ばれる。12msのフレームバジェット内で処理dispose() — 全リソースの解放LayerContextにはカメラ、スクリーン高さ、WebGLレンダラー、 フレームバジェットの残り時間、selectVisibleTilesヘルパーなどが含まれ、 レイヤーが必要な情報にすべてアクセスできます。
差分更新ではタイルを一意に識別するキー文字列が必要です。tile-layer-utils.ts に3つのキー操作関数があります。
tileKey(10, 20, 5) // --> "5/10/20" (z/x/y形式)
parseTileKey("5/10/20") // --> { x: 10, y: 20, z: 5 }
parentKey(10, 20, 5) // --> "4/5/10" (座標を2で割り、zを1下げる)
parentKey(0, 0, 0) // --> null (ルートに親はない)この章では、ラスタータイルレイヤーの全体を実装しました。