重要
混合现实学院教程在制作时考虑到了 HoloLens(第一代)、Unity 2017 和混合现实沉浸式头戴显示设备。 因此,对于仍在寻求这些设备的开发指导的开发人员而言,我们觉得很有必要保留这些教程。 我们不会在这些教程中更新 HoloLens 2 所用的最新工具集或交互相关的内容,因此这些教程可能与较新版本的 Unity 不相符。 我们将维护这些教程,使之持续适用于支持的设备。 已经为 HoloLens 2 发布了一系列新教程。
空间映射通过教授有关环境的全息影像,将现实世界和虚拟世界结合在一起。 在 MR 空间 230(Planetarium 项目)中,我们将了解如何:
- 扫描环境,将数据从 HoloLens 传输到开发计算机。
- 了解着色器并学习如何使用它们来可视化空间。
- 使用网格处理将房间网格分解为简单的平面。
- 不拘泥于在 MR 基础知识 101 中学到的放置技术,提供有关全息影像可在环境中放置的位置的反馈。
- 了解遮挡效果,这样当全息影像位于真实对象后面时,仍可以使用 X 射线视觉来查看它!
设备支持
| 课程 | HoloLens | 沉浸式头戴显示设备 |
|---|---|---|
| MR 空间 230:空间映射 | ✔ |
开始之前
先决条件
- 一台安装了正确工具的 Windows 10 电脑。
- 基础的 C# 编程能力。
- 应已完成 MR 基础知识 101。
- 一台配置用于开发的 HoloLens 设备。
项目文件
- 下载项目所需的文件。 需要 Unity 2017.2 或更高版本。
- 将文件解压缩到桌面或其他易于访问的位置。
注意
如果要在下载源代码之前查看它,可以在 GitHub 上查看。
说明
- 在 Visual Studio 中,需要在“工具”>“选项”>“调试”下禁用(取消选中)“启用仅我的代码”,以便在代码中命中断点。
Unity 设置
- 启动 “Unity”。
- 选择“新建”以创建新项目。
- 将项目命名为 “Planetarium”。
- 验证是否选择了 “3D” 设置。
- 单击“创建项目”。
- Unity 启动后,请转到“编辑”>“项目设置”>“播放器”。
- 在“检查器”面板中,找到并选择绿色的“Windows Store”图标。
- 展开“其他设置”。
- 在“渲染”部分中,选中“支持的虚拟现实”选项。
- 验证 “Windows Holographic” 是否出现在“虚拟现实 SDK” 的列表中。 如果未显示,请在列表底部选择 + 按钮,然后选择“Windows Holographic”。
- 展开“发布设置”。
- 在“功能”部分,检查以下设置:
- InternetClientServer
- PrivateNetworkClientServer
- Microphone
- SpatialPerception
- 转到“编辑”>“项目设置”>“质量”
- 在“检查器”面板的 Windows Store 图标下,选择“默认”行下的黑色下拉箭头,将默认设置更改为“非常低”。
- 转到“资产”>“导入包”>“自定义包”。
- 导航到“...\HolographicAcademy-Holograms-230-SpatialMapping\Starting”文件夹。
- 单击“Planetarium.unitypackage”。
- 单击“打开”。
- 此时应出现“导入 Unity 包”窗口,单击“导入”按钮。
- 等待 Unity 导入完成此项目所需的所有资产。
- 在“层次结构”面板中,删除“主相机”。
- 在“项目”面板的“HoloToolkit-SpatialMapping-230\Utilities\Prefabs”文件夹中,找到“主相机”对象。
- 将“主相机”预制件拖放到“层次结构”面板中。
- 在“层次结构”面板中,删除“定向光”对象。
- 在“项目”面板的“Holograms”文件夹中,找到“光标”对象。
- 将“光标”预制件拖放到“层次结构”中。
- 在“层次结构”面板中,选择“光标”对象。
- 在“检查器”面板中,单击“层”下拉列表,然后选择“编辑层…”。
- 将“用户层 31”命名为“SpatialMapping”。
- 保存新场景:“文件”>“将场景另存为…”
- 单击“新建文件夹”并将文件夹命名为“Scenes”。
- 将文件命名为“Planetarium”,并将其保存到“Scenes”文件夹中。
第 1 章 - 扫描
目标
- 了解 SurfaceObserver 及其设置如何影响体验和性能。
- 创建房间扫描体验以收集房间的网格。
说明
- 在“项目”面板的“HoloToolkit-SpatialMapping-230\SpatialMapping\Prefabs”文件夹中,找到“SpatialMapping”预制件。
- 将“SpatialMapping”预制件拖放到“层次结构”面板中。
“生成并部署(第 1 部分)”
- 在 Unity 中,选择“文件”>“生成设置”。
- 单击“添加开放式场景”将“Planetarium”场景添加到生成中。
- 在“平台”列表中选择“通用 Windows 平台”,然后单击“切换平台”。
- 将“SDK”设置为“通用 10”,并将“UWP 生成类型”设置为“D3D”。
- 选中“Unity C# 项目”。
- 单击“生成”。
- 创建名为“App”的新文件夹。
- 单击“App”文件夹。
- 按“选择文件夹”按钮。
- Unity 完成生成后,将出现一个文件资源管理器窗口。
- 双击“App”文件夹,将其打开。
- 双击 “Planetarium.sln”,将项目加载到 Visual Studio 中。
- 在 Visual Studio 中,使用顶部工具栏,将“配置”更改为“发布”。
- 将“平台”更改为“x86”。
- 单击“本地计算机”右侧的下拉箭头,然后选择“远程计算机”。
- 在“地址”字段中输入设备的 IP 地址,并将“身份验证模式”更改为“通用(未加密协议)”。
- 单击“调试”>“开始执行(不调试)”或按 Ctrl+F5。
- 在 Visual Studio 中查看“输出”面板,了解生成和部署状态。
- 部署应用后,请在房间四处走动。 你将看到周围的图面被黑色和白色线框网格覆盖。
- 扫描你周围的环境。 务必查看墙壁、天花板和地板。
生成并部署(第 2 部分)
现在,让我们了解空间映射如何影响性能。
- 在 Unity 中,选择“窗口”>“探查器”。
- 单击“添加探查器”>“GPU”。
- 单击“活动探查器”><输入 IP>。
- 输入 HoloLens 的“IP 地址”。
- 单击“连接”。
- 观察 GPU 渲染帧所需的毫秒数。
- 阻止应用程序在设备上运行。
- 返回到 Visual Studio 并打开 “SpatialMappingObserver.cs”。 你将在 Assembly-CSharp(通用 Windows)项目的“HoloToolkit\SpatialMapping”文件夹中找到它。
- 找到 “Awake()” 函数,并添加以下代码行:“TrianglesPerCubicMeter = 1200”;
- 将项目重新部署到设备,然后“重新连接探查器”。 观察渲染帧的毫秒数变化。
- 阻止应用程序在设备上运行。
“保存并在 Unity 中加载”
最后,保存房间网格,并将其加载到 Unity。
- 返回 Visual Studio,并删除在上一部分于 “Awake()” 函数中添加的 “TrianglesPerCubicMeter” 行。
- 将项目重新部署到设备。 我们现在应该以每立方米 “500” 个三角形运行。
- 打开浏览器并输入 HoloLens IP 地址以导航到“Windows 设备门户”。
- 在左侧面板中选择“3D 视图”选项。
- 在“图面重构”下,选择“更新”按钮。
- 观察已在 HoloLens 上扫描的区域是否出现在显示窗口中。
- 若要保存房间扫描,请按“保存”按钮。
- 打开“Downloads”文件夹以查找保存的房间模型 “SRMesh.obj”。
- 将 “SRMesh.obj” 复制到 Unity 项目的“Assets”文件夹。
- 在 Unity 中,选择“层次结构”面板中的 “SpatialMapping” 对象。
- 找到“对象图面观察器(脚本)”组件。
- 单击“房间模型”属性右侧的圆圈。
- 找到并选择 “SRMesh” 对象,然后关闭窗口。
- 验证“检查器”面板中“房间模型”属性现在是否设置为“SRMesh”。
- 按“播放”按钮进入 Unity 预览模式。
- SpatialMapping 组件将从保存的房间模型加载网格,以便你可以在 Unity 中使用它们。
- 切换到“场景”视图,查看以线框着色器显示的所有房间模型。
- 再次按“播放”按钮退出预览模式。
“注意”:下次进入 Unity 的预览模式时,它会默认加载保存的房间网格。
第 2 章 - 可视化
目标
- 了解着色器的基本知识。
- 可视化你的环境。
说明
- 在 Unity 的“层次结构”面板中,选择“SpatialMapping”对象。
- 在“检查器”面板中,找到“空间映射管理器(脚本)”组件。
- 单击“图面材料”属性右侧的圆圈。
- 找到并选择 “BlueLinesOnWalls” 材料并关闭窗口。
- 在“项目”面板的“Shaders”文件夹中,双击“BlueLinesOnWalls”,在 Visual Studio 中打开该着色器。
- 这是一个简单的像素(顶点到片段)着色器,用于完成以下任务:
- 将顶点的位置转换为世界空间。
- 检查顶点的法线,确定像素是否为垂直。
- 设置要渲染的像素的颜色。
生成和部署
- 返回到 Unity,按“播放”以进入预览模式。
- 蓝线将渲染在房间网格的所有垂直图面上(从我们保存的扫描数据自动加载)。
- 切换到“场景”选项卡以调整房间的视图,并查看整个房间网格在 Unity 中的显示方式。
- 在“项目”面板中,找到“Materials”文件夹,选择“BlueLinesOnWalls”材料。
- 修改某些属性,并查看更改在 Unity 编辑器中的显示方式。
- 在“检查器”面板中,调整 “LineScale” 值,使线条显得更粗或更细。
- 在“检查器”面板中,调整 “LinesPerMeter” 值以更改每面墙壁上显示的线条数。
- 再次单击“播放”以退出预览模式。
- 生成并部署到 HoloLens,并观察着色器渲染在真实图面上的显示方式。
Unity 在预览材料方面表现出色,但在设备中签出渲染一般来说比较好。
第 3 章 - 处理
目标
- 了解处理空间映射数据以在应用程序中使用的技术。
- 分析空间映射数据以查找平面并删除三角形。
- 使用平面进行全息影像放置。
说明
- 在 Unity 的“项目”面板的“Holograms”文件夹中,找到“SpatialProcessing”对象。
- 将“SpatialProcessing”对象拖放到“层次结构”面板中。
SpatialProcessing 预制件包含用于处理空间映射数据的组件。 “SurfaceMeshesToPlanes.cs” 将根据空间映射数据查找并生成平面。 我们将在应用程序中使用平面来表示墙壁、地板和天花板。 此预制件还包含 “RemoveSurfaceVertices.cs”,可从空间映射网格中删除顶点。 这可用于在网格中创建孔,或删除不再需要的多余三角形(因为可改用平面)。
- 在 Unity 的“项目”面板的“Holograms”文件夹中,找到“SpaceCollection”对象。
- 将“SpaceCollection”对象拖放到“层次结构”面板中。
- 在“层次结构”面板中,选择“SpatialProcessing”对象。
- 在“检查器”面板中,找到“播放空间管理器(脚本)”组件。
- 双击“PlaySpaceManager.cs”,在 Visual Studio 中打开它。
PlaySpaceManager.cs 包含特定于应用程序的代码。 我们将向此脚本添加功能,以实现以下行为:
- 超过扫描时间限制(10 秒)后,停止收集空间映射数据。
- 处理空间映射数据:
- 使用 SurfaceMeshesToPlanes 创建将世界表示为平面(墙壁、地板、天花板等)的更简单表示形式。
- 使用 RemoveSurfaceVertices 可删除位于平面边界内的图面三角形。
- 生成世界中的全息影像集合,并将其放置在用户附近的墙壁和地板平面上。
完成 PlaySpaceManager.cs 中标记的编码练习,或从下面将脚本替换为完成的解决方案:
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Windows.Speech;
using Academy.HoloToolkit.Unity;
/// <summary>
/// The SurfaceManager class allows applications to scan the environment for a specified amount of time
/// and then process the Spatial Mapping Mesh (find planes, remove vertices) after that time has expired.
/// </summary>
public class PlaySpaceManager : Singleton<PlaySpaceManager>
{
[Tooltip("When checked, the SurfaceObserver will stop running after a specified amount of time.")]
public bool limitScanningByTime = true;
[Tooltip("How much time (in seconds) that the SurfaceObserver will run after being started; used when 'Limit Scanning By Time' is checked.")]
public float scanTime = 30.0f;
[Tooltip("Material to use when rendering Spatial Mapping meshes while the observer is running.")]
public Material defaultMaterial;
[Tooltip("Optional Material to use when rendering Spatial Mapping meshes after the observer has been stopped.")]
public Material secondaryMaterial;
[Tooltip("Minimum number of floor planes required in order to exit scanning/processing mode.")]
public uint minimumFloors = 1;
[Tooltip("Minimum number of wall planes required in order to exit scanning/processing mode.")]
public uint minimumWalls = 1;
/// <summary>
/// Indicates if processing of the surface meshes is complete.
/// </summary>
private bool meshesProcessed = false;
/// <summary>
/// GameObject initialization.
/// </summary>
private void Start()
{
// Update surfaceObserver and storedMeshes to use the same material during scanning.
SpatialMappingManager.Instance.SetSurfaceMaterial(defaultMaterial);
// Register for the MakePlanesComplete event.
SurfaceMeshesToPlanes.Instance.MakePlanesComplete += SurfaceMeshesToPlanes_MakePlanesComplete;
}
/// <summary>
/// Called once per frame.
/// </summary>
private void Update()
{
// Check to see if the spatial mapping data has been processed
// and if we are limiting how much time the user can spend scanning.
if (!meshesProcessed && limitScanningByTime)
{
// If we have not processed the spatial mapping data
// and scanning time is limited...
// Check to see if enough scanning time has passed
// since starting the observer.
if (limitScanningByTime && ((Time.time - SpatialMappingManager.Instance.StartTime) < scanTime))
{
// If we have a limited scanning time, then we should wait until
// enough time has passed before processing the mesh.
}
else
{
// The user should be done scanning their environment,
// so start processing the spatial mapping data...
/* TODO: 3.a DEVELOPER CODING EXERCISE 3.a */
// 3.a: Check if IsObserverRunning() is true on the
// SpatialMappingManager.Instance.
if(SpatialMappingManager.Instance.IsObserverRunning())
{
// 3.a: If running, Stop the observer by calling
// StopObserver() on the SpatialMappingManager.Instance.
SpatialMappingManager.Instance.StopObserver();
}
// 3.a: Call CreatePlanes() to generate planes.
CreatePlanes();
// 3.a: Set meshesProcessed to true.
meshesProcessed = true;
}
}
}
/// <summary>
/// Handler for the SurfaceMeshesToPlanes MakePlanesComplete event.
/// </summary>
/// <param name="source">Source of the event.</param>
/// <param name="args">Args for the event.</param>
private void SurfaceMeshesToPlanes_MakePlanesComplete(object source, System.EventArgs args)
{
/* TODO: 3.a DEVELOPER CODING EXERCISE 3.a */
// Collection of floor and table planes that we can use to set horizontal items on.
List<GameObject> horizontal = new List<GameObject>();
// Collection of wall planes that we can use to set vertical items on.
List<GameObject> vertical = new List<GameObject>();
// 3.a: Get all floor and table planes by calling
// SurfaceMeshesToPlanes.Instance.GetActivePlanes().
// Assign the result to the 'horizontal' list.
horizontal = SurfaceMeshesToPlanes.Instance.GetActivePlanes(PlaneTypes.Table | PlaneTypes.Floor);
// 3.a: Get all wall planes by calling
// SurfaceMeshesToPlanes.Instance.GetActivePlanes().
// Assign the result to the 'vertical' list.
vertical = SurfaceMeshesToPlanes.Instance.GetActivePlanes(PlaneTypes.Wall);
// Check to see if we have enough horizontal planes (minimumFloors)
// and vertical planes (minimumWalls), to set holograms on in the world.
if (horizontal.Count >= minimumFloors && vertical.Count >= minimumWalls)
{
// We have enough floors and walls to place our holograms on...
// 3.a: Let's reduce our triangle count by removing triangles
// from SpatialMapping meshes that intersect with our active planes.
// Call RemoveVertices().
// Pass in all activePlanes found by SurfaceMeshesToPlanes.Instance.
RemoveVertices(SurfaceMeshesToPlanes.Instance.ActivePlanes);
// 3.a: We can indicate to the user that scanning is over by
// changing the material applied to the Spatial Mapping meshes.
// Call SpatialMappingManager.Instance.SetSurfaceMaterial().
// Pass in the secondaryMaterial.
SpatialMappingManager.Instance.SetSurfaceMaterial(secondaryMaterial);
// 3.a: We are all done processing the mesh, so we can now
// initialize a collection of Placeable holograms in the world
// and use horizontal/vertical planes to set their starting positions.
// Call SpaceCollectionManager.Instance.GenerateItemsInWorld().
// Pass in the lists of horizontal and vertical planes that we found earlier.
SpaceCollectionManager.Instance.GenerateItemsInWorld(horizontal, vertical);
}
else
{
// We do not have enough floors/walls to place our holograms on...
// 3.a: Re-enter scanning mode so the user can find more surfaces by
// calling StartObserver() on the SpatialMappingManager.Instance.
SpatialMappingManager.Instance.StartObserver();
// 3.a: Re-process spatial data after scanning completes by
// re-setting meshesProcessed to false.
meshesProcessed = false;
}
}
/// <summary>
/// Creates planes from the spatial mapping surfaces.
/// </summary>
private void CreatePlanes()
{
// Generate planes based on the spatial map.
SurfaceMeshesToPlanes surfaceToPlanes = SurfaceMeshesToPlanes.Instance;
if (surfaceToPlanes != null && surfaceToPlanes.enabled)
{
surfaceToPlanes.MakePlanes();
}
}
/// <summary>
/// Removes triangles from the spatial mapping surfaces.
/// </summary>
/// <param name="boundingObjects"></param>
private void RemoveVertices(IEnumerable<GameObject> boundingObjects)
{
RemoveSurfaceVertices removeVerts = RemoveSurfaceVertices.Instance;
if (removeVerts != null && removeVerts.enabled)
{
removeVerts.RemoveSurfaceVerticesWithinBounds(boundingObjects);
}
}
/// <summary>
/// Called when the GameObject is unloaded.
/// </summary>
private void OnDestroy()
{
if (SurfaceMeshesToPlanes.Instance != null)
{
SurfaceMeshesToPlanes.Instance.MakePlanesComplete -= SurfaceMeshesToPlanes_MakePlanesComplete;
}
}
}
生成和部署
- 部署到 HoloLens 之前,请按 Unity 中的“播放”按钮进入播放模式。
- 从文件加载房间网格后,请等待 10 秒钟,然后开始对空间映射网格进行处理。
- 处理完成后,平面将显示为表示地板、墙壁、天花板等。
- 找到所有平面后,你应该会看到太阳系出现在照相机附近的地板上。
- 两张海报也应显示在照相机附近的墙上。 如果在“游戏”模式下无法看到它们,则切换到“场景”选项卡。
- 再次按“播放”按钮退出播放模式。
- 像往常一样生成并部署到 HoloLens。
- 等待空间映射数据的扫描和处理完成。
- 看到平面后,尝试在你的世界找到太阳系和海报。
第 4 章 - 放置
目标
- 确定全息影像是否适合图面。
- 当全息影像可以/无法适应图面时,向用户提供反馈。
说明
- 在 Unity 的“层次结构”面板中,选择“SpatialProcessing”对象。
- 在“检查器”面板中,找到“图面网格到平面(脚本)”组件。
- 将“绘制平面”属性更改为“无”以清除选择。
- 将“绘制平面”属性更改为“墙壁”,以便仅渲染墙面。
- 在“项目”面板的“Scripts”文件夹中,双击“Placeable.cs”以在 Visual Studio 中将其打开。
“Placeable”脚本已附加到平面查找完成后创建的海报和投影框。 我们只需取消注释某些代码,此脚本将实现以下内容:
- 通过从边界立方体的中心和四个角进行光线投射,确定全息影像是否适合图面。
- 检查图面法线,确定它是否足够光滑,以使全息影像可齐平放置。
- 在全息影像周围渲染边界立方体,以在放置时显示其实际大小。
- 在全息影像下方/后面投射阴影,以显示其在地板/墙壁上的放置位置。
- 如果无法将全息影像放置在图面上,则将阴影渲染为红色;如果可放置在该图面上,则渲染为绿色。
- 重新放置全息影像,使其与具有相关性的图面类型(垂直或水平)对齐。
- 将全息影像平滑地放在所选图面上,以避免跳转或贴靠行为。
取消注释以下编码练习中的所有代码,或在 “Placeable.cs” 中使用此已完成的解决方案:
using System.Collections.Generic;
using UnityEngine;
using Academy.HoloToolkit.Unity;
/// <summary>
/// Enumeration containing the surfaces on which a GameObject
/// can be placed. For simplicity of this sample, only one
/// surface type is allowed to be selected.
/// </summary>
public enum PlacementSurfaces
{
// Horizontal surface with an upward pointing normal.
Horizontal = 1,
// Vertical surface with a normal facing the user.
Vertical = 2,
}
/// <summary>
/// The Placeable class implements the logic used to determine if a GameObject
/// can be placed on a target surface. Constraints for placement include:
/// * No part of the GameObject's box collider impacts with another object in the scene
/// * The object lays flat (within specified tolerances) against the surface
/// * The object would not fall off of the surface if gravity were enabled.
/// This class also provides the following visualizations.
/// * A transparent cube representing the object's box collider.
/// * Shadow on the target surface indicating whether or not placement is valid.
/// </summary>
public class Placeable : MonoBehaviour
{
[Tooltip("The base material used to render the bounds asset when placement is allowed.")]
public Material PlaceableBoundsMaterial = null;
[Tooltip("The base material used to render the bounds asset when placement is not allowed.")]
public Material NotPlaceableBoundsMaterial = null;
[Tooltip("The material used to render the placement shadow when placement it allowed.")]
public Material PlaceableShadowMaterial = null;
[Tooltip("The material used to render the placement shadow when placement it not allowed.")]
public Material NotPlaceableShadowMaterial = null;
[Tooltip("The type of surface on which the object can be placed.")]
public PlacementSurfaces PlacementSurface = PlacementSurfaces.Horizontal;
[Tooltip("The child object(s) to hide during placement.")]
public List<GameObject> ChildrenToHide = new List<GameObject>();
/// <summary>
/// Indicates if the object is in the process of being placed.
/// </summary>
public bool IsPlacing { get; private set; }
// The most recent distance to the surface. This is used to
// locate the object when the user's gaze does not intersect
// with the Spatial Mapping mesh.
private float lastDistance = 2.0f;
// The distance away from the target surface that the object should hover prior while being placed.
private float hoverDistance = 0.15f;
// Threshold (the closer to 0, the stricter the standard) used to determine if a surface is flat.
private float distanceThreshold = 0.02f;
// Threshold (the closer to 1, the stricter the standard) used to determine if a surface is vertical.
private float upNormalThreshold = 0.9f;
// Maximum distance, from the object, that placement is allowed.
// This is used when raycasting to see if the object is near a placeable surface.
private float maximumPlacementDistance = 5.0f;
// Speed (1.0 being fastest) at which the object settles to the surface upon placement.
private float placementVelocity = 0.06f;
// Indicates whether or not this script manages the object's box collider.
private bool managingBoxCollider = false;
// The box collider used to determine of the object will fit in the desired location.
// It is also used to size the bounding cube.
private BoxCollider boxCollider = null;
// Visible asset used to show the dimensions of the object. This asset is sized
// using the box collider's bounds.
private GameObject boundsAsset = null;
// Visible asset used to show the where the object is attempting to be placed.
// This asset is sized using the box collider's bounds.
private GameObject shadowAsset = null;
// The location at which the object will be placed.
private Vector3 targetPosition;
/// <summary>
/// Called when the GameObject is created.
/// </summary>
private void Awake()
{
targetPosition = gameObject.transform.position;
// Get the object's collider.
boxCollider = gameObject.GetComponent<BoxCollider>();
if (boxCollider == null)
{
// The object does not have a collider, create one and remember that
// we are managing it.
managingBoxCollider = true;
boxCollider = gameObject.AddComponent<BoxCollider>();
boxCollider.enabled = false;
}
// Create the object that will be used to indicate the bounds of the GameObject.
boundsAsset = GameObject.CreatePrimitive(PrimitiveType.Cube);
boundsAsset.transform.parent = gameObject.transform;
boundsAsset.SetActive(false);
// Create a object that will be used as a shadow.
shadowAsset = GameObject.CreatePrimitive(PrimitiveType.Quad);
shadowAsset.transform.parent = gameObject.transform;
shadowAsset.SetActive(false);
}
/// <summary>
/// Called when our object is selected. Generally called by
/// a gesture management component.
/// </summary>
public void OnSelect()
{
/* TODO: 4.a CODE ALONG 4.a */
if (!IsPlacing)
{
OnPlacementStart();
}
else
{
OnPlacementStop();
}
}
/// <summary>
/// Called once per frame.
/// </summary>
private void Update()
{
/* TODO: 4.a CODE ALONG 4.a */
if (IsPlacing)
{
// Move the object.
Move();
// Set the visual elements.
Vector3 targetPosition;
Vector3 surfaceNormal;
bool canBePlaced = ValidatePlacement(out targetPosition, out surfaceNormal);
DisplayBounds(canBePlaced);
DisplayShadow(targetPosition, surfaceNormal, canBePlaced);
}
else
{
// Disable the visual elements.
boundsAsset.SetActive(false);
shadowAsset.SetActive(false);
// Gracefully place the object on the target surface.
float dist = (gameObject.transform.position - targetPosition).magnitude;
if (dist > 0)
{
gameObject.transform.position = Vector3.Lerp(gameObject.transform.position, targetPosition, placementVelocity / dist);
}
else
{
// Unhide the child object(s) to make placement easier.
for (int i = 0; i < ChildrenToHide.Count; i++)
{
ChildrenToHide[i].SetActive(true);
}
}
}
}
/// <summary>
/// Verify whether or not the object can be placed.
/// </summary>
/// <param name="position">
/// The target position on the surface.
/// </param>
/// <param name="surfaceNormal">
/// The normal of the surface on which the object is to be placed.
/// </param>
/// <returns>
/// True if the target position is valid for placing the object, otherwise false.
/// </returns>
private bool ValidatePlacement(out Vector3 position, out Vector3 surfaceNormal)
{
Vector3 raycastDirection = gameObject.transform.forward;
if (PlacementSurface == PlacementSurfaces.Horizontal)
{
// Placing on horizontal surfaces.
// Raycast from the bottom face of the box collider.
raycastDirection = -(Vector3.up);
}
// Initialize out parameters.
position = Vector3.zero;
surfaceNormal = Vector3.zero;
Vector3[] facePoints = GetColliderFacePoints();
// The origin points we receive are in local space and we
// need to raycast in world space.
for (int i = 0; i < facePoints.Length; i++)
{
facePoints[i] = gameObject.transform.TransformVector(facePoints[i]) + gameObject.transform.position;
}
// Cast a ray from the center of the box collider face to the surface.
RaycastHit centerHit;
if (!Physics.Raycast(facePoints[0],
raycastDirection,
out centerHit,
maximumPlacementDistance,
SpatialMappingManager.Instance.LayerMask))
{
// If the ray failed to hit the surface, we are done.
return false;
}
// We have found a surface. Set position and surfaceNormal.
position = centerHit.point;
surfaceNormal = centerHit.normal;
// Cast a ray from the corners of the box collider face to the surface.
for (int i = 1; i < facePoints.Length; i++)
{
RaycastHit hitInfo;
if (Physics.Raycast(facePoints[i],
raycastDirection,
out hitInfo,
maximumPlacementDistance,
SpatialMappingManager.Instance.LayerMask))
{
// To be a valid placement location, each of the corners must have a similar
// enough distance to the surface as the center point
if (!IsEquivalentDistance(centerHit.distance, hitInfo.distance))
{
return false;
}
}
else
{
// The raycast failed to intersect with the target layer.
return false;
}
}
return true;
}
/// <summary>
/// Determine the coordinates, in local space, of the box collider face that
/// will be placed against the target surface.
/// </summary>
/// <returns>
/// Vector3 array with the center point of the face at index 0.
/// </returns>
private Vector3[] GetColliderFacePoints()
{
// Get the collider extents.
// The size values are twice the extents.
Vector3 extents = boxCollider.size / 2;
// Calculate the min and max values for each coordinate.
float minX = boxCollider.center.x - extents.x;
float maxX = boxCollider.center.x + extents.x;
float minY = boxCollider.center.y - extents.y;
float maxY = boxCollider.center.y + extents.y;
float minZ = boxCollider.center.z - extents.z;
float maxZ = boxCollider.center.z + extents.z;
Vector3 center;
Vector3 corner0;
Vector3 corner1;
Vector3 corner2;
Vector3 corner3;
if (PlacementSurface == PlacementSurfaces.Horizontal)
{
// Placing on horizontal surfaces.
center = new Vector3(boxCollider.center.x, minY, boxCollider.center.z);
corner0 = new Vector3(minX, minY, minZ);
corner1 = new Vector3(minX, minY, maxZ);
corner2 = new Vector3(maxX, minY, minZ);
corner3 = new Vector3(maxX, minY, maxZ);
}
else
{
// Placing on vertical surfaces.
center = new Vector3(boxCollider.center.x, boxCollider.center.y, maxZ);
corner0 = new Vector3(minX, minY, maxZ);
corner1 = new Vector3(minX, maxY, maxZ);
corner2 = new Vector3(maxX, minY, maxZ);
corner3 = new Vector3(maxX, maxY, maxZ);
}
return new Vector3[] { center, corner0, corner1, corner2, corner3 };
}
/// <summary>
/// Put the object into placement mode.
/// </summary>
public void OnPlacementStart()
{
// If we are managing the collider, enable it.
if (managingBoxCollider)
{
boxCollider.enabled = true;
}
// Hide the child object(s) to make placement easier.
for (int i = 0; i < ChildrenToHide.Count; i++)
{
ChildrenToHide[i].SetActive(false);
}
// Tell the gesture manager that it is to assume
// all input is to be given to this object.
GestureManager.Instance.OverrideFocusedObject = gameObject;
// Enter placement mode.
IsPlacing = true;
}
/// <summary>
/// Take the object out of placement mode.
/// </summary>
/// <remarks>
/// This method will leave the object in placement mode if called while
/// the object is in an invalid location. To determine whether or not
/// the object has been placed, check the value of the IsPlacing property.
/// </remarks>
public void OnPlacementStop()
{
// ValidatePlacement requires a normal as an out parameter.
Vector3 position;
Vector3 surfaceNormal;
// Check to see if we can exit placement mode.
if (!ValidatePlacement(out position, out surfaceNormal))
{
return;
}
// The object is allowed to be placed.
// We are placing at a small buffer away from the surface.
targetPosition = position + (0.01f * surfaceNormal);
OrientObject(true, surfaceNormal);
// If we are managing the collider, disable it.
if (managingBoxCollider)
{
boxCollider.enabled = false;
}
// Tell the gesture manager that it is to resume
// its normal behavior.
GestureManager.Instance.OverrideFocusedObject = null;
// Exit placement mode.
IsPlacing = false;
}
/// <summary>
/// Positions the object along the surface toward which the user is gazing.
/// </summary>
/// <remarks>
/// If the user's gaze does not intersect with a surface, the object
/// will remain at the most recently calculated distance.
/// </remarks>
private void Move()
{
Vector3 moveTo = gameObject.transform.position;
Vector3 surfaceNormal = Vector3.zero;
RaycastHit hitInfo;
bool hit = Physics.Raycast(Camera.main.transform.position,
Camera.main.transform.forward,
out hitInfo,
20f,
SpatialMappingManager.Instance.LayerMask);
if (hit)
{
float offsetDistance = hoverDistance;
// Place the object a small distance away from the surface while keeping
// the object from going behind the user.
if (hitInfo.distance <= hoverDistance)
{
offsetDistance = 0f;
}
moveTo = hitInfo.point + (offsetDistance * hitInfo.normal);
lastDistance = hitInfo.distance;
surfaceNormal = hitInfo.normal;
}
else
{
// The raycast failed to hit a surface. In this case, keep the object at the distance of the last
// intersected surface.
moveTo = Camera.main.transform.position + (Camera.main.transform.forward * lastDistance);
}
// Follow the user's gaze.
float dist = Mathf.Abs((gameObject.transform.position - moveTo).magnitude);
gameObject.transform.position = Vector3.Lerp(gameObject.transform.position, moveTo, placementVelocity / dist);
// Orient the object.
// We are using the return value from Physics.Raycast to instruct
// the OrientObject function to align to the vertical surface if appropriate.
OrientObject(hit, surfaceNormal);
}
/// <summary>
/// Orients the object so that it faces the user.
/// </summary>
/// <param name="alignToVerticalSurface">
/// If true and the object is to be placed on a vertical surface,
/// orient parallel to the target surface. If false, orient the object
/// to face the user.
/// </param>
/// <param name="surfaceNormal">
/// The target surface's normal vector.
/// </param>
/// <remarks>
/// The alignToVerticalSurface parameter is ignored if the object
/// is to be placed on a horizontalSurface
/// </remarks>
private void OrientObject(bool alignToVerticalSurface, Vector3 surfaceNormal)
{
Quaternion rotation = Camera.main.transform.localRotation;
// If the user's gaze does not intersect with the Spatial Mapping mesh,
// orient the object towards the user.
if (alignToVerticalSurface && (PlacementSurface == PlacementSurfaces.Vertical))
{
// We are placing on a vertical surface.
// If the normal of the Spatial Mapping mesh indicates that the
// surface is vertical, orient parallel to the surface.
if (Mathf.Abs(surfaceNormal.y) <= (1 - upNormalThreshold))
{
rotation = Quaternion.LookRotation(-surfaceNormal, Vector3.up);
}
}
else
{
rotation.x = 0f;
rotation.z = 0f;
}
gameObject.transform.rotation = rotation;
}
/// <summary>
/// Displays the bounds asset.
/// </summary>
/// <param name="canBePlaced">
/// Specifies if the object is in a valid placement location.
/// </param>
private void DisplayBounds(bool canBePlaced)
{
// Ensure the bounds asset is sized and positioned correctly.
boundsAsset.transform.localPosition = boxCollider.center;
boundsAsset.transform.localScale = boxCollider.size;
boundsAsset.transform.rotation = gameObject.transform.rotation;
// Apply the appropriate material.
if (canBePlaced)
{
boundsAsset.GetComponent<Renderer>().sharedMaterial = PlaceableBoundsMaterial;
}
else
{
boundsAsset.GetComponent<Renderer>().sharedMaterial = NotPlaceableBoundsMaterial;
}
// Show the bounds asset.
boundsAsset.SetActive(true);
}
/// <summary>
/// Displays the placement shadow asset.
/// </summary>
/// <param name="position">
/// The position at which to place the shadow asset.
/// </param>
/// <param name="surfaceNormal">
/// The normal of the surface on which the asset will be placed
/// </param>
/// <param name="canBePlaced">
/// Specifies if the object is in a valid placement location.
/// </param>
private void DisplayShadow(Vector3 position,
Vector3 surfaceNormal,
bool canBePlaced)
{
// Rotate and scale the shadow so that it is displayed on the correct surface and matches the object.
float rotationX = 0.0f;
if (PlacementSurface == PlacementSurfaces.Horizontal)
{
rotationX = 90.0f;
shadowAsset.transform.localScale = new Vector3(boxCollider.size.x, boxCollider.size.z, 1);
}
else
{
shadowAsset.transform.localScale = boxCollider.size;
}
Quaternion rotation = Quaternion.Euler(rotationX, gameObject.transform.rotation.eulerAngles.y, 0);
shadowAsset.transform.rotation = rotation;
// Apply the appropriate material.
if (canBePlaced)
{
shadowAsset.GetComponent<Renderer>().sharedMaterial = PlaceableShadowMaterial;
}
else
{
shadowAsset.GetComponent<Renderer>().sharedMaterial = NotPlaceableShadowMaterial;
}
// Show the shadow asset as appropriate.
if (position != Vector3.zero)
{
// Position the shadow a small distance from the target surface, along the normal.
shadowAsset.transform.position = position + (0.01f * surfaceNormal);
shadowAsset.SetActive(true);
}
else
{
shadowAsset.SetActive(false);
}
}
/// <summary>
/// Determines if two distance values should be considered equivalent.
/// </summary>
/// <param name="d1">
/// Distance to compare.
/// </param>
/// <param name="d2">
/// Distance to compare.
/// </param>
/// <returns>
/// True if the distances are within the desired tolerance, otherwise false.
/// </returns>
private bool IsEquivalentDistance(float d1, float d2)
{
float dist = Mathf.Abs(d1 - d2);
return (dist <= distanceThreshold);
}
/// <summary>
/// Called when the GameObject is unloaded.
/// </summary>
private void OnDestroy()
{
// Unload objects we have created.
Destroy(boundsAsset);
boundsAsset = null;
Destroy(shadowAsset);
shadowAsset = null;
}
}
生成和部署
- 与前面一样,生成项目并部署到 HoloLens。
- 等待空间映射数据的扫描和处理完成。
- 看到太阳系时,凝视下面的投影框并执行选择手势来移动它。 选中投影框时,投影框周围将出现一个边界立方体。
- 移动头部,凝视房间中的不同位置。 投影框应随你的凝视移动。 当投影框下方的阴影变为红色时,你无法将全息影像放在该图面上。 当投影框下方的阴影变为绿色时,你可通过执行另一个选择手势来将全息影像放在该图面上。
- 在墙上找到并选择其中一个全息海报,将其移动到新位置。 请注意,不能将海报放在地板或天花板上,并且当你四处移动时,它始终正确面向每面墙。
第 5 章 - 遮挡
目标
- 确定全息影像是否被空间映射网格遮挡。
- 应用不同的遮挡技术来实现有趣的效果。
说明
首先,我们将允许空间映射网格遮挡其他全息影像,而不遮挡现实世界:
- 在“层次结构”面板中,选择“SpatialProcessing”对象。
- 在“检查器”面板中,找到“播放空间管理器(脚本)”组件。
- 单击“辅助材料”属性右侧的圆圈。
- 找到并选择“Occlusion”材料并关闭窗口。
接下来,我们将向地球添加一个特殊行为,以便每当它被另一个全息影像(如太阳)或空间映射网格遮挡时,它都会有一个蓝色高亮颜色:
- 在“项目”面板的“Holograms”文件夹中,展开“SolarSystem”对象。
- 单击“地球”。
- 在“检查器”面板中,找到地球的材料(底部组件)。
- 在“着色器”下拉列表中,将着色器更改为“自定义”>“OcclusionRim”。 每当另一个对象遮挡地球时,这都会在地球周围渲染蓝色高亮颜色。
最后,我们将为太阳系中的行星启用 X 射线视觉效果。 我们需要编辑 “PlanetOcclusion.cs”(在 Scripts\SolarSystem 文件夹中找到)以实现以下内容:
- 确定行星是否被 SpatialMapping 层(房间网格和图面)遮挡。
- 每当行星被 SpatialMapping 层遮挡时,显示行星的线框表示形式。
- 当行星未被 SpatialMapping 层阻挡时,隐藏行星的线框表示形式。
遵循 PlanetOcclusion.cs 中的编码练习,或使用以下解决方案:
using UnityEngine;
using Academy.HoloToolkit.Unity;
/// <summary>
/// Determines when the occluded version of the planet should be visible.
/// This script allows us to do selective occlusion, so the occlusionObject
/// will only be rendered when a Spatial Mapping surface is occluding the planet,
/// not when another hologram is responsible for the occlusion.
/// </summary>
public class PlanetOcclusion : MonoBehaviour
{
[Tooltip("Object to display when the planet is occluded.")]
public GameObject occlusionObject;
/// <summary>
/// Points to raycast to when checking for occlusion.
/// </summary>
private Vector3[] checkPoints;
// Use this for initialization
void Start()
{
occlusionObject.SetActive(false);
// Set the check points to use when testing for occlusion.
MeshFilter filter = gameObject.GetComponent<MeshFilter>();
Vector3 extents = filter.mesh.bounds.extents;
Vector3 center = filter.mesh.bounds.center;
Vector3 top = new Vector3(center.x, center.y + extents.y, center.z);
Vector3 left = new Vector3(center.x - extents.x, center.y, center.z);
Vector3 right = new Vector3(center.x + extents.x, center.y, center.z);
Vector3 bottom = new Vector3(center.x, center.y - extents.y, center.z);
checkPoints = new Vector3[] { center, top, left, right, bottom };
}
// Update is called once per frame
void Update()
{
/* TODO: 5.a DEVELOPER CODING EXERCISE 5.a */
// Check to see if any of the planet's boundary points are occluded.
for (int i = 0; i < checkPoints.Length; i++)
{
// 5.a: Convert the current checkPoint to world coordinates.
// Call gameObject.transform.TransformPoint(checkPoints[i]).
// Assign the result to a new Vector3 variable called 'checkPt'.
Vector3 checkPt = gameObject.transform.TransformPoint(checkPoints[i]);
// 5.a: Call Vector3.Distance() to calculate the distance
// between the Main Camera's position and 'checkPt'.
// Assign the result to a new float variable called 'distance'.
float distance = Vector3.Distance(Camera.main.transform.position, checkPt);
// 5.a: Take 'checkPt' and subtract the Main Camera's position from it.
// Assign the result to a new Vector3 variable called 'direction'.
Vector3 direction = checkPt - Camera.main.transform.position;
// Used to indicate if the call to Physics.Raycast() was successful.
bool raycastHit = false;
// 5.a: Check if the planet is occluded by a spatial mapping surface.
// Call Physics.Raycast() with the following arguments:
// - Pass in the Main Camera's position as the origin.
// - Pass in 'direction' for the direction.
// - Pass in 'distance' for the maxDistance.
// - Pass in SpatialMappingManager.Instance.LayerMask as layerMask.
// Assign the result to 'raycastHit'.
raycastHit = Physics.Raycast(Camera.main.transform.position, direction, distance, SpatialMappingManager.Instance.LayerMask);
if (raycastHit)
{
// 5.a: Our raycast hit a surface, so the planet is occluded.
// Set the occlusionObject to active.
occlusionObject.SetActive(true);
// At least one point is occluded, so break from the loop.
break;
}
else
{
// 5.a: The Raycast did not hit, so the planet is not occluded.
// Deactivate the occlusionObject.
occlusionObject.SetActive(false);
}
}
}
}
生成和部署
- 像往常一样将应用程序生成并部署到 HoloLens。
- 等待空间映射数据的扫描和处理完成(你应看到蓝色线条显示在墙上)。
- 找到并选择太阳系的投影框,然后将该框放在墙旁边或柜台后面。
- 可以通过隐藏在图面后面来观察海报或投影框以查看基本遮挡。
- 找到地球,每当它移动到另一个全息影像或图面后面时,都应该有一个蓝色高亮颜色效果。
- 观察行星在房间的墙壁或其他图面后面移动。 现在,你已拥有 X 射线视觉,并可以看到其线框框架!
结束
祝贺你! 你现已完成“MR 空间 230:空间映射”。
- 你知道如何扫描环境,以及如何将空间映射数据加载到 Unity。
- 你了解着色器的基本信息,以及如何使用材料来重新可视化世界。
- 你学习了用于查找平面和从网格中删除三角形的新处理技术。
- 你能够移动全息影像,并将全息影像放在有意义的图面上。
- 你体验了不同的遮挡技术,并利用了 X 射线视觉的强大功能!