第8章: MVTベクトルタイル

ラスター + ベクトルタイルのレイヤースタック

はじめに

MVT(Mapbox Vector Tiles)はGISのデファクトスタンダードで、MapLibreやQGISでの利用経験がある方も多いでしょう。3Dではタイルローカル座標(0〜4096の整数)から球面ジオメトリへの変換を自前で実装する必要があります。

レイヤースタックはGISのレイヤーパネルにおける「重ね順」に相当します。ラスタータイルの上にベクトルデータを重ねる構造で、2D地図では不要な高度オフセット(Zファイティング対策)など3D固有の問題への対処も学びます。

この章で学ぶこと

  • MVT(Mapbox Vector Tile)フォーマットの構造
  • Protocol Buffers (PBF) によるデコード
  • MVTタイル座標から球面座標への変換
  • ライン・ポリゴン・ポイントの3種類のジオメトリ描画
  • earcutによるポリゴン三角分割
  • VectorTileLayerの構成とスロットリング
  • 高度オフセットによるZファイティング回避

ベクトルタイルとは

ラスタータイル(第7章)は画像ファイルです。地図の見た目が固定されており、クライアント側でスタイルを変更できません。ベクトルタイルは、地理的なフィーチャー(道路、建物、河川など)のジオメトリとプロパティをバイナリで格納したタイルです。クライアント側でデコードし、任意のスタイルで描画できます。

ラスター
ベクトル
データ形式
PNG/JPEG画像
Protocol Buffers
スタイル変更
不可能
クライアント側で自由
回転・拡大
ピクセルが見える
常に滑らか
データ量
大(画像)
小(座標データ)
デコード負荷
なし
あり(CPU処理)

MVTフォーマットの構造

MVT(Mapbox Vector Tile)は、Mapboxが設計したベクトルタイルフォーマットで、Protocol Buffers(protobuf)でエンコードされています。

MVTファイル構造
.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のデコード

MVTのデコードには @mapbox/vector-tile ライブラリと pbf ライブラリを使います。

mvt-loader.ts - デコード処理
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タイルを使用しています。oceanwater_polygonsstreetsbuildingsboundariesland などのレイヤーを含みます。

MVT座標から球面座標への変換

MVTの座標は (0, 0) 〜 (extent, extent) の整数空間にあります。これを球面上の3D座標に変換するのが mvtToScene 関数です。

mvt-mesh.ts - 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点です:

  • メルカトルY空間での補間: MVTの座標はメルカトル投影空間にあるため、Y方向の補間はメルカトルY空間で行います
  • 高度オフセット(50m): ベクトルデータをラスタータイルの真上(楕円体面から50m浮かせた位置)に描画し、Z-fightingを防ぎます

レイヤースタック

GlobeViewerの layers 配列にレイヤーを追加するだけで、 描画順序が自動管理されます。 配列の先頭が最も下(背面)、末尾が最も上(前面)に描画されます。

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

ジオメトリの構築: ライン

MVTのフィーチャーは3種類のジオメトリタイプを持ちます。ライン(type = 2: LineString)は連続する頂点のペアを LineSegments(線分の列)として格納します。

mvt-mesh.ts - buildLineGeometry
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三角分割

earcutライブラリ(Mapbox開発、最悪計算量はO(n²)だが実用的にはほぼ線形時間で動作する高速な三角分割)によるポリゴン塗りつぶしを実装しています。

mvt-mesh.ts - buildPolygonFillFromCache
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 で穴の開始位置を指定)
  • 3D座標対応(第3引数 dim=3 で3D座標をそのまま渡せる)
  • 高速かつ堅牢な実装

塗りつぶしには MeshBasicMaterial(ライティング無し)を使い、DoubleSide で球面のどちら側から見ても可視にしています。opacity は半透明にして、下のラスタータイルが透けて見えるようにしています。

コラム: earcutの球面上での制約

earcutは本来2D三角分割のためのアルゴリズムですが、3D座標を直接渡すことで球面上のポリゴンにも適用できます。ただし、高緯度でのメルカトル歪みにより三角分割の品質が低下する可能性があります。また、タイルをまたぐような巨大ポリゴンでは自己交差のリスクがあります。本書の実装はタイル単位の局所的なポリゴンを対象としているため、実用上は問題になりにくい範囲です。

ライン描画: LineSegments2とLineMaterialプール

通常のThree.jsの LineSegments では線幅を変更できません(WebGLの制約)。太い線を描画するため、Three.jsのサンプルに含まれる LineSegments2 を使用しています。

同じスタイル(色・線幅・透明度)のマテリアルは複数タイルで使い回せるため、プール方式で管理しています。

mvt-mesh.ts - LineMaterialプール
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 でスキップされるため、不要なレイヤーの描画を抑制するフィルタとしても機能します。

VectorTileLayer - デフォルトスタイル
// デフォルトでは 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(バックフェイスカリング有効)でしたが、ベクトルデータは法線方向が一定でないため、両面描画が安全です。

VectorTileLayerのスロットリング

MVTのデコードと座標変換はラスタータイル(テクスチャのロードのみ)よりCPU負荷が高いため、VectorTileLayerには更新頻度を制限するスロットリングが実装されています。

VectorTileLayer.ts - スロットリング
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() の分離設計:

  • update(): カメラ変更チェック + スロットリング判定のみ
  • doUpdate(): 実際のSSE選択と差分更新

pendingUpdate フラグにより、遅延更新の重複スケジュールを防止しています。

リソース解放: disposeMvtGroup

MVTグループのリソース解放は、3種類のオブジェクトを適切にdisposeする必要があります。

mvt-mesh.ts - disposeMvtGroup
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();
}
  • LineSegments2: Geometryのみdispose(LineMaterialはプール管理のため除外)
  • THREE.Points: GeometryとMaterialの両方をdispose
  • THREE.Mesh: GeometryとMaterialの両方をdispose(ポリゴン塗りつぶし用)

高度オフセット

ベクトルタイルのジオメトリをラスタータイルの球面メッシュと 完全に同じ高さに配置すると、GPUが描画順序を正しく判断できず Zファイティング(チラつき)が発生します。

これを避けるため、ベクトルタイルは地表から 50m上空にオフセットして配置しています。 50mは地表に近づいても視覚的に不自然にならず、 かつZファイティングを確実に防げる距離として選ばれています。

まとめ

この章では、MVTベクトルタイルの描画を実装しました。

  • MVTフォーマット: Protocol Buffersでエンコードされたベクトルデータ
  • デコード: @mapbox/vector-tile + pbf ライブラリ
  • 座標変換: MVT extent空間 → メルカトルY補間 → ECEF → シーン座標
  • ジオメトリ: ライン(LineSegments2)、ポリゴン輪郭+塗りつぶし(earcut)、ポイント(Points)
  • Z-fighting対策: 50mの高度オフセット
  • スタイル: VectorStyleによるレイヤー別の色・線幅・透明度、フィルタ機能
  • スロットリング: 200msの更新間隔制限でCPU負荷を軽減
  • リソース管理: disposeMvtGroupによる3種類のオブジェクトの適切なdispose