You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

基于ARFoundation/Azure空间锚点的Hololens 2双设备空间对齐问题

HoloLens 2共享坐标系实现问题排查与修复

我正在学习相关教程,目标是在Unity 2020环境下,使用PUN2、ARFoundation和MRTK实现两台HoloLens 2的共享坐标系。原教程采用较旧的WSA平台WorldAnchors,我将其修改为使用ARFoundation。目前两台设备可通过PUN2正常通信,但教程中的蓝色立方体无法在两台设备上对齐,立方体仅以各自设备启动时的参考帧为基准。

根据调试日志,第一台设备可成功创建并将锚点保存到Azure,第二台设备也能找到该锚点,但显然缺少必要的设备间坐标转换步骤。以下是我修改后的代码,AzureSpatialAnchorService.cs和PhotonScript.cs均挂载在场景中的根游戏对象上,场景截图已附上。请指出代码中的问题,以及需要进行哪些具体修改才能实现两台设备的空间对齐?


AzureSpatialAnchorService.cs

using Microsoft.Azure.SpatialAnchors.Unity;
using Microsoft.MixedReality.Toolkit.Utilities;
using System;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.XR.ARFoundation;
using UnityEngine.XR.WSA;

namespace AzureSpatialAnchors
{
    [RequireComponent(typeof(SpatialAnchorManager))]
    public class AzureSpatialAnchorService : MonoBehaviour
    {
        [Serializable]
        public class AzureSpatialAnchorServiceProfile
        {
            [SerializeField]
            [Tooltip("Azure空间锚点服务的账号ID")]
            string azureAccountId;
            public string AzureAccountId => this.azureAccountId;

            [SerializeField]
            [Tooltip("Azure空间锚点服务的访问密钥")]
            string azureServiceKey;
            public string AzureServiceKey => this.azureServiceKey;
        }

        [SerializeField]
        [Tooltip("Azure空间锚点服务配置")]
        AzureSpatialAnchorServiceProfile profile = new AzureSpatialAnchorServiceProfile();
        public AzureSpatialAnchorServiceProfile Profile => this.profile;

        TaskCompletionSource<CloudSpatialAnchor> taskWaitForAnchorLocation;

        private SpatialAnchorManager _spatialAnchorManager = null;

        public async Task<string> CreateAnchorOnObjectAsync(GameObject gameObjectForAnchor)
        {
            string anchorId = string.Empty;
            try
            {
                await this.StartSession();

                Debug.Log("已启动ASA会话");

                // 添加并配置ASA组件
                CloudNativeAnchor cloudNativeAnchor = gameObjectForAnchor.AddComponent<CloudNativeAnchor>();
                await cloudNativeAnchor.NativeToCloud();
                Debug.Log("完成本地锚点转云锚点");
                CloudSpatialAnchor cloudSpatialAnchor = cloudNativeAnchor.CloudAnchor;
                cloudSpatialAnchor.Expiration = DateTimeOffset.Now.AddDays(3);

                // 采集环境数据,直到满足创建锚点的条件
                while (!_spatialAnchorManager.IsReadyForCreate)
                {
                    float createProgress = _spatialAnchorManager.SessionStatus.RecommendedForCreateProgress;
                    Debug.Log($"ASA - 移动设备采集更多环境数据:{createProgress:0%}");
                }

                Debug.Log($"ASA - 正在保存云锚点... ");
                await _spatialAnchorManager.CreateAnchorAsync(cloudSpatialAnchor);

                anchorId = cloudSpatialAnchor?.Identifier;

                bool saveSucceeded = cloudSpatialAnchor != null;
                if (!saveSucceeded)
                {
                    Debug.LogError("ASA - 保存锚点失败,未抛出异常");
                    return anchorId;
                }

                anchorId = cloudSpatialAnchor.Identifier;
                Debug.Log($"ASA - 云锚点保存成功,ID:{anchorId}");
            }
            catch (Exception exception)
            {
                Debug.Log("ASA - 保存锚点失败:" + exception.ToString());
                Debug.LogException(exception);
            }
            return anchorId;
        }

        public async Task<bool> PopulateAnchorOnObjectAsync(string anchorId, GameObject gameObjectForAnchor)
        {
            bool anchorLocated = false;

            try
            {
                await this.StartSession();

                this.taskWaitForAnchorLocation = new TaskCompletionSource<CloudSpatialAnchor>();

                var watcher = _spatialAnchorManager.Session.CreateWatcher(
                    new AnchorLocateCriteria()
                    {
                        Identifiers = new string[] { anchorId },
                        BypassCache = true,
                        Strategy = LocateStrategy.AnyStrategy,
                        RequestedCategories = AnchorDataCategory.Spatial
                    }
                );

                var cloudAnchor = await this.taskWaitForAnchorLocation.Task;

                anchorLocated = cloudAnchor != null;

                if (anchorLocated)
                {
                    Debug.Log("锚点已定位");
                    gameObjectForAnchor.AddComponent<CloudNativeAnchor>().CloudToNative(cloudAnchor);
                    Debug.Log("已绑定本地锚点");
                }
                watcher.Stop();
            }
            catch (Exception ex)
            {
                Debug.Log($"捕获异常:{ex.Message}");
            }
            return anchorLocated;
        }

        public async Task<bool> StartSession()
        {
            _spatialAnchorManager = GetComponent<SpatialAnchorManager>();
            _spatialAnchorManager.LogDebug += (sender, args) => Debug.Log($"ASA - 调试信息:{args.Message}");
            _spatialAnchorManager.Error += (sender, args) => Debug.LogError($"ASA - 错误:{args.ErrorMessage}");
            _spatialAnchorManager.AnchorLocated += OnAnchorLocated;
            await _spatialAnchorManager.StartSessionAsync();
            return true;
        }

        void OnLocateAnchorsCompleted(object sender, LocateAnchorsCompletedEventArgs args)
        {
            Debug.Log("锚点定位任务完成");
            Debug.Assert(this.taskWaitForAnchorLocation != null);

            if (!this.taskWaitForAnchorLocation.Task.IsCompleted)
            {
                this.taskWaitForAnchorLocation.TrySetResult(null);
            }
        }

        void OnAnchorLocated(object sender, AnchorLocatedEventArgs args)
        {
            Debug.Log($"锚点定位回调,状态:{args.Status},锚点ID:{args.Anchor?.Identifier},本地锚点:{args.Anchor?.LocalAnchor}");
            Debug.Assert(this.taskWaitForAnchorLocation != null);

            this.taskWaitForAnchorLocation.SetResult(args.Anchor);
        }

        void OnCloudSessionError(object sender, SessionErrorEventArgs args)
        {
            Debug.Log($"ASA会话错误:{args.ErrorMessage}");
        }

        void ThrowOnBadAuthConfiguration()
        {
            if (string.IsNullOrEmpty(this.Profile.AzureAccountId) ||
                string.IsNullOrEmpty(this.Profile.AzureServiceKey))
            {
                throw new ArgumentNullException("缺少Azure服务的必要配置信息");
            }
        }
    }
}

PhotonScript.cs

using System;
using System.Threading.Tasks;
using AzureSpatialAnchors;
using ExitGames.Client.Photon;
using Photon.Pun;
using Photon.Realtime;

public class PhotonScript : MonoBehaviourPunCallbacks
{
    enum RoomStatus
    {
        None,
        CreatedRoom,
        JoinedRoom,
        JoinedRoomDownloadedAnchor
    }

    public int emptyRoomTimeToLiveSeconds = 120;

    RoomStatus roomStatus = RoomStatus.None;

    void Start()
    {
        PhotonNetwork.ConnectUsingSettings();
    }

    public override void OnConnectedToMaster()
    {
        base.OnConnectedToMaster();

        var roomOptions = new RoomOptions();
        roomOptions.EmptyRoomTtl = this.emptyRoomTimeToLiveSeconds * 1000;
        PhotonNetwork.JoinOrCreateRoom(ROOM_NAME, roomOptions, null);
    }

    public async override void OnJoinedRoom()
    {
        base.OnJoinedRoom();

        if (this.roomStatus == RoomStatus.None)
        {
            this.roomStatus = RoomStatus.JoinedRoom;
        }
        await this.PopulateAnchorAsync();
    }

    public async override void OnCreatedRoom()
    {
        base.OnCreatedRoom();
        this.roomStatus = RoomStatus.CreatedRoom;
        await this.CreateAnchorAsync();
    }

    async Task CreateAnchorAsync()
    {
        var anchorService = this.GetComponent<AzureSpatialAnchorService>();

        var anchorId = await anchorService.CreateAnchorOnObjectAsync(this.gameObject);

        PhotonNetwork.CurrentRoom.SetCustomProperties(
            new Hashtable()
            {
                { ANCHOR_ID_CUSTOM_PROPERTY, anchorId }
            }
        );
    }

    async Task PopulateAnchorAsync()
    {
        if (this.roomStatus == RoomStatus.JoinedRoom)
        {
            object keyValue = null;

            if (PhotonNetwork.CurrentRoom.CustomProperties.TryGetValue(
                ANCHOR_ID_CUSTOM_PROPERTY, out keyValue))
            {
                this.roomStatus = RoomStatus.JoinedRoomDownloadedAnchor;

                var anchorService = this.GetComponent<AzureSpatialAnchorService>();

                await anchorService.PopulateAnchorOnObjectAsync(
                    (string)keyValue, this.gameObject);
            }
        }
    }

    public async override void OnRoomPropertiesUpdate(Hashtable propertiesThatChanged)
    {
        base.OnRoomPropertiesUpdate(propertiesThatChanged);

        await this.PopulateAnchorAsync();
    }

    static readonly string ANCHOR_ID_CUSTOM_PROPERTY = "anchorId";
    static readonly string ROOM_NAME = "HardCodedRoomName";
}

场景截图

场景截图


问题分析与修复方案

核心问题点

  1. 锚点应用逻辑不严谨:未判断锚点定位的成功状态,可能将无效锚点绑定到对象;重复添加CloudNativeAnchor组件导致冲突。
  2. 坐标同步层级错误:共享对象未绑定到锚点对象的子层级,导致锚点对齐后无法同步空间坐标。
  3. 锚点绑定后未锁定变换:锚点应用后未重置对象的父级,导致设备本地参考系仍影响对象位置。

具体修改步骤

1. 修正AzureSpatialAnchorService中的PopulateAnchorOnObjectAsync方法

public async Task<bool> PopulateAnchorOnObjectAsync(string anchorId, GameObject gameObjectForAnchor)
{
    bool anchorLocated = false;

    try
    {
        await this.StartSession();

        this.taskWaitForAnchorLocation = new TaskCompletionSource<CloudSpatialAnchor>();

        var watcher = _spatialAnchorManager.Session.CreateWatcher(
            new AnchorLocateCriteria()
            {
                Identifiers = new string[] { anchorId },
                BypassCache = true,
                Strategy = LocateStrategy.AnyStrategy,
                RequestedCategories = AnchorDataCategory.Spatial
            }
        );

        var cloudAnchor = await this.taskWaitForAnchorLocation.Task;

        // 新增:验证锚点有效性
        if (cloudAnchor == null || cloudAnchor.LocalAnchor == null)
        {
            Debug.LogError("ASA - 获取的云锚点无效");
            watcher.Stop();
            return false;
        }

        anchorLocated = true;
        Debug.Log("锚点定位成功");

        // 移除已存在的CloudNativeAnchor,避免组件冲突
        var existingCNA = gameObjectForAnchor.GetComponent<CloudNativeAnchor>();
        if (existingCNA != null)
        {
            Destroy(existingCNA);
        }

        // 绑定云锚点到对象
        var cloudNativeAnchor = gameObjectForAnchor.AddComponent<CloudNativeAnchor>();
        cloudNativeAnchor.CloudToNative(cloudAnchor);
        Debug.Log("本地锚点绑定完成");

        // 关键:将对象脱离原有父级,确保锚点完全控制其空间位置
        gameObjectForAnchor.transform.SetParent(null, worldPositionStays: false);

        watcher.Stop();
    }
    catch (Exception ex)
    {
        Debug.Log($"捕获异常:{ex.Message}");
    }
    return anchorLocated;
}

2. 修正OnAnchorLocated事件处理逻辑

只在锚点成功定位时返回有效结果:

void OnAnchorLocated(object sender, AnchorLocatedEventArgs args)
{
    Debug.Log($"锚点定位回调,状态:{args.Status},锚点ID:{args.Anchor?.Identifier}");
    Debug.Assert(this.taskWaitForAnchorLocation != null);

    if (args.Status == LocateAnchorStatus.Located && args.Anchor != null)
    {
        this.taskWaitForAnchorLocation.SetResult(args.Anchor);
    }
    else if (args.Status == LocateAnchorStatus.NotLocated)
    {
        Debug.LogError($"ASA - 锚点 {args.Identifier} 未找到");
        this.taskWaitForAnchorLocation.SetResult(null);
    }
}

3. 调整场景层级结构

将所有需要共享的对象(如蓝色立方体)设置为挂载了锚点的根对象的子物体,确保锚点对齐后,子物体的坐标基于统一的空间参考系同步。

4. 优化PhotonScript中的锚点创建逻辑

添加锚点ID有效性校验:

async Task CreateAnchorAsync()
{
    var anchorService = this.GetComponent<AzureSpatialAnchorService>();

    var anchorId = await anchorService.CreateAnchorOnObjectAsync(this.gameObject);

    if (!string.IsNullOrEmpty(anchorId))
    {
        PhotonNetwork.CurrentRoom.SetCustomProperties(
            new Hashtable()
            {
                { ANCHOR_ID_CUSTOM_PROPERTY, anchorId }
            }
        );
        Debug.Log($"锚点ID {anchorId} 已同步到房间属性");
    }
    else
    {
        Debug.LogError("锚点创建失败,无法同步房间属性");
    }
}

额外注意事项

  • 确保HoloLens设备开启空间感知,创建锚点时移动设备采集足够环境数据(等待IsReadyForCreate为true)。
  • 验证Azure空间锚点服务的账号ID和密钥配置正确,避免权限问题。
  • 测试时两台设备需处于同一物理空间,确保ASA能识别共同环境特征完成锚点对齐。

内容的提问来源于stack exchange,提问作者John Cast

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.23 14:15:31