案例研究 - 扩展 HoloLens 的空间映射功能

在为 Microsoft HoloLens 创建我们的第一个应用时,我们希望了解可以在多大程度上推动设备上空间映射的界限。 Microsoft Studios 的软件工程师 Jeff Evertt 解释了如何开发出一项新技术以更好地控制全息影像在用户真实环境中的定位方式。

注意

HoloLens 2 实现新的场景理解运行时,为混合现实开发人员提供了结构化的高级别环境表示,旨在直观地为环境感知型应用程序进行开发。

观看视频

超越空间映射

在我们开发最初几款 HoloLens 游戏中的 FragmentsYoung Conker 这两款游戏时,我们发现,当我们在物理世界中对全息影像执行过程定位时,需要对用户环境有更高程度的理解。 每款游戏都有其特定的定位需求:例如,在 Fragments 中,我们希望能够区分不同的表面(例如地面或桌子),以将提示放置在相关位置。 我们还希望能够识别真人大小的全息人物可以坐到的表面,例如沙发或椅子。 在 Young Conker 中,我们希望 Conker 和他的对手能够使用玩家房间的凸起表面作为平台。

我们这些游戏的开发合作伙伴 Asobo Studios 直面了这个问题,并开发了一种技术来扩展 HoloLens 的空间映射功能。 使用该技术,我们可以分析玩家的房间并识别墙壁、桌子、椅子和地面等表面。 它还使我们能够根据一组约束进行优化,以确定全息对象的最佳位置。

空间理解代码

我们采用了 Asobo 的原始代码,并创建了一个封装该技术的库。 Microsoft 和 Asobo 现在已开源此代码并在 MixedRealityToolkit 上提供,可供你在自己的项目中使用。 其中包含了所有源代码,你可以根据需要对其进行自定义并与社区分享你的改进看法。 C++ 求解器的代码已封装到 UWP DLL 中,并通过包含在 MixedRealityToolkit 中的嵌入式预制件公开给 Unity。

Unity 示例中包含许多有用的查询,让你可以查找墙壁上的空白空间,将对象放置在天花板上或放置在地面上的较大空间中,识别角色坐的位置,以及执行其他无数的空间理解查询。

虽然 HoloLens 提供的空间映射解决方案在设计上足够通用,可以满足所有问题空间的需求,但构建空间理解模块是为了支持两款特定游戏的需求。 因此,它的解决方案是围绕特定过程和一组假设构建的:

  • “固定大小的游戏空间”:用户在 init 调用中指定最大游戏空间大小
  • “一次性扫描过程”:该过程需要一个用户可以在其中四处走动的离散扫描阶段,用于定义游戏空间。 只有在扫描完成后,查询功能才会起作用。
  • 用户驱动的游戏空间“绘制”:在扫描阶段,用户移动并环顾游戏空间,从而有效地绘制出所要包括的区域。 在此阶段,生成的网格对于提供用户反馈非常重要。
  • “家庭或办公室室内布置”:查询功能是围绕平面和墙壁按直角设计的。 这是一种软限制。 但是,在扫描阶段,会完成主轴分析以优化长轴和短轴的网格细分。

房间扫描过程

加载空间理解模块时,要做的第一件事就是扫描空间,从而识别并标记所有可用表面 — 例如地面、天花板和墙壁。 在扫描过程中,环顾房间并“绘制”要包括在扫描范围内的区域。

在此阶段看到的网格是重要的视觉反馈片段,让用户知道正在扫描房间的哪些部分。 空间理解模块的 DLL 在内部将游戏空间存储为 8 厘米大小的体素立方体网格。 在扫描的初始环节,完成主要成分分析以确定房间的轴。 在内部,该模块存储与这些轴对齐的体素空间。 大约每秒通过从体素体中提取等值面生成一个网格。

Spatial mapping mesh in white and understanding playspace mesh in green

以白色表示的空间映射网格和以绿色表示的理解游戏空间网格

包含的 SpatialUnderstanding.cs 文件管理扫描阶段过程。 它调用以下函数:

  • “SpatialUnderstanding_Init”:在启动时调用一次
  • “GeneratePlayspace_InitScan”:指示扫描阶段应该开始
  • “GeneratePlayspace_UpdateScan_DynamicScan”:调用每一帧来更新扫描过程。 相机位置和方向被传入并用于游戏空间绘制过程,如上所述。
  • “GeneratePlayspace_RequestFinish”:调用此函数以完成游戏空间。 这会使用在扫描阶段“绘制”的区域来定义和锁定游戏空间。 应用程序可以在扫描阶段查询统计数据,也可以查询自定义网格以提供用户反馈。
  • “Import_UnderstandingMesh”:在扫描期间,由模块提供并放置在理解预制件上的 “SpatialUnderstandingCustomMesh” 行为将定期查询过程生成的自定义网格。 此外,扫描完成后会再次执行此操作。

由 “SpatialUnderstanding” 行为驱动的扫描流调用 “InitScan”,然后对每一帧调用 “UpdateScan”。 当统计数据查询报告了合理的覆盖范围时,用户可以通过隔空敲击手势来调用 “RequestFinish” 以指示扫描阶段结束。 持续调用 “UpdateScan”,直到它的返回值指示 DLL 已完成处理

查询

扫描完成后,可以在接口中访问三种不同类型的查询:

  • “拓扑查询”:基于被扫描房间的拓扑的快速查询
  • “形状查询”:这些查询利用拓扑查询的结果来查找与你定义的自定义形状非常匹配的水平表面
  • “对象位置查询”:这些查询更复杂,它们根据对象的一组规则和约束查找最合适的位置

除了三个主要查询之外,还有一个光线投射接口,使用它可以检索标记的表面类型,并可以复制出自定义的密封房间网格。

拓扑查询

在 DLL 中,拓扑管理器处理环境的标记。 如上所述,大部分数据存储在体素体中包含的面元内。 此外,“PlaySpaceInfos” 结构用于存储有关游戏空间的信息,包括世界对齐(下文提供了更多详细信息)、地面和天花板高度

试探法用于确定地面、天花板和墙壁。 例如,表面积大于 1 平方米的最大和最低水平表面被视为地面。 请注意,在扫描期间使用的相机路径也在此过程中使用。

拓扑管理器公开的一部分查询通过 DLL 公开。 公开的拓扑查询如下:

  • QueryTopology_FindPositionsOnWalls
  • QueryTopology_FindLargePositionsOnWalls
  • QueryTopology_FindLargestWall
  • QueryTopology_FindPositionsOnFloor
  • QueryTopology_FindLargestPositionsOnFloor
  • QueryTopology_FindPositionsSittable

每个查询都有一组特定于查询类型的参数。 在以下示例中,用户指定所需立体的最小高度和宽度、地面上方的最小放置高度,以及立体前面的最小净空量。 所有测量值均以米为单位。

EXTERN_C __declspec(dllexport) int QueryTopology_FindPositionsOnWalls(
          _In_ float minHeightOfWallSpace,
          _In_ float minWidthOfWallSpace,
          _In_ float minHeightAboveFloor,
          _In_ float minFacingClearance,
          _In_ int locationCount,
          _Inout_ Dll_Interface::TopologyResult* locationData)

其中每个查询采用预分配的 “TopologyResult” 结构数组。 “locationCount” 参数指定传入的数组的长度。 返回值报告返回的位置数。 此数字永远不会大于传入的 “locationCount” 参数

“TopologyResult” 包含返回的立体的中心位置、朝向(即法线)以及找到的空间的尺寸

struct TopologyResult
     {
          DirectX::XMFLOAT3 position;
          DirectX::XMFLOAT3 normal;
          float width;
          float length;
     };

请注意,在 Unity 示例中,其中每个查询已链接到虚拟 UI 面板中的某个按钮。 该示例将其中每个查询的参数硬编码为合理值。 有关更多示例,请参阅示例代码中的“SpaceVisualizer.cs”

形状查询

在 DLL 内部,形状分析器 (“ShapeAnalyzer_W”) 使用拓扑分析器来匹配用户定义的自定义形状。 Unity 示例包含一组预定义的形状,这些形状显示在形状选项卡上的查询菜单中。

请注意,形状分析仅适用于水平表面。 例如,沙发是由平坦的座椅表面和沙发靠背的平坦顶部定义的。 形状查询查找具有特定大小、高度和纵横比范围的两个表面,这两个表面相互对齐并连接。 根据 API 的术语,沙发座椅和沙发靠背的顶部是形状组件,对齐要求是形状组件约束。

Unity 示例 (“ShapeDefinition.cs”) 中为“sittable ”对象定义了如下所示的示例查询

shapeComponents = new List<ShapeComponent>()
     {
          new ShapeComponent(
               new List<ShapeComponentConstraint>()
               {
                    ShapeComponentConstraint.Create_SurfaceHeight_Between(0.2f, 0.6f),
                    ShapeComponentConstraint.Create_SurfaceCount_Min(1),
                    ShapeComponentConstraint.Create_SurfaceArea_Min(0.035f),
               }),
     };
     AddShape("Sittable", shapeComponents);

每个形状查询由一组形状组件定义,其中每个组件具有一组组件约束,以及一组形状约束,它们列出了组件之间的依赖关系。 此示例在单个组件定义中包含三个约束,组件之间没有形状约束(因为只有一个组件)。

相比之下,沙发形状具有两个形状组件和四个形状约束。 请注意,组件是在用户的组件列表中按索引(在本例中为 0 和 1)标识的。

shapeConstraints = new List<ShapeConstraint>()
        {
              ShapeConstraint.Create_RectanglesSameLength(0, 1, 0.6f),
              ShapeConstraint.Create_RectanglesParallel(0, 1),
              ShapeConstraint.Create_RectanglesAligned(0, 1, 0.3f),
              ShapeConstraint.Create_AtBackOf(1, 0),
        };

Unity 模块中提供了包装器函数,用于轻松创建自定义形状定义。 可以在 “SpatialUnderstandingDll.cs” 中的 “ShapeComponentConstraint” 和 “ShapeConstraint” 结构内找到组件和形状约束的完整列表

The blue rectangle highlights the results of the chair shape query.

蓝色矩形突出显示了椅子形状查询的结果。

对象位置求解器

对象位置查询可用于确定对象在物理房间中的理想放置位置。 求解器根据对象规则和约束查找最合适的位置。 此外,在使用 “Solver_RemoveObject” 或 “Solver_RemoveAllObjects” 调用删除对象之前,对象查询会一直存在,从而可以实现受约束的多对象放置

对象位置查询由三个部分构成:带参数的位置类型、规则列表和约束列表。 若要运行查询,请使用以下 API:

public static int Solver_PlaceObject(
                [In] string objectName,
                [In] IntPtr placementDefinition,	// ObjectPlacementDefinition
                [In] int placementRuleCount,
                [In] IntPtr placementRules,     	// ObjectPlacementRule
                [In] int constraintCount,
                [In] IntPtr placementConstraints,	// ObjectPlacementConstraint
                [Out] IntPtr placementResult)

此函数采用对象名称、位置定义以及规则和约束列表。 C# 包装器提供构造帮助器函数来简化规则和约束的构造。 位置定义包含查询类型 — 即以下类型之一:

public enum PlacementType
                {
                    Place_OnFloor,
                    Place_OnWall,
                    Place_OnCeiling,
                    Place_OnShape,
                    Place_OnEdge,
                    Place_OnFloorAndCeiling,
                    Place_RandomInAir,
                    Place_InMidAir,
                    Place_UnderFurnitureEdge,
                };

每个位置类型都有一组对该类型唯一的参数。 “ObjectPlacementDefinition” 结构包含一组用于创建这些定义的静态帮助器函数。 例如,若要在地面上查找一个可放置对象的位置,可使用以下函数:

public static ObjectPlacementDefinition Create_OnFloor(Vector3 halfDims)

除了位置类型外,还可以提供一组规则和约束。 不能违反规则。 然后根据一组约束来优化可能的、与类型和规则相符的放置位置,以选择最佳放置位置。 每个规则和约束可以由提供的静态创建函数来创建。 下面提供了一个示例规则和约束构造函数。

public static ObjectPlacementRule Create_AwayFromPosition(
                    Vector3 position, float minDistance)
               public static ObjectPlacementConstraint Create_NearPoint(
                    Vector3 position, float minDistance = 0.0f, float maxDistance = 0.0f)

以下对象位置查询查找一个位置,以便将一个半米的立方体放置在表面的边缘,远离其他先前放置的对象并靠近房间的中心。

List<ObjectPlacementRule> rules = 
          new List<ObjectPlacementRule>() {
               ObjectPlacementRule.Create_AwayFromOtherObjects(1.0f),
          };

     List<ObjectPlacementConstraint> constraints = 
          new List<ObjectPlacementConstraint> {
               ObjectPlacementConstraint.Create_NearCenter(),
          };

     Solver_PlaceObject(
          “MyCustomObject”,
          new ObjectPlacementDefinition.Create_OnEdge(
          new Vector3(0.25f, 0.25f, 0.25f), 
          new Vector3(0.25f, 0.25f, 0.25f)),
          rules.Count,
          UnderstandingDLL.PinObject(rules.ToArray()),
          constraints.Count,
          UnderstandingDLL.PinObject(constraints.ToArray()),
          UnderstandingDLL.GetStaticObjectPlacementResultPtr());

如果成功,则返回包含放置位置、尺寸和方向的 “ObjectPlacementResult” 结构。 此外,该位置将添加到 DLL 的内部放置对象列表中。 后续位置查询会考虑到此对象。 Unity 示例中的 “LevelSolver.cs” 文件包含更多示例查询

The blue boxes show the result from three Place On Floor queries with

蓝色框显示了带有“远离相机位置”规则的三个“放在地上”查询的结果。

提示

  • 在求解某个级别或应用程序方案所需的多个对象的放置位置时,请先求解必需的大对象,以最大限度地提高找到所需空间的概率。
  • 放置顺序非常重要。 如果找不到对象位置,请尝试使用约束较低的配置。 准备好一组回退配置对于多个房间配置的支持性功能至关重要。

光线投射

除了三个主要查询外,还可以使用光线投射接口来检索标记的表面类型,并可以复制自定义的密封游戏空间网格。在扫描房间并完成后,将在内部为地面、天花板和墙壁等表面生成标签。 “PlayspaceRaycast” 函数接收一条光线,并返回该光线是否与已知表面发生碰撞的结果,如果发生碰撞,则以 “RaycastResult” 的形式返回有关该表面的信息

struct RaycastResult
     {
          enum SurfaceTypes
          {
               Invalid,	// No intersection
               Other,
               Floor,
               FloorLike,         // Not part of the floor topology, 
                                  //     but close to the floor and looks like the floor
               Platform,          // Horizontal platform between the ground and 
                                  //     the ceiling
               Ceiling,
               WallExternal,
               WallLike,          // Not part of the external wall surface, 
                                  //     but vertical surface that looks like a 
                                  //	wall structure
               };
               SurfaceTypes SurfaceType;
               float SurfaceArea;	// Zero if unknown 
                                        //	(i.e. if not part of the topology analysis)
               DirectX::XMFLOAT3 IntersectPoint;
               DirectX::XMFLOAT3 IntersectNormal;
     };

在内部,光线投射的计算依据是游戏空间的计算出的 8 立方厘米体素表示形式。 每个体素包含一组表面元(简称面元),其中包含已处理的拓扑数据。 将比较相交体素单元中包含的面元,最佳匹配项用于查找拓扑信息。 此拓扑数据包含以 “SurfaceTypes” 枚举形式返回的标签,以及相交表面的表面积

在 Unity 示例中,游标每帧投射一条光线。 首先,对 Unity 的碰撞体投射一条光线;其次,对理解模块的世界表示形式投射一条光线;最后,对 UI 元素投射一条光线。 在此应用程序中,优先级从高到低分别为 UI、理解结果和 Unity 的碰撞体。 SurfaceType 报告为游标旁边的文本

Raycast result reporting intersection with the floor.

光线投射结果报告与地面相交。

获取代码

MixedRealityToolkit 中提供了开源代码。 如果你在项目中使用该代码,请在 HoloLens 开发人员论坛上与我们分享。 我们迫不及待地想看看你的效果如何!

关于作者

Jeff Evertt, Software Engineering Lead at Microsoft Jeff Evertt 是一名软件工程主管,从孵化到体验开发,他从早期开始就一直致力于 HoloLens 相关的工作。 在参与 HoloLens 项目之前,他曾在 Xbox Kinect 和游戏行业从事各种平台和游戏方面的工作。 Jeff 对机器人、图形和各种新奇的事物充满热情。 他喜欢探索新事物和研究软件、硬件,尤其是结合了两者的领域。

另请参阅