[Из песочницы] Как виртуальная реальность пришла в проект на Unity

«Веяния моды привносят в жизнь новые проблемы заставляют меняться» Наверно именно от этой мысли было принято решение подключить в проект на Unity, шлем виртуальной реальности, всем известный Oculus Rift DK2. Вопреки суровому прощупыванию рублем финансового дна удалось заказать Oculus Rift с доставкой в Санкт-Петербург по адекватной цене. Оперативно, менее чем за две недели, заказ прибыл в стены нашего офиса.863c7111362e49d58c724cec26339b34.png

Общее впечатлениеВ коробке, как и предполагалось лежал сам шлем, набор необходимых кабелей, 2 комплекта линз и камера позиционирования шлема в пространстве. После распаковки и подключения к тестовому компьютеру сразу обнаружилась первая странность. Шлем отказывался работать в режиме Direct Display, прекрасно чувствуя себя в режиме второго монитора. Причем данная особенность наблюдалась только на тестовом компьютере. В качестве решения было принято множество адекватных и не очень решений в виде переустановки драйверов, установки недостающих Microsoft Visual C++ Redistributable и прочих «нужных» приложений и библиотек. После переустановки Windows шлем по-прежнему работал только в режиме расширенного дисплея. Но мудрый коллега установил на тестовый компьютер все доступные на тот момент обновления Windows, за что ему огромное спасибо. И одно, но самое нужное, из более тысячи установленных обновлений решало проблему, шлем заработал в режиме Direct Mode.Наконец-то, можно было приступить к вкусному — играм тестированию возможностей. Первое впечатление — «ВАУ». Мозг активно утверждал, что все реально и можно даже потрогать. Словами это не описать, лучше попробовать.

Интеграция в проект Опустим лирику, пора приступать к серьезным вещам — интеграции шлема в проект на движке Unity.Первым делом скачан официальные пакет Oculus Unity 4 Integration, наиболее актуальной версии. Разработчикам пакета очень хочется сказать, спасибо, префаб плеера сделан на отлично, несколько кликов позволяет погрузится в виртуальную реальность своего проекта. Только вот изображения и определение позиции и поворот головы для полноценного проекта недостаточно, необходимо сделать несколько вещей: отображать интерфейс пользователя; показывать курсор; вычислять луч с камер; Приступая к реализации в качестве исходного префаба был взят — OvrCameraRig, находящийся в официальном пакете к Unity.Отображение интерфейса пользователя После экспериментов и переделывания всего используемого интерфейса, а его предостаточно, наиболее оптимальным выбрано направление — получать изображение с камеры интерфейса в текстуру, затем отображать её перед игроком. Добавив новый класс, отвечающий за интеграцию шлема в проект. В нем появились первые строчки кода, позволяющий по маске слоя интерфейса найти нужную камеру и получать с нее изображение. [SerializeField] private string guiLayerName; [SerializeField] private string guiLayerPlaneName; [SerializeField] private Color backgroundColor = new Color (0, 0, 0, 0.5f); [SerializeField] private GameObject guiPlanePrefab = null; private RenderTexture _guiRenderTexture = null; private RenderTexture guiRenderTexture { get { if (null == _guiRenderTexture) { _guiRenderTexture = new RenderTexture (Screen.width, Screen.height, 0); } return _guiRenderTexture; } } private Transform centerEyeAnchor = null; private Camera guiCamera = null; private int guiLayer = 0; private int guiLayerPlane = 0; private GameObject guiPlane = null; private void Start () { guiLayer = LayerMask.NameToLayer (guiLayerName); guiLayerPlane = LayerMask.NameToLayer (guiLayerPlaneName); guiCamera = NGUITools.FindCameraForLayer (guiLayer); centerEyeAnchor = GetComponent().centerEyeAnchor; if (null!= guiCamera) { guiRootPanel = guiCamera.GetComponentInParent(); guiCamera.targetTexture = guiRenderTexture; if (null!= guiPlanePrefab) { guiPlane = Instantiate (guiPlanePrefab) as GameObject; guiPlane.layer = guiLayerPlane; guiPlane.renderer.material.mainTexture = guiRenderTexture; Vector3 ls = guiPlane.transform.lossyScale; Vector3 lp = guiPlane.transform.position; Quaternion lr = guiPlane.transform.rotation; guiPlane.transform.parent = transform; guiPlane.transform.localScale = ls; guiPlane.transform.localPosition = lp; guiPlane.transform.localRotation = lr; } } else { throw new UnityException (string.Format («Camera for layer {0} not found», guiLayer)); } } private void OnGUI () { RenderTexture previousActive = RenderTexture.active; RenderTexture.active = guiRenderTexture; GL.Clear (false, true, backgroundColor); RenderTexture.active = previousActive; guiCamera.Render (); } Изначально картинка с интерфейсом отображалась на обычной прямоугольной плоскости, но при использовании шлема возникал дискомфорт. Были испробованы различные варианты формы поверхности, самым приятным глазу стала изогнутая плоскость, на подобии современных изогнутых телевизоров. Плоскость с нужными значениями была вынесена в отдельный префаб, но можно этого не делать и пропустить участок кода с выставлением позиции плоскости. Я рекомендую выбрать такие параметры позиции плоскости перед игроком, чтобы пользователь, приближая голову не смог посмотреть, как выглядит плоскость сзади, но и не слишком далеко, чтобы в случае можно было приблизить голову и прочитать что написано. В результате при запуске получилась примерно вот такая картина.3bbb8a6a7eab46a08cbd13586a481369.png

Для отображения плоскости с изображением меню поверх всех объектов окружения, необходимо гуглить писать шейдер который всегда будет рисовать себя поверх всех. В мануале написания шейдеров к движку Unity в статье «ShaderLab syntax: Culling & Depth Testing» описано что в проход шейдера добавить параметр ZTest Always и будет счастье шейдер будет рисовать, как и планировалось. Выбрав первый попавшийся не освещаемый шейдер, я использовал шейдер, поставляемый совместно с NGUI, копируем его, даем новое имя и добавляем параметр ZTest.

Pass { Cull Off Lighting On ZWrite Off ZTest Always Fog { Mode Off } Offset 0, -1 Blend SrcAlpha OneMinusSrcAlpha CGPROGRAM #pragma vertex vert #pragma fragment frag #include «UnityCG.cginc» … } Вид из редактора позволяет увидеть, что плоскость хоть и пересекает стену строения, но изображение все равно рисуется последним.394f5ae176a1423ca996f04410bcfa50.png

А так это выглядит в шлеме:

85ccb128bf8a4069a40c0dc3f0738164.png

Результат работы мне понравился, как по ресурсам, так и по виду. Отбросив виртуальную реальность в сторону, можно налить себе очередную чашечку кофе и поболтать с коллегами о бытии мирском.

Курсор «Откуда куда зачем все это непонятно. Сидел бы пил кофе, смотрел бы в плоский монитор, не сильно то и нужна мне эта виртуальная реальность. Хотя кого я обманываю, конечно же нужна«Никогда не думал, что отображать курсор будет сложно, но для шлема это оказалась весьма интересная задачка. Самое первое и очевидное рисовать курсор на обычном интерфейсе и отображать его на плоскости перед игроком.UIPanel, UISprite или UITexture, получается курсор. Красиво, изящно, просто. Но в шлеме все совершенно иначе. Двигаем мышкой курсор — двигается, наводим на элемент интерфейса — реагирует, отлично, даже не верится. Наводим курсор на пустую область меню, смотрим в пространство и мозг пытается фокусироваться на объекте в пространстве, но какая-то мушка на стекле, курсор, мешает это сделать, либо курсор раздваивается, либо пространство впереди. Конечно можно сделать так чтобы меню исчезало и появлялось по желанию пользователя. Делается это добавлением парой строк кода и несколькими дополнительными свойствами. [SerializeField] private KeyCode showMenuKey = KeyCode.None; [SerializeField] private float displayTime = 0.61f; private float alphaCalculate = 1; private UIPanel guiRootPanel = null; private Start () { … guiRootPanel = guiCamera.GetComponentInParent(); … } private void OnGUI () { RenderTexture previousActive = RenderTexture.active; RenderTexture.active = guiRenderTexture; if (null!= guiRootPanel) guiRootPanel.alpha = alphaCalculate; Color color = backgroundColor * new Color (1, 1, 1, alphaCalculate); GL.Clear (false, true, color); RenderTexture.active = previousActive; guiCamera.Render (); } private void LateUpdate () { if (Input.GetKeyDown (showMenuKey)) { menuIsShow = ! menuIsShow; StopCoroutine («LerpAlpha»); StartCoroutine («LerpAlpha», (menuIsShow? 1: 0)); } } private IEnumerator LerpAlpha (float endAlpha) { float t = 0; float time = Mathf.Abs (endAlpha — alphaCalculate) / displayTime; while (t < time) { t += Time.deltaTime; alphaCalculate = Mathf.Lerp(alphaCalculate, endAlpha, t / time); yield return null; } } Возможно этого будет достаточно в тех проектах где меню не используется в игровом мире. Но текущий проект подразумевал другое использование меню и поиски решений продолжились.Была опробована идея "лазерной указки”. Привязать к плееру новый объект с источником света, выставить следующие параметры.6df867e8d4714c4b84fd2a81c5525636.png

И мечта котика, светящаяся точка, перемещается по виртуальному миру. В шлеме глаз нарадоваться не может. Точка аккурат на том объекте куда указывает и никакого дискомфорта. Наигравшись с «лазерной указкой» в виртуальном мире, возвращаюсь в реальный и понимаю, решение не совсем подходящее. Заменив источник света на прожектор получается красивый курсор, он может быть не только светящейся точкой, но и любой картинкой которая есть в наличии.

ccd708f94a304207a92e23a64bd38674.PNG

Плюс в материал прожектора необходимо вставить следующий шейдер:

Shader«Projector/Additive»{ Properties{ _ShadowTex («Cookie»,2D)=»{TexGenObjectLinear} } Subshader{ Pass{ CullBack ZWriteOff Color[_Color] ColorMaskRGB BlendSrcAlphaOneMinusSrcAlpha Offset0,0 SetTexture[_ShadowTex]{ constantColor (1,1,1,1) combinetexture*constant, texture Matrix[_Projector] } } } } У прожектора есть небольшое ограничение, он невидим на скайбоксе. Для исправления данной особенности, я решил сделать следующее: добавил новый объект дочерний к прожектору; на этот объект добавил компоненты: MeshRenderer, Mesh; выбрал не освещаемый материал с изображением курсора; выставил согласно длины и размера прожектора. Вот такой результат получился в итоге: 42a43421609942b79183e18e1888404c.png

4d28cdd628b04edfb13db6cb061e0eee.png

В результате есть курсор, который видим в пространстве, но никак не видим в меню и наоборот, есть в меню, но вызывает дискомфорт при его отображении в пространстве.

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

Оставив все как есть, я взялся за определение позиции курсора. В распоряжении имеется позиция курсора согласно указателю мыши, есть значения поворота и положения головы. Как наиболее адекватно управлять курсором совершенно не понятно. «Истина рождается в споре.» После небольшого обсуждения с коллегами их мнения разделились, одна половина говорила, что двигать курсором лучше мышкой, другая — лучше головой. «Лучше один раз попробовать, а потом обсуждать.»

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

[SerializeField] private GameObject cursor3dPrefab = null; [SerializeField] private GameObject cursor2dPrefab = null; private GameObject cursor3d = null; private GameObject cursor2d = null; private Start () { … if (null!= cursor3dPrefab) { cursor3d = Instantiate (cursor3dPrefab) as GameObject; cursor3d.transform.parent = centerEyeAnchor; cursor3d.transform.localPosition = Vector3.zero; cursor3d.transform.localRotation = Quaternion.identity; cursor3d.transform.localScale = Vector3.one; } if (null!= cursor2dPrefab) { cursor2d = Instantiate (cursor2dPrefab) as GameObject; cursor2d.transform.parent = guiCamera.transform; cursor2d.transform.localPosition = Vector3.zero; cursor2d.transform.localRotation = Quaternion.identity; cursor2d.transform.localScale = Vector3.one; UITexture texture = cursor2d.GetComponentInChildren(); if (null!= texture) cursor2d = texture.gameObject; } … } Но два разных курсора, да еще и которые управляются по-разному, это мне показались халтурным исполнением.ScreenPointToRay для Raycast «Что нам стоит дом построить, нарисуем будем жить«Половина пути пройдено. Надо вычислять и возвращать луч. Одна очень хорошая мысль, моментально, посетила мою бедную голову, жалко это происходит не регулярно. Необходимо написать для камеры расширение, которое имело следующий вызов, Camera.main.ExternalScreenPointToRay, и возвращало новый луч. Для этого необходим код: public static class ExternalCamera { public static RayExternalScreenPointToRay (thisCameracamera, Vector3position){ return camera.ScreenPointToRay (position); } } Добавлен статичный флаг о возможности использовать вычисления позиции шлема. public static bool useOVR { get; private set; } А также добавления статичной ссылки на экземпляр класса public static ExtensionOVR instance { get; private set; } Не забываем в функции Start () присваивать им значения instance = this; useOVR = true; Такой синглтон получился.Для переключения режимом вычисления значении луча я создал следующее перечисление. Ох, и люблю я это делать, плодить перечисления.

public enum CameraRay { Head, Cursor } В качестве отправной точки я решил взять следующее условие: если камера трехмерная — луч это туда куда направлен прожектор, если двухмерная — луч — это позиция где пересекается прожектор и плоскость на которой отображается рендер меню. Т.е. вычисления примут примерно следующий вид: public Ray ScreenPointToRay (Camera camera, Vector2 position) { return camera.orthographic? guiPointToRay: headPointToRay; } А код расширения камер примет вот такой вид: public static Ray ExternalScreenPointToRay (this Camera camera, Vector3 position) { return ExtensionOVR.useOVR? ExtensionOVR.instance.ScreenPointToRay (camera, position) : camera.ScreenPointToRay (position); } Для вычисления луча трехмерного курсора все предельно понятно: private Ray headPointToRay { get { return (cameraRay == CameraRay.Cursor && null!= cursor3d) ? new Ray (cursor3d.transform.position, cursor3d.transform.forward) : new Ray (centerEyeAnchor.position, centerEyeAnchor.forward); } } Для вычисления позиции двухмерного луча необходимо найти пересечение прожектора и плоскости. Это легко посчитать, используя RaycastHit.textureCoord. Предварительно к плоскости добавлен Mesh Collider и она выделена в отдельный слой. public Vector2 cursorPosition { get; private set; }

private Ray guiPointToRay { get { RaycastHit hit; if (Physics.Raycast (headPointToRay, out hit, 1000, 1 << guiLayerPlane)) { cursorPosition = new Vector2(hit.textureCoord.x * Screen.width, hit.textureCoord.y * Screen.height); return guiCamera.ScreenPointToRay(cursorPosition); } else { return new Ray(); } } } Немного добавил изменение позиции курсора согласно выбранному режиму в функцию Update(). if (cameraRay == CameraRay.Cursor && null != cursor3d) { Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); cursor3d.transform.LookAt(cursor3d.transform.position + ray.direction); } if (null != cursor2d) { cursor2d.transform.localPosition = cursorPositionOffset; } Ну и еще свойство для удобства: public Vector2 cursorPositionOffset { get { return cursorPosition + offsetCursor; } } Теперь заменив все вызовы ScreenPointToRay на ExternalScreenPointToRay курсор синхронно двигается в меню и в пространстве, смотреть одно загляденье. Правда есть маленький минус. Курсор теперь виден на плоскости и в пространстве одновременно. Немного преобразив код шедера плоскости с изображением интерфейса, убираем полупрозрачность. v2f vert (appdata_t v) { ... o.color.a = v.color.a > 0? 3: 0; … } И финальный штрих, в интерфейсе на месте где должен отображаться курсор вешается коллайдер и проверяя находится ли двухмерный курсор над коллайдером интерфейса отображаем его, либо прячем в противном случае. public bool enable2DCursor { get { return Physics.Raycast (guiCamera.ScreenPointToRay (cursorPosition), float.MaxValue, 1 << guiLayer); } }

private void Update () { … if (enable2DCursor) { cursor2d.transform.localPosition = cursorPositionOffset; } else { cursor2d.transform.localPosition = Vector3.up * 10000; } … } Вот вроде и все, есть возможность навигации по меню, есть возможность отображать курсор там куда будет фокусироваться зрение, есть возможность вычислять луч для Raycast.Итог Какие я хочу дать рекомендации для оптимизации проекта под виртуальный мир на основе полученного опыта.Использовать крупный шрифт, достаточно посмотреть на билборд при запуске шлем, не смотря на то что кто-то скажет, что крупный шрифт не смотрится. Лучше услышать от пользователя что некрасиво выглядит, чем то, что непонятно что написано и он не может с этим работать. Все двухмерное меню стараться сдвигать к центру, лучше делать его трехмерным. Но лучше это делать с нуля, а не переделывать имеющееся. Использование шлема виртуальной реальности значительно улучшает презентабельность продукта. Для использования шлема виртуальной реальности нужна очень хорошо оптимизированная сцена. Иначе при высоких настройках изображение расплывается и организм чувствует себя не совсем хорошо. Это написано в мануале, но я от себя добавлю не используйте MSAA 8x, размер RenderTexture для глаза весит >100 Mb в памяти что не может не огорчать. P.S. Наверно некоторые подумают, что это бред и почему для пространственного курсора я не использовал более дешевый прием с райкастом и рисованием билборда направленного в сторону плеера с вычислением его размера относительно дальности. На сцене в используемом проекте не везде стоят коллайдеры и некоторые коллайдеры не соответствуют размерам объекта и в итоге получается, что курсор иногда висит непонятно где и как.

© Habrahabr.ru