В этой статье я бы хотел поделится с вами небольшими решениями которые, я применял в разработке развлекательного приложения для нескольких VR – платформ. Само приложение представляет из себя проигрыватель видео (360/4K, FullHD/2D, 2K/3-Screen, стриминг видеопотока, закачка в файл для offline-воспроизведения). Меню приложения выполнено в стилизированных 3D сценах под конкретный фильм. 3-Screen - это Barco Escape (https://www.barco.com/en/product/barco-escape) формат где видео состоит из трех экранов.
Мне необходимо было разработать приложения для платформ:
- Oculus Rift
- Oculus/Samsung GearVR
- Steam HTCVive
- Google Daydream
- iOS Cardboard
- Viveport HTCVive
Оборудование для тестирования:
- Samsung S6 (как минимальный для GearVR)
- PC-desktop: i5-4590/8Gb/nVidia 970 (Oculus Rift / HTC Vive)
- Samsung S8 (для Google Daydream)
- iPhone 7 (для iOS)
Оптимизация.
За основу в требованиях лучше всего подойдет Rift Virtual Reality Check (VRC) Guidelines (https://developer.oculus.com/distribute/latest/concepts/publish-rift-app-submission/) и Mobile Virtual Reality Check (VRC) Guidelines (https://developer.oculus.com/distribute/latest/concepts/publish-mobile-req/) - как самые требовательные к аппаратному обеспечению. Если вы пройдете ревью в Oculus то к производительности у вас не будет вопросов на других платформах. Приложение должно стабильно работать на протяжении 45 минут с производительностью 60 fps на GearVR и с производительностью 90 fps на Rift. Для начала можно прикрутить самописный счетчик кадров и показывать под прицелом:
using UnityEngine; using UnityEngine.UI; public class FPSCounter : MonoBehaviour { public float updateInterval = 0.5F; public string tOut; public Text text; private float accum = 0f; // FPS accumulated over the interval private int frames = 0; // Frames drawn over the interval private float timeleft = 0f; // Left time for current interval private void Start() { timeleft = updateInterval; } private void Update() { timeleft -= Time.deltaTime; accum += Time.timeScale / Time.deltaTime; ++frames; if (timeleft <= 0.0) { float fps = accum / frames; tOut = string.Format("{0:F0} FPS", fps); timeleft = updateInterval; accum = 0f; frames = 0; } if (text != null) { text.text = tOut; } } }
Но, лучше использовать Oculus Debug Tool (https://developer.oculus.com/documentation/pcsdk/latest/concepts/dg-debug-tool/), где в процессе работы будет сниматься статистика и рисоваться на график в реальном времени поверх приложения, - утилиты существуют как для мобильных устройств так и для desktop (работает и с HTC Vive):
Придерживаясь стандартных правил удалось добиться должной производительности:
- минимум полигонов
- запекание света и отражений в текстуры (камера не предполагает движение)
- избегать прозрачностей
- избегать мультиматериалов
- избегать перегруженных шейдеров
- периодически тестировать на устройствах прямо в процессе моделирования сцены
Когда на сцене проигрывается 2D видео, оно «проецируется» на экран, от которого предполагается свечение.
Shader "Onix/Unlit/ColorLightmap" { Properties { _Diffuse("Diffuse", 2D) = "white" {} [HideInInspector] _texcoord( "", 2D ) = "white" {} _LightmapWhite("LightmapWhite", 2D) = "white" {} [HideInInspector] _texcoord2( "", 2D ) = "white" {} _LightmapDark("LightmapDark", 2D) = "white" {} _LightColor("LightColor", 2D) = "white" {} _LightValue("LightValue", Range( 0 , 1)) = 0 } SubShader { Tags { "RenderType"="Opaque" "LightMode" = "ForwardBase" } LOD 100 Cull Off Pass { CGPROGRAM #pragma target 3.0 #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float4 texcoord : TEXCOORD0; float4 texcoord1 : TEXCOORD1; UNITY_VERTEX_INPUT_INSTANCE_ID }; struct v2f { float4 vertex : SV_POSITION; float4 texcoord : TEXCOORD0; float4 lightColor : COLOR; UNITY_VERTEX_OUTPUT_STEREO }; uniform sampler2D _Diffuse; uniform float4 _Diffuse_ST; uniform sampler2D _LightmapWhite; uniform float4 _LightmapWhite_ST; uniform sampler2D _LightmapDark; uniform float4 _LightmapDark_ST; uniform sampler2D _LightColor; uniform float _LightValue; v2f vert ( appdata v ) { v2f o; UNITY_SETUP_INSTANCE_ID(v); UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o); o.texcoord.xy = v.texcoord.xy; o.texcoord.zw = v.texcoord1.xy; // ase common template code o.vertex.xyz += float3(0,0,0) ; o.vertex = UnityObjectToClipPos(v.vertex); o.lightColor = tex2Dlod(_LightColor, float4(0.5, 0.5, 0, 16.0)); return o; } fixed4 frag (v2f i ) : SV_Target { fixed4 myColorVar; // ase common template code float2 uv_Diffuse = i.texcoord.xy * _Diffuse_ST.xy + _Diffuse_ST.zw; float2 uv2_LightmapWhite = i.texcoord.zw * _LightmapWhite_ST.xy + _LightmapWhite_ST.zw; float2 uv2_LightmapDark = i.texcoord.zw * _LightmapDark_ST.xy + _LightmapDark_ST.zw; float4 lerpResult4 = lerp( tex2D( _LightmapWhite, uv2_LightmapWhite ) , ( tex2D( _LightmapDark, uv2_LightmapDark ) * i.lightColor) , _LightValue); float4 blendOpSrc10 = tex2D( _Diffuse, uv_Diffuse ); float4 blendOpDest10 = lerpResult4; myColorVar = ( saturate( ( blendOpDest10 > 0.5 ? ( 1.0 - ( 1.0 - 2.0 * ( blendOpDest10 - 0.5 ) ) * ( 1.0 - blendOpSrc10 ) ) : ( 2.0 * blendOpDest10 * blendOpSrc10 ) ) )); return myColorVar; } ENDCG } } CustomEditor "ASEMaterialInspector" }
Texture texture = subPlayer.TextureProducer.GetTexture(); if (texture != null) { if (_videoFrame == null) { _videoFrame = new RenderTexture(32, 32, 0, RenderTextureFormat.ARGB32); _videoFrame.useMipMap = true; _videoFrame.autoGenerateMips = true; _videoFrame.Create(); } _videoFrame.DiscardContents(); // blit to RT so we can average over some pixels Graphics.Blit(texture, _videoFrame); }
Если ваша картинка все еще «пиксельная» - нужно применять сглаживание, включать mipmaps. А еще можете использовать ResolutionScale для поддерживаемых устройств:
if (OVRPlugin.tiledMultiResSupported) { UnityEngine.XR.XRSettings.eyeTextureResolutionScale = 1.2f; OVRPlugin.tiledMultiResLevel = OVRPlugin.TiledMultiResLevel.LMSMedium; } else { UnityEngine.XR.XRSettings.eyeTextureResolutionScale = 1.1f; }
Shader "UI/AAFont" { Properties { [PerRendererData] _MainTex("Sprite Texture", 2D) = "white" {} _Color("Tint", Color) = (1,1,1,1) _StencilComp("Stencil Comparison", Float) = 8 _Stencil("Stencil ID", Float) = 0 _StencilOp("Stencil Operation", Float) = 0 _StencilWriteMask("Stencil Write Mask", Float) = 255 _StencilReadMask("Stencil Read Mask", Float) = 255 _ColorMask("Color Mask", Float) = 15 [Toggle(UNITY_UI_ALPHACLIP)] _UseUIAlphaClip("Use Alpha Clip", Float) = 0 } SubShader { Tags { "Queue" = "Transparent" "IgnoreProjector" = "True" "RenderType" = "Transparent" "PreviewType" = "Plane" "CanUseSpriteAtlas" = "True" } Stencil { Ref[_Stencil] Comp[_StencilComp] Pass[_StencilOp] ReadMask[_StencilReadMask] WriteMask[_StencilWriteMask] } Cull Off Lighting Off ZWrite Off ZTest[unity_GUIZTestMode] Blend SrcAlpha OneMinusSrcAlpha ColorMask[_ColorMask] Pass { Name "Default" CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma target 2.0 #include "UnityCG.cginc" #include "UnityUI.cginc" #pragma multi_compile __ UNITY_UI_CLIP_RECT #pragma multi_compile __ UNITY_UI_ALPHACLIP struct appdata_t { float4 vertex : POSITION; float4 color : COLOR; float2 texcoord : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID }; struct v2f { float4 vertex : SV_POSITION; fixed4 color : COLOR; float2 texcoord : TEXCOORD0; float4 worldPosition : TEXCOORD1; UNITY_VERTEX_OUTPUT_STEREO }; fixed4 _Color; fixed4 _TextureSampleAdd; float4 _ClipRect; v2f vert(appdata_t v) { v2f OUT; UNITY_SETUP_INSTANCE_ID(v); UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(OUT); OUT.worldPosition = v.vertex; OUT.vertex = UnityObjectToClipPos(OUT.worldPosition); OUT.texcoord = v.texcoord; OUT.color = v.color * _Color; return OUT; } sampler2D _MainTex; fixed4 frag(v2f IN) : SV_Target { float2 dx = ddx(IN.texcoord) * 0.25; float2 dy = ddy(IN.texcoord) * 0.25; float4 tex0 = tex2D(_MainTex, IN.texcoord + dx + dy); float4 tex1 = tex2D(_MainTex, IN.texcoord + dx - dy); float4 tex2 = tex2D(_MainTex, IN.texcoord - dx + dy); float4 tex3 = tex2D(_MainTex, IN.texcoord - dx - dy); float4 tex = (tex0 + tex1 + tex2 + tex3) * 0.25; half4 color = (tex + _TextureSampleAdd) * IN.color; #ifdef UNITY_UI_CLIP_RECT color.a *= UnityGet2DClipping(IN.worldPosition.xy, _ClipRect); #endif #ifdef UNITY_UI_ALPHACLIP clip(color.a - 0.001); #endif return color; } ENDCG } } }
Плагин CurvedUI (https://assetstore.unity.com/packages/tools/gui/curved-ui-vr-ready-solution-to-bend-warp-your-canvas-53258) отлично справляется с быстрым переключением способов ввода на разных VR платформах, а так же позволяет искривить canvas до более приятной для VR изогнутой формы (жаль, что при этом ломая Dynamic batching).
Проигрывание видео.
Не так давно Unity улучшила и исправила многие недочеты своего Video Player. Пусть простота его использования, совместимость с разными платформами и производительность улучшилась, но не достаточно чтобы использовать его в текущем приложении (из-за проблем с поддержкой больших файлов, производительностью, поддержкой кодеков, стримингом видеопотока). Перепробовав множество различных решений я остановился на AVProVideo (https://assetstore.unity.com/packages/tools/video/avpro-video-56355). Он единственный, который мог обеспечить должную производительность, как на мобильных устройствах, так и на desktop-решениях.
Перед публикацией не забудьте устанавливать Hardware Decoding. Для сборки Oculus Rift необходимо установить Use Unity Audio и добавить компонент Audio Output – так пользователь услышит аудио не в VR-гарнитуре, а не в устройстве по умолчанию Windows (некоторые пользователи могут иметь нестандартные конфигурации).
360 видео проигрывается на сфере вокруг пользователя, 3-Screen на 3-plane mesh.
Загрузка файлов.
Загружать файлы можно с помощью WebClient и, практически, стандартных методов.
private void DownloadFile() { ServicePointManager.ServerCertificateValidationCallback = MyRemoteCertificateValidationCallback; _webClient = new WebClient(); _webClient.DownloadFileCompleted += new System.ComponentModel.AsyncCompletedEventHandler(AsyncCallDownloadComplete); _webClient.DownloadProgressChanged += new DownloadProgressChangedEventHandler(AsyncCallDownloadProgress); _webClient.DownloadFileAsync(new Uri(videoData.path), _lclPath); _isDownloading = true; } private void AsyncCallDownloadComplete(object sender, System.ComponentModel.AsyncCompletedEventArgs e) { _isDownloading = false; if (e.Error == null) { _dwnloadProgress = 1f; } else { _dwnloadProgress = 0f; File.Delete(_lclPath); Debug.Log(e.Error.Message); } } private void AsyncCallDownloadProgress(object sender, DownloadProgressChangedEventArgs e) { _dwnloadProgress = (float)e.ProgressPercentage / 100f; if (e.ProgressPercentage == 100) { _isDownloading = false; _dwnloadProgress = 1f; } }
Дополнительно, вам может понадобится проверка сертификата:
using System.Net; using System.Net.Security; using System.Security.Cryptography.X509Certificates; //To validate SSL certificates public static bool MyRemoteCertificateValidationCallback(System.Object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors) { bool isOk = true; // If there are errors in the certificate chain, look at each error to determine the cause. if (sslPolicyErrors != SslPolicyErrors.None) { for (int i = 0; i < chain.ChainStatus.Length; i++) { if (chain.ChainStatus[i].Status != X509ChainStatusFlags.RevocationStatusUnknown) { chain.ChainPolicy.RevocationFlag = X509RevocationFlag.EntireChain; chain.ChainPolicy.RevocationMode = X509RevocationMode.Online; chain.ChainPolicy.UrlRetrievalTimeout = new TimeSpan(0, 1, 0); chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllFlags; bool chainIsValid = chain.Build((X509Certificate2)certificate); if (!chainIsValid) { isOk = false; } } } } return isOk; }
Сохранять можно по пути:
_lclPath = Application.persistentDataPath + filename;
Заметки.
А теперь список небольших заметок и решений, которые могут понадобятся при портировании или разработке приложения на VR-платформы. Я хотел использовать только стандартные плагины для VR, которые уже включены в редактор, но как оказалось - это не возможно. Чтобы соответствовать требованиям платформы вам прийдется интегрировать плагины от платформы, чтобы реализовать некоторые плюшки.
* Oculus. Приложение должно поддерживать функцию ReCenter:
private void Update() { if (OVRPlugin.shouldRecenter) { ReCenter(); } } private void ReCenter() { UnityEngine.XR.InputTracking.Recenter(); }
* GearVR. Приложение должно предлагать свое меню выхода из приложения:
//OVRManager.PlatformUIConfirmQuit(); //or OVRPlugin.ShowUI(OVRPlugin.PlatformUI.ConfirmQuit);
* Если вам нужно отключить позиционирование шлема в пространстве можете использовать данный скрипт. Внимание! Он противоречит требованиям платформы.
using UnityEngine; public class VRMoveHack : MonoBehaviour { public Transform vrroot; private Transform mTransform; private Vector3 initialPosition; private void Awake() { mTransform = GetComponent<Transform>(); initialPosition = mTransform.position; //Disabling position breaks the head model on Gear VR, and is against the Rift store guidelines. //I would recommend instead of disabling positional tracking, to give a bubble on rift of ~1m that if the player leaves the screen fades to black. //If you have to disable positional tracking, at least add back in the head model, which consists of a position shift of 0.075f * camera up + 0.0805f * camera forward. initialPosition += (mTransform.up * 0.075f) + (mTransform.forward * 0.0805f); } private void LateUpdate() { Vector3 vrpos; vrpos = mTransform.TransformPoint(vrroot.localPosition); vrpos = mTransform.InverseTransformPoint(vrpos); mTransform.position = initialPosition - vrpos; } }
* Для платформы Daydream при нажатии на иконку назад должен происходить выход из приложения, вот скрипт, который может помочь с кнопкой «назад» на всех платформах:
using UnityEngine; public class EscapeScene : MonoBehaviour { public static System.Action OnEscape; private void Update() { #if MYGOOGLEVR if (Input.GetKeyDown(KeyCode.Escape)) Application.Quit(); if (GvrControllerInput.AppButtonDown) #else if (Input.GetKeyDown(KeyCode.Escape) || Input.GetButtonDown("Cancel") || Input.GetKeyDown(KeyCode.JoystickButton2)) #endif { if (OnEscape != null) OnEscape.Invoke(); } } }
* GearVR. Ваше приложение не будет проверятся на последних версиях ОС и может быть отвергнуто. Производите сборку c Mininum API Level: Android 5.0 (API level 21), иначе пишите в комментариях к ревью, что тестировать приложение нужно на устройствах с последней версией ОС.
* GearVR/Rift. Чтобы не получить малопонятный отказ по причине VRC.Mobile.Security загружайте сборку в канал ALPHA и RC, а так же STORE перед релизом.
* iOS. Не забудьте прикрепить документ/договор, касательно использования стороннего контента, если вы используете таковой. Мне повезло попасть на «опытного» ревьювера, которому я описывал текстом и скриншотами как пользоваться cardboard системой, куда заходить чтобы протестировать in-apps и работу приложения.
* Viveport. Для платформы желательно интегрировать SDK, чтобы подключить DRM. Можно использовать автоматическую Wrapper-based DRM, но только если вы не используете implemented with .Net framework. Я сделал если приложение не проходит проверку - оно закрывается.
* Steam. Магазин не содержит раздела для развлекательных приложений, поэтому видео-контент лучше публиковать отдельно в разделе Video, или в разделе Software. Наверное поэтому приложение Maski расположено в разделе Software / Video Production.
* Feature. Если вы сделаете запрос площадке на “фичеринг”. Ваше приложение будет детальнее рассматриваться. Могут попросить убрать помощь (Google), добавить дополнительные иконки в кнопки пользовательского интерфейса (Google), попросят больше сглаживания (Oculus), попросят сделать некоторые элементы окружения динамическими, например, волны на воде (Oculus).
* Oculus: Review my project/code. У Oculus (https://developer.oculus.com/distribute/latest/concepts/publish-feedback/) существует возможность запроса на рассмотрение вашего проекта инженерами. Вы предоставляете исходные коды, а через пару дней получаете проект с изменениями, советами по оптимизации и улучшениями. Некоторые из них могут быть общими, а некоторые будут специфичными, которые могут быть реализованы только на Oculus платформе.
* При необходимости нужно проверять есть ли доступ к интернету и выводить сообщение пользователю. Необходимо выводить любую информацию об ошибке или при длительной загрузке сцены или контента (”Loading”, ”Buffering”, ”Check your internet connection and try again.”, и т. д.). Иначе, приложение будут считать неисправным.
if (Application.internetReachability == NetworkReachability.NotReachable)
* При снятии VR-шлема приложение должно ставится на паузу. Чтобы отловить это событие, можно использовать стандартый метод:
private void OnApplicationPause(bool pause) { //do pause }
Комментарии
Отправить комментарий