第7章: ラスタータイルレイヤー

OpenStreetMapタイルを球面に貼り付ける

はじめに

Leafletなら L.tileLayer(url).addTo(map) の1行で済むタイル表示が、3Dエンジンの内部でどのように実現されているかを体験する章です。第1〜6章で積み上げてきた知識がここで結実します。

GlobeViewerはGISのMapクラスに相当するファサードで、レンダラー・カメラ・コントロール・レイヤー管理を1つのインターフェースに統合します。非同期ロード、LRUキャッシュ、親子タイル遷移など、2D地図ライブラリが内部で行っている最適化の仕組みも学びます。

この章で学ぶこと

  • RasterTileLayer のアーキテクチャと構成要素
  • LRUキャッシュ付きタイルテクスチャローダーの設計
  • 同時リクエスト制限とキューイング
  • 差分更新(前フレームとの差分で追加・削除を判定)
  • 親子タイルの遷移(子のロード完了まで親を保持)
  • レイヤーのライフサイクル管理 —— initialize / update / dispose の共通パターン

全体の構成

RasterTileLayerは、前章のSSE選択、タイルメッシュ生成、そしてこの章で解説するテクスチャローダーを統合するレイヤーです。

RasterTileLayerの構成
RasterTileLayer
  |
  +-- selectVisibleTiles()  <- tile-tree.ts(第6章)
  |     | 必要なタイルの一覧
  +-- 差分更新ロジック
  |     +-- 追加すべきタイル --> loadTile()
  |     +-- 削除すべきタイル --> removeTile()
  +-- TileTextureLoader    <- tile-loader.ts
  |     +-- LRUキャッシュ
  |     +-- 同時リクエスト制限
  |     +-- キャンセル機構
  +-- createTileMesh()      <- tile-mesh.ts
        | THREE.Mesh
      group.add(mesh)

GlobeViewerファサード

ここまでの章では、レンダラー・カメラ・コントロール・描画ループを 各ページで直接組み立てていました。 GlobeViewerはこれらを1つのクラスにまとめた ファサード(facade)パターンの実装です。

利用側は「キャンバスとレイヤーの配列を渡して start() するだけ」で、 内部で ThreeRenderer(カメラ・コントロール・照明)、 RenderLoop(毎フレーム更新)、 LayerContext(レイヤーへの情報提供)が自動的に連携します。

ch07/+page.svelte
const viewer = new GlobeViewer({
  canvas: canvasEl,
  layers: [
    new RasterTileLayer()
  ]
});
viewer.start();

// クリーンアップ時
viewer.dispose();

TileTextureLoader

テクスチャのロードは TileTextureLoader クラスが担います。WebGISでは大量のHTTPリクエストが発生するため、単純に TextureLoader.load() を呼ぶだけでは問題が生じます:

  • ブラウザの同時接続制限(HTTP/2でも上限あり)を超過する
  • 不要になったタイルのリクエストが帯域を消費する
  • 毎回ネットワークアクセスが発生し、低速になる
tile-loader.ts - TileTextureLoader
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キャッシュの仕組み

LRU(Least Recently Used)キャッシュは 最近使ったタイルをメモリに保持し、 容量(256枚)を超えたら最も古いものから順に破棄する仕組みです。 キャッシュヒット時は即座にPromiseを解決し、ネットワークアクセスを回避します。

tile-loader.ts - load()
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回目以降のアクセスはほぼ瞬時です。

キャッシュの管理: touchCacheとevict

tile-loader.ts - キャッシュ管理
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)を超えたら最古のエントリを追い出します。

orphanedマップ: 安全なテクスチャ解放

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() を再帰的に呼ぶことで、キューが順次消化されます。

tile-loader.ts - 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();
      }
    );
  }
}

キャンセル機構

カメラの移動により不要になったタイルのロードを即座にキャンセルし、帯域を節約します。

tile-loader.ts - cancel()
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します。

差分更新: cameraMatrixChanged

RasterTileLayer.update() は毎フレーム呼ばれますが、タイルの追加・削除は「前フレームからの差分」でのみ行います。まずカメラの変更を検出します。

tile-layer-utils.ts - cameraMatrixChanged
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 を返す)場合に効率的です。

buildTileSyncPlan: 差分計算の共通化

差分更新のロジックは tile-layer-utils.ts に共通化されており、RasterTileLayerとVectorTileLayerの両方が使います。

tile-layer-utils.ts - buildTileSyncPlan
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タイル

最適化ポイント

  1. カメラ変更チェック: cameraMatrixChanged() で16要素を比較。変更なしなら全スキップ
  2. セット比較: buildTileSyncPlan 内で setsEqual() を使い、前フレームと同一なら差分計算もスキップ
  3. ロード優先順: toAdd.sort((a, b) => a.z - b.z) で低ズーム(画面上で大きい)タイルを先にロード
  4. 不要リクエストのキャンセル: toCancel で不要になったpendingリクエストを即座にキャンセルし、帯域を節約

親子タイルの遷移

ズームインすると、親タイルが4つの子タイルに置き換わります。しかし、子タイルのロードには時間がかかるため、ロード完了まで親タイルを表示し続ける必要があります。

tile-layer-utils.ts - hasChildrenPending
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: 全子ロード完了
  --> 親を削除

タイルURL

OSMタイルは以下のURL形式で256x256pxのPNG画像として配信されています:

タイルURL
https://tile.openstreetmap.org/{z}/{x}/{y}.png

例えば z=2, x=3, y=1 のタイルは /2/3/1.png でリクエストされます。

リソース解放

RasterTileLayer - removeTile
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つのキー操作関数があります。

tile-layer-utils.ts - キー操作
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  (ルートに親はない)

まとめ

この章では、ラスタータイルレイヤーの全体を実装しました。

  • TileTextureLoader: LRUキャッシュ(256枚)+ 同時リクエスト制限(6)+ キャンセル機構
  • 差分更新: 前フレームとの集合演算で追加・削除を判定
  • カメラ変更検出: 行列比較でスキップ可能なフレームを特定
  • 親子遷移: 子タイルのロード完了まで親タイルを保持
  • リソース管理: Geometry・Material・Textureの確実なdispose