HoloLens (第 1 代) Spatial 230:空間對應

重要

Mixed Reality Academy 教學課程是使用 HoloLens (第 1 代) 、Unity 2017 和 Mixed Reality 沈浸式頭戴式裝置所設計。 因此,對於仍在尋找這些裝置開發指引的開發人員而言,我們覺得這些教學課程很重要。 這些教學課程不會使用用於 HoloLens 2 的最新工具組或互動進行更新,而且可能與較新版本的 Unity 不相容。 系統會保留這些資訊,以繼續在支援的裝置上運作。 已針對 HoloLens 2 公佈一系列新的教學課程

空間對應 透過教學環境全像投影,將真實世界和虛擬世界結合在一起。 在 MR Spatial 230 (Project Foundationium) 中,我們將瞭解如何:

  • 掃描環境,並將數據從 HoloLens 傳輸到您的開發電腦。
  • 探索著色器,並瞭解如何使用這些著色器來可視化您的空間。
  • 使用網格處理,將會議室網格分解成簡單的平面。
  • 超越我們在 MR Basics 101 中學到的放置技術,並提供可放置於環境中之全像投影的意見反應。
  • 探索遮蔽效果,因此當您的全像投影位於真實世界物件後方時,您仍然可以使用 X 光視覺來查看它!

裝置支援

課程 HoloLens 沉浸式頭戴裝置
MR Spatial 230:空間對應 ✔️

在您開始使用 Intune 之前

必要條件

專案檔

  • 下載專案所需的 檔案 。 需要 Unity 2017.2 或更新版本。
    • 如果您仍然需要 Unity 5.6 支援,請使用 此版本
    • 如果您仍然需要 Unity 5.5 支援,請使用 此版本
    • 如果您仍然需要 Unity 5.4 支援,請使用 此版本
  • 將檔案解除封存到桌面或其他容易觸達的位置。

注意

如果您想要在下載之前查看原始程式碼,可在 GitHub 上取得

備註

  • 必須在 [工具選項偵錯] 底下的 [工具>選項>偵錯] 下停用 [啟用 Just My Code],才能在程式代碼中叫用斷點 (取消) 核取。

Unity 設定

  • 啟動 Unity
  • 選取 [新增 ] 以建立新的專案。
  • 將專案命名為[行星] 。
  • 確認已選取 3D 設定。
  • 按一下 [建立專案]。
  • Unity 啟動時,請移至 [編輯 > 專案設定 > 播放機]。
  • [偵測器] 面板中,尋找並選取綠色 的 Windows 市集 圖示。
  • 展開 [其他設定]。
  • 在 [ 轉譯 ] 區段中,檢查 [ 虛擬現實支援] 選項。
  • 確認 [Windows 全像攝影] 出現在 [虛擬實境 SDK] 列表中。 如果沒有,請選取+清單底部的按鈕,然後選擇 [Windows 全像攝影]。
  • 展開 [發佈設定]。
  • 在 [ 功能] 區段中,檢查下列設定:
    • InternetClientServer
    • PrivateNetworkClientServer
    • 麥克風
    • SpatialPerception
  • 移至 [編輯項目設定>品質]>
  • [偵測器 ] 面板的 [Windows 市集] 圖示下,選取 [預設] 數據列底下的黑色下拉式箭號,並將預設設定變更為 [非常低]。
  • 移至 [資產>匯入套件自定義套件>]。
  • 流覽至 ...\HolographicAcademy-Holograms-230-SpatialMapping\Starting 資料夾。
  • 按兩下 [儲存][套件]。unitypackage
  • 按一下 [開啟]
  • [ 匯入 Unity 套件 ] 視窗應該會出現,按兩下 [ 入] 按鈕。
  • 等候 Unity 匯入我們需要完成此專案的所有資產。
  • 在 [ 階層] 面板中,刪除 主要相機
  • [專案] 面板中, HoloToolkit-SpatialMapping-230\Utilities\Prefabs 資料夾,尋找 Main Camera 物件。
  • 主要相機 預製專案拖放至 [階層 ] 面板。
  • 在 [ 階層] 面板中,刪除 [方向光線 ] 物件。
  • [專案] 面板的 [全像投影] 資料夾中,找出 Cursor 物件。
  • 將 & 游標預製 專案拖曳到 階層中。
  • 在 [ 階層] 面板中,選取 Cursor 物件。
  • [偵測器 ] 面板中,按兩下 [ 圖層 ] 下拉式清單,然後選取 [ 編輯圖層...]。
  • 使用者第31層 命名為 「SpatialMapping」。。
  • 儲存新的場景: 檔案 > 儲存場景...
  • 按兩下 [新增資料夾 ],並將資料夾命名為 Scenes
  • 將檔案命名為 「Worldium」,並將它儲存在 Scenes 資料夾中。

第 1 章 - 掃描

目標

  • 瞭解 SurfaceObserver 及其設定如何影響體驗和效能。
  • 建立會議室掃描體驗,以收集會議室的網格。

指示

  • [專案] 面板中 HoloToolkit-SpatialMapping-230\SpatialMapping\Prefabs 資料夾中,尋找 SpatialMapping prefab
  • 將 & 將 SpatialMapping 預製專案拖曳到 [階層 ] 面板中。

建置和部署 (第 1 部分)

  • 在 Unity 中,選取 [ 檔案 > 建置設定]。
  • 按兩下 [新增開啟場景 ],將 [檔案] 場景新增至組建。
  • 在 [平臺] 列表中選取 [通用 Windows 平台],然後按兩下 [切換平臺]。
  • SDK 設定為 通用 10並將 UWP 組建類型 設定為 D3D
  • 檢查 Unity C# 專案
  • 按一下 [建置]
  • 建立名為 「App」 的新資料夾
  • 按兩下 [ 應用程式 ] 資料夾。
  • 按 [ 選取資料夾] 按鈕。
  • 當 Unity 建置完成時,會出現 檔案總管 視窗。
  • 按兩下 [應用程式 ] 資料夾以開啟它。
  • 在 Visual Studio 中按兩下 [檔案], 以載入專案。
  • 在 Visual Studio 中,使用頂端工具列將 [組態] 變更為 [發行]。
  • 將 [平臺] 變更為 x86
  • 按兩下 [本機計算機] 右邊的下拉式箭號,然後選取 [ 遠端電腦]。
  • 在 [位址] 欄位中輸入您的 裝置 IP 位址, 並將 [驗證模式] 變更為 [通用 (未加密通訊協定])
  • 按兩下 [偵錯 -> 啟動但不偵錯 ],或按 Ctrl + F5
  • 觀看 Visual Studio 中的 [ 輸出] 面板,以取得建置和部署狀態。
  • 部署應用程式之後,請逐步解說會議室。 您會看到黑色和白色網狀網狀結構所涵蓋的周圍表面。
  • 掃描您的周遭環境。 請務必查看牆、上限和樓層。

建置和部署 (第2部分)

現在讓我們來探索空間對應如何影響效能。

  • 在 Unity 中,選取 [視窗 > 分析工具]。
  • 按兩下 [新增分析工具 > GPU]。
  • 按兩下 [作用中分析工具 ><][輸入IP>]。
  • 輸入 HoloLens 的 IP 位址
  • 按一下 [連線]。
  • 觀察 GPU 轉譯畫面所需的毫秒數。
  • 停止應用程式在裝置上執行。
  • 返回 Visual Studio 並開啟 SpatialMappingObserver.cs。 您可以在 Assembly-CSharp (Universal Windows) 專案的 HoloToolkit\SpatialMapping 資料夾中找到它。
  • 尋找 Awake () 函式,並新增下列程式代碼行: TrianglesPerCubicMeter = 1200;
  • 將專案重新部署至您的裝置,然後 重新連接分析工具。 觀察轉譯框架的毫秒數變更。
  • 停止應用程式在裝置上執行。

在 Unity 中儲存和載入

最後,讓我們儲存會議室網格,並將其載入 Unity。

  • 返回 Visual Studio,並移除您在上一節期間於 Awake () 函式中新增的三角形PerCubicMeter 行。
  • 將專案重新部署至您的裝置。 我們現在應該以每立方公尺 500 個三角形來執行。
  • 開啟瀏覽器,然後在 HoloLens IPAddress 中輸入以流覽至 Windows 裝置入口網站
  • 選取左側面板中的 [3D 檢視 ] 選項。
  • [Surface 重建] 底下,選取 [ 更新 ] 按鈕。
  • 觀看您在 HoloLens 上掃描的區域會出現在顯示視窗中。
  • 若要儲存會議室掃描,請按 [ 儲存 ] 按鈕。
  • 開啟 [ 下載 ] 資料夾,以尋找儲存的會議室模型 SRMesh.obj
  • SRMesh.obj 複製到 Unity 專案的 Assets 資料夾。
  • 在 Unity 中,選取 [階層] 面板中的 SpatialMapping 物件。
  • 找出 物件 Surface 觀察者 (腳稿) 元件。
  • 按兩下 會議室模型 屬性右邊的圓形。
  • 尋找並選取 SRMesh 物件,然後關閉視窗。
  • 確認 [偵測器] 面板中的 [會議室模型] 屬性現在已設定為 SRMesh
  • 按 [ 播放] 按鈕以進入 Unity 的預覽模式。
  • SpatialMapping 元件會從儲存的空間模型載入網格,讓您可以在 Unity 中使用網格。
  • 切換至 場景 檢視,以查看使用線框著色器顯示的所有會議室模型。
  • 再次按 [ 播放] 按鈕以結束預覽模式。

注意: 下次您在 Unity 中進入預覽模式時,預設會載入已儲存的空間網格。

第 2 章 - 視覺效果

目標

  • 瞭解著色器的基本概念。
  • 將周圍的環境可視化。

指示

  • 在 Unity 的 [階層] 面板中,選取 SpatialMapping 物件。
  • [偵測器 ] 面板中,尋找 [空間對應管理員] ([腳本]) 元件。
  • 按兩下 Surface Material 屬性右邊的圓形。
  • 尋找並選取 BlueLinesOnWalls 材質,然後關閉視窗。
  • [專案 面板 著色器 ] 資料夾中,按兩下 BlueLinesOnWalls ,在 Visual Studio 中開啟著色器。
  • 這是簡單的圖元 (頂點片段) 著色器,可完成下列工作:
    1. 將頂點的位置轉換成世界空間。
    2. 檢查頂點的正常值,以判斷圖元是否為垂直。
    3. 設定要轉譯的圖元色彩。

建置和部署

  • 返回 Unity,然後按 [播放 ] 進入預覽模式。
  • 藍色線條會在房間網格的所有垂直表面呈現, (自動從儲存的掃描數據載入) 。
  • 切換至 [ 場景 ] 索引標籤,以調整會議室的檢視,並查看整個會議室網格在 Unity 中的顯示方式。
  • [專案] 面板中,尋找 [材質] 資料夾,然後選取 BlueLinesOnWalls 材質。
  • 修改某些屬性,並查看變更在 Unity 編輯器中的顯示方式。
    • [偵測器 ] 面板中,調整 LineScale 值,使線條看起來更粗或更細。
    • [偵測器 ] 面板中,調整 LinesPerMeter 值,以變更每個牆上出現多少行。
  • 再次按兩下 [播放 ] 以結束預覽模式。
  • 建置並部署至 HoloLens,並觀察著色器呈現在真實表面的方式。

Unity 會執行絕佳的預覽材質工作,但最好在裝置中籤出轉譯。

第 3 章 - 處理

目標

  • 瞭解如何處理空間對應數據以在應用程式中使用的技術。
  • 分析空間對應數據以尋找平面並移除三角形。
  • 使用平面進行全像投影放置。

指示

  • 在 Unity 的 [專案] 面板中, [全像投影] 資料夾中,尋找 SpatialProcessing 物件。
  • 將 & 將 SpatialProcessing 物件拖曳至 [階層 ] 面板。

SpatialProcessing 預製專案包含處理空間對應數據的元件。 SurfaceMeshesToPlanes.cs 會根據空間對應數據尋找併產生平面。 我們將使用應用程式中的平面來代表牆、樓層和上限。 此預製專案也包含 RemoveSurfaceVertices.cs ,可從空間對應網格中移除頂點。 這可以用來在網格中建立漏洞,或移除不再需要的超三角形 (,因為平面可以改用) 。

  • 在 Unity 的 [專案] 面板中, [全像投影] 資料夾中,尋找 SpaceCollection 物件。
  • SpaceCollection 物件拖放至 [階層 ] 面板。
  • 在 [ 階層] 面板中,選取 SpatialProcessing 物件。
  • 在 [ 偵測器 ] 面板中,尋找 [播放空間管理員] (腳本) 元件。
  • 按兩下 PlaySpaceManager.cs ,在 Visual Studio 中開啟它。

PlaySpaceManager.cs 包含應用程式特定的程式代碼。 我們會將功能新增至此腳本,以啟用下列行為:

  1. 在超過掃描時間限制后停止收集空間對應數據, (10 秒) 。
  2. 處理空間對應資料:
    1. 使用 SurfaceMeshesToPlanes 建立較簡單的世界表示法,做為平面 (牆、樓層、上限等) 。
    2. 使用 RemoveSurfaceVertices 移除落在平面界限內的表面三角形。
  3. 產生世界中全像投影的集合,並將其放在使用者附近的牆和樓層平面上。

完成 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 物件。
  • [偵測器 ] 面板中,尋找 Surface Meshes To Planes (Script) 元件。
  • Draw Planes 屬性變更為 Nothing ,以清除選取範圍。
  • [繪製平面] 屬性變更為 [牆],以便只轉譯牆面。
  • [專案 ] 面板的 [ 腳本 ] 資料夾中,按兩下 [Placeable.cs ] 以在 Visual Studio 中開啟它。

可置放腳本已附加至平面尋找完成之後所建立的海報和投影方塊。 我們只需要取消批註一些程序代碼,此腳本將會達到下列目的:

  1. 透過從周框立方體中央和四個角落的光線傳播,判斷全像投影是否適合表面。
  2. 檢查表面正常,以判斷全像投影是否夠平滑,讓全像投影排清。
  3. 在全像投影周圍轉譯周框立方體,以顯示其放置時的實際大小。
  4. 在全像投影底下/後方投射陰影,以顯示將放置於底板/牆的位置。
  5. 如果無法將全像投影放在表面,則將陰影轉譯為紅色,如果可以,則為綠色。
  6. 重新調整全像投影的方向,使其與具有親和性的介面類型 (垂直或水準) 對齊。
  7. 順暢地將全像投影放在選取的表面上,以避免跳躍或貼齊行為。

取消批注下列程式代碼練習中的所有程式代碼,或在 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 物件。
  • [偵測器] 面板中,尋找 [播放空間管理員] (腳本) 元件。
  • 按兩下 [次要材質] 屬性右邊的圓形。
  • 尋找並選取 遮蔽 材質,然後關閉視窗。

接下來,我們要將特殊行為新增至地球,讓它在被另一個全像投影遮蔽時,會有藍色醒目提示, (像是太陽) ,或透過空間對應網格:

  • [專案] 面板中的 [Holograms ] 資料夾中,展開 SolarSystem 物件。
  • 按兩下 [地球]。
  • 在 [ 偵測器] 面板中,尋找地球的材質 (底端元件) 。
  • 在 [ 著色器] 下拉式清單中,將著色器變更為 [自定義 > 遮蔽Rim]。 每當被另一個物件遮蔽時,這會在地球周圍呈現藍色醒目提示。

最後,我們將為太陽能系統中的行星啟用 X 光視覺效果。 我們需要編輯 PlanetOcclusion.cs (在 Scripts\SolarSystem 資料夾中找到) ,才能達到下列目的:

  1. 判斷 SpatialMapping 層次是否遮蔽了行星, (空間網格和平面) 。
  2. 每當空間地圖層次遮蔽行星時,就會顯示行星的線框表示。
  3. 當 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 Spatial 230:空間對應

  • 您知道如何掃描您的環境,並將空間對應數據載入 Unity。
  • 您瞭解著色器的基本概念,以及如何使用材質來重新可視化世界。
  • 您已了解尋找平面並移除網格中三角形的新處理技術。
  • 您可以在有意義的表面上移動和放置全像投影。
  • 您經歷了不同的遮蔽技術,並運用了 X 光視覺的強大功能!