HoloLens (1. generace) Spatial 230: Prostorové mapování

Důležité

Kurzy Mixed Reality Academy byly navrženy s ohledem na HoloLens (1. generace), Unity 2017 a Mixed Reality Asistivní náhlavní soupravy. Proto se domníváme, že je důležité ponechat tyto kurzy pro vývojáře, kteří stále hledají pokyny k vývoji pro tato zařízení. Tyto kurzy nebudou aktualizovány nejnovějšími sadami nástrojů nebo interakcemi používanými pro HoloLens 2 a nemusí být kompatibilní s novějšími verzemi Unity. Budou zachovány, aby mohly pokračovat v práci na podporovaných zařízeních. Byla publikována nová série kurzů pro HoloLens 2.

Prostorové mapování kombinuje reálný a virtuální svět tím, že vyučuje hologramy o prostředí. V MR Spatial 230 (Projekt Planetárium) se naučíme:

  • Zkontrolujte prostředí a přeneste data z HoloLensu na vývojový počítač.
  • Prozkoumejte shadery a zjistěte, jak je použít k vizualizaci prostoru.
  • Rozdělte síť místnosti na jednoduché roviny pomocí zpracování sítí.
  • Přejděte nad rámec technik umísťování, které jsme se naučili v MR Basics 101, a poskytněte nám zpětnou vazbu o tom, kam je možné umístit hologram do prostředí.
  • Prozkoumejte efekty okluze, takže když je hologram za objektem skutečného světa, můžete ho stále vidět pomocí rentgenového vidění!

Podpora zařízení

Kurz HoloLens Imerzivní náhlavní soupravy
MR Spatial 230: Prostorové mapování ✔️

Než začnete

Požadavky

Soubory projektu

  • Stáhněte si soubory vyžadované projektem. Vyžaduje Unity 2017.2 nebo novější.
  • Zrušte archivaci souborů na ploše nebo na jiné snadno dostupné místo.

Poznámka

Pokud si chcete před stažením projít zdrojový kód, je k dispozici na GitHubu.

Poznámky

  • V sadě Visual Studio musí být v části Možnosti > ladění možností nástrojů > zakázané (nezaškrtnuté) povolit jen můj kód, aby se v kódu dostaly do zarážek.

Nastavení Unity

  • Spusťte Unity.
  • Vyberte Nový a vytvořte nový projekt.
  • Pojmenujte projekt Planetárium.
  • Ověřte, že je vybrané nastavení 3D .
  • Klikněte na Create Project (Vytvořit projekt).
  • Po spuštění Unity přejděte na Upravit > přehrávač nastavení > projektu.
  • Na panelu Inspektor vyhledejte a vyberte zelenou ikonu Windows Storu .
  • Rozbalte další nastavení.
  • V části Vykreslování zaškrtněte možnost Virtuální realita podporovaná .
  • Ověřte, že se v seznamu sad SDK pro virtuální realitu zobrazuje Windows Holographic. Pokud ne, vyberte + tlačítko v dolní části seznamu a zvolte Windows Holographic.
  • Rozbalte nastavení publikování.
  • V části Capabilities (Možnosti ) zkontrolujte následující nastavení:
    • InternetClientServer
    • PrivateNetworkClientServer
    • Mikrofon
    • SpatialPerception
  • Přejít na Upravit > kvalitu nastavení > projektu
  • Na panelu Inspektor pod ikonou Windows Store vyberte černou šipku rozevíracího seznamu pod řádkem Výchozí a změňte výchozí nastavení na Velmi nízká.
  • Přejděte do části Vlastní balíček importu > prostředků>.
  • Přejděte do složky ...\HolographicAcademy-Holograms-230-SpatialMapping\Starting .
  • Klikněte na Planetárium.unitypackage.
  • Klikněte na Otevřít.
  • Mělo by se zobrazit okno Importovat balíček Unity , klikněte na tlačítko Importovat .
  • Počkejte, až Unity naimportuje všechny prostředky, které budeme potřebovat k dokončení tohoto projektu.
  • Na panelu Hierarchie odstraňte hlavní kameru.
  • Ve složce HoloToolkit-SpatialMapping-230\Utilities\Prefabs na panelu Projekt najděte objekt Hlavní kamera.
  • Přetáhněte panel Hlavní kamera na panel Hierarchie .
  • Na panelu Hierarchie odstraňte objekt Směrové světlo .
  • Na panelu Projekt ve složce Hologramy vyhledejte objekt Kurzor .
  • Přetáhněte & přetáhněte prefab kurzoru do hierarchie.
  • Na panelu Hierarchie vyberte objekt Kurzor .
  • Na panelu Inspektor klikněte na rozevírací seznam Vrstva a vyberte Upravit vrstvy....
  • Pojmenujte user layer 31 jako "SpatialMapping".
  • Uložit novou scénu: Soubor > Uložit scénu jako...
  • Klikněte na Nová složka a pojmenujte složku Scény.
  • Pojmenujte soubor Planetárium a uložte ho do složky Scény .

Kapitola 1 – Skenování

Cíle

  • Přečtěte si o surfaceObserveru a o tom, jak jeho nastavení ovlivňuje prostředí a výkon.
  • Vytvořte prostředí pro skenování místnosti, abyste mohli shromažďovat sítě v místnosti.

Pokyny

  • Ve složce Panel projektůHoloToolkit-SpatialMapping-230\SpatialMapping\Prefabs najděte prefab SpatialMapping .
  • Přetáhněte & přetáhněte prefab SpatialMapping na panel Hierarchie .

Sestavení a nasazení (část 1)

  • V Unity vyberte Nastavení sestavení souboru>.
  • Klikněte na Přidat otevřené scény a přidejte do sestavení scénu Planetárium .
  • V seznamu Platforma vyberte Univerzální platforma Windows a klikněte na Přepnout platformu.
  • Nastavte sadu SDK na Universal 10 a typ sestavení UPW na D3D.
  • Zkontrolujte projekty Unity C#.
  • Klikněte na Sestavit.
  • Vytvořte novou složku s názvem "Aplikace".
  • Jedním kliknutím klikněte na složku Aplikace .
  • Stiskněte tlačítko Vybrat složku .
  • Po dokončení vytváření Unity se zobrazí okno Průzkumník souborů.
  • Poklikáním na složku Aplikace ji otevřete.
  • Poklikáním na Planetárium.sln načtěte projekt v sadě Visual Studio.
  • V sadě Visual Studio pomocí horního panelu nástrojů změňte Konfiguraci na Vydané verze.
  • Změňte platformu na x86.
  • Klikněte na šipku rozevíracího seznamu napravo od položky Místní počítač a vyberte Vzdálený počítač.
  • Do pole Adresa zadejte IP adresu zařízení a změňte režim ověřování na Universal (Nešifrovaný protokol).
  • Klikněte na Ladit –> Spustit bez ladění nebo stiskněte Ctrl +F5.
  • Stav sestavení a nasazení najdete na panelu Výstup v sadě Visual Studio.
  • Jakmile se aplikace nasadí, projděte se po místnosti. Uvidíte okolní povrchy pokryté černou a bílou drátěnou sítí.
  • Zkontrolujte své okolí. Nezapomeňte se podívat na stěny, stropy a podlahy.

Sestavení a nasazení (část 2)

Teď se podíváme, jak může prostorové mapování ovlivnit výkon.

  • V Unity vyberte Profiler oken>.
  • Klikněte na Přidat profiler > GPU.
  • Klikněte na Aktivní profiler ><Zadejte IP> adresu.
  • Zadejte IP adresu vašeho HoloLensu.
  • Klikněte na Připojit.
  • Sledujte počet milisekund, které gpu potřebuje k vykreslení snímku.
  • Zastavte spuštění aplikace na zařízení.
  • Vraťte se do sady Visual Studio a otevřete SpatialMappingObserver.cs. Najdete ho ve složce HoloToolkit\SpatialMapping projektu Assembly-CSharp (Universal Windows).
  • Vyhledejte funkci Awake() a přidejte následující řádek kódu: TrianglesPerCubicMeter = 1200;
  • Znovu nasaďte projekt do zařízení a pak znovu připojte profiler. Sledujte změnu počtu milisekund pro vykreslení rámce.
  • Zastavte spuštění aplikace na zařízení.

Uložení a načtení v Unity

Nakonec si uložme síť místností a naložíme ji do Unity.

  • Vraťte se do sady Visual Studio a odeberte řádek TrianglesPerCubicMeter , který jste přidali do funkce Awake() v předchozí části.
  • Znovu nasaďte projekt do zařízení. Teď bychom měli běžet s 500 trojúhelníky na metr krychlový.
  • Otevřete prohlížeč a zadáním ip adresy HoloLens přejděte na Portál zařízení s Windows.
  • Na levém panelu vyberte možnost 3D zobrazení .
  • V části Rekonstrukce zařízení Surface vyberte tlačítko Aktualizovat .
  • Sledujte, jak se v okně zobrazení zobrazí oblasti, které jste naskenovali na HoloLensu.
  • Pokud chcete sken místnosti uložit, stiskněte tlačítko Uložit .
  • Otevřete složku Stažené soubory a vyhledejte uložený model místnosti SRMesh.obj.
  • Zkopírujte soubor SRMesh.obj do složky Assets vašeho projektu Unity.
  • V Unity vyberte objekt SpatialMapping na panelu Hierarchie .
  • Vyhledejte komponentu Object Surface Observer (Script).
  • Klikněte na kroužek napravo od vlastnosti Model místnosti .
  • Vyhledejte a vyberte objekt SRMesh a zavřete okno.
  • Ověřte, že je vlastnost Model místnosti na panelu Inspektoru nastavená na HODNOTU SRMesh.
  • Stisknutím tlačítka Přehrát přejděte do režimu náhledu Unity.
  • Komponenta SpatialMapping načte sítě z uloženého modelu místnosti, abyste je mohli použít v Unity.
  • Přepněte do zobrazení scény , abyste viděli celý model místnosti zobrazený pomocí shaderu drátového modelu.
  • Dalším stisknutím tlačítka Přehrát ukončete režim náhledu.

POZNÁMKA: Při příštím přechodu do režimu náhledu v Unity se ve výchozím nastavení načte uložená síť místností.

Kapitola 2 – Vizualizace

Cíle

  • Seznamte se se základy shaderů.
  • Vizualizujte své okolí.

Pokyny

  • Na panelu Hierarchie Unity vyberte objekt SpatialMapping .
  • Na panelu Inspektor vyhledejte komponentu Správce prostorového mapování (Script).
  • Klikněte na kroužek napravo od vlastnosti Materiál povrchu .
  • Najděte a vyberte materiál BlueLinesOnWalls a zavřete okno.
  • Ve složce Shaders panelu projektu poklikáním na BlueLinesOnWalls otevřete shader v sadě Visual Studio.
  • Jedná se o jednoduchý pixel (vrchol k fragmentu) shader, který provádí následující úlohy:
    1. Převede umístění vrcholu na světový prostor.
    2. Zkontroluje normální hodnotu vrcholu a určí, jestli je pixel svislý.
    3. Nastaví barvu pixelu pro vykreslování.

Sestavení a nasazení

  • Vraťte se do Unity a stisknutím přehrát přejděte do režimu náhledu.
  • Modré čáry budou vykresleny na všech svislých plochách sítě místnosti (které se automaticky načtou z našich uložených dat skenování).
  • Přepněte na kartu Scéna a upravte zobrazení místnosti a podívejte se, jak se v Unity zobrazuje celá síť místností.
  • Na panelu Projekt vyhledejte složku Materiály a vyberte materiál BlueLinesOnWalls .
  • Upravte některé vlastnosti a podívejte se, jak se změny zobrazí v editoru Unity.
    • Na panelu Inspektor upravte hodnotu LineScale tak, aby čáry vypadaly silnější nebo tenčí.
    • Na panelu Inspektor upravte hodnotu LinesPerMeter tak, aby se změnilo, kolik čar se zobrazí na každé zdi.
  • Dalším kliknutím na Přehrát ukončete režim náhledu.
  • Sestavte a nasaďte do HoloLensu a sledujte, jak se vykreslování shaderu zobrazuje na skutečných površích.

Unity dělá skvělou práci při vytváření náhledů materiálů, ale vždy je vhodné rezervovat vykreslování v zařízení.

Kapitola 3 - Zpracování

Cíle

  • Naučte se techniky zpracování prostorových mapových dat pro použití ve vaší aplikaci.
  • Analyzujte data prostorového mapování, abyste našli roviny a odstranili trojúhelníky.
  • Pro umístění hologramu používejte roviny.

Pokyny

  • Na panelu Projekt Unity ve složce Holograms najděte objekt SpatialProcessing .
  • Přetáhněte & přesuňte objekt SpatialProcessing do panelu Hierarchie .

Prefab SpatialProcessing obsahuje komponenty pro zpracování dat prostorového mapování. SurfaceMeshesToPlanes.cs najde a vygeneruje roviny na základě dat prostorového mapování. V naší aplikaci budeme používat letadla k reprezentaci zdí, podlah a stropů. Tento prefab také obsahuje RemoveSurfaceVertices.cs , který může odebrat vrcholy z prostorové mapovací sítě. Můžete ho použít k vytvoření otvorů v síti nebo k odstranění nadbytečných trojúhelníků, které už nejsou potřeba (protože místo toho je možné použít roviny).

  • Na panelu Projekt Unity ve složce Holograms vyhledejte objekt SpaceCollection .
  • Přetáhněte objekt SpaceCollection do panelu Hierarchie .
  • Na panelu Hierarchie vyberte objekt SpatialProcessing .
  • Na panelu Inspektor vyhledejte komponentu Správce herního prostoru (Script).
  • Poklikáním na PlaySpaceManager.cs ho otevřete v sadě Visual Studio.

PlaySpaceManager.cs obsahuje kód specifický pro aplikaci. Do tohoto skriptu přidáme funkce, které umožní následující chování:

  1. Po překročení časového limitu skenování (10 sekund) přestaňte shromažďovat data prostorového mapování.
  2. Zpracování dat prostorového mapování:
    1. Použijte SurfaceMeshesToPlanes k vytvoření jednodušší reprezentace světa jako rovin (stěny, podlahy, stropy atd.).
    2. Pomocí funkce RemoveSurfaceVertices odeberte povrchové trojúhelníky, které spadají do hranic roviny.
  3. Vygenerujte kolekci hologramů na světě a umístěte je na zeď a podlahu v blízkosti uživatele.

Dokončete kódovací cvičení označená v PlaySpaceManager.cs nebo nahraďte skript hotovým řešením níže:

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;
        }
    }
}

Sestavení a nasazení

  • Před nasazením do HoloLensu přejděte stisknutím tlačítka Přehrát v Unity do režimu přehrávání.
  • Po načtení sítě místnosti ze souboru počkejte 10 sekund, než se zahájí zpracování v síti prostorového mapování.
  • Po dokončení zpracování se zobrazí roviny, které představují podlahu, stěny, strop atd.
  • Poté, co byla nalezena všechna letadla, měli byste vidět, že se na podlaze v blízkosti kamery objeví sluneční soustava.
  • Na stěnách v blízkosti kamery by se měly objevit i dva plakáty. Pokud je nevidíte v herním režimu, přepněte na kartu Scéna.
  • Opětovným stisknutím tlačítka Přehrát ukončete režim přehrávání.
  • Sestavte a nasaďte do HoloLensu jako obvykle.
  • Počkejte na dokončení prohledávání a zpracování dat prostorového mapování.
  • Jakmile uvidíte letadla, zkuste najít sluneční soustavu a plakáty ve vašem světě.

Kapitola 4 - Umístění

Cíle

  • Určete, jestli se hologram vejde na povrch.
  • Poskytněte uživateli zpětnou vazbu, když se hologram může nebo nemůže vejít na povrch.

Pokyny

  • Na panelu Hierarchie Unity vyberte objekt SpatialProcessing .
  • Na panelu Inspektor vyhledejte komponentu Surface Meshes to Planes (Script).
  • Pokud chcete výběr vymazat, změňte vlastnost Nakreslit roviny na Nothing .
  • Změňte vlastnost Nakreslit roviny na Stěnu, aby se vykreslovaly pouze roviny stěn.
  • Na panelu Projekt ve složce Skripty poklikejte na Placeable.cs a otevřete ho v sadě Visual Studio.

Umístitelný skript je již připojen k plakátům a projekčnímu rámečku, které jsou vytvořeny po dokončení hledání roviny. Jediné, co musíme udělat, je zrušit komentář k nějakému kódu a tento skript dosáhne následujícího:

  1. Určete, jestli se hologram vejde na povrch, a to pomocí paprsku ze středu a čtyř rohů ohraničující krychle.
  2. Zkontrolujte normální povrch a zjistěte, jestli je dostatečně hladký, aby se na něm hologram vyrovnal.
  3. Vykreslením ohraničující datové krychle kolem hologramu zobrazíte jeho skutečnou velikost při umísťování.
  4. Pod nebo za hologramem zahodíte stín, abyste ho mohli umístit na podlahu nebo zeď.
  5. Vykreslíte stín červeně, pokud hologram nelze umístit na povrch, nebo zeleně, pokud je to možné.
  6. Přeorientujte hologram tak, aby byl zarovnán s typem povrchu (svislým nebo vodorovným), ke kterému má spřažení.
  7. Hladce umístěte hologram na vybranou plochu, abyste se vyhnuli chování při skákání nebo přichycení.

Zrušte komentář veškerého kódu v následujícím cvičení kódování nebo použijte toto dokončené řešení v 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;
    }
}

Sestavení a nasazení

  • Stejně jako předtím sestavte projekt a nasaďte ho do HoloLensu.
  • Počkejte na dokončení prohledávání a zpracování dat prostorového mapování.
  • Když uvidíte sluneční soustavu, upřete pohled na projekci dole a pomocí vybraného gesta ji posouvejte. Když je pole projekce vybrané, kolem pole projekce bude vidět ohraničující datová krychle.
  • Přesuňte hlavu, abyste se dívali na jiné místo v místnosti. Okno projekce by mělo sledovat váš pohled. Když se stín pod promítacím rámečkem změní na červenou barvu, nemůžete na tuto plochu umístit hologram. Když se stín pod polem projekce změní na zelenou, můžete hologram umístit provedením jiného gesta výběru.
  • Najděte a vyberte jeden z holografických plakátů na zdi a přesuňte ho na nové místo. Všimněte si, že plakát nelze umístit na podlahu nebo strop a že při pohybu zůstává správně orientovaný na každou zeď.

Kapitola 5 – Okluze

Cíle

  • Určete, jestli je hologram zakrytý sítí prostorového mapování.
  • Použijte různé techniky okluze, abyste dosáhli zábavného efektu.

Pokyny

Za prvé, umožníme, aby síť prostorového mapování osílala ostatní hologramy, aniž by okružovala skutečný svět:

  • Na panelu Hierarchie vyberte objekt SpatialProcessing .
  • Na panelu Inspektor vyhledejte komponentu Správce herního prostoru (Script).
  • Klikněte na kruh napravo od vlastnosti Sekundární materiál .
  • Najděte a vyberte materiál Okluze a zavřete okno.

Dále přidáme k Zemi zvláštní chování, aby měla modré zvýraznění, kdykoli se omotá jiným hologramem (například sluncem) nebo prostorovou mapovací sítí:

  • Na panelu Projekt rozbalte ve složce Hologramy objekt SolarSystem .
  • Klikněte na Zemi.
  • Na panelu Inspektor najděte zemský materiál (spodní součást).
  • V rozevíracím seznamu Shader změňte shader na Custom > OcclusionRim. Tím se vykreslí modré zvýraznění kolem Země, kdykoli ho zakrytí jiný objekt.

Nakonec umožníme rentgenový efekt pro planety v naší sluneční soustavě. Abychom dosáhli následujících výsledků, budeme muset upravit soubor PlanetOcclusion.cs (nachází se ve složce Scripts\SolarSystem):

  1. Určete, jestli je planeta okrytá vrstvou SpatialMapping (sítě místností a roviny).
  2. Umožňuje zobrazit drátové znázornění planety vždy, když je omítaná vrstvou SpatialMapping.
  3. Skrytí drátového znázornění planety, pokud není blokována vrstvou SpatialMapping.

Postupujte podle cvičení kódování v souboru PlanetOcclusion.cs nebo použijte následující řešení:

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);
            }
        }
    }
}

Sestavení a nasazení

  • Sestavte a nasaďte aplikaci do HoloLensu jako obvykle.
  • Počkejte na dokončení skenování a zpracování dat prostorového mapování (na stěnách by se měly zobrazit modré čáry).
  • Najděte a vyberte pole projekce sluneční soustavy a pak ho nastavte vedle zdi nebo za čítač.
  • Základní okluzi můžete zobrazit tak, že skryjete za povrchy, abyste se mohli podívat na plakát nebo projekci.
  • Podívejte se na Zemi, měl by tam být efekt modrého zvýraznění, kdykoli se dostane za jiným hologramem nebo povrchem.
  • Sledujte, jak se planety pohybují za stěnou nebo jinými povrchy v místnosti. Teď máte rentgenové vidění a můžete vidět jejich drátěné kostry!

Konec

Gratulujeme! Nyní jste dokončili MR Spatial 230: Prostorové mapování.

  • Víte, jak zkontrolovat prostředí a načíst data prostorového mapování do Unity.
  • Rozumíte základům shaderů a tomu, jak se dají materiály použít k opětovné vizualizaci světa.
  • Seznámili jste se s novými technikami zpracování pro hledání rovin a odstraňování trojúhelníků ze sítě.
  • Mohli jste se pohybovat a umísťovat hologramy na povrchy, které dávaly smysl.
  • Vyzkoušeli jste různé techniky okluze a využili sílu rentgenového vidění!