はじめに
グレンジ Advent Calendar 2022 18日目の記事を担当している高橋と申します。グレンジではクライアントサイドのエンジニアをやっています。
この記事では、Unityシーン上にMapboxが生成した3D地図を下記画像のような近似する荒野風の風景に置き換える方法を紹介します。
Mapboxは、デジタル地図の開発プラットフォームで描画内容を手軽に色々とカスタマイズできることを売りの一つとしています。とりあえずアカウント登録すれば無料で色々と試せます。
Mapboxでは平面的な地図だけではなく、地形や建物を3D化した地図も手軽に描画できます。1
なお、この記事では、Mapboxに関する説明は、サンプルの実装に関連する部分にのみ絞っていてMapbox全般の詳細な説明は特にしていません。
・Mapbox
公式:https://www.mapbox.jp/
Wiki:https://ja.wikipedia.org/wiki/Mapbox
環境等
このサンプルは以下の環境で動作確認済みです。
・OS:Mac OSX 12.6(Monterey)
・Unity:2019.4.31f1
・Mapbox SDK(Maps SDK for Unity):2.1.1
・サンプル
https://github.com/prog-k-dev/GRMapboxTerrain
サンプルの構成について
・シーン
Assets/TerrainMap/TerrainMap.unity
◆Gameビュー
・地図: Mapboxから取得した地図が表示されます。
・Mesh/Terrain切替ボタン: 地図をMesh表示とTerrain表示で交互に切り替えます。
◆Hierarchyビュー
・Mapboxオブジェクト
AbstructMapコンポーネントが含まれています。AbstructMapにはMapboxの地図に関する設定が概ね全て含まれています。(AbstructMapの詳細はこちら )
・GRCustomMapオブジェクト
GRCustomMapコンポーネントが含まれています。GRCustomMapにはタイル生成完了時のコールバック処理やUI処理などマップ生成の全体制御などが実装されています。
◆実行前の準備
サンプルには全てのファイルが含まれていますが、実行するためにはMapboxへのアカウント登録とMapboxが発行するアクセストークンが必要になります。アカウント登録などの詳細については、次の「準備」をご覧ください。既にMapboxのアカウントをお持ちの方は「準備」は飛ばして構いません。
準備
サンプルを動かす場合は、この作業が必須となります。既にMapboxのアカウントをお持ちの方はこの章は飛ばして構いません。
◆アカウント登録
サンプルには全てのファイルが含まれていますが、実行するためには有効なアクセストークンが必要になります。
アカウントの取得自体は特に難しくなありません。公式ページトップ 右上の「サインアップ」をクリックしてあとは指示に従って必要な項目を入力するだけです。ただ途中でクレジットカード情報の入力が必須っぽいのでそこだけちょっと面倒な気はします。
◆アクセストークン設定
Mapboxにログインしてアカウントページ を開きます。このページの「Default public token」をコピーします。
次にUnityメニュー「Mapbox」>Mapbox Setupウィンドウを開き「Access Token」に先ほどコピーした内容をペーストして「Submit」ボタンを押下してしばらく待ちます。アクセストークンが有効であれば数秒程度で「Valid」と表示されるはずです。
これで準備は完了です。
◆補足
・コンパイルエラーについて
このサンプルでは解消していますが、Maps SDK for Unityをいちからインポートした場合、ARがらみのコンパイルエラーが出るかもしれません。
<エラーメッセージ>
Assets/GoogleARCore/SDK/InstantPreview/Scripts/InstantPreviewManager.cs(32,23): error CS0234: The type or namespace name 'SpatialTracking' does not exist in the namespace 'UnityEngine' (are you missing an assembly reference?)
これは、AR関連のパッケージが古いせいで出るエラーのようなのでPackageManagerからARFoundationをインストールすれば解決します。
もっと手っ取り早く、バッサリAR関連のフォルダを削除してもMapboxは動作するようです(もちろんAR関連APIは使えなくなりますが)
MapboxAR
GoogleARCore
UnityARInterface
UnityARKitPlugin
・TerrainToolKit2017について
Terrainの加工に色々と便利なフリーアセット。本サンプルでも一部TerrainToolKit2017の機能を利用しています。
https://assetstore.unity.com/packages/tools/terrain/terrain-toolkit-2017-83490
Mapbox地図描画
Mapboxについて詳しく書こうとするキリがないのと本旨からずれてしまうのでこの記事ではMapboxに関する詳しい紹介はしていませんが、最初にMapboxで地図を描画する方法だけ手短に紹介します。
Mapbox標準の地図を描画するのは本当に簡単です。基本的には、AbstructMapコンポーネントを追加して地図を表示したい場所の緯度経度を入力するだけです。
画像は、新宿都庁のあたり。緯度経度は「35.689823013063766, 139.69215499826305」になります。奥の方に東京都庁、手前右に三角ビル(新宿住友ビル)などがあるのがわかります。
本サンプルのAbstructMap設定内容について知りたい場合は、こちらに詳細を記載しましたがので必要な方だけご覧ください。
Mapbox地図カスタマイズ
前置きが長くなりましたが、ここからいよいよ本題のMapboxが生成する地図の加工に入っていきます。
大まかな処理の流れとしては
1)Mapboxが地図を生成
2)地図内に配置されたメッシュをカスタムModifierで受け取り加工
3)タイル(地図の1区画)生成完了のコールバック処理で2で加工した情報をもとにTerrain用のデータを生成
4)Terrainに3で生成したデータをセットする
という感じになっています。
Mapboxのイベントをハンドリング
まずは、タイル生成完了時に処理を実行したいのでタイル生成完了のイベントをハンドリングします。
サンプルでは、GRCustomMapクラスのStart()でイベントハンドラを設定しています。イベントハンドラでの具体的な処理については後述します。
private void Start()
{
if (_abstractMap != null)
{
// イベントハンドラ設定
_abstractMap.OnTileFinished += OnTileFinished;
_abstractMap.OnTilesStarting += OnTilesStarting;
_abstractMap.OnTilesDisposing += OnTilesDisposing;
}
}
Modifierによる地図データ加工
Mapboxでは、目的に応じて実装したModifier(GameObjectModifier派生クラス)を指定することによりMapboxが生成する地図を様々な形に加工することができます。
今回は、自前のModifierとしてGRReplaceTerrainModifierを実装しました。
GRReplaceTerrainModifierでは、Mapboxが生成したタイル、建物のメッシュデータを含むデータを受け取り必要に応じてそれを加工しています。
ただ、今回実際の加工はGRTileMeshクラスに実装されているので、GRReplaceTerrainModifierの役割は、GRTileMeshの生成と管理といったところであまり多くの実装は含まれていません。
GRReplaceTerrainModifierをModifierとして指定する方法はこちらに記載しましたので必要に応じてご覧ください。
メッシュの加工
メッシュの加工については、GRTileMeshクラスに大部分を実装しています。
GRTileMeshの役割は、主に2つ。
・メッシュを生成
・メッシュをレンダリングしてその結果から高さ情報を取り出す
メッシュを生成
関連メソッド:GRTileMesh.SetTile, GRTileMesh.AddMesh
Mapboxが生成したメッシュからポリゴンデータを取り出して
・タイルで1つ
・全建物で1つ
の2つのメッシュを生成します。タイルには、地面のメッシュが含まれています。
ここでやっていることはそんなに複雑ではありません。単にGRReplaceTerrainModifierから受け取ったメッシュからポリゴンを取り出して次々にGRTileMeshで生成したメッシュに足していくだけです。
結果として画像のようなメッシュが二つ生成されます。
ここで一つだけ重要なのは、生成するメッシュには頂点カラーを付加するということ。この頂点カラーは後で高さ情報を取り出すために必要になります。
メッシュから高さ情報を取り出す
関連メソッド:GRTileMesh.GetGroundHeight, GRTileMesh.GetFeatureHeight
ここが今回のサンプルのある意味肝になります。
Terrainは、例えば解像度が256x256であれば、縦横256個に区切ったグリッドそれぞれに0〜1に正規化された高さを設定することで地形の起伏を表現します。
従ってここでは、タイルを256x256で分割してそのそれぞれのグリッドに対応する高さを取得することが必要になります。
そこで利用するのが、先ほど生成した地面と建物のメッシュになります。
メッシュから高さ情報を取り出す流れは
1) メッシュの頂点カラーに高さを格納する
2) メッシュの真上にカメラを配置してオフスクリーンでレンダリング
3) レンダリング結果の頂点色から高さを取り出す
となります。
メッシュの頂点カラーに高さを格納する
関連メソッド:GRTileMesh.UpdateHeight
メッシュ頂点カラーのR要素に頂点高さを格納します。
ただし、この際格納する値はメッシュの最小高さと最大高さの範囲で0〜1に正規化された値になります。
頂点カラーに0〜1に正規化された頂点高さを格納した結果のメッシュは画像のようになります。0〜1に正規化されているので一番低い場所が黒で一番高い場所が赤になっているのがわかります。
メッシュの真上にカメラを配置してオフスクリーンでレンダリング
関連メソッド:GRTileMesh.GetHeight
カメラの設定はこんな感じ。
重要なのは
・CurringMask:Terrainレイヤのみ指定
・Projection:Orthographic
・Size:50(正方形のメッシュだけを綺麗にカメラに収めるため)
・TargetTexture:実行時に生成したRenderTextureをセットする
のあたり。
レンダリング結果はこんな感じになります。ここでは対象のメッシュだけを隙間なくピッタリとカメラに収めることが重要になってきますが、そのための設定がProjection=Orthographic、Size=50になります。
レンダリング結果の頂点色から高さを取り出す
関連メソッド:GRTileMesh.GetPixels
カメラに設定したRenderTextureからReadPixels()でピクセル列を読み込みます。
private Color[] GetPixels(RenderTexture targetTexture)
{
Color[] colors = null;
var currentRenderTexture = RenderTexture.active;
{
RenderTexture.active = targetTexture;
// オフスクリーンレンダリング結果のピクセル列を取得
var texture = new Texture2D(targetTexture.width, targetTexture.height);
texture.ReadPixels(new Rect(0, 0, targetTexture.width, targetTexture.height), 0, 0);
texture.Apply();
colors = texture.GetPixels();
}
RenderTexture.active = currentRenderTexture;
return colors;
}
Mapboxでは、UnityTile.HeightDataに高さ情報が一次元配列の形で格納されています。例えばこの配列の長さが65,536だった場合、この配列には256x256の解像度でタイル内の高さが格納されています。
RenderTextureは、あらかじめこのMapboxの高さ解像度と同じ解像度で生成しています。
Terrainも同じ解像度で合わせています。さらにピクセル色のR要素もTerrainの高さデータと同様に0〜1で正規化されているので基本的には、ここで取り出したピクセル色のR要素を二次元配列に格納してやればそれがそのままTerrainの高さデータとなります。
private bool GetHeight(float[,] heights)
{
//(・・・中略・・・)
var colors = GetPixels(_camera.targetTexture);
if (colors == null)
{
Debug.LogError("Error");
return false;
}
if (colors.Length != (_textureSize.x * _textureSize.y))
{
Debug.LogError("Error");
return false;
}
var colorIndex = 0;
for (int x = 0; x < _textureSize.x; ++x)
{
for (int y = 0; y < _textureSize.y; ++y)
{
var color = colors[colorIndex++];
// Alpha値がゼロなら下限値固定。それ以外はRed値が高さとなる
heights[x, y] = (color.a == 0 ? 0 : color.r);
}
}
Terrainを生成
関連メソッド:GRCustomMap.OnTileFinished、GRCustomMap.AddTile
Mapboxがタイルを生成し終えると、GRCustomMap.OnTileFinished()が呼ばれます。
この時点で、前述のメッシュ加工も全て完了していつでも高さ情報が取り出せる状態になっています。
ここの処理の流れは
1)Terrainをインスタンス化
2)Terrainに高さをセットする
3)Terrainを整える
という感じになります。
Terrainをインスタンス化
関連メソッド:GRCustomMap.AddTile
あらかじめプレハブ化しておいたTerrainをインスタンス化します。
ここで注意が必要なのは、Terrainを動的に複製する場合は、GameObjectだけではなくTerrainの中のTerrainDataも複製してやる必要があるということです。
これをやらないと一つのプレハブから複数のTerrainをインスタンス化した場合に、全てのTerrainの内容が同じになってしまいます。これはTerrainのデータの実体は概ねTerrainDataに含まれており、TerrainDataは複製しても同じ実体を参照しているからです。
生成したTerrainの内容は、GRTileMeshにセットされた解像度(Mapboxと合わせてある)や高さ範囲(=最高高さー最低高さ)を参照して初期化します。
Terrainに高さをセットする
関連メソッド:GRCustomMap.AddTile
GRTileMeshから高さ情報を取り出してTerrainにセット(TerrainData.SetHeight)します。
同時にTerrain各グリッドのテクスチャもセットします。
今回は簡単に地面の高さを草原のテクスチャにして、建物の高さを岩石のテクスチャにします。
private bool AddTile(UnityTile tile)
{
// (・・・中略・・・)
// Terrainアルファマップ、ハイトマップ更新
float[,] heights = null;
float[,,] alphaMaps = null;
{
// 地面高さ取得
heights = tileMesh.GetGroundHeight();
if (heights == null)
{
return false;
}
// 建物高さ取得
var featureHeights = tileMesh.GetFeatureHeight();
if (featureHeights == null)
{
return false;
}
if (heights.Length != featureHeights.Length)
{
Debug.LogError("Error");
return false;
}
alphaMaps = terrainData.GetAlphamaps(0, 0, resolution, resolution);
for (int x = 0, countX = heights.GetLength(0); x < countX; ++x)
{
for (int y = 0, countY = heights.GetLength(1); y < countY; ++y)
{
var groundHeight = heights[x, y];
var featureHeight = featureHeights[x, y];
if (featureHeight <= groundHeight)
{
// 地面部分アルファマップ設定
alphaMaps[x, y, 0] = 1;
alphaMaps[x, y, 1] = 0;
}
else
{
// 建物部分アルファマップ、高さ設定
heights[x, y] = featureHeight;
alphaMaps[x, y, 0] = 0;
alphaMaps[x, y, 1] = 1;
}
}
}
}
terrainData.SetAlphamaps(0, 0, alphaMaps);
terrainData.SetHeights(0, 0, heights);
Terrainを整える
関連メソッド:GRCustomMap.AddTile
最後の仕上げです。
ここまでで完成したTerrainはグリッド毎に高さをセットしているのと、高さの元になっている建物が角張っていたり、建物屋根と同じように平ら過ぎたりでまだ人工物の匂いが残っています。
そこでTerrainの加工に色々と便利なフリーアセット「TerrainToolkit」(リンク)を使ってTerrainにスムージングをかけます。
実装は簡単でTerrainToolkitのSmoothTerrainメソッドを呼び出すだけです。
// TerrainToolkitを利用してTerrainをスムージング
_terrainToolkit.SmoothTerrain(1, 0.414f);
風化でエッジが削られて丸くなったように自然な感じになりました。
ズームするとさらに一目瞭然の違いがありますね。ただ、それでもなお人工物の匂いはまだ残っているので、メッシュ加工の時点で頂点座標自体に軽くノイズを乗せてやればさらに自然になるかもしれませんね。他にもTerrainテクスチャの塗り分けなどまだまだ改善の余地は色々とありそうです。
基本的にTerrainToolkitのエディ拡張で実行する各種のTerrain編集機能は、概ねスクリプトからも実行できるのでTerrainを色々と加工したい場合はとても重宝しています。
以上で、Mapboxが生成した3D地図を近似する荒野風の風景に置き換える処理の説明は完了です。
最後までご覧いただきありがとうございます。
以下では、補足的に本サンプルでのMapboxコンポーネントの設定など補足的な説明が続きます。興味のある方はこちらも併せてご覧ください。
AbstructMapコンポーネント設定
AbstructMapにはMapboxの地図に関する設定が概ね全て含まれています。
サンプルシーン「TerrainMap」ではHierarchyの「Map/CitySimulatorMap」にAbstructMapコンポーネントが追加されています。
GENERAL設定
◆Latitutde Longitude:緯度経度
表示したい地図の場所を緯度経度で指定します。Searchボタン押下で表示される検索ウィンドウに適当に地名とか入れると候補が出るのでそこから選択してもいいし、GoogleMapとかから緯度経度をコピペしてきてもいいです。
例えば東京スカイツリーの緯度経度だと 35.71000925, 139.810281 のあたりになります。
◆Zoom:縮尺
数値が大きいほど詳細になります。デフォルトでは4ですが、これだとほぼ世界地図くらいの縮尺の地図になるのでサンプルでは16に設定しています。
◆ExtentOptions:タイル範囲
マップ読み込み、生成を行う範囲。地図データは基本的にタイルと呼ばれる単位に分割されます。ExtentOptionsでは、タイルを取得する単位を設定します。
デフォルト設定では「RangeAroundCenter」になっていると思います。この設定では、中心タイルの周辺に幾つのタイルを生成するかの範囲を東西南北でそれぞれ指定できます。
デフォルトでは東西南北それぞれ1になっているかと思いますが、この設定だと合計9タイル読み込まれることになり環境にもよると思いますが処理が重くなるので本サンプルでは全てゼロに設定しています。この設定だと生成されるのは中心のタイル一つのみとなります。
MAP LAYERS設定
◆Data Source:データソース
どのマップデータを取ってくるかを指定する。デフォルトでは「Mapbox Streets」になっていますが、色々と比べてみたけどこのデフォルトのデータソースが一番良かったので本サンプルでも設定はそのままです。Mapboxのデータソースは、基本的にOpenStreetMapになるようですが日本の場合は、ゼンリンのデータソースを利用することもできるらしいです。2
FEATURES設定
マップの見た目をカスタマイズする場合は、この設定を色々と修正することになります。
◆MapFeatures:マップに追加で表示する要素
「AddFeature」ボタンを押下すると追加できる要素のリストが表示されます。
今回必要なのは、Buildingsだけなので追加しているのはこれだけですが、例えばRoadsを追加すると道路ネットワークが追加で表示されます。
ここから先の設定はBuildingsの詳細設定になります。Buildingsの詳細設定を編集するにはMapFeaturesリストに追加されたBuildingsを選択します。
◆Modeling: モデリング設定
・Geometry Type:モデル構成要素
Roof Only(屋根)、Side Only(側面)、Roof And Side(屋根&側面/デフォルト)の3つから選択。今回はデフォルトのまま屋根&側面を指定していますが、基本的にここで生成されるモデルは屋根形状をした方向に押し出しただけの形状なので単純に3DモデルデータからTerrainを生成するという目的を考えると屋根だけでも問題ありません。今回は、通常マップとTerrainを交互に切り替えたかったのであえてRoof And Sideにしています。
◆Texturing:テクスチャ
建物にどのようなテクスチャを貼るか選択します。今回はデフォルトのままRealistic(リアル)を選択していますが、単純に3DモデルデータからTerrainを生成するという目的を考えるとColor(単色塗りつぶし)でも問題ありません。
◆Behavior Modifiers:挙動修正
地図内モデル生成時の様々な挙動をカスタマイズします。
Modifierには、MeshModifiersとGameObjectModifiersの2種類あるようです。ここに任意のModifierを追加することで様々な挙動をカスタマイズできます。
GameObjectModifierには、GameObjectModifierクラスから派生したクラスから生成したスクリプタブルオブジェクトを指定できます。(GameObjectModifierはScriptableObjectを継承している)
今回は、自前のModifierとしてGRReplaceTerrainModifierを実装したのGameObjectModifierにはこれを指定します。GRReplaceTerrainModifierはタイルおよび建物のメッシュをTerrainに置き換えるためのメッシュ変換が実装されています。
public class GameObjectModifier : ModifierBase
{
public virtual void Run(VectorEntity ve, UnityTile tile)
{
}
public virtual void OnPoolItem(VectorEntity vectorEntity)
{
}
public virtual void Clear()
{
}
public virtual void ClearCaches()
{
}
}
参考
◆ファイル構成
今回の実装に関連するファイルは概ね「Assets/TerrainMap」フォルダ以下に格納されています。
・Assets/TerrainMap/Data
マテリアル、シェーダ、プレハブなどがフォルダ毎に格納されています。
・Assets/TerrainMap/Scripts
今回追加で実装したスクリプトが格納されています。
GRCustomMap.cs:メイン制御
GRReplaceTerrainModifier.cs:カスタムモディファイア
GRTileMesh.cs:タイル毎のメッシュ加工