OpenXRとMeta Questではじめる!AR/MRアプリの現実世界との位置合わせ - ARアンカー編 -

に公開

openxr-meta-anchor

1. はじめに

前回の記事「OpenXRとMeta Questではじめる!AR/MRアプリの現実世界との位置合わせ - Immersal編 -」では、Meta Quest のOpenXR プロジェクトの導入から Immersal SDK を利用した現実世界の位置と仮想空間の位置を合わせる方法について解説しました。

今回は、AR Foundation の アンカー機能を利用して、Meta Quest で現実世界の位置と仮想空間の位置を合わせる方法について解説します。

この記事では、以下のようなものを作成します。
https://youtube.com/shorts/5ionoPorfHQ?feature=share

なお、この記事は、前回の記事の続きとして書かれていますので、2. OpenXR:Meta パッケージを利用した開発環境構築までを完了していることを前提としています。

2. OpenXR Meta 2.2.0 におけるアンカーのサポート状況

Open XR Meta 2.2.0 では、アンカーの保存、読み込み、削除の機能がサポートされています。
AR Foundation で定義されている全てのアンカー機能がサポートされているわけではありません。

3. 平面検出の有効化

ARアンカーは空間のどこにでも置けますが、仮想オブジェクトを自然に見せるには、床や机といった現実の平面に配置するのが最も効果的です。
平面検出は、まさにその「置き場所」をアプリが認識するための基本機能となります。

そのため、ARアンカーの利用と直接は関係ありませんが、 平面検出の有効化について説明しておきます。
平面検出、バウンディングボックス、メッシュ、オクルージョンの各機能を利用するには、USE_SCENE 権限を追加する必要があります。

なお、この記事では、平面にアンカーを配置するようなサンプルは作成していないので、この章は読み飛ばして頂いても大丈夫です。

3.1. Project Settings : AndroidManifest.xml の設定

ProjectSettings01

<?xml version="1.0" encoding="utf-8"?>
<manifest
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <application>
        <!--Used when Application Entry is set to Activity, otherwise remove this activity block-->
        <!--<activity android:name="com.unity3d.player.UnityPlayerActivity"
                  android:theme="@style/UnityThemeSelector">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
            <meta-data android:name="unityplayer.UnityActivity" android:value="true" />
        </activity>-->
        <!--Used when Application Entry is set to GameActivity, otherwise remove this activity block-->
        <activity android:name="com.unity3d.player.UnityPlayerGameActivity"
                  android:theme="@style/BaseUnityGameActivityTheme">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
            <meta-data android:name="unityplayer.UnityActivity" android:value="true" />
            <meta-data android:name="android.app.lib_name" android:value="game" />
        </activity>
    </application>
    <uses-permission android:name="com.oculus.permission.USE_SCENE" />
</manifest>

3.2. PermissionsCheck スクリプトをアセットフォルダに追加

Packages > Unity OpenXR Meta > Tests > Runtime > CodeSamples フォルダにある PermissionsCheck.cs スクリプトを Assets フォルダの適当な場所にコピーします。

Unity01

3.3. 平面検出のシーン設定

  • XR Origin オブジェクトに ARPlaneManager コンポーネントを追加して、無効化
    Unity02
  • 空のゲームオブジェクトを作成して、PermissionsCheck スクリプトを追加し、ARPlaneManager コンポーネントが有効になるように設定
    Unity03

4. ARアンカー利用の実装

4.1. ARAnchorManager コンポーネントの追加

XR Origin オブジェクトに ARAnchorManager コンポーネントを追加し、アンカーを配置した際に生成するプレハブを設定
Unity04

4.2. UniTask の導入

ARアンカーの保存、読み込み、削除は非同期で行う必要があるため、非同期処理を簡単に記述できるようにここでは UniTask を利用します。

4.3. ARアンカーの保存、読み込み、削除の実装

アンカー機能を利用するための公式の雛形が用意されていますので、これをベースに実装します。
Packages > AR Foundation > Tests > Runtime > CodeSamples フォルダにある ARAnchorManagerSample.cs スクリプトをアセットフォルダに ARAnchorController.cs としてコピーします。

Unity05

  • ARAnchorController.cs スクリプトの追記・修正の例
ARAnchorController.cs
using System.Collections.Generic;
using System.Threading;
using Cysharp.Threading.Tasks;
using Unity.Collections;
using Unity.XR.CoreUtils.Collections;
using UnityEngine;
using UnityEngine.XR.ARFoundation;
using UnityEngine.XR.ARSubsystems;

namespace b0bmat0ca.OpenXrMeta
{
    using UnityEngine.XR.OpenXR.NativeTypes;
    
    /// <summary>
    /// UnityEngine.XR.ARFoundation.Tests.ARAnchorManagerSamplesクラスを元にして、利用可能な形で実装したものです
    /// </summary>
    public class ARAnchorController : MonoBehaviour
    {
        #region DescriptorChecks
        
        /// <summary>
        /// トラッキング可能オブジェクトへのアンカーアタッチメント機能をサポートしているかチェック
        /// </summary>
        public bool SupportsTrackableAttachments(ARAnchorManager manager)
        {
            return manager.descriptor.supportsTrackableAttachments;
        }

        /// <summary>
        /// アンカーの保存機能をサポートしているかチェック
        /// </summary>
        public bool SupportsSynchronousAdd(ARAnchorManager manager)
        {
            return manager.descriptor.supportsSynchronousAdd;
        }

        /// <summary>
        /// アンカーの保存機能をサポートしているかチェック
        /// </summary>
        public bool SupportsSaveAnchor(ARAnchorManager manager)
        {
            return manager.descriptor.supportsSaveAnchor;
        }

        /// <summary>
        /// アンカーの読み込み機能をサポートしているかチェック
        /// </summary>
        public bool SupportsLoadAnchor(ARAnchorManager manager)
        {
            return manager.descriptor.supportsLoadAnchor;
        }

        /// <summary>
        /// アンカーの削除機能をサポートしているかチェック
        /// </summary>
        public bool SupportsEraseAnchor(ARAnchorManager manager)
        {
            return manager.descriptor.supportsEraseAnchor;
        }

        /// <summary>
        /// 保存されたアンカーID取得機能をサポートしているかチェック
        /// </summary>
        public bool SupportsGetSavedAnchorIds(ARAnchorManager manager)
        {
            return manager.descriptor.supportsGetSavedAnchorIds;
        }

        /// <summary>
        /// 非同期処理のキャンセレーション機能をサポートしているかチェック
        /// </summary>
        public bool SupportsAsyncCancellation(ARAnchorManager manager)
        {
            return manager.descriptor.supportsAsyncCancellation;
        }

        /// <summary>
        /// 全ての機能サポート状況を確認
        /// </summary>
        public void CheckForOptionalFeatureSupport(ARAnchorManager manager)
        {
            // Use manager.descriptor to determine which optional features
            // are supported on the device.
            
            Debug.Log($"TrackableAttachments: {SupportsTrackableAttachments(manager)}");
            Debug.Log($"SynchronousAdd: {SupportsSynchronousAdd(manager)}");
            Debug.Log($"SaveAnchor: {SupportsSaveAnchor(manager)}");
            Debug.Log($"LoadAnchor: {SupportsLoadAnchor(manager)}");
            Debug.Log($"EraseAnchor: {SupportsEraseAnchor(manager)}");
            Debug.Log($"GetSavedAnchorIds: {SupportsGetSavedAnchorIds(manager)}");
            Debug.Log($"AsyncCancellation: {SupportsAsyncCancellation(manager)}");
        }
        
        #endregion

        #region TryAddAnchorAsync
        
        /// <summary>
        /// 指定した位置と回転でアンカーを非同期で作成します
        /// </summary>
        public async UniTask<ARAnchor> CreateAnchorAsync(ARAnchorManager manager, Pose pose)
        {
            Result<ARAnchor> result = await manager.TryAddAnchorAsync(pose);
            if (result.status.IsSuccess())
            {
                return result.value;
            }
            return null;
        }
        #endregion

        #region AttachAnchor
        
        /// <summary>
        /// ARPlaneにアンカーをアタッチします
        /// </summary>
        public ARAnchor AttachAnchor(ARAnchorManager manager, ARPlane plane, Pose pose)
        {
            if (SupportsTrackableAttachments(manager))
            {
                ARAnchor anchor = manager.AttachAnchor(plane, pose);
                return anchor;
            }
            return null;
        }
        #endregion

        #region TrySaveAnchorAsync
        
        /// <summary>
        /// アンカーを永続化して保存します
        /// </summary>
        public async UniTask<SerializableGuid?> SaveAnchorAsync(ARAnchorManager manager, ARAnchor anchor)
        {
            if (!SupportsSaveAnchor(manager))
            {
                Debug.LogWarning("SaveAnchor is not supported on this device");
                return null;
            }
            
            Result<SerializableGuid> result = await manager.TrySaveAnchorAsync(anchor);
            if (result.status.IsError())
            {
                // handle error
                return null;
            }

            // Save this value, then use it as an input parameter
            // to TryLoadAnchorAsync or TryEraseAnchorAsync
            return result.value;
        }
        #endregion

        #region TrySaveAnchorsAsync
        
        /// <summary>
        /// 複数のアンカーを一括で永続化して保存します(Meta OpenXRでは全体成功か全体失敗)
        /// </summary>
        public async UniTask<List<ARSaveOrLoadAnchorResult>> SaveAnchorsAsync(
            ARAnchorManager manager,
            IEnumerable<ARAnchor> anchors)
        {
            if (!SupportsSaveAnchor(manager))
            {
                Debug.LogWarning("SaveAnchor is not supported on this device");
                return new List<ARSaveOrLoadAnchorResult>();
            }
            
            List<ARSaveOrLoadAnchorResult> results = new List<ARSaveOrLoadAnchorResult>();
            await manager.TrySaveAnchorsAsync(anchors, results);

            // Check results - each result indicates success or failure for each anchor
            return results;
        }
        #endregion

        #region TryLoadAnchorAsync
        
        /// <summary>
        /// 保存されたアンカーをGUIDから読み込みます
        /// </summary>
        public async UniTask<ARAnchor> LoadAnchorAsync(ARAnchorManager manager, SerializableGuid guid)
        {
            if (!SupportsLoadAnchor(manager))
            {
                Debug.LogWarning("LoadAnchor is not supported on this device");
                return null;
            }
            
            Result<ARAnchor> result = await manager.TryLoadAnchorAsync(guid);
            if (result.status.IsError())
            {
                // handle error
                return null;
            }

            // You can use this anchor as soon as it's returned to you.
            return result.value;
        }
        #endregion

        #region TryLoadAnchorsAsync
        
        /// <summary>
        /// 複数の保存されたアンカーを一括でGUIDから読み込みます
        /// </summary>
        public async UniTask<List<ARSaveOrLoadAnchorResult>> LoadAnchorsAsync(
            ARAnchorManager manager,
            IEnumerable<SerializableGuid> savedAnchorGuids,
            System.Action<ReadOnlyListSpan<ARSaveOrLoadAnchorResult>> onIncrementalResults = null)
        {
            if (!SupportsLoadAnchor(manager))
            {
                Debug.LogWarning("LoadAnchor is not supported on this device");
                return new List<ARSaveOrLoadAnchorResult>();
            }
            
            List<ARSaveOrLoadAnchorResult> results = new List<ARSaveOrLoadAnchorResult>();
            await manager.TryLoadAnchorsAsync(
                savedAnchorGuids,
                results,
                onIncrementalResults ?? OnIncrementalResultsAvailable);

            // Check results - each result indicates success or failure for each anchor
            return results;
        }

        /// <summary>
        /// アンカーの段階的読み込み結果を処理します
        /// </summary>
        private void OnIncrementalResultsAvailable(ReadOnlyListSpan<ARSaveOrLoadAnchorResult> loadAnchorResults)
        {
            foreach (ARSaveOrLoadAnchorResult loadAnchorResult in loadAnchorResults)
            {
                // You can use these anchors immediately without waiting for the
                // entire batch to finish loading.
                // loadAnchorResult.resultStatus.IsSuccess() will always be true
                // for anchors passed to the incremental results callback.
                ARAnchor loadedAnchor = loadAnchorResult.anchor;
            }
        }
        #endregion

        #region TryEraseAnchorAsync
        
        /// <summary>
        /// 保存されたアンカーをGUIDで指定して削除します
        /// </summary>
        public async UniTask<bool> EraseAnchorAsync(ARAnchorManager manager, SerializableGuid guid)
        {
            if (!SupportsEraseAnchor(manager))
            {
                Debug.LogWarning("EraseAnchor is not supported on this device");
                return false;
            }
            
            XRResultStatus status = await manager.TryEraseAnchorAsync(guid);
            if (status.IsError())
            {
                // handle error
                return false;
            }

            // The anchor was successfully erased.
            return status.IsSuccess();
        }
        #endregion

        #region TryEraseAnchorsAsync
        
        /// <summary>
        /// 複数の保存されたアンカーを一括でGUIDから削除します(Meta OpenXRでは全体成功か全体失敗)
        /// </summary>
        public async UniTask<List<XREraseAnchorResult>> EraseAnchorsAsync(
            ARAnchorManager manager,
            IEnumerable<SerializableGuid> savedAnchorGuids)
        {
            if (!SupportsEraseAnchor(manager))
            {
                Debug.LogWarning("EraseAnchor is not supported on this device");
                return new List<XREraseAnchorResult>();
            }
            
            List<XREraseAnchorResult> eraseAnchorResults = new List<XREraseAnchorResult>();
            await manager.TryEraseAnchorsAsync(savedAnchorGuids, eraseAnchorResults);
            
            // Check results - each result indicates success or failure for each anchor
            return eraseAnchorResults;
        }
        #endregion

        #region TryGetSavedAnchorIdsAsync
        
        /// <summary>
        /// デバイスに保存されている全てのアンカーのGUID一覧を取得します
        /// </summary>
        public async UniTask<NativeArray<SerializableGuid>?> GetSavedAnchorIdsAsync(ARAnchorManager manager, Allocator allocator = Allocator.Temp)
        {
            if (!SupportsGetSavedAnchorIds(manager))
            {
                Debug.LogWarning("GetSavedAnchorIds is not supported on this device");
                return null;
            }
            
            // If you need to keep the saved anchor IDs longer than a frame, use
            // Allocator.Persistent instead, then remember to Dispose the array.
            Result<NativeArray<SerializableGuid>> result = await manager.TryGetSavedAnchorIdsAsync(allocator);

            if (result.status.IsError())
            {
                // handle error
                return null;
            }

            // Do something with the saved anchor IDs
            return result.value;
        }
        #endregion

        #region AsyncCancellation
        
        /// <summary>
        /// キャンセレーショントークン付きで保存されたアンカーGUID一覧を取得します
        /// </summary>
        public async UniTask<NativeArray<SerializableGuid>?> GetSavedAnchorIdsWithCancellationAsync(ARAnchorManager manager, CancellationToken cancellationToken, Allocator allocator = Allocator.Temp)
        {
            if (!SupportsGetSavedAnchorIds(manager) || !SupportsAsyncCancellation(manager))
            {
                Debug.LogWarning("GetSavedAnchorIds or AsyncCancellation is not supported on this device");
                return null;
            }
            
            // Create a CancellationTokenSource to serve our CancellationToken
            // Use one of the other methods in the persistent anchor API
            Result<NativeArray<SerializableGuid>> result = await manager.TryGetSavedAnchorIdsAsync(allocator, cancellationToken);

            if (result.status.IsError())
            {
                return null;
            }

            // Cancel the async operation before it completes if needed
            return result.value;
        }
        #endregion

        #region Meta OpenXR固有のヘルパーメソッド
        /// <summary>
        /// Meta OpenXRの詳細エラー情報を取得します
        /// </summary>
        public string GetDetailedErrorInfo(XRResultStatus status)
        {
            if (status.IsSuccess())
                return "";
            
            try
            {
                // Meta OpenXRのnativeStatusCodeをXrResultとして取得を試行
                XrResult xrResult = (XrResult)status.nativeStatusCode;
                return $"エラー: {status} (XrResult: {xrResult})";
            }
            catch (System.InvalidCastException)
            {
                // XrResult型が利用できない場合のフォールバック
                return $"エラー: {status} (nativeCode: {status.nativeStatusCode})";
            }
        }

        /// <summary>
        /// アンカーのtrackableIdとGUIDの関係を確認します(Meta OpenXRでは同じ値)
        /// </summary>
        public bool ValidateAnchorGuid(ARAnchor anchor, SerializableGuid savedGuid)
        {
            // Meta OpenXRではアンカーのGUIDはtrackableIdと同じ
            return anchor.trackableId.Equals(savedGuid);
        }

        /// <summary>
        /// バッチ保存が成功したかを確認します(Meta OpenXRでは全体成功か全体失敗)
        /// </summary>
        public bool IsBatchSaveSuccessful(List<ARSaveOrLoadAnchorResult> results)
        {
            // Meta OpenXRでは1つでも失敗したら全体が失敗する
            if (results == null || results.Count == 0) return false;
            
            foreach (ARSaveOrLoadAnchorResult result in results)
            {
                if (result.resultStatus.IsError())
                    return false;
            }
            return true;
        }

        /// <summary>
        /// バッチ削除が成功したかを確認します(Meta OpenXRでは全体成功か全体失敗)
        /// </summary>
        public bool IsBatchEraseSuccessful(List<XREraseAnchorResult> results)
        {
            // Meta OpenXRでは1つでも失敗したら全体が失敗する
            if (results == null || results.Count == 0) return false;
            
            foreach (XREraseAnchorResult result in results)
            {
                if (result.resultStatus.IsError())
                    return false;
            }
            return true;
        }
        #endregion
    }
}

次に、ARAnchorController.cs スクリプトのメソッドを呼び出して、ARアンカーの保存、読み込み、削除を実行するスクリプトを作成します。
ここでは、コントローラーのボタン入力でアンカーの配置、保存、読み込み、削除を行う AnchorPlacer.cs スクリプトの例を示します。

  • AnchorPlacer.cs スクリプトの作成
AnchorPlacer.cs
using System;
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.XR.ARFoundation;
using UnityEngine.InputSystem;
using UnityEngine.XR.ARSubsystems;
using UnityEngine.Assertions;

namespace b0bmat0ca.OpenXrMeta
{
    /// <summary>
    /// コントローラーのボタン入力でARAnchorを配置するクラス
    /// </summary>
    public class AnchorPlacer : MonoBehaviour
    {
        [Header("Dependencies")]
        [SerializeField] private ARAnchorManager _anchorManager;
        [SerializeField] private ARAnchorController _anchorController;
        
        [Header("Controller Input")]
        [SerializeField] private InputActionReference _controllerPositionAction;
        [SerializeField] private InputActionReference _triggerButtonAction;
        [SerializeField] private InputActionReference _primaryButtonAction;
        [SerializeField] private InputActionReference _secondaryButtonAction;
        
        [Header("Anchor Settings")]
        [SerializeField] private Vector3 _anchorOffset = Vector3.zero;
        
        private readonly string ANCHOR_IDS_KEY = "SavedAnchorIds";
        private readonly string ANCHOR_COUNT_KEY = "SavedAnchorCount";
        
        private void Awake()
        {
            Assert.IsNotNull(_anchorManager, "ARAnchorManager is not assigned in AnchorPlacer");
            Assert.IsNotNull(_anchorController, "ARAnchorController is not assigned in AnchorPlacer");
            Assert.IsNotNull(_triggerButtonAction, "Trigger Button Action is not assigned in AnchorPlacer");
            Assert.IsNotNull(_primaryButtonAction, "Primary Button Action is not assigned in AnchorPlacer");
            Assert.IsNotNull(_secondaryButtonAction, "Secondary Button Action is not assigned in AnchorPlacer");
            Assert.IsNotNull(_controllerPositionAction, "Controller Position Action is not assigned in AnchorPlacer");
        }

        private void Start()
        {
            // コントローラーボタンのイベント設定
            SetupInputActions();
        }
        
        private void OnEnable()
        {
            EnableInputActions();
        }
        
        private void OnDisable()
        {
            DisableInputActions();
        }
        
        private void SetupInputActions()
        {
            if (_triggerButtonAction != null)
            {
                _triggerButtonAction.action.performed += OnTriggerPressed;
            }
            
            if (_primaryButtonAction != null)
            {
                _primaryButtonAction.action.performed += OnPrimaryButtonPressed;
            }
            
            if (_secondaryButtonAction != null)
            {
                _secondaryButtonAction.action.performed += OnSecondaryButtonPressed;
            }
        }
        
        private void EnableInputActions()
        {
            _triggerButtonAction?.action.Enable();
            _primaryButtonAction?.action.Enable();
            _secondaryButtonAction?.action.Enable();
            _controllerPositionAction?.action.Enable();
        }
        
        private void DisableInputActions()
        {
            _triggerButtonAction?.action.Disable();
            _primaryButtonAction?.action.Disable();
            _secondaryButtonAction?.action.Disable();
            _controllerPositionAction?.action.Disable();
        }
        
        private void OnTriggerPressed(InputAction.CallbackContext context)
        {
            PlaceAnchorAtControllerPosition();
        }
        
        private void OnPrimaryButtonPressed(InputAction.CallbackContext context)
        {
            LoadAnchors();
        }
        
        private void OnSecondaryButtonPressed(InputAction.CallbackContext context)
        {
            RemoveAllAnchors();
        }
        
        /// <summary>
        /// コントローラーの現在位置にアンカーを配置します
        /// </summary>
        public async UniTaskVoid PlaceAnchorAtControllerPosition()
        {
            // コントローラーの位置を取得(回転はWorld座標系で設定)
            Vector3 controllerPosition = GetControllerPosition();
            Vector3 anchorPosition = controllerPosition + _anchorOffset;
            Quaternion anchorRotation = Quaternion.identity; // World座標系の回転
            
            Pose anchorPose = new Pose(anchorPosition, anchorRotation);
            
            await PlaceAnchorAsync(anchorPose);
        }
        
        /// <summary>
        /// 指定した位置にアンカーを配置します
        /// </summary>
        public async UniTaskVoid PlaceAnchorAt(Vector3 position, Quaternion rotation)
        {
            Pose anchorPose = new Pose(position, rotation);
            await PlaceAnchorAsync(anchorPose);
        }
        
        /// <summary>
        /// 保存されているすべてのアンカーをロードします
        /// </summary>
        public async UniTaskVoid LoadAnchors()
        {
            try
            {
                Debug.Log("PlayerPrefsから保存されたアンカーIDを取得中...");
                
                // PlayerPrefsから保存されたアンカーIDを取得
                List<SerializableGuid> savedAnchorIds = GetAnchorIdsFromPlayerPrefs();
                
                if (savedAnchorIds.Count == 0)
                {
                    Debug.Log("保存されたアンカーが見つかりませんでした");
                    return;
                }
                
                Debug.Log($"{savedAnchorIds.Count}個の保存されたアンカーが見つかりました");
                
                // アンカーをロード
                List<ARSaveOrLoadAnchorResult> results = await _anchorController.LoadAnchorsAsync(_anchorManager, savedAnchorIds);
                
                int successCount = 0;
                foreach (ARSaveOrLoadAnchorResult result in results)
                {
                    if (result.resultStatus.IsSuccess())
                    {
                        successCount++;
                        Debug.Log($"アンカーをロードしました: {result.anchor.trackableId}");
                    }
                    else
                    {
                        Debug.LogWarning($"アンカーのロードに失敗しました: {_anchorController.GetDetailedErrorInfo(result.resultStatus)}");
                    }
                }
                
                Debug.Log($"アンカーロード完了: {successCount}/{results.Count}個成功");
            }
            catch (System.Exception e)
            {
                Debug.LogError($"アンカーロード中にエラーが発生しました: {e.Message}");
            }
        }
        
        private async UniTask PlaceAnchorAsync(Pose pose)
        {
            try
            {
                Debug.Log($"アンカー配置開始: {pose.position}");
                
                // ARAnchorControllerを使用してアンカーを作成
                ARAnchor anchor = await _anchorController.CreateAnchorAsync(_anchorManager, pose);
                
                if (anchor != null)
                {
                    Debug.Log($"アンカーが正常に配置されました: {anchor.trackableId} at {pose.position}");
                    
                    // アンカーを保存
                    SerializableGuid? savedGuid = await _anchorController.SaveAnchorAsync(_anchorManager, anchor);
                    if (savedGuid.HasValue)
                    {
                        SaveAnchorIdToPlayerPrefs(savedGuid.Value);
                        Debug.Log($"アンカーIDを保存しました: {savedGuid.Value}");
                    }
                    else
                    {
                        Debug.LogWarning("アンカーの保存に失敗しました");
                    }
                }
                else
                {
                    Debug.LogWarning("アンカーの作成に失敗しました");
                }
            }
            catch (System.Exception e)
            {
                Debug.LogError($"アンカー配置中にエラーが発生しました: {e.Message}");
                Debug.LogError($"スタックトレース: {e.StackTrace}");
            }
        }
        
        private Vector3 GetControllerPosition()
        {
            if (_controllerPositionAction != null && _controllerPositionAction.action.enabled)
            {
                return _controllerPositionAction.action.ReadValue<Vector3>();
            }
            
            Debug.LogWarning("コントローラー位置が取得できません。");
            return Vector3.zero;
        }
        
        /// <summary>
        /// 現在配置されているすべてのアンカーを削除(PlayerPrefsからも削除)
        /// </summary>
        public async UniTaskVoid RemoveAllAnchors()
        {
            try
            {
                Debug.Log("すべてのアンカーを削除中...");
                
                // PlayerPrefsから保存されたアンカーIDを取得
                List<SerializableGuid> savedAnchorIds = GetAnchorIdsFromPlayerPrefs();
                
                if (savedAnchorIds.Count > 0)
                {
                    // 永続化されたアンカーを削除
                    List<XREraseAnchorResult> eraseResults = await _anchorController.EraseAnchorsAsync(_anchorManager, savedAnchorIds);
                    
                    int successCount = 0;
                    foreach (XREraseAnchorResult result in eraseResults)
                    {
                        if (result.resultStatus.IsSuccess())
                        {
                            successCount++;
                        }
                        else
                        {
                            Debug.LogWarning($"アンカーの削除に失敗: {_anchorController.GetDetailedErrorInfo(result.resultStatus)}");
                        }
                    }
                    
                    Debug.Log($"永続化アンカー削除完了: {successCount}/{eraseResults.Count}個成功");
                }
                
                // 現在のセッションのアンカーも削除
                if (_anchorManager != null)
                {
                    List<ARAnchor> anchorsToRemove = new List<ARAnchor>();
                    foreach (ARAnchor anchor in _anchorManager.trackables)
                    {
                        anchorsToRemove.Add(anchor);
                    }
                    
                    foreach (ARAnchor anchor in anchorsToRemove)
                    {
                        _anchorManager.TryRemoveAnchor(anchor);
                    }
                    
                    Debug.Log($"セッションアンカー削除完了: {anchorsToRemove.Count}個削除");
                }
                
                // PlayerPrefsからアンカーIDをすべて削除
                ClearSavedAnchorIds();
                
                Debug.Log("すべてのアンカーとIDの削除が完了しました");
            }
            catch (Exception e)
            {
                Debug.LogError($"アンカー削除中にエラーが発生しました: {e.Message}");
            }
        }
        
        #region PlayerPrefs管理
        
        /// <summary>
        /// アンカーIDをPlayerPrefsに保存
        /// </summary>
        private void SaveAnchorIdToPlayerPrefs(SerializableGuid anchorId)
        {
            int currentCount = PlayerPrefs.GetInt(ANCHOR_COUNT_KEY, 0);
            string key = $"{ANCHOR_IDS_KEY}_{currentCount}";
            // SerializableGuidを標準的なGUID形式の文字列に変換
            string value = anchorId.guid.ToString();
            
            PlayerPrefs.SetString(key, value);
            PlayerPrefs.SetInt(ANCHOR_COUNT_KEY, currentCount + 1);
            PlayerPrefs.Save();
            
            Debug.Log($"PlayerPrefsに保存: Key={key}, Value={value}, Count={currentCount + 1}");
        }
        
        /// <summary>
        /// PlayerPrefsから保存されたアンカーIDを取得
        /// </summary>
        private List<SerializableGuid> GetAnchorIdsFromPlayerPrefs()
        {
            List<SerializableGuid> anchorIds = new List<SerializableGuid>();
            int count = PlayerPrefs.GetInt(ANCHOR_COUNT_KEY, 0);
            
            Debug.Log($"PlayerPrefsから読み込み: Count={count}");
            
            for (int i = 0; i < count; i++)
            {
                string key = $"{ANCHOR_IDS_KEY}_{i}";
                string guidString = PlayerPrefs.GetString(key, "");
                
                Debug.Log($"PlayerPrefs読み込み: Key={key}, Value={guidString}");
                
                if (!string.IsNullOrEmpty(guidString))
                {
                    if (Guid.TryParse(guidString, out System.Guid guid))
                    {
                        SerializableGuid serializableGuid = new SerializableGuid(guid);
                        anchorIds.Add(serializableGuid);
                        Debug.Log($"有効なアンカーID追加: {serializableGuid}");
                    }
                    else
                    {
                        Debug.LogWarning($"無効なGUID形式: {guidString}");
                    }
                }
                else
                {
                    Debug.LogWarning($"空のGUID文字列: Key={key}");
                }
            }
            
            Debug.Log($"PlayerPrefsから{anchorIds.Count}個のアンカーIDを取得しました");
            return anchorIds;
        }
        
        /// <summary>
        /// PlayerPrefsから保存されたアンカーIDをすべて削除
        /// </summary>
        public void ClearSavedAnchorIds()
        {
            int count = PlayerPrefs.GetInt(ANCHOR_COUNT_KEY, 0);
            
            for (int i = 0; i < count; i++)
            {
                PlayerPrefs.DeleteKey($"{ANCHOR_IDS_KEY}_{i}");
            }
            
            PlayerPrefs.DeleteKey(ANCHOR_COUNT_KEY);
            PlayerPrefs.Save();
            
            Debug.Log("保存されたアンカーIDをすべて削除しました");
        }
        #endregion
    }
}

4.4. コントローラーの入力アクションの作成

Assets > Create > Input Actions で Input Action アセットを作成します。
上記の AnchorPlacer.cs スクリプトで利用するアクションを以下のように作成します。

  • ControllerInputAction
    Unity06
Action Maps Actions Action Properties Binding Properties
LeftController TriggerButtonAction Action Type : Button Path : <XRController>{LeftHand}/{TriggerButton}
PrimaryButtonAction Action Type : Button Path : <XRController>{LeftHand}/{PrimaryButton}
SecondaryButtonAction Action Type : Button Path : <XRController>{LeftHand}/{SecondaryButton}
DevicePosition Action Type : Value
Control Type : Vector3
Path : <XRController>{LeftHand}/devicePosition}
RightController TriggerButtonAction Action Type : Button Path : <XRController>{RightHand}/{TriggerButton}
PrimaryButtonAction Action Type : Button Path : <XRController>{RightHand}/{PrimaryButton}
SecondaryButtonAction Action Type : Button Path : <XRController>{RightHand}/{SecondaryButton}
DevicePosition Action Type : Value
Control Type : Vector3
Path : <XRController>{RightHand}/devicePosition}

4.5. シーンの作成

上記で作成したスクリプト、アクションアセットを利用して、シーンを作成します。
右コントローラーの操作向けの設定例を示します。

  • 空のゲームオブジェクトを作成して、ARAnchorController、AnchorPlacer コンポーネントを追加し、必要な参照を設定
    Unity07

5. まとめ

今回は、AR Foundationのアンカー機能を利用して、Meta Questで現実世界と仮想空間の位置を合わせる方法について解説しました。

この機能により、アプリを再起動しても現実世界の特定の位置にオブジェクトを固定表示することが可能になります。
これは、前回紹介したVPS(Immersal)とは異なり、ユーザーが手動で基準点を設定する基本的な位置合わせの手法です。

次回は、今回作成したARアンカーを複数デバイス間で共有する機能について解説します。

エンジニア絶賛募集中!

MESONではUnityエンジニアを絶賛募集中です! XR、空間コンピューティングのプロジェクトに関わってみたい! 開発したい! という方はぜひご応募ください!

MESONのメンバーページからご応募いただくか、TwitterのDMなどでご連絡ください。

書いた人

ボブ

高野 剛

Unixシステムのインフラ構築・運用を経験後、ECサイトを中心としたWebアプリ開発、プロジェクトマネージメントに従事する。
ミニオン好きが高じて、USJに通い続ける中、XRアトラクションに魅了される。
自分が感動したことを他の人にも体験してもらいたいという思いから、転職を決意し、XRの学校での1年間の学びを経てMESONへ入社。

X

MESONテックブログ

Discussion