はじめに

Geospatial APIはGoogle I/O 2022で発表されたVPS(Visual Positioning System)です。
Googleストリートビューのデータをもとに位置合わせができるため、従来のVPSのような点群マップの事前作成が不要です。
また、位置合わせには緯度・経度といった汎用的な地理座標が使われているため、他の地理座標を使ったサービスとの連携のしやすさも期待されます。
本稿では、建物の3Dモデルと地理座標が紐付けられているPLATEAUのCityGMLとGeospatial APIを使って建物の3DモデルをAR空間上に配置する方法を紹介します。

こちらが今回のデモです。

目次

開発環境

  • Unity2021.3.3f1
    • XR Plugin Management 4.2.1
    • ARCore Extensions 1.32.0
    • AR Foundation 4.2.3
    • ARCore XR Plugin 4.2.3
    • ARKit XR Plugin 4.2.3
    • UniTask 2.3.1
  • PowerShell 7.2.5
  • Android 12

Geospatial API サンプルのセットアップ

まずは、公式のドキュメントARCore Geospatial APIをUnityで使ってみるを参考に、サンプルシーンのビルドと実機での動作確認をします。
問題があればこちらが参考になります。

建物モデルの準備

次の手順で、Geospatial APIを使って配置したい建物のモデルを準備します。

  1. PLATEAUから使いたい地域のCityGMLファイルをダウンロード
  2. CityGMLをUnityで使える形式へ変換
  3. Unityへインポート

PLATEAUから使いたい地域のCityGMLファイルをダウンロード

まずは、PLATEAUからCityGMLのファイルをダウンロードします。
CityGMLには建物の3D形状データだけでなく、その建物の基準となる地理座標(緯度・経度・標高)が含まれています。Geospatial APIで配置する際にこの座標を利用します。

3D都市モデル(Project PLATEAU)ポータルサイトから使いたい地域を選択し、CityGMLデータをダウンロードします。
ダウンロードしたZIPファイルを解凍したら、構築範囲図(*_indexmap_op.pdf)を開きます。

都市モデルは一定の範囲ごとに区切られているため、この図から自分が使いたい地域の番号を探してください。この番号がgmlファイルのファイル名の先頭に付けられています。
gmlファイルは udx/bldg/ 内にあります。

今回は池袋のLOD2整備範囲で試します。この場合、番号が 53394577 でgmlファイルパスは udx/bldg/53394577_bldg_6697_2_op.gml となります。

このgmlファイルのパスは後ほど使います。

CityGMLをUnityで使える形式へ変換

前項で用意したgmlファイルはそのままUnityでの使用ができないため、対応する形式へ変換する必要があります。
今回はPLATEAUのCityGMLを.objに変換するツールを使います。
このツールでは元のCityGMLファイルから、基準となる位置の緯度・経度・標高などをヘッダーに記述したobjファイルを出力できます。
objファイルをUnityへインポート後にヘッダーを読み込めば、建物と地理座標を紐づけて扱うことができるはずです。

GitHubのReleasesから実行ファイルをダウンロードできます。

$ CityGMLToObj
Project PLATEAU の CityGMLファイル(.gml)を .obj 形式に変換します。
https://github.com/ksasao/PlateauCityGmlSharp

基準点を指定しない場合は、モデル全体の緯度、経度、高度 の最小値が原点になります。
.obj ファイルは outputフォルダ以下に出力されます。

使い方: CityGMLToObj .gmlファイルのパス [[基準点となる緯度] [経度] [高度] [緯度の下限] [経度の下限] [緯度の上限] [経度の上限]]

例1) シンプルな利用方法(テクスチャがある場合はフルパスを指定してください)
CityGMLToObj sample.gml

例2) 北緯 35.000度 東経 135.000度 高度100m を原点として座標を変換
CityGMLToObj sample.gml 35.000 135.000 0

何かキーを押してください...

前項で確認したgmlファイルのパスを第1引数に指定して実行すると、実行ファイルと同じディレクトリの output 下に変換されたobjファイルが出力されます。

$ CityGMLToObj tokyo23/13100_tokyo23-ku_2020_citygml_3_2_op/udx/bldg/53394577_bldg_6697_2_op.gml

試しにobjファイルの先頭を確認してみると、確かに座標が記述されています。

$ less output/53394577_bldg_6697_2_op13116-bldg-56.obj

# Origin: 35.7248537,139.7121684,23.16826185
# Lower : 35.7264700,139.7246761,23.72905608
# Upper : 35.7266317,139.7248213,41.10107303
mtllib 13116-bldg-56.mtl
g model
v -1142.198 0.563387 181.055
v -1129.93 0.5617155 179.7292
...

Unityへインポート

このobjファイルが入っているフォルダごとUnityへインポートします。
インポートが完了したらUnityプロジェクトからmtlファイルを削除して問題ありません。

建物モデルはそれぞれ個別にobjファイル化されているため、必要なものだけに絞って利用可能です。
今回はすべてまとめて1つのプレハブ化して使います。

Geospatial APIとPLATEAUの連携

前項で用意した建物モデルをGeospatial APIを使ってAR空間に配置していきます。

PLATEAUのCityGMLの地理座標とGeospatial APIの地理座標の違い

緯度と経度はCityGMLもGeospatial APIも座標系の違いを意識せず、そのままやりとりできます。
高度は楕円体高(=標高+ジオイド高)をGeospatial APIへ渡す必要があります。
CityGMLに含まれている高度は標高なので、その地点のジオイド高は別途求めます。ジオイド高の取得方法は後述します。

Geospatial APIでは WGS84 、PLATEAUのCityGMLでは JDG2011 + JDG2011 (vertical) height という座標系が使われているようです。
WGS84 と JDG2011 はほぼ誤差がなく同一のものとして扱えますが、WGS84 の高度は楕円体高を指すのに対し、 JDG2011 (vertical) height の高度は標高を指しています。

ジオイドとは
(https://www.gsi.go.jp/buturisokuchi/grageo_geoid.html)

Geospatial APIで任意のオブジェクトを配置

まずは、地理座標を指定してGameObjectを配置できるようにします。
次のメソッドでは、第1引数に指定したオブジェクトを第2引数の地理座標(緯度、経度、高度、方向)をもとにAR空間上に配置します。
ドキュメントに記載されていたものと同様の処理です。

[SerializeField] ARAnchorManager _arAnchorManager;

private GameObject Place(GameObject targetObj, GeospatialPose pose)
{
    var quaternion = Quaternion.AngleAxis(180f - (float)pose.Heading, Vector3.up);
    var anchor = _arAnchorManager.AddAnchor(pose.Latitude, pose.Longitude, pose.Altitude, quaternion);
    var placed = Instantiate(targetObj, anchor.transform);
    return placed;
}

建物の地理座標を取得する

gmlから変換したobjファイルのヘッダーに記述されている地理座標を読み込んでおきます。
objファイルヘッダーには、 Origin ・ Lower ・ Upper の3種類の座標がありました。
それぞれの内容は次の通りです。

OriginLowerUpper
すべての建物の緯度、経度、標高の最小値
(コマンドライン引数で基準点を指定しなかった場合)
各建物の緯度、経度、標高の最小値各建物の緯度、経度、標高の最大値

今回はすべての建物を1つの Prefab としているため、親となっている GameObject にのみアンカーを設置します。
よって、すべての建物の共通の基準点であるOriginのみ読み取り、地理座標を親の GameObject のアンカーに紐づけます。

建物モデルの基準点の地理座標を読み込む実装を以下に示します。

/// <summary>
/// 地理座標
/// </summary>
[Serializable]
public struct GeoCoord
{
    /// <summary>
    /// 緯度
    /// </summary>
    public double Latitude;

    /// <summary>
    /// 経度
    /// </summary>
    public double Longitude;

    /// <summary>
    /// 標高
    /// </summary>
    public double Elevation;

    /// <summary>
    /// ジオイド高
    /// </summary>
    public double Geoid;

    /// <summary>
    /// 高度(楕円体高)
    /// </summary>
    public double Altitude => Elevation + Geoid;
}

/// <summary>
/// 国土地理院ジオイド高APIのレスポンス
/// </summary>
[Serializable]
public struct GsiGeoidHeightJson
{
    public OutputData OutputData;
}

[Serializable]
public struct OutputData
{
    public double latitude;
    public double longitude;
    public double geoidHeight;
}
/// <summary>
/// 建物群
/// </summary>
public class BuildingGroup : MonoBehaviour
{
    /// <summary>
    /// 基準の地理座標
    /// </summary>
    public GeoCoord Origin => _origin;

    [SerializeField] private GeoCoord _origin;

    /// <summary>
    /// objファイルに埋め込まれた地理座標を取得するためのモデルのアセット
    /// Originの座標は同じ区画内で共通なのでどれか1つだけ使用する
    /// </summary>
    [SerializeField] private GameObject _originBuilding;

    /// <summary>
    /// 国土地理院のジオイド高取得API
    /// </summary>
    /// <param name="latitude"></param>
    /// <param name="longitude"></param>
    /// <returns></returns>
    private static string GsiGeoidApi(double latitude, double longitude) => $"https://vldb.gsi.go.jp/sokuchi/surveycalc/geoid/calcgh/cgi/geoidcalc.pl?outputType=json&latitude={latitude}&longitude={longitude}";

    [Header("動的にマテリアルを切り替え")]
    [SerializeField] private Material _replaceTarget;
    [SerializeField] private List<Renderer> _renderers = new();
    [SerializeField] private List<Material> _originalMaterials = new();

    private bool _isMaterialOrigin = true;

    /// <summary>
    /// マテリアル切り替え
    /// </summary>
    public void SwitchMaterial()
    {
        if (_isMaterialOrigin)
        {
            ReplaceMaterial();
        }
        else
        {
            RestoreMaterial();
        }
    }

    private void ReplaceMaterial()
    {
        if (_replaceTarget == null) return;
        foreach (var renderer in _renderers)
        {
            renderer.material = _replaceTarget;
        }
        _isMaterialOrigin = false;
    }

    private void RestoreMaterial()
    {
        if (_renderers.Count != _originalMaterials.Count) return;
        for (var i = 0; i < _renderers.Count; i++)
        {
            _renderers[i].material = _originalMaterials[i];
        }
        _isMaterialOrigin = true;
    }


    #if UNITY_EDITOR

    [ContextMenu(nameof(Initialize))]
    private void Initialize()
    {
        SetOriginAsync().Forget();

        // デバッグ用に動的にマテリアルを切り替える準備
        _renderers = GetComponentsInChildren<Renderer>().ToList();
        _originalMaterials.Clear();
        foreach (var renderer in _renderers)
        {
            _originalMaterials.Add(renderer.sharedMaterial);
        }
    }

    /// <summary>
    /// 子の建物のOriginを初期化
    /// </summary>
    private async UniTask SetOriginAsync()
    {
        // 元のアセット(objファイル)のパスを取得
        var result = GetAssetPath(_originBuilding);
        if (!result.ok) return;
        // Originの地理座標を読み込み
        await LoadGeoCoordOriginAsync(result.path);
        await UniTask.DelayFrame(1, cancellationToken: this.GetCancellationTokenOnDestroy());
        EditorUtility.SetDirty(this);
        AssetDatabase.SaveAssets();
        Debug.Log("Save Assets");
    }

    /// <summary>
    /// <see cref="target"/>の元のアセットのパスを取得
    /// </summary>
    /// <param name="target"></param>
    /// <returns></returns>
    private static (bool ok, string path) GetAssetPath(GameObject target)
    {
        var original = UnityEditor.PrefabUtility.GetCorrespondingObjectFromOriginalSource(target);
        var ok = original != null;
        return (ok, ok ? UnityEditor.AssetDatabase.GetAssetPath(original) : string.Empty);
    }

    /// <summary>
    /// gmlから変換したobjのヘッダーから地理座標を読み込む
    /// </summary>
    /// <param name="filePath"></param>
    /// <exception cref="FileNotFoundException"></exception>
    private async UniTask LoadGeoCoordOriginAsync(string filePath)
    {
        if (!File.Exists(filePath)) throw new FileNotFoundException();
        // Originは1行目にあるので、ファイルの1行目だけ読み込めばよい
        using var reader = new StreamReader(filePath, System.Text.Encoding.GetEncoding("UTF-8"));
        var header = await reader.ReadLineAsync();
        var geoCoord = header?.Split(' ')
            .LastOrDefault()?
            .Split(',')
            .Select(x => (ok: double.TryParse(x, out var coord), coord: coord))
            .Where(x => x.ok)
            .Select(x => x.coord)
            .ToArray();
        
        if (geoCoord == null || geoCoord.Length < 3) return;

        // ジオイド高を取得
        var geoid = await RequestGeoidAsync(geoCoord[0], geoCoord[1]);

        _origin = new GeoCoord
        {
            Latitude = geoCoord[0],
            Longitude = geoCoord[1],
            Elevation = geoCoord[2],
            Geoid = geoid,
        };
    }

    /// <summary>
    /// ジオイド高をリクエスト
    /// </summary>
    /// <param name="lat">緯度</param>
    /// <param name="lng">経度</param>
    /// <returns></returns>
    private async UniTask<double> RequestGeoidAsync(double lat, double lng)
    {
        var request = UnityWebRequest.Get(GsiGeoidApi(lat, lng));
        await request.SendWebRequest();
        if (request.error != null)
        {
            Debug.LogError(request.error);
            return double.NaN;
        }

        var json = request.downloadHandler.text;
        var response = JsonUtility.FromJson<GsiGeoidHeightJson>(json);
        return response.OutputData.geoidHeight;
    }
    #endif

}

建物の親オブジェクトへこの BuildingGroup コンポーネントを追加し、子にある適当な建物オブジェクトの1つをインスペクターの Origin Building へ割り当てます。
BuildingGroup のコンテキストメニューから Initialize を選択すると、 Origin 欄へ地理座標が入力されています。

また、デバッグ用に ReplaceTarget へマテリアルを指定しておくと実行時に建物のマテリアルを入れ替えられるようにしました。

ジオイド高の取得には、国土地理院のジオイド高計算APIを使用しました。
このAPIでは、緯度と経度からその地点のジオイド高を取得できます。同一IPアドレスからのリクエストは10秒間で10回との制限があるので、利用の際には注意が必要です。

Geospatial APIでPLATEAUのCityGMLモデルを配置する

さて、 準備が整ったので実際にGeospatial APIで建物モデルを配置してみます。
Geospatial APIのサンプルシーンのUIをほぼそのまま使います。

public class GeospatialPlateauExample : MonoBehaviour
{
    [Header("Geospatial")]
    [SerializeField] private ARAnchorManager _arAnchorManager;

    [Header("Buildings")]
    [SerializeField] private BuildingGroup _buildingGroup;

    private BuildingGroup _placedBuilding;

    [Header("UI")]
    [SerializeField] private Button _placeButton;
    [SerializeField] private Button _clearButton;
    [SerializeField] private Button _switchMaterialButton;

    private void Start()
    {
        // 配置ボタン
        _placeButton.onClick.AddListener(() =>
        {
            if (_placedBuilding != null) return;

            var placed = Place(_buildingGroup.gameObject, new GeospatialPose
            {
                Latitude = _buildingGroup.Origin.Latitude,
                Longitude = _buildingGroup.Origin.Longitude,
                Altitude = _buildingGroup.Origin.Altitude
            });
            if (placed == null) return;
            if (placed.TryGetComponent<BuildingGroup>(out var buildingGroup))
            {
                _placedBuilding = buildingGroup;
            }
        });

        // 削除ボタン
        _clearButton.onClick.AddListener(Clear);

        // マテリアル切り替えボタン
        _switchMaterialButton.onClick.AddListener(() =>
        {
            if (_placedBuilding == null) return;
            _placedBuilding.SwitchMaterial();
        });
    }

    /// <summary>
    /// <see cref="targetObj"/>を<see cref="pose"/>に配置する
    /// </summary>
    /// <param name="targetObj">対象のオブジェクト</param>
    /// <param name="pose">地理座標上の姿勢</param>
    /// <returns></returns>
    private GameObject Place(GameObject targetObj, GeospatialPose pose)
    {
        var quaternion = Quaternion.AngleAxis(180f - (float)pose.Heading, Vector3.up);
        var anchor = _arAnchorManager.AddAnchor(pose.Latitude, pose.Longitude, pose.Altitude, quaternion);
        var placed = Instantiate(targetObj, anchor.transform);
        return placed;
    }

    /// <summary>
    /// 配置済みのオブジェクトを削除する
    /// </summary>
    private void Clear()
    {

        if(_placedBuilding == null) return;
        Destroy(_placedBuilding.gameObject);
        _placedBuilding = null;
    }
}

これで冒頭の動画のように実際の建物の形状にぴったり合うよう配置されます。

おわりに

以上、Geospatial APIでPLATEAUのCityGML建物モデルを正確に配置する方法の紹介でした。
正確な地理座標が取得できるようになったことで、従来のスマホアプリでは活かしきれなかった地理情報を扱うサービスが今後活躍するでしょう。
また、Geospatial APIはまだ公開されたばかりなので今後のアップデートにも期待です。



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる