HoloLens(第一代)共享 240:多个 HoloLens 设备
重要
混合现实学院教程在制作时考虑到了 HoloLens(第一代)、Unity 2017 和混合现实沉浸式头戴显示设备。 因此,对于仍在寻求这些设备的开发指导的开发人员而言,我们觉得很有必要保留这些教程。 我们不会在这些教程中更新 HoloLens 2 所用的最新工具集或交互相关的内容,因此这些教程可能与较新版本的 Unity 不相符。 我们将维护这些教程,使之持续适用于支持的设备。 已经为 HoloLens 2 发布了一系列新教程。
当我们在空间中移动时,全息映像通过保持在适当的位置存在于我们的世界中。 HoloLens 使用各种坐标系统来跟踪对象的位置和方向,从而保持全息影像的位置。 当我们在设备之间共享这些坐标系统时,可以创建共享体验,使我们能够参与共享的全息世界。
在本教程中,我们将:
- 为共享体验设置网络。
- 跨 HoloLens 设备共享全息影像。
- 在共享的全息世界中发现其他人。
- 创建共享的交互式体验,你可以瞄准其他玩家,并向他们发射射弹!
设备支持
课程 | HoloLens | 沉浸式头戴显示设备 |
---|---|---|
MR 共享 240:多个 HoloLens 设备 | ✔ |
开始之前
先决条件
项目文件
- 下载项目所需的文件。 需要 Unity 2017.2 或更高版本。
- 将文件解压缩到桌面或其他易于访问的位置。 将文件夹名称保留为 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 中已为你完成此操作,但请在你自己的项目中考虑到这一点。
- 在 Unity 编辑器中,导航到“编辑”>“项目设置”>“玩家”,转到“玩家设置”
- 单击“Microsoft Store”选项卡
- 在“发布设置”>“功能”部分中,选中“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 上发射射弹。
- 当地下世界出现时,向地下世界机器人发射射弹(击中一个机器人三次可获得额外的乐趣)。