Pull to refresh

Как виртуальная реальность пришла в проект на Unity

Reading time 11 min
Views 20K
«Веяния моды привносят в жизнь новые проблемы заставляют меняться» Наверно именно от этой мысли было принято решение подключить в проект на 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<OVRCameraRig>().centerEyeAnchor;
	if (null != guiCamera) {
		guiRootPanel = guiCamera.GetComponentInParent<UIPanel>();
		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();
}

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



Для отображения плоскости с изображением меню поверх всех объектов окружения, необходимо гуглить писать шейдер который всегда будет рисовать себя поверх всех. В мануале написания шейдеров к движку 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<UIPanel>();
 	…
}
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;
	}
}

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



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



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

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

У прожектора есть небольшое ограничение, он невидим на скайбоксе. Для исправления данной особенности, я решил сделать следующее:
  1. добавил новый объект дочерний к прожектору;
  2. на этот объект добавил компоненты: MeshRenderer, Mesh;
  3. выбрал не освещаемый материал с изображением курсора;
  4. выставил согласно длины и размера прожектора.

Вот такой результат получился в итоге:





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

Была идея написать для камер шейдер рисующий картинку курсора для глаза с сдвигом к носу в зависимости от 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<UITexture>();
		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. Наверно некоторые подумают, что это бред и почему для пространственного курсора я не использовал более дешевый прием с райкастом и рисованием билборда направленного в сторону плеера с вычислением его размера относительно дальности. На сцене в используемом проекте не везде стоят коллайдеры и некоторые коллайдеры не соответствуют размерам объекта и в итоге получается, что курсор иногда висит непонятно где и как.
Tags:
Hubs:
+23
Comments 14
Comments Comments 14

Articles