[Astro] #164 Terrain Explorer — 地理院タイルの地形を立体にして、VRで歩くツールを作成
はじめに
前回(#163)は、Word / Excel / PowerPoint の中身を覗くツールでした。
[Astro] #163 Office Inspector — Word / Excel / PowerPointの中身を覗くツールを作成 // PROTOCOL.LAIN
Word / Excel / PowerPoint のファイルを分解して、中身を見るツールを作りました。文書の中身・XML・16 進ダンプの 3 つを同時に表示して、どこを触っても対応する場所が光ります。
lain-lab.com今回は、地形を立体にして歩くツール(/terrain-explorer) です。
ファイルの中身を覗くツールが 3 つ続いたので、違うものを作りたくなりました。地図は、このサイトではまだ触っていません。「地形を選んで立体にして、そこを VR で歩けたら面白そうだ」と思ったのがきっかけです。
データは、国土地理院が公開している「地理院タイル」を使います。標高も航空写真も、全国分が公開されています。
要点は次の 3 つです。
- 標高タイルを読んで、選んだ範囲の地形を立体にする
- その場に降りて歩く。歩いた先の地面は、その場で読み足す
- Quest では、模型として眺めて、指した場所に実寸で降りる
以降は、実装の全体を簡単に残しておきます。
完成画面
降りて歩く
海の上から陸を見る
操作動画
PC
VR
使い方は 3 手です。
- 左の地図を動かして、赤い四角に範囲を入れる(地名で探すこともできる)
- 「立体にする」を押す。ドラッグで見回せる
- 「ここを歩く」を押す。W A S D で歩く。Esc で上からの視点に戻る
Quest では、画面下の「ENTER VR」から入ります。
地理院タイルとは
地図を、256 × 256 画素の正方形の画像に切り分けて配っているものです。URL に「ズーム・横の番号・縦の番号」を入れると、その 1 枚が返ってきます。
https://cyberjapandata.gsi.go.jp/xyz/dem_png/14/14505/6462.png
└種類┘ └z┘ └x─┘ └y─┘
ズームが 1 つ上がると、1 枚が 4 枚に分かれて、細かくなります。Google マップなどと同じ方式です。
今回使うのは 3 種類です。
| 種類 | 中身 | 使い道 |
|---|---|---|
dem_png | 標高(10 m メッシュ) | 地形の形 |
seamlessphoto | 航空写真 | 地形に貼る絵 |
std | 標準地図 | 場所を選ぶ地図 |
ブラウザから直接読めます。別のサイトのデータをブラウザから読むには、相手のサーバーが許可している必要がありますが(CORS)、地理院タイルは許可されていました。サーバーを自分で用意しなくて済みます。
構成
src/pages/terrain-explorer.astro ページ本体(マークアップとスタイル)
src/scripts/terrain-explorer/
├─ tiles.js タイルの取得、座標の変換、標高の読み取り
├─ slippy.js 場所を選ぶ地図
├─ terrain.js 選んだ範囲を立体にする(遠景)
├─ detail.js 歩いている場所のまわりに、細かい地面を読み足す
└─ main.js 画面の組み立て。見回す・歩く・VR
全部で約 1,300 行です。外部ライブラリは three.js だけです。地図の表示も、ライブラリを使わずに書いています。
これまでのツールは public/ に素の JavaScript を置いていましたが、今回は three.js を import するので src/scripts/ に置いて、Astro にまとめてもらっています。
座標:世界を 1 枚の絵として扱う
タイルの座標は「Web メルカトル」です。ズーム z のとき、世界全体が 1 辺 256 × 2^z 画素の、1 枚の正方形の絵になります。この絵の上の位置を、ここでは「世界ピクセル」と呼びます。
// 経度・緯度 → 世界ピクセル
export function lonLatToPx(lon, lat, z) {
const n = TILE * 2 ** z;
const s = Math.sin((lat * Math.PI) / 180);
return {
x: ((lon + 180) / 360) * n,
y: (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * n,
};
}
世界ピクセルを 256 で割った整数部が、タイルの番号です。
1 画素が地上の何メートルかは、緯度で変わります。メルカトル図法は、北へ行くほど引き伸ばされるためです。
export function metersPerPx(lat, z) {
return (156543.03392 * Math.cos((lat * Math.PI) / 180)) / 2 ** z;
}
富士山のあたり(北緯 35 度)だと、ズーム 14 で 1 画素が約 7.8 m です。標高タイルは 10 m メッシュなので、ズーム 14 がいちばん細かいズームになります。
標高タイル:色が数値になっている
標高タイルは、PNG 画像です。ただし、見るための絵ではありません。1 画素の赤・緑・青の 3 バイトで、1 つの数値を表しています。
const x = d[p] * 65536 + d[p + 1] * 256 + d[p + 2]; // R, G, B
if (d[p + 3] === 0 || x === 8388608) { out[i] = NaN; continue; } // データなし(海など)
const m = (x < 8388608 ? x : x - 16777216) * 0.01; // 単位は m
- 3 バイトをつなげた値に 0.01 を掛けると、標高(m)になる
- 2 の 23 乗ちょうどは「データなし」
- それより大きい値は、負の標高(2 の 24 乗を引く)
画像として読み込んで、canvas に描いて、画素を取り出します。ここで 1 つ気を付けることがあります。ブラウザは、画像を読むときに色を調整することがあります。絵ならそれでいいのですが、色が数値になっているデータでは、値が変わってしまいます。調整をしないように指定して読みます。
createImageBitmap(blob, { colorSpaceConversion: 'none', premultiplyAlpha: 'none' });
場所を選ぶ地図
左の地図は、自分で書きました。やっていることは単純です。
- 地図の中心(経度・緯度)を、世界ピクセルに直す
- canvas の四隅が、どのタイルにかかるかを求める
- そのタイルを読んで、ずらして描く
ドラッグしたら中心を動かし、ホイールでズームを 1 つ変えます。
範囲を表す赤い四角は、動かしません。常に地図の中央にあって、地図の方を動かします。四角を動かしたり、大きさを変えたりする操作を作らなくて済みます。
タイルは、同時に 6 枚までしか取りに行きません。相手は公共のサーバーです。
標高の格子から、面を作る
選んだ範囲の標高を、縦横に並んだ数値の格子として取り出します。この格子の 1 マスを、三角形 2 枚に分けて、面にします。
// 格子の 1 マスを三角形 2 枚に分ける
for (let j = 0, p = 0; j < h - 1; j++) {
for (let k = 0; k < w - 1; k++) {
const a = j * w + k, b = a + 1, c = a + w, d = c + 1;
idx[p++] = a; idx[p++] = c; idx[p++] = b;
idx[p++] = b; idx[p++] = c; idx[p++] = d;
}
}
座標の単位はメートルです。東が x、南が z、上が y で、範囲の中心を原点にしています。
格子の点の数には、上限を付けています(256 / 512 / 1024)。20 km 四方をズーム 14 で取ると 2,500 点四方になって、重すぎます。上限に収まる、いちばん細かいズームを選びます。20 km 四方なら、ズーム 12 で約 640 点四方(1 点が 31 m)になります。
「高さの強調」は、面を作り直さずに、縦方向に引き伸ばしているだけです。
写真の細かさは、標高とは別に決める
貼る写真は、標高とは別のズームで取ります。
最初は、標高のズームに合わせていました。20 km 四方だと、写真の 1 画素が 15 m になります。上から見る分にはそれらしく見えますが、降りて立つと、何が写っているのか分かりません。
写真は、1 辺 4,096 画素に収まる、いちばん細かいズームで取るようにしました。
| 範囲 | 標高の 1 点 | 写真の 1 画素 |
|---|---|---|
| 20 km 四方 | 31 m | 約 8 m |
| 5 km 四方 | 7.8 m | 約 2 m |
| 1 km 四方 | 7.8 m | 約 0.5 m |
1 km 四方まで絞ると、山頂の建物や登山道が見えます。ただ、それだと 500 m 歩いたら端です。
足元だけ、細かい地面を読み足す
そこで、選んだ範囲の地形は「遠景」として残して、自分のまわりだけ、細かい地面を重ねる ことにしました。ゲームで、歩いた先の地形をその場で読み込むのと同じ考え方です。
┌──────────────────────────┐
│ 遠景(選んだ範囲。粗い) │
│ ┌──────────┐ │
│ │ 細かい地面 │ │
│ │ 5 × 5 枚 = 2.5 km │ │
│ │ ┌────┐ │ │
│ │ │ 3 × 3 │ │ │
│ │ │写真0.5m│ │ │
│ │ └────┘ │ │
│ └──────────┘ │
└──────────────────────────┘
細かい地面は、正方形の板(チャンク)に分けます。1 枚は、ズーム 16 のタイル 1 枚分で、約 500 m 角です。
- 自分のいるチャンクを中心に、5 × 5 枚を出す
- 標高は、どのチャンクもズーム 14(10 m メッシュ)
- 写真は、足元の 3 × 3 枚がズーム 18(約 0.5 m)、その外がズーム 17
- 隣のチャンクへ移ったら、進む側を読み足して、後ろを捨てる
チャンクの大きさをタイルの大きさに合わせたので、「このチャンクに貼る写真は、どのタイルか」が計算で決まります。ズーム 16 のタイル 1 枚は、ズーム 18 のタイル 4 × 4 枚と、ちょうど同じ範囲です。
板の継ぎ目
隣り合う板の端は、同じ点を共有させます。1 枚は 64 マスなので、点は 65 × 65 個です。端の 1 列は、隣の板の最初の列と同じ値になります。
陰影を付けるための「面の向き」も、板ごとに計算すると、端で食い違って線が見えます。板の外側の 1 点まで含めて、標高タイルから直接求めています。
// 傾き: 東西・南北の隣との高さの差から、面の向きを出す
const nx = (h(ix - 1, iy) - h(ix + 1, iy)) / (2 * mpp);
const nz = (h(ix, iy - 1) - h(ix, iy + 1)) / (2 * mpp);
そろってから、一度に入れ替える
チャンクは、読めた順に 1 枚ずつ出すのではなく、25 枚そろってから一度に入れ替えます。
理由は、遠景との関係です。細かい地面を出している範囲では、遠景を描きません。両方を描くと、粗い方の面が、細かい方を突き抜けて見えるためです。「描かない範囲」は四角形 1 つで指定しているので、細かい地面も四角くそろっていないと困ります。
遠景の側は、描画のプログラム(シェーダー)に 1 行足して、その四角の中の画素を捨てています。
if (vHoleXZ.x > uHole.x && vHoleXZ.x < uHole.z && vHoleXZ.y > uHole.y && vHoleXZ.y < uHole.w) discard;
遠景との境目
細かい地面と遠景は、写真のズームが違います。撮影した時期が違うこともあって、色がそろいません。境目に、はっきりした線が出ます。
外周の 200 m で、細かい地面を少しずつ透明にしています。その帯では遠景も描いておくので、粗い写真と細かい写真が、なだらかに入れ替わります。色の違いそのものは消えません。
歩く
歩くときは、自分の位置の地面の高さを毎回求めて、そこに目の高さを足します。
地面の高さは、描いている面と同じ三角形の上で 求めます。ここは一度間違えました(ハマりどころの 3)。
// マスの 4 隅。a = 左上、b = 右上、c = 左下、d = 右下。分け目は b と c を結ぶ線。
if (u + v <= 1) y = a + (b - a) * u + (c - a) * v;
else y = d + (c - d) * (1 - u) + (b - d) * (1 - v);
目の高さは、初期値を 3 m にしています。実際の背丈(1.7 m)だと、真上から撮った写真が地面に貼り付いて見えるので、少し浮かせた方が自然に見えます。
歩いている間、左の地図は自分に付いてきて、中央の三角が向きを示します。地図はもともと全国のタイルを読む作りなので、中心を自分の位置に合わせるだけです。
VR:模型と実寸
Quest では、2 つの見方を切り替えます。
- 模型:地形を、目の前の机の上に、幅 1.2 m で置く
- 実寸:地形の上に、実際の大きさで立つ
模型をコントローラーで指して引き金を引くと、指した場所に実寸で降ります。もう一度引くと、模型に戻ります。
仕組みは 2 つのグループです。
scene
├─ dolly(自分を載せる台)
│ ├─ camera
│ └─ コントローラー
└─ world(地形を入れる箱)
├─ 遠景
└─ 細かい地面
- 模型のとき:
worldを縮めて、目の前に置く。dollyは部屋の原点 - 実寸のとき:
worldは元の大きさ。dollyを地形の上で動かす
実寸で歩くときは、dolly の位置を動かし、高さを足元の地面に合わせます。VR では、頭の位置は部屋の中で動くので、「dolly の位置 + 部屋の中での頭のずれ」が、地形の上での自分の位置になります。
向きを変えるときは、頭の位置を軸にして dolly を回します。dolly の原点で回すと、部屋の中心から離れて立っているとき、立ち位置がずれます。
vrHead(tmpB); // 回す前の頭の位置
dolly.rotation.y += a;
vrHead(tmpA); // 回した後の頭の位置
dolly.position.x += tmpB.x - tmpA.x; // ずれた分を戻す
dolly.position.z += tmpB.z - tmpA.z;
検証
開発に使った環境からは、地理院のサーバーに繋がりませんでした。そこで、偽のタイルサーバー を書いて試験しています。
- 富士山の位置に、高さ 3,776 m の円錐の山を置く。式で高さが決まる
- その高さを、標高タイルと同じ形式の PNG にして返す(PNG の書き出しも自前)
- 写真の代わりに、高さの縞模様とタイルの境目の線を返す
式で高さが決まっているので、読み取った結果と突き合わせられます。Playwright で画面を動かして、次を確かめています。
- 標高の読み取りが、元の式と合う
- 足元の高さと、描かれた面のずれが 0(真上から面に線を落として比べる。遠景と細かい地面の両方)
- チャンクが 25 枚出て、足元の 9 枚が細かい写真になる
- チャンクをまたぐと入れ替わり、古いものは捨てられて 25 枚のまま
- 選んだ範囲の外に立てる。地図が付いてくる
- VR の台の計算(模型の位置、降りた場所、向きを変えても立ち位置が動かない、1 秒で 5 m 進む)
VR は、機器がないと動かせません。位置の計算だけを、関数を直接呼んで確かめています。
本物のタイルと、Quest での動作は、手元で確かめました。自動の試験はありません。
ハマりどころ
1. 降りたら、初代プレイステーションだった
最初の版で富士山に降りると、ポリゴンの塊でした。写真の 1 画素が 15 m だったためです。「写真の細かさは、標高とは別に決める」の節のとおりに直しました。
2. VR に入ると、空しか見えない
模型の表示を、最初は「自分を 800 倍の巨人にする」方法で作りました。dolly を拡大すれば、地形が小さく見えます。
Quest で入ると、空の水色だけで、地形が出ません。
three.js の VR は、左右の目をまとめた「見える範囲」を計算して、範囲の外のものを描かないようにしています。この計算が、台の拡大を考えていないようで、目の前 1 m にある地形が「手前すぎて見えない」と判定されていました。原因は推定です。計算の中身を最後まで追ってはいません。
自分を大きくするのをやめて、地形の方を縮めるようにしたら、出ました。
3. たまに、地面にめり込む
VR で火口の縁を歩くと、足が地面に沈むことがありました。
足元の高さを、マスの 4 隅から滑らかに按分して求めていたのが原因です。描いている面は、マスを三角形 2 枚に分けたものです。平らな所では同じ値になりますが、マスがねじれている所では、按分した値が、描かれた面より低くなります。
描いているのと同じ三角形の上で求めるようにしました。
4. 見えているのに、行けない
細かい地面を入れた直後、1 km 四方で選ぶと、2.5 km 先まで見えているのに、500 m で壁に当たりました。「選んだ範囲の端で止める」処理を、消し忘れていました。
5. 海が真っ黒
沖の方は、写真のタイルがありません。取れなかった部分は canvas が透明のままで、貼ると黒くなります。先に海の色で塗ってから、タイルを描くようにしました。
標高の方は、塗りません。「透明 = データなし」として読んでいるためです。
6. 赤い四角が、だんだん濃くなる
地図のタイルが 1 枚届くたびに、範囲の四角を上から描いていました。半透明の赤が、届いた枚数だけ重なります。
タイルは裏の canvas に描いておいて、表には「裏を写す → 四角を 1 回描く」の順で描くようにしました。
7. 試験が途中で止まる
GPU のない環境で試験すると、2 回目の「立体にする」が、タイルを 6 枚読んだところで止まりました。
止まっていたのではなく、遅かっただけです。1 回目で作った地形の描画に 1 コマ 1 秒近くかかっていて、その間、通信の続きが処理されません。試験では、点の数を減らして軽くしています。
使うときの決まり
地理院タイルは、出典を書けば自由に使えます(国土地理院コンテンツ利用規約)。加工して使うときは、加工したことも書きます。このツールでは、画面の左下に「地理院タイル(標高タイル・全国最新写真・標準地図)を加工して作成」と入れて、タイル一覧のページにリンクしています。
立体にしたデータを、ファイルに書き出す機能は入れていません。読んで、その場で表示するだけです。
まだ入れていないこと
- GLB / STL への書き出し
- 建物や木。地面に写真を貼っているだけなので、立体なのは地形だけです
- 斜面との当たり判定。急な斜面に近づくと、顔が斜面に入ります
- 遠景の読み足し。選んだ範囲の外へ出ると、見えるのは自分のまわり 2.5 km だけです
- スマートフォンで歩く操作
- 太陽の向き、空、水面
おわりに
作ってみて分かったのは、材料はもう全部そろっている、ということでした。
標高は、全国の陸地が 10 m メッシュで公開されています。航空写真も、全国分があります。どちらも、URL に番号を入れれば、ブラウザから直接読めます。こちらで書いたのは、読んで、並べて、面にする部分だけです。
限界もはっきりしています。標高は 10 m ごとなので、崖や建物は出ません。写真は真上から撮ったものなので、垂直な面は引き伸ばされて流れます。なだらかな山や火口は得意で、街中は苦手です。
それでも、Quest で富士山の火口の縁に立つと、航空写真を貼っただけの地面なのに、ちゃんと「そこにいる」感じがします。模型を指して降りる瞬間が、いちばん面白いです。
地図は、今回が初めてでした。断面図、見える範囲、歩いた記録を重ねるなど、同じタイルの読み取りを使って作れるものが、いくつかあります。
次は、使い方のガイド記事をまとめる予定です。