【Unity, ARFoundation】ARKitの空間情報記録機能「ARWorldMap」を使ってみた
ARKitには ARWorldMap
という、取得した空間の特徴点情報を一つにまとめる機能があります。
ARWorldMapを外部データとして保存 / 読み込みすることで、以下のようなことが実現できるでしょう。
* 空間に対するユーザーの位置を算出 * コンテンツの基準点を常に一定の位置に設定(ARAnchorとの併用 * 保存した空間の形状を点群で表示し、俯瞰的に観測 ...etc
今回はUnityおよび ARFoundation
を通じて、この機能に関する開発を行ってみました。
最終的には ARAnchor
と組み合わせることで、配置した3DオブジェクトをARWorldMap読み込み時に復元するアプリケーションを作成してみます。
目次
リポジトリ
公式サンプルをForkし、以下フォルダに2つデモを追加しました。
/Assets/_ExtraDemo
環境
- 開発PC : Windows10 Pro
- Unity : 2019.4.8
- ビルドPC ; MacMini(2020) Catalina 10.15.6
Unity Package
- ARFoundation : 3.1.5
- ARKitPlugin : 3.1.7
空間の特徴点情報 保存/読込
ARFoundationの公式サンプルに ARWorldMap
に関するデモシーンがあります。
こちらが最もシンプルに、空間情報を保存 / 読込できるもののようです。先ずはこちらをビルドしてみました。
Sceneのパス : /Assets/Scenes/ARWorldMap.unity
実装
以下の1スクリプトで実装されています。
Assets/Scripts/ARWorldMapController.cs
上記スクリプトの中からARWorldMap保存/読み込みに関するメソッドを抜き出してみました。
ARKit独自機能なため、プラットフォームの #define ディレクティブ #if UNITY_IOS
で囲ってあげないとAndroid時にエラーが出るので要注意です。
#if UNITY_IOS IEnumerator Save() { // ARKitSessionSubsystemをARSessionから取得 var sessionSubsystem = (ARKitSessionSubsystem)m_ARSession.subsystem; if (sessionSubsystem == null) { Log("No session subsystem available. Could not save."); yield break; } // ARWorldMapRequestをARKitSessionSubsystemから取得 var request = sessionSubsystem.GetARWorldMapAsync(); // ARWorldMapRequestが終わるまでループ while (!request.status.IsDone()) yield return null; // ARWorldMapRequestがエラー時 if (request.status.IsError()) { Log(string.Format("Session serialization failed with status {0}", request.status)); yield break; } // ARWorldMapを取得しRequestを破棄 var worldMap = request.GetWorldMap(); request.Dispose(); SaveAndDisposeWorldMap(worldMap); } void SaveAndDisposeWorldMap(ARWorldMap worldMap) { // ARWorldMapをNativeArray<Vector3>にシリアライズ Log("Serializing ARWorldMap to byte array..."); var data = worldMap.Serialize(Allocator.Temp); Log(string.Format("ARWorldMap has {0} bytes.", data.Length)); // ローカルに保存 var file = File.Open(path, FileMode.Create); var writer = new BinaryWriter(file); writer.Write(data.ToArray()); writer.Close(); // 用済みのデータやARWroldMap破棄 data.Dispose(); worldMap.Dispose(); Log(string.Format("ARWorldMap written to {0}", path)); } IEnumerator Load() { var sessionSubsystem = (ARKitSessionSubsystem)m_ARSession.subsystem; if (sessionSubsystem == null) { Log("No session subsystem available. Could not load."); yield break; } // ファイルを読み込み、NativeArray<byte>生成 var file = File.Open(path, FileMode.Open); if (file == null) { Log(string.Format("File {0} does not exist.", path)); yield break; } Log(string.Format("Reading {0}...", path)); int bytesPerFrame = 1024 * 10; var bytesRemaining = file.Length; var binaryReader = new BinaryReader(file); var allBytes = new List<byte>(); while (bytesRemaining > 0) { var bytes = binaryReader.ReadBytes(bytesPerFrame); allBytes.AddRange(bytes); bytesRemaining -= bytesPerFrame; yield return null; } var data = new NativeArray<byte>(allBytes.Count, Allocator.Temp); data.CopyFrom(allBytes.ToArray()); // NativeArray<byte>をARWorldMapにデシリアライズ Log(string.Format("Deserializing to ARWorldMap...", path)); ARWorldMap worldMap; if (ARWorldMap.TryDeserialize(data, out worldMap)) data.Dispose(); if (worldMap.valid) { Log("Deserialized successfully."); } else { Debug.LogError("Data is not a valid ARWorldMap."); yield break; } // ARWorldMapをARKitSubsystemに適用 Log("Apply ARWorldMap to current session."); sessionSubsystem.ApplyWorldMap(worldMap); } #endif
結果
このようにスキャンし、セーブボタンを押すとARWorldMapが保存されます。
アプリを再起動しロードボタンを押してみると、20秒ほどで前回保存したARWorldMapが読み込まれます。
使用上のポイント
このデモSceneをビルドする上で注意点があったのでまとめます。
1. 対応バージョン要確認
使用したいUnityバージョンによって、サンプルのバージョンが変わります。
今回は記事投稿時点で安定してそうな 3.1
を選択しました。
latest-version以外を使用する場合は、gitブランチを切り替えましょう。
2. Packageに関するエラー
使用するUnityバージョンによって、以下のようなエラーが出ます。
エラーに該当するPackageのバージョンが Verify
になっているか、PackageManagerで確認しましょう。
3. MappingStatusTextが表示されない
Textの参照がなぜか初期状態ではずれていました。 実機でログを確認したい方は、UITextにあてはめましょう。
4. 読込待機時間
保存は一瞬で終わるのに対して、読み込みは結構かかります。 だいたいですが、20畳くらいの部屋の空間マップデータが20~30秒かかりました。
コンテンツに盛り込む場合は、ローディングをつける等の工夫が必要そうですね。
ARAnchorとの併用
ドキュメントをみると、ARWorldMap
は空間の特徴点情報と一緒に、 ARAnchor
の情報も含んでくれるようです。
タップ位置にアンカーを作成する実装を加えて、検証してみました。
Sceneのパス : Assets/_ExtraDemo/Scenes/ARWorldMapWithAnchors.unity
実装
アンカーに関する実装は、以下のサンプルSceneがあります。
Assets/Scenes/AnchorPoints.unity
要するに、以下3つのコンポーネントを ARSessionOrigin
に追加するとよさそうです。
Assets/Scripts/AnchorCreator.cs
はタップ位置にARAnchorを設置するスクリプトです。
今回詳しい内容は割愛しますが、このままだとUIをすり抜けてAnchorが生成されてしまうため、Update内に以下スクリプトを追加してあげるとより良い感じです。
// uGUI操作時は無視 if (EventSystem.current.IsPointerOverGameObject(Input.GetTouch(0).fingerId)) { return; }
結果
Anchorを乱れうちしてARWorldMapを保存。
読み込んだところ復元しました。
3Dモデルの配置復元
無事にARAnchorもARWorldMapに含まれて保存できていることを確認しました。 つまり、ある3DモデルとARAnchorとの位置関係を別途保存しておけば、そのモデルも同位置に復元することができそうです。
Sceneのパス : Assets/_ExtraDemo/Scenes/ARWorldMapWithAnchorAndOrnament.unity
実装
単体のARAnchor作成機能
まず、 ARAnchor
は1つで十分なので、1つだけ作成するスクリプトを作ります。
これは AR Session Origin
にアタッチして使用します。
using System.Collections.Generic; using System.Linq; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.XR.ARFoundation; using UnityEngine.XR.ARSubsystems; [RequireComponent(typeof(ARAnchorManager))] [RequireComponent(typeof(ARRaycastManager))] public class SingleAnchorCreator : MonoBehaviour { [SerializeField] private Transform _anchorTrackingTransform; private ARAnchor _anchor; private ARRaycastManager _raycastManager; private ARAnchorManager _anchorManager; private static List<ARRaycastHit> _raycastHits = new List<ARRaycastHit>(); public ARAnchor Anchor => _anchor; void Awake() { _raycastManager = GetComponent<ARRaycastManager>(); _anchorManager = GetComponent<ARAnchorManager>(); _anchorManager.anchorsChanged += OnAnchorsChanged; } void Update() { // シングルタップ時以外は無視 if (Input.touchCount != 1) { return; } // uGUI操作時は無視 if (EventSystem.current.IsPointerOverGameObject(Input.GetTouch(0).fingerId)) { return; } // タッチ開始時以外は無視 Touch touch = Input.GetTouch(0); if (touch.phase != TouchPhase.Began) { return; } if (_raycastManager.Raycast(touch.position, _raycastHits, TrackableType.FeaturePoint)) { // 古いAnchor削除 RemoveAnchor(); // 新規Anchor作成 Pose hitPose = _raycastHits[0].pose; AddAnchor(hitPose); } } private void OnAnchorsChanged(ARAnchorsChangedEventArgs eventArgs) { if (eventArgs.added.Count > 0) { Transform targetTransform = eventArgs.added.Last().transform; Pose targetPose = new Pose(targetTransform.position, targetTransform.rotation); MoveTrackingObj(targetPose); } } public void AddAnchor(Pose hitPose) { _anchor = _anchorManager.AddAnchor(hitPose); if (_anchor == null) { Logger.Log("Error creating anchor"); } } public void RemoveAnchor() { if (_anchor == null) { return; } _anchorManager.RemoveAnchor(_anchor); _anchor = null; } public void MoveTrackingObj(Pose pose) { _anchorTrackingTransform.SetPositionAndRotation(pose.position, pose.rotation); } }
3Dモデル配置 / 保存 / 読み込み 機能
つぎに3Dオブジェクトです。以下機能を持つスクリプトを作成しました。
これも AR Session Origin
に追加します。
* 2本指タップで3Dモデルの配置 * ARAnchorとの位置関係を保存 / 読み込みするメソッド
using System.Collections.Generic; using System.Linq; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.XR.ARFoundation; using UnityEngine.XR.ARSubsystems; [RequireComponent(typeof(ARAnchorManager))] [RequireComponent(typeof(ARRaycastManager))] public class OrnamentCreator : MonoBehaviour { [SerializeField] private GameObject _ornamentPrefab; private ARRaycastManager _arRaycastManager; private ARAnchorManager _arAnchorManager; private ARAnchor _latestARAnchor; private GameObject _latestAnchorTrackerObj; private GameObject _ornamentObj; private static List<ARRaycastHit> _raycastHits = new List<ARRaycastHit>(); void Start() { _arRaycastManager = GetComponent<ARRaycastManager>(); _arAnchorManager = GetComponent<ARAnchorManager>(); _latestAnchorTrackerObj = new GameObject(); _latestAnchorTrackerObj.name = "ARAnchorTracker"; _arAnchorManager.anchorsChanged += OnARAnchorChanged; } void Update() { // 2本タップ以外はスルー if (Input.touchCount != 2) { return; } // uGUI操作時は無視 if (EventSystem.current.IsPointerOverGameObject(Input.GetTouch(0).fingerId)) { return; } // タップ開始時以外はスルー Touch touch = Input.GetTouch(0); if (touch.phase != TouchPhase.Began) { return; } // タップ処理 if (_arRaycastManager.Raycast(touch.position, _raycastHits, TrackableType.FeaturePoint)) { Pose hitPose = _raycastHits[0].pose; UpdateOrnament(hitPose); } } /// <summary> /// 置物の作成・位置の更新 /// </summary> /// <param name="pose">配置位置</param> /// <param name="isPoseLocal">poseがローカル座標のときはtrue</param> private void UpdateOrnament(Pose pose, bool isPoseLocal = false) { if (_ornamentObj == null) { _ornamentObj = Instantiate(_ornamentPrefab, _latestAnchorTrackerObj.transform); } if (isPoseLocal) { _ornamentObj.transform.localPosition = pose.position; _ornamentObj.transform.localRotation = pose.rotation; } else { _ornamentObj.transform.SetPositionAndRotation(pose.position, pose.rotation); } } /// <summary> /// アンカー更新時のイベント /// </summary> /// <param name="eventArgs">イベントの変数</param> private void OnARAnchorChanged(ARAnchorsChangedEventArgs eventArgs) { // Anchor追加時 if (eventArgs.added.Count > 0) { // 最新ARAnchor更新 _latestARAnchor = eventArgs.added.Last(); // _arAnchorTrackerObj移動 Transform arAnchorTransform = _latestARAnchor.transform; _latestAnchorTrackerObj.transform.SetPositionAndRotation( arAnchorTransform.position, arAnchorTransform.rotation ); } // Anchor消滅時 if (eventArgs.removed.Contains(_latestARAnchor)) { _latestARAnchor = null; } } /// <summary> /// アンカーに対する置物の位置を保存 /// </summary> public void SaveOrnamentPoseFromAnchor() { // Anchorや置物がないときはスルー bool isEmpty = _latestARAnchor == null || _ornamentObj == null; if (isEmpty) { Debug.LogError("ARAnchor is not found;"); return; } // Anchorとの位置関係を保存 Vector3 diffPos = _ornamentObj.transform.localPosition; Quaternion diffRot = _ornamentObj.transform.localRotation; PlayerPrefs.SetFloat("OrnamentPosX", diffPos.x); PlayerPrefs.SetFloat("OrnamentPosY", diffPos.y); PlayerPrefs.SetFloat("OrnamentPosZ", diffPos.z); PlayerPrefs.SetFloat("OrnamentRotX", diffRot.x); PlayerPrefs.SetFloat("OrnamentRotY", diffRot.y); PlayerPrefs.SetFloat("OrnamentRotZ", diffRot.z); PlayerPrefs.SetFloat("OrnamentRotW", diffRot.w); } /// <summary> /// アンカーに対する置物の位置を読み込んで移動 /// </summary> public void LoadOrnamentPoseFromAnchor() { // Anchorとの位置関係を読み込み float posX = PlayerPrefs.GetFloat("OrnamentPosX"); float posY = PlayerPrefs.GetFloat("OrnamentPosY"); float posZ = PlayerPrefs.GetFloat("OrnamentPosZ"); float rotX = PlayerPrefs.GetFloat("OrnamentRotX"); float rotY = PlayerPrefs.GetFloat("OrnamentRotY"); float rotZ = PlayerPrefs.GetFloat("OrnamentRotZ"); float rotW = PlayerPrefs.GetFloat("OrnamentRotW"); Pose diffPose = new Pose( new Vector3(posX, posY, posZ), new Quaternion(rotX, rotY, rotZ, rotW) ); UpdateOrnament(diffPose, true); } }
別途3Dモデル位置のSaveとLoadボタンを用意し、それぞれ SaveOrnamentPoseFromAnchor
と LoadOrnamentPoseFromAnchor
が実行されるようにします。
ちなみに今回使用した3DモデルはAssetStoreでみつけた宝箱。
結果
このように配置して保存。
アプリ再起動後にARWorldMapと3Dモデル位置を読み込むと、宝箱を同じ位置に再配置することができました。
まとめ
iOS限定ではありますが、空間情報を保存することができました。
マルチプラットフォームで空間情報を保存・共有したい場合には AzureSpatialAnchor なども検討してみたほうがいいかもしれませんね。
Appendix
ARWorldMapの参考記事
developer.apple.com qiita.com qiita.com qiita.com shu223.hatenablog.com
iOSビルドうまくいかない問題
今回、新品のMacMiniでiOS13.7にビルドをしようとしたら、Permision denied
エラーやら、XCodeのバージョン違いやらで結構つまづいてしまいました。
いつの時代もスッといくものではないですよね...(1年半前くらいにも天を仰いだ気がする)。
今回ビルドが通るまでに参考になった記事を載せておきます。 以下はすべての人に当てはまるわけではないので、うまくいかない人はめげずにエラー文で検索してみましょう。