UnityでARスタンプラリーアプリを作ってみた

# ARスタンプラリーアプリとは何か？
ARスタンプラリーアプリとはAR機能を使ったスタンプラリーを行うアプリです。
典型的なスタンプラリーの形式は
1. スタンプカードを持ったユーザーが特定の場所へ行く
2. その場所にあるスタンプを自身のスタンプカードに押すことでポイントを取得する
3. スタンプカードにスタンプを集めることでポイントを貯める

だと思います。それに対してARスタンプラリーアプリでは、
1. スマホを持ったユーザーが特定の画像をカメラで写す
2. カメラが画像を認識することで特定の3Dモデルが表示され、そのモデルをタッチすることでポイントを取得する
3. 取得したポイントはユーザーごとにWebサーバーで貯められ、スマホから確認することができる

という機能になっています。
両者の比較を図にするとこんな感じです。
![AR Stamp Rally Image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478883/cc9ee9ab-68d7-229b-1f46-b539dbb57246.png)

# 作ったもの
下の映像は今回私が作成したARスタンプラリーアプリのデモです。
デモでは以下の3つを実行しています。（デモが分かりにくくてすいません）
1. ユーザーを新規登録する
2. PCの画像をカメラに写すことで3Dモデルが表示される（左の桃のようなキャラクターは自分の大学のマスコットキャラクターを模して作りました）
3. 3Dモデルをタッチすることで、モデルのアニメーションと共に右上のユーザーのポイントを表す`Point`が0から1に加算される

https://twitter.com/SAshunchan/status/1597264684532789248

# この記事で知れること
この記事では以下の二つについて紹介していきます。
- Unityでスマートフォンからオブジェクトを表示するスクリプトの書き方
- UnityでAPI連携をするスクリプトの書き方

バックエンドの処理やモデルのアニメーションについてはWebサイトなどで簡単に知れることであり、この記事で紹介すると記事自体が長くなってしまうため省きます。

# 実装について
## 開発&動作環境

| ツール　| バージョン |
|:-:|:-:|
|Unity|2021.3.6f1|
|Android|10|

## 構成図
アプリの簡単な構成図は以下の通りです。
![AR production image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478883/2662f920-ed8c-b6e3-27d1-401fc9aef4ce.png)

## 前提条件
UnityでARを行うのに必要な`AR Foundation`のインストールやHierarchyに`AR Session`と`AR Session Origin`の追加の方法はネットで調べればたくさん出てきますのでこの記事では触れません。もし探すのが面倒な方は以下のサイトを参考にしてみてください。また、現在Unity Hubからプロジェクトを作る際に`AR テンプレート`を選択することですぐにARが始められるようになっていますので、今回私は使用していませんが`AR テンプレート`を使ってAR環境を整えるのもアリです。

https://nn-hokuson.hatenablog.com/entry/2020/07/07/222010

## ARでオブジェクトを表示する機能のスクリプト

### スクリプトを書く前準備
1. Asset内で`左クリック→Create→XR→Reference Image Library`を選択して、`Reference Image Library`を作成する
2. Hierarchy内の`AR Session Origin`を右クリックし、先ほど作成した`Reference Image Library`を`add component`
3. `Reference Image Library`に認識したい画像を追加（画像によっては使用できないものもあります。使用できる画像の条件については[こちらの記事](https://manual.coco-ar.com/faq/images/marker/provider.html)を参考にしてみてください）
4. ARでオブジェクトを表示する機能のスクリプトが書かれたファイルを`AR Session Origin`に`add component`
5. 追加したコンポーネントには`Placeable Prefabs`というプロパティがあるので、そこに認識したい画像と同じ名前を持つ3Dモデルを追加

```csharp
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.XR;
using UnityEngine.XR.ARFoundation;
using UnityEngine.XR.ARSubsystems;

[RequireComponent(typeof(ARTrackedImageManager))]
public class ImageTracking : MonoBehaviour // 名前は何でも良いよ
{
    [SerializeField]
    private GameObject[] placeablePrefabs;

    private Dictionary<string, GameObject> spawnedPrefabs = new Dictionary<string, GameObject>();
    private ARTrackedImageManager trackedImageManager;

    // すべての前準備
    private void Awake()
    {
        trackedImageManager = FindObjectOfType<ARTrackedImageManager>();

        foreach (GameObject prefab in placeablePrefabs)
        {
            GameObject newPrefab = Instantiate(prefab);
            spawnedPrefabs.Add(prefab.name, newPrefab);
            newPrefab.SetActive(false);
        }

        trackedImageManager.trackedImagesChanged += ImageChanged;
    }

    // 3Dモデルを表示するイベントを解除
    private void OnDisable()
    {
        trackedImageManager.trackedImagesChanged -= ImageChanged;
    }

    // 3Dモデルの表示
    private void ImageChanged(ARTrackedImagesChangedEventArgs eventArgs)
    {
        foreach (ARTrackedImage trackedImage in eventArgs.added)
        {
            UpdateImage(trackedImage);
        }

        foreach (ARTrackedImage trackedImage in eventArgs.updated)
        {
            UpdateImage(trackedImage);
        }

        foreach (ARTrackedImage trackedImage in eventArgs.removed)
        {
            spawnedPrefabs[trackedImage.name].SetActive(false);
        }
    }

    // 表示される画像によって表示するオブジェクトを変更させる
    private void UpdateImage(ARTrackedImage trackedImage)
    {
        string name = trackedImage.referenceImage.name;
        var imageMarkerTransform = trackedImage.transform;

        GameObject prefab = spawnedPrefabs[name];
        var markerRotation = imageMarkerTransform.rotation * Quaternion.Euler(0f, 0f, 180f);
        prefab.transform.SetPositionAndRotation(imageMarkerTransform.position, markerRotation);
        prefab.transform.SetParent(imageMarkerTransform);
    
        prefab.SetActive(trackedImage.trackingState == TrackingState.Tracking);
    }
}

```

スクリプトを書く上で参考にした記事

https://qiita.com/OKsaiyowa/items/29504242ec74cb5dfb04

どのような処理が行われているのかをメソッドごとに見ていきます。

### 宣言部分
`placeablePrefabs`にはスクリプトを書く前準備の5で追加した3Dモデルが格納されています。
`spawnedPrefabs`は表示する3Dモデルの名前をキー、3Dモデルのオブジェクトを値とする配列で、`Awake`メソッドで使用します。

### Awake
このメソッドでは3Dモデルを表示する準備を行っています。
行っていることは以下の２つです。
- `PlacePrefabs`で格納した3Dモデルをコピーしたオブジェクトを生成し、`spawnedPrefabs`に3Dモデルの名前をキーとして格納します。
- `trackedImageManager`にあらかじめセットされている`trackedImagesChanged`イベントに後に紹介する`ImageChanged`メソッドを登録します。`trackedImagesChanged`イベントとはスマホに画像が新たに写る時・別の画像が映る時・画像が映らなくなった時に発火し、イベントの結果を`ImageChanged`メソッドに引数として渡します。

参考

https://docs.unity3d.com/Packages/com.unity.xr.arfoundation@2.1/api/UnityEngine.XR.ARFoundation.ARTrackedImageManager.html#UnityEngine_XR_ARFoundation_ARTrackedImageManager_trackedImagesChanged

### OnDisable
`Awake`メソッドで`trackedImagesChanged`イベントに登録した`ImageChanged`メソッドを解除します。今回は一つのイベントに一つのメソッドしか登録していないので解除をしなくても動作するのですが、もし一つのイベントに複数のメソッドを登録しなくてはいけない時、解除をしないと参照されないメソッドが出てきてしまうので、本番を意識してイベントの解除処理を書いています。
ちなみに`OnDisable`メソッドはUnityのスクリプトのライフサイクルにおける最後の方に実行されるメソッドです。

参考

https://docs.unity3d.com/ja/2021.3/Manual/ExecutionOrder.html

### ImageChanged
このメソッドでは3Dモデルの表示の実行を行っています。
`trackedImagesChanged`イベントの登録時に受け取った`ARTrackedImagesChangedEventArgs`の値によって実行内容を切り替えています。`ARTrackedImagesChangedEventArgs`の値は以下の三つがあり、発火するイベント内容によってそれぞれの値は`List<ARTrackedImage>`を保持します。（`ARTrackedImage`オブジェクトは認識する画像の情報を含んでいます。）

| 1 | 2 |
|:-:|:-:|
| added  | 画面上で画像の追加があった時にその画像の情報を保持する  |
| updated  | 画面上で画像の変更があった時にその画像の情報を保持する  |
| removed  | 画面上で映らなくなった画像があった時にその画像の情報を保持する  |

`added`・`updated`の場合は`trackedImagesChanged`イベントの切り替えによる`ARTrackedImagesChangedEventArgs`の値の要素（`ARTrackedImage`オブジェクト）を`UpdateImage`メソッドに渡し、`removed`の場合は3Dモデルを見えなくします。（このアプリでは`updated`の場合の処理しか実行されなかったので、`added`・`removed`がどのときに画像の情報を保持するタイミングは公式資料などから自分なりに読み取った内容をただ載せているだけで、確証はありません。）

参考

https://docs.unity3d.com/Packages/com.unity.xr.arfoundation@2.1/api/UnityEngine.XR.ARFoundation.ARTrackedImagesChangedEventArgs.html

### UpdateImage
このメソッドでは3Dモデルの表示の実装をしています。
行っていることは以下の3つです。
- `ARTrackedImage`オブジェクトに含まれる画像の名前・位置・向きを取得します。
- 画像の情報をもとに表示する3Dモデルの表示位置を調整します。
- 画像が追跡可能な範囲まで画面にはっきりと表示されている場合、3Dモデルを表示する。

参考

https://docs.unity3d.com/Packages/com.unity.xr.arfoundation@2.1/api/UnityEngine.XR.ARFoundation.ARTrackedImage.html


## ユーザーの動作をバックエンドに伝えるスクリプト
今回紹介するのは画像を認識することによって表示された3Dモデルをユーザーがタッチした時にユーザーのポイントを追加するスクリプトです。

### スクリプトを書く前準備
- このスクリプトが書かれたファイルを表示する3Dモデル（プレハブ）に`add component`
- 追加したコンポーネントに`Target Renderer`というプロパティが表示されるので、3Dモデルのパーツの一つをそこへ追加

```csharp
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Networking;

    public class TouchTranstion : MonoBehaviour // 名前は何でも良いよ
{
    [SerializeField] Renderer targetRenderer;
    
    // オブジェクトが表示時の処理の実行
    void Update()
    {
        if (targetRenderer.isVisible && Input.touchCount > 0)
        {
           StartCoroutine(IncrementPointApi()); 
        }
    }

// ポイントの追加
    IEnumerator IncrementPointApi()
    {
        string inputText = PlayerPrefs.GetString("Name");
        WWWForm formData = new WWWForm();
        formData.AddField("name", inputText);

        // usingを使用する理由はメモリリーク対策
        using UnityWebRequest www = UnityWebRequest.Post("API（URL）", formData);
        yield return www.SendWebRequest();

        if (www.result == UnityWebRequest.Result.ConnectionError || www.result == UnityWebRequest.Result.ProtocolError)
        {
            // API通信が失敗した時の処理
        }
        else
        {
            // API通信が成功した時の処理
        }
    }
}
```

## 宣言部分
3Dモデルが表示されているかの判定用に使用する`targetRenderer`を用意しています。`targetRenderer`には前準備で追加した3Dモデルのパーツの一部（今回は胴体パーツ）が`Mesh Render`として格納されています。

### Update
このメソッドでは、`targetRenderer`で格納したパーツが表示されるかつユーザーが画面にタッチした時にユーザーのポイントを追加する`IncrementPointApi`メソッドを実行しています。`StartCoroutine`を使用して`IncrementPointApi`メソッドを実行する理由は、`StartCoroutine`がないと`UnityWebRequest`クラスを利用したAPI通信が伴うメソッドの実行ができないからです。
`targetRenderer`が表示されているかの判断は`Renderer`クラスの変数`isVisible`を使用します。

参考

https://docs.unity3d.com/ja/2021.2/ScriptReference/Renderer.html

### IncrementPointApi
このメソッドで行っていることは以下の三つです
- `WWWForm`クラスを使用してバックエンドへ送るデータの作成します。`WWWForm`の`AddField`メソッドの第一引数にフィールド名、第二引数に値を入れることでフォームデータの追加ができます。
- `UnityWebRequest`クラスを使用し、APIを通してバックエンドへデータを送信します。`UnityWebRequest`クラスの`Post`メソッドの第一引数にAPI、第二引数にフォームデータを入れることで`UnityWebRequest`オブジェクトをHTTPのPOSTが行える状態にし、`SendWebRequest`メソッドを実行することでバックエンドとの通信を行っています。`UnityWebRequest`オブジェクト生成時に`using`を書いている理由は、これがないとメモリリークでエラーが生じてしまうからです。また、`SendWebRequest`メソッドに`yield`を書く理由は、バックエンドこれがないとバックエンドの結果を取得できないからです。
- API通信が成功or失敗した際の処理の実行をします。今回の処理ではバックエンドとの通信エラーが起きるか、バックエンドから4xxor5xxエラーが返されたら失敗の処理が行われます。

参考

https://docs.unity3d.com/ja/2018.4/ScriptReference/WWWForm.html

# 終わりに
UnityとC#について筆者が初心者であることから、処理の説明がところどころいい加減になってしまいました。
今後UnityとC#の学習を深めることで、両者の理解を深めるとともにARスタンプラリーアプリの完成度を高めていきたいです！