はじめに
普段は企画職を中心に学んでいる私が、株式会社GENEROSITY様での臨地実務実習で行わせていただいた、XR技術を使ったサービスの企画発案から価値検証モデル(Minimum Viable Product, 通称:MVP)の開発内容を全3回に分けて記事にさせていただきます。
第2回では、サービスの主要素でもあったARシーンの開発についてUnity初心者でも分かるように細かく説明していきます。
目次
概要
今回のゴール
環境
実装
準備
スクリプトの作成
実行
まとめ
概要
私は「他人に言いにくい本音を抱えているが、誰にも見られたくない」といった課題を解決するために、本音を一人で解消するためのスマホ向けアプリ「本音バルーン」を作成いたしました。
視覚的なカタルシス(爽快感やストレスの解消)を体験した人がより感じるようにするため、ARを活用したゲーミフィケーション要素を企画に取り入れました。
私が開発したMVPの全体の流れを動画にしたので、ぜひご覧ください!(0.75倍速推奨)
リリースまでは至っておらず、あくまでもMVPであることをご了承ください。
今回のゴール
配置されているボタンUIを除いた、風船オブジェクトの表示から画面タップでのレイキャストを利用して削除したときにエフェクトを発生させます。
環境
OS
Windows11
Unityバージョン
Unity - 2022.3.49fi
テストデバイス
Google Pixel 6(Android 12)
Unity上のSimulatorは実装されていないのでGoogle Pixel 5で行っています
実装
準備
プロジェクトの設定
All templates から AR Mobile
を選び、プロジェクトを作成しましょう。
Unityには便利なテンプレートが多くあるので、作りたい物に合わせてうまく活用しましょう。
写真ではProject name
がデフォルトの状態になっていますが、今回はsample
と名付けました。
テンプレートから開くと画面が下図のようになっていると思います。特徴としては、AR Foundation
を使ったAR開発に必要なAR Session
、Event System
、XR Origin
オブジェクトがシーンに配置されていることです。また、メインカメラがHierarchy
のXR Origin
オブジェクトの子オブジェクトに置かれています。本来ならば、このオブジェクトを呼ぶためのインポートを行わなければならないのですが、予めテンプレートが全て行ってくれています。
今回はビルドをAndroidで行うので、Unity内でビルドのための初期設定をする必要があります。
初期設定に関しては以下のブログの2-1の部分をご覧ください。
今回もプロジェクト内で日本語を使用しているので、必要に応じて以下のブログの準備の部分を参考にしてください。
https://qiita.com/Hikari_Fukazawa/private/9d4606b3115bbde712dc#%E6%BA%96%E5%82%99 //後で変更
外部モデル
表示させたいオブジェクトを事前に用意しておきます。今回はAR空間に3Dオブジェクトを生成します。私は概要の内容に合わせた風船のオブジェクトにしています。下のサイトの無料モデルを使わせていただきました。
注意
モデルのダウンロードにはアカウント登録が必要になります!
削除されたときに発生するエフェクトも用意しておきます。Unity公式のAsset Storeにて、とても素晴らしいフリーアセットがあるので活用します。
スクリプトの作成
今回のゴールは大きく分けて3つのスクリプトで制御されています。どのような役割を持っているのか含めて一つずつ説明していきます。
3つ合わせるとエラーの原因究明が難しくなるので、
こまめなデバッグをおすすめします。
RandomObjectPlacementスクリプト
AR空間にオブジェクトをランダムな配置で生成してくれるスクリプトです。カメラからの距離を測って離れた位置に生成することが可能です。以下の数値はInspector
上で自由に調整することが可能なので、仕様に合わせて変更してください。
using System.Collections.Generic;
using TMPro;
using UnityEngine;
public class RandomObjectPlacement : MonoBehaviour
{
public List<GameObject> objectPrefabs; // 配置するオブジェクトのプレハブリスト
public float rangeX = 0.5f; // X軸方向の配置範囲
public float rangeZ = 0.5f; // Z軸方向の配置範囲
public float distanceFromCamera = 10.0f; // カメラからオブジェクトを配置する距離
public float minYPosition = 3.0f; // オブジェクトが配置される最低のY軸の高さ
public float minScale = 0.1f; // オブジェクトの最小スケール
public float maxScale = 0.3f; // オブジェクトの最大スケール
public float minDistanceBetweenObjects = 0.5f; // オブジェクト同士の最小間隔
public int defaultObjectCount = 10; // 生成されるデフォルトのオブジェクト数
private List<Vector3> placedObjectPositions = new List<Vector3>(); // 配置済みのオブジェクトの位置を格納するリスト
}
以下のスクリプトが一連のオブジェクトの生成メソッドになります。説明すると、シーン開始時にオブジェクトを生成するのですが、その際にオブジェクト同士が衝突していないかの確認の試行を指定回数内で行ってから配置を決定しています。また、オブジェクトがカメラを向けた正面から左右合わせて180度内に限定で生成されるように調整してあります。調整次第でさらに範囲を絞ることもカメラの周囲全体にすることも可能です。
private void Start()
{
// ゲーム開始時にオブジェクト配置を行う
PlaceObjects();
}
public void PlaceObjects()
{
// デフォルトのオブジェクト数を生成
int additionalObjects = defaultObjectCount - placedObjectPositions.Count;
if (additionalObjects > 0)
{
PlaceObjectsRandomly(additionalObjects);
}
}
// 指定数のオブジェクトをランダムに配置するメソッド
void PlaceObjectsRandomly(int count)
{
for (int i = 0; i < count; i++)
{
Vector3 objectPosition; // オブジェクトの配置位置
int maxAttempts = 200; // 配置位置を探す最大試行回数
int attempts = 0; // 現在の試行回数
bool positionFound = false; // 有効な配置位置が見つかったかのフラグ
do
{
// カメラの正面方向を基準にランダムな角度を生成
Vector3 forward = Camera.main.transform.forward;
float randomAngle = Random.Range(-90f, 90f); // -90度から90度の範囲で角度を生成
Quaternion rotation = Quaternion.Euler(0, randomAngle, 0); // 回転を計算
Vector3 randomDirection = rotation * forward; // 回転を適用した方向を取得
// カメラ位置から指定の距離分だけ正面方向に離れた位置を計算
objectPosition = Camera.main.transform.position + randomDirection.normalized * (distanceFromCamera + 2);
// オブジェクトの高さが最低Y位置より低い場合は最低値に調整
if (objectPosition.y < minYPosition)
{
objectPosition.y = minYPosition;
}
positionFound = true; // 一旦、配置位置が有効と仮定
// 既存のオブジェクトとの間隔が狭すぎないかチェック
foreach (Vector3 placedPosition in placedObjectPositions)
{
// 既存の位置と距離が近い場合は無効な位置と判定
if (Vector3.Distance(objectPosition, placedPosition) < minDistanceBetweenObjects)
{
positionFound = false;
break;
}
}
attempts++; // 試行回数をインクリメント
} while (!positionFound && attempts < maxAttempts); // 有効な位置が見つかるまでまたは最大試行回数まで繰り返す
// 配置可能な位置が見つかった場合のみオブジェクトを生成
if (positionFound)
{
int randomIndex = Random.Range(0, objectPrefabs.Count); // プレハブリストからランダムに選択
GameObject newObject = Instantiate(objectPrefabs[randomIndex], objectPosition, Quaternion.identity); // オブジェクトを生成
// オブジェクトのスケールをランダムに設定
float randomScale = Random.Range(minScale, maxScale);
newObject.transform.localScale = new Vector3(randomScale, randomScale, randomScale);
// 配置したオブジェクトの位置をリストに追加
placedObjectPositions.Add(objectPosition);
}
else
{
// 最大試行回数まで位置が見つからなかった場合の警告ログ
Debug.LogWarning($"Failed to place object after {maxAttempts} attempts.");
}
}
}
全体図
using System.Collections.Generic;
using TMPro;
using UnityEngine;
public class RandomObjectPlacement : MonoBehaviour
{
public List<GameObject> objectPrefabs; // 配置するオブジェクトのプレハブリスト
public float rangeX = 0.5f; // X軸方向の配置範囲
public float rangeZ = 0.5f; // Z軸方向の配置範囲
public float distanceFromCamera = 10.0f; // カメラからオブジェクトを配置する距離
public float minYPosition = 3.0f; // オブジェクトが配置される最低のY軸の高さ
public float minScale = 0.1f; // オブジェクトの最小スケール
public float maxScale = 0.3f; // オブジェクトの最大スケール
public float minDistanceBetweenObjects = 0.5f; // オブジェクト同士の最小間隔
public int defaultObjectCount = 10; // スコアがない場合に生成されるデフォルトのオブジェクト数
private List<Vector3> placedObjectPositions = new List<Vector3>(); // 配置済みのオブジェクトの位置を格納するリスト
private void Start()
{
// ゲーム開始時にオブジェクト配置を行う
PlaceObjects();
}
// オブジェクトを配置するメソッド
public void PlaceObjects()
{
// ScoreManagerからスコアを取得し、オブジェクト数をスコアに応じて決定
ScoreManager scoreManager = ScoreManager.instance;
// ScoreManagerが存在し、スコアが0より大きい場合
if (scoreManager != null && scoreManager.GetScore() > 0)
{
// スコアまたはmaxObjectsの少ない方を配置数とする
int targetObjectCount = Mathf.Min(scoreManager.GetScore(), maxObjects);
// 現在配置済みのオブジェクト数との差を追加するオブジェクト数とする
int additionalObjects = targetObjectCount - placedObjectPositions.Count;
// 追加するオブジェクトがある場合
if (additionalObjects > 0)
{
PlaceObjectsRandomly(additionalObjects); // 指定数のオブジェクトをランダムに配置
}
}
else
{
// スコアが0またはScoreManagerが存在しない場合、デフォルト数のオブジェクトを生成
PlaceDefaultObjects();
}
}
// デフォルトのオブジェクト数を生成するメソッド
public void PlaceDefaultObjects()
{
// 生成されるべきオブジェクト数を計算
int additionalObjects = defaultObjectCount - placedObjectPositions.Count;
// 必要数のオブジェクトを配置
if (additionalObjects > 0)
{
PlaceObjectsRandomly(additionalObjects);
}
}
// 指定数のオブジェクトをランダムに配置するメソッド
void PlaceObjectsRandomly(int count)
{
for (int i = 0; i < count; i++)
{
Vector3 objectPosition; // オブジェクトの配置位置
int maxAttempts = 200; // 配置位置を探す最大試行回数
int attempts = 0; // 現在の試行回数
bool positionFound = false; // 有効な配置位置が見つかったかのフラグ
do
{
// カメラの正面方向を基準にランダムな角度を生成
Vector3 forward = Camera.main.transform.forward;
float randomAngle = Random.Range(-90f, 90f); // -90度から90度の範囲で角度を生成
Quaternion rotation = Quaternion.Euler(0, randomAngle, 0); // 回転を計算
Vector3 randomDirection = rotation * forward; // 回転を適用した方向を取得
// カメラ位置から指定の距離分だけ正面方向に離れた位置を計算
objectPosition = Camera.main.transform.position + randomDirection.normalized * (distanceFromCamera + 2);
// オブジェクトの高さが最低Y位置より低い場合は最低値に調整
if (objectPosition.y < minYPosition)
{
objectPosition.y = minYPosition;
}
positionFound = true; // 一旦、配置位置が有効と仮定
// 既存のオブジェクトとの間隔が狭すぎないかチェック
foreach (Vector3 placedPosition in placedObjectPositions)
{
// 既存の位置と距離が近い場合は無効な位置と判定
if (Vector3.Distance(objectPosition, placedPosition) < minDistanceBetweenObjects)
{
positionFound = false;
break;
}
}
attempts++; // 試行回数をインクリメント
} while (!positionFound && attempts < maxAttempts); // 有効な位置が見つかるまでまたは最大試行回数まで繰り返す
// 配置可能な位置が見つかった場合のみオブジェクトを生成
if (positionFound)
{
int randomIndex = Random.Range(0, objectPrefabs.Count); // プレハブリストからランダムに選択
GameObject newObject = Instantiate(objectPrefabs[randomIndex], objectPosition, Quaternion.identity); // オブジェクトを生成
// オブジェクトのスケールをランダムに設定
float randomScale = Random.Range(minScale, maxScale);
newObject.transform.localScale = new Vector3(randomScale, randomScale, randomScale);
// 配置したオブジェクトの位置をリストに追加
placedObjectPositions.Add(objectPosition);
}
else
{
// 最大試行回数まで位置が見つからなかった場合の警告ログ
Debug.LogWarning($"Failed to place object after {maxAttempts} attempts.");
}
}
}
}
ObjectManagerスクリプト
生成されるオブジェクトの状態を管理します。今回のゴールのための要素に絞って説明します。
UX向上のため、シーン内に生成されたオブジェクトが合計でいくつあるのかを示すテキストを参照しています。(ここで日本語を使用しています)
spawnedObjectsリストは生成されたオブジェクトをタグの付与をしてから格納しています。これにより、手打ちでの抜けミスを防ぎます。
using System.Collections.Generic;
using UnityEngine.EventSystems;
using UnityEngine;
using UnityEngine.UI; // UIコンポーネントの使用に必要
using TMPro;
public List<GameObject> spawnedObjects = new List<GameObject>();
public class ObjectManager : MonoBehaviour
{
[SerializeField] private TextMeshProUGUI remainingBalloonsText; // 残り風船数の表示用テキスト
public List<GameObject> spawnedObjects = new List<GameObject>();
}
シーン開始時、ランダム生成されたオブジェクトに特定のタグを付与しています。そしてタグのついたオブジェクトをリストに格納しています。このリストに入ったオブジェクトがテキストによってカウントされます。
private void Start()
{
// ObjectInteractionなどから生成されたオブジェクトを取得するため、リストを初期化
UpdateSpawnedObjectsList();
// 残り風船数を表示
UpdateRemainingBalloonsDisplay();
}
// タグが "Interactable" のオブジェクトをすべて取得してリストに格納
public void UpdateSpawnedObjectsList()
{
spawnedObjects = new List<GameObject>(GameObject.FindGameObjectsWithTag("Interactable"));
UpdateRemainingBalloonsDisplay(); // 残り風船数を更新
}
ここでオブジェクトの状態(表示されているか削除されたか)を確認しています。
// オブジェクト削除時にリストから削除し、状態を更新
public void RemoveObjectFromList(GameObject obj)
{
if (spawnedObjects.Contains(obj))
{
spawnedObjects.Remove(obj);
Debug.Log("オブジェクトを削除しました: " + obj.name); // デバッグログを追加
// 残り風船数を更新
UpdateRemainingBalloonsDisplay();
}
}
画面に残っているオブジェクトの数を表示します。数値は他のメソッドによって常に更新されます。
// 残り風船数を表示するメソッド
public void UpdateRemainingBalloonsDisplay()
{
// 現在残っている風船の数を表示
int remainingBalloons = spawnedObjects.Count;
remainingBalloonsText.text = "残りの風船の数: " + remainingBalloons;
}
全体図
using System.Collections.Generic;
using UnityEngine.EventSystems;
using UnityEngine;
using UnityEngine.UI; // UIコンポーネントの使用に必要
using TMPro;
public class ObjectManager : MonoBehaviour
{
[SerializeField] private TextMeshProUGUI remainingBalloonsText; // 残り風船数の表示用テキスト
public List<GameObject> spawnedObjects = new List<GameObject>();
private void Start()
{
// ObjectInteractionなどから生成されたオブジェクトを取得するため、リストを初期化
UpdateSpawnedObjectsList();
// StopBarの表示をScoreManagerのスコアに基づいて更新
// UpdateStopBarVisibility();
// 残り風船数を表示
UpdateRemainingBalloonsDisplay();
}
// タグが "Interactable" のオブジェクトをすべて取得してリストに格納
public void UpdateSpawnedObjectsList()
{
spawnedObjects = new List<GameObject>(GameObject.FindGameObjectsWithTag("Interactable"));
UpdateRemainingBalloonsDisplay(); // 残り風船数を更新
}
// オブジェクト削除時にリストから削除し、状態を更新
public void RemoveObjectFromList(GameObject obj)
{
if (spawnedObjects.Contains(obj))
{
spawnedObjects.Remove(obj);
Debug.Log("オブジェクトを削除しました: " + obj.name); // デバッグログを追加
// 残り風船数を更新
UpdateRemainingBalloonsDisplay();
}
}
}
ObjectInteractionスクリプト
ランダムに生成されたオブジェクトをレイキャストヒットによって削除、同時にエフェクトを発生させます。
レイキャストヒットを行うには、シーン内のARRaycastManagerがアタッチされたXR Origin
を参照しなくてはいけません。必ず参照しましょう。
他には、表示したいエフェクトを格納するリストがあります。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using UnityEngine.XR.ARFoundation; //Must
using UnityEngine.XR.ARSubsystems; //Must
public class ObjectInteraction : MonoBehaviour
{
[SerializeField] private Camera arCamera;
[SerializeField] private ARRaycastManager raycastManager;
public ObjectManager objectManager;
public int HitCount; // タッチしたオブジェクトの数
[SerializeField] private List<GameObject> effectPrefabs;
[SerializeField] private List<GameObject> effectPrefabs1;
}
Updateメソッドでレイキャストヒットによるオブジェクトへの反応をしています。
レイキャストヒットで感知したオブジェクトに特定のタグが付いていた際に削除するメソッドを送っています。
void Update()
{
if (Input.touchCount > 0 || Input.GetMouseButtonDown(0))
{
Vector2 touchPosition = Input.touchCount > 0 ? Input.GetTouch(0).position : (Vector2)Input.mousePosition;
List<ARRaycastHit> hits = new List<ARRaycastHit>();
// ARRaycastManagerを使ってタッチ位置からレイキャストを行う
if (raycastManager.Raycast(touchPosition, hits, TrackableType.PlaneWithinPolygon))
{
Pose hitPose = hits[0].pose;
// ヒットした位置にオブジェクトがあるかPhysics.Raycastで確認
Ray ray = new Ray(arCamera.transform.position, hitPose.position - arCamera.transform.position);
if (Physics.Raycast(ray, out RaycastHit hit))
{
var target = hit.collider.gameObject;
if (target.CompareTag("Interactable"))
{
// オブジェクトのインタラクションを処理
HandleObjectInteraction(target);
}
}
}
}
}
オブジェクトを削除し、エフェクトを発生させるメソッドです。エフェクトの発生は1つでもいいし、無くてもいい物なのですが、オブジェクトが削除されたと分かりやすくなる視認性と今回のサービスに欠かせなかった爽快感というUXの向上のために必須だと思い、取り入れることにしました。
別々のリストに格納されているエフェクトを同時に呼び出して発生させています。別のリストを使って管理している理由として、爆発のエフェクトと文字が飛び出すエフェクトのどちらも必ず表示されるようにしたかったからです。1つのリストに格納して2つのエフェクトをランダムに抽選して発生させるでも出来ると思うのですが、技術より先に数で押しました。
ObjectManagerのリストにオブジェクトが削除されたことを伝え、カウントを更新させます。
private void HandleObjectInteraction(GameObject target)
{
// effectPrefabsとeffectPrefabs1のそれぞれからランダムにエフェクトを選択し、同時に生成する
int randomIndex = Random.Range(0, effectPrefabs.Count);
int randomIndex1 = Random.Range(0, effectPrefabs1.Count);
GameObject effectPrefab = effectPrefabs[randomIndex];
GameObject effectPrefab1 = effectPrefabs1[randomIndex1];
// エフェクトを生成し、一定時間後に削除
GameObject effectInstance = Instantiate(effectPrefab, target.transform.position, Quaternion.identity);
GameObject effectInstance1 = Instantiate(effectPrefab1, target.transform.position, Quaternion.identity);
Destroy(effectInstance, 2f);
Destroy(effectInstance1, 2f);
//オブジェクトを削除
DestroyObject(target);
// ObjectManagerのリストからオブジェクトを削除
objectManager?.RemoveObjectFromList(target);
}
// オブジェクトの位置を管理リストから削除し、オブジェクトを破棄するメソッド
public void DestroyObject(GameObject obj)
{
placedObjectPositions.Remove(obj.transform.position); // 配置リストから削除
Destroy(obj); // オブジェクトの削除
}
全体図
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using UnityEngine.XR.ARFoundation; //Must
using UnityEngine.XR.ARSubsystems; //Must
public class ObjectInteraction : MonoBehaviour
{
[SerializeField] private Camera arCamera;
[SerializeField] private ARRaycastManager raycastManager;
public ObjectManager objectManager;
public int HitCount; // タッチしたオブジェクトの数
[SerializeField] private List<GameObject> effectPrefabs;
[SerializeField] private List<GameObject> effectPrefabs1;
void Update()
{
if (Input.touchCount > 0 || Input.GetMouseButtonDown(0))
{
Vector2 touchPosition = Input.touchCount > 0 ? Input.GetTouch(0).position : (Vector2)Input.mousePosition;
List<ARRaycastHit> hits = new List<ARRaycastHit>();
// ARRaycastManagerを使ってタッチ位置からレイキャストを行う
if (raycastManager.Raycast(touchPosition, hits, TrackableType.PlaneWithinPolygon))
{
Pose hitPose = hits[0].pose;
// ヒットした位置にオブジェクトがあるかPhysics.Raycastで確認
Ray ray = new Ray(arCamera.transform.position, hitPose.position - arCamera.transform.position);
if (Physics.Raycast(ray, out RaycastHit hit))
{
var target = hit.collider.gameObject;
if (target.CompareTag("Interactable"))
{
// オブジェクトのインタラクションを処理
HandleObjectInteraction(target);
}
}
}
}
}
private void HandleObjectInteraction(GameObject target)
{
// effectPrefabsとeffectPrefabs1のそれぞれからランダムにエフェクトを選択し、同時に生成する
int randomIndex = Random.Range(0, effectPrefabs.Count);
int randomIndex1 = Random.Range(0, effectPrefabs1.Count);
GameObject effectPrefab = effectPrefabs[randomIndex];
GameObject effectPrefab1 = effectPrefabs1[randomIndex1];
// エフェクトを生成し、一定時間後に削除
GameObject effectInstance = Instantiate(effectPrefab, target.transform.position, Quaternion.identity);
GameObject effectInstance1 = Instantiate(effectPrefab1, target.transform.position, Quaternion.identity);
Destroy(effectInstance, 2f);
Destroy(effectInstance1, 2f);
//オブジェクトを削除
DestroyObject(target);
// ObjectManagerのリストからオブジェクトを削除
objectManager?.RemoveObjectFromList(target);
}
// オブジェクトの位置を管理リストから削除し、オブジェクトを破棄するメソッド
public void DestroyObject(GameObject obj)
{
placedObjectPositions.Remove(obj.transform.position); // 配置リストから削除
Destroy(obj); // オブジェクトの削除
}
}
実行
作成したスクリプトは全てXR Origin
にドラッグ&ドロップでアタッチすることが可能です。
また、Hierarchy
上に作成した空のオブジェクト内に全てアタッチでも可能になっています。
視認性が上がることで管理しやすくなると思います。
正しく実行されていれば動画のようになるはずです。
まとめ
今回はオブジェクトの生成から削除とエフェクトの生成を記しました。実際の制作物では、ObejectManagerがより多くの配置されているオブジェクトの表示と非表示の状態管理を行っていたり、シーンの開始時とオブジェクトが全て削除された際などのメソッドが組まれています。
長かったと思いますが、お読みいただきありがとうございました。
第3回の記事も合わせてご覧ください