HoloLens(第一代)共享 240:多个 HoloLens 设备

重要

混合现实学院教程在制作时考虑到了 HoloLens(第一代)、Unity 2017 和混合现实沉浸式头戴显示设备。 因此,对于仍在寻求这些设备的开发指导的开发人员而言,我们觉得很有必要保留这些教程。 我们不会在这些教程中更新 HoloLens 2 所用的最新工具集或交互相关的内容,因此这些教程可能与较新版本的 Unity 不相符。 我们将维护这些教程,使之持续适用于支持的设备。 已经为 HoloLens 2 发布了一系列新教程

当我们在空间中移动时,全息映像通过保持在适当的位置存在于我们的世界中。 HoloLens 使用各种坐标系统来跟踪对象的位置和方向,从而保持全息影像的位置。 当我们在设备之间共享这些坐标系统时,可以创建共享体验,使我们能够参与共享的全息世界。

在本教程中,我们将:

  • 为共享体验设置网络。
  • 跨 HoloLens 设备共享全息影像。
  • 在共享的全息世界中发现其他人。
  • 创建共享的交互式体验,你可以瞄准其他玩家,并向他们发射射弹!

设备支持

课程 HoloLens 沉浸式头戴显示设备
MR 共享 240:多个 HoloLens 设备

开始之前

先决条件

  • 一台 Windows 10 电脑,其中已安装正确的工具,并具有 Internet 访问权限。
  • 至少两台配置用于开发的 HoloLens 设备。

项目文件

  • 下载项目所需的文件。 需要 Unity 2017.2 或更高版本。
    • 如果仍需要 Unity 5.6 支持,请使用此版本
    • 如果仍需要 Unity 5.5 支持,请使用此版本
    • 如果仍需要 Unity 5.4 支持,请使用此版本
  • 将文件解压缩到桌面或其他易于访问的位置。 将文件夹名称保留为 SharedHolograms

注意

如果要在下载源代码之前查看它,可以在 GitHub 上查看

第 1 章 - 全息世界

在本章中,我们将设置第一个 Unity 项目,并逐步完成生成和部署过程。

目标

  • 设置 Unity 以开发全息应用。
  • 查看全息影像!

说明

  • 启动 “Unity”。
  • 选择打开
  • 输入先前解压缩的 SharedHolograms 文件夹所在的位置。
  • 选择“项目名称”并单击“选择文件夹”
  • 在“层次结构”中,右键单击“主相机”,然后选择“删除”
  • 在 HoloToolkit-Sharing-240/Prefabs/Camera 文件夹中,找到“主相机”预制件。
  • 将“主相机”拖放到“层次结构”中。
  • 在“层次结构”中,单击“创建”和“创建空白项”
  • 右键单击新“GameObject”,再选择“重命名”
  • 将 GameObject 重命名为 HologramCollection
  • 在“层次结构”中选择“HologramCollection”对象。
  • 在“检查器”中,将“转换位置”设置为“X: 0, Y: -0.25, Z: 2”
  • 在“项目”面板的“全息影像”文件夹中,找到“EnergyHub”资产。
  • 将“EnergyHub”对象从“项目”面板拖放到“层次结构”,作为“HologramCollection 的子级”
  • 选择“文件”>“将场景另存为...”
  • 将场景命名“SharedHolograms”,然后单击“保存”
  • 在 Unity 中按“播放”按钮预览全息影像
  • 再次按“播放”以停止预览模式

将项目从 Unity 导出到 Visual Studio

  • 在 Unity 中,选择“文件”>“生成设置”
  • 单击“添加开放式场景”以添加场景
  • 在“平台”列表中选择“通用 Windows 平台”,然后单击“切换平台”
  • 将“SDK”设置为“通用 10”
  • 将“目标设备”设置为“HoloLens”,并将“UWP 生成类型”设置为“D3D”
  • 选中“Unity C# 项目”
  • 单击“生成”
  • 在出现的文件资源管理器窗口中,创建名为“App”的新文件夹
  • 单击“App”文件夹
  • 按“选择文件夹”
  • 完成 Unity 设置后,将出现一个文件资源管理器窗口。
  • 打开“App”文件夹。
  • 打开“SharedHolograms.sln”以启动 Visual Studio。
  • 在 Visual Studio 中使用顶部工具栏,将目标从“调试”更改为“发布”,并从“ARM”更改为“X86”
  • 单击“本地计算机”旁边的下拉箭头,然后选择“远程设备”
    • 将“地址”设置为 HoloLens 的名称或 IP 地址。 如果你不知道自己的设备 IP 地址,可以在“设置”>“网络和 Internet”>“高级选项”中找到,或询问 Cortana“你好小娜,我的 IP 地址是什么?”
    • 将“身份验证模式”保留为“通用”
    • 单击“选择”
  • 单击“调试”>“开始执行(不调试)”或按 Ctrl+F5。 如果这是你第一次部署到设备,需要将设备与 Visual Studio 配对
  • 佩戴 HoloLens 并找到 EnergyHub 全息影像。

第 2 章 - 交互

在本章中,我们将与全息影像进行交互。 首先,我们添加一个光标来可视化凝视。 然后,添加手势,并用手将全息影像放置在空间中。

目标

  • 使用凝视输入来控制光标。
  • 使用手势输入与全息影像交互。

说明

凝视

  • 在“层次结构”面板中选择“HologramCollection”对象。
  • 在“检查器”面板中,单击“添加组件”按钮。
  • 在菜单中的搜索框内键入“凝视管理器”。 选择搜索结果。
  • 在“HoloToolkit-Sharing-240\Prefabs\Input”文件夹中,找到“光标”资产。
  • 将“光标”资产拖放到“层次结构”中。

手势

  • 在“层次结构”面板中选择“HologramCollection”对象。
  • 单击“添加组件”,并在搜索字段中键入“手势管理器”。 选择搜索结果。
  • 在“层次结构”面板中,展开“HologramCollection”
  • 选择子“EnergyHub”对象。
  • 在“检查器”面板中,单击“添加组件”按钮。
  • 在菜单中的搜索框内键入“全息影像放置”。 选择搜索结果。
  • 可通过选择“文件”>“保存场景”来保存场景。

部署和体验

  • 使用上一章中的说明生成并部署到 HoloLens。
  • 在 HoloLens 上启动该应用后,请转动头部,注意 EnergyHub 如何追随你的凝视。
  • 注意在你凝视全息影像时光标是如何显示的,当你停止凝视全息影像时,光标会变成点光。
  • 进行隔空敲击来放置全息影像。 目前,在我们的项目中,只能放置全息影像一次(重新部署后可重试)。

第 3 章 - 共享坐标

浏览全息影像并与之交互很有趣,但我们来进一步了解一下。 我们将建立我们的第一个共享体验 - 每个人都可以一起看到的一个全息影像。

目标

  • 为共享体验设置网络。
  • 建立一个共同的参考点。
  • 在不同的设备上共享坐标系统。
  • 每个人都看到了相同的全息影像!

注意

必须为应用声明 InternetClientServer 和 PrivateNetworkClientServer 功能才能连接到共享服务器。 全息影像 240 中已为你完成此操作,但请在你自己的项目中考虑到这一点。

  1. 在 Unity 编辑器中,导航到“编辑”>“项目设置”>“玩家”,转到“玩家设置”
  2. 单击“Microsoft Store”选项卡
  3. 在“发布设置”>“功能”部分中,选中“InternetClientServer”功能和“PrivateNetworkClientServer”功能

说明

  • 在“项目”面板中,导航到“HoloToolkit-Sharing-240\Prefabs\Sharing”文件夹。
  • 将“共享”预制件拖放到“层次结构”面板中。

接下来,我们需要启动共享服务。 共享体验中只有一台电脑需要执行此步骤。

  • 在 Unity 的顶部菜单中,选择“HoloToolkit-Sharing-240”菜单
  • 在下拉列表中选择“启动共享服务”项。
  • 选中“专用网络”选项,并在出现防火墙提示时单击“允许访问”
  • 记下“共享服务”控制台窗口中显示的 IPv4 地址。 此 IP 与运行服务的计算机的 IP 相同。

按照将加入共享体验的所有电脑上的其余说明进行操作。

  • 在“层次结构”中,选择“共享”对象。
  • 在“检查器”的“共享阶段”组件上,将“服务器地址”从“localhost”更改为运行 SharingService.exe 的计算机的 IPv4 地址。
  • 在“层次结构”中选择“HologramCollection”对象。
  • 在“检查器”中,单击“添加组件”按钮。
  • 在搜索框中,键入“导入导出定位点管理器”。 选择搜索结果。
  • 在“项目”面板中,导航到“脚本”文件夹。
  • 双击“HologramPlacement”脚本以在 Visual Studio 中打开它。
  • 将内容替换为以下代码。
using UnityEngine;
using System.Collections.Generic;
using UnityEngine.Windows.Speech;
using Academy.HoloToolkit.Unity;
using Academy.HoloToolkit.Sharing;

public class HologramPlacement : Singleton<HologramPlacement>
{
    /// <summary>
    /// Tracks if we have been sent a transform for the anchor model.
    /// The anchor model is rendered relative to the actual anchor.
    /// </summary>
    public bool GotTransform { get; private set; }

    private bool animationPlayed = false;

    void Start()
    {
        // We care about getting updates for the anchor transform.
        CustomMessages.Instance.MessageHandlers[CustomMessages.TestMessageID.StageTransform] = this.OnStageTransform;

        // And when a new user join we will send the anchor transform we have.
        SharingSessionTracker.Instance.SessionJoined += Instance_SessionJoined;
    }

    /// <summary>
    /// When a new user joins we want to send them the relative transform for the anchor if we have it.
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    private void Instance_SessionJoined(object sender, SharingSessionTracker.SessionJoinedEventArgs e)
    {
        if (GotTransform)
        {
            CustomMessages.Instance.SendStageTransform(transform.localPosition, transform.localRotation);
        }
    }

    void Update()
    {
        if (GotTransform)
        {
            if (ImportExportAnchorManager.Instance.AnchorEstablished &&
                animationPlayed == false)
            {
                // This triggers the animation sequence for the anchor model and 
                // puts the cool materials on the model.
                GetComponent<EnergyHubBase>().SendMessage("OnSelect");
                animationPlayed = true;
            }
        }
        else
        {
            transform.position = Vector3.Lerp(transform.position, ProposeTransformPosition(), 0.2f);
        }
    }

    Vector3 ProposeTransformPosition()
    {
        // Put the anchor 2m in front of the user.
        Vector3 retval = Camera.main.transform.position + Camera.main.transform.forward * 2;

        return retval;
    }

    public void OnSelect()
    {
        // Note that we have a transform.
        GotTransform = true;

        // And send it to our friends.
        CustomMessages.Instance.SendStageTransform(transform.localPosition, transform.localRotation);
    }

    /// <summary>
    /// When a remote system has a transform for us, we'll get it here.
    /// </summary>
    /// <param name="msg"></param>
    void OnStageTransform(NetworkInMessage msg)
    {
        // We read the user ID but we don't use it here.
        msg.ReadInt64();

        transform.localPosition = CustomMessages.Instance.ReadVector3(msg);
        transform.localRotation = CustomMessages.Instance.ReadQuaternion(msg);

        // The first time, we'll want to send the message to the anchor to do its animation and
        // swap its materials.
        if (GotTransform == false)
        {
            GetComponent<EnergyHubBase>().SendMessage("OnSelect");
        }

        GotTransform = true;
    }

    public void ResetStage()
    {
        // We'll use this later.
    }
}
  • 回到 Unity,在“层次结构”面板中选择“HologramCollection”
  • 在“检查器”面板中,单击“添加组件”按钮。
  • 在菜单中的搜索框内键入“应用状态管理器”。 选择搜索结果。

部署和体验

  • 为 HoloLens 设备生成项目。
  • 首先指定一个 HoloLens 进行部署。 你需要等待定位点被上传到服务,然后才能放置 EnergyHub(这可能需要大约 30-60 秒)。 在上传完成之前,将忽略你的点击手势。
  • 放置 EnergyHub 后,其位置将被上传到服务,然后你可以将其部署到所有其他 HoloLens 设备上。
  • 当新的 HoloLens 首次加入会话时,EnergyHub 在该设备上的位置可能不正确。 但是,一旦从服务下载了定位点和 EnergyHub 位置,EnergyHub 应跳至新的共享位置。 如果在大约 30-60 秒内没有发生此情况,请走到原来 HoloLens 设置定位点时的位置,以收集更多的环境线索。 如果位置仍未锁定,请重新部署到设备。
  • 当设备全部准备就绪并运行应用时,请查找 EnergyHub。 你们的全息影像位置以及文本的方向都一致吗?

第 4 章 - 发现

现在,所有人都可以看到相同的全息影像! 让我们看看与共享全息世界连接的其他人。 在本章中,我们将抓取同一共享会话中所有其他 HoloLens 设备的头部位置和旋转动作。

目标

  • 在共享体验中发现彼此。
  • 选择并共享玩家头像。
  • 在每个人的头像旁边附上玩家的头像。

说明

  • 在“项目”面板中,导航到“全息影像”文件夹。
  • 将“PlayerAvatarStore”拖放到“层次结构”中。
  • 在“项目”面板中,导航到“脚本”文件夹。
  • 双击“AvatarSelector”脚本以在 Visual Studio 中打开它。
  • 将内容替换为以下代码。
using UnityEngine;
using Academy.HoloToolkit.Unity;

/// <summary>
/// Script to handle the user selecting the avatar.
/// </summary>
public class AvatarSelector : MonoBehaviour
{
    /// <summary>
    /// This is the index set by the PlayerAvatarStore for the avatar.
    /// </summary>
    public int AvatarIndex { get; set; }

    /// <summary>
    /// Called when the user is gazing at this avatar and air-taps it.
    /// This sends the user's selection to the rest of the devices in the experience.
    /// </summary>
    void OnSelect()
    {
        PlayerAvatarStore.Instance.DismissAvatarPicker();

        LocalPlayerManager.Instance.SetUserAvatar(AvatarIndex);
    }

    void Start()
    {
        // Add Billboard component so the avatar always faces the user.
        Billboard billboard = gameObject.GetComponent<Billboard>();
        if (billboard == null)
        {
            billboard = gameObject.AddComponent<Billboard>();
        }

        // Lock rotation along the Y axis.
        billboard.PivotAxis = PivotAxis.Y;
    }
}
  • 在“层次结构”中选择“HologramCollection”对象。
  • 在“检查器”中,单击“添加组件”
  • 在搜索框中,键入“本地玩家管理器”。 选择搜索结果。
  • 在“层次结构”中选择“HologramCollection”对象。
  • 在“检查器”中,单击“添加组件”
  • 在搜索框中,键入“远程玩家管理器”。 选择搜索结果。
  • 在 Visual Studio 中打开“HologramPlacement”脚本。
  • 将内容替换为以下代码。
using UnityEngine;
using System.Collections.Generic;
using UnityEngine.Windows.Speech;
using Academy.HoloToolkit.Unity;
using Academy.HoloToolkit.Sharing;

public class HologramPlacement : Singleton<HologramPlacement>
{
    /// <summary>
    /// Tracks if we have been sent a transform for the model.
    /// The model is rendered relative to the actual anchor.
    /// </summary>
    public bool GotTransform { get; private set; }

    /// <summary>
    /// When the experience starts, we disable all of the rendering of the model.
    /// </summary>
    List<MeshRenderer> disabledRenderers = new List<MeshRenderer>();

    void Start()
    {
        // When we first start, we need to disable the model to avoid it obstructing the user picking a hat.
        DisableModel();

        // We care about getting updates for the model transform.
        CustomMessages.Instance.MessageHandlers[CustomMessages.TestMessageID.StageTransform] = this.OnStageTransform;

        // And when a new user join we will send the model transform we have.
        SharingSessionTracker.Instance.SessionJoined += Instance_SessionJoined;
    }

    /// <summary>
    /// When a new user joins we want to send them the relative transform for the model if we have it.
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    private void Instance_SessionJoined(object sender, SharingSessionTracker.SessionJoinedEventArgs e)
    {
        if (GotTransform)
        {
            CustomMessages.Instance.SendStageTransform(transform.localPosition, transform.localRotation);
        }
    }

    /// <summary>
    /// Turns off all renderers for the model.
    /// </summary>
    void DisableModel()
    {
        foreach (MeshRenderer renderer in gameObject.GetComponentsInChildren<MeshRenderer>())
        {
            if (renderer.enabled)
            {
                renderer.enabled = false;
                disabledRenderers.Add(renderer);
            }
        }

        foreach (MeshCollider collider in gameObject.GetComponentsInChildren<MeshCollider>())
        {
            collider.enabled = false;
        }
    }

    /// <summary>
    /// Turns on all renderers that were disabled.
    /// </summary>
    void EnableModel()
    {
        foreach (MeshRenderer renderer in disabledRenderers)
        {
            renderer.enabled = true;
        }

        foreach (MeshCollider collider in gameObject.GetComponentsInChildren<MeshCollider>())
        {
            collider.enabled = true;
        }

        disabledRenderers.Clear();
    }


    void Update()
    {
        // Wait till users pick an avatar to enable renderers.
        if (disabledRenderers.Count > 0)
        {
            if (!PlayerAvatarStore.Instance.PickerActive &&
            ImportExportAnchorManager.Instance.AnchorEstablished)
            {
                // After which we want to start rendering.
                EnableModel();

                // And if we've already been sent the relative transform, we will use it.
                if (GotTransform)
                {
                    // This triggers the animation sequence for the model and
                    // puts the cool materials on the model.
                    GetComponent<EnergyHubBase>().SendMessage("OnSelect");
                }
            }
        }
        else if (GotTransform == false)
        {
            transform.position = Vector3.Lerp(transform.position, ProposeTransformPosition(), 0.2f);
        }
    }

    Vector3 ProposeTransformPosition()
    {
        // Put the model 2m in front of the user.
        Vector3 retval = Camera.main.transform.position + Camera.main.transform.forward * 2;

        return retval;
    }

    public void OnSelect()
    {
        // Note that we have a transform.
        GotTransform = true;

        // And send it to our friends.
        CustomMessages.Instance.SendStageTransform(transform.localPosition, transform.localRotation);
    }

    /// <summary>
    /// When a remote system has a transform for us, we'll get it here.
    /// </summary>
    /// <param name="msg"></param>
    void OnStageTransform(NetworkInMessage msg)
    {
        // We read the user ID but we don't use it here.
        msg.ReadInt64();

        transform.localPosition = CustomMessages.Instance.ReadVector3(msg);
        transform.localRotation = CustomMessages.Instance.ReadQuaternion(msg);

        // The first time, we'll want to send the message to the model to do its animation and
        // swap its materials.
        if (disabledRenderers.Count == 0 && GotTransform == false)
        {
            GetComponent<EnergyHubBase>().SendMessage("OnSelect");
        }

        GotTransform = true;
    }

    public void ResetStage()
    {
        // We'll use this later.
    }
}
  • 在 Visual Studio 中打开“AppStateManager”脚本。
  • 将内容替换为以下代码。
using UnityEngine;
using Academy.HoloToolkit.Unity;

/// <summary>
/// Keeps track of the current state of the experience.
/// </summary>
public class AppStateManager : Singleton<AppStateManager>
{
    /// <summary>
    /// Enum to track progress through the experience.
    /// </summary>
    public enum AppState
    {
        Starting = 0,
        WaitingForAnchor,
        WaitingForStageTransform,
        PickingAvatar,
        Ready
    }

    /// <summary>
    /// Tracks the current state in the experience.
    /// </summary>
    public AppState CurrentAppState { get; set; }

    void Start()
    {
        // We start in the 'picking avatar' mode.
        CurrentAppState = AppState.PickingAvatar;

        // We start by showing the avatar picker.
        PlayerAvatarStore.Instance.SpawnAvatarPicker();
    }

    void Update()
    {
        switch (CurrentAppState)
        {
            case AppState.PickingAvatar:
                // Avatar picking is done when the avatar picker has been dismissed.
                if (PlayerAvatarStore.Instance.PickerActive == false)
                {
                    CurrentAppState = AppState.WaitingForAnchor;
                }
                break;
            case AppState.WaitingForAnchor:
                if (ImportExportAnchorManager.Instance.AnchorEstablished)
                {
                    CurrentAppState = AppState.WaitingForStageTransform;
                    GestureManager.Instance.OverrideFocusedObject = HologramPlacement.Instance.gameObject;
                }
                break;
            case AppState.WaitingForStageTransform:
                // Now if we have the stage transform we are ready to go.
                if (HologramPlacement.Instance.GotTransform)
                {
                    CurrentAppState = AppState.Ready;
                    GestureManager.Instance.OverrideFocusedObject = null;
                }
                break;
        }
    }
}

部署和体验

  • 生成项目并将其部署到 HoloLens 设备。
  • 听到咻的声音时,请找到头像选择菜单,并使用隔空敲击手势选择一个头像。
  • 如果你不查看任何全息影像,则当 HoloLens 与服务进行通信时,光标周围的点光将变为不同的颜色:初始化(深紫色),下载定位点(绿色),导入/导出位置数据(黄色),上传定位点(蓝色)。 如果光标周围的点光为默认颜色(浅紫色),表明你已准备好在会话中与其他玩家进行交互!
  • 查看与你的空间连接的其他人 - 将有一个全息机器人漂浮在他们的肩膀上方,并模拟他们的头部运动!

第 5 章 - 放置

在本章中,我们将使定位点能够被放置在真实世界的表面上。 我们将使用共享坐标,将该定位点放在连接到共享体验的所有人之间的中间点。

目标

  • 根据玩家的头部位置将全息影像放置在空间映射网格上。

说明

  • 在“项目”面板中,导航到“全息影像”文件夹。
  • 将“CustomSpatialMapping”预制件拖放到“层次结构”上
  • 在“项目”面板中,导航到“脚本”文件夹。
  • 双击“AppStateManager”脚本以在 Visual Studio 中打开它。
  • 将内容替换为以下代码。
using UnityEngine;
using Academy.HoloToolkit.Unity;

/// <summary>
/// Keeps track of the current state of the experience.
/// </summary>
public class AppStateManager : Singleton<AppStateManager>
{
    /// <summary>
    /// Enum to track progress through the experience.
    /// </summary>
    public enum AppState
    {
        Starting = 0,
        PickingAvatar,
        WaitingForAnchor,
        WaitingForStageTransform,
        Ready
    }

    // The object to call to make a projectile.
    GameObject shootHandler = null;

    /// <summary>
    /// Tracks the current state in the experience.
    /// </summary>
    public AppState CurrentAppState { get; set; }

    void Start()
    {
        // The shootHandler shoots projectiles.
        if (GetComponent<ProjectileLauncher>() != null)
        {
            shootHandler = GetComponent<ProjectileLauncher>().gameObject;
        }

        // We start in the 'picking avatar' mode.
        CurrentAppState = AppState.PickingAvatar;

        // Spatial mapping should be disabled when we start up so as not
        // to distract from the avatar picking.
        SpatialMappingManager.Instance.StopObserver();
        SpatialMappingManager.Instance.gameObject.SetActive(false);

        // On device we start by showing the avatar picker.
        PlayerAvatarStore.Instance.SpawnAvatarPicker();
    }

    public void ResetStage()
    {
        // If we fall back to waiting for anchor, everything needed to
        // get us into setting the target transform state will be setup.
        if (CurrentAppState != AppState.PickingAvatar)
        {
            CurrentAppState = AppState.WaitingForAnchor;
        }

        // Reset the underworld.
        if (UnderworldBase.Instance)
        {
            UnderworldBase.Instance.ResetUnderworld();
        }
    }

    void Update()
    {
        switch (CurrentAppState)
        {
            case AppState.PickingAvatar:
                // Avatar picking is done when the avatar picker has been dismissed.
                if (PlayerAvatarStore.Instance.PickerActive == false)
                {
                    CurrentAppState = AppState.WaitingForAnchor;
                }
                break;
            case AppState.WaitingForAnchor:
                // Once the anchor is established we need to run spatial mapping for a
                // little while to build up some meshes.
                if (ImportExportAnchorManager.Instance.AnchorEstablished)
                {
                    CurrentAppState = AppState.WaitingForStageTransform;
                    GestureManager.Instance.OverrideFocusedObject = HologramPlacement.Instance.gameObject;

                    SpatialMappingManager.Instance.gameObject.SetActive(true);
                    SpatialMappingManager.Instance.DrawVisualMeshes = true;
                    SpatialMappingDeformation.Instance.ResetGlobalRendering();
                    SpatialMappingManager.Instance.StartObserver();
                }
                break;
            case AppState.WaitingForStageTransform:
                // Now if we have the stage transform we are ready to go.
                if (HologramPlacement.Instance.GotTransform)
                {
                    CurrentAppState = AppState.Ready;
                    GestureManager.Instance.OverrideFocusedObject = shootHandler;
                }
                break;
        }
    }
}
  • 在“项目”面板中,导航到“脚本”文件夹。
  • 双击“HologramPlacement”脚本以在 Visual Studio 中打开它。
  • 将内容替换为以下代码。
using UnityEngine;
using System.Collections.Generic;
using UnityEngine.Windows.Speech;
using Academy.HoloToolkit.Unity;
using Academy.HoloToolkit.Sharing;

public class HologramPlacement : Singleton<HologramPlacement>
{
    /// <summary>
    /// Tracks if we have been sent a transform for the model.
    /// The model is rendered relative to the actual anchor.
    /// </summary>
    public bool GotTransform { get; private set; }

    /// <summary>
    /// When the experience starts, we disable all of the rendering of the model.
    /// </summary>
    List<MeshRenderer> disabledRenderers = new List<MeshRenderer>();

    /// <summary>
    /// We use a voice command to enable moving the target.
    /// </summary>
    KeywordRecognizer keywordRecognizer;

    void Start()
    {
        // When we first start, we need to disable the model to avoid it obstructing the user picking a hat.
        DisableModel();

        // We care about getting updates for the model transform.
        CustomMessages.Instance.MessageHandlers[CustomMessages.TestMessageID.StageTransform] = this.OnStageTransform;

        // And when a new user join we will send the model transform we have.
        SharingSessionTracker.Instance.SessionJoined += Instance_SessionJoined;

        // And if the users want to reset the stage transform.
        CustomMessages.Instance.MessageHandlers[CustomMessages.TestMessageID.ResetStage] = this.OnResetStage;

        // Setup a keyword recognizer to enable resetting the target location.
        List<string> keywords = new List<string>();
        keywords.Add("Reset Target");
        keywordRecognizer = new KeywordRecognizer(keywords.ToArray());
        keywordRecognizer.OnPhraseRecognized += KeywordRecognizer_OnPhraseRecognized;
        keywordRecognizer.Start();
    }

    /// <summary>
    /// When the keyword recognizer hears a command this will be called.  
    /// In this case we only have one keyword, which will re-enable moving the
    /// target.
    /// </summary>
    /// <param name="args">information to help route the voice command.</param>
    private void KeywordRecognizer_OnPhraseRecognized(PhraseRecognizedEventArgs args)
    {
        ResetStage();
    }

    /// <summary>
    /// Resets the stage transform, so users can place the target again.
    /// </summary>
    public void ResetStage()
    {
        GotTransform = false;

        // AppStateManager needs to know about this so that
        // the right objects get input routed to them.
        AppStateManager.Instance.ResetStage();

        // Other devices in the experience need to know about this as well.
        CustomMessages.Instance.SendResetStage();

        // And we need to reset the object to its start animation state.
        GetComponent<EnergyHubBase>().ResetAnimation();
    }

    /// <summary>
    /// When a new user joins we want to send them the relative transform for the model if we have it.
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    private void Instance_SessionJoined(object sender, SharingSessionTracker.SessionJoinedEventArgs e)
    {
        if (GotTransform)
        {
            CustomMessages.Instance.SendStageTransform(transform.localPosition, transform.localRotation);
        }
    }

    /// <summary>
    /// Turns off all renderers for the model.
    /// </summary>
    void DisableModel()
    {
        foreach (MeshRenderer renderer in gameObject.GetComponentsInChildren<MeshRenderer>())
        {
            if (renderer.enabled)
            {
                renderer.enabled = false;
                disabledRenderers.Add(renderer);
            }
        }

        foreach (MeshCollider collider in gameObject.GetComponentsInChildren<MeshCollider>())
        {
            collider.enabled = false;
        }
    }

    /// <summary>
    /// Turns on all renderers that were disabled.
    /// </summary>
    void EnableModel()
    {
        foreach (MeshRenderer renderer in disabledRenderers)
        {
            renderer.enabled = true;
        }

        foreach (MeshCollider collider in gameObject.GetComponentsInChildren<MeshCollider>())
        {
            collider.enabled = true;
        }

        disabledRenderers.Clear();
    }


    void Update()
    {
        // Wait till users pick an avatar to enable renderers.
        if (disabledRenderers.Count > 0)
        {
            if (!PlayerAvatarStore.Instance.PickerActive &&
            ImportExportAnchorManager.Instance.AnchorEstablished)
            {
                // After which we want to start rendering.
                EnableModel();

                // And if we've already been sent the relative transform, we will use it.
                if (GotTransform)
                {
                    // This triggers the animation sequence for the model and
                    // puts the cool materials on the model.
                    GetComponent<EnergyHubBase>().SendMessage("OnSelect");
                }
            }
        }
        else if (GotTransform == false)
        {
            transform.position = Vector3.Lerp(transform.position, ProposeTransformPosition(), 0.2f);
        }
    }

    Vector3 ProposeTransformPosition()
    {
        Vector3 retval;
        // We need to know how many users are in the experience with good transforms.
        Vector3 cumulatedPosition = Camera.main.transform.position;
        int playerCount = 1;
        foreach (RemotePlayerManager.RemoteHeadInfo remoteHead in RemotePlayerManager.Instance.remoteHeadInfos)
        {
            if (remoteHead.Anchored && remoteHead.Active)
            {
                playerCount++;
                cumulatedPosition += remoteHead.HeadObject.transform.position;
            }
        }

        // If we have more than one player ...
        if (playerCount > 1)
        {
            // Put the transform in between the players.
            retval = cumulatedPosition / playerCount;
            RaycastHit hitInfo;

            // And try to put the transform on a surface below the midpoint of the players.
            if (Physics.Raycast(retval, Vector3.down, out hitInfo, 5, SpatialMappingManager.Instance.LayerMask))
            {
                retval = hitInfo.point;
            }
        }
        // If we are the only player, have the model act as the 'cursor' ...
        else
        {
            // We prefer to put the model on a real world surface.
            RaycastHit hitInfo;

            if (Physics.Raycast(Camera.main.transform.position, Camera.main.transform.forward, out hitInfo, 30, SpatialMappingManager.Instance.LayerMask))
            {
                retval = hitInfo.point;
            }
            else
            {
                // But if we don't have a ray that intersects the real world, just put the model 2m in
                // front of the user.
                retval = Camera.main.transform.position + Camera.main.transform.forward * 2;
            }
        }
        return retval;
    }

    public void OnSelect()
    {
        // Note that we have a transform.
        GotTransform = true;

        // And send it to our friends.
        CustomMessages.Instance.SendStageTransform(transform.localPosition, transform.localRotation);
    }

    /// <summary>
    /// When a remote system has a transform for us, we'll get it here.
    /// </summary>
    /// <param name="msg"></param>
    void OnStageTransform(NetworkInMessage msg)
    {
        // We read the user ID but we don't use it here.
        msg.ReadInt64();

        transform.localPosition = CustomMessages.Instance.ReadVector3(msg);
        transform.localRotation = CustomMessages.Instance.ReadQuaternion(msg);

        // The first time, we'll want to send the message to the model to do its animation and
        // swap its materials.
        if (disabledRenderers.Count == 0 && GotTransform == false)
        {
            GetComponent<EnergyHubBase>().SendMessage("OnSelect");
        }

        GotTransform = true;
    }

    /// <summary>
    /// When a remote system has a transform for us, we'll get it here.
    /// </summary>
    void OnResetStage(NetworkInMessage msg)
    {
        GotTransform = false;

        GetComponent<EnergyHubBase>().ResetAnimation();
        AppStateManager.Instance.ResetStage();
    }
}

部署和体验

  • 生成项目并将其部署到 HoloLens 设备。
  • 当应用准备就绪后,请站成一圈,注意 EnergyHub 如何出现在每个人的中心位置。
  • 点击放置 EnergyHub。
  • 尝试语音命令“重置目标”以选取 EnergyHub 备份,并以小组形式协同工作,将全息影像移动到一个新的位置。

第 6 章 - 真实世界物理特性

在本章中,我们将添加在真实世界表面弹跳的全息影像。 看看你的空间填满了你和好友启动的项目!

目标

  • 发射射弹,它们会在真实世界表面弹跳。
  • 共享射弹,让其他玩家可以看到它们。

说明

  • 在“层次结构”中选择“HologramCollection”对象。
  • 在“检查器”中,单击“添加组件”
  • 在搜索框中,键入“射弹发射器”。 选择搜索结果。

部署和体验

  • 生成并部署到 HoloLens 设备。
  • 当应用在所有设备上运行时,进行隔空敲击以在真实世界表面发射射弹。
  • 看看当你的射弹与另一个玩家的头像碰撞时会发生什么情况!

第 7 章 - 压轴戏

在本章中,我们将揭晓一个只有通过协作才能发现的门户。

目标

  • 在定位点处一起发射足够的射弹,以发现秘密门户!

说明

  • 在“项目”面板中,导航到“全息影像”文件夹。
  • 将“地下世界”资产作为“HologramCollection 的子级”进行拖放。
  • 选中“HologramCollection”后,单击“检查器”中的“添加组件”按钮。
  • 在菜单中的搜索框内键入“ExplodeTarget”。 选择搜索结果。
  • 选中“HologramCollection”后,从“层次结构”将“EnergyHub”对象拖动到“检查器”中的“目标”字段。
  • 选中“HologramCollection”后,从“层次结构”将“Underworld”对象拖动到“检查器”中的“地下世界”字段。

部署和体验

  • 生成并部署到 HoloLens 设备。
  • 应用启动后,一起协作在 EnergyHub 上发射射弹。
  • 当地下世界出现时,向地下世界机器人发射射弹(击中一个机器人三次可获得额外的乐趣)。