基于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"; }
场景截图

问题分析与修复方案
核心问题点
- 锚点应用逻辑不严谨:未判断锚点定位的成功状态,可能将无效锚点绑定到对象;重复添加
CloudNativeAnchor组件导致冲突。 - 坐标同步层级错误:共享对象未绑定到锚点对象的子层级,导致锚点对齐后无法同步空间坐标。
- 锚点绑定后未锁定变换:锚点应用后未重置对象的父级,导致设备本地参考系仍影响对象位置。
具体修改步骤
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
相关产品推荐
相关产品推荐

