iOSエンジニアの堤です。8月22日〜24日に開催された国内最大 1 のiOSカンファレンスで登壇しました。
プロポーザルは こちら、発表スライドはこちら:
そしてつい数日前 2 にiOSDC公式チャンネルにて発表動画が公開されました:
本記事は、同発表をベースとしつつ、時間が足りずカットした内容も盛り込みつつ記事として再構成したものになります。
はじめに
GISとは
- Geographic Information System(地理情報システム)の略
- 地理空間情報(=位置情報+関連する情報)を扱うシステム 3
地図アプリもGISの一例
地図アプリ: 地理空間情報(=位置情報+関連する情報)を重ね合わせて可視化し、いろんな機能(検索、ナビゲーション等)をつけたシステム
今日のゴール
地図をしくみから理解し、iOSでさまざまな応用ができるようになる
話さないこと(スコープ外): 特定のSDKやAPIの使い方解説
(ソースコードも出てくるが、今回重視するのは概念やしくみの理解)
アジェンダ
- GISの基礎
- 地図をゼロから実装する
- 応用編
GISの基礎
ラスターデータとベクトルデータ
ラスターデータ
- 要は画像データ
- 位置情報はどう持つのか?
- → タイルインデックスにより一意に定まる(後述)
ベクトルデータ
緯度経度の座標を使い、以下の3つの形状を表すデータ。
- 点(Point)・・・地点
- 線(LineString)・・・経路、路線
- 面(Polygon)・・・領域
位置に関する属性情報も付与可能
- 例
- 県境データ(面)に都道府県名や人口
- 道路データ(線)に路線名や制限速度
地図タイル
巨大な地理情報データを一度に配信できない → タイル状に分割
タイルインデックス
タイルインデックス Z/X/Y
Z
: ズームレベルX
: 横方向のインデックスY
: 縦方向のインデックス
→ そのタイルの領域が一意に定まる 4
提供元が違うタイルでもインデックスが同じであれば同じ領域を表す
0/0/0
: 地球全体を表す1枚のタイル
ズームレベルが1つ上がると1つのタイルが4つに分割
ラスタータイルとベクトルタイル
ラスタータイル: ラスターデータのタイル
- ファイルの実体は単なる画像(256x256 or 512x512が一般的)
- タイルインデックス自体が位置(領域)を表す
ベクトルタイル: ベクトルデータのタイル
- 詳細は後述
地図をゼロから実装する
MapKitや他のSDKを「使わずに」 iOSで地図を実装してみる
作れないものは、理解できない ― リチャード・ファインマン 5
→ 作りながら地図の仕組み/GISの基礎を理解していく
最小限の地図実装を目指す
- ラスタータイルを使って地図を表示
- 現在位置を表示
- ズームレベルの変更
ステップ1: ラスタータイルの描画
国土地理院タイル「標準地図」を描画する
国土地理院タイル「標準地図」のURLフォーマット:
https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png
実装:
let urlStr = String(format: "https://cyberjapandata.gsi.go.jp/xyz/std/%d/%d/%d.png", z, x, y) AsyncImage(url: URL(string: urlStr)!)
完成
- ラスタータイルを使って地図を表示
ステップ2: 現在位置を表示する
現在位置(=緯度経度)をラスタータイル上にマッピングする
Webメルカトル投影法 6 に基づき 経緯度 → ピクセル座標に変換
- 緯度経度 → ピクセル座標への変換実装 7
func latlonToXY(coord: CLLocationCoordinate2D, zoom: Double) -> (Int, Int) { ... }
- 現在位置取得 → ピクセル座標 → スクリーン座標
let (px, py) = latlonToXY(coord: location.coordinate, zoom: z) // ピクセル座標 let x = px / tileWidth let y = py / tileWidth print("タイルインデックス: \(z)/\(x)/\(y)") ... // → タイル内ピクセル座標 → スクリーン座標
完成
- ラスタータイルを使って地図を表示
- 現在位置を表示 (緯度経度→ピクセル座標→スクリーン座標)
ステップ3: ズーム
ズームレベルが1つ上がると4つのタイルに分割
- ズームイン: 1つのタイル領域に4つのタイルを描画
- ズームアウト: 4つのタイル領域に1つのタイルを描画
4つのラスタータイルを描画
VStack(spacing: 0) { ForEach(0..<2, id: \.self) { row in HStack(spacing: 0) { ForEach(0..<2, id: \.self) { column in ... // タイル描画 } } } }
完成
- ラスタータイルを使って地図を表示
- 現在位置を表示
- ズームレベルの変更
応用編
もうちょっと「自前実装ならでは」感がほしい...
ラスタータイルはあらかじめ地名などが埋め込まれているものが多い
→ ベクトルタイルを利用する
クライアント側で動的に描画するため、見た目をカスタマイズ可能
ベクトルタイルの描画方法
- ベクトルタイルを読み込む(Protocol Buffers形式のファイルをデコード)
- デコードしたベクトルデータを使用して、iOSで地図として描画する
ベクトルタイルの中身を見てみよう
→ 多くのレイヤーがあり、それぞれに多くのフィールドがあり、さらにその値として多数のクラスが定義されている 10
ベクトルタイルレンダラの自前実装は難しい
- 地図のズームレベルや位置に応じて適切にスケーリングを行う必要がある
- 投影法を加味した複雑な計算が必要
- ほぼ全画面に大量のベクトルデータをレンダリングする必要があり、かつ滑らかに動作する必要がある
- OpenGLやMetalを使用し、GPUを活用して描画する
→ ここからはゼロからではなくSDKを使います
ただし
- 「SDKの使い方」ではなく
- あくまで地図のしくみを理解していろんな応用ができるように
というのを主眼に置いて解説します
こういうのはどう実現する?
- 地名ラベルを非表示に
- 道路を際立たせる
- 山や川など歩けない場所は可視化
→ ベクトルタイルの描画方法を変更したい
→ スタイルのカスタマイズ
スタイル: 地図の視覚的な外観を定義するための設定
- ベクトルタイルのデータをどのように表示するかを定義する
- 実態はJSON
- 各社のスタイル仕様は互換性がない ^8
スタイルをサポートしているiOS SDK
- Mapbox Maps Native SDK for iOS
- MapLibre Native SDK for iOS
- Mapboxから枝分かれしたOSS 11
どちらでも目的は果たせるが、今回はMapbox SDKを選択
スタイルのカスタマイズ方法
- GUIエディタを利用(Mapbox Studio, MapTiler Cloud, Maputnik)
- クライアントサイドでの動的なスタイル変更
Mapbox Studioでスタイルをカスタマイズする
- 地名ラベルを非表示に
- 道路を際立たせる
- 山や川など歩けない場所は可視化
(詳細手順はツールの使い方解説になるため割愛)
スタイルを適用する
スタイルのJSONをエクスポート 12 → アプリに組み込む
let styleJSONURL = Bundle.main.url(..., withExtension: "json")! let styleJSON = try String(contentsOf: jsonURL) let options = MapInitOptions(styleJSON: styleJSON)
完成
ベクトルタイルどこいった?🤔
スタイルをカスタマイズ(=ベクトルタイルの描画方法を変更)
本当?🤔
- コードに一切ベクトルタイルの気配がない
- タイルインデックス(Z/X/Y)とかも1ミリも出てきていない...
let styleJSONURL = Bundle.main.url(..., withExtension: "json")! let styleJSON = try String(contentsOf: jsonURL) let options = MapInitOptions(styleJSON: styleJSON)
style.json の中身をのぞいてみると...
スタイルを定義するJSONファイルの中で、ベクトルタイルURLを指定している
https://a.tiles.mapbox.com/v4/{タイルセットID}/{z}/{x}/{y}.vector.pbf
(.pbfはProtocol Buffersでエンコードされたベクトルタイルの拡張子)
→ スタイルは明確にベクトルタイルを扱って見た目をカスタマイズする仕組みであることがわかる
3D表示
地図を描画できていれば、それを3D的に描画すればOK
地形を立体的に表示するには?
地形を立体的に表示するしくみ
標高のラスタータイルをハイトマップとして使う
標高タイルの利用方法
標高値がラスタータイルのRGB値にエンコーディングされている
MapTiler社のTerrain RGB形式 13 の場合:
elevation = -10000 + ((R * 256 * 256 + G * 256 + B) * 0.1)
→ 標高値をハイトマップとして3D化に使える
標高タイルをハイトマップ化
地図のラスタータイルをハイトマップで3D化
ハイトマップを使って2D画像を3D化する実装は、デプスマップを使った3D化実装 14 と同様
iOSでの3D表示
MapboxでもMapKitでもカメラでどの角度から見るか設定するだけ
let cameraOptions = CameraOptions( center: ..., zoom: zoomLevel, pitch: 60) // 0度だと真上からみた2Dビューとなる let options = MapInitOptions(cameraOptions: cameraOptions, styleJSON: styleJSON) mapView = MapView(frame: view.bounds, mapInitOptions: options)
3D地形表示のコード 16
// 標高のラスタータイルをデータソースとして追加している var demSource = RasterDemSource(id: "...") demSource.url = "mapbox://mapbox.mapbox-terrain-dem-v1" ... try! mapView.mapboxMap.addSource(demSource) var terrain = Terrain(sourceId: demSource.id) ... try! mapView.mapboxMap.setTerrain(terrain)
自キャラとモンスターの3Dモデルを設置
緯度経度からシーン内のXY座標へ変換し設置する
- → Webメルカトル投影法(前述)を使う
SDKを使う場合は意識する必要なし 17
デモ
- マップの3D表示
- 地形も標高タイルで3D化
- 現在位置に自キャラ、公園のある位置にモンスターの3Dモデルを設置
まとめ
- ラスターデータとベクトルデータ
- 地図タイル/タイルインデックス
- ベクトルタイルとスタイル
- 3D表現
→ 「地図のしくみ」を理解
GIS入門、昨日25分話しましたが、まとめてみると結局これだけだったりするんですよね。ラスター/ベクトルタイル、スタイルぐらいを理解しておけば、世界中の色んな団体が公開してる膨大な地図データや各種統計、衛星データ、3D都市モデル等々が自分の引き出しに加わって、一気に世界が広がる #iosdc https://t.co/pb3RRAnnYf pic.twitter.com/HwgKtUCXAJ
— 堤修一 / Shuichi Tsutsumi (@shu223) 2024年8月23日
(おまけ)ビルの3D表示のしくみ
❌️ ビルの3Dモデルを持っている? 18
⭕️ 「建物の領域を表す多角形」を「建物の高さ」情報(ベクトルタイル内に持っている)を用いて3D的に表示している 19
前述のkyotoのタイルセットの中身を見ると、
建物の領域を表す多角形に加えて、building
レイヤーに
- render_height
- render_min_height
というフィールドがあり、高さの情報を持っていることがわかる。
続・GIS入門
プロポーザルでは言及していたものの、20分という発表時間内ではカバーしきれなかった内容について、その後の別イベントや記事で解説したものを抜粋します。
外部データの活用
国土地理院が配信している地形図や航空写真、JAXAやNASAの衛星データなどの外部データもiOSで活用できるようになります。(iOSDCプロポーザルより)
3D都市モデルの活用
PLATEAUで配布されている3D都市モデルの活用についても解説します。(iOSDCプロポーザルより)
#PLATEAU の3D都市モデルをiOSネイティブで描画 (#iosdc のプロポーザルではここまで話す予定だったけど全然時間が足りず断念) pic.twitter.com/SA4gFiTnG2
— 堤修一 / Shuichi Tsutsumi (@shu223) 2024年9月11日
その他派生記事・発表
おわりに
Sansan / Eightのモバイルアプリ開発を進めていく仲間を募集中です!選考評価なしで現場のエンジニアのリアルな声が聞けるカジュアル面談もありますので、ご興味のある方はぜひ面談だけでもお越しいただけたら幸いです。
- 今年は参加者1500人超で過去最高だったそうです。↩
- 2024.10.8に本記事を書いています。↩
- 「地理空間情報:GISとは - 国土交通省」の記述:『様々な地理空間情報を重ね合わせて表示するためのシステム 』↩
- XYZ方式と呼ばれ、デファクトスタンダードとなっている。↩
- オライリー社の「ゼロから作るDeep Learning」シリーズより引用↩
- 北緯および南緯85.051129度以上の描画を諦め、地球全体を正方形の地図として表現する投影法。地理院地図やGoogle Maps等、多くの地図アプリケーションの土台となっている。↩
- 緯度経度をピクセル座標に変換するSwift実装↩
- MapTiler社が提供している、OpenStreetMap や Natural Earth などの全世界の地図データをベクトルタイル化したもの↩
- JapanやTokyoだと、データサイズやレイヤー数等で Mapbox Studioのアップロード制限 に引っかかる↩
- OpenMapTilesのスキーマは公開されている: openmaptiles.org/schema/↩
- Mapbox系マップSDK for iOSの系譜↩
-
mapbox://styles/...
のURIを直接指定する方法もある↩ - 国土地理院の標高タイルはまた違う形式でエンコーディングされている↩
- デプスマップを使った3D化実装の例: iOS-Depth-Sampler↩
- 地図タイルは「国土地理院 全国ランドサットモザイク画像」、標高タイルは「MapTiler Terrain RGB」を利用↩
- 詳細なコードとその解説は別途Zenn記事にまとめます↩
- MapKitでもMapbox SDKでも、緯度経度だけで地図上の然るべき位置に設置できる(2Dと同様)↩
- 3Dモデルを持っているケースもある↩
-
この機能を司るクラスはMapbox SDKでは
FillExtrusionLayer
と命名されており、"extrusion" は「押出成形」の意味↩