[Astro] #164 Terrain Explorer — 地理院タイルの地形を立体にして、VRで歩くツールを作成

[Astro] #164 Terrain Explorer — 地理院タイルの地形を立体にして、VRで歩くツールを作成

はじめに

前回(#163)は、Word / Excel / PowerPoint の中身を覗くツールでした。

今回は、地形を立体にして歩くツール(/terrain-explorer) です。

ファイルの中身を覗くツールが 3 つ続いたので、違うものを作りたくなりました。地図は、このサイトではまだ触っていません。「地形を選んで立体にして、そこを VR で歩けたら面白そうだ」と思ったのがきっかけです。

データは、国土地理院が公開している「地理院タイル」を使います。標高も航空写真も、全国分が公開されています。

要点は次の 3 つです。

  1. 標高タイルを読んで、選んだ範囲の地形を立体にする
  2. その場に降りて歩く。歩いた先の地面は、その場で読み足す
  3. Quest では、模型として眺めて、指した場所に実寸で降りる

以降は、実装の全体を簡単に残しておきます。

完成画面

Terrain Explorer — 20 km 四方を立体にしたところ

降りて歩く

富士山の山頂に降りたところ。左の地図に現在地と向きが出る

海の上から陸を見る

海の上に立って、陸を見ているところ

操作動画

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' });

場所を選ぶ地図

左の地図は、自分で書きました。やっていることは単純です。

  1. 地図の中心(経度・緯度)を、世界ピクセルに直す
  2. canvas の四隅が、どのタイルにかかるかを求める
  3. そのタイルを読んで、ずらして描く

ドラッグしたら中心を動かし、ホイールでズームを 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 で富士山の火口の縁に立つと、航空写真を貼っただけの地面なのに、ちゃんと「そこにいる」感じがします。模型を指して降りる瞬間が、いちばん面白いです。

地図は、今回が初めてでした。断面図、見える範囲、歩いた記録を重ねるなど、同じタイルの読み取りを使って作れるものが、いくつかあります。

次は、使い方のガイド記事をまとめる予定です。