Написать пост

Как разрабатывать приложения смешанной реальности для Microsoft HoloLens: взаимодействие с окружающим пространством

Аватар Лапа

Обложка поста Как разрабатывать приложения смешанной реальности для Microsoft HoloLens: взаимодействие с окружающим пространством

В предыдущей части мы создали нашу первую голограмму и научились с ней взаимодействовать. Теперь мы соединим нашу голограмму с реальным миром.

Смешанная реальность

Как было отмечено в первой части, Hololens — это устройство, позволяющее комбинировать реальный и виртуальный мир. Представьте, что можно поместить у себя на стене таблицу Менделеева или поставить на столе макет дома. В прошлый раз мы с вами создали голограмму автомобиля и научились с ней взаимодействовать.

Однако автомобиль каждый раз появляется на одном и том же месте, что не очень удобно. Можно, конечно, воспользоваться классом GestureTest и перетащить автомобиль в любую точку окружающего пространства. Но проблема в том, что часть автомобиля может оказаться за стеной. Или вы захотите поставить его на пол. А теперь представьте: вы захотите написать игру, в которой из стен должны появляться враги и нападать на пользователя (аналог — RoboRaid). Иными словами, вы хотите, чтобы голограммы взаимодействовали с объектами реального мира, а объекты реального мира — с голограммами. В таких случаях необходимо обладать информацией об окружающем пространстве.

В Hololens есть 4 камеры для анализа поверхностей, а также камера глубины и IMU-сенсор, благодаря чему можно провести очень точный геометрический анализ окружающего пространства: стен, столов, стульев и даже человеческого тела. При этом данные сохраняются на устройстве и обновляются по мере необходимости. Представьте, что можно без проблем расположить голограмму на голове ничего не подозревающего товарища.

Как разрабатывать приложения смешанной реальности для Microsoft HoloLens: взаимодействие с окружающим пространством 1

Spatial Mapping

Процесс комбинирования голограмм и реального мира называется Spatial Mapping. Сначала приложение предоставляет устройству ограничитель, выполненный в виде многогранника, который отвечает за свою часть пространства. По мере сканирования ограничитель формирует набор полигонов. Чем больше полигонов, тем точнее геометрические данные и тем больше нагрузка на устройство. Так, например, на картинке сверху считывание идёт из расчёта 500 полигонов на кубический метр.

Непрерывное перемещение пользователя в пространстве и изменение положения объектов (например, передвижение стола) также влияют на точность Spatial Mapping. При разработке приложений это необходимо учитывать. Затем на основе полигонов определяются типы поверхностей, будь то пол, стол или стена, и вы сможете научить голограммы взаимодействовать с ними.

Spatial Anchor

Положение голограмм в пространстве можно фиксировать, присваивая им объекты Spatial Anchor (далее по тексту — пространственные якоря). Таким образом, при следующей загрузке приложения вы сможете увидеть их ровно на том же месте, на котором оставили в прошлый раз.

Spatial Mapping в Unity

HoloToolkit содержит готовые компоненты для работы со Spatial Mapping, основными из которых являются:

  1. Spatial Mapping, основной компонент.
  2. Remote Mapping, позволяющий делиться данными об окружающем пространстве.
  3. Spatial Understanding — решение, предоставленное разработчиками «Young Conker», позволяет получить более детальный анализ поверхностей. Что значит более детальный? Это значит, что ваши стены, пол и потолок разбиваются на плоскости, что позволяет оптимизировать положение голограмм.

В части 2 мы добавили SpatialPerceptions в Capabilities. Если вы ещё не сделали этого, сделайте, т.к. иначе Spatial Mapping работать не будет. Если вы хотите впоследствии работать с Remote Mapping, включите в Capabilites поддержку сети. Добавьте в сцену префаб SpatialMapping.

Как разрабатывать приложения смешанной реальности для Microsoft HoloLens: взаимодействие с окружающим пространством 2

Основными компонентами префаба являются три скрипта:

  1. SpatialMappingObserver. Является оболочкой для Surface Observer, сканера поверхностей. Позволяет задать количество полигонов на кубометр, объём в пространстве (наш ограничитель) и частоту обновлений.
  2. ObjectSurfaceObserver. Позволяющий загрузить в Unity модель комнаты.
  3. SpatialMappingManager. Управляет текущим источником поверхностей, т.е. компонентами 1) и 2). Позволяет настраивать параметры отображения и материал для поверхности.

Первым делом добавьте модель комнаты в ObjectSurfaceObserver. Во вкладке Project в строке поиска введите “SRMesh“. Добавьте его в поле Room Model. После этого вы должны увидеть в Unity следующую картину:

Как разрабатывать приложения смешанной реальности для Microsoft HoloLens: взаимодействие с окружающим пространством 3

Это — наша тестовая комната. Вы можете загрузить свою собственную модель комнаты. О том, как это можно сделать, я расскажу в следующей части.
Настройте префаб так же, как на картинке. Обязательно отключите AutoStartObserver, мы будем делать это из кода.

Как разрабатывать приложения смешанной реальности для Microsoft HoloLens: взаимодействие с окружающим пространством 4

Создайте пустой объект, добавьте скрипт SurfaceMeshesToPlane. Скрипт позволяет отобразить доступные для работы поверхности в виде объекта SurfacePlane. Вы можете указать скрипту, какие поверхности отобразить, а какие удалить.

Теперь попробуем просканировать комнату. Приложение может сканировать непрерывно, в течение определённого времени или до тех пор, пока не будет предоставлено достаточное количество поверхностей. Всё зависит от ваших текущих задач. Напишем простой скрипт, позволяющий реализовать каждый из перечисленных сценариев:

			using System;
using UnityEngine;
using System.Collections;
using HoloToolkit.Unity;

public class StartSpatialMapping : MonoBehaviour
{
    void Start()
    {
        StartCoroutine(StartScan());
    }
    
    IEnumerator StartScan()
    {
        SpatialMappingManager.Instance.drawVisualMeshes = true; // Отрисовка полигонов комнаты
        SpatialMappingManager.Instance.StartObserver(); // Запускаем сканирование.
        yield return new WaitForSeconds(5.0f); // Ждём 15 секунд
        SpatialMappingManager.Instance.StopObserver(); // Останавливаем сканирование, если сканирование больше не нужно.
        SurfaceMeshesToPlanes.Instance.MakePlanesComplete += delegate
        {
            // Достаточно ли у нас поверхностей для дальнейшей работы. 
            if (SurfaceMeshesToPlanes.Instance.GetActivePlanes(PlaneTypes.Floor | PlaneTypes.Table).Count > 2) 
            {
                SpatialMappingManager.Instance.drawVisualMeshes = false;
                Debug.Log("Enough planes to continue");
            }
            foreach (var instanceActivePlane in SurfaceMeshesToPlanes.Instance.ActivePlanes) //MakePlanes отрисовывает
                //Поверхности в реальном мире. Если это не требуется -- мы их удаляем.
                Destroy(instanceActivePlane);
            foreach (var mesh in SpatialMappingManager.Instance.GetMeshes())
                Destroy(mesh); // То же касается полигонов.

        };
        SurfaceMeshesToPlanes.Instance.MakePlanes(); // Создаём поверхности (пол, стены и т.д.)
    }
}
		

Если вы не удаляли поверхности, то должны увидеть следующую картину в Unity (белая поверхность — пол, синяя — стол):

Как разрабатывать приложения смешанной реальности для Microsoft HoloLens: взаимодействие с окружающим пространством 5

Попробуем установить автомобиль на горизонтальную поверхность. В первую очередь добавьте компонент BoxCollider родительскому элементу автомобиля. Настройте коллайдер так, чтобы автомобиль полностью находился внутри. Напишем скрипт, реализующий данную возможность:

			using UnityEngine;
using HoloToolkit.Unity;

public class PlaceObjectOnHorizontalSurface : Singleton<PlaceObjectOnHorizontalSurface>
{
    private bool _isPlacing;

    private readonly float _hoverDistance = 0.15f; // Расстояние между объектом и полом во время передвижения (объект немножно "Парит")

    private float _distanceThreshold = 0.02f; // Мин. расстояние

    private float _maximumPlacementDistance = 15.0f;

    private readonly float _placementVelocity = 0.06f;

    private Vector3 _targetPosition;

    public bool IsPlacing
    {
        get { return _isPlacing; }
        set { _isPlacing = value; }
    }

    void Awake()
    {
        _targetPosition = gameObject.transform.position;
        gameObject.GetComponent<BoxCollider>().enabled = true;
        _isPlacing = true; 

    }
    
    void Update()
    {
        if (_isPlacing)
            Move();
    }

    // Перемещаем объект.
    private void Move()
    {
        Vector3 moveTo;
        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;

            if (hitInfo.distance <= _hoverDistance)
                offsetDistance = 0f;
            moveTo = hitInfo.point + (offsetDistance * hitInfo.normal);
        }
        else // ...В противном случае, машина перемещается на расстояние 3м от пользователя.
            moveTo = Camera.main.transform.position + (Camera.main.transform.forward * 3f); ;

        gameObject.transform.position = Vector3.Lerp(gameObject.transform.position, moveTo, _placementVelocity);
    }

    //TODO: Делаем public метод. Каким образом его вызывать - зависит от вашей фантазии.
    public void Place()
    {
        Vector3 position;
        Vector3 surfaceNormal;

        if (CanBePlaced(out position, out surfaceNormal))
        {
            _targetPosition = position + (0.01f * surfaceNormal);
            gameObject.GetComponent<BoxCollider>().enabled = false;
            gameObject.transform.position = _targetPosition;
            _isPlacing = false;
        }
    }

    // Можно ли поставить объект на горизонтальную поверхность.
    private bool CanBePlaced(out Vector3 position, out Vector3 surfaceNormal)
    {
        var raycastDirection = -(Vector3.up);
        position = Vector3.zero;
        surfaceNormal = Vector3.zero;

        Vector3[] facePoints = GetColliderFacePoints(); // А для этого смотрим, пересекаются ли у нас точки коллайдера с поверхностью

        for (int i = 0; i < facePoints.Length; i++)
        {
            facePoints[i] = gameObject.transform.TransformVector(facePoints[i]) + gameObject.transform.position;
        }

        RaycastHit centerHit;
        if (!Physics.Raycast(facePoints[0],
            raycastDirection,
            out centerHit,
            _maximumPlacementDistance,
            SpatialMappingManager.Instance.LayerMask))
        {
            return false; // Если цент не попадает на гор. поверхность - ставить нельзя.
        }

        position = centerHit.point;
        surfaceNormal = centerHit.normal;

        for (int i = 1; i < facePoints.Length; i++) // Проверяем крайние точки.
        {
            RaycastHit hitInfo;
            if (Physics.Raycast(facePoints[i],
                raycastDirection,
                out hitInfo,
                _maximumPlacementDistance,
                SpatialMappingManager.Instance.LayerMask))
            {
                if (!IsEquivalentDistance(centerHit.distance, hitInfo.distance))
                {
                    return false;
                }
            }
            else
            {
                return false;
            }
        }
        return true;
    }


    private Vector3[] GetColliderFacePoints()
    {
        var boxCollider = gameObject.GetComponent<BoxCollider>();
        var extents = boxCollider.size / 2;

        var minX = boxCollider.center.x - extents.x;
        var maxX = boxCollider.center.x + extents.x;
        var minY = boxCollider.center.y - extents.y; // Берем только одну нижнюю точку по y.
        var minZ = boxCollider.center.z - extents.z;
        var maxZ = boxCollider.center.z + extents.z;

        var center = new Vector3(boxCollider.center.x, minY, boxCollider.center.z);
        var corner0 = new Vector3(minX, minY, minZ);
        var corner1 = new Vector3(minX, minY, maxZ);
        var corner2 = new Vector3(maxX, minY, minZ);
        var corner3 = new Vector3(maxX, minY, maxZ);

        return new[] { center, corner0, corner1, corner2, corner3 };
    }
    
    private bool IsEquivalentDistance(float d1, float d2)
    {
        float dist = Mathf.Abs(d1 - d2);
        return dist <= _distanceThreshold;
    }
}
		

Теперь автомобиль будет двигаться вместе с вами по комнате. Каким образом вы будете его устанавливать — голосом или жестами — зависит от вас. Как это сделать вы уже знаете.

SpatialAnchor в Unity

Для работы с пространственными якорями в Unity есть специальный компонент WorldAnchor, а также класс WorldAnchorStore, предназначенный для сохранения якорей на устройстве.

Теперь представьте: вот вы установили один раз автомобиль и хотите, чтобы он оставался на том же самом месте и при следующем запуске программы. Первым делом добавьте свойство в скрипт PlaceObjectOnHorizontalSurface, а в методе Awake уберите установку поля в true:

			public bool IsPlacing
    {
        get { return _isPlacing; }
        set { _isPlacing = value; }
    }
		

Прикрепите к родительскому объекту Car следующий скрипт (все пояснения в комментариях):

			using HoloToolkit.Unity;
using UnityEngine;
using UnityEngine.VR.WSA;
using UnityEngine.VR.WSA.Persistence; // Обязательно добавляем.

public class CarAnchorManager : Singleton<CarAnchorManager>
{
    private WorldAnchorStore _anchorStore;

    private const string AnchorId = "NiceCar"; // ID якоря.

    void Start()
    {
        WorldAnchorStore.GetAsync(AnchorStoreReady); // Загружаем AnchorStore.
    }

    private void AnchorStoreReady(WorldAnchorStore store)
    {
        _anchorStore = store;
        var anchor = _anchorStore.Load(AnchorId, gameObject); // Попробуем загрузить якорь.
        if (anchor == null)
        {
            PlaceObjectOnHorizontalSurface.Instance.IsPlacing = true; // Если такого нет, добавляем возможность двигать автомобиль
        }
    }

    //Вызываем этот метод в PlaceObjectOnHorizontalPlace в методе Place
    public void CreateAndExportAnchor()
    {
        var anchor = gameObject.AddComponent<WorldAnchor>();
        if (anchor.isLocated) // Если якорь определён в пространстве.
        {
            Export();
        }
        else
            anchor.OnTrackingChanged += OnTrackingChange;
    }

    private void OnTrackingChange(WorldAnchor self, bool located)
    {
        if (located)
            Export();
    }

    private void Export()
    {
        var anchor = GetComponent<WorldAnchor>();

        if (anchor == null)
            return;
        _anchorStore.Save(AnchorId, anchor); // Сохраняем якорь для следующего использования.
    }
}
		

Учтите: если вы захотите переставить автомобиль в другое место, вам придётся удалить компонент WorldAnchor, затем после установки создать его заново и сохранить.

В данной статье мы изложили основные принципы взаимодействия голограмм с окружающим пространством. Фактически наше приложение готово. Мы можем смело загружать его на устройство. О том, как это сделать, будет рассказано в следующей статье.

Выражаем благодарность Тимуру Ахметову, разработчику из компании HoloGroup и департаменту стратегических технологий Microsoft за предоставленный материал.

Для справки: HoloGroup является одним из первых разработчиков для HoloLens в России и 1 сентября 2016 года выпустила первое русскоязычное приложение HoloStudy.

Unity
Виртуальная реальность
Microsoft
HoloLens
Материалы от друзей Tproger
3071