【第3回】【Unity】【初心者向け】 AR Foundationを利用してARマーカーを認識しマーカーを基準にオブジェクトを生成する

# はじめに
この度インターンシップにて、株式会社GENEROSITY様でARコンテンツの開発を行いました。そこでの実習内容を第1回から第4回の全4部に渡って記事にさせていただきます。

第3回では2週間行ったAR開発の内容のモデル生成方法についてをまとめています。ARの技術として今回は、**ARマーカー**を認識しその上にオブジェクトを出現させるという事を行いました。第2回の内容を踏まえて進んでいきますので、まだ第2回をご覧になっていない方はぜひ以下からご覧になってください。

https://qiita.com/Ryushin_Mano/items/c66ac00adaa55ceebc93




# 目次
[対象者](#対象者)
[企画概要](#企画概要)
[使用ツール](#使用ツール)
[開発環境](#開発環境)
　[OS](#os)
　[Unity](#unity)
　[テストデバイス](#テストデバイス)
[制作](#制作)
　 [モデルの生成](#モデルの生成)
 　 　[Unity Asset Store](#unity-asset-store)
　 　[ModelSpawner.cs](#modelspawnercs)
　 　[IconDisplayManager.csに追加のコード](#icondisplaymanagercsに追加のコード)
[まとめ](#まとめ)




## 対象者
**・プログラミング初心者の方**
**・UnityでのAR開発に興味がある方**





## 企画概要
企画名は**AR授業資料** 。例えば地理の学習でARを用いて仮想の地球儀を出現させ国の位置を目で見て学んだり、化学では物質の分子構造を視覚化した疑似的なモデルをARを用いて出現させることでより理解度が深まる工夫をした、頭だけでなく**視覚的情報**を与えることで子どものさらなる学力を向上を目指したコンテンツです。

今回実習では価値の検証に必要な最低限の機能を備えたプロダクト、**MVP**(Minimum Viable Product)として、**水素と酸素の混合気体の爆発実験のシミュレーション映像**を制作することにしました。

https://monstar-lab.com/dx/about/about-mvp/




## 使用ツール
**Unity**
**Visual Studio 2022**
**blender4.3**
**Adobe Illustrator 2025**
**ChatGPT**



## 開発環境
### OS
**Windows 11 Home**

### Unity
**Unity 2022.3.56f1**
開発開始時にUnity6がリリースされていましたが、Unity6でのAR開発について情報が少なかったため、過去バージョンで制作しました。

**AR Foundation ver. 5.1.5**
AR機能を提供するフレームワークです。

https://docs.unity3d.com/Packages/com.unity.xr.arfoundation@5.1/manual/index.html


### テストデバイス
**Google pixel 6 (Android 15)**
実習にてお借りした端末です。Android端末で**設定**から**開発者向けオプション**を開き、**USBデバッグをオン**にしましょう。



## 制作
第3回ではモデルの生成についてのまとめとなります。

### モデルの生成
モデルはスクリプト内でプレハブからインスタンス化させます。(出現させるという解釈でとりあえずいいと思います。)

スクリプトを作成する前に1つやらなければならないことがあります。それは最初に用意したマッチ棒について、モデルは作りましたが肝心の**火**が点いていません。



#### Unity Asset Store

今回は制作期間も限られていたため、自分で火を作るのではなく**Unity Asset Store**からフリーのアセットを使わせていただきます。

https://assetstore.unity.com/ja-JP?srsltid=AfmBOopg87No6996QIzISOVr-o7_RKLeP2eCNh9kS20edBZbiG6KGlPP

今回私がマッチ棒の火に使わせていただいたのがこちらです。

https://assetstore.unity.com/packages/vfx/particles/fire-explosions/free-fire-vfx-urp-266226

後に爆発も必要なので爆発のエフェクトも探します。今回はこちらを使わせていただきました。

https://assetstore.unity.com/packages/vfx/particles/particle-pack-127325


UnityからAsset Storeに直接移動し、インポートできるのでその方法でエフェクトを持ってきましょう。Asset Storeからのインポート手順はこちらのサイトをご覧ください。

https://www.somethingfun.co.jp/video_tips/unity-assetstore


エフェクトをインポートしたという想定で続けさせていただきます。

マッチ棒のモデルのプレハブの中に、使う火のエフェクトのプレハブを入れます。火のエフェクトの位置を合わせ、マッチ棒の角度をお好みで変えます。

![スクリーンショット 2025-02-05 122027.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4001687/d6bdbfb5-451f-4b47-b6cf-13a08b8c281c.png)


マッチ棒のモデルと火のエフェクトが一緒に入ったプレハブ`MatchStickFire`を作ります。
このプレハブを`Project`の`Prefabs`にドラッグ&ドロップをして入れてください。

![スクリーンショット 2025-02-05 122902.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4001687/24372009-f39b-ccec-2455-56eb46d5dbfa.png)

:::note
もしエフェクトがピンク色になっていた場合は以下のサイトをご覧いただくと問題を解決できます。

https://zenn.dev/uni2_stu/articles/281b75c4624368

:::




#### ModelSpawner.cs
モデルのプレハブが準備できたらモデルを生成するためのスクリプトを作成していきます。これまでと同じ手順で`C# Script`を新規で作成し、名前は`ModelSpawner`とします。

```ruby:ModelSpawner.cs

using System.Collections.Generic;
using Unity.XR.CoreUtils;
using UnityEngine;
using UnityEngine.XR.ARFoundation;
using UnityEngine.XR.ARSubsystems;

using System.Collections;

public class ModelSpawner : MonoBehaviour
{
    [SerializeField] private GameObject H2; // 水素分子の3Dモデルのプレハブ
    [SerializeField] private GameObject O2; // 酸素分子の3Dモデルのプレハブ
    [SerializeField] private GameObject MatchStickFire; // マッチ棒の3Dモデルのプレハブ
    [SerializeField] private GameObject H2O; // 水分子の3Dモデルのプレハブ
    [SerializeField] private ARTrackedImageManager trackedImageManager; // ARTrackedImageManagerを設定

    private List<GameObject> hydrogenMolecules = new List<GameObject>(); // 水素分子オブジェクトを格納するリスト
    private List<GameObject> waterMolecules = new List<GameObject>(); // 水分子オブジェクトを格納するリスト

    private ARTrackedImage trackedImage; // トラッキングされたAR画像
    private bool hasHydrogenSpawned = false; // 水素分子が生成されたかどうかのフラグ
    private bool hasOxygenSpawned = false; // 酸素分子が生成されたかどうかのフラグ
    private bool hasMatchStickFire = false; // 火のついたマッチ棒が生成されたかどうかのフラグ
    private bool hasWaterSpawned = false; // 水分子が生成されたかどうかのフラグ

    private void Awake()
    {
        // XR OriginオブジェクトからARTrackedImageManagerを取得
        var xrOrigin = FindObjectOfType<XROrigin>(); // XROriginを探す
        if (xrOrigin != null )
        {
            trackedImageManager = xrOrigin.GetComponent<ARTrackedImageManager>(); // XROrigin内にあるARTrackedImageManagerを取得
        }

        if (trackedImageManager == null)
        {
            Debug.LogError("ARTrackedImageManager not found in XR Origin!");
        }
        else
        {
            Debug.Log("ARTrackedImageManager found and assigned.");
        }
    }

    private void Start()
    {
        //必要に応じてARTrackedImageManagerを設定
        trackedImageManager.trackedImagesChanged += OnTrackedImagesChanged;
    }

    private void OnEnable()
    {
        // トラッキングされる画像が変わったときのイベントリスナーを設定
        if (trackedImageManager != null)
        {
            trackedImageManager.trackedImagesChanged += OnTrackedImagesChanged;
            Debug.Log("Event listener registered.");
        }
        else
        {
            Debug.LogWarning("ARTrackedImageManager is not assigned.");
        }
    }

    private void OnDisable()
    {
        // トラッキングされる画像が変わったときのイベントリスナーを解除
        if (trackedImageManager != null)
        {
            trackedImageManager.trackedImagesChanged -= OnTrackedImagesChanged;
        }
    }

    private void OnTrackedImagesChanged(ARTrackedImagesChangedEventArgs eventArgs)
    {
        Debug.Log("OnTrackedImagesChanged called!"); // このログが表示されるか確認

        // マーカーの検出時には何も生成しないようにする
        foreach (var trackedImage in eventArgs.updated)
        {
            Debug.Log($"Updated: {trackedImage.referenceImage.name}, Tracking state: {trackedImage.trackingState}");

            // マーカーがトラッキングされている場合
            if (trackedImage.trackingState == UnityEngine.XR.ARSubsystems.TrackingState.Tracking)
            {
                Debug.Log($"marker tracked!");
                this.trackedImage = trackedImage; // トラッキングされた画像を保存
            }
        }
    }

    // Hydrogenアイコンが押されたときに水素分子を生成する
    public void OnHydrogenIconPressed()
    {
        if (trackedImageManager != null && !hasHydrogenSpawned)
        {
            Debug.Log("Hydrogen icon pressed! Spawning Hydrogen molecules.");
            SpawnHydrogenModels(trackedImage); // 水素分子を生成
            hasHydrogenSpawned = true;
        }
    }

    // Oxygenアイコンが押されたときに酸素分子を1つ生成する
    public void OnOxygenIconPressed()
    {
        // 酸素分子がすでに出ていない場合のみ生成
        if (trackedImageManager != null && !hasOxygenSpawned)
        {
            Debug.Log("Oxygen icon pressed! Spawning Oxygen molecules.");
            // 酸素分子を生成
            SpawnOxygenModels(trackedImage); 
            hasOxygenSpawned = true; // 酸素分子が生成されたことを記録
        }
    }

    // MatchStickFireアイコンが押されたときに火のついたマッチ棒を1つ生成
    // 8秒後に水分子を生成
    public void OnMatchStickFireIconPressed()
    {
        // 火のついたマッチ棒が出ていない場合のみ生成
        if (trackedImageManager != null && !hasMatchStickFire)
        {
            Debug.Log("MatchStickFire icon pressed! Spawning MatchStickFire.");

            // 火のついたマッチ棒を生成
            SpawnMatchStickFireModels(trackedImage); 
            hasMatchStickFire = true; // 火のついたマッチ棒が生成されたことを記録

            // 8秒後に水分子を生成する処理を開始
            StartCoroutine(SpawnWaterAfterDelay(8f));
        }
    }

    // 8秒後に水分子を生成するコルーチン
    private IEnumerator SpawnWaterAfterDelay(float delay)
    {
        yield return new WaitForSeconds(delay); // 指定した秒数待機
        WaterSpawn();
    }

    // 水分子2つを生成する
    public void WaterSpawn()
    {
        // 水分子がすでに出ていない場合のみ生成
        if (trackedImageManager != null && !hasWaterSpawned)
        {
            Debug.Log("Spawning Water molecules.");

            // 水分子を生成
            SpawnWaterModels(trackedImage);
            hasWaterSpawned = true;
        }
    }

    // 水素分子2つを生成するメソッド
    public void SpawnHydrogenModels(ARTrackedImage trackedImage)
    {
        Debug.Log("Spawning 2 Hydrogen molecules...");
        // ARマーカーの位置を取得
        Vector3 markerPosition = trackedImage.transform.position; 
        Debug.Log($"Hydrogen molecule spawned at: {markerPosition}"); // 生成位置確認


        // 水素分子を2つ生成
        GameObject hydrogen1 = Instantiate(H2, markerPosition + new Vector3(0.5f, 2f, 0f), H2.transform.rotation);
        GameObject hydrogen2 = Instantiate(H2, markerPosition + new Vector3(0.5f, 1.8f, 0f), H2.transform.rotation);

        // 生成した水素分子をリストに追加
        hydrogenMolecules.Add(hydrogen1);
        hydrogenMolecules.Add(hydrogen2);
    }

    // 酸素分子1つを生成するメソッド
    public void SpawnOxygenModels(ARTrackedImage trackedImage)
    {
        Debug.Log("Spawning 1 Oxygen molecules...");
        // ARマーカーの位置を取得
        Vector3 markerPosition = trackedImage.transform.position;
        Debug.Log($"Oxygen molecule spawned at: {markerPosition}"); // 生成位置確認

        // 酸素分子を1つ生成
        GameObject oxygen = Instantiate(O2, markerPosition + new Vector3(0.5f, 1.75f, 0f), O2.transform.rotation);

        // 位置をマーカーに調整
        oxygen.transform.position = markerPosition + new Vector3(0.5f, 1.75f, 0f);
    }

    

    // 火のついたマッチを1つ生成するメソッド
    public void SpawnMatchStickFireModels(ARTrackedImage trackedImage)
    {
        Debug.Log("Spawning 1 MatchStickFire...");
        // ARマーカーの位置を取得
        Vector3 markerPosition = trackedImage.transform.position;
        Debug.Log($"MatchStickFire spawned at: {markerPosition}"); // 生成位置確認


        // 火のついたマッチ棒を1つ生成
        GameObject matchstickfire = Instantiate(MatchStickFire, markerPosition + new Vector3(0.75f, 1.75f, -0.5f), MatchStickFire.transform.rotation);

        // 位置をマーカーに調整
        matchstickfire.transform.position = markerPosition + new Vector3(0.75f, 1.75f, -0.5f);
    }

    // 水分子2つを生成するメソッド
    public void SpawnWaterModels(ARTrackedImage trackedImage)
    {
        Debug.Log("Spawning 2 Water molecules...");
        // ARマーカーの位置を取得
        Vector3 markerPosition = trackedImage.transform.position;
        Debug.Log($"Water molecule spawned at: {markerPosition}"); // 生成位置確認


        // 水分子を2つ生成
        GameObject water1 = Instantiate(H2O, markerPosition + new Vector3(0.5f, 2f, 0f), H2O.transform.rotation);
        GameObject water2 = Instantiate(H2O, markerPosition + new Vector3(0.5f, 1.8f, 0f), H2O.transform.rotation);

        // 生成した水素分子をリストに追加
        waterMolecules.Add(water1);
        waterMolecules.Add(water2);
    }
}

```
</details>


書き終えたら、空のゲームオブジェクトを作成しそれにスクリプトをアタッチしましょう。ゲームオブジェクトは`ModelSpawnerObject`と名前を付けます。

`Inspector`の中で適切なプレハブをアタッチしてください。
![スクリーンショット 2025-02-05 135725.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4001687/07c6af84-982c-b457-9473-a249f3206521.png)


ここまで出来たらもう一工夫必要です。`ModelSpawner`スクリプトを見ると、**アイコンを押したときにモデルを生成させる**ようにしています。

先ほど`IconDisplayManager`スクリプトでUIを表示させるスクリプトを書きましたが、UIにボタン機能は実装していません。`IconDisplayManager`スクリプトでアイコンにボタン機能を実装するコードを追加しましょう。



#### IconDisplayManager.csに追加のコード

ボタン機能を実装するための関数を追加したコードがこちらです。追加した部分だけだとどこに書けばいいのか分からなくなると思うので全文載せます。(私自身がそうでしたので...)

```ruby:IconDisplayManager.cs 追加

using System.Collections;
using System.Collections.Generic;
using System.Data;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.UI;
using UnityEngine.XR.ARFoundation;


public class IconDisplayManager : MonoBehaviour
{
    [SerializeField] private GameObject[] icons; // 表示するUIアイコン(複数対応)
    [SerializeField] public ModelSpawner ModelSpawner; // ModelSpawnerの参照
    private ARTrackedImageManager trackedImageManager;
    private bool[] isIconHidden; // アイコンが非表示になっているかを記録


    private void Awake()
    {
        trackedImageManager = FindObjectOfType<ARTrackedImageManager>();

        // 非表示状態の配列を初期化
        isIconHidden = new bool[icons.Length];

        // シーン開始時に全アイコンを非表示にする
        foreach (GameObject icon in icons)
        {
            if (icon != null)
            {
                icon.SetActive(false); // 非表示に設定
            }
        }
    }

    private void OnEnable()
    {
        if (trackedImageManager != null)
        {
            trackedImageManager.trackedImagesChanged += OnTrackedImagesChanged;
        }
    }

    private void OnDisable()
    {
        if (trackedImageManager != null)
        {
            trackedImageManager.trackedImagesChanged -= OnTrackedImagesChanged;
        }
    }

    private void OnTrackedImagesChanged(ARTrackedImagesChangedEventArgs eventArgs)
    {
        // ARマーカーが検出された場合
        if (eventArgs.added.Count > 0 || eventArgs.updated.Count > 0)
        {
            StartCoroutine(DisplayIconsWithDelay());
            SetupIconButtons();
        }
    }

    private IEnumerator DisplayIconsWithDelay()
    {
        yield return new WaitForSeconds(2f); // 2秒待機

        Debug.Log($"Icons array length: {icons.Length}"); // 配列の長さを表示

        // すべてのアイコンを表示 (ただし、非表示にされたものは除外)
        for (int i = 0; i < icons.Length; i++)
        {
            if (icons[i] != null && !isIconHidden[i]) // 非表示にされていないアイコンだけ表示
            {
                icons[i].SetActive(true);
                Debug.Log($"Icon {i} is now active: {icons[i].name}");
            }
            else
            {
                Debug.LogWarning($"Icon {i} is null or hidden.");
            }
        }
    }

    private void SetupIconButtons()
    {
        // 各アイコンにボタンを設定
        for (int i = 0; i < icons.Length; i++)
        {
            // インデックスが3 (4つ目)以上の場合はスキップ
            // アイコンの箱は消さない
            if (i > 2) continue;

            if (icons[i] == null) continue; // アイコンがnullならスキップ

            Button button = icons[i].GetComponent<Button>();
            if (button == null)
            {
                button = icons[i].AddComponent<Button>();
            }

            // ボタンのクリックイベントを設定
            int index = i; // ローカルコピーを使う
            button.onClick.RemoveAllListeners(); // リスナーをリセット
            button.onClick.AddListener(() =>
            {
                Debug.Log($"Button for Icon {index} clicked!"); // ログ確認
                OnIconTapped(index);
            });
        }
    }

    private void OnIconTapped(int index)
    {
        Debug.Log($"Icon {index} tapped!"); // タップされたアイコンのインデックスを表示

        // 1番目のアイコン (Hydrogen) が押された場合
        if (icons[index].name == "Hydrogen")
        {
            Debug.Log("Hydrogen icon tapped! Calling OnHydrogenIconPressed...");

            // ModelSpawnerに指示して水素分子を生成
            if (ModelSpawner != null)
            {
                ModelSpawner.OnHydrogenIconPressed(); // 水素分子を2つ生成
            }
            else
            {
                Debug.LogError("ModelSpawner is not assigned in IconDisplayManager!");
            }
        }
        else
        {
            Debug.Log($"Icon {index} is not associated with Hydrogen.");
        }

        // 2番目のアイコン (Oxygen) が押された場合
        if (icons[index].name == "Oxygen")
        {
            Debug.Log("Oxygen icon tapped! Calling OnOxygenIconPressed...");

            // ModelSpawnerに指示して酸素分子を生成
            if (ModelSpawner != null)
            {
                ModelSpawner.OnOxygenIconPressed(); // 酸素分子を1つ生成
            }
            else
            {
                Debug.LogError("ModelSpawner is not assigned in IconDisplayManager!");
            }
        }

        // 3つ目のアイコン (MatchStickFire) が押された場合
        if (icons[index].name == "MatchStickFire")
        {
            Debug.Log("MatchStickFire icon tapped! Calling OnMatchStickFireIconPressed...");

            // ModelSpawnerに指示して火のついたマッチ棒を生成
            if (ModelSpawner != null)
            {
                ModelSpawner.OnMatchStickFireIconPressed(); // 火のついたマッチ棒を1つ生成
            }
            else
            {
                Debug.LogError("ModelSpawner is not assigned in IconDisplayManager!");
            }

            // 8秒後にWaterSpawnAfterDelayを呼び出す
            StartCoroutine(WaterSpawnAfterDelay(8f));

            // 4つ目のアイコン (インデックス3) を非表示にする
            if (icons.Length > 3 && icons[3] != null) // 配列が4つ以上あり、4つ目のアイコンが存在する場合
            {
                Debug.Log($"Hiding 4th icon: {icons[3].name})");
                icons[3].SetActive(false); // 非表示にする
                isIconHidden[3] = true; // 非表示状態を保存
            }
        }

        // 4つ目のアイコン(インデックスが3) は何もしない
        if (index == 3)
        {
            Debug.Log("This icon does nothing.");
            return;
        }

        // 他のアイコンは非表示にする
        CanvasRenderer canvasrenderer = icons[index].GetComponent<CanvasRenderer>();
        if (canvasrenderer != null)
        {
            Debug.Log($"Hiding icon {index}: {icons[index].name}");
            canvasrenderer.SetAlpha(0f); // 透明にする
            icons[index].SetActive(false); // 非表示にする
            isIconHidden[index] = true; // 非表示状態を記録
        }
        else
        {
            Debug.LogWarning($"Icon {index} is null or already inactive.");
        }
    }

    
    // 8秒後にModelSpawnerのWaterSpawn()を呼び出すコルーチン
    private IEnumerator WaterSpawnAfterDelay(float delay)
    {
        yield return new WaitForSeconds(delay);

        if (ModelSpawner != null)
        {
            ModelSpawner.WaterSpawn(); // ModelSpawnerのWaterSpawn()を呼び出す
        }
        else
        {
            Debug.LogError("ModelSpaener is not assigned in IconDisplayManager!");
        }
    }
}
```
</details>

ここまで書き終えたら`Unity`で`IconDisplayManagerObject`の`Inspector`を見てください。おそらく`Model Spawner`と書いてある箇所があると思います。ここに、`ModelSpawnerObject`をドラッグ&ドロップをしてください。

![スクリーンショット 2025-02-05 141841.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4001687/b5f697d8-b2bd-aa10-897a-4cfc49521218.png)

![スクリーンショット 2025-02-05 141421.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4001687/91e44dae-ce3f-f5a0-b335-e9abbff586b7.png)

上の画像のようになっていればOKです！

ここまで出来たら実行してみてください。アイコンをタップしてモデルが生成されれば成功です！

![Screenshot_2025-02-05-14-31-26-32_60acaf36eb21ddc1bae5c6992ed50ac6.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4001687/8ba4b99e-f0c9-32d5-3e9a-c5fb2676c425.jpeg)



### まとめ
 ここまでが第3回の内容になります。自分で作ったモデルが現実に存在しているように感じられて面白さを私は感じました。
 
 次回、第4回では生成したモデルにアニメーションをつけるのと、フラスコの粉砕、爆発についてまとめます。次回が実習の制作内容についての最終回になっていますのでぜひ最後までご覧いただけると嬉しいです！



**おすすめ**

**第1回はこちら**

https://qiita.com/Ryushin_Mano/items/8e094501f409dc955898


**第2回はこちら**

https://qiita.com/Ryushin_Mano/items/c66ac00adaa55ceebc93


**第4回はこちら**

https://qiita.com/Ryushin_Mano/items/24461bde667d56061bed