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

## GeoSpatialAPI

Googleが発表したVPSです。
世界規模で事前スキャンを行うことなく利用可能です。
<iframe width="560" height="315" src="https://www.youtube.com/embed/PM5rl4z9mto" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

## GeoSpatialAPIの導入

導入に関しては以下の記事が詳しいです。
【参考リンク】：[ARCore Geospatial APIをUnityで使ってみる](https://zenn.dev/tkada/articles/04b44474149130#unity%E3%81%AE%E6%BA%96%E5%82%99)

## 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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/45bb3e55-046f-b874-a925-8e4dc7f0f27f.gif)



### サンプルコード

```CS
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によって更新し続けます。

```CS: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(精度)から、位置合わせが完了したことを推定しています。

```CS: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/40140d0f-4d3d-53f7-5c7a-b189ccbc96ed.png)

【引用元】：[Obtain camera Geospatial pose](https://developers.google.com/ar/develop/unity-arf/geospatial/developer-guide-ios#obtain_camera_geospatial_pose)

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

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

---

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

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

---

### 高度について
アンカーとして定義したい高度について、求め方を工夫する必要があります。
標高とジオイド高を加算した、楕円体高が必要となります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/76aaafb2-5393-12cc-3784-b52aaa5f0453.png)

【参考リンク】：[ARCore Geospatial APIハンズオン by AR Fukuoka](https://www.docswell.com/s/Tks_Yoshinaga/Z86Q2K-geospatial-api#p201)

標高は[地理院地図](https://www.gsi.go.jp/tizu-kutyu.html)、ジオイド高(EGM96)については[GeographicLib](https://geographiclib.sourceforge.io/cgi-bin/GeoidEval)というWebサイトで取得可能です。



## 空間共有の方法

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

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

---

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

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

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

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

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

以下の画像のような状態となります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/658f2e44-e869-d43e-abde-2ab3826279b4.png)


この辺の話は以下の記事で詳しく解説しています。
【参考リンク】：[【Unity(C#)】ARFoundationのImageTrackingを使って三人称視点の実装](https://qiita.com/OKsaiyowa/items/597c5db17be40ab3b265#%E4%BD%8D%E7%BD%AE%E5%90%88%E3%82%8F%E3%81%9B%E3%81%AE%E6%89%8B%E6%B3%95)

---

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

"Session Spaceごと動かす"というのがどういうことか、わかりやすいのが以下のGIFです。
![MoveARSessionOrigin.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/03e94d3d-6d2f-8bf7-a4c6-476807a5ddc7.gif)

【引用元】：[Scaling with ARFoundation](https://forum.unity.com/threads/scaling-with-arfoundation.543953/)

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

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

```CS
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](https://docs.unity3d.com/Packages/com.unity.xr.arfoundation@1.0/api/UnityEngine.XR.ARFoundation.ARSessionOrigin.html?_gl=1*3fpl7l*_ga*MjEwMDIxMTUyNy4xNTc5MjUxNTgw*_ga_1S78EFL1W5*MTYyODkzMTcwOS40NC4xLjE2Mjg5MzQ3MzUuNjA.&_ga=2.222615131.151418787.1628824669-2100211527.1579251580#UnityEngine_XR_ARFoundation_ARSessionOrigin_MakeContentAppearAt_Transform_Vector3_)を参考にしています。

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

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

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

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

contentOffsetTransform.SetPositionAndRotation(pos, rot);
```

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

```CS:位置補正完了処理の該当箇所
/// <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の簡易版ルーム入室コードです。

```CS
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);
    }
}
```

次にお絵描き機能です。

```CS
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;
        }
    }
}
```

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

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">地味ですがGeoSpatialAPIとPhotonで空間共有してみました🙆‍♂️<br><br>白い物体の位置が原点となるようにカメラの親を動かして相手の位置を推定しています👍 <a href="https://t.co/1x1Zn66WcC">pic.twitter.com/1x1Zn66WcC</a></p>&mdash; KENTO⚽️XRエンジニア😎Shader100記事マラソン挑戦中74/100 (@kento_xr) <a href="https://twitter.com/kento_xr/status/1551195344909713408?ref_src=twsrc%5Etfw">July 24, 2022</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

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

★★★★★★★★★★★★★★★
★★★ [GeoSpatialSamples](https://github.com/ForJobOk/GeoSpatialSamples) ★★★
★★★★★★★★★★★★★★★

## 参考リンク
[ARCore Geospatial APIハンズオン by AR Fukuoka](https://www.docswell.com/s/Tks_Yoshinaga/Z86Q2K-geospatial-api)
[^1]: [Session Spaceについて](https://docs.unity3d.com/Packages/com.unity.xr.arfoundation@4.1/manual/index.html)