你当前正在访问 Microsoft Azure Global Edition 技术文档网站。 如果需要访问由世纪互联运营的 Microsoft Azure 中国技术文档网站,请访问 https://docs.azure.cn

教程:有关使用 Azure 空间定位点创建新 HoloLens Unity 应用的分步说明

本教程将演示如何使用 Azure Spatial Anchor 创建新的 HoloLens Unity 应用。

先决条件

若要完成本教程,请确保做好以下准备:

  1. 电脑 - 一台运行 Windows 的电脑
  2. Visual Studio - Visual Studio 2019,装有“通用 Windows 平台开发”工作负载和“Windows 10 SDK(10.0.18362.0 或更高版本)”组件。 适用于 Visual Studio 的 C++/WinRT Visual Studio 扩展 (VSIX) 应从 Visual Studio Marketplace 安装。
  3. HoloLens - 一个启用了开发人员模式的 HoloLens 设备。 本文需要包含 Windows 2020 年 5 月 10 日更新的 HoloLens 设备。 要在 HoloLens 上更新为最新版本,请打开“设置”应用,转到“更新和安全”,然后选择“检查更新”按钮
  4. Unity - Unity 2020.3.25,包含模块“通用 Windows 平台生成支持”和“Windows 生成支持 (IL2CPP)”

创建和设置 Unity 项目

创建新项目

  1. 在“Unity 中心”,选择“新建项目”
  2. 选择“3D”
  3. 输入项目名称,并输入保存位置
  4. 选择“创建项目”并等待 Unity 创建项目

更改生成平台

  1. 在 Unity 编辑器中,选择“文件”>“生成设置”
  2. 依次选择“通用 Windows 平台”、“切换平台”。 等待 Unity 处理完所有文件。

导入 ASA 和 OpenXR

  1. 启动混合现实功能工具
  2. 选择你的项目路径(包含 Assets、Packages、ProjectSettings 等子文件夹的文件夹),然后选择“发现功能”
  3. 在“Azure 混合现实”服务下,选择以下两项
    1. Azure 空间定位点 SDK 核心版
    2. 适用于 Windows 的 Azure 空间定位点 SDK
  4. 在“平台支持”下,选择
    1. 混合现实 OpenXR 插件

注意

确保已刷新目录,并为每一项选择最新版本

MRFT - Feature Selection

  1. 按“获取功能”-->“导入”-->“审批”-->“退出”
  2. 重新聚焦 Unity 窗口时,Unity 将开始导入模块
  3. 如果出现了有关使用新输入系统的消息,请选择“是”以重启 Unity 并启用后端。

设置项目设置

现在来设置一些 Unity 项目设置,帮助面向 Windows Holographic SDK 进行开发。

更改 OpenXR 设置

  1. 选择“文件”>“生成设置”(在完成上一步骤后可能仍未关闭)
  2. 选择“播放器设置...”
  3. 选择“XR 插件管理”
  4. 确保已选择“通用 Windows 平台设置”选项卡,并选中“OpenXR”和“Microsoft HoloLens 功能组”旁边的框
  5. 选择“OpenXR”旁边的黄色警告符号以显示所有 OpenXR 问题。
  6. 选择“全部修复”
  7. 若要修复“必须至少添加一个交互配置文件”的问题,请选择“编辑”打开“OpenXR 项目设置”。 在“交互配置文件”下选择 + 符号,然后选择“Microsoft 手动交互配置文件”Unity - OpenXR Setup

更改质量设置

  1. 选择“编辑”>“项目设置”>“质量”
  2. 在“通用 Windows 平台”徽标下的列中,选择“默认”行中的箭头,然后选择“极低”。 当“通用 Windows 平台”列和“极低”行中的框为绿色时,表明已正确应用该设置 。

设置功能

  1. 转到“编辑”>“项目设置”>“播放器”(在完成上一步骤后可能仍未关闭)。
  2. 确保已选择“通用 Windows 平台设置”选项卡
  3. 在“发布设置”配置部分,启用以下各项
    1. InternetClient
    2. InternetClientServer
    3. PrivateNetworkClientServer
    4. SpatialPerception(可能已启用)

设置主相机

  1. 在“层次结构面板”中,选择“主摄像头” 。
  2. 在“检查器”中,将其转换位置设置为“0,0,0” 。
  3. 找到“清除标志”属性,将下拉列表从“Skybox”更改为“纯色” 。
  4. 选择“背景”字段以打开颜色选取器。
  5. 将“R、G、B 和 A”设置为“0” 。
  6. 在底部选择“添加组件”,并将“跟踪姿势驱动程序”组件添加到相机Unity - Camera Setup

试试看 #1

现在应已设置好一个可部署到 HoloLens 设备的空场景。 若要测试一切设置是否有效,请在“Unity”中生成应用,并从“Visual Studio”进行部署 。 按照使用 Visual Studio 进行部署和调试执行此操作。 应会显示 Unity 启动屏幕,然后是清晰的显示屏。

创建空间定位点资源

转到 Azure 门户

在左窗格中,选择“创建资源”。

使用搜索框以搜索“空间定位点”。

Screenshot showing the results of a search for Spatial Anchors.

选择“空间定位点”,然后选择“创建” 。

在“空间定位点帐户”窗格中,执行以下操作:

  • 使用常规字母数字字符输入唯一的资源名称。

  • 选择想要将资源附加到的订阅。

  • 选择“新建”可创建资源组。 将其命名为 myResourceGroup,然后选择“确定” 。

    资源组是在其中部署和管理 Azure 资源(例如 Web 应用、数据库和存储帐户)的逻辑容器。 例如,可以选择在使用完之后通过一个简单的步骤删除整个资源组。

  • 选择可在其中放置资源的位置(区域)。

  • 选择“创建”开始创建资源。

Screenshot of the Spatial Anchors pane for creating a resource.

创建资源后,Azure 门户显示部署已完成。

Screenshot showing that the resource deployment is complete.

选择“转到资源”。 你现在可以查看资源属性。

将资源的“帐户 ID”值复制到文本编辑器中,供稍后使用。

Screenshot of the resource properties pane.

另外,将资源的“帐户域”值复制到文本编辑器中,供稍后使用。

Screenshot showing the resource's account domain value.

在“设置”下,选择“访问密钥” 。 将“帐户密钥”的“主密钥”值复制到文本编辑器中,供稍后使用 。

Screenshot of the Keys pane for the account.

创建和添加脚本

  1. 在 Unity 的“项目”窗格中,在“Assets”文件夹中创建名为“Scripts”的新文件夹。
  2. 在该文件夹中单击右键并选择“创建”->“C# 脚本”。 将其标题指定为“AzureSpatialAnchorsScript”
  3. 转到“游戏对象”->“创建空白项”。
  4. 选择该对象,然后在“检查器”中将其从“GameObject”重命名为“AzureSpatialAnchors”。
  5. 仍在 GameObject 中操作
    1. 将其位置设置为 0,0,0
    2. 选择“添加组件”,然后搜索并添加“AzureSpatialAnchorsScript”
    3. 再次选择“添加组件”,然后搜索并添加“AR Anchor Manager”。 这也会自动添加“AR 会话来源”。
    4. 再次选择“添加组件”,然后搜索并添加“SpatialAnchorManager”脚本
    5. 在添加的“SpatialAnchorManager”组件中,填写在上一步骤中从 Azure 门户上的空间定位点资源复制的“帐户 ID”、“帐户密钥”和“帐户域”。

Unity - ASA GameObject

应用概述

我们的应用支持以下交互:

手势 操作
点击任意位置 启动/继续会话 + 在手部位置创建定位点
点击定位点 删除 GameObject + 删除 ASA 云服务中的定位点
点击并按住 2 秒(+ 会话正在运行) 停止会话并删除所有 GameObjects。 保留 ASA 云服务中的定位点
点击并按住 2 秒(+ 会话未运行) 启动会话并查找所有定位点。

添加点击识别

让我们在脚本中添加一些代码,以便能够识别用户的点击手势

  1. 在 Unity 的“项目”窗格中双击 AzureSpatialAnchorsScript.cs,在 Visual Studio 中打开该脚本。
  2. 将以下数组添加到类
public class AzureSpatialAnchorsScript : MonoBehaviour
{
    /// <summary>
    /// Used to distinguish short taps and long taps
    /// </summary>
    private float[] _tappingTimer = { 0, 0 };
  1. 在 Update() 方法下面添加以下两个方法。 在稍后的阶段我们将添加实现
// Update is called once per frame
void Update()
{
}

/// <summary>
/// Called when a user is air tapping for a short time 
/// </summary>
/// <param name="handPosition">Location where tap was registered</param>
private async void ShortTap(Vector3 handPosition)
{
}

/// <summary>
/// Called when a user is air tapping for a long time (>=2 sec)
/// </summary>
private async void LongTap()
{
}
  1. 添加以下 import 语句
using UnityEngine.XR;
  1. Update() 方法顶部添加以下代码。 这样,应用便可以识别短时间和长时间(2 秒)的手部点击手势
// Update is called once per frame
void Update()
{

    //Check for any air taps from either hand
    for (int i = 0; i < 2; i++)
    {
        InputDevice device = InputDevices.GetDeviceAtXRNode((i == 0) ? XRNode.RightHand : XRNode.LeftHand);
        if (device.TryGetFeatureValue(CommonUsages.primaryButton, out bool isTapping))
        {
            if (!isTapping)
            {
                //Stopped Tapping or wasn't tapping
                if (0f < _tappingTimer[i] && _tappingTimer[i] < 1f)
                {
                    //User has been tapping for less than 1 sec. Get hand position and call ShortTap
                    if (device.TryGetFeatureValue(CommonUsages.devicePosition, out Vector3 handPosition))
                    {
                        ShortTap(handPosition);
                    }
                }
                _tappingTimer[i] = 0;
            }
            else
            {
                _tappingTimer[i] += Time.deltaTime;
                if (_tappingTimer[i] >= 2f)
                {
                    //User has been air tapping for at least 2sec. Get hand position and call LongTap
                    if (device.TryGetFeatureValue(CommonUsages.devicePosition, out Vector3 handPosition))
                    {
                        LongTap();
                    }
                    _tappingTimer[i] = -float.MaxValue; // reset the timer, to avoid retriggering if user is still holding tap
                }
            }
        }

    }
}

添加和配置 SpatialAnchorManager

ASA SDK 提供了一个名为 SpatialAnchorManager 的简单接口用于调用 ASA 服务。 我们将它作为变量添加到 AzureSpatialAnchorsScript.cs

首先添加 import 语句

using Microsoft.Azure.SpatialAnchors.Unity;

然后声明变量

public class AzureSpatialAnchorsScript : MonoBehaviour
{
    /// <summary>
    /// Used to distinguish short taps and long taps
    /// </summary>
    private float[] _tappingTimer = { 0, 0 };

    /// <summary>
    /// Main interface to anything Spatial Anchors related
    /// </summary>
    private SpatialAnchorManager _spatialAnchorManager = null;

Start() 方法中,将该变量分配到在上一步骤中添加的组件

// Start is called before the first frame update
void Start()
{
    _spatialAnchorManager = GetComponent<SpatialAnchorManager>();
}

若要接收调试和错误日志,需要订阅不同的回调

// Start is called before the first frame update
void Start()
{
    _spatialAnchorManager = GetComponent<SpatialAnchorManager>();
    _spatialAnchorManager.LogDebug += (sender, args) => Debug.Log($"ASA - Debug: {args.Message}");
    _spatialAnchorManager.Error += (sender, args) => Debug.LogError($"ASA - Error: {args.ErrorMessage}");
}

注意

若要查看日志,请确保在从 Unity 生成项目并打开 Visual Studio 解决方案 .sln 后,选择“调试”-->“运行并调试”,并在应用仍处于运行状态时,让 HoloLens 保持连接到计算机。

启动会话

若要创建和查找定位点,首先必须启动一个会话。 调用 StartSessionAsync() 时,SpatialAnchorManager 将根据需要创建一个会话,然后启动该会话。 我们将此会话添加到 ShortTap() 方法中。

/// <summary>
/// Called when a user is air tapping for a short time 
/// </summary>
/// <param name="handPosition">Location where tap was registered</param>
private async void ShortTap(Vector3 handPosition)
{
    await _spatialAnchorManager.StartSessionAsync();
}

创建定位点

运行一个会话后,接下来可以创建定位点。 在此应用程序中,我们想要跟踪创建的定位点 GameObjects 和创建的定位点标识符(定位点 ID)。 我们在代码中添加两个列表。

using Microsoft.Azure.SpatialAnchors.Unity;
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.XR;
    /// <summary>
    /// Main interface to anything Spatial Anchors related
    /// </summary>
    private SpatialAnchorManager _spatialAnchorManager = null;

    /// <summary>
    /// Used to keep track of all GameObjects that represent a found or created anchor
    /// </summary>
    private List<GameObject> _foundOrCreatedAnchorGameObjects = new List<GameObject>();

    /// <summary>
    /// Used to keep track of all the created Anchor IDs
    /// </summary>
    private List<String> _createdAnchorIDs = new List<String>();

创建方法 CreateAnchor,用于在其参数定义的位置创建一个定位点。

using System.Threading.Tasks;
/// <summary>
/// Creates an Azure Spatial Anchor at the given position rotated towards the user
/// </summary>
/// <param name="position">Position where Azure Spatial Anchor will be created</param>
/// <returns>Async Task</returns>
private async Task CreateAnchor(Vector3 position)
{
    //Create Anchor GameObject. We will use ASA to save the position and the rotation of this GameObject.
}

由于空间定位点不仅包含位置,而且还包含旋转,因此让我们将旋转设置为在创建定位点时始终朝向 HoloLens。

/// <summary>
/// Creates an Azure Spatial Anchor at the given position rotated towards the user
/// </summary>
/// <param name="position">Position where Azure Spatial Anchor will be created</param>
/// <returns>Async Task</returns>
private async Task CreateAnchor(Vector3 position)
{
    //Create Anchor GameObject. We will use ASA to save the position and the rotation of this GameObject.
    if (!InputDevices.GetDeviceAtXRNode(XRNode.Head).TryGetFeatureValue(CommonUsages.devicePosition, out Vector3 headPosition))
    {
        headPosition = Vector3.zero;
    }

    Quaternion orientationTowardsHead = Quaternion.LookRotation(position - headPosition, Vector3.up);

}

设置所需定位点的位置和旋转后,让我们创建一个可见的 GameObject。 请注意,空间定位点不要求定位点 GameObject 对最终用户可见,因为空间定位点的主要用途是提供一个通用且持久的参考框架。 对于本教程,我们将定位点可视化为立方体。 每个定位点将初始化为一个白色立方体,而在创建过程成功后,该立方体又会变成绿色立方体。

/// <summary>
/// Creates an Azure Spatial Anchor at the given position rotated towards the user
/// </summary>
/// <param name="position">Position where Azure Spatial Anchor will be created</param>
/// <returns>Async Task</returns>
private async Task CreateAnchor(Vector3 position)
{
    //Create Anchor GameObject. We will use ASA to save the position and the rotation of this GameObject.
    if (!InputDevices.GetDeviceAtXRNode(XRNode.Head).TryGetFeatureValue(CommonUsages.devicePosition, out Vector3 headPosition))
    {
        headPosition = Vector3.zero;
    }

    Quaternion orientationTowardsHead = Quaternion.LookRotation(position - headPosition, Vector3.up);

    GameObject anchorGameObject = GameObject.CreatePrimitive(PrimitiveType.Cube);
    anchorGameObject.GetComponent<MeshRenderer>().material.shader = Shader.Find("Legacy Shaders/Diffuse");
    anchorGameObject.transform.position = position;
    anchorGameObject.transform.rotation = orientationTowardsHead;
    anchorGameObject.transform.localScale = Vector3.one * 0.1f;

}

注意

我们将使用旧版着色器,因为它已包含在默认的 Unity 生成中。 仅当手动指定或者直接在场景中添加其他着色器(例如默认着色器)时,才会包含这些着色器。 如果未包含着色器,但应用程序尝试渲染着色器,则会出现粉红色的材质。

现在让我们添加并配置空间定位点组件。 将定位点的过期时间设置为创建定位点后的 3 天。 3 天之后,将自动从云中删除该定位点。 请记得添加 import 语句

using Microsoft.Azure.SpatialAnchors;
/// <summary>
/// Creates an Azure Spatial Anchor at the given position rotated towards the user
/// </summary>
/// <param name="position">Position where Azure Spatial Anchor will be created</param>
/// <returns>Async Task</returns>
private async Task CreateAnchor(Vector3 position)
{
    //Create Anchor GameObject. We will use ASA to save the position and the rotation of this GameObject.
    if (!InputDevices.GetDeviceAtXRNode(XRNode.Head).TryGetFeatureValue(CommonUsages.devicePosition, out Vector3 headPosition))
    {
        headPosition = Vector3.zero;
    }

    Quaternion orientationTowardsHead = Quaternion.LookRotation(position - headPosition, Vector3.up);

    GameObject anchorGameObject = GameObject.CreatePrimitive(PrimitiveType.Cube);
    anchorGameObject.GetComponent<MeshRenderer>().material.shader = Shader.Find("Legacy Shaders/Diffuse");
    anchorGameObject.transform.position = position;
    anchorGameObject.transform.rotation = orientationTowardsHead;
    anchorGameObject.transform.localScale = Vector3.one * 0.1f;

    //Add and configure ASA components
    CloudNativeAnchor cloudNativeAnchor = anchorGameObject.AddComponent<CloudNativeAnchor>();
    await cloudNativeAnchor.NativeToCloud();
    CloudSpatialAnchor cloudSpatialAnchor = cloudNativeAnchor.CloudAnchor;
    cloudSpatialAnchor.Expiration = DateTimeOffset.Now.AddDays(3);

}

若要保存定位点,用户必须收集环境数据。

/// <summary>
/// Creates an Azure Spatial Anchor at the given position rotated towards the user
/// </summary>
/// <param name="position">Position where Azure Spatial Anchor will be created</param>
/// <returns>Async Task</returns>
private async Task CreateAnchor(Vector3 position)
{
    //Create Anchor GameObject. We will use ASA to save the position and the rotation of this GameObject.
    if (!InputDevices.GetDeviceAtXRNode(XRNode.Head).TryGetFeatureValue(CommonUsages.devicePosition, out Vector3 headPosition))
    {
        headPosition = Vector3.zero;
    }

    Quaternion orientationTowardsHead = Quaternion.LookRotation(position - headPosition, Vector3.up);

    GameObject anchorGameObject = GameObject.CreatePrimitive(PrimitiveType.Cube);
    anchorGameObject.GetComponent<MeshRenderer>().material.shader = Shader.Find("Legacy Shaders/Diffuse");
    anchorGameObject.transform.position = position;
    anchorGameObject.transform.rotation = orientationTowardsHead;
    anchorGameObject.transform.localScale = Vector3.one * 0.1f;

    //Add and configure ASA components
    CloudNativeAnchor cloudNativeAnchor = anchorGameObject.AddComponent<CloudNativeAnchor>();
    await cloudNativeAnchor.NativeToCloud();
    CloudSpatialAnchor cloudSpatialAnchor = cloudNativeAnchor.CloudAnchor;
    cloudSpatialAnchor.Expiration = DateTimeOffset.Now.AddDays(3);

    //Collect Environment Data
    while (!_spatialAnchorManager.IsReadyForCreate)
    {
        float createProgress = _spatialAnchorManager.SessionStatus.RecommendedForCreateProgress;
        Debug.Log($"ASA - Move your device to capture more environment data: {createProgress:0%}");
    }

}

注意

HoloLens 可能会重复使用围绕定位点捕获的环境数据,导致在首次调用 IsReadyForCreate 时其值已经为 true。

准备好云空间定位点后,接下来可在此处尝试实际保存。

/// <summary>
/// Creates an Azure Spatial Anchor at the given position rotated towards the user
/// </summary>
/// <param name="position">Position where Azure Spatial Anchor will be created</param>
/// <returns>Async Task</returns>
private async Task CreateAnchor(Vector3 position)
{
    //Create Anchor GameObject. We will use ASA to save the position and the rotation of this GameObject.
    if (!InputDevices.GetDeviceAtXRNode(XRNode.Head).TryGetFeatureValue(CommonUsages.devicePosition, out Vector3 headPosition))
    {
        headPosition = Vector3.zero;
    }

    Quaternion orientationTowardsHead = Quaternion.LookRotation(position - headPosition, Vector3.up);

    GameObject anchorGameObject = GameObject.CreatePrimitive(PrimitiveType.Cube);
    anchorGameObject.GetComponent<MeshRenderer>().material.shader = Shader.Find("Legacy Shaders/Diffuse");
    anchorGameObject.transform.position = position;
    anchorGameObject.transform.rotation = orientationTowardsHead;
    anchorGameObject.transform.localScale = Vector3.one * 0.1f;

    //Add and configure ASA components
    CloudNativeAnchor cloudNativeAnchor = anchorGameObject.AddComponent<CloudNativeAnchor>();
    await cloudNativeAnchor.NativeToCloud();
    CloudSpatialAnchor cloudSpatialAnchor = cloudNativeAnchor.CloudAnchor;
    cloudSpatialAnchor.Expiration = DateTimeOffset.Now.AddDays(3);

    //Collect Environment Data
    while (!_spatialAnchorManager.IsReadyForCreate)
    {
        float createProgress = _spatialAnchorManager.SessionStatus.RecommendedForCreateProgress;
        Debug.Log($"ASA - Move your device to capture more environment data: {createProgress:0%}");
    }

    Debug.Log($"ASA - Saving cloud anchor... ");

    try
    {
        // Now that the cloud spatial anchor has been prepared, we can try the actual save here.
        await _spatialAnchorManager.CreateAnchorAsync(cloudSpatialAnchor);

        bool saveSucceeded = cloudSpatialAnchor != null;
        if (!saveSucceeded)
        {
            Debug.LogError("ASA - Failed to save, but no exception was thrown.");
            return;
        }

        Debug.Log($"ASA - Saved cloud anchor with ID: {cloudSpatialAnchor.Identifier}");
        _foundOrCreatedAnchorGameObjects.Add(anchorGameObject);
        _createdAnchorIDs.Add(cloudSpatialAnchor.Identifier);
        anchorGameObject.GetComponent<MeshRenderer>().material.color = Color.green;
    }
    catch (Exception exception)
    {
        Debug.Log("ASA - Failed to save anchor: " + exception.ToString());
        Debug.LogException(exception);
    }
}

最后,将函数调用添加到 ShortTap 方法

/// <summary>
/// Called when a user is air tapping for a short time 
/// </summary>
/// <param name="handPosition">Location where tap was registered</param>
private async void ShortTap(Vector3 handPosition)
{
    await _spatialAnchorManager.StartSessionAsync();
        await CreateAnchor(handPosition);
}

我们的应用现在可以创建多个定位点。 任何设备现在都可以找到创建的定位点(如果尚未过期),前提是它们知道定位点 ID 并有权访问 Azure 上的相同空间定位点资源。

停止会话和销毁 GameObjects

为了模拟另一个设备查找所有定位点的场景,我们现在停止会话并删除所有定位点 GameObject(保留定位点 ID)。 然后,启动一个新会话,并使用存储的定位点 ID 来查询定位点。

SpatialAnchorManager 可以简单地通过调用其 DestroySession() 方法来处理会话停止操作。 我们将此组件添加到 LongTap() 方法

/// <summary>
/// Called when a user is air tapping for a long time (>=2 sec)
/// </summary>
private async void LongTap()
{
        _spatialAnchorManager.DestroySession();
}

创建一个方法用于删除所有定位点 GameObjects

/// <summary>
/// Destroys all Anchor GameObjects
/// </summary>
private void RemoveAllAnchorGameObjects()
{
    foreach (var anchorGameObject in _foundOrCreatedAnchorGameObjects)
    {
        Destroy(anchorGameObject);
    }
    _foundOrCreatedAnchorGameObjects.Clear();
}

然后在 LongTap() 中销毁会话后调用此方法

/// <summary>
/// Called when a user is air tapping for a long time (>=2 sec)
/// </summary>
private async void LongTap()
{
        // Stop Session and remove all GameObjects. This does not delete the Anchors in the cloud
        _spatialAnchorManager.DestroySession();
        RemoveAllAnchorGameObjects();
        Debug.Log("ASA - Stopped Session and removed all Anchor Objects");
}

查找定位点

现在,我们尝试使用创建定位点时所用的正确位置和旋转再次查找这些定位点。 为此,需要启动一个会话,并创建一个 Watcher 来查找符合给定条件的定位点。 作为条件,我们将为它馈送前面创建的定位点的 ID。 创建方法 LocateAnchor(),并使用 SpatialAnchorManager 创建一个 Watcher。 有关不使用定位点 ID 的查找策略,请参阅定位点查找策略

/// <summary>
/// Looking for anchors with ID in _createdAnchorIDs
/// </summary>
private void LocateAnchor()
{
    if (_createdAnchorIDs.Count > 0)
    {
        //Create watcher to look for all stored anchor IDs
        Debug.Log($"ASA - Creating watcher to look for {_createdAnchorIDs.Count} spatial anchors");
        AnchorLocateCriteria anchorLocateCriteria = new AnchorLocateCriteria();
        anchorLocateCriteria.Identifiers = _createdAnchorIDs.ToArray();
        _spatialAnchorManager.Session.CreateWatcher(anchorLocateCriteria);
        Debug.Log($"ASA - Watcher created!");
    }
}

观察器在启动后,如果它找到了符合给定条件的定位点,就会触发一个回调。 首先让我们创建名为 SpatialAnchorManager_AnchorLocated() 的定位点查找方法,并配置为在观察器找到定位点时调用该方法。 此方法将创建一个视觉 GameObject,并向其附加本机定位点组件。 该本机定位点组件确保为 GameObject 设置正确的位置和旋转。

与创建过程类似,定位点将附加到 GameObject。 此 GameObject 不一定要在场景中可见,也能让空间定位点正常工作。 对于本教程,我们将在找到每个定位点后将其可视化为蓝色立方体。 如果只使用定位点建立共享坐标系,则无需将创建的 GameObject 可视化。

/// <summary>
/// Callback when an anchor is located
/// </summary>
/// <param name="sender">Callback sender</param>
/// <param name="args">Callback AnchorLocatedEventArgs</param>
private void SpatialAnchorManager_AnchorLocated(object sender, AnchorLocatedEventArgs args)
{
    Debug.Log($"ASA - Anchor recognized as a possible anchor {args.Identifier} {args.Status}");

    if (args.Status == LocateAnchorStatus.Located)
    {
        //Creating and adjusting GameObjects have to run on the main thread. We are using the UnityDispatcher to make sure this happens.
        UnityDispatcher.InvokeOnAppThread(() =>
        {
            // Read out Cloud Anchor values
            CloudSpatialAnchor cloudSpatialAnchor = args.Anchor;

            //Create GameObject
            GameObject anchorGameObject = GameObject.CreatePrimitive(PrimitiveType.Cube);
            anchorGameObject.transform.localScale = Vector3.one * 0.1f;
            anchorGameObject.GetComponent<MeshRenderer>().material.shader = Shader.Find("Legacy Shaders/Diffuse");
            anchorGameObject.GetComponent<MeshRenderer>().material.color = Color.blue;

            // Link to Cloud Anchor
            anchorGameObject.AddComponent<CloudNativeAnchor>().CloudToNative(cloudSpatialAnchor);
            _foundOrCreatedAnchorGameObjects.Add(anchorGameObject);
        });
    }
}

现在让我们订阅来自 SpatialAnchorManager 的 AnchorLocated 回调,以确保在观察器找到定位点后调用 SpatialAnchorManager_AnchorLocated() 方法。

// Start is called before the first frame update
void Start()
{
    _spatialAnchorManager = GetComponent<SpatialAnchorManager>();
    _spatialAnchorManager.LogDebug += (sender, args) => Debug.Log($"ASA - Debug: {args.Message}");
    _spatialAnchorManager.Error += (sender, args) => Debug.LogError($"ASA - Error: {args.ErrorMessage}");
    _spatialAnchorManager.AnchorLocated += SpatialAnchorManager_AnchorLocated;
}

最后,让我们扩展 LongTap() 方法以包含查找定位点的操作。 使用 IsSessionStarted 布尔值来确定是要根据应用概述中所述查找所有定位点还是销毁所有定位点

/// <summary>
/// Called when a user is air tapping for a long time (>=2 sec)
/// </summary>
private async void LongTap()
{
    if (_spatialAnchorManager.IsSessionStarted)
    {
        // Stop Session and remove all GameObjects. This does not delete the Anchors in the cloud
        _spatialAnchorManager.DestroySession();
        RemoveAllAnchorGameObjects();
        Debug.Log("ASA - Stopped Session and removed all Anchor Objects");
    }
    else
    {
        //Start session and search for all Anchors previously created
        await _spatialAnchorManager.StartSessionAsync();
        LocateAnchor();
    }
}

试试看 #2

应用现在支持创建并查找定位点。 遵循使用 Visual Studio 进行部署和调试,在 Unity 中生成应用并从 Visual Studio 部署它。

确保 HoloLens 已连接到 Internet。 在应用已启动并且“由 Unity 制作”消息消失后,在周围短按一下。 此时应会出现一个白色立方体,其中显示了要创建的定位点的位置和旋转。 将自动调用定位点创建过程。 当你慢慢环顾四周时,即会捕获环境数据。 收集足够的环境数据后,应用将尝试在指定的位置创建定位点。 定位点创建过程完成后,立方体将变为绿色。 在 Visual Studio 中检查调试日志,看看是否一切都按预期进行。

长按会从场景中删除所有 GameObjects 并停止空间定位点会话。

清除场景后,可以再次长按,这会启动一个会话并查找以前创建的定位点。 找到定位点后,位于定位位置的、使用设置的旋转方向的蓝色立方体会将其可视化。 这些定位点(只要它们未过期)可由任何受支持的设备查找到,前提是这些设备获取了正确的定位点 ID 并有权访问你的空间定位点资源。

删除定位点

现在我们的应用可以创建和查找定位点。 当它删除 GameObjects 时,不会删除云中的定位点。 让我们添加一项功能,以便在点击现有的定位点时也从云中删除它。

添加一个接收 GameObject 的方法 DeleteAnchor。 然后,将 SpatialAnchorManager 和对象的 CloudNativeAnchor 组件结合使用,以请求删除云中的定位点。

/// <summary>
/// Deleting Cloud Anchor attached to the given GameObject and deleting the GameObject
/// </summary>
/// <param name="anchorGameObject">Anchor GameObject that is to be deleted</param>
private async void DeleteAnchor(GameObject anchorGameObject)
{
    CloudNativeAnchor cloudNativeAnchor = anchorGameObject.GetComponent<CloudNativeAnchor>();
    CloudSpatialAnchor cloudSpatialAnchor = cloudNativeAnchor.CloudAnchor;

    Debug.Log($"ASA - Deleting cloud anchor: {cloudSpatialAnchor.Identifier}");

    //Request Deletion of Cloud Anchor
    await _spatialAnchorManager.DeleteAnchorAsync(cloudSpatialAnchor);

    //Remove local references
    _createdAnchorIDs.Remove(cloudSpatialAnchor.Identifier);
    _foundOrCreatedAnchorGameObjects.Remove(anchorGameObject);
    Destroy(anchorGameObject);

    Debug.Log($"ASA - Cloud anchor deleted!");
}

若要从 ShortTap 调用此方法,需要能够确定点击位置是否靠近现有的可见定位点。 让我们创建一个帮助器方法来处理此操作

using System.Linq;
/// <summary>
/// Returns true if an Anchor GameObject is within 15cm of the received reference position
/// </summary>
/// <param name="position">Reference position</param>
/// <param name="anchorGameObject">Anchor GameObject within 15cm of received position. Not necessarily the nearest to this position. If no AnchorObject is within 15cm, this value will be null</param>
/// <returns>True if a Anchor GameObject is within 15cm</returns>
private bool IsAnchorNearby(Vector3 position, out GameObject anchorGameObject)
{
    anchorGameObject = null;

    if (_foundOrCreatedAnchorGameObjects.Count <= 0)
    {
        return false;
    }

    //Iterate over existing anchor gameobjects to find the nearest
    var (distance, closestObject) = _foundOrCreatedAnchorGameObjects.Aggregate(
        new Tuple<float, GameObject>(Mathf.Infinity, null),
        (minPair, gameobject) =>
        {
            Vector3 gameObjectPosition = gameobject.transform.position;
            float distance = (position - gameObjectPosition).magnitude;
            return distance < minPair.Item1 ? new Tuple<float, GameObject>(distance, gameobject) : minPair;
        });

    if (distance <= 0.15f)
    {
        //Found an anchor within 15cm
        anchorGameObject = closestObject;
        return true;
    }
    else
    {
        return false;
    }
}

现在,我们可以扩展 ShortTap 方法以包含 DeleteAnchor 调用

/// <summary>
/// Called when a user is air tapping for a short time 
/// </summary>
/// <param name="handPosition">Location where tap was registered</param>
private async void ShortTap(Vector3 handPosition)
{
    await _spatialAnchorManager.StartSessionAsync();
    if (!IsAnchorNearby(handPosition, out GameObject anchorGameObject))
    {
        //No Anchor Nearby, start session and create an anchor
        await CreateAnchor(handPosition);
    }
    else
    {
        //Delete nearby Anchor
        DeleteAnchor(anchorGameObject);
    }
}

试试看 #3

遵循使用 Visual Studio 进行部署和调试,在 Unity 中生成应用并从 Visual Studio 部署它。

请注意,在此应用中,手部点击手势的位置是手掌的中心,而不是指尖。

点击某个已创建(绿色)或已找到(蓝色)的定位点时,将向空间定位点服务发送一个请求,以便从帐户中删除此定位点。 停止会话(长按)并再次启动会话(长按)以搜索所有定位点。 将不再查找已删除的定位点。

将所有内容放在一起

下面是将所有不同的元素放在一起后,完整的 AzureSpatialAnchorsScript 类文件看起来的样子。 可以将它用做参考与自己的文件进行比较,看是否有任何差异。

注意

你将注意到,我们在脚本中包含了 [RequireComponent(typeof(SpatialAnchorManager))]。 借助此语句,Unity 将确保 AzureSpatialAnchorsScript 附加到的 GameObject 也附加了 SpatialAnchorManager

using Microsoft.Azure.SpatialAnchors;
using Microsoft.Azure.SpatialAnchors.Unity;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.XR;


[RequireComponent(typeof(SpatialAnchorManager))]
public class AzureSpatialAnchorsScript : MonoBehaviour
{
    /// <summary>
    /// Used to distinguish short taps and long taps
    /// </summary>
    private float[] _tappingTimer = { 0, 0 };

    /// <summary>
    /// Main interface to anything Spatial Anchors related
    /// </summary>
    private SpatialAnchorManager _spatialAnchorManager = null;

    /// <summary>
    /// Used to keep track of all GameObjects that represent a found or created anchor
    /// </summary>
    private List<GameObject> _foundOrCreatedAnchorGameObjects = new List<GameObject>();

    /// <summary>
    /// Used to keep track of all the created Anchor IDs
    /// </summary>
    private List<String> _createdAnchorIDs = new List<String>();

    // <Start>
    // Start is called before the first frame update
    void Start()
    {
        _spatialAnchorManager = GetComponent<SpatialAnchorManager>();
        _spatialAnchorManager.LogDebug += (sender, args) => Debug.Log($"ASA - Debug: {args.Message}");
        _spatialAnchorManager.Error += (sender, args) => Debug.LogError($"ASA - Error: {args.ErrorMessage}");
        _spatialAnchorManager.AnchorLocated += SpatialAnchorManager_AnchorLocated;
    }
    // </Start>

    // <Update>
    // Update is called once per frame
    void Update()
    {

        //Check for any air taps from either hand
        for (int i = 0; i < 2; i++)
        {
            InputDevice device = InputDevices.GetDeviceAtXRNode((i == 0) ? XRNode.RightHand : XRNode.LeftHand);
            if (device.TryGetFeatureValue(CommonUsages.primaryButton, out bool isTapping))
            {
                if (!isTapping)
                {
                    //Stopped Tapping or wasn't tapping
                    if (0f < _tappingTimer[i] && _tappingTimer[i] < 1f)
                    {
                        //User has been tapping for less than 1 sec. Get hand position and call ShortTap
                        if (device.TryGetFeatureValue(CommonUsages.devicePosition, out Vector3 handPosition))
                        {
                            ShortTap(handPosition);
                        }
                    }
                    _tappingTimer[i] = 0;
                }
                else
                {
                    _tappingTimer[i] += Time.deltaTime;
                    if (_tappingTimer[i] >= 2f)
                    {
                        //User has been air tapping for at least 2sec. Get hand position and call LongTap
                        if (device.TryGetFeatureValue(CommonUsages.devicePosition, out Vector3 handPosition))
                        {
                            LongTap();
                        }
                        _tappingTimer[i] = -float.MaxValue; // reset the timer, to avoid retriggering if user is still holding tap
                    }
                }
            }

        }
    }
    // </Update>


    // <ShortTap>
    /// <summary>
    /// Called when a user is air tapping for a short time 
    /// </summary>
    /// <param name="handPosition">Location where tap was registered</param>
    private async void ShortTap(Vector3 handPosition)
    {
        await _spatialAnchorManager.StartSessionAsync();
        if (!IsAnchorNearby(handPosition, out GameObject anchorGameObject))
        {
            //No Anchor Nearby, start session and create an anchor
            await CreateAnchor(handPosition);
        }
        else
        {
            //Delete nearby Anchor
            DeleteAnchor(anchorGameObject);
        }
    }
    // </ShortTap>

    // <LongTap>
    /// <summary>
    /// Called when a user is air tapping for a long time (>=2 sec)
    /// </summary>
    private async void LongTap()
    {
        if (_spatialAnchorManager.IsSessionStarted)
        {
            // Stop Session and remove all GameObjects. This does not delete the Anchors in the cloud
            _spatialAnchorManager.DestroySession();
            RemoveAllAnchorGameObjects();
            Debug.Log("ASA - Stopped Session and removed all Anchor Objects");
        }
        else
        {
            //Start session and search for all Anchors previously created
            await _spatialAnchorManager.StartSessionAsync();
            LocateAnchor();
        }
    }
    // </LongTap>

    // <RemoveAllAnchorGameObjects>
    /// <summary>
    /// Destroys all Anchor GameObjects
    /// </summary>
    private void RemoveAllAnchorGameObjects()
    {
        foreach (var anchorGameObject in _foundOrCreatedAnchorGameObjects)
        {
            Destroy(anchorGameObject);
        }
        _foundOrCreatedAnchorGameObjects.Clear();
    }
    // </RemoveAllAnchorGameObjects>

    // <IsAnchorNearby>
    /// <summary>
    /// Returns true if an Anchor GameObject is within 15cm of the received reference position
    /// </summary>
    /// <param name="position">Reference position</param>
    /// <param name="anchorGameObject">Anchor GameObject within 15cm of received position. Not necessarily the nearest to this position. If no AnchorObject is within 15cm, this value will be null</param>
    /// <returns>True if a Anchor GameObject is within 15cm</returns>
    private bool IsAnchorNearby(Vector3 position, out GameObject anchorGameObject)
    {
        anchorGameObject = null;

        if (_foundOrCreatedAnchorGameObjects.Count <= 0)
        {
            return false;
        }

        //Iterate over existing anchor gameobjects to find the nearest
        var (distance, closestObject) = _foundOrCreatedAnchorGameObjects.Aggregate(
            new Tuple<float, GameObject>(Mathf.Infinity, null),
            (minPair, gameobject) =>
            {
                Vector3 gameObjectPosition = gameobject.transform.position;
                float distance = (position - gameObjectPosition).magnitude;
                return distance < minPair.Item1 ? new Tuple<float, GameObject>(distance, gameobject) : minPair;
            });

        if (distance <= 0.15f)
        {
            //Found an anchor within 15cm
            anchorGameObject = closestObject;
            return true;
        }
        else
        {
            return false;
        }
    }
    // </IsAnchorNearby>
  
    // <CreateAnchor>
    /// <summary>
    /// Creates an Azure Spatial Anchor at the given position rotated towards the user
    /// </summary>
    /// <param name="position">Position where Azure Spatial Anchor will be created</param>
    /// <returns>Async Task</returns>
    private async Task CreateAnchor(Vector3 position)
    {
        //Create Anchor GameObject. We will use ASA to save the position and the rotation of this GameObject.
        if (!InputDevices.GetDeviceAtXRNode(XRNode.Head).TryGetFeatureValue(CommonUsages.devicePosition, out Vector3 headPosition))
        {
            headPosition = Vector3.zero;
        }

        Quaternion orientationTowardsHead = Quaternion.LookRotation(position - headPosition, Vector3.up);

        GameObject anchorGameObject = GameObject.CreatePrimitive(PrimitiveType.Cube);
        anchorGameObject.GetComponent<MeshRenderer>().material.shader = Shader.Find("Legacy Shaders/Diffuse");
        anchorGameObject.transform.position = position;
        anchorGameObject.transform.rotation = orientationTowardsHead;
        anchorGameObject.transform.localScale = Vector3.one * 0.1f;

        //Add and configure ASA components
        CloudNativeAnchor cloudNativeAnchor = anchorGameObject.AddComponent<CloudNativeAnchor>();
        await cloudNativeAnchor.NativeToCloud();
        CloudSpatialAnchor cloudSpatialAnchor = cloudNativeAnchor.CloudAnchor;
        cloudSpatialAnchor.Expiration = DateTimeOffset.Now.AddDays(3);

        //Collect Environment Data
        while (!_spatialAnchorManager.IsReadyForCreate)
        {
            float createProgress = _spatialAnchorManager.SessionStatus.RecommendedForCreateProgress;
            Debug.Log($"ASA - Move your device to capture more environment data: {createProgress:0%}");
        }

        Debug.Log($"ASA - Saving cloud anchor... ");

        try
        {
            // Now that the cloud spatial anchor has been prepared, we can try the actual save here.
            await _spatialAnchorManager.CreateAnchorAsync(cloudSpatialAnchor);

            bool saveSucceeded = cloudSpatialAnchor != null;
            if (!saveSucceeded)
            {
                Debug.LogError("ASA - Failed to save, but no exception was thrown.");
                return;
            }

            Debug.Log($"ASA - Saved cloud anchor with ID: {cloudSpatialAnchor.Identifier}");
            _foundOrCreatedAnchorGameObjects.Add(anchorGameObject);
            _createdAnchorIDs.Add(cloudSpatialAnchor.Identifier);
            anchorGameObject.GetComponent<MeshRenderer>().material.color = Color.green;
        }
        catch (Exception exception)
        {
            Debug.Log("ASA - Failed to save anchor: " + exception.ToString());
            Debug.LogException(exception);
        }
    }
    // </CreateAnchor>

    // <LocateAnchor>
    /// <summary>
    /// Looking for anchors with ID in _createdAnchorIDs
    /// </summary>
    private void LocateAnchor()
    {
        if (_createdAnchorIDs.Count > 0)
        {
            //Create watcher to look for all stored anchor IDs
            Debug.Log($"ASA - Creating watcher to look for {_createdAnchorIDs.Count} spatial anchors");
            AnchorLocateCriteria anchorLocateCriteria = new AnchorLocateCriteria();
            anchorLocateCriteria.Identifiers = _createdAnchorIDs.ToArray();
            _spatialAnchorManager.Session.CreateWatcher(anchorLocateCriteria);
            Debug.Log($"ASA - Watcher created!");
        }
    }
    // </LocateAnchor>

    // <SpatialAnchorManagerAnchorLocated>
    /// <summary>
    /// Callback when an anchor is located
    /// </summary>
    /// <param name="sender">Callback sender</param>
    /// <param name="args">Callback AnchorLocatedEventArgs</param>
    private void SpatialAnchorManager_AnchorLocated(object sender, AnchorLocatedEventArgs args)
    {
        Debug.Log($"ASA - Anchor recognized as a possible anchor {args.Identifier} {args.Status}");

        if (args.Status == LocateAnchorStatus.Located)
        {
            //Creating and adjusting GameObjects have to run on the main thread. We are using the UnityDispatcher to make sure this happens.
            UnityDispatcher.InvokeOnAppThread(() =>
            {
                // Read out Cloud Anchor values
                CloudSpatialAnchor cloudSpatialAnchor = args.Anchor;

                //Create GameObject
                GameObject anchorGameObject = GameObject.CreatePrimitive(PrimitiveType.Cube);
                anchorGameObject.transform.localScale = Vector3.one * 0.1f;
                anchorGameObject.GetComponent<MeshRenderer>().material.shader = Shader.Find("Legacy Shaders/Diffuse");
                anchorGameObject.GetComponent<MeshRenderer>().material.color = Color.blue;

                // Link to Cloud Anchor
                anchorGameObject.AddComponent<CloudNativeAnchor>().CloudToNative(cloudSpatialAnchor);
                _foundOrCreatedAnchorGameObjects.Add(anchorGameObject);
            });
        }
    }
    // </SpatialAnchorManagerAnchorLocated>

    // <DeleteAnchor>
    /// <summary>
    /// Deleting Cloud Anchor attached to the given GameObject and deleting the GameObject
    /// </summary>
    /// <param name="anchorGameObject">Anchor GameObject that is to be deleted</param>
    private async void DeleteAnchor(GameObject anchorGameObject)
    {
        CloudNativeAnchor cloudNativeAnchor = anchorGameObject.GetComponent<CloudNativeAnchor>();
        CloudSpatialAnchor cloudSpatialAnchor = cloudNativeAnchor.CloudAnchor;

        Debug.Log($"ASA - Deleting cloud anchor: {cloudSpatialAnchor.Identifier}");

        //Request Deletion of Cloud Anchor
        await _spatialAnchorManager.DeleteAnchorAsync(cloudSpatialAnchor);

        //Remove local references
        _createdAnchorIDs.Remove(cloudSpatialAnchor.Identifier);
        _foundOrCreatedAnchorGameObjects.Remove(anchorGameObject);
        Destroy(anchorGameObject);

        Debug.Log($"ASA - Cloud anchor deleted!");
    }
    // </DeleteAnchor>

}

后续步骤

在本教程中,你已了解如何使用 Unity 为 HoloLens 实现基本的空间定位点应用程序。 若要深入了解如何在新 Android 应用中使用 Azure 空间定位点,请继续学习下一教程。