第5章: タイルジオメトリ

タイル座標から球面メッシュを生成

はじめに

GISのラスタタイルは「画像」ですが、球面に貼るには画像を受け止める「面」が必要です。3DではBufferGeometryで頂点・法線・UV・インデックスを自分で定義してポリゴンメッシュを構築します。

GIS特有の重要ポイントがUV座標のメルカトルY補間です。メルカトル投影で作られたタイル画像は緯度方向のピクセル配分が非線形なため、球面メッシュに正しく貼るには補正が必要になります。ワイヤーフレーム表示に切り替えて、メッシュの実体を確認してみてください。

この章で学ぶこと

  • タイル座標(z/x/y)から球面メッシュを生成する方法 —— タイル画像を貼る面をプログラムで作る
  • 頂点(vertices)、法線(normals)、UV座標、インデックスの役割 —— 3Dメッシュを構成する4つのデータ配列
  • メルカトルY補間によるテクスチャ座標の整合 —— メルカトル投影の歪みを補正するGIS特有の処理
  • 変換チェーン: (u,v) → (lon, mercY) → lat → ECEF → シーン座標
  • インデックスの巻き方向(CCW)とバックフェイスカリング
  • z=2(4x4=16タイル)のカラフルなメッシュ表示 —— タイル境界と分割パターンの可視化

表示切替

ワイヤーフレームをONにすると、各タイルが16x16のグリッドに 分割されている様子が確認できます。 これが球面を近似するポリゴンメッシュの実体です。

タイルを球面に「貼る」とは

タイル画像は平面の正方形画像(256x256px)であり、地球は球面(楕円体面)です。 タイルを球面に貼るためには、タイルの範囲を球面上のメッシュ(三角形の集合)として生成し、 タイル画像をテクスチャとして貼り付ける必要があります。

タイル画像(256x256px)   球面メッシュ

+------------------+    /----------\

|                  |  → / / / / / / \

|  OSMタイル      |    / / / / / /   |

|                  |    \ / / / / /  /

+------------------+    \----------/

   UV座標で対応  → 球面座標に変換

なぜタイルごとにメッシュを生成するのか

地球全体を1つの巨大メッシュとして作ると、地表に近づいたときに解像度が足りず、 曲面がカクカクして見えます。 かといって全体を高解像度にすると頂点数が膨大になりパフォーマンスが悪化します。

タイル単位でメッシュを生成すると、カメラの近くだけ細かく、 遠くは粗くというLOD(Level of Detail)制御が可能になります。 これは次の第6章で実装するSSEの基盤になる重要な設計です。

computeTileGeometry関数

球面メッシュの生成は core/tile-geometry.tscomputeTileGeometry 関数が担います。 この関数はThree.jsに一切依存せず、Float32ArrayUint32Array の生データを返します。

パラメータデフォルト意味
x, y, z-タイル座標
segments16分割数(16x16 = 256セル)
heightFnなし各格子点の標高を返す関数

segments = 16 は、PLATEAUプロトタイプの実装を参考にした値です。 16分割で289頂点、512三角形となり、球面の曲率を十分に表現しつつ、GPUへの負荷も適度です。

メッシュの4つの構成要素

Three.jsの BufferGeometry は4つのデータ配列で構成されます:

  • vertices(頂点座標) — 各頂点のx,y,z座標。タイルの緯度経度範囲を segments x segments のグリッドに分割し、 各点をWGS84楕円体面上の3D座標に変換
  • normals(法線) — 各頂点の「外向き」方向ベクトル。 ライティング計算で面の明暗を決める。 原点方向ベクトルの正規化で近似(扁平率が約0.3%と小さいため十分)
  • uvs(テクスチャ座標) — タイル画像のどの位置をこの頂点に対応させるか。 0〜1の範囲で指定。メルカトルY補間が必要(後述)
  • indices(インデックス) — 頂点をどの順序で3つずつ結んで三角形にするか。 グリッドの各セルを2つの三角形に分割
computeTileGeometry の使い方
const data = computeTileGeometry(
  x, y, z,
  16  // segments: 16x16グリッド
);

const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
  'position',
  new THREE.BufferAttribute(data.vertices, 3)
);
geometry.setAttribute(
  'normal',
  new THREE.BufferAttribute(data.normals, 3)
);
geometry.setAttribute(
  'uv',
  new THREE.BufferAttribute(data.uvs, 2)
);
geometry.setIndex(
  new THREE.BufferAttribute(data.indices, 1)
);

頂点生成: メルカトルY空間での補間

頂点生成の核心は、緯度をメルカトルY空間で補間することです。

なぜ緯度を直接線形補間しないのか? タイル画像はメルカトル投影で作られています。メルカトル投影では、 高緯度ほどY方向の引き伸ばしが大きくなります。タイル画像のピクセル座標は メルカトルYに対して線形であり、 緯度に対して線形ではありません。

もし緯度を直接線形補間すると、テクスチャがずれて道路や海岸線が歪んでしまいます。 メルカトルY空間で補間すると、タイル画像のUV座標と球面メッシュの頂点が正しく対応します。

頂点生成の核心部分
const mercYNorth =
  latToMercatorY(bounds.north);
const mercYSouth =
  latToMercatorY(bounds.south);

for (let j = 0; j <= segments; j++) {
  for (let i = 0; i <= segments; i++) {
    const u = i / segments;
    const v = j / segments;
    const lon = bounds.west
      + (bounds.east - bounds.west) * u;
    // メルカトルY空間で補間!
    const mercY = mercYNorth
      + (mercYSouth - mercYNorth) * v;
    const lat = mercatorYToLat(mercY);

    const ecef =
      geodeticToEcef(lat, lon, alt);
    const scene =
      ecefToScenePosition(ecef.x, ecef.y, ecef.z);
    // scene座標をvertices配列に格納
  }
}

変換チェーン

各頂点は以下の変換チェーンで計算されます:

(u, v) → (lon, mercY) → lat → (lat, lon, alt) → ECEF → シーン座標
  • 格子点 (i, j) を正規化座標 (u, v) に変換
  • u から経度を線形補間、v からメルカトルYを線形補間
  • メルカトルYを緯度に逆変換
  • 緯度経度高度をECEF座標に変換
  • ECEF座標をシーン座標に軸変換

法線の計算

地球の表面における法線ベクトルは、原点(地球の中心)から頂点への方向ベクトルに近似できます。 厳密には楕円体の法線は原点方向と一致しませんが、扁平率が小さい(約0.3%)ため、この近似で十分です。

法線の計算
// 球面法線 = 頂点位置の正規化ベクトル
const len = Math.sqrt(
  scene.x*scene.x
  + scene.y*scene.y
  + scene.z*scene.z
);
normals[idx*3]   = scene.x / len;
normals[idx*3+1] = scene.y / len;
normals[idx*3+2] = scene.z / len;

UV座標

UV座標の設定は一見シンプルですが、1 - v という反転に注目してください。

UV座標
uvs[idx*2]   = u;
uvs[idx*2+1] = 1 - v;
  • j = 0(北端) → v = 0 → UV v = 1(テクスチャの上端)
  • j = segments(南端) → v = 1 → UV v = 0(テクスチャの下端)

Three.jsのテクスチャはデフォルトで flipY = true であり、 画像をロード時にY方向へ反転します。その結果、OpenGLのUV座標系(v=0が下、v=1が上)と画像ピクセルの対応が入れ替わります。 タイル画像では北が上端(y=0側)なので、1 - v により北端とUV上端が正しく対応します。

インデックスの生成

各セル(格子の正方形)を2つの三角形に分割します。

a --- b

| \  |

|  \ |

c --- d

三角形1: a → c → b

三角形2: b → c → d

インデックス生成
for (let j = 0; j < segments; j++) {
  for (let i = 0; i < segments; i++) {
    const a = j*(segments+1) + i;
    const b = a + 1;
    const c = (j+1)*(segments+1) + i;
    const d = c + 1;

    indices[ptr++] = a; // 三角形1
    indices[ptr++] = c;
    indices[ptr++] = b;

    indices[ptr++] = b; // 三角形2
    indices[ptr++] = c;
    indices[ptr++] = d;
  }
}

頂点の巻き方向(winding order)は反時計回り(CCW)で、 Three.jsのデフォルトのフロントフェイス判定と一致します。 この順序により、外積(法線方向)が球面の外側を向き、 THREE.FrontSide でレンダリングする際に正しく表示されます。

裏面(地球の内側から見た面)は描画されません。 これはバックフェイスカリングと呼ばれる最適化で、 描画する三角形数を半分に削減します。

数値例(segments = 16)

項目
頂点数(16+1)² = 289
セル数16² = 256
三角形数256 x 2 = 512
インデックス数512 x 3 = 1,536

heightFnパラメータ

heightFn パラメータは、各格子点 (i, j) に対して標高(メートル)を返す関数です。 省略すると標高0(楕円体面上)になります。 この機能は後の章の「地形(Terrain)」機能の基盤となります。

heightFn の使用例
// 全頂点を100km上空に配置
const elevated = computeTileGeometry(
  0, 0, 2, 16, () => 100_000
);

// 格子点に応じた可変標高(地形メッシュ)
const terrain = computeTileGeometry(
  0, 0, 10, 16,
  (i, j) => heightData[j * 17 + i]
);

Three.jsのMesh生成

core/tile-geometry.ts が返した生データを、 Three.jsのMeshに変換するのは renderer/tile-mesh.ts です。 core層とrenderer層の分離により、この変換コードは非常に薄くなっています。

createTileMesh
export function createTileMesh(
  x: number, y: number, z: number,
  texture: THREE.Texture,
  segments: number = 16
): THREE.Mesh {
  const data =
    computeTileGeometry(x, y, z, segments);
  const geometry = new THREE.BufferGeometry();
  // ... 属性の設定 ...
  const material =
    new THREE.MeshStandardMaterial({
      map: texture,
      side: THREE.FrontSide
    });
  return new THREE.Mesh(geometry, material);
}

MeshStandardMaterial を選択している理由は、 ライティングに反応するPBR(物理ベースレンダリング)マテリアルであり、 DirectionalLightによる陰影が自然に表現されるためです。

隣接タイルの継ぎ目

タイルベースのレンダリングで最も重要な品質指標は、 隣接タイル境界での法線の一致です。 境界頂点が同じ経度緯度から生成されることで、シームレスな描画が保証されます。

隣接タイルの法線一致テスト
it('隣接タイルの境界頂点の法線が一致',
() => {
  const left =
    computeTileGeometry(0,0,1, segments);
  const right =
    computeTileGeometry(1,0,1, segments);
  // leftの右端列とrightの左端列が一致
  for (let j = 0; j <= segments; j++) {
    const leftIdx =
      j*(segments+1) + segments;
    const rightIdx =
      j*(segments+1) + 0;
    for (let c=0; c<3; c++)
      expect(left.normals[leftIdx*3+c])
        .toBeCloseTo(
          right.normals[rightIdx*3+c], 5
        );
  }
});

このテストが失敗する(法線が不連続になる)と、 隣接タイル間の境界にライティングの「継ぎ目」が見えてしまいます。

色分け

各タイルのHSL色相はタイル位置に基づいて計算されます。 hue = ((x + y * n) / n²) * 360 で16タイルが虹色グラデーションになり、 タイル境界と分割パターンが直感的に理解できます。

まとめ

この章では、タイルの球面メッシュを生成する処理を実装しました。

  • メルカトルY補間: テクスチャとメッシュを正しく対応させるための鍵
  • 変換チェーン: UV → 経度/メルカトルY → 緯度 → ECEF → シーン座標
  • 法線: 原点方向ベクトルの正規化で近似
  • UV反転: 1 - v でThree.jsのテクスチャ座標系に合わせる
  • インデックス: 各セルを2三角形に分割、CCW巻き順
  • heightFn: 地形メッシュ生成の基盤

次章では、「どのタイルを表示すべきか」を決定するSSEベースのLOD制御とFrustumカリングを実装します。