ラスター + PLATEAU 3D建物モデル
OGC 3D TilesはCesiumが中心となって開発した、大規模3Dデータのストリーミング規格です。GISの3D可視化で広く使われており、PLATEAUのデータ配信にも採用されています。
第6章のSSEによるLOD制御と第3章のECEF座標変換が再び登場し、ここまでの知識が有機的につながります。本章では外部ライブラリに頼らず、tileset.jsonのパースからSSEベースのタイル走査、B3DM/GLBのロードまでを独自実装します。
3D Tilesは、Open Geospatial Consortium (OGC) が策定した3Dジオスパシャルデータのストリーミング配信フォーマットです。もともとはCesiumチームが設計し、現在はOGC標準になっています。
3D Tilesの特徴:
データは tileset.json をルートとする木構造で構成されます。各ノードは以下の情報を持ちます:
SSEによるLOD制御は第6章のラスタータイルと同じ原理です。geometricError とカメラ距離からSSEを計算し、閾値を超えたら子タイルをロードします。
この章ではラスタータイルと3D Tilesの2つのレイヤーを同時に表示します。 それぞれ独立したLOD制御を持ち、 GlobeViewerが毎フレーム統一的に update() を呼び出します。
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 // 上記を統合する制御層外部依存はGLTFLoaderとDRACOLoader(Three.jsのアドオン)のみです。タイルセットの解析、走査、キャッシュ管理は全て自前で実装しています。
| モジュール | 責務 |
|---|---|
| types.ts | Tiles3DNode, SceneBounds, TraversalResult 等の型定義 |
| tileset-parser.ts | tileset.json のフェッチとツリー構築(transform累積、v1.0/v1.1互換) |
| tile-traversal.ts | SSEベースの再帰走査(REPLACE/ADDリファインメント対応) |
| bounding-volume.ts | region/box/sphere → SceneBounds変換、可視判定 |
| tile-cache.ts | LRUキャッシュ、同時リクエスト制限、優先度キュー、遅延破棄 |
| content-loader.ts | B3DMヘッダー解析、GLBロード、RTC_CENTER抽出、行列合成 |
tileset-parser.ts は tileset.json をフェッチし、再帰的にタイルツリーを構築します。各ノードの transform は親の変換行列と乗算して累積し、子ノードに伝播します。
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(中心座標 + 半径)に変換します。
// 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)));変換ルール:
tile-traversal.ts はタイルツリーを再帰的に走査し、カメラ視点から表示すべきタイル(desired)、除去すべきタイル(toRemove)、キャンセルすべきリクエスト(toCancel)を決定します。
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 + 子を再帰走査tile-cache.ts はロード済みタイルのキャッシュ管理と、ネットワークリクエストの制御を担当します。
| 定数 | 値 | 役割 |
|---|---|---|
| MAX_CACHE_SIZE | 128 | キャッシュ最大エントリ数 |
| MAX_CONCURRENT | 6 | 同時HTTPリクエスト上限 |
| DISPOSAL_BUDGET_MS | 2 | 1フレームの破棄時間予算(ms) |
| MAX_DISPOSALS_PER_FRAME | 3 | 1フレームの最大破棄数 |
キャッシュの動作:
protectedKeys(現在シーンに表示中のタイル)はエビクション対象外content-loader.ts はタイルコンテンツの実体をロードします。先頭4バイトのマジックナンバーでB3DM(0x6d643362)かGLB(0x46546c67)を判定します。
// 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オフセットを合成する処理です。
PLATEAUの3D Tilesデータは、glTFモデルにDraco圧縮を使用しています。Dracoはメッシュの頂点データを効率的に圧縮するライブラリ(Google開発)で、ファイルサイズを大幅に削減します。
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の互換性問題が生じる可能性があるため、この動的生成は重要です。
3D TilesのモデルはECEF座標系で配置されています。これをThree.jsのシーン座標に変換するために、Groupに軸変換行列を適用します。
// 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上で頂点変換を行う必要がありません。
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 は Layer 抽象クラスを継承し、Tiles3DAdapter を内部に持つ薄いラッパーです。コールバックを通じてカスタムシェーダーの差し替えに対応します。
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);
}
}注目点:
Partial<Props> & Pick<Props, 'url'> で、urlだけは必ず指定する必要があることを型レベルで表現PLATEAUは国土交通省が主導する日本全国の3D都市モデル整備プロジェクトです。建物、道路、橋梁などの3Dモデルがオープンデータとして公開されています。
このデモでは千代田区のLOD1(建物の箱型モデル)データを使用しています。LOD1は建物のフットプリント(面積)と高さのみの簡略モデルで、LOD2(屋根形状あり)やLOD3(窓・ドアなどの詳細)と比べてデータサイズが小さく、広範囲の表示に向いています。
new Tiles3DLayer({
url: 'https://assets.cms.plateau.reearth.io/assets/.../tileset.json'
})errorTarget: 16(デフォルト)は、SSE閾値に相当するパラメータで、値が小さいほど高品質(より詳細なタイルをロード)になりますが、同時に表示タイル数とリクエスト数も増えます。
この章では、3D Tilesのロード・LOD制御を外部ライブラリに頼らず独自実装しました。