【Unity(C#)】VRとARでスケールの異なる空間同士を共有する方法

## はじめに
以下のようなコンテンツをwithARハッカソンにて作りました。

<iframe width="560" height="560" src="https://www.youtube.com/embed/vxG50ucajfc" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>

動画の冒頭に`VR空間のコンテンツ`と`平面検知で空間に設置したARコンテンツ`、それぞれスケールの異なる空間同士で位置同期を行っています。本記事はそのやり方についてメモします。

※OculusQuestとARFoundationの組み合わせとなります

## バージョン情報
VR側
|  諸々名前 | バージョン |
|:-:|:-:|
| Unity  |  2020.3.4f1 |
| PUN2 |  2.41 |
| Oculus Integration  | 35.0  |
|  XR Plugin Management | 4.0.1  |

AR側
|  諸々名前 | バージョン |
|:-:|:-:|
| Unity  |  2020.3.4f1 |
| PUN2 |  2.41 |
|  ARFoundation |  4.1.7 |
|  ARCore XR Plugin | 4.1.7  |
| ARKit XR Plugin  | 4.1.7  |
|  XR Plugin Management | 4.0.1  |


## 考え方
VRとARでスケールの異なる空間同士を共有する方法の考え方として、
2つ考慮すべきことがあります。

**原点合わせ**と、**スケールの解決**です。

---

### 原点合わせ

VR空間とAR空間の原点を合わせる必要があります。
VR空間の原点はアプリを立ち上げた際に決まります。(各デバイスの`正面をリセットする機能`を使うことで再設定も可能です)

AR側についてですが、今回は平面検知なので、以下記事のようなフローでコンテンツを配置します。

【参考リンク】：[【Unity(C#)】ARFoundationにおける平面検知シーケンスの実装](https://qiita.com/OKsaiyowa/items/00748baf198b6661b6c5)

この時、原点はどこになるかというとスマホを起動した位置となります。
つまり、単純に座標を送りあうだけでは原点から見たコンテンツの座標位置がVR/ARでそれぞれ違うため、位置同期を正しく行うことができません。

:::note warn
以下例のようにVR側とAR側で原点から見たコンテンツの位置はそれぞれ異なります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/587aa2e4-18f0-34d5-6005-8e82eb9f0767.png)

:::

そこで、今回の手法としては**VR側の原点は起動位置(あるいは正面をリセット機能を利用した場所)で固定とし、AR側では原点を変更する処理を行ったうえで位置同期を行う**ようにします。

---

#### サンプルコード

以下記事のコードを修正していきます。
【参考リンク】：[「良いコード/悪いコードで学ぶ設計入門」を読んだので自分の記事のコードをリファクタしてみた](https://qiita.com/OKsaiyowa/items/4cf8b3a25114f75042de)

以下はAR側で原点を変更する処理についてです。
配置したオブジェクトがユーザーの向きに回転したうえで、その配置場所を原点として扱うような実装となっています。

```diff_c_sharp
using UnityEngine;
using UnityEngine.XR.ARFoundation;
using UnityEngine.XR.ARSubsystems;

/// <summary>
/// 配置完了のシーケンス
/// </summary>
public class DetectedSequence : ISequence
{
    public void OnEnter(DetectReferenceProvider provider)
    {
        var arObject = provider.ArObject;
        var hit = provider.ArRayCasterToPlane.Hits[0].pose;
        var arPlaneManager = provider.ArPlaneManager;
        
        DetectObject(arObject, hit);
+       AdjustOrigin(arObject, provider.ArSessionOrigin, provider.OriginTransform);
        DisablePlaneDetection(arPlaneManager);
        provider.DetectGuide.SetActive(false);
        provider.ScanGuide.SetActive(false);

+       //通信処理を開始
+       provider.PunConnect.StartPunConnect();
    }

    /// <summary>
    /// オブジェクトの配置処理
    /// </summary>
    /// <param name="arObject">ARオブジェクト</param>
    /// <param name="hit">衝突箇所</param>
    private void DetectObject(GameObject arObject,Pose hit)
    {
        arObject.transform.position = hit.position;
        arObject.SetActive(true);
        var cameraPos = Camera.main.transform.position;
        cameraPos.y = arObject.transform.position.y;
        arObject.transform.LookAt(cameraPos);
    }

+   /// <summary>
+   /// SessionSpaceを動かして原点位置を調整する
+   /// </summary>
+   /// <param name="arObject">ARオブジェクト</param>
+   /// <param name="arSessionOrigin">ARSessionOriginのインスタンス</param>
+   /// <param name="originTransform">Unity座標系の原点に配置したゲームオブジェクトのTransform</param>
+   private void AdjustOrigin(GameObject arObject, ARSessionOrigin arSessionOrigin, Transform originTransform)
+   {
+       //SessionSpaceを動かして、現在のARオブジェクトの位置が原点にあるように見える状態にする
+       arSessionOrigin.MakeContentAppearAt
+       (
+           originTransform.transform,
+           arObject.transform.position,
+           arObject.transform.rotation
+       );
+
+       //ARオブジェクトを定義した原点の位置に戻す
+       arObject.transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity);
+   }

    /// <summary>
    /// 平面検知機能をオフにする処理
    /// </summary>
    /// <param name="arPlaneManager">ARPlaneManagerのインスタンス</param>
    private void DisablePlaneDetection(ARPlaneManager arPlaneManager)
    {
        arPlaneManager.requestedDetectionMode = PlaneDetectionMode.None;
        foreach (ARPlane plane in arPlaneManager.trackables)
        {
            plane.gameObject.SetActive(false);
        }
    }
}
```

DetectReferenceProviderに必要な参照を追加しています。

```diff_c_sharp:DetectReferenceProvider 
using Hs.Pun;
using UnityEngine;
using UnityEngine.XR.ARFoundation;

/// <summary>
/// 配置処理に必要なインスタンスについて参照をひとまとめに担うクラス
/// </summary>
public class DetectReferenceProvider : MonoBehaviour
{
    [SerializeField] private GameObject arObject;
    [SerializeField] private GameObject scanGuide;
    [SerializeField] private GameObject detectGuide;
    [SerializeField] private ARRayCasterToPlane arRayCasterToPlane;
    [SerializeField] private ARPlaneManager arPlaneManager;
+   [SerializeField] private ARSessionOrigin arSessionOrigin;
+   [SerializeField] private PunConnect punConnect;

    /// <summary>
    /// ARオブジェクトをひとまとめにしたオブジェクト
    /// </summary>
    public GameObject ArObject => arObject;
    
    /// <summary>
    /// スキャン時のガイドをひとまとめにしたオブジェクト
    /// </summary>
    public GameObject ScanGuide => scanGuide;
    
    /// <summary>
    /// 配置時のガイドをひとまとめにしたオブジェクト
    /// </summary>
    public GameObject DetectGuide => detectGuide;
    
+   /// <summary>
+   /// PUN2接続処理を行うコンポーネント
+   /// </summary>
+   public PunConnect PunConnect => punConnect;
    
    /// <summary>
    /// RayCasterのインスタンス
    /// </summary>
    public ARRayCasterToPlane ArRayCasterToPlane => arRayCasterToPlane;
    
    /// <summary>
    /// ARPlaneManagerのインスタンス
    /// </summary>
    public ARPlaneManager ArPlaneManager => arPlaneManager;
    
+   /// <summary>
+   /// ARSessionOriginのインスタンス
+   /// </summary>
+   public ARSessionOrigin ArSessionOrigin => arSessionOrigin;

+   /// <summary>
+   /// Originとして振る舞うゲームオブジェクト
+   /// </summary>
+   public Transform OriginTransform
+   {
+       get
+       {
+           if (originTransform != null)
+           {
+               return originTransform;
+           }
+           
+           originTransform = new GameObject("Origin").transform;
+           return originTransform;
+       }
+   }

+   private Transform originTransform;
}

```

通信処理についても配置と同時に開始するように追記しており、
以下が`provider.PunConnect.StartPunConnect();`で行われる処理を記述したクラスとなっています。

```CS
using Photon.Pun;
using Photon.Realtime;
using UnityEngine;

/// <summary>
/// PUN2接続処理
/// </summary>
public class PunConnect : MonoBehaviourPunCallbacks
{
    [SerializeField] private GameObject avatar;
    
    //ルームオプションのプロパティー
    private readonly RoomOptions _roomOptions = new RoomOptions()
    {
        MaxPlayers = 3, //人数制限
        IsOpen = true, //部屋に参加できるか
        IsVisible = true, //この部屋がロビーにリストされるか
    };

    /// <summary>
    /// 接続開始
    /// </summary>
    public void StartPunConnect()
    {
        //PhotonServerSettingsに設定した内容を使ってマスターサーバーへ接続する
        PhotonNetwork.ConnectUsingSettings();
    }

    //マスターサーバーへの接続が成功した時に呼ばれるコールバック
    public override void OnConnectedToMaster()
    {
        //"Test"という名前のルームに参加する（ルームが無ければ作成してから参加する）
        PhotonNetwork.JoinOrCreateRoom("Test", _roomOptions, TypedLobby.Default);
    }
    
    //部屋への接続が成功した時に呼ばれるコールバック
    public override void OnJoinedRoom()
    {
        //アバターを生成
        var avatar = PhotonNetwork.Instantiate(this.avatar.name, Vector3.zero, Quaternion.identity);
        avatar.name = this.avatar.name;
    }
}

```

---

### スケールの解決

VRとARでスケールが異なる場合、前述の原点を合わせる処理を行っていても、位置がずれてしまうという問題があります。

例として、以下画像はそれぞれスケールの異なる空間を表しています。
右の赤枠のスケールを1としたとき、左の青枠はその2倍となります。
この画像はスケールを考慮した状態となっています。
それぞれの空間のCubeの位置について、"スケールが異なるが、原点から見た相対位置が同じである"と直感的に感じることができると思います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/443cc4e2-1e20-6a6b-c6df-435517c6fc6a.png)

以下はスケールを考慮しなかった場合です。どちらの空間のCubeに対してもY座標を3としています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/cec56833-8f28-cec7-e73b-627294234ac2.png)

このように、スケールの異なる空間同士において、
単純に座標を送りあうだけでは、原点から見た相対位置を破綻なく同期することができません。

:::note warn
スケールの異なる空間同士における位置同期を理解するには、
オブジェクトの座標にスケールの倍率がかかるイメージが必要です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/8732f078-090f-cd85-85fa-437cf185eb2e.png)

:::

この問題に関しては、以下の2つのステップで解決します。

**・空間のスケールを定義する**
**・送信された/送信した座標を相手側の空間のスケールに合わせて補正する**

---

#### 空間のスケールを定義する
まず、VR空間とAR空間がどのような比率であるか定義する必要があります。
今回、例として**VR:AR=1:5**としました。

つまり、以下のような関係が成り立ちます。
・VR側のオブジェクトをAR側で表示するときは、VR空間で表示する場合よりも1/5倍の大きさで表示する必要がある
・AR側のオブジェクトをVR側で表示するときは、AR空間で表示する場合よりも5倍の大きさで表示する必要がある

:::note warn
・VR側のアバターに関して

AR側で生成されるVR側アバターについて、あらかじめアバターのRootのScaleを1/5倍にしておき、
子階層の手や頭はローカルポジションで同期すれば破綻の無いような作りとなっています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/c8680fc2-f7f8-4d31-4816-da17afa4e0b8.png)
:::

:::note warn
・AR側のアバターに関して

AR側で生成されるAR側アバターについて、あらかじめ表示するモデルのScaleを5倍にしておき、
VR側で生成された際に5倍の状態が同期されるようにしています。(PhotonTransformViewが勝手に同期してくれる)

自身のアバターの大きさについて、AR側においては整合性が取れませんが、自身のアバターは非表示となるので無視しています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/28ca4891-7a07-89fd-ca32-65e5c9902f39.png)
:::

---

#### 送信された/送信した座標を相手側の空間のスケールに合わせて補正する

スケールの異なる空間同士の座標について補正を行います。

まずはVR側です。
座標にスケールの倍率(この場合1/5)をかけることで相手側の空間のスケールに合うように補正しています。
コードは[過去記事](https://qiita.com/OKsaiyowa/items/aeedf7b0fedfe5b73a55#%E3%82%B3%E3%83%BC%E3%83%89)の流用なので、追記箇所のみ抜粋します。

```diff_c_sharp

 public class VRDataSync : MonoBehaviourPunCallbacks, IPunObservable
{
+   [SerializeField] private float sizeAdjustValue = 0.2f;

    ~略~
    
    private async UniTask ReadyVR(CancellationToken ct)
    {
        if (!photonView.IsMine)
        {
            //部屋に入るまで待つ
            await UniTask.WaitUntil(() => PhotonNetwork.InRoom, cancellationToken: ct);
            
            //正しい順序で生成したボーンのリストを作成
            ReadyHand(leftHandVisual, bonesL, out _isInitializedHandL);
            ReadyHand(rightHandVisual, bonesR, out _isInitializedHandR);

+           //空間のスケールの違いを考慮した座標に変換
+           transform.position *= sizeAdjustValue;
            return;
        }
        
        ~略~
    }
    
}

```

次にAR側です。
AR側の位置同期についてですが、カメラにアバターを追従させて、同期自体は同期させたいオブジェクトにPhotonTransformViewをアタッチすることで実現しています。

その追従させた座標にスケールの倍率(この場合5)をかけることで相手側の空間のスケールに合うように補正しています。

```CS
using UnityEngine;

/// <summary>
/// ARのプレーヤーの位置同期
/// 追従をここでさせて、同期自体はPhotonTransformViewに任せる
/// </summary>
public class FollowCamera : MonoBehaviour
{
    [SerializeField] private GameObject head;
    [SerializeField] private float sizeAdjustValue = 5f;

    private void Update()
    {
        var cam = Camera.main.transform;
        head.transform.SetPositionAndRotation(cam.position * sizeAdjustValue, cam.rotation);
    }
}
```
VRは送信された座標を補正するパターン、ARは送信する座標を補正するパターンでそれぞれ実装してみました。

## デモ
VR空間はAR空間側からはミニチュアで、AR空間はVR空間側からは巨大な状態で表示し、それぞれスケールの異なる空間同士の違和感ない位置同期が完成しました。
![ARVRSyncDemo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/c9109ccd-7c6d-e9a9-033f-4b8b0952e338.gif)

## おわりに
VRと平面検知のARと都市空間規模のARという全く異なる空間情報同士であっても工夫すれば位置同期が実現できるということを証明できてよかったです。
ただ、理屈はわかっていても、2つのUnityプロジェクトで3つのシーンに対して別々の実装をして整合性を担保するのは中々大変でした。

この辺りも次実装する機会があればもう少し工夫してやりたいです。(特に良い方法は思いついてない)

## 参考リンク
[【Unity(C#),PUN2】OculusQuestのハンドトラッキング同期実装](https://qiita.com/OKsaiyowa/items/80fb857a285ee4aed95c)
[【Unity(C#)】ARFoundationのImageTrackingを使って三人称視点の実装](https://qiita.com/OKsaiyowa/items/597c5db17be40ab3b265)
[【Unity(C#)】ARFoundationにおける平面検知シーケンスの実装](https://qiita.com/OKsaiyowa/items/00748baf198b6661b6c5)