44
33

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【Unity】GeoSpatialAPIの基礎理解~空間共有コンテンツ作成まで

Last updated at Posted at 2022-07-26

GeoSpatialAPI

Googleが発表したVPSです。
世界規模で事前スキャンを行うことなく利用可能です。

GeoSpatialAPIの導入

導入に関しては以下の記事が詳しいです。
【参考リンク】:ARCore Geospatial APIをUnityで使ってみる

GeoSpatialAPIでできること

GeoSpatialAPIによって可能になることは以下です。

  • VPS(カメラ映像およびGPS)から指定した緯度経度高度/方位の場所を推定し、アンカーとなるオブジェクトを生成する
  • VPS(カメラ映像およびGPS)から端末の緯度経度高度/方位を通常のGPSより高い精度で取得する

CloudAnchorとの違いとしては、実空間の事前スキャンが不要な点です。

バージョン情報

本記事におけるライブラリ等のバージョン情報です。

諸々名前 バージョン
Unity 2020.3.4f1
ARFoundation 4.1.7
ARCore XR Plugin 4.1.7
ARKit XR Plugin 4.1.7
XR Plugin Management 4.0.1
ARCore Extensions 1.32.0
PUN 2 2.32

アンカーとなるオブジェクトを生成する

まずはアンカーとなるオブジェクトを生成し、その座標にARオブジェクトを表示してみます。

サンプル

交差点の真ん中あたりで北を向くようにオブジェクトを配置しました。
数秒で位置合わせが完了します。
Geo4.gif

サンプルコード

using Google.XR.ARCoreExtensions;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.XR.ARFoundation;
using UnityEngine.XR.ARSubsystems;

/// <summary>
/// アンカーの位置にオブジェクトを表示する
/// </summary>
public class ObjectOnAnchor : MonoBehaviour
{
    [SerializeField] private ARAnchorManager arAnchorManager;
    [SerializeField] private AREarthManager arEarthManager;
    [SerializeField] private Transform arObject;

    [SerializeField] private double latitude;
    [SerializeField] private double longitude;
    [SerializeField] private double altitude;
    
    [SerializeField] private Text statusText;

    private const double VERTICAL_THRESHOLD = 25;
    private const double HOLIZONTAL_THRESHOLD = 25;
    
    private ARGeospatialAnchor anchor;

    private void Update()
    {
        //UnityEditorではAREarthManagerが動作しないのでスキップ
        if (Application.isEditor)
        {
            SetInfo("On Editor.");
            return;
        }
        
        //ARFoundationのトラッキング準備が完了するまで何もしない
        if (ARSession.state != ARSessionState.SessionTracking)
        {
            SetInfo("ARSession.state is not ready.");
            return;
        }
        
        if (!IsSupportedDevice())
        {
            SetInfo("This device is out of support GeoSpatial.");
            return;
        }

        if (!IsHighAccuracyDeviceEarthPosition())
        {
            SetInfo("Accuracy is low.");
            return;
        }
        else
        {
            SetInfo("Accuracy is High.");
        }
        
        if (IsExistGeoSpatialAnchor(latitude, longitude, altitude))
        {
            SetInfo("Adjust position and rotation.");
            Adjust();
        }
    }

    private void SetInfo(string info)
    {
        statusText.text = info;
    }

    /// <summary>
    /// 対応端末かチェック
    /// </summary>
    /// <returns>対応端末であればTrueを返す</returns>
    private bool IsSupportedDevice()
    {
        return arEarthManager.IsGeospatialModeSupported(GeospatialMode.Enabled) != FeatureSupported.Unsupported;
    }

    /// <summary>
    /// デバイスの位置精度をチェック
    /// </summary>
    /// <returns>閾値以上の位置精度であればTrueを返す</returns>
    private bool IsHighAccuracyDeviceEarthPosition()
    {
        //EarthTrackingStateが準備できていない場合
        if (arEarthManager.EarthTrackingState != TrackingState.Tracking) return false;

        //自身の端末の位置を取得し、精度が高い位置情報が取得できているか確認する
        var pose = arEarthManager.CameraGeospatialPose;
        var verticalAccuracy = pose.VerticalAccuracy;
        var horizontalAccuracy = pose.HorizontalAccuracy;

        //位置情報が安定していない場合
        if (verticalAccuracy > VERTICAL_THRESHOLD && horizontalAccuracy > HOLIZONTAL_THRESHOLD) return false;
        
        return true;
    }

    /// <summary>
    /// アンカーの位置にオブジェクトを出す
    /// </summary>
    private void Adjust()
    {
        arObject.SetPositionAndRotation(anchor.transform.position,anchor.transform.rotation);
    }

    /// <summary>
    /// アンカーの存在を確認
    /// なければ追加
    /// </summary>
    private bool IsExistGeoSpatialAnchor(double lat, double lng, double alt)
    {
        //EarthTrackingStateの準備ができていない場合は処理しない
        if (arEarthManager.EarthTrackingState != TrackingState.Tracking)
        {
            return false;
        }

        if (anchor == null)
        {
            //GeoSpatialアンカーを作成
            //この瞬間に反映されるわけではなくARGeospatialAnchorのUpdate関数で毎フレーム補正がかかる
            var offsetRotation = Quaternion.AngleAxis(180f, Vector3.up);
            anchor = arAnchorManager.AddAnchor(lat, lng, alt, offsetRotation);
        }

        return anchor != null;
    }
}

位置補正について

GeoSpatialAnchorの仕組みとして、生成された時点で座標が定まるわけではなく、
Updateで常に座標が更新され続けるという仕組みとなります。

具体的にはARGeospatialAnchor.csが生成されたアンカーにアタッチされ、
自身の位置をVPSによって更新し続けます。

ARGeospatialAnchor
/// <summary>
/// Unity's Update method.
/// </summary>
public void Update()
{
    if (ARCoreExtensions._instance.currentARCoreSessionHandle == IntPtr.Zero ||
        _anchorHandle == IntPtr.Zero)
    {
        return;
    }

    // Get the current Pose.
    ApiPose apiPose = AnchorApi.GetAnchorPose(
        ARCoreExtensions._instance.currentARCoreSessionHandle,
        _anchorHandle);
    _pose = apiPose.ToUnityPose();

    // Update the Geospatial Anchor transform to match.
    transform.localPosition = _pose.position;
    transform.localRotation = _pose.rotation;
}

位置合わせ成功の判定

位置補正に関連することとして、GeoSpatialAnchorは "位置合わせの完了"について定義されていません。
位置合わせが完了した、というタイミングを自身で定義する必要があります。

今回はVPSの返す値(緯度経度高度および方位)のAccuracy(精度)から、位置合わせが完了したことを推定しています。

Accuracy利用箇所
/// <summary>
/// デバイスの位置精度をチェック
/// </summary>
/// <returns>閾値以上の位置精度であればTrueを返す</returns>
private bool IsHighAccuracyDeviceEarthPosition()
{
    //EarthTrackingStateが準備できていない場合
    if (arEarthManager.EarthTrackingState != TrackingState.Tracking) return false;

    //自身の端末の位置を取得し、精度が高い位置情報が取得できているか確認する
    var pose = arEarthManager.CameraGeospatialPose;
    var verticalAccuracy = pose.VerticalAccuracy;
    var horizontalAccuracy = pose.HorizontalAccuracy;

    //位置情報が安定していない場合
    if (verticalAccuracy > VERTICAL_THRESHOLD && horizontalAccuracy > HOLIZONTAL_THRESHOLD) return false;
    
    return true;
}

Accuracyについてはドキュメントに詳細があります。

以下は方位のAccuracyが10度(左)と15度(右)の時の誤差についての図です。
image.png

【引用元】:Obtain camera Geospatial pose

Accuracyは”デバイスの緯度経度高度/方位がAccuracyの値の範囲内である可能性”を示します。
その可能性は68%とのことです。

このことから、Accuracyが任意の閾値を超えたら(あるいは指定秒数間超え続けたら)位置合わせが完了したこととみなす
という方法が考えられます。


体験範囲

GeoSpatialAPIで注意しないといけないのは体験範囲です。
例えば東京駅から新宿駅方面の緯度経度高度に出た物体を見る、ということもできてしまいます。
FarClipの設定次第では何も出現していないように見えてしまいます。

自前での実装が必要となりますが、体験範囲を定義し、適切な場所にユーザーを誘導する作りが必要となります。


高度について

アンカーとして定義したい高度について、求め方を工夫する必要があります。
標高とジオイド高を加算した、楕円体高が必要となります。
image.png

【参考リンク】:ARCore Geospatial APIハンズオン by AR Fukuoka

標高は地理院地図、ジオイド高(EGM96)についてはGeographicLibというWebサイトで取得可能です。

空間共有の方法

ここから本題の空間共有です。方法としては以下の2種類が考えられます。

  • アンカーを基準として同じコンテンツを同一タイムラインで再生する
  • アンカーの位置を原点として扱えるようにSession Space1ごと移動させる

アンカーを基準として同じコンテンツを同一タイムラインで再生する

こちらの手法については簡単です。

先ほどのアンカーとなるオブジェクトを生成し、その座標にARオブジェクトを表示したサンプル
同じタイミングで再生することができれば、同じコンテンツを複数人が同じ時系列で体験可能です。

ただし、この同一タイムラインで再生する手法の場合、
"ユーザーの位置を参照して動的に生成されたオブジェクト"を同じ空間で共有することはできません。

この理由はユーザー同士のSession Spaceの定義が異なるからです。
Session Spaceは通常、ARSessionの開始した際のデバイスの位置を原点とします。

以下の画像のような状態となります。
image.png

この辺の話は以下の記事で詳しく解説しています。
【参考リンク】:【Unity(C#)】ARFoundationのImageTrackingを使って三人称視点の実装


アンカーの位置を原点として扱えるようにSession Spaceごと移動させる

そこで解決策として、Session Spaceの原点がアンカーの位置に来るようにSession Spaceごと動かすという手法があります。

"Session Spaceごと動かす"というのがどういうことか、わかりやすいのが以下のGIFです。
MoveARSessionOrigin.gif

【引用元】:Scaling with ARFoundation

オブジェクトの座標は変わっていませんが、
空間(Session Space)ごと動かすことで相対的にオブジェクトが移動したように見えます。

この処理を実際に加えたのが下記コードです。

using System;
using System.Collections;
using Google.XR.ARCoreExtensions;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.XR.ARFoundation;
using UnityEngine.XR.ARSubsystems;

/// <summary>
/// アンカーの位置が原点となるように振る舞う
/// </summary>
public class GeoSpatialAdjustOrigin : MonoBehaviour
{
    [SerializeField] private ARAnchorManager arAnchorManager;
    [SerializeField] private AREarthManager arEarthManager;
    [SerializeField] private Transform origin;
    [SerializeField] private double latitude;
    [SerializeField] private double longitude;
    [SerializeField] private double altitude;
    [SerializeField, Range(1, 10)] private float scanTime = 3f;
    [SerializeField] private Text statusText;
    
    private const double VERTICAL_THRESHOLD = 15;
    private const double HOLIZONTAL_THRESHOLD = 15;
    
    private ARGeospatialAnchor anchor;
    private GameObject contentOffsetGameObject;
    private Coroutine runningCoroutine;

    public bool IsAdjustCompleted { get; private set; }

    /// <summary>
    /// ARSessionOriginの配下にSession Spaceの原点となるオブジェクトを生成する
    /// </summary>
    private Transform contentOffsetTransform
    {
        get
        {
            if (contentOffsetGameObject == null)
            {
                contentOffsetGameObject = new GameObject("Content Placement Offset");
                contentOffsetGameObject.transform.SetParent(transform, false);
                
                for (var i = 0; i < transform.childCount; ++i)
                {
                    var child = transform.GetChild(i);
                    if (child != contentOffsetGameObject.transform)
                    {
                        child.SetParent(contentOffsetGameObject.transform, true);
                        --i; 
                    }
                }
            }

            return contentOffsetGameObject.transform;
        }
    }

    private void Update()
    {
        //位置合わせ完了後は何もしない
        if (IsAdjustCompleted) return;
        
        //UnityEditorではAREarthManagerが動作しないのでスキップ
        if (Application.isEditor)
        {
            SetInfo("On Editor.");
            return;
        }
        
        //ARFoundationのトラッキング準備が完了するまで何もしない
        if (ARSession.state != ARSessionState.SessionTracking)
        {
            SetInfo("ARSession.state is not ready.");
            return;
        }
        
        if (!IsSupportedDevice())
        {
            SetInfo("This device is out of support GeoSpatial.");
            return;
        }

        if (!IsHighAccuracyDeviceEarthPosition())
        {
            SetInfo("Accuracy is low.");
            return;
        }
        else
        {
            SetInfo("Accuracy is High.");
        }
        
        if (IsExistGeoSpatialAnchor(latitude, longitude, altitude))
        {
            SetInfo("Adjust position and rotation.");
            runningCoroutine ??= StartCoroutine(AdjustCoroutine(AdjustComplete));
        }
    }

    /// <summary>
    /// 位置合わせ完了時に行う処理
    /// </summary>
    private void AdjustComplete()
    {
        SetInfo("Adjust Complete.");
        IsAdjustCompleted = true;
    }

    private void SetInfo(string info)
    {
        statusText.text = info;
    }

    /// <summary>
    /// 対応端末かチェック
    /// </summary>
    /// <returns>対応端末であればTrueを返す</returns>
    private bool IsSupportedDevice()
    {
        return arEarthManager.IsGeospatialModeSupported(GeospatialMode.Enabled) != FeatureSupported.Unsupported;
    }

    /// <summary>
    /// デバイスの位置精度をチェック
    /// </summary>
    /// <returns>閾値以上の位置精度であればTrueを返す</returns>
    private bool IsHighAccuracyDeviceEarthPosition()
    {
        //EarthTrackingStateが準備できていない場合
        if (arEarthManager.EarthTrackingState != TrackingState.Tracking) return false;

        //自身の端末の位置を取得し、精度が高い位置情報が取得できているか確認する
        var pose = arEarthManager.CameraGeospatialPose;
        var verticalAccuracy = pose.VerticalAccuracy;
        var horizontalAccuracy = pose.HorizontalAccuracy;

        //位置情報が安定していない場合
        if (verticalAccuracy > VERTICAL_THRESHOLD && horizontalAccuracy > HOLIZONTAL_THRESHOLD) return false;
        
        return true;
    }
    
    /// <summary>
    /// アンカーが原点の位置に来るようにSession Spaceを動かして補正する
    /// 指定秒数間位置合わせ精度が安定したら位置補正完了とみなし、補正しない
    /// </summary>
    private IEnumerator AdjustCoroutine(Action adjustCompleteAction)
    {
        var startAdjustTime = Time.time;

        //Accuracyが一定秒数間安定するまで位置補正処理を行う
        while (scanTime > Time.time - startAdjustTime)
        {
            if (IsHighAccuracyDeviceEarthPosition() && ARSession.state == ARSessionState.SessionTracking)
            {
                //回転補正
                var rot = Quaternion.Inverse(anchor.transform.rotation) * contentOffsetTransform.rotation;

                //位置補正
                var pos = contentOffsetTransform.position - anchor.transform.position;

                contentOffsetTransform.SetPositionAndRotation(pos, rot);
            }
            else
            {
                //精度が落ちたらやり直し
                startAdjustTime = Time.time;
            }

            yield return null;
        }
        
        adjustCompleteAction.Invoke();
    }

    /// <summary>
    /// アンカーの存在を確認
    /// なければ追加
    /// </summary>
    private bool IsExistGeoSpatialAnchor(double lat, double lng, double alt)
    {
        //EarthTrackingStateの準備ができていない場合は処理しない
        if (arEarthManager.EarthTrackingState != TrackingState.Tracking)
        {
            return false;
        }

        if (anchor == null)
        {
            //GeoSpatialアンカーを作成
            //この瞬間に反映されるわけではなくARGeospatialAnchorのUpdate関数で毎フレーム補正がかかる
            var offsetRotation = Quaternion.AngleAxis(180f, Vector3.up);
            anchor = arAnchorManager.AddAnchor(lat, lng, alt, offsetRotation);
        }

        return anchor != null;
    }
    
    /// <summary>
    /// ワールド座標を任意の点から見たローカル座標に変換
    /// </summary>
    /// <param name="world">ワールド座標</param>
    /// <returns></returns>
    public Vector3 WorldToOriginLocal(Vector3 world)
    {
        return origin.transform.InverseTransformDirection(world);
    }
}

空間(Session Space)ごと動かすために、ARSessionOriginを動かしています。
その際、GeoSpatialAnchorの座標がSession Spaceの原点にくるように
補正して動かしています。

ちなみに、この指定座標を原点として扱う実装はMakeContentAppearAtを参考にしています。

ただし、MakeContentAppearAtはUpdateで連続して呼び出すことを想定していないため、
GeoSpatialAnchorのように常に位置補正を行う仕組みとは相性が悪いです。

そこで、自前で補正する仕組みを実装しました。

補正処理の該当箇所
//回転補正
var rot = Quaternion.Inverse(anchor.transform.rotation) * contentOffsetTransform.rotation;

//位置補正
var pos = contentOffsetTransform.position - anchor.transform.position;

contentOffsetTransform.SetPositionAndRotation(pos, rot);

またこの補正については一定の秒数以上Accuracyが安定した場合には位置合わせが完了したとみなし、
以後の位置補正はARFoundationの自己位置推定に委ねています。

位置補正完了処理の該当箇所
/// <summary>
/// アンカーが原点の位置に来るようにSession Spaceを動かして補正する
/// 指定秒数間位置合わせ精度が安定したら位置補正完了とみなし、補正しない
/// </summary>
private IEnumerator AdjustCoroutine(Action adjustCompleteAction)
{
    var startAdjustTime = Time.time;

    //Accuracyが一定秒数間安定するまで位置補正処理を行う
    while (scanTime > Time.time - startAdjustTime)
    {
        if (IsHighAccuracyDeviceEarthPosition() && ARSession.state == ARSessionState.SessionTracking)
        {
           //位置補正処理
        }
        else
        {
            //精度が落ちたらやり直し
            startAdjustTime = Time.time;
        }

        yield return null;
    }
    
    adjustCompleteAction.Invoke();
}

場合によってはGeoSpatialAPIの位置補正によって位置補正を再度行いたい、という場面もあるので
このあたりはプロジェクトの要望によって変化すると思います。

Photonでお絵描き機能

ここからはPhotonの実装です。
下記はPhotonの簡易版ルーム入室コードです。

using Photon.Pun;
using Photon.Realtime;
using UnityEngine;

/// <summary>
/// サーバーへ接続
/// </summary>
public class ConnectPunServer : MonoBehaviourPunCallbacks
{
    [SerializeField] private GameObject playerPrefab;

    void Start()
    {
        PhotonNetwork.ConnectUsingSettings();
    }

    public override void OnConnectedToMaster()
    {
        PhotonNetwork.JoinOrCreateRoom("TestRoom", new RoomOptions(), TypedLobby.Default);
    }

    public override void OnJoinedRoom()
    {
        PhotonNetwork.Instantiate(playerPrefab.name, Vector3.zero, Quaternion.identity);
    }
}

次にお絵描き機能です。

using Photon.Pun;
using UnityEngine;

/// <summary>
/// お絵描き機能
/// </summary>
public class Paint : MonoBehaviourPun
{
    [SerializeField] private GameObject inkPrefab;
    [SerializeField] private Transform inkParent;

    /// <summary>
    /// 原点を定めるコンポーネント
    /// </summary>
    private GeoSpatialAdjustOrigin geoSpatialAdjsutOrigin;

    private void Start()
    {
        geoSpatialAdjsutOrigin = FindObjectOfType<GeoSpatialAdjustOrigin>();
    }

    private void Update()
    {
        if (!photonView.IsMine) return;
        //位置合わせ完了までお絵描き機能は利用できない
        if (!geoSpatialAdjsutOrigin.IsAdjustCompleted) return;

        if (0 < Input.touchCount)
        {
            var touch = Input.GetTouch(0);
            var inputPosition = Input.GetTouch(0).position;
            var paintPosZ = 0.3f;
            var tmpTouchPos = new Vector3(inputPosition.x, inputPosition.y, paintPosZ);
            var touchWorldPos = geoSpatialAdjsutOrigin.WorldToOriginLocal(Camera.main.ScreenToWorldPoint(tmpTouchPos));

            if (touch.phase == TouchPhase.Began)
            {
                photonView.RPC(nameof(PaintStartRPC), RpcTarget.All, touchWorldPos);
            }
            else if (touch.phase == TouchPhase.Moved || touch.phase == TouchPhase.Stationary)
            {
                photonView.RPC(nameof(PaintingRPC), RpcTarget.All, touchWorldPos);
            }
        }
    }

    /// <summary>
    /// RPCで生成
    /// </summary>
    [PunRPC]
    private void PaintStartRPC(Vector3 inkPosition)
    {
        Instantiate(inkPrefab, inkPosition, Quaternion.identity, inkParent);
    }

    /// <summary>
    /// RPCで動かす
    /// </summary>
    [PunRPC]
    private void PaintingRPC(Vector3 inkPosition)
    {
        if (inkParent.childCount > 0)
        {
            inkParent.transform.GetChild(inkParent.childCount - 1).transform.localPosition = inkPosition;
        }
    }
}

サンプル

以下のように空間を共有することに成功しました。

サンプルプロジェクト

サンプルプロジェクトをGithubで公開しました。

★★★★★★★★★★★★★★★
★★★ GeoSpatialSamples ★★★
★★★★★★★★★★★★★★★

参考リンク

ARCore Geospatial APIハンズオン by AR Fukuoka

  1. Session Spaceについて

44
33
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
44
33

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?