デコシノニッキ

ホロレンジャーの戦いの記録

HoloLensでの動的なViewportScaleの調整

AdaptiveQualityとAdaptiveViewportについて以前登壇で扱ったのですが、詳しく解説していなかったので触れてみます。

www.slideshare.net

AdaptiveQualityExampleScene

github.com

AdaptiveQualityExampleSceneのサンプルを実行します。 HoloToolkit-Examples/AdaptiveQualityの中にAdaptiveQualityExampleSceneが入っています。

f:id:haikage1755:20180308194801g:plain:w350

高負荷では、QualityLevelと共にViewportScaleが低下していきます。ViewportScaleはEditor上では値が取得できないので0.00と表示されていますが実機では値が変化し、文字がぼやけていきます。

GpuTimingCamera

カメラのレンダリングコストを計算します。コストの部分はGPUTimingで計算できます。

   /// <summary>
    /// カメラのレンダリングに費やされたGPU時間を追跡します。
   /// ステレオレンダリングの場合、サンプリングは左目の始めから右目の終わりまで行われます。
    /// </summary>
    public class GpuTimingCamera : MonoBehaviour
    {
        public string TimingTag = "Frame";

        private Camera timingCamera;

        private void Start()
        {
            timingCamera = GetComponent<Camera>();
            Debug.Assert(timingCamera, "GpuTimingComponent must be attached to a Camera.");
        }

        protected void OnPreRender()
        {
            if (timingCamera.stereoActiveEye == Camera.MonoOrStereoscopicEye.Left || timingCamera.stereoActiveEye == Camera.MonoOrStereoscopicEye.Mono)
            {
                GpuTiming.BeginSample(TimingTag);
            }
        }

        protected void OnPostRender()
        {
            if (timingCamera.stereoActiveEye == Camera.MonoOrStereoscopicEye.Right || timingCamera.stereoActiveEye == Camera.MonoOrStereoscopicEye.Mono)
            {
                GpuTiming.EndSample();
            }
        }
    }

AdaptiveQuality

AdaptiveQualityには設定値に、GpuTimingCameraにレンダリングコストを計算するカメラを指定したり、Qualityの変化する段階、閾値を指定します。
f:id:haikage1755:20180308201014p:plain:h150

AdaptiveQualityは、算出したGPU時間を元にアプリのQualityレベルの上げ下げを動的に行い、変更イベントを通知する役割を担っており、QualityLevelに応じた処理の切り分けを補助します。次に挙げるAdaptiveViewportがその例になります。

毎Updateで閾値と比較

        private void UpdateAdaptiveQuality()
        {
            //計測時間を取得
            float lastAppFrameTime = (float)GpuTiming.GetTime("Frame");

            if (lastAppFrameTime <= 0)
            {
                return;
            }

            //取得した値をキューに格納
            lastFrames.Enqueue(lastAppFrameTime);
            if (lastFrames.Count > maxLastFrames)
            {
                lastFrames.Dequeue();
            }

            frameCountSinceLastLevelUpdate++;
            if (frameCountSinceLastLevelUpdate < minFrameCountBeforeQualityChange)
            {
                return;
            }

            // 最後のフレームが予算(Budget)を上回っている場合、Qualityレベルを2つ下げます。
            if (lastAppFrameTime > MaxFrameTimeThreshold * frameTimeQuota)
            {
                UpdateQualityLevel(-2);
            }
            else if (lastAppFrameTime < MinFrameTimeThreshold * frameTimeQuota)
            {
                // 最後の5フレームがGPU使用量の閾値を下回っている場合は、Qualityレベルを1つ上げます。
                if (LastFramesBelowThreshold(maxLastFrames))
                {
                    UpdateQualityLevel(1);
                }
            }
        }

変更の通知

        private void UpdateQualityLevel(int delta)
        {
            // QualityLevelの更新
            int prevQualityLevel = QualityLevel;
            QualityLevel = Mathf.Clamp(QualityLevel + delta, MinQualityLevel, MaxQualityLevel);

            // QualityLevel変更通知の発火
            if (QualityLevel != prevQualityLevel)
            {
                if (QualityChanged != null)
                {
                    QualityChanged(QualityLevel, prevQualityLevel);
                }
                frameCountSinceLastLevelUpdate = 0;
            }
        }

AdaptiveViewport

AdaptiveViewportでは、AdaptiveQualityから通知されるQualityの変更に応じてViewportScaleを動的に変更させることで、FPSの安定化を目指します。

f:id:haikage1755:20180308205623p:plain:h150

        //Qualityレベルに応じたViewportScaleの計算
        private void SetScaleFromQuality(int quality)
        {
            //QualityをMax, Minの間でClampする
            int clampedQuality = Mathf.Clamp(quality, MinSizeQualityLevel, FullSizeQualityLevel);

            //Qualityを最大値と最小値の間で補間する
            float lerpVal = Mathf.InverseLerp(MinSizeQualityLevel, FullSizeQualityLevel, clampedQuality);
            //ViewportScaleのMax=1と最小値の間で補間する
            CurrentScale = Mathf.Lerp(MinViewportSize, 1.0f, lerpVal);
        }

CurrentScaleをもとに描画を変更。UnityEngine.XR.XRSettings.renderViewportScaleはEditor上でGetしても0.00しか返ってきません(なんで)

        protected void OnPreCull()
        {
#if UNITY_2017_2_OR_NEWER
            UnityEngine.XR.XRSettings.renderViewportScale = CurrentScale;
#else
            UnityEngine.VR.VRSettings.renderViewportScale = CurrentScale;
#endif
        }

使い方まとめ

ExampleにしかSceneがないため、使い方が分かりづらいと思います。

  1. MainCameraに「AdaptiveViewport.cs」をアタッチ
  2. 「AdaptiveQuality.cs」を適当なオブジェクト(空でもよい)にアタッチ
  3. AdaptiveQualityのAdaptiveCameraにMainCameraを指定
  4. AdaptiveViewportにAdaptiveQualityを指定

AdaptiveViewportの問題点

FPSを安定化するという目的はVR/ARアプリケーションにとっては非常に重要なところです。しかしながら、どうしても見栄えを優先したい場合もあります。 例えば何かしら重い処理が走ったときに、意図せず画面全体がぼやけるという現象がAdaptiveViewportによっては引き起こされます。(そんな処理組むのが悪いんだけど…)

そこで、2017年12月27日(水)〜2018年1月4日(木)、池袋西武本店7階催事場で開催された「歌舞伎の世界展」の一角で本技術を体験するというコンテンツが参考になりました。
(すごかったんだけど、宣伝がひっそりすぎて全然気づかなかった…) 超歌舞伎に登場する初音ミクさんの美麗な舞を高品質なままMicrosoft HoloLensで鑑賞する技術 - dwango on GitHub

これは最終的にこうしました

  • HoloLensではCGの画面占有率が上がるとFillRateが厳しい。ミクさんに近づくと動的にXR.renderViewportScaleを下げることでフレームレートを維持する

ミクさんに近づくにつれて動的にレンダリング解像度が下がるようにしました。これは段階的に(離散的に)変わるのではなく、徐々に変化します。最も近づいたときにはrenderViewportScale=0.4くらい、つまり768x432くらいのレンダリング解像度に下がります。が、これミクさんがアップになるにつれ解像度が下がることになるため意識して見ていてもほとんどわからないです。

HoloLensではそもそも画面が塗りつぶされるほど、何かに近づいたりするとパフォーマンスが落ちるようです。そこでMIROさんのとったアプローチは距離をもとにRenderViewScaleを調整するという方法です。

Distance Based Viewport

f:id:haikage1755:20180308212921p:plain:w250

という訳でAdaptiveViewportベースにそれっぽいのを作ってみました。

github.com

f:id:haikage1755:20180308211825p:plain:h150

  • TargetOverride: Objectを指定することで、RayやColliderを使わず距離判定ができます
  • Max Distance: Rayの最長距離です
  • Min Distance: Rayの最短距離です
  • Min Viewport Size: Viewportの下限値です
using UnityEngine;

namespace HoloToolkit.CustomUtility {
    /// <summary>
    /// 対象との距離ベースのViewScale調整スクリプト
    /// </summary>
    public class DistanceBasedViewport : MonoBehaviour {

        [SerializeField]
        [Tooltip("TargetOverrideを指定すると、Rayによる距離計算は行いません。")]
        private GameObject targetOverride;
        
        [SerializeField]
        [Tooltip("Rayの最長飛距離です")]
        private float maxDistance = 10f;

        [SerializeField]
        [Tooltip("Rayの最短飛距離です")]
        private float minDistance = 0.1f;

        [SerializeField]
        [Tooltip("ViewportScaleの最小値です")]
        private float minViewportSize = 0.5f;

        [SerializeField]
        public float CurrentScale { get; private set; }

        private void OnEnable() {
            CurrentScale = 1.0f;
        }

        private void OnDisable() {
#if UNITY_2017_2_OR_NEWER
            UnityEngine.XR.XRSettings.renderViewportScale = 1.0f;
#else
            UnityEngine.VR.VRSettings.renderViewportScale = 1.0f;
#endif
        }

        private Camera cam;

        // Use this for initialization
        void Start() {
            cam = Camera.main;
        }

        protected void OnPreCull() {

            if(targetOverride != null) {
                ConfigureTransfomOverrideViewScale();
            }
            else {
                ConfigureRayViewScare();
            }

            Debug.LogFormat("ViewScale {0}", CurrentScale);

#if UNITY_2017_2_OR_NEWER
            UnityEngine.XR.XRSettings.renderViewportScale = CurrentScale;
#else
            UnityEngine.VR.VRSettings.renderViewportScale = CurrentScale;
#endif
        }

        /// <summary>
        /// Targetを指定している際のViewportScaleの計算です
        /// </summary>
        private void ConfigureTransfomOverrideViewScale() {
            var viewPos = cam.WorldToViewportPoint(targetOverride.transform.position);
            if( 0 < viewPos.x && viewPos.x < 1 &&
                0 < viewPos.y && viewPos.y < 1 ) {
                var distance = Vector3.Distance(cam.transform.position, transform.position);
            }
            else {
                CurrentScale = 1.0f;
            }
        }

        /// <summary>
        /// RayによるViewScaleの計算です
        /// </summary>
        private void ConfigureRayViewScare() {
            var ray = new Ray(cam.transform.position, cam.transform.forward);
            RaycastHit hit;

            if (Physics.Raycast(ray, out hit, maxDistance)) {
                var distance = Vector3.Distance(cam.transform.position, hit.point);
                var clampDistance = Mathf.Clamp(distance, minDistance, maxDistance);
                var lerpVal = Mathf.InverseLerp(minDistance, maxDistance, clampDistance);
                CurrentScale = Mathf.Lerp(minViewportSize, 1.0f, lerpVal);
            }
            else {
                CurrentScale = 1.0f;
            }
        }
    }

}

TargetOverrideを設定すると、Rayを使わずとも強制的に対象とのオブジェクトの距離判定をとることができます。
カメラ内に対象がいるかどうかをRenderer.IsVisibleで指定してもよかったのですが、どのRendererを指定するかが複雑な構造を持つオブジェクトだと指定しづらかったので、今回はTransformをViewportに変換して判定を作っています。

実際に動かしてみた

ユニティちゃんライセンス

この作品はユニティちゃんライセンス条項の元に提供されています

[デコシノニッキ]は、Amazon.co.jpを宣伝しリンクすることによってサイトが紹介料を獲得できる手段を提供することを目的に設定されたアフィリエイト宣伝プログラムである、Amazonアソシエイト・プログラムの参加者です。」