[Из песочницы] Как виртуальная реальность пришла в проект на Unity
«Веяния моды привносят в жизнь новые проблемы заставляют меняться» Наверно именно от этой мысли было принято решение подключить в проект на Unity, шлем виртуальной реальности, всем известный Oculus Rift DK2. Вопреки суровому прощупыванию рублем финансового дна удалось заказать Oculus Rift с доставкой в Санкт-Петербург по адекватной цене. Оперативно, менее чем за две недели, заказ прибыл в стены нашего офиса.
Общее впечатлениеВ коробке, как и предполагалось лежал сам шлем, набор необходимых кабелей, 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
Для отображения плоскости с изображением меню поверх всех объектов окружения, необходимо гуглить писать шейдер который всегда будет рисовать себя поверх всех. В мануале написания шейдеров к движку 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» … } Вид из редактора позволяет увидеть, что плоскость хоть и пересекает стену строения, но изображение все равно рисуется последним.
А так это выглядит в шлеме:
Результат работы мне понравился, как по ресурсам, так и по виду. Отбросив виртуальную реальность в сторону, можно налить себе очередную чашечку кофе и поболтать с коллегами о бытии мирском.
Курсор
«Откуда куда зачем все это непонятно. Сидел бы пил кофе, смотрел бы в плоский монитор, не сильно то и нужна мне эта виртуальная реальность. Хотя кого я обманываю, конечно же нужна«Никогда не думал, что отображать курсор будет сложно, но для шлема это оказалась весьма интересная задачка. Самое первое и очевидное рисовать курсор на обычном интерфейсе и отображать его на плоскости перед игроком.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
И мечта котика, светящаяся точка, перемещается по виртуальному миру. В шлеме глаз нарадоваться не может. Точка аккурат на том объекте куда указывает и никакого дискомфорта. Наигравшись с «лазерной указкой» в виртуальном мире, возвращаюсь в реальный и понимаю, решение не совсем подходящее. Заменив источник света на прожектор получается красивый курсор, он может быть не только светящейся точкой, но и любой картинкой которая есть в наличии.
Плюс в материал прожектора необходимо вставить следующий шейдер:
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; выбрал не освещаемый материал с изображением курсора; выставил согласно длины и размера прожектора. Вот такой результат получился в итоге:
В результате есть курсор, который видим в пространстве, но никак не видим в меню и наоборот, есть в меню, но вызывает дискомфорт при его отображении в пространстве.
Была идея написать для камер шейдер рисующий картинку курсора для глаза с сдвигом к носу в зависимости от 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
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. Наверно некоторые подумают, что это бред и почему для пространственного курсора я не использовал более дешевый прием с райкастом и рисованием билборда направленного в сторону плеера с вычислением его размера относительно дальности. На сцене в используемом проекте не везде стоят коллайдеры и некоторые коллайдеры не соответствуют размерам объекта и в итоге получается, что курсор иногда висит непонятно где и как.