【Unity + ARFoundation + Lightship】ロケーションベースARを作ろう!(コンパスバー)

# はじめに
こんにちは。
今回は前回に引き続き、「ロケーションベースARを作ろう!〜コンパスバー編〜」ということで、ARに方角とオブジェクトの方向を表示するコンパスの作り方を記事にしていこうと思います。

# 環境
- MacBook Pro(2024/macOS Sequoia 15.3)
- Unity(2022.3.53f1)
    - ARFoundation(5.1.5)
    - Niantic Lightship AR Plugin(1.4.2)
- iPhone 15(iOS 18.1.1)

# 実装する機能
- 東西南北を表示するコンパス
- 生成されたオブジェクト(以下、Objective)の存在する方向を表示するアイコン

# 実装
[こちら](https://github.com/HikariNamiki/ForQiitaRepository/tree/main/Unity-Compassbar)からコンパスバーUIの素材をダウンロードすることができます。
#### 仕様
- Canvas内にUIの形で表示
- North, South, East, Westそれぞれを別々のRaw ImageUIとする
- コンパスの長さを720unitとし、`Input.compass.trueHeading`で得られる値を計算して、UIをx軸方向に動かす
- Objectiveの方向は方位角ではなくUnity世界での`Main Camera`のz軸方向のベクトルと`Main Camera`からObjectiveへのベクトルの投影ベクトルで計算する


一部前回の記事の`ObjectSpawner.cs`に変更を加える形で紹介するところがあります。

まず、`CompassManager`という名前でスクリプトを新規作成します。
とりあえずコードを全部貼ります。
```c#: CompassManager.cs
using System;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

public class CompassManager : MonoBehaviour
{
    public GameObject compass;

    // Compass UI
    [SerializeField]
    private GameObject CompassUI;
    [SerializeField]
    private RawImage NorthIcon;
    [SerializeField]
    private RawImage SouthIcon;
    [SerializeField]
    private RawImage EastIcon;
    [SerializeField]
    private RawImage WestIcon;
    
    [SerializeField]
    private GameObject ObjectIcon;

    [SerializeField]
    private TextMeshProUGUI angleText;

    public struct AddedIcon
    {
        public GameObject icon;
        public GameObject spawnedObject;
        public Coordinates coordinates;
        public RectTransform rectTransform;

        public AddedIcon(GameObject icon, GameObject spawnedObject, Coordinates coordinates){
            this.icon = icon;
            this.spawnedObject = spawnedObject;
            this.coordinates = coordinates;
            this.rectTransform = icon.GetComponent<RectTransform>();
        }
    }

    private List<AddedIcon> addedIcons = new List<AddedIcon>();

    Coordinates current_coordinates;

    double lastCompassUpdateTime = 0;
    Quaternion correction = Quaternion.identity;
    Quaternion targetCorrection = Quaternion.identity;

    LocationInfo lastData;
    double lastLocationUpdateTime = 0;



    void Start()
    {
        Input.gyro.enabled = true;
        Input.compass.enabled = true;
        Input.location.Start();
        
        // icon Initiation
        // 180-degree turn -> x-delta 720

        // x=0が中心(初期はN)
        NorthIcon.rectTransform.localPosition = new Vector3(0, 0, 0);
        // x=720は一番外(初期はS)
        SouthIcon.rectTransform.localPosition = new Vector3(720, 0, 0);
        // x=360は右端(初期はE)
        EastIcon.rectTransform.localPosition = new Vector3(360, 0, 0);
        // x=-360は左端(初期はW)
        WestIcon.rectTransform.localPosition = new Vector3(-360, 0, 0);


    }

    // Update is called once per frame
    void Update()
    {

        float phone_deg = Input.compass.trueHeading;
        float phone_degForUI = phone_deg;

        lastData = Input.location.lastData;

        if(lastData.timestamp > lastLocationUpdateTime){
            lastLocationUpdateTime = lastData.timestamp;

            current_coordinates = new Coordinates(lastData.latitude, lastData.longitude, lastData.altitude);

        }

        

        Debug.Log("|Unity|Front: " + phone_deg);

        if(phone_deg < 180){

            // x軸の値をy, phone_degをxとして
            // [Nアイコン]x = 0 のとき y = 0, x = 180 のとき y = 720となってほしい 
            NorthIcon.rectTransform.localPosition = new Vector3(-720 * (phone_deg / 180), 0, 0);
            // [Sアイコン]x = 0 のとき y = 720, x = 180 のとき y = 0となってほしい 
            SouthIcon.rectTransform.localPosition = new Vector3(720 * (1 - phone_deg / 180), 0, 0);
            // [Eアイコン]Nアイコン + 360
            EastIcon.rectTransform.localPosition = new Vector3(-720 * (phone_deg / 180) + 360, 0, 0);
            // [Wアイコン]Nアイコン - 360
            WestIcon.rectTransform.localPosition = new Vector3(-720 * (phone_deg / 180) - 360, 0, 0);


            foreach(AddedIcon i in addedIcons){
                var compassForward = compass.transform.TransformDirection(Vector3.forward);
                var targetDirection = i.spawnedObject.transform.position - compass.transform.position;

                var pCompassForward = Vector3.ProjectOnPlane(compassForward, Vector3.up);
                var pTargetDirection = Vector3.ProjectOnPlane(targetDirection, Vector3.up);

                float angle = Vector3.SignedAngle(pCompassForward, pTargetDirection, Vector3.up);


                i.rectTransform.localPosition = new Vector3(360 * (angle / 90), 0, 0);

                Debug.Log("|Unity|Icon direction1: " + angle);

            }

        }else if(phone_deg > 180){

            //180<phone_deg<360のときは動作の仕方が異なる(アイコンの移動方向が違う)

            phone_deg -= 180;
            
            NorthIcon.rectTransform.localPosition = new Vector3(720 * (1 - phone_deg / 180), 0, 0);
            SouthIcon.rectTransform.localPosition = new Vector3(-720 * (phone_deg / 180), 0, 0);
            EastIcon.rectTransform.localPosition = new Vector3(-720 * (phone_deg / 180) - 360, 0, 0);
            WestIcon.rectTransform.localPosition = new Vector3(-720 * (phone_deg / 180) + 360, 0, 0);

            foreach(AddedIcon i in addedIcons){
                
                var compassForward = compass.transform.TransformDirection(Vector3.forward);
                var targetDirection = i.spawnedObject.transform.position - compass.transform.position;

                var pCompassForward = Vector3.ProjectOnPlane(compassForward, Vector3.up);
                var pTargetDirection = Vector3.ProjectOnPlane(targetDirection, Vector3.up);

                float angle = Vector3.SignedAngle(pCompassForward, pTargetDirection, Vector3.up);



                Debug.Log("|Unity|Icon direction2: " + angle);

                i.rectTransform.localPosition = new Vector3(360 * (angle / 90), 0, 0);


            }


        }else if(phone_deg == 180){

            NorthIcon.rectTransform.localPosition = new Vector3(720, 0, 0);
            SouthIcon.rectTransform.localPosition = new Vector3(0, 0, 0);
            EastIcon.rectTransform.localPosition = new Vector3(-360, 0, 0);
            WestIcon.rectTransform.localPosition = new Vector3(+360, 0, 0);

            foreach(AddedIcon i in addedIcons){
                var compassForward = compass.transform.TransformDirection(Vector3.forward);
                var targetDirection = i.spawnedObject.transform.position - compass.transform.position;

                var pCompassForward = Vector3.ProjectOnPlane(compassForward, Vector3.up);
                var pTargetDirection = Vector3.ProjectOnPlane(targetDirection, Vector3.up);

                float angle = Vector3.SignedAngle(pCompassForward, pTargetDirection, Vector3.up);

                i.rectTransform.localPosition = new Vector3(360 * (angle / 90), 0, 0);

                Debug.Log("|Unity|Icon direction3: " + angle);

            }


        }
        
        angleText.text = ((int)phone_degForUI).ToString();
    }


    public void AddObjectIcon(GameObject spawnedObject, Coordinates coordinates)
    {
        GameObject iconObj = Instantiate(ObjectIcon, compass.transform);
        iconObj.transform.SetParent(CompassUI.transform, false);
        addedIcons.Add(new AddedIcon(iconObj, spawnedObject, coordinates));

    }

    public void ResetIcons()
    {
        if(addedIcons.Count != 0){
            foreach(AddedIcon i in addedIcons){
                Destroy(i.icon);
            }
        }
        
        addedIcons.Clear();
    }

    private double ToRadian(double degree)
    {
        return degree * Math.PI / 180;
    }



}
```

### 説明
Start関数内でジャイロの有効化、コンパスの有効化、ロケーションサービズの有効化を行います。
また[仕様](#仕様)からそれぞれの方位の初期位置を決定します。180度回転で`rectTransform.localPosition`の値が720変わるようにします。

```c#
    void Start()
    {
        Input.gyro.enabled = true;
        Input.compass.enabled = true;
        Input.location.Start();
        
        // icon Initiation
        // 180-degree turn -> x-delta 720

        // x=0が中心(初期はN)
        NorthIcon.rectTransform.localPosition = new Vector3(0, 0, 0);
        // x=720は一番外(初期はS)
        SouthIcon.rectTransform.localPosition = new Vector3(720, 0, 0);
        // x=360は右端(初期はE)
        EastIcon.rectTransform.localPosition = new Vector3(360, 0, 0);
        // x=-360は左端(初期はW)
        WestIcon.rectTransform.localPosition = new Vector3(-360, 0, 0);

    }
```

後述しますが、AddObjectIcon関数を叩くことでObjectiveのアイコンを生成することにしています。また、その際に生成したアイコンの情報を`AddedIcon`構造体でListに保存します。アイコンに紐づくObjectiveのGameObjectのデータをもたせることで後で計算できるようにします。
```c#
    public void AddObjectIcon(GameObject spawnedObject, Coordinates coordinates)
    {
        GameObject iconObj = Instantiate(ObjectIcon, compass.transform);
        iconObj.transform.SetParent(CompassUI.transform, false);
        addedIcons.Add(new AddedIcon(iconObj, spawnedObject, coordinates));

    }
```

Update関数内でコンパスのアイコンの位置を随時更新します。
方角アイコンは一定の規則に従って更新します。
Objectiveアイコンは
1. コンパスオブジェクト(後述しますが、端末の向いている方向を取得するためのGameObjectです)のz軸方面のベクトルを取得
1. 端末からObjectiveに向けたベクトルを取得
1. それぞれ平面に投影する
1. 投影したベクトルの差を`Vector3.SignedAngle`を用いて取得する
1. アイコンのrectTransformを変更する

といった流れで処理します。
`Vector3.SignedAngle`は-180~180の値を返すので、90度でUIの一番端(x方向±360)になるように式を作りました。

```c#
// Update is called once per frame
    void Update()
    {

        // 〜中略~

        if(phone_deg < 180){

            // x軸の値をy, phone_degをxとして
            // [Nアイコン]x = 0 のとき y = 0, x = 180 のとき y = 720となってほしい 
            NorthIcon.rectTransform.localPosition = new Vector3(-720 * (phone_deg / 180), 0, 0);
            // [Sアイコン]x = 0 のとき y = 720, x = 180 のとき y = 0となってほしい 
            SouthIcon.rectTransform.localPosition = new Vector3(720 * (1 - phone_deg / 180), 0, 0);
            // [Eアイコン]Nアイコン + 360
            EastIcon.rectTransform.localPosition = new Vector3(-720 * (phone_deg / 180) + 360, 0, 0);
            // [Wアイコン]Nアイコン - 360
            WestIcon.rectTransform.localPosition = new Vector3(-720 * (phone_deg / 180) - 360, 0, 0);


            foreach(AddedIcon i in addedIcons){
                var compassForward = compass.transform.TransformDirection(Vector3.forward);
                var targetDirection = i.spawnedObject.transform.position - compass.transform.position;

                var pCompassForward = Vector3.ProjectOnPlane(compassForward, Vector3.up);
                var pTargetDirection = Vector3.ProjectOnPlane(targetDirection, Vector3.up);

                float angle = Vector3.SignedAngle(pCompassForward, pTargetDirection, Vector3.up);


                i.rectTransform.localPosition = new Vector3(360 * (angle / 90), 0, 0);

                Debug.Log("|Unity|Icon direction1: " + angle);

            }

            // 以下省略

```

コンパスにアイコンを追加する処理を`ObjectSpawner`に追加します。
```diff_c_sharp: ObjectSpawner.cs

    //前略
public class ObjectSpawner : MonoBehaviour
{
    // For coordinates calculate constants
    const double EPSILON = 1e-8;
    const double SEMEMAJOR_AXIS_GRS80 = 6378137;
    const double FLATTENING_GRS80 = 298.257222101;

    // 生成するオブジェクト
    [SerializeField]
    private GameObject spawnObject;

    // 生成したオブジェクトを保持するリスト
    private List<GameObject> spawnedObjects = new List<GameObject>();
    private double target_latitude;
    private double target_longitude;
    private double target_altitude;
    private double current_altitude;


+   [SerializeField]
+   private CompassManager compassManager;

    
    // Start is called before the first frame update
    void Start()
    {
        // compass&Locationシステムの有効化
        Input.compass.enabled = true;
        Input.location.Start();

    }

    public void StartAR(){

        if (Input.location.isEnabledByUser){
            SpawnObjectData[] data = getSpawnDataCSV();
            foreach(SpawnObjectData i in data){
                target_latitude = i.latitude;
                target_longitude = i.longitude;
                target_altitude = i.altitude;

                LocationInfo lastData = Input.location.lastData;
                current_altitude = Input.location.lastData.altitude;

                Coordinates current_coordinates = new Coordinates(lastData.latitude, lastData.longitude, lastData.altitude);
                Coordinates target_coordinates = new Coordinates(target_latitude, target_longitude, target_altitude);


                Vector3 spawnPoint = CalculateSpawnPoint(current_coordinates, target_coordinates);
                GameObject spawnedObject = Instantiate(spawnObject, spawnPoint, Quaternion.identity);
                spawnedObjects.Add(spawnedObject);

+               compassManager.addWindmillIcon(spawnedObject, target_coordinates);
            }

+           // コンパスに追加
+           compassManager.objects = spawnedObjects;
            

        }

    }
    //以下省略
```

### UnityEditorでのUI作成
ヒエラルキーで`UI`>`Canvas`をクリックし、Canvasを作成します。

![CleanShot 2025-02-18 at 20.34.28@2x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3949821/b4806f0a-c0a9-407b-8d91-5662a2084bd9.png)

Canvasのサイズの設定をします。その前にカメラのGameタブから画角を縦型16:9に変えましょう。

![CleanShot 2025-02-18 at 21.01.24@2x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3949821/a6aafc7a-abc6-4e60-b317-d0c80945b114.png)

以下のように設定してください。
![CleanShot 2025-02-18 at 20.35.52@2x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3949821/2ba0e0ea-5f0a-40f8-a9cc-40f4abb5f4f1.png)

Canvasの子として空の`GameObject`、その子として`Image`(以下、Mask)を作成し、Imageに`Mask`コンポーネントをアタッチしてください。このとき赤枠で囲んだチェックは外してください。またMaskの横幅は720に設定して、`PosX`, `PosY`は0にしてください。

![CleanShot 2025-02-18 at 20.39.20@2x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3949821/cbf56fbb-d174-4d00-a0a3-5750acd1fcd6.png)

Maskの子として`Image`を作成し細長くしてください。横幅は720に設定してください。

![CleanShot 2025-02-18 at 20.42.02@2x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3949821/41ecb0f9-68da-4489-87ea-41ac431ec11b.png)

Maskの子として`Raw Image`を作成して、以下のように設定してください。

![CleanShot 2025-02-18 at 20.44.39@2x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3949821/8c5b5e39-f271-482c-85a7-a9714d6ae96f.png)

他の`S`, `E`, `W`も同様に設定し、`PosX`をそれぞれ`720`, `360`, `-360`としてください。

![CleanShot 2025-02-18 at 20.50.23@2x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3949821/27827359-ed6e-43b6-a7cd-e0d23b2a5c74.png)

`Compass`の子としてImage、その子としてTextを作成します。テキストは中央揃えにして図のように配置すると良いでしょう。
![CleanShot 2025-02-18 at 21.35.36@2x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3949821/958191cc-efdf-4175-b4b1-e0229d720c2d.png)

`Compass_ObjIcon`のImageのPrefabを作成しておいてください。
![CleanShot 2025-02-18 at 21.40.33@2x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3949821/f49f9b19-3db2-4c93-a855-03a8a5a86b62.png)


UIから離れて、`XR Origin`>`Camera Offset`>`Main Camera`の子として空のGameObjectを作成し、名前を`CompassObj`とします。
![CleanShot 2025-02-18 at 21.31.49@2x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3949821/b56cfbc0-f2a9-4998-8c06-463fb4f27bee.png)

シーンに空のGameObjectを作成し、`CompassManager`として作成し、`CompassManager`コンポーネントをアタッチしてください
![CleanShot 2025-02-18 at 21.31.02@2x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3949821/2de0baf9-4e98-4392-a9f9-0835cc8b77aa.png)

以下のように設定してください。
![CleanShot 2025-02-19 at 19.12.08@2x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3949821/017c1f8f-5e3e-428b-bed2-4ae48553f6a0.png)


## 実行結果
このここまで作ったら、`ObjectSpawner`の`SpawnObject`関数を叩いたときにアイコンも生成されます。
実行するとこんな感じです(若干UIが異なっています)。

![Result.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3949821/1f5e8536-eb40-4fd4-9cb9-b950a3c46281.gif)

# おわりに
具体的に開発した内容を引っ張ってきたので汎用性に乏しい記事となってしまいました。
雑になってしまって申し訳ありませんが、ある程度の枠組みを流用して皆様のケースに合わせて使ってください。
また、この実装ならARでない普通のゲームにも導入することができると思います。参考にしてください。
では、また。

# 参考

https://www.youtube.com/watch?v=RXOjm_DkahA