Slippy Mapタイル座標系の球面上可視化
メルカトル投影とSlippy Mapタイル座標はGIS技術者にとって既知の概念ですが、2D地図では正方形タイルを平面に並べるだけなのに対し、3Dでは球面上の曲線として描画する必要があります。
この章では、平面で見慣れたタイルグリッドが球面ではどう見えるかを3Dで観察できます。ズームレベルを切り替えると、タイルの分割パターンが球面上でどう変化するかを直感的に理解できます。
タイル数: 16 (4 x 4)
地球は3次元の楕円体ですが、地図タイルは2次元の正方形画像です。 3次元の曲面を2次元の平面に変換する操作を地図投影(map projection)と呼びます。 投影法には多くの種類がありますが、Webの地図タイルシステムではほぼ例外なく メルカトル投影法が使われています。
メルカトル投影法は、1569年にゲラルドゥス・メルカトルが考案した正角円筒図法です。 「正角」とは、地図上の任意の点で局所的な角度が保存される(局所的に小さな図形の形状が保存される)ことを意味します。
経度 lam(ラジアン)、緯度 phi(ラジアン)をメルカトル座標 (x, y) に変換する式:
x = a * lam
y = a * ln( tan( pi/4 + phi/2 ) )
ここで a はWGS84の長半径(6,378,137m)です。 経度方向はそのまま線形に対応しますが、 緯度方向は非線形変換を使います。 この変換により高緯度ほど引き伸ばされ、 グリーンランドがアフリカより大きく見えるという歪みが生じます。
export function lonLatToMercator(
lon: number, lat: number
): MercatorCoord {
const lam = degToRad(lon);
const phi = degToRad(clampLatitude(lat));
return {
x: lam * WGS84.a,
y: WGS84.a * Math.log(
Math.tan(Math.PI/4 + phi/2)
)
};
}逆変換の緯度計算は、以下の等式に基づいています:
export function mercatorToLonLat(
mx: number, my: number
): { lon: number; lat: number } {
const lam = mx / WGS84.a;
const phi = 2 * Math.atan(
Math.exp(my / WGS84.a)
) - Math.PI / 2;
return {
lon: radToDeg(lam),
lat: radToDeg(phi)
};
}メルカトルY座標の式 y = ln(tan(pi/4 + phi/2)) において、 phi → ±90° のとき y → ±∞ となります。極点では投影が無限遠に発散するのです。
タイル地図システムでは、地図全体を正方形に収める必要があります。 メルカトル座標のX範囲は [-pi*a, pi*a] です。 Y方向も同じ範囲に収まる緯度を求めると:
pi*a = a * ln( tan(pi/4 + phi_max/2) )
phi_max = 2 * atan(exp(pi)) - pi/2
= 85.0511°
つまり、±85.05°はメルカトル地図が正方形になる境界の緯度です。 南極大陸の内陸部は描画対象外になりますが、実用上の影響は限定的です。
export const MAX_MERCATOR_LATITUDE =
85.051_128_78;
export function clampLatitude(
lat: number
): number {
return Math.max(
-MAX_MERCATOR_LATITUDE,
Math.min(MAX_MERCATOR_LATITUDE, lat)
);
}Column: 厳密メルカトルとWebメルカトルの違い
厳密なメルカトル投影法では、楕円体の離心率 e を考慮した補正項が入ります。 しかし、Webメルカトル(EPSG:3857)は地球を球として扱い、この補正を省略しています。 これはGoogleが2005年にGoogle Mapsを開発した際の設計判断です。 EPSG(測地系コード管理機関)は当初、この「不正確な」投影法にコード付与を拒否していました。 最大で約0.33%の誤差がありますが、タイル地図の用途では十分な精度です。
タイル座標系での緯度の補間(第5章で使用)のために、
WGS84スケーリングなしの「正規化メルカトルY」関数を用意しています。 lonLatToMercator との違いは、a(赤道半径)を掛けないことです。
export function latToMercatorY(
lat: number
): number {
const phi = degToRad(clampLatitude(lat));
return Math.log(
Math.tan(Math.PI/4 + phi/2)
);
}タイルの球面メッシュ生成(第5章)では、「タイル範囲内で緯度をメルカトルY空間で線形補間する」必要があり、 このときスケーリングは不要です。 タイル画像はメルカトル投影されており、タイル画像のピクセルY座標はメルカトルYに対して線形です。 もし緯度空間で等間隔に分割すると、高緯度ほどテクスチャが引き伸ばされ、タイル画像との対応がずれます。
Web地図で広く使われるタイル座標系は、Slippy Map Tilesと呼ばれ、 OpenStreetMapが標準化しました。基本的な仕組みは以下の通りです。
タイル座標は左上が原点(0,0)で、x は右方向(東)、y は下方向(南)に増加します。
| ズームレベル | タイル数 | 赤道での1タイル幅 |
|---|---|---|
| 0 | 1 | 40,075 km |
| 1 | 4 | 20,038 km |
| 4 | 256 | 2,505 km |
| 8 | 65,536 | 156 km |
| 12 | 16,777,216 | 9.8 km |
| 16 | 4,294,967,296 | 610 m |
X座標の計算は直感的です。経度を0〜360の範囲に正規化し、 2^z 個のタイルに均等に振り分けます。
Y座標はメルカトル投影を経由するため、やや複雑です。 Y座標は北が0であることに注意してください。
export function lonLatToTile(
lon: number, lat: number, zoom: number
): TileCoord {
const latRad = degToRad(
clampLatitude(lat)
);
const n = 2 ** zoom;
const x = Math.floor(
((lon + 180) / 360) * n
);
const y = Math.floor(
((1 - Math.log(
Math.tan(latRad)
+ 1/Math.cos(latRad)
) / Math.PI) / 2) * n
);
return {
x: Math.max(0, Math.min(n-1, x)),
y: Math.max(0, Math.min(n-1, y)),
z: zoom
};
}タイル (x, y, z) の左上角の経度緯度を返します。
右下角を得るには (x+1, y+1, z) を渡します。 緯度の逆変換で使われる Math.sinh は、 sinh(x) = (exp(x) - exp(-x))/2 であり、メルカトルY→緯度の逆変換を簡潔に表現しています。
export function tileToLonLat(
x: number, y: number, z: number
): { lon: number; lat: number } {
const n = 2 ** z;
const lon = (x / n) * 360 - 180;
const latRad = Math.atan(
Math.sinh(
Math.PI * (1 - (2*y) / n)
)
);
return { lon, lat: radToDeg(latRad) };
}タイルの地理的な範囲は、tileToLonLat を2回呼ぶことで求められます。 この TileBounds は後続の章で頻繁に使われます:
タイルの球面メッシュ生成(第5章)、MVTタイルのジオメトリ変換など。
export function tileBounds(
x: number, y: number, z: number
): TileBounds {
const nw = tileToLonLat(x, y, z);
const se = tileToLonLat(x+1, y+1, z);
return {
west: nw.lon, east: se.lon,
north: nw.lat, south: se.lat
};
}タイルサーバーは、z/x/y のパスでタイル画像を配信する規約に従います。 TileUrlGenerator 型を定義し、関数として切り出すことで、タイルソースの切り替えが容易になっています。
export type TileUrlGenerator = (
z: number, x: number, y: number
) => string;
// OpenStreetMapタイルURL
export const osmTileUrl:
TileUrlGenerator = (z, x, y) =>
`https://tile.openstreetmap.org/$
{z}/${ x}/${ y}.png`;
// 国土地理院標準タイルURL
export const gsiTileUrl:
TileUrlGenerator = (z, x, y) =>
`https://cyberjapandata.gsi.go.jp/
xyz/std/${ z}/${ x}/${ y}.png`;Column: TMS vs XYZ
タイル座標のY軸の向きには2つの規約があります。 XYZ(Slippy Map Tiles)ではY=0が北端で、本書で使用しています。 TMS(Tile Map Service)ではY=0が南端です(Y_TMS = 2^z - 1 - Y_XYZ)。 OSM、Google Maps、Mapbox等はXYZを採用しており、Web地図ではXYZが事実上の標準です。
球面上にタイルの境界線を描画する際、辺を直線で結ぶと球面の曲率でずれてしまうため、 各辺を20点サンプリングして曲線として近似しています。
const bounds = tileBounds(tx, ty, z);
// 北辺: west→eastを20分割して点を生成
for (
let i = 0; i <= EDGE_SAMPLES; i++
) {
const lon = bounds.west
+ (bounds.east - bounds.west)
* (i / EDGE_SAMPLES);
points.push(
geoToSceneVec3(
bounds.north, lon, TILE_ALTITUDE
)
);
}隣接タイルの区別のため、 (tx + ty) % 8 で8色のパレットから色を割り当てています。 チェッカーボード的に色が変わるため、タイルの境界が視覚的に明確です。
タイルラインは楕円体の表面ではなく、 50km上空(TILE_ALTITUDE = 50000)に描画しています。 楕円体表面と完全に同一の位置に配置すると、
深度値が同じになり描画順序が不安定になるZファイティングが発生するためです。
z ≤ 3のときは各タイルの中心に「z/x/y」のラベルが表示されます。 CanvasテクスチャでSprite(常にカメラに正面を向くビルボード)を生成し、 タイルの中心上空30kmに配置しています。 z ≥ 4ではタイル数が多くなりすぎるためラベルは非表示にしています。
この章では、Webメルカトル投影法とタイル座標系を実装しました。
次章では、このタイル座標から球面メッシュを生成し、タイル画像をテクスチャとして貼り付ける処理を実装します。