Meta Questで空間アンカーの共有を行う

2024/06/10に公開2

quest-sharing-spatial-anchor.png

1. 概要

Meta社がMeta Quest向けに提供しているShared Spatial Anchor(共有空間アンカー)の利用方法について説明します。この機能を使うことで、複数のユーザー間で空間の位置情報を共有し、同じ場所に仮想オブジェクトを配置することが可能になります。Meta社が公開している公式サンプルを参考にしながら、個人的にポイントと感じた部分を紹介します。

2. 利用環境

説明で利用している環境は以下の通りです。

  • Unity 2022.3.24f1
  • Meta XR Core SDK v64
  • Meta XR Platform SDK v64
  • Netcode for GameObjects v1.8.1[1]

3. Building Blocks で提供される機能

3.1. シーンに配置したコンポーネント群の概要

GameObject コンポーネント 概要
[BuildingBlock] Spatial Anchor Core SpatialAnchorCoreBuildingBlock Meta社のBuildingBlocks用XR開発ライブラリの一部で、空間アンカーの基本的な管理と操作の機能を提供します。
[BuildingBlock] Sample Spatial Anchor Controller SpatialAnchorSpawnerBuildingBlock Meta社のBuildingBlocks用XR開発ライブラリの一部で、空間アンカーを生成および配置する機能を提供します。
SpatialAnchorLoaderBuildingBlock Meta社のBuildingBlocks用XR開発ライブラリの一部で、保存された空間アンカーの読み込みおよび生成する機能を提供します。
SpatialAnchorLocalStorageManagerBuildingBlock Meta社のBuildingBlocks用XR開発ライブラリの一部で、空間アンカーのUUIDをローカルストレージに保存および管理するための機能を提供します。

3.1.1. コンポーネントの関係

SpatialAnchorCoreBuildingBlockに実装されている空間アンカーのAPIを利用する実装例として、SpatialAnchorSpawnerBuildingBlockSpatialAnchorLoaderBuildingBlockSpatialAnchorLocalStorageManagerBuildingBlockが提供されています。

3.2. 共有空間アンカーAPIコンポーネントの追加

Building Blocksで「Shared Spatial Anchor Core」を選択することで、共有空間アンカーのAPIコンポーネントを追加できます。

メニュー Hierarchy
buildingblocks01.png buildingblocks02.png

3.2.1. コンポーネントの概要

GameObject コンポーネント 概要
[BuildingBlock] Shared Spatial Anchor Core SharedSpatialAnchorCore Meta社のBuildingBlocks用XR開発ライブラリの一部で、SpatialAnchorCoreBuildingBlockクラスを継承し、空間アンカーの共有機能を提供します。

3.2.2. コンポーネントの関係

SharedSpatialAnchorCoreは、SpatialAnchorCoreBuildingBlockクラスを継承しており、空間アンカーの生成、保存、読み込み、消去の機能を拡張(override)、もしくは非表示(new)にしています。

3.3. 共有空間アンカーAPIの利用方法

アプリの仕様に合わせてSpatialAnchorSpawnerBuildingBlockSpatialAnchorLoaderBuildingBlockSpatialAnchorLocalStorageManagerBuildingBlockに類似した機能を実装し、各ジョブ完了時に発生するイベントに対して処理を追加する形で利用します。

イベント 概要
OnAnchorCreateCompleted(OVRSpatialAnchor, OVRSpatialAnchor.OperationResult) 空間アンカーの生成が完了した際に発生するイベント
OnAnchorsLoadCompleted(List<OVRSpatialAnchor>) 空間アンカーの読み込みが完了した際に発生するイベント
OnAnchorsEraseAllCompleted(OVRSpatialAnchor.OperationResult) 空間アンカーの全消去が完了した際に発生するイベント
OnAnchorEraseCompleted(OVRSpatialAnchor, OVRSpatialAnchor.OperationResult) 空間アンカーの消去が完了した際に発生するイベント
OnSpatialAnchorsShareCompleted(List<OVRSpatialAnchor>, OVRSpatialAnchor.OperationResult) 空間アンカーの共有が完了した際に発生するイベント

4. Netcode for GameObjectsについて

本記事は共有空間アンカーが主題のため、Netcode for GammeObjectsの詳細については割愛しますが、サンプルコードで使用している基本的なコンポーネントについて説明します。

Netcode for GameObjectsはUnityのマルチプレイヤーパッケージで、サーバー・クライアントモデルを採用しており、ホストがその両方の役割を担うことも可能です。リモートプロシージャーコール(RPC)を利用してクライアントとサーバー間の同期や操作を可能にします。

4.1. 基本的なコンポーネント

  • NetworkManager: ゲームのネットワーク状態を管理し、プレイヤーの接続や設定を制御
  • NetworkBehaviour: ネットワーク同期機能を追加したMonoBehaviourを継承したクラス
  • NetworkVariable: クライアント・サーバー間で変数を同期する仕組み
  • NetworkClient: サーバーに接続されているクライアントを表し、クライアントごとの情報を管理するクラス
  • NetworkObject: ネットワーク上で同期されるオブジェクトを管理し、生成や削除、状態の同期を行うクラス
    • PlayerObject: 各プレイヤーのネットワーク上のオブジェクトで、状態や行動を同期

5. 共有空間アンカーの実装例

5.1. Oculus User IDの取得と保持

空間アンカーの共有はMetaのクラウド上でOculus User IDをキーに行われます。そのため、Oculus User IDを取得、保持する必要があります。
アプリケーション開始時に、Oculus Platform SDKを初期化し、ログイン中のユーザー情報を取得します。

5.1.1. Oculus Platform SDKの初期化とログイン情報の取得

NetcodeAnchorManager
private void Start()
{
    Core.AsyncInitialize();
    Users.GetLoggedInUser().OnComplete(GetLoggedInUserCallback);
}

5.1.2. ログイン中のユーザー情報の保持

NetcodeAnchorManager
public string OculusUsername { get; private set;}
public ulong OculusUserId { get; private set;}

private void GetLoggedInUserCallback(Message msg)
{
    if (msg.IsError)
    {
        Debug.Log($"<<<{nameof(NetcodeAnchorManager)}>>> GetLoggedInUserCallback: failed with error: {msg.GetError().Message}");
        return;
    }
    Debug.Log($"<<<{nameof(NetcodeAnchorManager)}>>> GetLoggedInUserCallback: success with message: {msg.GetString()} type: {msg.Type}");

    bool isLoggedInUserMessage = msg.Type == Message.MessageType.User_GetLoggedInUser;
    if (!isLoggedInUserMessage) return;
    
    // ログイン中のユーザー情報を保持
    OculusUsername = msg.GetUser().OculusID;
    OculusUserId = msg.GetUser().ID;
    Debug.Log($"<<<{nameof(NetcodeAnchorManager)}>>> GetLoggedInUserCallback: oculus user name: {OculusUsername} oculus user id: {OculusUserId}");
}

5.2. Oculus User IDをサーバーとクライアントで同期

ユーザーがサーバーに接続した際に、Oculus User IDをPlayerObjectのコンポーネントに保存します。これにより、空間アンカーの共有時にOculus User IDを利用することができます。

PlayerController
// Oculus Platform SDKのユーザー名を保存するための変数
private NetworkVariable<FixedString64Bytes> _oculusUsername = new(
    new FixedString64Bytes(""),
    NetworkVariableReadPermission.Everyone,
    NetworkVariableWritePermission.Owner
);
// Oculus Platform SDKのユーザーIDを保存するための変数
private NetworkVariable<ulong> _oculusUserId = new(
    0,
    NetworkVariableReadPermission.Everyone,
    NetworkVariableWritePermission.Owner
);

private void Start()
{
    if (IsOwner)
    {
        _oculusUsername.Value = new FixedString64Bytes(_netcodeAnchorManager.OculusUsername);
        _oculusUserId.Value = _netcodeAnchorManager.OculusUserId;
    }
}

5.3. 空間アンカーの生成・配置と共有

ここでは、Hostとなるユーザーがアンカーを生成・配置し、他のユーザーに共有する実装例を紹介します。

5.3.1. 空間アンカーの生成・配置

SharedSpatialAnchorSpawner
public GameObject AnchorPrefab
{
    get => _anchorPrefab;
    set
    {
        _anchorPrefab = value;
        if (_anchorPrefabTransform)
        {
            Destroy(_anchorPrefabTransform.gameObject);
        }

        _anchorPrefabTransform = Instantiate(AnchorPrefab).transform;
        FollowHand = _followHand;
    }
}

public bool FollowHand
{
    get => _followHand;
    set
    {
        _followHand = value;
        if (_followHand)
        {
            _initialPosition = _anchorPrefabTransform.position;
            _initialRotation = _anchorPrefabTransform.rotation;
            _anchorPrefabTransform.SetParent(_cameraRig.rightControllerAnchor);
            _anchorPrefabTransform.localPosition = Vector3.zero;
            _anchorPrefabTransform.localRotation = Quaternion.identity;
        }
        else
        {
            _anchorPrefabTransform.SetParent(null);
            _anchorPrefabTransform.SetPositionAndRotation(_initialPosition, _initialRotation);
        }
    }
}

[SerializeField] private SharedSpatialAnchorCore _sharedSpatialAnchorCore;

[SerializeField] private GameObject _anchorPrefab;
[SerializeField] private bool _followHand = true;

private Transform _anchorPrefabTransform;
private Vector3 _initialPosition;
private Quaternion _initialRotation;

public void SpawnSharedSpatialAnchor()
{
    Vector3 position = _anchorPrefabTransform.position;
    Quaternion rotation = _anchorPrefabTransform.rotation;

    if (!FollowHand)
    {
        position = AnchorPrefab.transform.position;
        rotation = AnchorPrefab.transform.rotation;
    }

    _sharedSpatialAnchorCore.InstantiateSpatialAnchor(AnchorPrefab, position, rotation);
}

5.3.2. 空間アンカーの共有許可

空間アンカーの生成が完了したタイミングで、Metaのクラウドに対して、指定したユーザーが空間アンカーを取得できるように許可を行います。

SharedSpatialAnchorController
[SerializeField] private SharedSpatialAnchorCore _sharedSpatialAnchorCore;
[SerializeField] private NetcodeAnchorManager _netcodeAnchorManager;

private void OnAnchorCreateCompletedHandler(OVRSpatialAnchor anchor, OVRSpatialAnchor.OperationResult result)
{
    if (result != OVRSpatialAnchor.OperationResult.Success)
    {
        Debug.Log($"<<<{nameof(SharedSpatialAnchorController)}>>> OnAnchorCreateCompletedHandler: failed with result: {result}");
        return;
    }
    Debug.Log($"<<<{nameof(SharedSpatialAnchorController)}>>> OnAnchorCreateCompletedHandler: success with result: {result}");
    
    List<OVRSpaceUser> spaceUserList = _netcodeAnchorManager.GetListOfSpaceUsers();
    if (spaceUserList.Count == 0)
    {
        Debug.Log($"<<<{nameof(SharedSpatialAnchorController)}>>> ShareSpatialAnchor: no clients to share anchor with.");
        return;
    }
    
    List<OVRSpatialAnchor> spatialAnchorList = GetListOfSpatialAnchors();
    if (spatialAnchorList.Count == 0)
    {
        Debug.Log($"<<<{nameof(SharedSpatialAnchorController)}>>> ShareSpatialAnchor: no anchors to share.");
        return;
    }
    
    _sharedSpatialAnchorCore.ShareSpatialAnchors(spatialAnchorList, spaceUserList);
}

5.3.3. 空間アンカーの共有

空間アンカーの共有許可が完了したタイミングで、他のユーザーに空間アンカーの情報を送信します。
正確には、ClientのRPCを利用して、クライアント側で空間アンカーの情報を処理します。

SharedSpatialAnchorController
[SerializeField] private NetcodeAnchorManager _netcodeAnchorManager;

private void OnSpatialAnchorsShareCompletedHandler(List<OVRSpatialAnchor> anchors, OVRSpatialAnchor.OperationResult result)
{
    if (result != OVRSpatialAnchor.OperationResult.Success)
    {
        Debug.Log($"<<<{nameof(SharedSpatialAnchorController)}>>> OnSpatialAnchorsShareCompletedHandler: failed with result: {result}");
        return;
    }
    Debug.Log($"<<<{nameof(SharedSpatialAnchorController)}>>> OnSpatialAnchorsShareCompletedHandler: success with result: {result}");
    
    _netcodeAnchorManager.SendSpatialAnchorsToClients(anchors);
}
NetcodeAnchorManager
private NetworkManager _networkManager;

private void Start()
{
    _networkManager = NetworkManager.Singleton;
}

/// <summary>
/// ホストがクライアントに共有するSharedSpatialAnchorのリストを送信
/// </summary>
/// <param name="anchors"></param>
public void SendSpatialAnchorsToClients(List<OVRSpatialAnchor> anchors)
{
    if (!_networkManager.IsHost) return;
    
    List<Guid> sharedSpatialAnchorUuids = anchors.Select(anchor => anchor.Uuid).ToList();
    string sharedSpatialAnchorUuidsString = string.Join(",", sharedSpatialAnchorUuids.Select(uuid => uuid.ToString()).ToArray());
    Debug.Log($"<<<{nameof(NetcodeAnchorManager)}>>> SendSharedSpatialAnchorUuidsToClients: {sharedSpatialAnchorUuidsString}");

    ulong clientId = _networkManager.LocalClientId;
    NetworkClient client = _networkManager.ConnectedClients[clientId];
    
    SharedSpatialAnchorClientController sharedSpatialAnchorClientControllerController = client.PlayerObject.GetComponent<SharedSpatialAnchorClientController>();
    if (sharedSpatialAnchorClientControllerController == null)
    {
        Debug.Log($"<<<{nameof(NetcodeAnchorManager)}>>> SharedSpatialAnchorClientController not found.");
        return;
    }
    sharedSpatialAnchorClientControllerController.SendAnchorUuidsClientRpc(sharedSpatialAnchorUuidsString);
}
SharedSpatialAnchorClientController
/// <summary>
/// クライアントに空間アンカーのUUIDを受け渡すClientRpc
/// </summary>
/// <param name="uuids"></param>
[ClientRpc]
public void SendAnchorUuidsClientRpc(string uuids)
{
    if (IsHost) return;
    
    Debug.Log($"<<<{nameof(SharedSpatialAnchorClientController)}>>> SendAnchorsUuidsClientRpc: {uuids}");
    ProcessReceivedAnchorUuids(uuids);
}

5.3.4. 空間アンカーの生成・配置(クライアント)

クライアント側で、共有された空間アンカーリストを受信し、配置します。

SharedSpatialAnchorClientController
private SharedSpatialAnchorLoader _sharedSpatialAnchorLoader;

private void Start()
{
    _sharedSpatialAnchorLoader = FindObjectOfType<SharedSpatialAnchorLoader>();
    if (_sharedSpatialAnchorLoader == null)
    {
        Debug.Log($"<<<{nameof(SharedSpatialAnchorClientController)}>>> SharedSpatialAnchorLoader not found.");
        return;
    }
}

private void ProcessReceivedAnchorUuids(string uuids)
{
    List<Guid> anchorUuidsList = uuids.Split(',').Select(Guid.Parse).ToList();
    LoadAnchorFromCloud(anchorUuidsList);
}

private void LoadAnchorFromCloud(List<Guid> uuidsList)
{
    _sharedSpatialAnchorLoader.LoadAnchorFromCloud(uuidsList);
}

6. ソースコード全文

参考までに、5.で紹介した実装例のソースコード全文を記載します。

6.1. NetcodeAnchorManager

OculusプラットフォームとNetcode for GameObjectsを使用して、Oculusユーザー情報の取得、空間アンカーのクライアント間での共有を行うクラスです。

NetcodeAnchorManager
using System;
using System.Collections.Generic;
using System.Linq;
using Oculus.Platform;
using Unity.Netcode;
using UnityEngine;
using UnityEngine.Assertions;

namespace b0bmat0ca.Netcode
{
    public class NetcodeAnchorManager : MonoBehaviour
    {
        public string OculusUsername { get; private set;}
        public ulong OculusUserId { get; private set;}
        
        [SerializeField] private OVRCameraRig _cameraRig;
        [SerializeField] private NetcodeControlPanel _netcodeControlPanel;
        
        private NetworkManager _networkManager;
        
        private void Start()
        {
            Assert.IsNotNull(_cameraRig, $"<<<{nameof(NetcodeAnchorManager)}>>> Camera rig is not set.");
            Assert.IsNotNull(_netcodeControlPanel, $"<<<{nameof(NetcodeAnchorManager)}>>> Netcode control panel is not set.");
            
            _netcodeControlPanel.ShowInitialPanel();
            _netcodeControlPanel.transform.SetParent(_cameraRig.leftHandAnchor);
            
            _networkManager = NetworkManager.Singleton;
            
            Debug.Log($"<<<{nameof(NetcodeAnchorManager)}>>> System version: {OVRPlugin.version}");
            
            // Oculus Platform SDKの初期化とログイン情報の取得
            Core.AsyncInitialize();
            Users.GetLoggedInUser().OnComplete(GetLoggedInUserCallback);
        }
        
        /// <summary>
        /// 接続中のクライアントリストからOculusユーザーIDを取得
        /// </summary>
        /// <returns></returns>
        public List<OVRSpaceUser> GetListOfSpaceUsers()
        {
            if (!_networkManager.IsHost) return null;
            
            List<OVRSpaceUser> spaceUserList = new List<OVRSpaceUser>();
            foreach (NetworkClient client in _networkManager.ConnectedClientsList)
            {
                PlayerController playerController = client.PlayerObject.GetComponent<PlayerController>();
                if (playerController == null)
                {
                    Debug.Log($"<<<{nameof(NetcodeAnchorManager)}>>> PlayerController not found.");
                    continue;
                }
                spaceUserList.Add(new OVRSpaceUser(playerController.OculusUserId));
            }
            
            return spaceUserList;
        }
        
        /// <summary>
        /// ホストがクライアントに共有するSharedSpatialAnchorのリストを送信
        /// </summary>
        /// <param name="anchors"></param>
        public void SendSpatialAnchorsToClients(List<OVRSpatialAnchor> anchors)
        {
            if (!_networkManager.IsHost) return;
            
            List<Guid> sharedSpatialAnchorUuids = anchors.Select(anchor => anchor.Uuid).ToList();
            string sharedSpatialAnchorUuidsString = string.Join(",", sharedSpatialAnchorUuids.Select(uuid => uuid.ToString()).ToArray());
            Debug.Log($"<<<{nameof(NetcodeAnchorManager)}>>> SendSharedSpatialAnchorUuidsToClients: {sharedSpatialAnchorUuidsString}");

            ulong clientId = _networkManager.LocalClientId;
            NetworkClient client = _networkManager.ConnectedClients[clientId];
            
            SharedSpatialAnchorClientController sharedSpatialAnchorClientControllerController = client.PlayerObject.GetComponent<SharedSpatialAnchorClientController>();
            if (sharedSpatialAnchorClientControllerController == null)
            {
                Debug.Log($"<<<{nameof(NetcodeAnchorManager)}>>> SharedSpatialAnchorClientController not found.");
                return;
            }
            sharedSpatialAnchorClientControllerController.SendAnchorUuidsClientRpc(sharedSpatialAnchorUuidsString);
        }
        
        private void GetLoggedInUserCallback(Message msg)
        {
            if (msg.IsError)
            {
                Debug.Log($"<<<{nameof(NetcodeAnchorManager)}>>> GetLoggedInUserCallback: failed with error: {msg.GetError().Message}");
                return;
            }
            Debug.Log($"<<<{nameof(NetcodeAnchorManager)}>>> GetLoggedInUserCallback: success with message: {msg.GetString()} type: {msg.Type}");

            bool isLoggedInUserMessage = msg.Type == Message.MessageType.User_GetLoggedInUser;
            if (!isLoggedInUserMessage) return;
            
            // ログイン中のユーザー情報を保持
            OculusUsername = msg.GetUser().OculusID;
            OculusUserId = msg.GetUser().ID;
            Debug.Log($"<<<{nameof(NetcodeAnchorManager)}>>> GetLoggedInUserCallback: oculus user name: {OculusUsername} oculus user id: {OculusUserId}");
        }
    }
}

6.2. PlayerController

Oculusユーザー情報を保持し、クライアントがサーバーに接続した際にOculusユーザー情報を同期するクラスです。

PlayerController
using Unity.Collections;
using Unity.Netcode;
using UnityEngine;

namespace b0bmat0ca.Netcode
{
    public class PlayerController : NetworkBehaviour
    {
        public string OculusUsername => _oculusUsername.Value.ToString();
        public ulong OculusUserId => _oculusUserId.Value;
        
        // Oculus Platform SDKのユーザー名を保存するための変数
        private NetworkVariable<FixedString64Bytes> _oculusUsername = new(
            new FixedString64Bytes(""),
            NetworkVariableReadPermission.Everyone,
            NetworkVariableWritePermission.Owner
        );
        // Oculus Platform SDKのユーザーIDを保存するための変数
        private NetworkVariable<ulong> _oculusUserId = new(
            0,
            NetworkVariableReadPermission.Everyone,
            NetworkVariableWritePermission.Owner
        );
        
        private OVRCameraRig _cameraRig;
        private NetcodeAnchorManager _netcodeAnchorManager;
        private NetcodeControlPanel _netcodeControlPanel;
        
        private void Start()
        {
            _cameraRig = FindObjectOfType<OVRCameraRig>();
            if (_cameraRig == null)
            {
                Debug.Log($"<<<{nameof(PlayerController)}>>> OVRCameraRig not found.");
                return;
            }
            
            _netcodeAnchorManager = FindObjectOfType<NetcodeAnchorManager>();
            if (_netcodeAnchorManager == null)
            {
                Debug.Log($"<<<{nameof(PlayerController)}>>> NetcodeAnchorManager not found.");
                return;
            }
            
            _netcodeControlPanel = FindObjectOfType<NetcodeControlPanel>();
            if (_netcodeControlPanel == null)
            {
                Debug.Log($"<<<{nameof(PlayerController)}>>> NetcodeControlPanel not found.");
                return;
            }
            
            if (IsOwner)
            {
                _oculusUsername.Value = new FixedString64Bytes(_netcodeAnchorManager.OculusUsername);
                _oculusUserId.Value = _netcodeAnchorManager.OculusUserId;
                Debug.Log($"<<<{nameof(PlayerController)}>>> Start: oculus username: {_oculusUsername.Value} oculus user id: {_oculusUserId.Value}");
                
                _netcodeControlPanel.gameObject.SetActive(false);

                if (!IsHost)
                {
                    NotifyClientConnectedServerRpc();
                }
            }

            if (IsHost)
            {
                _netcodeControlPanel.ShowConnectedPanel();
                _netcodeControlPanel.gameObject.SetActive(true);
            }
        }

        [ServerRpc]
        private void NotifyClientConnectedServerRpc()
        {
            Debug.Log($"<<<{nameof(PlayerController)}>>> NotifyClientConnectedServerRpc: client id: {OwnerClientId}");
        }
    }
}

6.3. SharedSpatialAnchorController

共有空間アンカーAPIからのイベントを受信し、処理を行うクラスです。

SharedSpatialAnchorController
using System.Collections.Generic;
using System.Linq;
using b0bmat0ca.Netcode;
using Meta.XR.BuildingBlocks;
using Unity.Netcode;
using UnityEngine;
using UnityEngine.Assertions;

namespace b0bmat0ca.SSA
{
    public class SharedSpatialAnchorController : MonoBehaviour
    {
        [SerializeField] private SharedSpatialAnchorCore _sharedSpatialAnchorCore;
        [SerializeField] private NetcodeAnchorManager _netcodeAnchorManager;
        [SerializeField] private AlignmentPlayer _alignmentPlayer;
        [SerializeField] private NetcodeControlPanel _netcodeControlPanel;
        
        private NetworkManager _networkManager;

        private void Start()
        {
            Assert.IsNotNull(_sharedSpatialAnchorCore, $"<<<{nameof(SharedSpatialAnchorController)}>>> Shared spatial anchor core is not set.");
            Assert.IsNotNull(_netcodeAnchorManager, $"<<<{nameof(SharedSpatialAnchorController)}>>> Netcode anchor manager is not set.");
            
            _sharedSpatialAnchorCore.OnAnchorCreateCompleted.AddListener(OnAnchorCreateCompletedHandler);
            _sharedSpatialAnchorCore.OnAnchorsLoadCompleted.AddListener(OnAnchorsLoadCompletedHandler);
            _sharedSpatialAnchorCore.OnSpatialAnchorsShareCompleted.AddListener(OnSpatialAnchorsShareCompletedHandler);
            _sharedSpatialAnchorCore.OnAnchorsEraseAllCompleted.AddListener(OnAnchorsEraseAllCompletedHandler);
            
            _networkManager = NetworkManager.Singleton;
        }
        
        private void OnAnchorCreateCompletedHandler(OVRSpatialAnchor anchor, OVRSpatialAnchor.OperationResult result)
        {
            if (result != OVRSpatialAnchor.OperationResult.Success)
            {
                Debug.Log($"<<<{nameof(SharedSpatialAnchorController)}>>> OnAnchorCreateCompletedHandler: failed with result: {result}");
                return;
            }
            Debug.Log($"<<<{nameof(SharedSpatialAnchorController)}>>> OnAnchorCreateCompletedHandler: success with result: {result}");
            
            List<OVRSpaceUser> spaceUserList = _netcodeAnchorManager.GetListOfSpaceUsers();
            if (spaceUserList.Count == 0)
            {
                Debug.Log($"<<<{nameof(SharedSpatialAnchorController)}>>> ShareSpatialAnchor: no clients to share anchor with.");
                return;
            }
            
            List<OVRSpatialAnchor> spatialAnchorList = GetListOfSpatialAnchors();
            if (spatialAnchorList.Count == 0)
            {
                Debug.Log($"<<<{nameof(SharedSpatialAnchorController)}>>> ShareSpatialAnchor: no anchors to share.");
                return;
            }
            
            _sharedSpatialAnchorCore.ShareSpatialAnchors(spatialAnchorList, spaceUserList);
        }
        
        private void OnAnchorsLoadCompletedHandler(List<OVRSpatialAnchor> anchors)
        {
            Debug.Log($"<<<{nameof(SharedSpatialAnchorController)}>>> OnAnchorsLoadCompletedHandler: loaded {anchors.Count} anchors.");
            
            if (_networkManager.IsHost)
            {
                _netcodeControlPanel.gameObject.SetActive(false);
                _netcodeAnchorManager.SendSpatialAnchorsToClients(anchors);
            }
            
            _alignmentPlayer.SetAligmentAnchor(anchors[0]);
            
            _netcodeControlPanel.ShowSharedPanel();
            _netcodeControlPanel.gameObject.SetActive(true);
        }
        
        private void OnSpatialAnchorsShareCompletedHandler(List<OVRSpatialAnchor> anchors, OVRSpatialAnchor.OperationResult result)
        {
            if (result != OVRSpatialAnchor.OperationResult.Success)
            {
                Debug.Log($"<<<{nameof(SharedSpatialAnchorController)}>>> OnSpatialAnchorsShareCompletedHandler: failed with result: {result}");
                return;
            }
            Debug.Log($"<<<{nameof(SharedSpatialAnchorController)}>>> OnSpatialAnchorsShareCompletedHandler: success with result: {result}");
            
            _netcodeAnchorManager.SendSpatialAnchorsToClients(anchors);
            _alignmentPlayer.SetAligmentAnchor(anchors[0]);
            
            _netcodeControlPanel.ShowSharedPanel();
            _netcodeControlPanel.gameObject.SetActive(true);
        }
        
        private void OnAnchorsEraseAllCompletedHandler(OVRSpatialAnchor.OperationResult result)
        {
            if (result != OVRSpatialAnchor.OperationResult.Success)
            {
                Debug.Log($"<<<{nameof(SharedSpatialAnchorController)}>>> OnAnchorsEraseAllCompletedHandler: failed with result: {result}");
                return;
            }
            Debug.Log($"<<<{nameof(SharedSpatialAnchorController)}>>> OnAnchorsEraseAllCompletedHandler: success with result: {result}");
        }
        
        private List<OVRSpatialAnchor> GetListOfSpatialAnchors()
        {
            OVRSpatialAnchor[] spatialAnchors = FindObjectsOfType<OVRSpatialAnchor>();

            return spatialAnchors.ToList();
        }
    }
}

6.4. SharedSpatialAnchorClientController

空間アンカーの共有情報を受信し、空間アンカーを読み込むクラスです。

SharedSpatialAnchorClientController
using System;
using System.Collections.Generic;
using System.Linq;
using b0bmat0ca.SSA;
using Unity.Netcode;
using UnityEngine;

namespace b0bmat0ca.Netcode
{
    public class SharedSpatialAnchorClientController : NetworkBehaviour
    {
        private SharedSpatialAnchorSpawner _sharedSpatialAnchorSpawner;
        private SharedSpatialAnchorLoader _sharedSpatialAnchorLoader;
        
        private void Start()
        {
            _sharedSpatialAnchorSpawner = FindObjectOfType<SharedSpatialAnchorSpawner>();
            if (_sharedSpatialAnchorSpawner == null)
            {
                Debug.Log($"<<<{nameof(SharedSpatialAnchorClientController)}>>> SharedSpatialAnchorSpawner not found.");
                return;
            }
            
            _sharedSpatialAnchorLoader = FindObjectOfType<SharedSpatialAnchorLoader>();
            if (_sharedSpatialAnchorLoader == null)
            {
                Debug.Log($"<<<{nameof(SharedSpatialAnchorClientController)}>>> SharedSpatialAnchorLoader not found.");
                return;
            }

            if (IsHost)
            {
                _sharedSpatialAnchorSpawner.StartPlacementMode();
            }
        }
        
        /// <summary>
        /// クライアントに空間アンカーのUUIDを受け渡すClientRpc
        /// </summary>
        /// <param name="uuids"></param>
        [ClientRpc]
        public void SendAnchorUuidsClientRpc(string uuids)
        {
            if (IsHost) return;
            
            Debug.Log($"<<<{nameof(SharedSpatialAnchorClientController)}>>> SendAnchorsUuidsClientRpc: {uuids}");
            ProcessReceivedAnchorUuids(uuids);
        }
        
        private void ProcessReceivedAnchorUuids(string uuids)
        {
            List<Guid> anchorUuidsList = uuids.Split(',').Select(Guid.Parse).ToList();
            LoadAnchorFromCloud(anchorUuidsList);
        }

        private void LoadAnchorFromCloud(List<Guid> uuidsList)
        {
            _sharedSpatialAnchorLoader.LoadAnchorFromCloud(uuidsList);
        }
    }
}

7. おわりに

Building Blocksの進化は目覚ましく、今後もさらなる機能拡張が期待されます。本記事で解説したマルチプレイ機能と空間アンカー機能の組み合わせも最新のSDKで可能となっています。[2]
今後のMeta Questの技術進化に注目です。

書いた人

ボブ

高野 剛

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

X

MESON Works

MESONの制作実績一覧もあります。ご興味ある方はぜひ見てみてください。

MESON Works

脚注
  1. 公式サンプルではPhoton PUN2を利用していますが、Netcode for GameObjectsで試しています。 ↩︎

  2. https://developer.oculus.com/documentation/unity/bb-multiplayer-blocks ↩︎

Discussion

クロマクロマ

興味深く読ませていただきました。
私は今個人的にUnityを勉強中なのですが、MetaQuest3で最近マルチプレイのMR共有アプリ(といっても単に簡単なオブジェクトを互いに表示するレベルです)を試作したときに、ユーザ間のオブジェクト位置が合わずにそのまま放置していた中で、この記事を見つけ修正する意欲が湧きました。
ただNetcode for GameObjectsではなく、MirrorNetworking、しかもMRTKを使っているのですが、
Shared Spatial AnchorというのはMirrorNetworkingでも使えるのでしょうか?
また、この記事のプロジェクトファイルをどこかでダウンロードすることは可能でしょうか?(可能でしたら勉強用途で確認したいなあと思いました。)
あまりよくわかっておらず、専門家のご意見がうかがいたいです。
よろしくお願いいたします

Bob MatocaBob Matoca

確認頂きありがとうございます!
結論として、MirrorNetworkingを利用した場合でも Shared Spatial Anchorは動作します。
ただ、MRTKで動作するかは確認が必要です。

Metaが提供しているShared Spatial Anchorは、以下の様に動作しています。

  1. 空間Anchorを生成したユーザーが、Metaのクラウドに空間Anchorの情報と共有対象のOculus ユーザーIDを送信
  2. 空間Anchorを生成したユーザーが、空間Anchorの一意のID(uuid)を全クライアントに対して、送信(RPC等)
  3. 各クライアントが、Metaのクラウドから、uuidを利用して空間アンカーを取得、各自の環境のローカルマップにおける同じ位置の座標に空間Anchorを生成
  4. 空間Anchorを利用して、各カメラ(Player)の位置を更新し、サーバー上で同期するオブジェクトが同じ位置関係で表示される様に整列

そのため、MirrorNetworkingなど、マルチプレイのネットワークソリューションに影響するものではないです。

MRTKで動作するかについては、5.1.1のログイン情報が取得できるかという点がポイントになるかと思います。
Meta XR Platform SDKを導入して、5.1.1の処理を参考にログイン情報が取得可能か確認いただけますでしょうか。
※ MRTKのバージョンがわからないのですが、Meta XR Core SDKは既に導入しているのではと推測しています。

プロジェクトは公開していないのですが、Metaが提供しているプログラムソースを複製して修正しているソースコード以外は、全文掲載していますので、この記事と前段の記事を参考に環境構築いただければ、近しい環境は作れると思います。
Metaが提供しているプログラムソースを複製・修正している部分は権利関係があるため、公開していないことご了承いただければと思います。