はじめに
こんにちは。
今回は前回に引き続き、「ロケーションベース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)の存在する方向を表示するアイコン
実装
こちらからコンパスバー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
という名前でスクリプトを新規作成します。
とりあえずコードを全部貼ります。
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変わるようにします。
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のデータをもたせることで後で計算できるようにします。
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アイコンは
- コンパスオブジェクト(後述しますが、端末の向いている方向を取得するためのGameObjectです)のz軸方面のベクトルを取得
- 端末からObjectiveに向けたベクトルを取得
- それぞれ平面に投影する
- 投影したベクトルの差を
Vector3.SignedAngle
を用いて取得する - アイコンのrectTransformを変更する
といった流れで処理します。
Vector3.SignedAngle
は-180~180の値を返すので、90度でUIの一番端(x方向±360)になるように式を作りました。
// 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
に追加します。
//前略
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を作成します。
Canvasのサイズの設定をします。その前にカメラのGameタブから画角を縦型16:9に変えましょう。
Canvasの子として空のGameObject
、その子としてImage
(以下、Mask)を作成し、ImageにMask
コンポーネントをアタッチしてください。このとき赤枠で囲んだチェックは外してください。またMaskの横幅は720に設定して、PosX
, PosY
は0にしてください。
Maskの子としてImage
を作成し細長くしてください。横幅は720に設定してください。
Maskの子としてRaw Image
を作成して、以下のように設定してください。
他のS
, E
, W
も同様に設定し、PosX
をそれぞれ720
, 360
, -360
としてください。
Compass
の子としてImage、その子としてTextを作成します。テキストは中央揃えにして図のように配置すると良いでしょう。
Compass_ObjIcon
のImageのPrefabを作成しておいてください。
UIから離れて、XR Origin
>Camera Offset
>Main Camera
の子として空のGameObjectを作成し、名前をCompassObj
とします。
シーンに空のGameObjectを作成し、CompassManager
として作成し、CompassManager
コンポーネントをアタッチしてください
実行結果
このここまで作ったら、ObjectSpawner
のSpawnObject
関数を叩いたときにアイコンも生成されます。
実行するとこんな感じです(若干UIが異なっています)。
おわりに
具体的に開発した内容を引っ張ってきたので汎用性に乏しい記事となってしまいました。
雑になってしまって申し訳ありませんが、ある程度の枠組みを流用して皆様のケースに合わせて使ってください。
また、この実装ならARでない普通のゲームにも導入することができると思います。参考にしてください。
では、また。
参考