К основному контенту

Заметки по соответствию требованиям платформ VR


В этой статье я бы хотел поделится с вами небольшими решениями которые, я применял в разработке развлекательного приложения для нескольких 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 видео, оно «проецируется» на экран, от которого предполагается свечение.

Для этого пришлось писать специальный шейдер. Материал имеет стандартную Diffuse текстуру, одну текстуру освещения от «солнца», другую текстуру освещения от «экрана», параметр смешивания освещения и средний цвет от экрана (уменьшенную текстуру видео-кадра). При начале проигрывания происходит плавное смешивание от дневного освещения к экранному, а далее каждый видео-кадр сжимается до одного цвета, которым закрашивается освещение.


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

Текстуру из кадра быстрее всего получать и масштабировать с помощью RenderTexture и Graphics.Blit:
   
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;
}

Для antialiasing на шрифте в UI можно использовать шейдер:
   
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
}

Комментарии