第9章: 3D Tilesの統合

ラスター + PLATEAU 3D建物モデル

はじめに

OGC 3D TilesはCesiumが中心となって開発した、大規模3Dデータのストリーミング規格です。GISの3D可視化で広く使われており、PLATEAUのデータ配信にも採用されています。

第6章のSSEによるLOD制御と第3章のECEF座標変換が再び登場し、ここまでの知識が有機的につながります。本章では外部ライブラリに頼らず、tileset.jsonのパースからSSEベースのタイル走査、B3DM/GLBのロードまでを独自実装します。

この章で学ぶこと

  • 3D Tilesフォーマットの概要と構造
  • 独自ローダーの6モジュール構成と設計
  • tileset.json のパースとツリー構築
  • SSEベースのタイル走査アルゴリズム
  • B3DM/GLBのコンテンツロードと行列合成
  • LRUキャッシュと同時リクエスト制限
  • Draco圧縮への対応
  • ECEF → シーン座標の行列変換
  • PLATEAU 3D Tilesデータの統合

3D Tilesとは

3D Tilesは、Open Geospatial Consortium (OGC) が策定した3Dジオスパシャルデータのストリーミング配信フォーマットです。もともとはCesiumチームが設計し、現在はOGC標準になっています。

3D Tilesの特徴:

  • 階層LOD: タイルセット全体がツリー構造で、距離に応じた詳細度制御が組み込まれている
  • バウンディングボリューム: 各タイルにバウンディング球やバウンディングボックスが定義される
  • コンテンツ形式: glTF/GLB(3Dモデル)、ポイントクラウド、3D Tilesの入れ子など
  • ECEF座標系: タイルの位置はECEF座標系で指定される

OGC 3D Tiles規格のデータ構造

データは tileset.json をルートとする木構造で構成されます。各ノードは以下の情報を持ちます:

  • boundingVolume — このタイルが占める空間範囲(box, region, sphere)
  • geometricError — このタイルの幾何学的な粗さ(m単位)
  • content.uri — 実際のメッシュデータ(.glb等)のURL
  • children — より詳細な子タイルの配列

SSEによるLOD制御は第6章のラスタータイルと同じ原理です。geometricError とカメラ距離からSSEを計算し、閾値を超えたら子タイルをロードします。

2レイヤースタック

この章ではラスタータイルと3D Tilesの2つのレイヤーを同時に表示します。 それぞれ独立したLOD制御を持ち、 GlobeViewerが毎フレーム統一的に update() を呼び出します。

ch09/+page.svelte
const viewer = new GlobeViewer({
  canvas: canvasEl,
  layers: [
    new RasterTileLayer(),
    new Tiles3DLayer({
      url: '...tileset.json'
    })
  ]
});

独自ローダーのモジュール構成

本書では外部ライブラリを使わず、3D Tilesのロード・LOD制御を独自に実装しています。renderer/tiles3d/ディレクトリ内の6つのモジュールがそれぞれ明確な責務を担います。

モジュール構成図
renderer/tiles3d/
├── types.ts            // 型定義(Tiles3DNode, SceneBounds 等)
├── tileset-parser.ts   // tileset.json → ツリー構築
├── tile-traversal.ts   // SSEベースのタイル走査
├── bounding-volume.ts  // BV → SceneBounds 変換
├── tile-cache.ts       // LRUキャッシュ + リクエスト管理
└── content-loader.ts   // B3DM/GLBパース + GLTFロード

renderer/
└── tiles3d-adapter.ts  // 上記を統合する制御層

外部依存はGLTFLoaderDRACOLoader(Three.jsのアドオン)のみです。タイルセットの解析、走査、キャッシュ管理は全て自前で実装しています。

モジュール責務
types.tsTiles3DNode, SceneBounds, TraversalResult 等の型定義
tileset-parser.tstileset.json のフェッチとツリー構築(transform累積、v1.0/v1.1互換)
tile-traversal.tsSSEベースの再帰走査(REPLACE/ADDリファインメント対応)
bounding-volume.tsregion/box/sphere → SceneBounds変換、可視判定
tile-cache.tsLRUキャッシュ、同時リクエスト制限、優先度キュー、遅延破棄
content-loader.tsB3DMヘッダー解析、GLBロード、RTC_CENTER抽出、行列合成

tileset.json のパースとツリー構築

tileset-parser.ts は tileset.json をフェッチし、再帰的にタイルツリーを構築します。各ノードの transform は親の変換行列と乗算して累積し、子ノードに伝播します。

tileset-parser.ts - buildNode(抜粋)
function buildNode(
  tileJson, parentTransform, parentRefine, baseUrl, depth, parent
): Tiles3DNode {
  // transform の累積計算
  let worldTransform = parentTransform;
  if (tileJson.transform) {
    const local = new Float64Array(tileJson.transform);
    worldTransform = multiplyMatrices(parentTransform, local);
  }

  // refine の継承(未指定なら親から引き継ぐ)
  const refine = tileJson.refine
    ? tileJson.refine.toUpperCase() : parentRefine;

  // content URI の解決 (v1.0: content.url, v1.1: content.uri)
  const rawUri = tileJson.content?.uri ?? tileJson.content?.url;
  const contentUri = rawUri ? resolveUri(rawUri, baseUrl) : null;

  // 子ノードを再帰的に構築
  for (const childJson of tileJson.children ?? []) {
    node.children.push(
      buildNode(childJson, worldTransform, refine, baseUrl, depth + 1, node)
    );
  }
}

v1.0 と v1.1 の互換性は、content URI の解決(content.uri ?? content.url)と refine の継承処理で吸収しています。Float64Array による行列演算で倍精度を維持し、ECEF座標系での誤差を抑えています。

バウンディングボリュームの変換

bounding-volume.ts は3D Tiles仕様の3種類のバウンディングボリューム(region, box, sphere)を、走査や可視判定に使える SceneBounds(中心座標 + 半径)に変換します。

bounding-volume.ts - region変換
// region: [west, south, east, north, minH, maxH](ラジアン)
// 18点サンプリング(経度3×緯度3×高度2)でバウンディング球を生成
const lonSamples = [west, (west + east) / 2, east];
const latSamples = [south, (south + north) / 2, north];
const hSamples = [minH, maxH];

for (const lon of lonSamples)
  for (const lat of latSamples)
    for (const h of hSamples)
      points.push(ecefToScenePosition(...geodeticToEcef(lat, lon, h)));

変換ルール:

  • region: 18点をECEF→シーン座標に変換し、外接球を算出。仕様によりtransformは適用しない
  • box: worldTransformで中心をECEF変換し、3半軸の長さからバウンディング球半径を算出
  • sphere: worldTransformで中心を変換し、半径はそのまま使用

SSEベースのタイル走査

tile-traversal.ts はタイルツリーを再帰的に走査し、カメラ視点から表示すべきタイル(desired)、除去すべきタイル(toRemove)、キャンセルすべきリクエスト(toCancel)を決定します。

tile-traversal.ts - visit アルゴリズム
function visit(node: Tiles3DNode): void {
  // 1. バウンディングボリューム → SceneBounds
  const bounds = toSceneBounds(node.boundingVolume, node.worldTransform);

  // 2. ホライズン + フラスタムカリング
  if (!isVisible(bounds, frustumPlanes, cameraPosition, cameraDistance))
    return;

  // 3. SSE計算
  const sse = computeSSE(node.geometricError, distance, screenHeight, fovRad);

  // 4. リファインメント判定
  const shouldRefine = sse > sseThreshold && hasChildren;

  if (!shouldRefine) {
    // このタイルで十分 → desired に追加
    desired.push(node);
  } else if (node.refine === 'REPLACE') {
    // 全可視子がready → 子を表示、そうでなければ親にフォールバック
    ...
  } else {
    // ADD: 親も子も表示
    desired.push(node);
    for (const child of node.children) visit(child);
  }
}

REPLACE戦略では、子タイルがまだロードされていない間は親タイルをフォールバックとして表示し、全可視子がready(キャッシュ済みまたはシーン内)になった時点で子に切り替えます。これにより画面のちらつきを抑えます。フォールバック時は直接の子のみをdesiredに追加し、再帰的に深い階層へは進みません。子がロード完了すれば次のトラバーサルパスで正規パス経由で深い階層に進みます。

desiredリストにはmaxDesired(デフォルト64)の上限があり、アクティブなタイル(既にシーンにあるもの)は常に追加される一方、新規タイルは上限内でのみ追加されます。最終的にdesiredはカメラ距離の昇順でソートされ、近いタイルから優先的にロードされます。

visit(node)
│
├── BV → SceneBounds 変換
├── ホライズンカリング(地平線の裏側を除外)
├── フラスタムカリング(視錐台の外を除外)
├── SSE = geometricError × screenHeight / (distance × 2 × tan(fov/2))
│
├── SSE ≤ 閾値 → desired に追加(このタイルで十分)
│
└── SSE > 閾値(リファインメント必要)
    ├── REPLACE: 全可視子 ready? → 子を走査
    │            not ready?    → 親 + 子を desired(フォールバック)
    └── ADD:     親を desired + 子を再帰走査

LRUキャッシュと同時リクエスト制限

tile-cache.ts はロード済みタイルのキャッシュ管理と、ネットワークリクエストの制御を担当します。

定数役割
MAX_CACHE_SIZE128キャッシュ最大エントリ数
MAX_CONCURRENT6同時HTTPリクエスト上限
DISPOSAL_BUDGET_MS21フレームの破棄時間予算(ms)
MAX_DISPOSALS_PER_FRAME31フレームの最大破棄数

キャッシュの動作:

  • LRUエビクション: キャッシュがMAX_CACHE_SIZEに達すると、最も古いアクセス順のエントリを除去。ただしprotectedKeys(現在シーンに表示中のタイル)はエビクション対象外
  • 優先度キュー: 同時リクエスト上限を超えた場合、SSEが大きいタイルから優先的にロード
  • 遅延破棄: Object3Dのdisposeはフレームバジェット内で段階的に実行し、フレーム落ちを防止
  • 失敗記録: ロード失敗したキーを記録し、無限リトライを防止

B3DM/GLBのコンテンツロード

content-loader.ts はタイルコンテンツの実体をロードします。先頭4バイトのマジックナンバーでB3DM(0x6d643362)かGLB(0x46546c67)を判定します。

content-loader.ts - B3DMヘッダー解析
// B3DM Header (28 bytes):
//   [0-3]   magic "b3dm"
//   [4-7]   version (1)
//   [8-11]  byteLength
//   [12-15] featureTableJSONByteLength
//   [16-19] featureTableBinaryByteLength
//   [20-23] batchTableJSONByteLength
//   [24-27] batchTableBinaryByteLength

// Feature Table JSON から RTC_CENTER を抽出
const featureTableJson = JSON.parse(decoder.decode(featureTableJsonBytes));
if (featureTableJson.RTC_CENTER) {
  rtcCenter = featureTableJson.RTC_CENTER;
}

// GLB 内の CESIUM_RTC 拡張もチェック(B3DM の RTC_CENTER より優先)
const cesiumRtc = extractCesiumRtcFromGlb(glbBuffer);

RTC_CENTER(Relative-To-Center)は、ECEF座標系での原点オフセットです。頂点座標を原点近傍の小さな値で表現し、float32の精度問題を回避するための仕組みです。B3DMのFeature TableとGLBのCESIUM_RTC拡張の両方をチェックします。

ロードされたObject3Dには、worldTransform × T(rtcCenter) × R(Y-up→Z-up) の合成行列が適用されます。glTFのY-up規約からECEFのZ-up座標系に変換しつつ、タイルの累積transformとRTCオフセットを合成する処理です。

DRACO圧縮への対応

PLATEAUの3D Tilesデータは、glTFモデルにDraco圧縮を使用しています。Dracoはメッシュの頂点データを効率的に圧縮するライブラリ(Google開発)で、ファイルサイズを大幅に削減します。

content-loader.ts - Draco設定
export class ContentLoader {
  private readonly gltfLoader: GLTFLoader;
  private readonly dracoLoader: DRACOLoader;

  constructor() {
    this.dracoLoader = new DRACOLoader();
    this.dracoLoader.setDecoderPath(
      `https://unpkg.com/three@0.${REVISION}.0/examples/jsm/libs/draco/gltf/`
    );
    this.gltfLoader = new GLTFLoader();
    this.gltfLoader.setDRACOLoader(this.dracoLoader);
  }
}

DRACOLoader はWebAssemblyデコーダーを使用します。REVISION 定数を使ってThree.jsのバージョンに自動追従する形でデコーダーURLを生成しています。デコーダーとThree.jsのバージョンが不一致だとWebAssemblyの互換性問題が生じる可能性があるため、この動的生成は重要です。

ECEF → シーン座標の行列変換

3D TilesのモデルはECEF座標系で配置されています。これをThree.jsのシーン座標に変換するために、Groupに軸変換行列を適用します。

tiles3d-adapter.ts - 行列変換
// ECEF --> シーン座標の軸変換行列を group に適用
// ecefToScenePosition: (x, y, z) --> (x, z, -y)
const ecefToScene = new THREE.Matrix4().set(
  1,  0,  0,  0,   // ECEF X --> シーン X
  0,  0,  1,  0,   // ECEF Z --> シーン Y
  0, -1,  0,  0,   // ECEF Y --> シーン -Z
  0,  0,  0,  1    // 同次座標
);
this.group.applyMatrix4(ecefToScene);
this.group.add(this.tileGroup);

この行列を group に適用すると、配下の tileGroup(およびその中の全モデル)が自動的にシーン座標に変換されます。Three.jsの変換行列の継承機構を活用した方法です。

なぜ関数ではなく行列か

第3章の ecefToScenePosition 関数は個々の頂点を変換するものでしたが、3D TilesではContentLoaderがGLTFLoaderから受け取った大量の頂点を持つObject3Dをそのまま扱います。個々の頂点に関数を適用することは不可能です。

代わりに、Three.jsのGroupに行列を設定することで、GPU側でモデル変換行列(model matrix)として適用されます。CPU上で頂点変換を行う必要がありません。

Tiles3DAdapter: 統合層

tiles3d-adapter.ts は上記6モジュールを統合する制御層です。毎フレームの update() で以下の処理フローを実行します。

adapter.update(camera, renderer)
│
├── processDisposalQueue()         // 遅延破棄を処理
├── カメラ変更チェック              // 前フレームと行列比較
├── カメラ移動スロットリング        // 200ms間隔で更新を制限
│
├── setProtectedKeys(activeKeys)   // 表示中タイルをLRUエビクションから保護
├── readyKeys = active ∪ cached    // REPLACE判定用のready集合を構築
│
├── traverseTileset()              // SSEベースのツリー走査
│     ├── BV → SceneBounds
│     ├── ホライズン + フラスタムカリング
│     ├── SSE計算 + リファインメント判定
│     ├── REPLACE: readyKeysで安定的に子/親を切替
│     └── desired / toRemove / toCancel 生成
│
├── キャンセル処理                  // toCancel のリクエストを中断
├── シーン除去                      // toRemove をtileGroupから除去
├── キャッシュヒット → シーン追加    // desired & cached → add
└── キャッシュミス → ロードリクエスト // desired & !cached → request

カメラが移動していない場合は走査をスキップし、不要なCPU負荷を避けます。cameraMatrixChangedは前フレームのカメラ行列をFloat32Arrayで保持し、要素ごとに比較しています。カメラ移動に起因する更新は200ms間隔でスロットリングしますが、タイルロード完了時の更新は即座に実行し、REPLACEフォールバックの遷移を遅延なく処理します。

protectedKeysはシーンに表示中のタイルキーの集合で、LRUエビクション時にこれらのタイルが追い出されることを防ぎます。readyKeysはactiveKeys(シーン内)とcachedKeys(キャッシュ済み)の和集合で、REPLACEリファインメントの「全子タイルがready」判定に使います。キャッシュ済みタイルは同一フレーム内で即座にシーンに追加できるため、activeKeysだけでなくcachedKeysも含めることで、トラバーサル結果のフリップフロップ(親子の交互切替)を防いでいます。

Tiles3DLayer

Tiles3DLayerLayer 抽象クラスを継承し、Tiles3DAdapter を内部に持つ薄いラッパーです。コールバックを通じてカスタムシェーダーの差し替えに対応します。

Tiles3DLayer.ts(抜粋)
export class Tiles3DLayer extends Layer<Tiles3DLayerProps> {
  private adapter: Tiles3DAdapter | null = null;

  initialize(ctx: LayerContext): void {
    this.adapter = new Tiles3DAdapter(
      { url, errorTarget, maxDepth, fetchOptions },
      {
        onTilesetLoaded: (sphere) => { ... },
        onLoadError: (error, url) => { ... },
        // シェーダーモード指定時にマテリアル差し替え
        onModelLoaded: (scene) => {
          applyHeightGradient(scene, ...);  // or applyFlatColor / applySunLighting
        },
        onModelDisposed: (scene) => {
          disposeCustomMaterials(scene);
        }
      }
    );
    this.group.add(this.adapter.group);
  }

  update(ctx: LayerContext): void {
    this.adapter?.update(ctx.camera, ctx.renderer);
  }
}

注目点:

  • urlは必須: Partial<Props> & Pick<Props, 'url'> で、urlだけは必ず指定する必要があることを型レベルで表現
  • initializeでAdapterを生成: LayerContextが利用可能になってから初期化
  • コールバックでシェーダー差し替え: onModelLoadedで各タイルのマテリアルをカスタムシェーダーに置換可能
  • updateは委譲するだけ: LOD制御はAdapter内部の独自走査アルゴリズムに任せる

PLATEAUとは

PLATEAUは国土交通省が主導する日本全国の3D都市モデル整備プロジェクトです。建物、道路、橋梁などの3Dモデルがオープンデータとして公開されています。

このデモでは千代田区のLOD1(建物の箱型モデル)データを使用しています。LOD1は建物のフットプリント(面積)と高さのみの簡略モデルで、LOD2(屋根形状あり)やLOD3(窓・ドアなどの詳細)と比べてデータサイズが小さく、広範囲の表示に向いています。

PLATEAUデータの使用
new Tiles3DLayer({
  url: 'https://assets.cms.plateau.reearth.io/assets/.../tileset.json'
})

errorTarget: 16(デフォルト)は、SSE閾値に相当するパラメータで、値が小さいほど高品質(より詳細なタイルをロード)になりますが、同時に表示タイル数とリクエスト数も増えます。

まとめ

この章では、3D Tilesのロード・LOD制御を外部ライブラリに頼らず独自実装しました。

  • 3D Tiles: OGC標準の3Dストリーミングフォーマット、階層LODとECEF座標系
  • 6モジュール構成: types / tileset-parser / tile-traversal / bounding-volume / tile-cache / content-loaderで責務分離
  • tileset-parser: transform累積、v1.0/v1.1互換でツリー構築
  • tile-traversal: SSE計算とREPLACE/ADDリファインメント、readyKeysによる安定的な親子切替、maxDesired上限
  • tile-cache: LRUキャッシュ(protectedKeysによる表示中タイル保護)、同時リクエスト制限、優先度キュー
  • content-loader: B3DMパース、RTC_CENTER抽出、GLTFLoader/DRACOLoader
  • 行列変換: GroupへのECEF → シーン変換行列適用で、GPU側で軸変換
  • PLATEAU: 千代田区LOD1建物モデルの統合