【Unity(C#)】ARFoundationのImageTrackingを使って三人称視点の実装

## はじめに
ARでの空間共有の手法をまとめた記事が意外となかったので書きます。

既にきれいにまとめている方がいたり、サンプルが出回っていたりしたわけじゃないので
もっといいやり方はあるかもしれません。

それはそれで各自どこかにまとめてくれると助かります。

## バージョン情報

| 諸々名前 | バージョン |
|:-:|:-:|
|  Unity | 2020.3.4f1 |
|  ARFoundation |  4.0.12 |
|  ARCore XR Plugin | 4.0.12  |
| ARKit XR Plugin  | 4.0.12  |
|  XR Plugin Management | 4.0.1  |
|  PUN 2 | 2.34.1  |

## デモ
他の人がプレイ中のARが確認可能です。

![MakerOrigin.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/b5e6e8dc-ea4e-b671-cc91-317cde56b7dd.gif)


## 位置合わせの手法

まず、ARでの位置合わせが一筋縄ではいかない理由を説明します。
2人のプレイヤーがARで空間共有を行う想定の図が下記です。

Unityの原点座標はARを起動した際に決定されます。
Aにとっての原点はBから見れば全く違う座標になってしまいます。Bからの目線も同様です。

![MarkerOriginExplanation2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/444f987e-1ef0-e568-b7b3-30ea78ebd644.png)

この状態でImageTrackingを行ったものが下記の図です。
読み取る画像マーカーの位置が現実空間上で一致していても、
お互いのUnityの原点が異なる以上はAR空間上では同じ位置にあることになりません。
![MarkerOriginExplanation3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/a316af43-2378-d3a4-0bea-c494e5aaad72.png)

このように、起動した端末の位置に応じてAR空間が展開されるため、何かしらの解決策が必要となります。

ARFoundationにはちゃんと解決策が用意されていました。
説明の前段階として、まずは下記GIFの挙動が重要となります。
![MoveARSessionOrigin.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/60e90854-98e9-f8e4-c825-903492c336e5.gif)

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

マウスで動かしているオブジェクトが動いているのではなく、
周りのPlaneやAR Camera(AR Session Origin)が動くことで相対的に動いて見える仕組みです。

この仕組みを使ってUnityのワールド空間の原点と画像マーカーをぴったり重ね合わせます。
下記のようなイメージです。赤い点がUnityのワールド空間の原点です。

![MarkerOriginExplanation.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/31bc61d0-333a-d046-1b74-f842a45df070.png)

すなわち、端末の起動位置に関係無く画像マーカーの位置を原点として扱えるようになるということです。

## コード

まず、画像マーカーから原点を定める処理を担うScriptです。

```C#:適当なオブジェクトにアタッチ
using System.Collections;
using UnityEngine;
using UnityEngine.XR.ARFoundation;

/// <summary>
/// 画像マーカーから原点を定める
/// </summary>
public class OriginDecideFromImageMaker : MonoBehaviour
{
    /// <summary>
    /// ARTrackedImageManager
    /// </summary>
    [SerializeField] private ARTrackedImageManager _imageManager;
    
    /// <summary>
    /// ARSessionOrigin
    /// </summary>
    [SerializeField] private ARSessionOrigin _sessionOrigin;

    /// <summary>
    /// ワールドの原点として振る舞うオブジェクト
    /// </summary>
    private GameObject _worldOrigin;

    /// <summary>
    /// コルーチン
    /// </summary>
    private Coroutine _coroutine;

    private void OnEnable()
    {
        _worldOrigin = new GameObject("Origin");
        _imageManager.trackedImagesChanged += OnTrackedImagesChanged;
    }

    private void OnDisable()
    {
        _imageManager.trackedImagesChanged -= OnTrackedImagesChanged;
    }

    /// <summary>
    /// 原点を定める
    /// 今回は画像マーカーの位置が原点となる
    /// </summary>
    /// <param name="trackedImage">認識した画像マーカー</param>
    /// <param name="trackInterval">認識のインターバル</param>
    /// <returns></returns>
    private IEnumerator OriginDecide(ARTrackedImage trackedImage,float trackInterval)
    {
        yield return new WaitForSeconds(trackInterval);
        var trackedImageTransform = trackedImage.transform;
        _worldOrigin.transform.SetPositionAndRotation(Vector3.zero,Quaternion.identity);
        _sessionOrigin.MakeContentAppearAt(_worldOrigin.transform, trackedImageTransform.position,trackedImageTransform.localRotation);
        _coroutine = null;
    }

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

    /// <summary>
    /// TrackedImagesChanged時の処理
    /// </summary>
    /// <param name="eventArgs">検出イベントに関する引数</param>
    private void OnTrackedImagesChanged(ARTrackedImagesChangedEventArgs eventArgs)
    {
        foreach (var trackedImage in eventArgs.added)
        {
            StartCoroutine(OriginDecide(trackedImage,0));
        }

        foreach (var trackedImage in eventArgs.updated)
        {
            if(_coroutine == null)  _coroutine = StartCoroutine(OriginDecide(trackedImage, 5));
        }
    }
}
```

### MakeContentAppearAt
`MakeContentAppearAt`が先述の"AR Session Originを動かして原点と画像マーカーの位置を合わせる処理"
を実行してくれる関数です。

第二、第三引数で指定した位置・回転を、第一引数に渡したTransformに反映します。
ただし、第一引数のTransformに直接値を反映するわけではなく、AR Session Originの位置・回転を変更することで
相対的に指定位置へ移動したように見えるだけなので要注意です。

### OnTrackedImagesChanged
過去に少しまとめてます。
【参考リンク】：[ARTrackedImageManager.trackedImagesChanged](https://qiita.com/OKsaiyowa/items/29504242ec74cb5dfb04#artrackedimagemanagertrackedimageschanged)

位置合わせを画像認識するたびに行うとカクカクするので、認識頻度にインターバルを設けています。

---


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

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

次にお絵描き機能です。

```C#
using Photon.Pun;
using UnityEngine;

public class Paint : MonoBehaviourPun
{
    [SerializeField] private GameObject _inkPrefab;
    [SerializeField] private Transform _inkParent;

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

    private void Start()
    {
        _originDecideFromImageMaker = FindObjectOfType<OriginDecideFromImageMaker>();
    }

    private void Update()
    {
        if (!photonView.IsMine) return;

        if (0 < Input.touchCount)
        {
            var touch = Input.GetTouch(0);
            var inputPosition = Input.GetTouch(0).position;
            var paintPosZ = 0.5f;
            var tmpTouchPos = new Vector3(inputPosition.x, inputPosition.y, paintPosZ);
            var touchWorldPos = _originDecideFromImageMaker.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.position = inkPosition;
        }
    }
} 
```

線の描画はTrailRendererを動かしているだけです。
【参考リンク】：[【Unity(C#)】ハンドトラッキングで簡易版VRお絵かきアプリ](https://qiita.com/OKsaiyowa/items/b5045fc240d1d9a57880)

##おわりに
自己位置推定の精度を考えると、スマホで画像マーカーだけで位置合わせを支えるのは無理があるなーというのがやってみた感想です。

##参考リンク
[ARSessionOrigin transform position and rotation to make created ARTrackedImage become Unity space zero](https://stackoverflow.com/questions/58564907/arsessionorigin-transform-position-and-rotation-to-make-created-artrackedimage-b)
[Class ARSessionOrigin](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_)
[PUN2（Photon Unity Networking 2）で始めるオンラインゲーム開発入門](https://zenn.dev/o8que/books/bdcb9af27bdd7d)