はじめに

Geospatial APIによって、モバイルアプリで現在地の高精度な地理座標を利用できるようになりました。
以前の記事では、PLATEAUの地理座標が埋め込まれた建物モデルをGeospatial APIと連携してAR空間に配置する方法を紹介しました。
今回は、GoogleのDirections APIと連携して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
  • Android 12

Directions APIについて

Directions APIは、指定した2地点間の経路を取得できるWebサービスです。
公共交通機関、自動車、徒歩、自転車などいくつかの交通手段の道順を取得できます。

リクエスト

APIのリクエストは最低限、出発地点のoriginと目的地のdestinationのみ指定すればよいです。
指定方法は次の3通りです。

  • Place ID
    • 地図上の場所を一意に特定できる
    • Geocoding APIPlaces API から取得できる
  • 住所
    • 住所の文字列をジオコーディングした緯度経度へ変換される
    • 東京駅東京スカイツリー のような日本語も可
    • コンビニ名 のように複数の候補が挙げられる文字列は Directions API のみでは不可。住所が一意に絞られるものを指定する。
  • 緯度・経度
    • 緯度と経度をカンマ区切りで指定する
  • その他オプションはこちら

レスポンス

  • ルート全体の距離
  • 所要時間
  • 道順の各ステップ
    • 各地点の座標(緯度と経度)
    • 次の地点の座標
    • 次の地点までの距離
    • 次の地点までの所要時間
    • 次の地点までの指示(右折など)
  • polyline
    • 道順の座標の配列
    • エンコードされているため、位置情報を取り出して使うなら自分でデコードする必要がある
    • 前述の各ステップよりも間隔が細かい
  • その他レスポンス詳細はこちら

事前準備

GCPのセットアップ

まずは、GCPのセットアップを行います。
新規プロジェクトを作成し、Directions APIとARCore APIの有効化とAPIKeyの作成をしてください。

Geospatial APIシーンのセットアップ

Geospatial APIのサンプルシーンをベースにします。
公式のドキュメントARCore Geospatial APIをUnityで使ってみるを参考に、サンプルシーンのビルドと実機での動作確認をして下さい。
問題があればトラブルシューティングの章が参考になります。

ARナビの実装

まずは必要な機能を実装していきます。

Directions APIから経路を取得する

Directions APIのリクエストにはさまざまなオプションもありますが、本稿では出発地と目的地を指定して、徒歩の経路を取得することのみを想定します。
次のクラスでAPIリクエストを行います。

次のURLでリクエストを行います。

originにはGeospatial APIで取得した緯度と経度をカンマ区切りで入力し、destinationには目的地を日本語の文字列で入力します。

https://maps.googleapis.com/maps/api/directions/json?origin=lat,lng&destination=目的地&mode=walking&language=ja&key=your-api-key

/// <summary>
/// Directions APIへ経路をリクエストする。
/// </summary>
/// <param name="origin">出発地</param>
/// <param name="destination">目的地</param>
/// <param name="apiKey">APIキー</param>
/// <param name="ct"></param>
/// <returns>ok: リクエスト成功したか</returns>
public static async UniTask<(bool ok, List<GeocodedWaypoint> waypoints, List<Route> routes)> RequestRouteAsync(
    (double lat, double lng) origin, string dest, string apiKey, CancellationToken ct)
{
    var url = $"https://maps.googleapis.com/maps/api/directions/json?origin={origin.lat},{origin.lng}&destination={dest}&mode=walking&language=ja&key={apiKey}";
    try
    {
        var request = await UnityWebRequest.Get(url).SendWebRequest().WithCancellation(ct);
        if (request.result != UnityWebRequest.Result.Success) return (false, default, default);
        var json = request.downloadHandler.text;
        var response = JsonUtility.FromJson<DirectionsResponse>(json);
        return (response.status == "OK", response.geocoded_waypoints, response.routes);
    }
    catch (Exception e)
    {
        Debug.LogError(e);
        return (false, default, default);
    }
}

レスポンスのjsonは次で受け取ります。

[Serializable]
public struct Bounds
{
    public Location northeast;
    public Location southwest;
}

[Serializable]
public struct Distance
{
    public string text;
    public int value;
}

[Serializable]
public struct Duration
{
    public string text;
    public int value;
}

[Serializable]
public struct GeocodedWaypoint
{
    public string geocoder_status;
    public string place_id;
    public List<string> types;
}

[Serializable]
public struct Leg
{
    public Distance distance;
    public Duration duration;
    public string end_address;
    public Location end_location;
    public string start_address;
    public Location start_location;
    public List<Step> steps;
    public List<object> traffic_speed_entry;
    public List<object> via_waypoint;
}


[Serializable]
public struct Polyline
{
    public string points;
}

[Serializable]
public struct DirectionsResponse
{
    public List<GeocodedWaypoint> geocoded_waypoints;
    public List<Route> routes;
    public string status;
}

[Serializable]
public struct Route
{
    public Bounds bounds;
    public string copyrights;
    public List<Leg> legs;
    public Polyline overview_polyline;
    public string summary;
    public List<string> warnings;
    public List<object> waypoint_order;
}

[Serializable]
public struct Location
{
    public double lat;
    public double lng;
}

[Serializable]
public struct Step
{
    public Distance distance;
    public Duration duration;
    public Location end_location;
    public string html_instructions;
    public Polyline polyline;
    public Location start_location;
    public string travel_mode;
    public string maneuver;
}

以下のように経路を取得します。

[SerializeField] private string _apiKey;
 
/// <summary>
/// polylineをデコードして取得した経路全体の座標
/// </summary>
private List<(double lat, double lng)> _overviewPath = new();

/// <summary>
/// 経路の道順
/// </summary>
private List<Step> _steps = new();

/// <summary>
/// 経路全体の距離
/// </summary>
private Distance _distance;

/// <summary>
/// 所要時間
/// </summary>
private Duration _duration;

/// <summary>
/// 目的地の住所からナビのルートを決定する
/// </summary>
/// <param name="origin">出発地</param>
/// <param name="destination">目的地</param>
/// <param name="ct"></param>
/// <returns>成功したか</returns>
public async UniTask<bool> RoutingAsync((double lat, double lng) origin, string dest, CancellationToken ct)
{
    Debug.Log("Routing...");
    var response = await DirectionsClient.RequestRouteAsync(origin, dest, _apiKey, ct);
    if (!response.ok)
    {
        Debug.Log("Request was Failed");
        return false;
    }

    // リクエストの際にalternativesオプションをつけていたら複数のルートが提示されるが、今回は扱わない
    if (!response.routes.Any())
    {
        Debug.Log("Routes not Found");
        return false;
    }
    var route = response.routes.First();

    var polyline = route.overview_polyline.points;
 
    if (!string.IsNullOrEmpty(polyline))
    {
        _overviewPath = GooglePoints.Decode(polyline).Select(x => (x.Latitude, x.Longitude)).ToList();
    }
    if (route.legs.Any())
    {
        var leg = route.legs.First();
        _distance = leg.distance;
        _duration = leg.duration;
        _steps = leg.steps;
        _steps.Clear();
        _steps.AddRange(leg.steps);
    }
    return true;
}

レスポンスの List<Routes> は道順の候補リストですが、デフォルトでは1つの道順のみです。オプションでalternatives=trueを指定した場合にのみ複数のルートが返されます。

冒頭のデモ動画で使用していたのはOverviewPolylineから取得できる経路の座標でした。
後ほど、List<Step> の座標と OverviewPolyline の座標との比較もしてみます。
PolylineのデコードにはGooglePoints.csを利用しました。

経路の各経由地点の高度を取得する

後にGeospatial APIで経路上に目印用のオブジェクトを配置するため、地表の高度(楕円体高)が必要になります。*1
この高度に関しては以前の記事でも触れていますが、経緯度に対して標高とジオイド高を求め、標高+ジオイド高とした値が楕円体高の値になります。
標高とジオイド高の取得には国土地理院のAPIを利用します。

/// <summary>
/// 国土地理院のAPI
/// 同一IPからのリクエストは10秒間に10回までの制限あり
/// https://vldb.gsi.go.jp/sokuchi/surveycalc/api_help.html
/// </summary>
public class GsiClient
{
    /// <summary>
    /// リクエスト制限回数
    /// </summary>
    private const int RequestCapacity = 10;

    /// <summary>
    /// リクエスト制限間隔
    /// </summary>
    private const int RequestInterval = 10;

    /// <summary>
    /// リトライ上限回数
    /// </summary>
    private const int RetryLimit = 5;
    public int RequestCount
    {
        get;
        private set;
    }

    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}";
    private static string GsiElevationApi(double latitude, double longitude) => $"https://cyberjapandata2.gsi.go.jp/general/dem/scripts/getelevation.php?outtype=JSON&lat={latitude}&lon={longitude}";

    /// <summary>
    /// 指定した地点のジオイド高をリクエストする。
    /// </summary>
    /// <param name="latitude">緯度</param>
    /// <param name="longitude">経度</param>
    /// <param name="ct"></param>
    /// <returns>ok: リクエスト成功したか, value: ジオイド高の値</returns>
    public async UniTask<(bool ok, double value)> RequestGeoidHeightAsync(double latitude, double longitude, CancellationToken ct)
    {
        var result = await RequestJsonAsync(GsiGeoidApi(latitude, longitude), ct);
        if (!result.ok) return (false, 0);

        var gsiResult = JsonUtility.FromJson<GsiGeoidHeightJson>(result.json);
        return (true, gsiResult.OutputData.geoidHeight);
    }

    /// <summary>
    /// 指定した地点の標高をリクエストする。
    /// </summary>
    /// <param name="latitude">緯度</param>
    /// <param name="longitude">経度</param>
    /// <param name="ct"></param>
    /// <returns>ok: リクエスト成功したか, value: 標高の値</returns>
    public async UniTask<(bool ok, double value)> RequestElevationAsync(double latitude, double longitude, CancellationToken ct)
    {
        var result = await RequestJsonAsync(GsiElevationApi(latitude, longitude), ct);
        if (!result.ok) return (false, 0);

        var gsiResult = JsonUtility.FromJson<GsiElevationJson>(result.json);
        return (true, gsiResult.elevation);
    }

    private async UniTask<(bool ok, string json)> RequestJsonAsync(string url, CancellationToken ct)
    {
        for (var i = 0; i <= RetryLimit; i++)
        {
            await UniTask.WaitUntil(() => RequestCount < RequestCapacity, cancellationToken: ct);
            try
            {
                RequestCount++;
                var request = await UnityWebRequest.Get(url).SendWebRequest().WithCancellation(ct);
                return (true, request.downloadHandler.text);
            }
            catch (UnityWebRequestException e)
            {
                // リクエストを送りすぎた時に503エラー
                if (e.ResponseCode == 503L)
                {
                    // 時間をおいてリトライ
                    Debug.Log("Retry");
                    await UniTask.Delay(TimeSpan.FromSeconds(RequestInterval), cancellationToken: ct);
                }
            }
            finally
            {
                IntervalTask();
            }
        }
        return (false, null);
    }
    private void IntervalTask(CancellationToken ct = default)
    {
        try
        {
            UniTask.Delay(TimeSpan.FromSeconds(RequestInterval), cancellationToken: ct)
                .ContinueWith(() => RequestCount--)
                .Forget();
        }
        catch (OperationCanceledException)
        {
            Debug.Log("Interval Cancelled");
            RequestCount--;
        }
    }
}

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

/// <summary>
/// 国土地理院標高APIのレスポンス
/// </summary>
[Serializable]
public struct GsiElevationJson
{
    public double elevation;
    public string hsrc;
}

*1 本稿作成中にリリースされたARCore Extensions 1.33.0に、Terrain Anchorという機能が追加されました。Terrain Anchorは高度ではなく地表からの高さを指定してオブジェクトを配置することができる機能です。本稿の最後で国土地理院APIを使い楕円体高を求める方法とTerrain Anchorを使う方法を比較します。

経路上にオブジェクト配置

UIのボタンが押されたときなどのタイミングで次のStartNavigationAsyncを呼び出します。

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

    [Header("Directions")]
    [SerializeField] private string _apiKey;
    /// <summary>
    /// 通過地点に配置するモデル
    /// </summary>
    [SerializeField] private GameObject _arrowPrefab;
    /// <summary>
    /// 目的地に配置するモデル
    /// </summary>
    [SerializeField] private GameObject _destinationPrefab;
    [SerializeField] private InputField _destinationInput;

    /// <summary>
    /// polylineをデコードして取得した経路全体の座標
    /// </summary>
    private List<(double lat, double lng)> _overviewPath = new();

    /// <summary>
    /// 経路の道順
    /// </summary>
    private List<Step> _steps = new();

    /// <summary>
    /// 経路全体の距離
    /// </summary>
    private Distance _distance;

    /// <summary>
    /// 所要時間
    /// </summary>
    private Duration _duration;

    private readonly GsiClient _elevationClient = new();
    private readonly GsiClient _geoidClient = new();


    public async UniTask StartNavigationAsync(CancellationToken ct)
    {
        if (!(_earthManager.EarthState == EarthState.Enabled &&
            _earthManager.EarthTrackingState == TrackingState.Tracking))
        {
            Debug.Log("Start Navigation Failed");
            return;
        }

        // デバイスの地理座標を取得
        var pose = _earthManager.CameraGeospatialPose;

        // 現在地からInputFieldに指定した目的地までの経路を決定
        var success = await RoutingAsync((pose.Latitude, pose.Longitude), _destinationInput.text, ct);
        if (!success)
        {
            Debug.Log("Routing was Failed");
            return;
        }
        Debug.Log("Routing was Succeeded.");
        try
        {
            await PlaceNavigationObjectsAsync(ct);
        }
        catch (OperationCanceledException)
        {
            Debug.Log("Navigation was Cancelled");
        }
    }

    /// <summary>
    /// 目的地の住所からナビのルートを決定する
    /// </summary>
    /// <param name="origin">出発地</param>
    /// <param name="destination">目的地</param>
    /// <param name="ct"></param>
    /// <returns>成功したか</returns>
    public async UniTask<bool> RoutingAsync((double lat, double lng) origin, string dest, CancellationToken ct)
    {
        Debug.Log("Routing...");
        var response = await DirectionsClient.RequestRouteAsync(origin, dest, _apiKey, ct);
        if (!response.ok)
        {
            Debug.Log("Request was Failed");
            return false;
        }

        // リクエストの際にalternativesオプションをつけていたら複数のルートが提示されるが、今回は扱わない
        if (!response.routes.Any())
        {
            Debug.Log("Routes not Found");
            return false;
        }
        var route = response.routes.First();

        var polyline = route.overview_polyline.points;
 
        if (!string.IsNullOrEmpty(polyline))
        {
            _overviewPath = GooglePoints.Decode(polyline).Select(x => (x.Latitude, x.Longitude)).ToList();
        }
        if (route.legs.Any())
        {
            var leg = route.legs.First();
            _distance = leg.distance;
            _duration = leg.duration;
            _steps = leg.steps;
            _steps.Clear();
            _steps.AddRange(leg.steps);
        }
        return true;
    }

    /// <summary>
    /// 楕円体高を取得する
    /// </summary>
    /// <param name="lat">緯度</param>
    /// <param name="lng">経度</param>
    /// <param name="ct"></param>
    /// <returns>ok: 成功したか value: 楕円体高</returns>
    private async UniTask<(bool ok, double value)> GetAltitudeAsync(double lat, double lng, CancellationToken ct)
    {
        // 与えられた緯度・経度地点の標高とジオイド高を取得
        var elevationTask = _elevationClient.RequestElevationAsync(lat, lng, ct);
        var geoidTask = _geoidClient.RequestGeoidHeightAsync(lat, lng, ct);
        var (elevation, geoid) = await UniTask.WhenAll(elevationTask, geoidTask);
        if (elevation.ok && geoid.ok) return (true, elevation.value + geoid.value);
        Debug.Log($"Elevation Request: {(elevation.ok ? "success" : "failed")}n" +
            $"Geoid Request: {(geoid.ok ? "success" : "failed")}");
        return (false, 0);
    }

    /// <summary>
    /// overviewの経路上にオブジェクトを配置する
    /// </summary>
    /// <param name="ct"></param>
    /// <returns></returns>
    private async UniTask PlaceNavigationObjectsAsync(CancellationToken ct)
    {
        for (var i = 0; i < _overviewPath.Count; i++)
        {
            var isGoal = i == _overviewPath.Count - 1;
            var targetObject = isGoal ? _destinationPrefab : _arrowPrefab;
            var point = _overviewPath[i];

            var altitude = await GetAltitudeAsync(point.lat, point.lng, ct);
            if (!altitude.ok) continue;
            Place(targetObject, new GeospatialPose
            {
                Latitude = point.lat,
                Longitude = point.lng,
                Altitude = altitude.value,
            });
        }
    }

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

これで冒頭のようなナビゲーションができるようになりました。

UIやルート上の線の更新部分は省略しています。

Terrain Anchor

ARCore Extensions 1.33.0からTerrain Anchorが追加されました。

Terrain Anchorは緯度と経度と地形に対する高度を指定してオブジェクトを配置できる機能です。

このTerrain Anchor による配置方法と、国土地理院のAPIで楕円体高を求める配置方法との比較をしてみます。

Terrainでオブジェクトを配置

次のPlaceNavigationObjectsByTerrainAnchorメソッドではちょうど地面の位置にオブジェクトを置くようになっています。

v1.33.0で追加された ARAnchorManager.ResolveAnchorOnTerrain は従来の ARAnchorManager.AddAnchorと同じく ARGeospatialAnchorを返します。

ただし、ARGeospatialAnchor にも新しく TerrainAnchorState という状態が追加されており、この状態が Success になるまではTerrain Anchorが作成完了していないので注意が必要です。

private async UniTask PlaceNavigationObjectsByTerrainAnchorAsync(CancellationToken ct)
{
    // 地形上の高さ
    // 0なら地面(床)の上
    double altitudeAboveTerrain = 0d;
    float timeoutSeconds = 10f;
    for (var i = 0; i < _overviewPath.Count; i++)
    {
        var isGoal = i == _overviewPath.Count - 1;
        var targetObject = isGoal ? _destinationPrefab : _arrowPrefab;
        var point = _overviewPath[i];

        var placed = Place(targetObject, new GeospatialPose
        {
            Latitude = point.lat,
            Longitude = point.lng,
            Altitude = altitudeAboveTerrain,
        }, true);
        if(placed == null) continue;
        // terrain anchorが作成完了したらオブジェクトを表示する
        placed.gameObject.SetActive(false);
        var timeoutTokenSource = new CancellationTokenSource();
        timeoutTokenSource.CancelAfterSlim(TimeSpan.FromSeconds(timeoutSeconds));
        var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutTokenSource.Token);
        try
        {
            await UniTask.WaitUntil(() => placed.terrainAnchorState == TerrainAnchorState.Success, cancellationToken: linkedTokenSource.Token);
            placed.gameObject.SetActive(true);
        }
        catch (OperationCanceledException)
        {
            if (timeoutTokenSource.IsCancellationRequested)
            {
                Debug.Log("Terrain Anchor Creation timed out.");
            }

            Debug.Log($"Canceled Terrain Anchor State: {placed.terrainAnchorState}");
            Destroy(placed.gameObject);
        }

    }
}

private ARGeospatialAnchor Place(GameObject obj, GeospatialPose pose, bool terrain = false)
{
    var quaternion = Quaternion.AngleAxis(180f - (float)pose.Heading, Vector3.up);
    // v1.33.0からARAnchorManagerにResolveAnchorOnTerrainが追加されている
    var anchor = terrain ?
        _arAnchorManager.ResolveAnchorOnTerrain(pose.Latitude, pose.Longitude, pose.Altitude, quaternion) :
        _arAnchorManager.AddAnchor(pose.Latitude, pose.Longitude, pose.Altitude, quaternion);
    if (anchor == null) return null;
    Instantiate(obj, anchor.transform);
    return anchor;
}

それでは、TerrainAnchorと国土地理院APIによる楕円体高のそれぞれを使ってナビゲーションの経路を表示してみます。

また、今回は Stepの各位置に矢印のオブジェクトを配置し、overview_polyline の各位置に点を配置します。

高度の種類ごとに矢印の色を分けました。

赤: デバイスのカメラ位置(地形の高度は考慮しない)

緑: 国土地理院APIによる楕円体高(標高+ジオイド高)

青: Terrain Anchor

このように、地面の高さに正確に配置されました。

また、国土地理院のAPIを使って1度にたくさんの配置をしようとしても、リクエストレートの制限の影響で少し時間を置きながら配置されることになります。

ところが、TerrainAnchorではこのように1度にまとめて配置できています。(TerrainAnchorStateの完了に少しの時間差はありますが、それでも十分早いです。)

TerrainAnchorの注意点としては、1度に40個以上のTerrainAnchorは使用できないようです。

おわりに

地理座標を扱えることでARアプリにナビゲーション機能を手軽に組み込めるようになりました。
今回の実装では省略したDirections APIのオプションや、他のAPIとの組み合わせもぜひ試してみて下さい。



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