TECH SCAPE

AR関連多め(HoloLens, AzureKinect、SparkAR)

【Unity, ARFoundation】ARKitの空間情報記録機能「ARWorldMap」を使ってみた

f:id:a_hancho:20200928142238p:plain

ARKitには ARWorldMap という、取得した空間の特徴点情報を一つにまとめる機能があります。

developer.apple.com

ARWorldMapを外部データとして保存 / 読み込みすることで、以下のようなことが実現できるでしょう。

* 空間に対するユーザーの位置を算出
* コンテンツの基準点を常に一定の位置に設定(ARAnchorとの併用
* 保存した空間の形状を点群で表示し、俯瞰的に観測
...etc

今回はUnityおよび ARFoundation を通じて、この機能に関する開発を行ってみました。

最終的には ARAnchor と組み合わせることで、配置した3DオブジェクトをARWorldMap読み込み時に復元するアプリケーションを作成してみます。

目次

リポジトリ

github.com

公式サンプルをForkし、以下フォルダに2つデモを追加しました。

/Assets/_ExtraDemo

環境

  • 開発PC : Windows10 Pro
  • Unity : 2019.4.8
  • ビルドPC ; MacMini(2020) Catalina 10.15.6
  • バイス : iPad Pro(2020) iOS13.7

  • 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

結果

f:id:a_hancho:20200928144141g:plain

このようにスキャンし、セーブボタンを押すとARWorldMapが保存されます。

アプリを再起動しロードボタンを押してみると、20秒ほどで前回保存したARWorldMapが読み込まれます。

使用上のポイント

このデモSceneをビルドする上で注意点があったのでまとめます。

1. 対応バージョン要確認

使用したいUnityバージョンによって、サンプルのバージョンが変わります。 今回は記事投稿時点で安定してそうな 3.1 を選択しました。

latest-version以外を使用する場合は、gitブランチを切り替えましょう。

2. Packageに関するエラー

使用するUnityバージョンによって、以下のようなエラーが出ます。

f:id:a_hancho:20200918003126p:plain

エラーに該当するPackageのバージョンが Verify になっているか、PackageManagerで確認しましょう。

f:id:a_hancho:20200918003240p:plain

3. MappingStatusTextが表示されない

Textの参照がなぜか初期状態ではずれていました。 実機でログを確認したい方は、UITextにあてはめましょう。

f:id:a_hancho:20200918142916j:plain

4. 読込待機時間

保存は一瞬で終わるのに対して、読み込みは結構かかります。 だいたいですが、20畳くらいの部屋の空間マップデータが20~30秒かかりました。

コンテンツに盛り込む場合は、ローディングをつける等の工夫が必要そうですね。

 

ARAnchorとの併用

ドキュメントをみると、ARWorldMap は空間の特徴点情報と一緒に、 ARAnchor の情報も含んでくれるようです。 タップ位置にアンカーを作成する実装を加えて、検証してみました。

Sceneのパス : Assets/_ExtraDemo/Scenes/ARWorldMapWithAnchors.unity

実装

アンカーに関する実装は、以下のサンプルSceneがあります。

Assets/Scenes/AnchorPoints.unity

要するに、以下3つのコンポーネントARSessionOrigin に追加するとよさそうです。

f:id:a_hancho:20200926233602p:plain

Assets/Scripts/AnchorCreator.cs はタップ位置にARAnchorを設置するスクリプトです。

今回詳しい内容は割愛しますが、このままだとUIをすり抜けてAnchorが生成されてしまうため、Update内に以下スクリプトを追加してあげるとより良い感じです。

// uGUI操作時は無視
if (EventSystem.current.IsPointerOverGameObject(Input.GetTouch(0).fingerId)) {
     return;
}

結果

f:id:a_hancho:20200928144420g:plain

Anchorを乱れうちしてARWorldMapを保存。

f:id:a_hancho:20200928144304g:plain

読み込んだところ復元しました。

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ボタンを用意し、それぞれ SaveOrnamentPoseFromAnchorLoadOrnamentPoseFromAnchor が実行されるようにします。

ちなみに今回使用した3DモデルはAssetStoreでみつけた宝箱。

assetstore.unity.com

結果

f:id:a_hancho:20200928145159p:plain

このように配置して保存。

f:id:a_hancho:20200928145510g:plain

アプリ再起動後に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年半前くらいにも天を仰いだ気がする)。

今回ビルドが通るまでに参考になった記事を載せておきます。 以下はすべての人に当てはまるわけではないので、うまくいかない人はめげずにエラー文で検索してみましょう。

winmemorandum.blogspot.com qiita.com