ラスター + ベクトルタイルのレイヤースタック
MVT(Mapbox Vector Tiles)はGISのデファクトスタンダードで、MapLibreやQGISでの利用経験がある方も多いでしょう。3Dではタイルローカル座標(0〜4096の整数)から球面ジオメトリへの変換を自前で実装する必要があります。
レイヤースタックはGISのレイヤーパネルにおける「重ね順」に相当します。ラスタータイルの上にベクトルデータを重ねる構造で、2D地図では不要な高度オフセット(Zファイティング対策)など3D固有の問題への対処も学びます。
ラスタータイル(第7章)は画像ファイルです。地図の見た目が固定されており、クライアント側でスタイルを変更できません。ベクトルタイルは、地理的なフィーチャー(道路、建物、河川など)のジオメトリとプロパティをバイナリで格納したタイルです。クライアント側でデコードし、任意のスタイルで描画できます。
MVT(Mapbox Vector Tile)は、Mapboxが設計したベクトルタイルフォーマットで、Protocol Buffers(protobuf)でエンコードされています。
.mvt ファイル
+-- VectorTile
+-- layers[] <- レイヤー(streets, buildings, water等)
+-- features[] <- フィーチャー
+-- type <- 1:Point, 2:LineString, 3:Polygon
+-- geometry <- 座標データ(extent空間)
+-- properties <- 属性データ各レイヤーには extent というパラメータがあります(通常4096)。フィーチャーの座標は (0, 0) 〜 (extent, extent) の整数値で表現されます。タイルの左上が
(0, 0)、右下が (extent, extent) です。
MVTのデコードには @mapbox/vector-tile ライブラリと pbf ライブラリを使います。
import { VectorTile } from '@mapbox/vector-tile';
import Pbf from 'pbf';
fetch(item.url)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.arrayBuffer();
})
.then((buf) => {
const tile = new VectorTile(new Pbf(buf));
// tile.layers でレイヤーにアクセス
});OSMF(OpenStreetMap Foundation)が配信するShortbreadスキーマのMVTタイルを使用しています。ocean、water_polygons、streets、buildings、boundaries、land などのレイヤーを含みます。
MVTの座標は (0, 0) 〜 (extent, extent)
の整数空間にあります。これを球面上の3D座標に変換するのが mvtToScene 関数です。
const ALTITUDE_OFFSET = 50; // 50m: z-fighting防止
function mvtToScene(
px: number, py: number,
extent: number,
bounds: { west, east, north, south },
mercYNorth: number, mercYSouth: number
): THREE.Vector3 {
const u = px / extent;
const v = py / extent;
const lon = bounds.west + (bounds.east - bounds.west) * u;
const mercY = mercYNorth + (mercYSouth - mercYNorth) * v;
const lat = mercatorYToLat(mercY);
const ecef = geodeticToEcef(lat, lon, ALTITUDE_OFFSET);
const s = ecefToScenePosition(ecef.x, ecef.y, ecef.z);
return new THREE.Vector3(s.x, s.y, s.z);
}変換チェーンはタイルメッシュ生成と同じです:
MVT座標 (px, py) --> (u, v) --> (lon, mercY) --> lat --> ECEF --> シーン座標重要なのは以下の2点です:
GlobeViewerの layers 配列にレイヤーを追加するだけで、 描画順序が自動管理されます。 配列の先頭が最も下(背面)、末尾が最も上(前面)に描画されます。
const viewer = new GlobeViewer({
canvas: canvasEl,
layers: [
new RasterTileLayer(),
new VectorTileLayer()
]
});MVTのフィーチャーは3種類のジオメトリタイプを持ちます。ライン(type = 2:
LineString)は連続する頂点のペアを LineSegments(線分の列)として格納します。
function buildLineGeometry(
feature: VectorTileFeature,
extent, bounds, mercYNorth, mercYSouth
): Float32Array | null {
const geom = feature.loadGeometry();
const positions: number[] = [];
for (const ring of geom) {
for (let i = 0; i < ring.length - 1; i++) {
const a = mvtToScene(ring[i].x, ring[i].y, ...);
const b = mvtToScene(ring[i+1].x, ring[i+1].y, ...);
positions.push(a.x, a.y, a.z, b.x, b.y, b.z);
}
}
if (positions.length === 0) return null;
return new Float32Array(positions);
}ポリゴン(type = 3)は輪郭線と塗りつぶしの両方で描画しています。頂点変換のキャッシュ共有により、MVT座標からシーン座標への変換(三角関数を含む重い処理)を一度だけ行います。
convertFeatureVertices(feature, ...)
|
+--> buildPolygonOutlineFromCache(rings) --> 輪郭線
|
+--> buildPolygonFillFromCache(rings) --> 塗りつぶしearcutライブラリ(Mapbox開発、最悪計算量はO(n²)だが実用的にはほぼ線形時間で動作する高速な三角分割)によるポリゴン塗りつぶしを実装しています。
import earcut from 'earcut';
function buildPolygonFillFromCache(
rings: ScenePoint[][]
): { positions: Float32Array; indices: Uint32Array } | null {
const coords: number[] = [];
const holeIndices: number[] = [];
for (let r = 0; r < rings.length; r++) {
if (r > 0) holeIndices.push(coords.length / 3);
for (const pt of rings[r]) {
coords.push(pt.x, pt.y, pt.z);
}
}
const indices = earcut(coords, holeIndices, 3);
return {
positions: new Float32Array(coords),
indices: new Uint32Array(indices)
};
}earcutアルゴリズムの特徴:
holeIndices で穴の開始位置を指定)dim=3 で3D座標をそのまま渡せる)塗りつぶしには MeshBasicMaterial(ライティング無し)を使い、DoubleSide で球面のどちら側から見ても可視にしています。opacity は半透明にして、下のラスタータイルが透けて見えるようにしています。
コラム: earcutの球面上での制約
earcutは本来2D三角分割のためのアルゴリズムですが、3D座標を直接渡すことで球面上のポリゴンにも適用できます。ただし、高緯度でのメルカトル歪みにより三角分割の品質が低下する可能性があります。また、タイルをまたぐような巨大ポリゴンでは自己交差のリスクがあります。本書の実装はタイル単位の局所的なポリゴンを対象としているため、実用上は問題になりにくい範囲です。
通常のThree.jsの LineSegments では線幅を変更できません(WebGLの制約)。太い線を描画するため、Three.jsのサンプルに含まれる LineSegments2 を使用しています。
同じスタイル(色・線幅・透明度)のマテリアルは複数タイルで使い回せるため、プール方式で管理しています。
const lineMaterialPool = new Map<string, LineMaterial>();
function getPooledLineMaterial(
color: number, linewidth: number,
opacity: number, resolution: THREE.Vector2
): LineMaterial {
const key = `${color}-${linewidth}-${opacity}`;
let mat = lineMaterialPool.get(key);
if (!mat) {
mat = new LineMaterial({
color, linewidth,
transparent: opacity < 1,
opacity, depthTest: true,
resolution
});
lineMaterialPool.set(key, mat);
}
return mat;
}resolution は全マテリアルで共有する THREE.Vector2 を参照渡しするため、ビューポートサイズの変更が全マテリアルに自動反映されます。
レイヤーごとの描画スタイルはオブジェクトで定義されています。スタイルにないレイヤーは continue でスキップされるため、不要なレイヤーの描画を抑制するフィルタとしても機能します。
// デフォルトでは streets レイヤーのみ描画
style: {
streets: { color: 0xdddddd, linewidth: 2 }
}
// 複数レイヤーを描画する場合は追加可能:
style: {
ocean: { color: 0x4488cc, opacity: 0.6 },
water_polygons: { color: 0x4488cc, opacity: 0.6 },
streets: { color: 0xdddddd, linewidth: 2 },
buildings: { color: 0xaaaaaa, opacity: 0.5 },
boundaries: { color: 0xccaa44 },
land: { color: 0x44aa44, opacity: 0.3 }
}デフォルトでは道路ネットワーク(streets)のみをライン描画します。レイヤーを増やすほど描画負荷が上がるため、必要に応じて段階的に追加する設計です。
ポリゴンの塗りつぶしでは fillOpacity = (styleEntry.opacity ?? 1) * 0.5 と計算されます。スタイルで指定した opacity のさらに半分を塗りつぶしの透明度にすることで、下のラスタータイルが透けて見える程度の半透明描画になります。
ポリゴン描画で THREE.DoubleSide を使う理由は、球面上のポリゴンがカメラの位置によっては裏面から見えるケースがあるためです。ラスタータイルでは FrontSide(バックフェイスカリング有効)でしたが、ベクトルデータは法線方向が一定でないため、両面描画が安全です。
MVTのデコードと座標変換はラスタータイル(テクスチャのロードのみ)よりCPU負荷が高いため、VectorTileLayerには更新頻度を制限するスロットリングが実装されています。
const UPDATE_THROTTLE_MS = 200;
update(ctx: LayerContext): void {
if (!cameraMatrixChanged(ctx.camera, this.lastCameraMatrix))
return;
const now = performance.now();
if (now - this.lastUpdateTime < UPDATE_THROTTLE_MS) {
// スロットリング中 --> 遅延更新をスケジュール
if (!this.pendingUpdate) {
this.pendingUpdate = true;
setTimeout(() => {
this.pendingUpdate = false;
this.doUpdate(ctx);
}, UPDATE_THROTTLE_MS);
}
return;
}
this.doUpdate(ctx);
}update() と doUpdate() の分離設計:
pendingUpdate フラグにより、遅延更新の重複スケジュールを防止しています。
MVTグループのリソース解放は、3種類のオブジェクトを適切にdisposeする必要があります。
export function disposeMvtGroup(group: THREE.Group): void {
for (const child of group.children) {
if (child instanceof LineSegments2) {
child.geometry.dispose();
// LineMaterialはプールが管理するためdisposeしない
} else if (child instanceof THREE.Points
|| child instanceof THREE.Mesh) {
child.geometry.dispose();
(child.material as THREE.Material).dispose();
}
}
group.clear();
}ベクトルタイルのジオメトリをラスタータイルの球面メッシュと 完全に同じ高さに配置すると、GPUが描画順序を正しく判断できず Zファイティング(チラつき)が発生します。
これを避けるため、ベクトルタイルは地表から 50m上空にオフセットして配置しています。 50mは地表に近づいても視覚的に不自然にならず、 かつZファイティングを確実に防げる距離として選ばれています。
この章では、MVTベクトルタイルの描画を実装しました。