Screen Space Errorベースの四分木タイル選択
GISのタイル地図はズームレベルに応じて解像度が切り替わりますが、3DではSSE(Screen Space Error)でカメラからの距離と画面上のピクセル数から自動判定します。CesiumやOGC 3D Tiles規格でも採用されている業界標準の手法です。
2D地図にはないホライズンカリング(地平線の裏側のタイルを除外)やフラスタムカリング(視野外のタイルを除外)など、3D固有の最適化も実装します。カメラを回転・ズームして、タイルの色分けが動的に変わる様子を観察してください。
タイル数: 0
ズーム範囲: z0 - z0
SSE閾値: 400px / 最大ズーム: 6
カメラを回転・ズームすると、左のデモでタイルの色が動的に変わります。 近い部分は高ズーム(青〜紫)、遠い部分は低ズーム(赤〜黄)になっている様子を観察してください。
地球全体をズームレベル18で表示しようとすると、約687億枚のタイルが必要です。当然、すべてを同時にロード・描画することは不可能です。
LOD(Level of Detail)制御は、カメラに近い部分を高解像度で、遠い部分を低解像度で表示する技術です。WebGISでは、「カメラに近い領域ほど高いズームレベルのタイルを使い、遠い領域は低いズームレベルで済ませる」という形で実現します。
タイルのGeometric Errorは、そのタイルが「地表面をどの程度の粗さで近似しているか」を表します。本書では、タイルの赤道での地表幅を使います。
/** タイルの Geometric Error(メートル単位の地表幅) */
export function tileGeometricError(z: number): number {
// 赤道での1タイル幅 [m] = 2*PI * R / 2^z
return (2 * Math.PI * WGS84.a) / (1 << z);
}1 << z はビットシフトによる 2^z の計算で、Math.pow(2, z) より高速です。ズームレベルが1上がるごとにGeometric Errorは半分になります。
| ズームレベル | Geometric Error | 意味 |
|---|---|---|
| 0 | ~40,075 km | 地球全体の幅 |
| 4 | ~2,505 km | 大陸レベル |
| 8 | ~157 km | 地方レベル |
| 12 | ~9.8 km | 都市レベル |
| 16 | ~611 m | 街区レベル |
Geometric Errorは単なる「タイルの地表幅」ではなく、現実の地表面(無限に詳細な理想の表現)に対しての誤差です。もっと細かく分割し続ければ見えたはずの山、都市、海岸線といった情報が正解だとすると、それを1枚で済ませることで最大その幅ぶんの位置的なズレ・省略が生じます。
つまりGeometric Errorは「このタイルで分割を打ち切ったときに、理想の表現から最大どれくらい離れうるか」の上限値です。
コラム: GSDとの違い
タイル画像の1ピクセルあたりの地表距離(GSD: Ground Sample Distance、例えばズームレベル4なら 2,505km / 256 = 9.8km)を誤差とする考え方も直感的にはもっともです。しかし、同じズームレベルのタイルでも256x256の画像を使うか512x512の画像を使うかはデータソース次第です。Quadtreeの分割判定は「この空間領域を分割すべきか」という幾何学的な問いであるため、タイル画像のピクセル数に依存しないタイル幅全体をGeometric Errorとして採用しています。
SSEは、Geometric Error(メートル単位の地表幅)を画面上のピクセル数に換算した指標です。「このタイルの誤差が、ユーザーの画面上で何ピクセルに見えるか」を表し、閾値と比較することで分割/採用を判定します。3D TilesやCesiumJSで広く使われている概念です。
例えば、ズームレベル4のタイル(Geometric Error = 2,505km)を考えます。同じタイルでも、カメラからの距離によってSSEは大きく変わります:
つまりSSEは、Geometric Errorという物理的な誤差を「ユーザーの目にどれだけ粗く映るか」に変換し、分割の要否を判断するための物差しです。
SSE = (g / d) * (h / (2 * tan(theta / 2)))
| 記号 | 意味 | 単位 |
|---|---|---|
| g | Geometric Error(幾何学的誤差) | メートル |
| d | カメラとタイルの距離 | メートル |
| h | スクリーンの高さ | ピクセル |
| theta | カメラの垂直視野角(FOV) | ラジアン |
SSEが大きいほど「誤差が画面上で目立つ」ことを意味します。
/** Screen Space Error を計算 */
export function computeSSE(
geometricError: number,
distance: number,
screenHeight: number,
fovRad: number
): number {
if (distance <= 0) return Infinity;
return (geometricError / distance) *
(screenHeight / (2 * Math.tan(fovRad / 2)));
}distance <= 0 の場合は Infinity を返します。これは、タイルがカメラと重なっている(またはカメラの背後にある)場合を安全に処理するためです。SSEがInfinityなら常に分割されます。
タイル座標系は本質的にQuadtree(四分木)です。ズームレベル0のルートタイル(0,0,0)を根として、各タイルは4つの子タイルを持ちます。SSEベースのLOD制御は、このQuadtreeをルートから走査し、各ノードで「このタイルで十分か、子タイルに分割すべきか」を判定します。
(0,0,0) z=0
/ | (0,0,1) (1,0,1) ... z=1
/ | (0,0,2) (1,0,2) ... z=2export function selectVisibleTiles(
cameraPosition: { x: number; y: number; z: number },
fovRad: number,
screenHeight: number,
frustumPlanes: FrustumPlane[],
sseThreshold = 400,
maxZoom = 6,
maxTiles = 128
): VisibleTile[] {
const result: Array<VisibleTile & { dist: number }> = [];
// ホライズンカリング用の事前計算(ループ外で1回だけ)
const camDist = Math.sqrt(
cameraPosition.x ** 2 +
cameraPosition.y ** 2 +
cameraPosition.z ** 2
);
function traverse(x, y, z): void {
const center = tileCenterScene(x, y, z);
const radius = tileBoundingSphereRadius(x, y, z);
// 1. ホライズンカリング(内積1回で済む)
if (!isAboveHorizon(center, radius, cameraPosition, camDist))
return;
// 2. Frustumカリング(6平面テスト)
if (!sphereInFrustum(center, radius, frustumPlanes))
return;
// 3. SSE判定
const distance = ...; // カメラまでの距離
const sse = computeSSE(
tileGeometricError(z), distance, screenHeight, fovRad
);
if (sse <= sseThreshold || z >= maxZoom) {
result.push({ x, y, z, dist: distance }); // 採用
return;
}
// 4. 4つの子タイルに分割して再帰
traverse(x*2, y*2, z+1);
traverse(x*2+1, y*2, z+1);
traverse(x*2, y*2+1, z+1);
traverse(x*2+1, y*2+1, z+1);
}
traverse(0, 0, 0);
// maxTiles超過時は距離ソートして近いもののみ
return result;
}traverse(0,0,0)
|
+-- ホライズンカリング --> 地球の裏側? --> return
|
+-- Frustum外? --> return(カリング)
|
+-- SSE <= 閾値 or z >= maxZoom? --> result.push(採用)
|
+-- SSE > 閾値 --> 4分割して再帰
+-- traverse(0,0,1)
+-- traverse(1,0,1)
+-- traverse(0,1,1)
+-- traverse(1,1,1)カリングの実行順序はホライズンカリング → Frustumカリング → SSE判定です。ホライズンカリングは内積1回で済むため、6平面テストのFrustumカリングより先に実行してコストを削減します。
sseThreshold = 400 は「タイルの粗さが画面上で400ピクセル以下なら許容する」という意味です。この値が小さいほど高品質(より多くのタイルをロード)、大きいほど低品質(少ないタイルで済む)になります。
maxTiles(デフォルト128)は、一度に表示するタイルの上限数です。Quadtree走査の結果がこの上限を超えた場合、カメラからの距離でソートし、近いタイルのみを残します。
if (result.length > maxTiles) {
result.sort((a, b) => a.dist - b.dist);
result.length = maxTiles;
}この制限の目的は2つあります:
距離ソートにより、カメラに近い(ユーザーが注視している)タイルが優先的に残ります。遠方の低解像度タイルが切り捨てられるため、視覚的な影響は最小限です。
地球は球体であるため、カメラから見て水平線(ホライズン)の向こう側のタイルは絶対に見えません。ホライズンカリングは、この「地球の裏側」のタイルを効率的に除外する最適化です。
カメラ *
/ |
/ 見える|
/ |
------*----------|---- ホライズン(水平線)
接点 見えない
*
タイルカメラから地球への接線が水平線を形成します。接線より手前のタイルは見える可能性があり、接線より向こう側のタイルは地球に遮られて見えません。
地球は球ではなく扁平楕円体のため、ホライズンの位置はカメラの方向によって変わります。カメラ方向の楕円体半径を動的に計算することで、楕円体上での正確なホライズンカリングを実現しています。
/** カメラ方向の楕円体半径を算出する。
* 楕円体方程式: (x²+z²)/a² + y²/b² = 1 */
function ellipsoidRadiusInCameraDir(
dirX: number, dirY: number, dirZ: number
): number {
const a = WGS84.a;
const b = WGS84.b;
return 1 / Math.sqrt(
(dirX * dirX + dirZ * dirZ) / (a * a)
+ (dirY * dirY) / (b * b)
);
}第2章の ellipsoidRadiusInDirection と同じ数学ですが、ここではThree.jsに依存しないスカラー引数版を使っています。
export function isAboveHorizon(
tileCenter: { x: number; y: number; z: number },
tileRadius: number,
cameraPosition: { x: number; y: number; z: number },
cameraDistance: number
): boolean {
// カメラ方向単位ベクトル
const invD = 1 / cameraDistance;
const camDirX = cameraPosition.x * invD;
const camDirY = cameraPosition.y * invD;
const camDirZ = cameraPosition.z * invD;
// カメラ方向の楕円体半径(球体近似ではなく動的計算)
const R = ellipsoidRadiusInCameraDir(
camDirX, camDirY, camDirZ
);
// カメラが楕円体内部にある場合はカリングしない
if (cameraDistance <= R) return true;
// タイル中心のカメラ方向への射影(内積)
const proj = tileCenter.x * camDirX
+ tileCenter.y * camDirY
+ tileCenter.z * camDirZ;
// ホライズンカットオフ距離: R^2 / d
const horizonCut = R * R * invD;
// バウンディングスフィア全体が裏側なら不可視
return proj + tileRadius >= horizonCut;
}カメラが原点から距離 d の位置にあるとき、カメラ方向の楕円体半径を R とすると、楕円体への接線の接点はカメラ方向への射影距離が R^2 / d の位置にあります。R は ellipsoidRadiusInCameraDir で動的に計算されるため、赤道方向と極方向で異なるホライズン位置を正しく反映します。
d
*-----------------* カメラ
| R^2/d
*--- 接点
/
/ R(カメラ方向の楕円体半径)
/
* 原点この R^2/d(コード中のhorizonCut)が、見える/見えないの境界を形成します。タイル中心をカメラ方向に射影した値 proj が horizonCut より小さい場合、タイルは水平線の向こう側にあります。ただし、タイルにはサイズがあるため、バウンディングスフィアの半径 tileRadius を加えて判定します:
この条件を満たすタイルだけが「見える可能性がある」と判定されます。
カメラが楕円体内部にある場合のガード: cameraDistance <= R のとき、ホライズンの概念が成立しないため、すべてのタイルを可視として扱います。
カメラの視錐台(Frustum)に入っていないタイルは描画する必要がありません。Frustumカリングにより、画面外のタイルの走査を早期に打ち切ります。
各タイルのバウンディング球(中心と半径)を計算します。
export function tileBoundingSphereRadius(
x: number, y: number, z: number
): number {
const bounds = tileBounds(x, y, z);
const latCenter = (bounds.north + bounds.south) / 2;
const cosLat = Math.cos(degToRad(latCenter));
return ((tileGeometricError(z) * cosLat) / 2) * Math.SQRT2;
}バウンディング球半径の計算過程:
tileGeometricError(z) は赤道での1タイルの幅(メートル)です。メルカトル投影では高緯度ほどタイル画像が引き伸ばされるため、同じズームレベルでも実際の地表面積は赤道で最大、極で最小になります。cosLat を掛けることで、その緯度での実際のタイル幅に補正します。
Math.SQRT2(=SQRT(2))は、正方形の対角線が辺のSQRT(2)倍であることに由来します。正方形タイルの外接円(バウンディングスフィア)の半径は、辺の半分
* SQRT(2) です。
/** 球がFrustum内にあるかチェック */
function sphereInFrustum(
center: { x: number; y: number; z: number },
radius: number,
planes: FrustumPlane[]
): boolean {
for (const plane of planes) {
const dist =
plane.normal.x * center.x +
plane.normal.y * center.y +
plane.normal.z * center.z +
plane.constant;
if (dist < -radius) return false;
}
return true;
}Frustumは6つの平面(near, far, left, right, top,
bottom)で定義されます。各平面について、球の中心からの符号付き距離が半径の負数より小さい場合、球は完全にFrustumの外側にあると判定できます。1つでもFrustum外の平面があれば false を返し、すべての平面に対して内側(または交差)なら true を返します。
6つの平面は、tile-layer-utils.ts の extractFrustumPlanes 関数で生成されます。
export function extractFrustumPlanes(
camera: THREE.PerspectiveCamera
): FrustumPlane[] {
const frustum = new THREE.Frustum();
const matrix = new THREE.Matrix4().multiplyMatrices(
camera.projectionMatrix,
camera.matrixWorldInverse
);
frustum.setFromProjectionMatrix(matrix);
return frustum.planes.map((plane) => ({
normal: { x: plane.normal.x, y: plane.normal.y, z: plane.normal.z },
constant: plane.constant
}));
}camera.projectionMatrix(射影行列)と camera.matrixWorldInverse(ビュー行列)の積がクリップ行列です。Three.jsの Frustum.setFromProjectionMatrix はこのクリップ行列から6つの平面を抽出します。
結果を FrustumPlane 型({ normal: { x, y, z }, constant })に変換しているのは、core/ 層がThree.jsに依存しないためです。
z=0 --- SSE高い --> 分割
z=1 -+-- 日本含む象限: SSE高い --> 分割
| z=2 --- 日本周辺: SSE高い --> さらに分割...
| z=3 --- 東京: SSEまだ高い --> 分割
| z=4 --- SSE <= 閾値 --> 採用!
+-- 大西洋: Frustum外 --> カリング
+-- 南極: SSE低い --> z=1で採用
+-- 太平洋: SSE低い --> z=1で採用結果として、カメラに近い東京周辺は高ズームレベル(高解像度)、遠い場所は低ズームレベル(低解像度)のタイルが選択されます。
const tiles = selectVisibleTiles(
cameraPos, // カメラ位置
fovRad, // FOV(ラジアン)
containerHeight, // 描画領域の高さ(px)
frustumPlanes, // 6平面
400, // SSE閾値(px)
6, // 最大ズーム
128 // 最大タイル数
);毎フレーム全タイルを破棄して再生成するのは非効率です。
代わりに、前フレームで表示していたタイルのMap(activeTiles)と 今フレームの選択結果を比較し、追加すべきタイルと削除すべきタイルだけを処理します。
さらに、カメラ行列が前フレームから変わっていない場合は タイル選択自体をスキップします。 cameraMatrixChanged() は 4x4行列の16要素を比較して変化を検出しています。
// 毎フレームの処理
camera.updateMatrixWorld();
if (cameraMatrixChanged()) {
const needed = selectVisibleTiles(...);
// 不要になったタイルを削除+dispose
for (const [key, mesh] of activeTiles) {
if (!needed.has(key)) {
scene.remove(mesh);
mesh.geometry.dispose();
mesh.material.dispose();
}
}
// 新規タイルを生成+追加
for (const tile of needed) {
if (!activeTiles.has(key)) {
// computeTileGeometry + シーン追加
}
}
}tile-tree.test.ts は、LOD制御の各コンポーネントを網羅的にテストしています。特に重要なのは パーティション検証 です。
| テストグループ | 主要テスト |
|---|---|
| tileGeometricError | z=0で赤道周長、zが増えると半減 |
| computeSSE | 既知値テスト、距離0でInfinity、距離と反比例 |
| isAboveHorizon | カメラ正面/裏側/水平線付近/地球内部の4パターン |
| selectVisibleTiles | 遠距離/近距離/maxZoom制限/パーティション検証 |
| tileBoundingSphereRadius | 極地 < 赤道の関係 |
Quadtree走査の結果が「隙間も重なりもない完全な分割」になっていることを検証するテストは、ユニットテストとしては珍しい手法です。正しいQuadtree走査であれば:
このテストは複数のカメラ距離(1.5R, 2.0R, 3.0R, 5.0R)で実行され、カメラ位置に依存しない汎用的な正しさを保証しています。
この章では、SSEベースのLOD制御を実装しました。