[Unity] ARKit (ARFoundation) でゲームの世界に入る


UnityのARパッケージが便利そうなので調べていたのですが、
各種設定等、忘れそうなので備忘録としてまとめました。


# 動作確認環境
- Unity 2020.2.7f1
- AR Foundation 4.0.12
- ARKit XR Plugin 4.0.12
- macOS 11.2.2
- iPhoneXR (SystemVersion 14.4)

# 使用アセット

本検証では、下記アセットを使用しています。

- アクター素材: [Unity-Chan! Model](https://assetstore.unity.com/packages/3d/characters/unity-chan-model-18705)
- 背景素材: [Fantasy Forest Environment - Free Demo](https://assetstore.unity.com/packages/3d/environments/fantasy/fantasy-forest-environment-free-demo-35361)


# 準備

### パッケージのインストール
- AR Foundation
- ARKit XR Plugin

![スクリーンショット 2021-03-15 15.37.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29099/3d319597-cb16-1567-415d-3e63d56097ae.png)
![スクリーンショット 2021-03-15 15.39.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29099/f36106da-2d15-8e6a-0caa-6584ffd8b5e2.png)

### 設定
- [Project Settings] > [Player] > [iOS] > [Configuration] > [Camera Usage Description]
- [Project Settings] > [XR Plug-in Management] > [iOS] > [AR Kit]
- 【※URP】 [Project Settings] > [Graphics] > [Scriptable Render Pipeline Settings]
    - [General] > [Renderer List]
    - [Renderer Features] > [Add Renderer Feature] > [AR Background Renderer Feature]

![スクリーンショット 2021-03-15 16.14.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29099/a08baf06-85f7-24cf-6e63-e15e3606ce13.png) ![スクリーンショット 2021-03-15 16.01.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29099/a0460e05-2b18-9b92-7877-a3ef6181de1a.png)


※URPでは、Scriptable Render Pipeline の Renderer に、AR Background Renderer Feature が定義されていないとカメラの映像が表示されないようです
![スクリーンショット 2021-03-15 17.40.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29099/ec67103f-0bda-d1b6-65db-823f918bc61b.png)
![スクリーンショット 2021-03-15 17.40.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29099/605ad785-72f4-30aa-ff04-816dd8f1ecf9.png)
![スクリーンショット 2021-03-15 17.43.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29099/4c7d2603-53dc-4e6d-5753-42addf4864ca.png)


# ゲームシーンの準備

### AR用のGameObject

Hierarchyビューで右クリックし、
・ [XR] > [AR Session]
・ [XR] > [AR Session Origin]
の２つを配置します。

![スクリーンショット 2021-03-15 15.57.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29099/e897f5f5-62de-cc84-0ebe-587ba268a4f5.png) ![スクリーンショット 2021-03-15 18.03.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29099/8d90f432-c611-13ee-25ed-d15fac3dbf29.png)

### モデルデータ

今回は [Unity-Chan! Model](https://assetstore.unity.com/packages/3d/characters/unity-chan-model-18705) を表示してみます。

パッケージをインストールしたら、
[unity-chan!] > [Unity-chan! Model] > [Prefabs] > [unitychan]
をシーンへ配置します。

※適当な直方体とかでも良いと思います

![スクリーンショット 2021-03-15 18.01.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29099/639e977e-1b47-c8a3-aa11-ab589d6b808d.png)

# 画面上のタップされたところにモデルを再配置してみる

まずは、機能が動作しているかの確認も含めて、
画面上のタップされたところにモデルを再配置してみます。

ゲームオブジェクト [AR Session Origin] に、下記コンポーネントをアタッチします
・ [AR Raycast Manager]
・ 画面のタップされた位置からRaycastを飛ばすスクリプトを作成

![スクリーンショット 2021-03-15 18.22.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29099/3da25903-7882-097e-7601-53c0f6b02b6f.png)

スクリプトはこんな感じで作って、再配置したいTransformにゲームシーンへ配置したunityachanを当てます。

```c#
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.XR.ARFoundation;

public class ARTest : MonoBehaviour
{
    private void Awake()
    {
        TryGetComponent(out _arRaycastManager);
    }
    private void Update()
    {
        if (Input.touchCount > 0)
        {
            var touch = Input.GetTouch(0);
            if (touch.phase == TouchPhase.Ended)
            {
                // 画面のタップされた位置からRaycastを飛ばす
                if (_arRaycastManager.Raycast(touch.position, _hitResults))
                {
                    _transform.position = _hitResults[0].pose.position;
                }
            }
        }
    }

    private readonly List<ARRaycastHit> _hitResults = new List<ARRaycastHit>();
    private ARRaycastManager _arRaycastManager = null;

    // 再配置したいTransform
    [SerializeField]
    private Transform _transform = null;
}
```

これでiOSアプリをビルド＆実行して、unitychanを再配置できる状態で動かせるようになると思います。
アプリを実行して周辺の地面をざっとカメラに映してから画面をタップすると、
きっとunitychanがその位置に現れるかと思います。

![IMG_7752.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29099/58dc49de-84a6-1d93-e29a-86cfbda1314d.png) ![aaa.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29099/24404d60-b01d-42df-0812-336ed8c21c26.gif)

※モデルのスケールを0.05等小さい値にし、机上の小物等の平面に配置して、フィギュア的な見せ方にするのも面白いです。

![スクリーンショット 2021-03-16 19.30.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29099/fdb1b0b3-ced6-08a2-2260-171621106557.png)

# デバッグ用の表示

アプリを実行している時に、空間が認識されている様子がわかると、テスト中はなんだか安心感があります。

安心感を得たい場合は、下記のコンポーネントを、[AR Session Origin]あたりに追加します。
・ AR Plane Manager (検出された平面の表示)
・ AR Point Cloud Manager (検出された点集合の表示)

そして、Hierarchyビューで右クリックし、
・ [XR] > [AR Default Plane]
・ [XR] > [AR Default Point Cloud]
をそれぞれ作成し、それぞれそのままPrefabにして、
上で作成したコンポーネントのPrefabに登録します。

![スクリーンショット 2021-03-15 18.28.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29099/0f32a2f7-33e3-7146-deed-e8d993818dcf.png)
![スクリーンショット 2021-03-15 18.31.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29099/870a3452-cc3f-f8f2-5850-b1bae24e855e.png)
![スクリーンショット 2021-03-15 18.30.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29099/08413e9a-c348-6d82-5e1d-ba6dd0e31f9f.png)

これでアプリを実行し、カメラで周囲を移していると
点群や平面の表示がされるようになり、
「あっ、正常に動いてるぅ」と思えるようになると思います。

![IMG_7754.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29099/e354df8b-beb7-88be-db8c-b12160eaa25c.png)


# GameScene背景の中に入る

## 背景アセットの導入

入る舞台が無いと始まらないので、
今回は [Fantasy Forest Environment - Free Demo](https://assetstore.unity.com/packages/3d/environments/fantasy/fantasy-forest-environment-free-demo-35361) の舞台へとお邪魔したいと思います。

パッケージをインストール後、シーン[demoScene_free]を開き
[AR Session][AR Session Origin][unitychan] を配置します。

![スクリーンショット 2021-03-16 18.11.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29099/bf2eac93-1f3e-e041-a75e-f7282fd816e0.png)

## 地面とカメラデバイスの距離(Y)を適用する

GameSceneカメラは、初期化時？の位置を原点(0,0,0) として
そこからの相対的な移動回転量を AR Camera の Transform へ適用していきます。

現実のカメラデバイスは、普通に持っていたら地面からざっくり1m前後のあたりにあると思いますので、
検出される地面の位置は -1.0 前後となりますが、
ゲームを作る時は大体、地面を Y=0 として作ると思いますので、
GameSceneカメラの方に、適正なY位置へ動いてもらうことを考えてみます。

要点は下記の２つとなります。

- AR Plane Manager のありがたい通知から、地面までの距離Yを取得する
- AR Session Origin の Transform.position.y にオフセット(地面からの距離Y) を適用する

### AR Session Origin

AR Session Origin の下には、実際にCameraコンポーネントを持つ
AR Camera がいます。

この AR Camera が、現実カメラデバイスとリンクする GameScene上のオブジェクトとなりますが、
このオブジェクトのTransformは自動で更新されるので直接オフセットを当てることができません。

その為、親オブジェクトである AR Session Origin にオフセットを適用します。
(名前的に使い方を間違ってはいないと信じています)

### AR Plane Manager

[AR Plane Manager] を [AR Session Origin] へアタッチします。

AR Plane Manager は planesChanged によって
・新規平面検出時
・既存平面更新時
に該当する平面情報を添えて通知を送ってくれます。

もらった平面の transform.position.y から、
現実カメラデバイスと現実地面との距離Yが取得できるのですが、
今回は最も遠い平面を地面として認識する為、
一番低い値を適用してみます。

※精度としては、地面から 30cm - 60cm くらい浮くことが多いですが、大雑把にVR的に使う場合はなんとかそれっぽくみえる感触です。

```c#
private void Awake()
{
    TryGetComponent(out _arSessionOrigin);
}

private void OnEnable()
{
    _arPlaneManager.planesChanged += OnARPlanesChanged;
}

private void OnDisable()
{
    _arPlaneManager.planesChanged -= OnARPlanesChanged;
}

private void OnARPlanesChanged(ARPlanesChangedEventArgs changed)
{
    var minY = Mathf.Min(
        changed.added.Min(plane => plane.transform.position.y),
        changed.updated.Min(plane => plane.transform.position.y));
    if (minY < _minGroundY)
    {
        // 検出された平面の中で、最も遠く(下)にいるものを地面とみなす
        _minGroundY = minY;

        // ARSessionOriginへ適用します
        var pos = _arSessionOrigin.transform.position;
        pos.y = -minY; // 地面への距離だけ、GameSceneカメラを上にオフセットします
        _arSessionOrigin.transform.position = pos;
    }
}

private ARSessionOrigin _arSessionOrigin = null;
```
## 景観の良い位置へ、GameSceneカメラをオフセットする

今回使用する背景は、ワールド原点にいると景観が良くないので、
[AR Session Origin]のXZに値を入れて、景観の良い位置へ移動します。

今回は X:22 Z:28 あたりが良さそうです。

![スクリーンショット 2021-03-16 18.22.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29099/163f46d8-a8e6-896f-83ee-5d960dc733a8.png)
![スクリーンショット 2021-03-16 18.22.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29099/2838af26-330a-6c1f-c4f4-220c97111e4e.png)

## カメラのFarを大きくする

AR Camera の初期値だと、今回の背景は相当手前でクリップされてしまうので、
大きな値にします。

![スクリーンショット 2021-03-16 19.27.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29099/e565439c-4bfa-2d51-1a9c-ab5531b323d3.png)

## アプリをビルドして実行

起動後、付近の地面あたりを見回して平面が認識されると、
GameSceneカメラのY値が適正化され
なんとなく背景に潜り込んだ状態になると思います。

![aaa.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29099/8e3e0c62-e064-a389-2843-6b22910f42ef.gif)


# 背景の活用

背景の中にカメラをリンクしている状態になったら、
あとはもう普通のゲームなので、以下のようなことも可能になります

- 背景コリジョンとの衝突点にモデルを配置
- ウォークスルー

## 背景コリジョンとの衝突点にモデルを配置

普段どおりタップされたスクリーン位置から、Physics.Raycastを飛ばして、
当たったところにモデルを再配置します。

ARSession有効中は、Camera.main が取れないようなので、
AR Camera の Camera コンポーネントを直接使います。

```c#
private void Awake()
{
    _arCamera = GetComponentInChildren<Camera>();
}

private void Update()
{
    if (Input.touchCount > 0)
    {
        var touch = Input.GetTouch(0);
        switch (touch.phase)
        {
            case TouchPhase.Ended:
                var ray = _arCamera.ScreenPointToRay(Input.mousePosition);
                if (Physics.Raycast(ray, out var hit, 100))
                {
                    _transform.position = hit.point;
                }
                break;
        }
    }
}

private Camera _arCamera = null;
```

アプリをビルドして実行し、背景の地面をタップすると
モデルがその位置へ再配置されます。

実際に見ている背景に綺麗に乗っかるので、
現実世界とのズレ幅は大分気にならない精度になると思います。

![bbb.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29099/fff2c6c8-9c0d-3106-ade6-5ffaed9a0d5e.gif)

## ウォークスルー

[AR Session Origin]を動かして、ウォークスルーしてみます。

```c#
private void Update()
{
    if (Input.touchCount > 0)
    {
        var touch = Input.GetTouch(0);
        switch (touch.phase)
        {
            case TouchPhase.Moved:
                // カメラ正面水平方向への前進後退
                if (Mathf.Abs(touch.deltaPosition.y) > 3f)
                {
                    var cameraForwardXZ = _arCamera.transform.forward;
                    cameraForwardXZ.y = 0f;
                    _arSessionOrigin.transform.position += cameraForwardXZ * (touch.deltaPosition.y * 0.02f);
                }
                // カメラのY軸回転
                if (Mathf.Abs(touch.deltaPosition.x) > 3f)
                {
                    _arSessionOrigin.transform.rotation = Quaternion.AngleAxis(touch.deltaPosition.x * 0.1f, Vector3.up) * _arSessionOrigin.transform.rotation;
                    _transform.transform.rotation = Quaternion.AngleAxis(touch.deltaPosition.x * 0.2f, Vector3.up) * _transform.transform.rotation;
                }
        }
    }
}
```

最初、前後の移動と左右の回転を、スワイプジェスチャーで行っています。
![bbb.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29099/9abe08b3-45e6-90e5-80a2-2f73805fac6e.gif)


# テスト用スクリプトまとめ

大雑把になりますが、今回作成したスクリプトになります

```c#
using System.Text;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.XR.ARFoundation;

public class ARTest : MonoBehaviour
{
    private void Awake()
    {
        TryGetComponent(out _arPlaneManager);
        TryGetComponent(out _arSessionOrigin);
        TryGetComponent(out _arRaycastManager);
        _arCamera = GetComponentInChildren<Camera>();

        _style = new GUIStyle
        {
            fontSize = 20,
            normal = new GUIStyleState { textColor = Color.white },
        };
    }

    private void OnEnable()
    {
        _arPlaneManager.planesChanged += OnARPlanesChanged;
    }

    private void OnDisable()
    {
        _arPlaneManager.planesChanged -= OnARPlanesChanged;
    }

    private void OnARPlanesChanged(ARPlanesChangedEventArgs changed)
    {
        var minY = Mathf.Min(
            changed.added.Min(plane => plane.transform.position.y),
            changed.updated.Min(plane => plane.transform.position.y));
        if (minY < _minGroundY)
        {
            _minGroundY = minY;

            var pos = _arSessionOrigin.transform.position;
            pos.y = -minY;
            _arSessionOrigin.transform.position = pos;
        }
    }

    private void Update()
    {
        if (Input.touchCount > 0)
        {
            var touch = Input.GetTouch(0);
            switch (touch.phase)
            {
                case TouchPhase.Began:
                    _touchBeganPosition = touch.position;
                    break;
                case TouchPhase.Ended:
#if false // ARPlaneで衝突判定を取りたい時
                    if (_arRaycastManager.Raycast(touch.position, _hitResults))
                    {
                        _transform.position = _hitResults[0].pose.position;
                    }
#else // 背景モデルで衝突判定を取りたい時
                    if (Vector2.Distance(_touchBeganPosition, touch.position) < 10f)
                    {
                        var ray = _arCamera.ScreenPointToRay(Input.mousePosition);
                        if (Physics.Raycast(ray, out var hit, 100))
                        {
                            _transform.position = hit.point;
                        }
                    }
#endif
                    break;
                case TouchPhase.Moved:
#if false // モデルを回したい時
                    _transform.transform.rotation = Quaternion.AngleAxis(touch.deltaPosition.x * 0.2f, Vector3.up) * _transform.transform.rotation;
#else // カメラを操作したい時
                    // カメラ正面水平方向への前進後退
                    if (Mathf.Abs(touch.deltaPosition.y) > 3f)
                    {
                        var cameraForwardXZ = _arCamera.transform.forward;
                        cameraForwardXZ.y = 0f;
                        _arSessionOrigin.transform.position += cameraForwardXZ * (touch.deltaPosition.y * 0.02f);
                    }
                    // カメラのY軸回転
                    if (Mathf.Abs(touch.deltaPosition.x) > 3f)
                    {
                        _arSessionOrigin.transform.rotation = Quaternion.AngleAxis(touch.deltaPosition.x * 0.1f, Vector3.up) * _arSessionOrigin.transform.rotation;
                        _transform.transform.rotation = Quaternion.AngleAxis(touch.deltaPosition.x * 0.2f, Vector3.up) * _transform.transform.rotation;
                    }
#endif
                    break;
            }
        }
    }

    private void OnGUI()
    {
        var sb = new StringBuilder();
        //sb.AppendLine($"UC: {_transform.position.x},{_transform.position.y},{_transform.position.z}");
        //sb.AppendLine($"SO: {_arSessionOrigin.transform.position.x},{_arSessionOrigin.transform.position.y},{_arSessionOrigin.transform.position.z}");
        sb.AppendLine($"MY: {_minGroundY}");
        GUI.Label(new Rect(200, 64, 640, 480), sb.ToString(), _style);
    }

    private readonly List<ARRaycastHit> _hitResults = new List<ARRaycastHit>();
    private ARRaycastManager _arRaycastManager = null;

    private ARPlaneManager _arPlaneManager = null;
    private ARSessionOrigin _arSessionOrigin = null;
    private Camera _arCamera = null;
    private float _minGroundY = 0f;
    private Vector2 _touchBeganPosition;

    private GUIStyle _style;

    // 再配置したいTransform
    [SerializeField]
    private Transform _transform = null;
}

```