タイル座標から球面メッシュを生成
GISのラスタタイルは「画像」ですが、球面に貼るには画像を受け止める「面」が必要です。3DではBufferGeometryで頂点・法線・UV・インデックスを自分で定義してポリゴンメッシュを構築します。
GIS特有の重要ポイントがUV座標のメルカトルY補間です。メルカトル投影で作られたタイル画像は緯度方向のピクセル配分が非線形なため、球面メッシュに正しく貼るには補正が必要になります。ワイヤーフレーム表示に切り替えて、メッシュの実体を確認してみてください。
ワイヤーフレームをONにすると、各タイルが16x16のグリッドに 分割されている様子が確認できます。 これが球面を近似するポリゴンメッシュの実体です。
タイル画像は平面の正方形画像(256x256px)であり、地球は球面(楕円体面)です。 タイルを球面に貼るためには、タイルの範囲を球面上のメッシュ(三角形の集合)として生成し、 タイル画像をテクスチャとして貼り付ける必要があります。
タイル画像(256x256px) 球面メッシュ
+------------------+ /----------\
| | → / / / / / / \
| OSMタイル | / / / / / / |
| | \ / / / / / /
+------------------+ \----------/
UV座標で対応 → 球面座標に変換
地球全体を1つの巨大メッシュとして作ると、地表に近づいたときに解像度が足りず、 曲面がカクカクして見えます。 かといって全体を高解像度にすると頂点数が膨大になりパフォーマンスが悪化します。
タイル単位でメッシュを生成すると、カメラの近くだけ細かく、 遠くは粗くというLOD(Level of Detail)制御が可能になります。 これは次の第6章で実装するSSEの基盤になる重要な設計です。
球面メッシュの生成は core/tile-geometry.ts の computeTileGeometry 関数が担います。
この関数はThree.jsに一切依存せず、Float32Array と Uint32Array の生データを返します。
| パラメータ | デフォルト | 意味 |
|---|---|---|
| x, y, z | - | タイル座標 |
| segments | 16 | 分割数(16x16 = 256セル) |
| heightFn | なし | 各格子点の標高を返す関数 |
segments = 16 は、PLATEAUプロトタイプの実装を参考にした値です。
16分割で289頂点、512三角形となり、球面の曲率を十分に表現しつつ、GPUへの負荷も適度です。
Three.jsの BufferGeometry は4つのデータ配列で構成されます:
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空間で補間すると、タイル画像の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配列に格納
}
}各頂点は以下の変換チェーンで計算されます:
地球の表面における法線ベクトルは、原点(地球の中心)から頂点への方向ベクトルに近似できます。 厳密には楕円体の法線は原点方向と一致しませんが、扁平率が小さい(約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座標の設定は一見シンプルですが、1 - v という反転に注目してください。
uvs[idx*2] = u;
uvs[idx*2+1] = 1 - v;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 でレンダリングする際に正しく表示されます。
裏面(地球の内側から見た面)は描画されません。 これはバックフェイスカリングと呼ばれる最適化で、 描画する三角形数を半分に削減します。
| 項目 | 値 |
|---|---|
| 頂点数 | (16+1)² = 289 |
| セル数 | 16² = 256 |
| 三角形数 | 256 x 2 = 512 |
| インデックス数 | 512 x 3 = 1,536 |
heightFn パラメータは、各格子点 (i, j) に対して標高(メートル)を返す関数です。
省略すると標高0(楕円体面上)になります。 この機能は後の章の「地形(Terrain)」機能の基盤となります。
// 全頂点を100km上空に配置
const elevated = computeTileGeometry(
0, 0, 2, 16, () => 100_000
);
// 格子点に応じた可変標高(地形メッシュ)
const terrain = computeTileGeometry(
0, 0, 10, 16,
(i, j) => heightData[j * 17 + i]
);core/tile-geometry.ts が返した生データを、
Three.jsのMeshに変換するのは renderer/tile-mesh.ts です。
core層とrenderer層の分離により、この変換コードは非常に薄くなっています。
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タイルが虹色グラデーションになり、 タイル境界と分割パターンが直感的に理解できます。
この章では、タイルの球面メッシュを生成する処理を実装しました。
次章では、「どのタイルを表示すべきか」を決定するSSEベースのLOD制御とFrustumカリングを実装します。