ARCore Geospatial APIを利用して、ARでアバターに走ってもらった話

この記事は、面白法人グループ Advent Calendar 2022 の12日目の記事です。



こんにちは。カヤックボンドでエンジニアをやっております青木です。

今回は、UnityとARKit、そして無料で使える位置情報API「ARCore Geospatial API」を利用し、ARでアバターを緯度経度ベースで現実世界に召喚し、動かしてみたいと思います。

目次

はじめに

皆さん、XRは好きですか?私は大好きです!
趣味の時間には、だいたいVRゲームを遊んでいるか、UnityでXRで色々作っているかをしています。

そんな私ですが、ARでアプリを利用したり、何かを作る際に、一点現状に悩みを抱えています。
それは、現実世界との位置合わせです。

ARアプリ内で設置したオブジェクトは、アプリを再起動しても現実世界の同じ場所に再生成されるようになって欲しいですし、他の端末と同じ空間を共有もしたいと思っています。(後者はARWorldMapである程度できるようですが…)

ですが、現実世界の一致した場所にオブジェクトを設置するためには、現実世界の正確な座標を測位する必要があり、お手軽でかつ精度を維持できる方法はなかなか見当たりませんでした。*1

しかし、高精度かつお手軽に位置合わせを行うことができるAPIが5月にGoogleから爆誕しました。
それは、ARCore Geospatial APIです。
今回は、このAPIを利用し、ARでアバターを指定した場所に召喚してみたいと思います。

ARCore Geospatial APIとは?

ARCore Geospatial APIとは、Googleが提供している、ARCoreで無料で利用可能*2な位置情報APIです。
このAPIは主に以下の機能をユーザーに提供してくれます。

GPSとストリートビューの膨大な画像データを併用することで、スマホの現在位置を高精度に測位し、ARでオブジェクトをどこに配置するかを自動で認識してくれる機能

そのため、緯度・経度等の座標情報をこのAPIに与えるだけで、簡単にARで現実世界の緯度経度を指定して、オブジェクトを生成することができます。
また誤差は条件にもよりますが、動かした限りでは1m未満にもなる等、非常に高精度です。
Google Mapのライブビューと同じシステムを利用しているようなので、期待できちゃいますね!

早速サンプルを動かしてみます。

サンプルプロジェクトを動かす

このAPIを利用する方法は様々ありますが、今回はUnityで動かしていきたいと思います。

利用する環境は以下の通りです。  Color Space はLinearにしておきましょう。

名前 バージョン 備考
Unity 2020.3.42f1
AR Foundation 4.2.2
ARCore XR Plugin 4.2.2 iOS環境でも、今回はARCoreは必要
ARKit XR Plugin 4.2.2
ARCore Extensions 1.34.0 https://github.com/google-ar/arcore-unity-extensions
UniVRM(アバター用) 1.11.3 https://github.com/vrm-c/UniVRM/releases?page=4
UniTask(アバター用) 2.3.3 https://github.com/Cysharp/UniTask/releases


Geospatial APIの環境構築は以下ブログ等が非常に分かりやすく書かれていました。
途中GCPでのAPIキーの発行等が必要になりますが、今のところこの機能は無料で利用できるので安心ですね。 zenn.dev

さて、環境構築が終わると、以下のようなサンプルシーンとご対面です。
Clipping Plane(描画距離)がデフォルトで最大20mまでしかなく、あまりにも短すぎるので1000にしておきました。 Geospital APIのサンプルシーン

UnityとXcodeでビルドすればiPhone等にデプロイできます。

デプロイしたものを起動し、カメラやGPSの利用などを許可すると、以下のような画面が開きます。
左上と左下に出力されているのはデバッグ情報で、現在の緯度経度や高度、APIのトラッキング等のステータス等を確認することができます。

起動したばかりの画面
起動すると、こんな画面が広がります。一番下の赤い文字は、私がつけた翻訳。
さて、起動したばかりの状態ではGeospatial APIはその力を発揮することができません。

高精度な位置合わせを行うためには、周りの風景をしばらく(5秒ぐらい)視界に収め、ストリートビューの画像データとカメラの風景が照会され測位が完了するのを待つ必要があります。
APIが位置情報の同期に成功すると、右下にボタンが表示されます。
測位成功
測位に成功した状態

同期に成功すると、画面をタップした場所の平面を認識し、その緯度・経度・高度の場所にアンカーを配置できるようになります。
ここで設置したアンカーの座標は保存されるので、アプリを再起動して精度を試してみましょう!

アンカーされたARマーカー アンカーされたARマーカー
アプリの再起動前と再起動後でアンカーの場所を比較。夜なのにしっかり同じ場所にアンカーが出ていますね!


画像真ん中の木と、画像右側の木にそれぞれアンカーを設置した状態で、アプリを再起動してみたものの比較です。
夜で背景が暗いにも関わらず、ほぼ同じ場所にオブジェクトを生成できていることがわかりました。いい滑り出しですね!

指定の座標にアバターを召喚し、近づいたら手を振ってもらう

さて、サンプルを動かせたところで、次は指定した座標に試しにアバターを召喚してみましょう。

出現させるアバターは、以下の子にしました。

booth.pm アバターのベースは上の右近ちゃんで、さらに下のベレーとメガネをつけています。
上の画像のサンタ帽のURLも貼っておきます。

しんぷるおーばるぐらす.unitypackage (バリエーション追加) - ぽぴぞん - BOOTH

Saku*saku リボンベレー/ribonberet - 蒼空の下の市場 BOOTH店/aozora no sita no sijou - BOOTH

サンタ帽の3Dモデル - 穴子電子商会 - BOOTH


アバターを召喚させるための要件は以下とします。

  • アバターを出す緯度経度はインペクターで予め指定しておき、アバターは出てからは不動。

  • 高度はスマホの高度からアバターの身長を差し引いた高度

  • 既存のGeospital APIのサンプルシーンのUI上にボタンをポン付けし、そのボタンが押されたら上の座標にアバターを移動し、アクティブ化する。

  • 出現したアバターは、カメラとの距離を測定し続け、一定距離以下に近づいた場合こちらに手を振るアニメーションを再生する。


さて、上記要件を満たすため、以下のスクリプトを組みました。
何か参考となればと思います。

アバター召喚用のスクリプト
using UnityEngine;
using Cysharp.Threading.Tasks;
using System.Threading;
using Google.XR.ARCoreExtensions;
using UnityEngine.XR.ARFoundation;

public class GeoAPIAvatarManager : MonoBehaviour
{
    [Header("AR")]
    // 緯度経度高度や、APIのトラッキング情報等を取得するマネージャー
    [SerializeField]
    public AREarthManager EarthManager;
    // 座標のアンカーのマネージャー
    [SerializeField]
    public ARAnchorManager AnchorManager;
    [SerializeField]
    private Camera camera;

    [Header("アバター")]
    [SerializeField]
    private GameObject avatar;
    [SerializeField]
    private Animator avatarAnim;
    [SerializeField]
    private Vector2 avatarLatLong;

    [Header("アニメーション")]
    private bool isNear;
    private Vector2 distanceNearFar = new Vector2(6.0f, 15.0f);
    private bool canChangeDistance = true;

    [Header("その他")]
    private CancellationTokenSource cancellation = new CancellationTokenSource();

    // アバターの位置合わせ
    // このメソッドを、Geospital APIで位置合わせができているタイミングで呼ぶと、アバターをavatarLatLongの座標に移動させる
    public void SetAvatarAnchor()
    {
        avatar.SetActive(true);

        // アバターを出現させる
        // カメラの地理空間上の座標を取得
        var pose = EarthManager.CameraGeospatialPose;
        // カメラの高度から、アバターの身長分の高度を引いた高さが、アバターの召喚高度
        double altitude = pose.Altitude;
        altitude -= 1.3f;

        // 座標情報からアンカーを生成
        var anchor = AnchorManager.AddAnchor(avatarLatLong.x , avatarLatLong.y , altitude, Quaternion.identity);
        if (anchor != null)
        {
            // アバターを、座標アンカーの子オブジェクトに
            avatar.transform.SetParent(anchor.transform);
            avatar.transform.localPosition = Vector3.zero;
        }
    }

    private void Update()
    {
        // アバターが非アクティブの際にはリターン
        if (avatar.activeInHierarchy == false)
        {
            return;
        }

        UpdateAvatarRotation();
        if (avatar.activeInHierarchy)
        {
            CheckCameraAvatarDistance();
        }
    }

    // アバターが常にカメラのほうを向くようにする
    private void UpdateAvatarRotation ()
    {
        avatar.transform.LookAt(camera.transform);
        Vector3 avatarRot = avatar.transform.eulerAngles;
        avatarRot.x = 0f;
        avatarRot.z = 0f;
        avatar.transform.eulerAngles = avatarRot;
    }

    // カメラとアバターの距離をチェックし、一定距離のラインを超えた際に、アバターに専用のモーションを取らせる
    private void CheckCameraAvatarDistance()
    {
        if (canChangeDistance == false)
        {
            return;
        }

        float distance = Vector3.Distance(camera.transform.position, avatar.transform.position);
        if (isNear && distanceNearFar.y < distance)
        {
            avatarAnim.SetTrigger("FarPlayer");
            isNear = false;
            ChangeDistanceWaiter();
        }
        else if (isNear == false && distance < distanceNearFar.x)
        {
            avatarAnim.SetTrigger("NearPlayer");
            isNear = true;
            ChangeDistanceWaiter();
        }
    }

    // アニメーションの切り替えインターバル
    private async UniTask ChangeDistanceWaiter()
    {
        canChangeDistance = false;
        await UniTask.Delay(15 * 1000 , false , PlayerLoopTiming.Update , cancellation.Token);
        canChangeDistance = true;
    }

    private void OnDisable()
    {
        cancellation.Cancel();
    }
}


既存のデバッグUIポン付けしたボタンを押すと、 SetAvatarAnchor()メソッドが呼び出され、アバターがアクティブ化されます。
このアバターは、AnchorManagerのAddAnchor()というメソッドから生成される自動で位置合わせを行ってくれるアンカーの子オブジェクトとなるようにしています。
そのため、このアバターは何もせずとも位置を自動補正し、特定の現実の地点に留まってくれます。

また、アバターのモーションですが、UpdateAvatarRotation() では、アバターが常にカメラのほうを向くための処理を、 CheckCameraAvatarDistance() では、カメラとアバターの距離が一定を超えた場合にアニメーションを切り替える処理をしています。
距離が遠い状態から近づくと手を振るアニメーションを自動再生するといった感じです。


また、アニメーションはバーチャルモーションキャプチャーというツールで撮りました!
さて、それでは早速動かしてみましょう!

動かした動画はこちら!

www.youtube.com


何回か試しましたが、水平方向に関しては大体誤差2~30cm程度でアバターを指定した場所に呼び出すことができました。
高度は若干不安定で、再起動するたびに50cmぐらい変動します。TerrainAnchorという機能を使うと軽減できるらしいので、また試してみたいです。

また、距離のチェックはばっちりで、近づいたらこちらに手を振ってきてくれました!
これで、位置が固定の場合の動作には問題がないことがわかりました。さて次は・・・

アバターに道を走ってもらう

ズバり!近くの道をダッシュしてもらおうと思います!
今回は以下の要件でアバターに近くの道をダッシュしてもらおうと思います。

  • 道の突き当たりと反対側の突き当たりの緯度経度をGoogle Mapで調べ、それぞれダッシュ開始・終了地点とする

  • ダッシュ開始地点とダッシュ終了地点の位置を上記緯度経度で指定し、そこに座標をアンカーする

  • ダッシュ開始地点とダッシュ終了地点の位置は固定とする

  • 線形補完でアバターの位置をワールド座標で直指定し、少しづつ開始地点から終了地点に移動させつつ、ダッシュモーションを再生する


これでかなりダッシュっぽい動きをしてもらえるはずです。
というわけで、上の動きを行うために、以下のスクリプトを組みました。

アバターにダッシュしてもらうスクリプト
using Google.XR.ARCoreExtensions;
using UnityEngine;
using UnityEngine.XR.ARFoundation;

public class AvatarRun : MonoBehaviour
{
    [Header("AR")]
    [SerializeField]
    private AREarthManager EarthManager;
    [SerializeField]
    private ARAnchorManager AnchorManager;

    [Header("ARアンカー")]
    // アバターのダッシュ開始地点の緯度経度
    [SerializeField]
    private Vector2 avatarWorldPositionMoveStart;
    // アバターのダッシュ目標地点の緯度経度
    [SerializeField]
    private Vector2 avatarWorldPositionMoveEnd;
    private Transform startAnchorTrans;
    private Transform targetAnchorTrans;

    [Header("アバター")]
     // アバターの親オブジェクトをここに指定。このオブジェクトが動くことでアバターも動く
     // VRMSpringBoneのCenterをこのオブジェクトに設定すると、揺れ物が乱れずに走ってくれる
    [SerializeField]
    private GameObject avatarParent;

    [Header("移動時間")]
    [SerializeField]
    private float moveTime;
    private float nowMoveTime;

    private void Update()
    {
        // アバターが非アクティブの時、リターン
        if (avatarParent.activeInHierarchy == false)
        {
            return;
        }

        SetAvatarRotation();
        SetAvatarPosition();
    }

    // アバターを常にダッシュ目標地点へ向け続ける
    private void SetAvatarRotation ()
    {
        avatarParent.transform.LookAt(targetAnchorTrans);
        Vector3 avatarRot = avatarParent.transform.eulerAngles;
        avatarRot.x = 0f;
        avatarRot.z = 0f;
        avatarParent.transform.eulerAngles = avatarRot;
    }

    // Vector3.Lerpで、アバターを出発地点から目標地点へ動かす
    // moveTime時間で、出発地点から目標地点へ移動しきります
    private void SetAvatarPosition()
    {
        // 移動開始からの時間
        nowMoveTime += Time.deltaTime;
        // 移動開始からの時間 / 移動所要時間が、出発点と目標地点の割合です
        Vector3 targetAvatarPosition = Vector3.Lerp(startAnchorTrans.position, targetAnchorTrans.position, nowMoveTime / moveTime);
        avatarParent.transform.position = targetAvatarPosition;
    }

    public void SetAvatarAnchor()
    {
        avatarParent.SetActive(true);
        var pose = EarthManager.CameraGeospatialPose;
        double altitude = pose.Altitude;
        altitude -= 1.2f;

        // ダッシュ目標地点のアンカー
        var targetPosAnchor = AnchorManager.AddAnchor(avatarWorldPositionMoveEnd.x, avatarWorldPositionMoveEnd.y, altitude, Quaternion.identity);
        if (targetPosAnchor != null)
        {
            targetAnchorTrans = targetPosAnchor.transform;
        }

        // ダッシュ開始地点のアンカー
        var anchor = AnchorManager.AddAnchor(avatarWorldPositionMoveStart.x, avatarWorldPositionMoveStart.y , altitude, Quaternion.identity);
        if (anchor != null)
        {
            startAnchorTrans = anchor.transform;
        }
    }
}

先ほどと同じく、既存のデバッグUIにボタンをポン付けし、そのボタンを押すことでSetAvatarAnchor()メソッドが呼ばれ、アバターの親オブジェクトがアクティブ化します。
またそれと同時に、ダッシュ開始地点とダッシュ終了地点がアンカーされます。

アクティブ化したアバターは、上のアンカーされた開始地点と終了地点の間を、指定時間で走り切るように、Vector3.Lerpの単純な線形補完で移動する感じです。

走るモーションのアニメーションコントローラーは、事前にインスペクタから指定してあります。
また、走るモーションはCC0のダッシュアニメーションを利用させていただきました。
【Unity】Running Motion~色んな走りモーション~【CC0】 - 梅干大好きっ子クラブ - BOOTH

さて、それでは早速動かしてみましょう!

動かした動画はこちら!

www.youtube.com

最初は豆粒みたいですが、0:10~ ぐらいから段々見えてきます。

いい感じですね!
道の向こう側の車線を、安定した走り方をしてくれています!
ブレて車線変更しまくりになるかと思いましたが、ちゃんと狙った場所を狙ったルートで走ってもらえました!

アバターの後ろを追いかけてみる!


アバターに指定した2点間を移動してもらうことができるようになった段階で、
今度は自分(スマホ側)が動いた場合、現実の座標をどの程度自動補正して追従してくれるのかが気になりました。
手ブレや、スマホの移動による座標のズレはどの程度自動補正してくれるのでしょう?

というわけで、先ほどのダッシュするアバターの後ろを、私自身が追いかけてみることにしました。
結果は以下の動画のようになりました。

うおおおおおおおおお!!(迫真の手ブレ) www.youtube.com


動画の途中で1回大きく横移動してしまったものの、基本的に道から大きく外れずに最後まで走り通すことができました。
早足ぐらいのスピードで移動しているせいで結構手ブレがひどいですが、これでもトラッキングには大した影響はないという印象です!すごい!

これなら、ARグラスを被って、アバターに道案内してもらえたり、ランニングのお供をしてもらえる時代もそう遠くないのかもしれませんね。

さいごに

UnityとARKitを利用してAR Core Geospital APIを動かしてみる記事を書かせていただきました。
アドベントカレンダーのような形式で記事を書くのは初めてで、文章長くなってしまいました。ごめんなさい!
そして最後まで読んでくれた方、ありがとうございます!

私自身は、PLATEAU *3の東京の3DモデルとこのAPIでの位置座標をリンクして使ったり、MediaPipe *4のハンドトラッキングと組み合わせてインタラクティブな使用をしたい等、まだまだやりたいことがたくさんあるので、また何かできたらどこかに書きたいなぁと思っています!

また、このAPIは、今のところ無料で利用することができ、またAndroid端末でもARCoreで動かすことができるので、対応スマホとMacのPCをお持ちの方は試してみると面白いです!
この記事が、何か皆様のお役に立ちましたら幸いです。

それでは、またいつかお会いしましょう。
ではっ!

*1:GPSは精度が厳しく、VPSは点群等の3Dマップが必要。ARマーカーは場所が限られる……

*2:2022年12月5日現在

*3:国土交通省が主導している、日本の都市の3Dモデル

*4:Googleの出しているMLソリューション。画像認識で手や顔、全身のトラッキング等ができる