Search Unity

Frame Timing Managerによるパフォーマンスボトルネックの監視

  • ゲーム開発
  • 開発者向け

<このページで学べる内容>

Frame Timing Manager(フレームタイミングマネージャ)を用いて実行にパフォーマンス上のボトルネックを監視する具体的なプログラムをご紹介します。

対象: Unity上級者

まず、本記事を読む前に以下のUnity公式ブログをご覧ください。

こちらのブログではフレームタイミングマネージャーについて詳細に紹介しています。フレームタイミングマネージャーとはフレーム全体の CPU 時間や GPU 時間の合計など、フレームレベルの時間計測を行うことができる機能です。このブログの概要はフレームタイミングマネージャーで出来ることは何か、計測内容は何か、そして実行中のプログラムにおいて利用するためのサンプルコードの掲載となっています。

もう一つ、日本語で簡単に紹介している動画がありますのでご覧ください。

これらの情報をまとめると以下の様になります。

  1. Frame Timing ManagerはUnity 2019から実装されていましたが、Unity 2022.1で大幅機能アップし、対応プラットフォームが増えました
  2. 使用できるレンダラーはBuilt-in、URP、HDRPのいずれでも利用可能です
  3. Frame Timing Managerを利用するにはProject Settings > Player > Other で「Flame Timing Stats」をチェックするのを忘れずに!
  4. URPやHDRPにおいてはデフォルトでFrame Timing Managerによる表示機能が組み込まれていますが、その機能を使うためにはBuild Settingsで「Development」にチェックする必要があります

今回のブログを作成するにあたって使用した環境を以下に示します。

  • Unity 2022.1.14f1
  • Windows 10(21H2)
  • Pixel 4 XL(Android 13)

Frame Timing Managerで取得できる値を確認

初めにFrame Timing Managerで取得できる値を表示してみます。Frame Timing Managerで取得できる値はドキュメントにすべて記載されています。

https://docs.unity3d.com/2022.1/Documentation/ScriptReference/FrameTiming.html

とは言え、性能測定に関する情報をいきなり全部理解するのは大変です。なので、実際に動かしながら少しずつ確認していきたいと思います。確認方法ですが、Unity 2022.1のURPやHDRPに内蔵されているFrame Timing Manageによる値の取得と表示機能を使って行いたいと思います。

では早速、Unity 2021.1.14f1で新規にプロジェクトを作成します。この時、余りシンプルなプロジェクトでは性能問題の確認ができないのでURPサンプルシーン付きを選択すると良いでしょう。実際にAndroid端末で試したときは初期シーンでも割と余裕でしたので、実行に表示するオブジェクトを増やしたり減らしたりできる様な機能を作りこんでおくと良いでしょう。

プロジェクトの準備が出来たらFrame Timing Manageを有効化します。Frame Timing Managerを使うためにProject Settings > Player > Other Settingsの「Frame Timing Statas」をチェックします。これを忘れると値が取得できませんので必ず設定をしてください。

グラフィカル ユーザー インターフェイス, テキスト, アプリケーション

自動的に生成された説明

次にメニューからWindow > Analysys > Rendering Debuggerを実行します。Rendering Debuggerウィンドウが表示されます。その中の「Display Stats」にFrame Timing Managerの値を表示されますが、このページはプレイモードの時だけ表示されます。なので早速プレイしてみましょう。

テキスト

自動的に生成された説明

表示している内容は大きく2つでFrame StatsとBottlenecksです。Bottlenecks はEditor上ではサポートされていませんので、アプリを実機にデプロイして確かめたいと思います。

次に対応プラットフォームを確認します。詳しくはドキュメントをご覧ください。

https://docs.unity3d.com/2022.1/Documentation/Manual/frame-timing-manager.html

以前は非常に限られたプラットフォームだけでしか利用できませんでしたが、新しいFrame Timing Managerは主要なプラットフォームで動作するようになりました。ただ、Metalや一部のプラットフォームでは値が取得できなかったり、他の値から計算をして求めていることもありますので、必ず制限事項の部分をご確認の上ご利用ください。

ここではURPで作成したプログラムをAndroid端末に転送して動作を確認する例を示します。

ビルドプラットフォームをAndroidに変更しましょう。このとき、「Development Build」のチェックを忘れないようにしてください。理由は、前述の通りURPやHDRPにはUnity Editorで表示したRendering Debuggerウィンドウの「Display Stats」の値を表示する機能が内蔵されているのですが、Development Buildの時だけ利用できるようになっているためです。勿論、確認が終わったらDevelopment Buildのチェックを外すのを忘れないようにしてください。

グラフィカル ユーザー インターフェイス

自動的に生成された説明

無事にアプリをAndroid端末にデプロイが完了し無事に動くことを確認してください。そうしたら、三本指で画面をダブルタップしてください。すると画面の左側に「Display Stats」と言うタイトルのダイアログが表示されます。これはUnity Editorで確認したものと同じ内容が表示されています。Android端末ではBottlenecksも有効になっていますので展開して内容を確認してください。なお写真の右側の部分はテスト用に作成した簡易UIですのでそちらは関係ありません。

これで取り敢えずは表示できましたが、これで何が分かるのでしょうか? Frame Timing Manageで取得できるの値と少し違うみたいですが、これは何の値を表示しているのでしょうか? そしてボトルネックとは何を示しているのでしょうか? これらについて解説します。

改めてFrame Timing Managerで取得できる値を確認しましょう。これはドキュメントおよびUnity公式ブログを見ると分かります。

https://docs.unity3d.com/2022.1/Documentation/ScriptReference/FrameTiming.html

https://blog.unity.com/ja/technology/detecting-performance-bottlenecks-with-unity-frame-timing-manager

ドキュメントには沢山の項目が掲載されていますが、主要項目は公式ブログの最後に書かれている「HUD」のサンプルコードを見ると分かると思います。

      var reportMsg = 
            $"\nCPU: {m_FrameTimings[0].cpuFrameTime :00.00}" +
            $"\nMain Thread: {m_FrameTimings[0].cpuMainThreadFrameTime:00.00}" +
            $"\nRender Thread: {m_FrameTimings[0].cpuRenderThreadFrameTime:00.00}" +
            $"\nGPU: {m_FrameTimings[0].gpuFrameTime:00.00}";

次にDisplay Statsに表示されている値は何でしょうか? ヒントはUnity公式ブログの「ボトルネック検出」にあります。

このコードはボトルネックの検出部分だけを抜き出しており、DetermineBottleneck(FrameTimeSample s)メソッドで何がボトルネックとなっているかを求めています。この関数の詳細については後で説明しますが、まずはDetermineBottleneck()の引数についてよく見てください。FrameTimeSample構造体となっています。Frame Timing Managerで取得できるクラスはFrameTiming構造体のため異なる構造体です。では、FrameTimeSample構造体はどこにあるかと言うと、先ほど上記で試したURPに組み込まれている表示機能の中にあります。この情報はフォーラムに書かれていますので詳細はフォーラムの方を確認してください。

https://forum.unity.com/threads/update-for-frame-timing-manager.1191877/

フォーラムで示されている情報から具体的なコードを探すと以下の場所にあります。

https://github.com/Unity-Technologies/Graphics/tree/master/Packages/com.unity.render-pipelines.core/Runtime/Debugging/FrameTiming

以下に、FrameTimeSample構造体の部分を抜粋します。

using System;
using System.Collections.Generic;

namespace UnityEngine.Rendering
{
    /// <summary>
    /// Represents timing data captured from a single frame.
    /// </summary>
    internal struct FrameTimeSample
    {
        internal float FramesPerSecond;
        internal float FullFrameTime;
        internal float MainThreadCPUFrameTime;
        internal float MainThreadCPUPresentWaitTime;
        internal float RenderThreadCPUFrameTime;
        internal float GPUFrameTime;


        internal FrameTimeSample(float initValue)
        {
            FramesPerSecond = initValue;
            FullFrameTime = initValue;
            MainThreadCPUFrameTime = initValue;
            MainThreadCPUPresentWaitTime = initValue;
            RenderThreadCPUFrameTime = initValue;
            GPUFrameTime = initValue;
        }
    };

やっとDisplay Statsに使われているパラメータが出てきました。

プログラムの中から利用する

FrameTimeSample構造体については分かりましたが、具体的な値を代入する処理がありません。FrameTimeSampleと同じファイルにはFrameTimeSampleHistoryクラスもありますが、これはFrameTimeSample構造体をいくつか格納して最大・最小・平均を求めるだけで、肝心のFrameTimeSampleとFrameTime構造体の関係は分かりませんでした。そこで一つ上のフォルダーに格納されているソースコードを色々と調べた結果、以下のようになっていることが分かりました。

        // FrameTimingデータの取得
        FrameTimingManager.CaptureFrameTimings();
        FrameTimingManager.GetLatestTimings(1, m_Timing);

        // FrameTimingデータから得られたデータをサンプリング
        if (m_Timing.Length > 0)
        {
            m_Sample.FullFrameTime = (float)m_Timing.First().cpuFrameTime;
            m_Sample.FramesPerSecond = m_Sample.FullFrameTime > 0f ? 1000f / m_Sample.FullFrameTime : 0f;
            m_Sample.MainThreadCPUFrameTime = (float)m_Timing.First().cpuMainThreadFrameTime;
            m_Sample.MainThreadCPUPresentWaitTime = (float)m_Timing.First().cpuMainThreadPresentWaitTime;
            m_Sample.RenderThreadCPUFrameTime = (float)m_Timing.First().cpuRenderThreadFrameTime;
            m_Sample.GPUFrameTime = (float)m_Timing.First().gpuFrameTime;
        }

分かってしまえばなんてことはなく、ほぼそのままの値を格納しているだけでした。

ということで、FrameTImeSampleの値が分かったので、Unity公式ブログで紹介されていた「ボトルネックの検出」のコードと組み合わせてみたいと思います。掲載されてコードはこんな感じでしたね。

    void Update()
    {
        FrameTimingManager.CaptureFrameTimings();
        var ret = FrameTimingManager.GetLatestTimings((uint)m_FrameTimings.Length, m_FrameTimings);
        if (ret > 0)
        {
            var bottleneck = DetermineBottleneck(m_FrameTimings[0]);
            // Your code logic here
        }
    }

「自分で書いてください」となっています。ということで、そこで、UnityのURPに内蔵されているDisplay Statsのコードを抜粋して、FrameTimeSampleの最大・最小・平均値を求めて、最終的にボトルネックを表示するようにしたプログラムを作りましたので以下に示します。

using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using TMPro;

// https://github.com/Unity-Technologies/Graphics/blob/master/Packages/com.unity.render-pipelines.core/Runtime/Debugging/DebugFrameTiming.cs
// https://github.com/Unity-Technologies/Graphics/blob/master/Packages/com.unity.render-pipelines.core/Runtime/Debugging/FrameTiming/FrameTimeBottleneck.cs
// https://github.com/Unity-Technologies/Graphics/blob/master/Packages/com.unity.render-pipelines.core/Runtime/Debugging/FrameTiming/FrameTimeSample.cs
// 上記3つのコードを引用して全体的に作り直し

public class BottleneckCheck : MonoBehaviour
{
    internal enum PerformanceBottleneck
    {
        Indeterminate,      // 決定できない
        PresentLimited,     // プレゼンテーションによる制限(vsyncまたはフレームレート上限)
        CPU,                // CPU(メインスレッド、レンダースレッド)による制限
        GPU,                // GPUによる制限
        Balanced,           // CPUとGPUの両方で制限される
    }

    const string k_FpsFormatString = "{0:F1}";
    const string k_MsFormatString = "{0:F2}ms";
    const float k_RefreshRate = 1f / 5f;

    FrameTimeSampleHistory m_FrameHistory;
    BottleneckHistory m_BottleneckHistory;

    /// <summary>
    /// ボトルネック履歴ウィンドウの大きさ(サンプル数)
    /// </summary>
    public int bottleneckHistorySize { get; set; } = 60;

    /// <summary>
    /// サンプル履歴ウィンドウのサイズ(サンプル数)
    /// </summary>
    public int sampleHistorySize { get; set; } = 30;

    FrameTiming[] m_Timing = new FrameTiming[1];
    FrameTimeSample m_Sample = new FrameTimeSample();

    // デバッグ表示用Unity UI
    public TextMeshProUGUI text;
    // 最終表示時刻
    float lastTime;

    private void Start()
    {
        m_FrameHistory = new FrameTimeSampleHistory(sampleHistorySize);
        m_BottleneckHistory = new BottleneckHistory(bottleneckHistorySize);

        lastTime = Time.time;
    }

    void Update()
    {
        m_Timing[0] = default;
        m_Sample = default;

        // FrameTimingデータの取得
        FrameTimingManager.CaptureFrameTimings();
        FrameTimingManager.GetLatestTimings(1, m_Timing);

        // FrameTimingデータから得られたデータをサンプリング
        if (m_Timing.Length > 0)
        {
            m_Sample.FullFrameTime = (float)m_Timing.First().cpuFrameTime;
            m_Sample.FramesPerSecond = m_Sample.FullFrameTime > 0f ? 1000f / m_Sample.FullFrameTime : 0f;
            m_Sample.MainThreadCPUFrameTime = (float)m_Timing.First().cpuMainThreadFrameTime;
            m_Sample.MainThreadCPUPresentWaitTime = (float)m_Timing.First().cpuMainThreadPresentWaitTime;
            m_Sample.RenderThreadCPUFrameTime = (float)m_Timing.First().cpuRenderThreadFrameTime;
            m_Sample.GPUFrameTime = (float)m_Timing.First().gpuFrameTime;
        }

        // サンプリングデータを保存
        m_FrameHistory.DiscardOldSamples(sampleHistorySize);
        m_FrameHistory.Add(m_Sample);
        m_FrameHistory.ComputeAggregateValues();

        // ボトルネックデータを保存
        m_BottleneckHistory.DiscardOldSamples(bottleneckHistorySize);
        m_BottleneckHistory.AddBottleneckFromAveragedSample(m_FrameHistory.SampleAverage);
        m_BottleneckHistory.ComputeHistogram();

        // k_RefreshRateの頻度でディスプレイを更新
        if (Time.time > lastTime + k_RefreshRate)
        {
            var displayMessage = "CPU:" + m_BottleneckHistory.Histogram.CPU + "\n";
            displayMessage += "GPU:" + m_BottleneckHistory.Histogram.GPU + "\n";
            displayMessage += "Present limited:" + m_BottleneckHistory.Histogram.PresentLimited + "\n";
            displayMessage += "Balanced:" + m_BottleneckHistory.Histogram.Balanced + "\n";
            text.text = displayMessage;
            lastTime = Time.time;
        }
    }

    #region ここから追加
    internal struct FrameTimeSample
    {
        internal float FramesPerSecond;
        internal float FullFrameTime;
        internal float MainThreadCPUFrameTime;
        internal float MainThreadCPUPresentWaitTime;
        internal float RenderThreadCPUFrameTime;
        internal float GPUFrameTime;

        internal FrameTimeSample(float initValue)
        {
            FramesPerSecond = initValue;
            FullFrameTime = initValue;
            MainThreadCPUFrameTime = initValue;
            MainThreadCPUPresentWaitTime = initValue;
            RenderThreadCPUFrameTime = initValue;
            GPUFrameTime = initValue;
        }
    };

    /// <summary>
    /// Container class for sample history with helpers to calculate min, max and average in one pass.
    /// </summary>
    class FrameTimeSampleHistory
    {
        public FrameTimeSampleHistory(int initialCapacity)
        {
            m_Samples.Capacity = initialCapacity;
        }

        List<FrameTimeSample> m_Samples = new();

        public FrameTimeSample SampleAverage;
        FrameTimeSample SampleMin;
        FrameTimeSample SampleMax;

        internal void Add(FrameTimeSample sample)
        {
            m_Samples.Add(sample);
        }

        // Helper functions

        static Func<float, float, float> s_SampleValueAdd = (float value, float other) =>
        {
            return value + other;
        };

        static Func<float, float, float> s_SampleValueMin = (float value, float other) =>
        {
            return other > 0 ? Mathf.Min(value, other) : value;
        };

        static Func<float, float, float> s_SampleValueMax = (float value, float other) =>
        {
            return Mathf.Max(value, other);
        };

        static Func<float, float, float> s_SampleValueCountValid = (float value, float other) =>
        {
            return other > 0 ? value + 1 : value;
        };

        static Func<float, float, float> s_SampleValueEnsureValid = (float value, float other) =>
        {
            return other > 0 ? value : 0;
        };

        static Func<float, float, float> s_SampleValueDivide = (float value, float other) =>
        {
            return other > 0 ? value / other : 0;
        };

        internal void ComputeAggregateValues()
        {
            void ForEachSampleMember(ref FrameTimeSample aggregate, FrameTimeSample sample, Func<float, float, float> func)
            {
                aggregate.FramesPerSecond = func(aggregate.FramesPerSecond, sample.FramesPerSecond);
                aggregate.FullFrameTime = func(aggregate.FullFrameTime, sample.FullFrameTime);
                aggregate.MainThreadCPUFrameTime = func(aggregate.MainThreadCPUFrameTime, sample.MainThreadCPUFrameTime);
                aggregate.MainThreadCPUPresentWaitTime = func(aggregate.MainThreadCPUPresentWaitTime, sample.MainThreadCPUPresentWaitTime);
                aggregate.RenderThreadCPUFrameTime = func(aggregate.RenderThreadCPUFrameTime, sample.RenderThreadCPUFrameTime);
                aggregate.GPUFrameTime = func(aggregate.GPUFrameTime, sample.GPUFrameTime);
            };

            FrameTimeSample average = new();
            FrameTimeSample min = new(float.MaxValue);
            FrameTimeSample max = new(float.MinValue);
            FrameTimeSample numValidSamples = new(); // Using the struct to record how many valid samples each field has

            for (int i = 0; i < m_Samples.Count; i++)
            {
                var s = m_Samples[i];

                ForEachSampleMember(ref min, s, s_SampleValueMin);
                ForEachSampleMember(ref max, s, s_SampleValueMax);
                ForEachSampleMember(ref average, s, s_SampleValueAdd);
                ForEachSampleMember(ref numValidSamples, s, s_SampleValueCountValid);
            }

            ForEachSampleMember(ref min, numValidSamples, s_SampleValueEnsureValid);
            ForEachSampleMember(ref max, numValidSamples, s_SampleValueEnsureValid);
            ForEachSampleMember(ref average, numValidSamples, s_SampleValueDivide);

            SampleAverage = average;
            SampleMin = min;
            SampleMax = max;
        }

        public void DiscardOldSamples(int sampleHistorySize)
        {
            Debug.Assert(sampleHistorySize > 0, "Invalid sampleHistorySize");

            while (m_Samples.Count >= sampleHistorySize)
                m_Samples.RemoveAt(0);

            m_Samples.Capacity = sampleHistorySize;
        }

        void Clear()
        {
            m_Samples.Clear();
        }
    }


    /// <summary>
    /// ヒストグラムを計算するヘルパーを持つボトルネック履歴のためのコンテナクラス
    /// </summary>
    class BottleneckHistory
    {
        public struct BottleneckHistogram
        {
            internal float PresentLimited;
            internal float CPU;
            internal float GPU;
            internal float Balanced;
        };
        public BottleneckHistory(int initialCapacity)
        {
            m_Bottlenecks.Capacity = initialCapacity;
        }

        List<PerformanceBottleneck> m_Bottlenecks = new();

        public BottleneckHistogram Histogram;

        public void DiscardOldSamples(int historySize)
        {
            Debug.Assert(historySize > 0, "Invalid sampleHistorySize");

            while (m_Bottlenecks.Count >= historySize)
                m_Bottlenecks.RemoveAt(0);

            m_Bottlenecks.Capacity = historySize;
        }

        public void AddBottleneckFromAveragedSample(FrameTimeSample frameHistorySampleAverage)
        {
            var bottleneck = DetermineBottleneck(frameHistorySampleAverage);
            m_Bottlenecks.Add(bottleneck);
        }

        public void ComputeHistogram()
        {
            var stats = new BottleneckHistogram();
            for (int i = 0; i < m_Bottlenecks.Count; i++)
            {
                switch (m_Bottlenecks[i])
                {
                    case PerformanceBottleneck.Balanced:
                        stats.Balanced++;
                        break;
                    case PerformanceBottleneck.CPU:
                        stats.CPU++;
                        break;
                    case PerformanceBottleneck.GPU:
                        stats.GPU++;
                        break;
                    case PerformanceBottleneck.PresentLimited:
                        stats.PresentLimited++;
                        break;
                }
            }

            stats.Balanced /= m_Bottlenecks.Count;
            stats.CPU /= m_Bottlenecks.Count;
            stats.GPU /= m_Bottlenecks.Count;
            stats.PresentLimited /= m_Bottlenecks.Count;

            Histogram = stats;
        }

        static PerformanceBottleneck DetermineBottleneck(FrameTimeSample s)
        {
            const float kNearFullFrameTimeThresholdPercent = 0.2f;
            const float kNonZeroPresentWaitTimeMs = 0.5f;

            if (s.GPUFrameTime == 0 || s.MainThreadCPUFrameTime == 0) // In direct mode, render thread doesn't exist
                return PerformanceBottleneck.Indeterminate; // Missing data
            float fullFrameTimeWithMargin = (1f - kNearFullFrameTimeThresholdPercent) * s.FullFrameTime;

            // GPUの時間はフレームタイムに近い、CPUの時間はそうではない
            if (s.GPUFrameTime > fullFrameTimeWithMargin &&
                s.MainThreadCPUFrameTime < fullFrameTimeWithMargin &&
                s.RenderThreadCPUFrameTime < fullFrameTimeWithMargin)
                return PerformanceBottleneck.GPU;

            // CPUの1つがフレームタイムに近く、GPUはそうではない
            if (s.GPUFrameTime < fullFrameTimeWithMargin &&
                (s.MainThreadCPUFrameTime > fullFrameTimeWithMargin ||
                 s.RenderThreadCPUFrameTime > fullFrameTimeWithMargin))
                return PerformanceBottleneck.CPU;

            // Vsyncまたはターゲットフレームレートが原因でメインスレッドが待機した
            if (s.MainThreadCPUPresentWaitTime > kNonZeroPresentWaitTimeMs)
            {
                // None of the times are close to frame time
                if (s.GPUFrameTime < fullFrameTimeWithMargin &&
                    s.MainThreadCPUFrameTime < fullFrameTimeWithMargin &&
                    s.RenderThreadCPUFrameTime < fullFrameTimeWithMargin)
                    return PerformanceBottleneck.PresentLimited;
            }

            return PerformanceBottleneck.Balanced;
        }

        void Clear()
        {
            m_Bottlenecks.Clear();
            Histogram = new BottleneckHistogram();
        }
    }
    #endregion
}

非常に長いコードですが、最初の方に独自コードがあります。後半部分はURPの中にあったコードを1つにまとめたものになっています。なので、実際に利用する時は必要に応じてファイルを分けて再利用可能な形にすると良いでしょう。

このサンプルコードではボトルネックの検出結果をUnity UIのText(TextMeshPro)に出力していますので、そのまま試す際には忘れずに設定してください。改造して別の表示形式にするのも良いでしょう。

なおこのサンプルコードのようにFrame Timing Manage APIを使って値を取得する時はDevelopment Buildのチェックは不要です。写真では比較用に両方の値を表示していましたが、製品開発中に使うかどうかはその時の状況次第になりますし、上記のサンプルコードを改造して使うことで製品の1部品として作りこんでしまえばDevelopment Buildで使える機能は不要でしょう。

またFrame Timing ManageはUnity Engineの中で収集する情報量を慎重に制限し、特殊なタスクとして設計されているので、実行のパフォーマンスのオーバーヘッドが非常に低くなっています。そのため、ほぼ製品版に近い性能を出しつつフレーム描画に関する性能測定ができるので、製品開発中に簡単な操作でFrame Timing Manageの値を表示をするHUDを仕掛けておくだけで簡単に性能の分析が可能になるので上手く活用してください。もちろん、リリース時は外しておくのが良いと思います。

ボトルネックの表示結果とその対応について

フレームタイミングマネージャーから提供されるデータは、ボトルネックの検出に使用することができます。ボトルネックの表示部分には4つの値が表示されていますが、これはメインスレッドCPU、レンダースレッドCPU、Present Wait、GPUの時間を比較し、フレームレートを下げる最大かつ最も関与している可能性の高い原因がどれかを判断した結果です。以下に表示内容の一覧と簡単な説明を示します。

ボトルネック個所説明
PresentLimitedプレゼンテーションによる制限(vsyncまたはフレームレート上限)具体的には、ターゲットとなるフレームレートに対してCPUもGPUも十分に余裕がある状態。
CPUCPU(メインスレッド、レンダースレッド)による制限具体的には、現在のフレーム当たりの処理時間のうち、そのほとんどをCPU処理時間(メインスレッド、レンダースレッド)が占めている状態
GPUGPUによる制限具体的には、現在のフレーム当たりの処理時間のうち、そのほとんどをGPU処理時間が占めている状態
BalancedCPUとGPUの両方で制限具体的には、現在のフレーム当たりの処理時間のうち、CPUもGPUも十分な時間を使っている状態。バランスが良い状態ではありますが、フレーム時間のほとんどを使用しているため余裕はあまり無いとも言えます。

目指すべきはPresentLimitedで、次がBalancedです。PresentLimitedはフレーム時間に対してまだ十分に余裕がある状態です。Balancedはまだフレーム時間を使い切ってはいませんが、CPUもGPUも同じぐらい利用している状態となっています。なのでわずかな修正でCPUまたはGPU不足になるかも知れないと思ってください。

問題となるのは、ボトルネックがCPUまたはGPUとなったときです。これはターゲットとなるフレーム時間と現在のフレーム時間に乖離があるかどうかにもよります。目標とするフレーム時間を維持できている状態で、もうこれ以上機能や描画の追加は無いと言うのであればそのままでも良いかも知れません。ですが、まだ開発途中だったり目標とするフレーム時間を超過している場合は解決すべき問題となります。

CPUネックに関してですが、多くの場合はプログラムの作りに依存するので、Deep Profilerを使用して真のボトルネックを検出するのが良いでしょう。

GPUネックに関してですが、3Dモデルの頂点数の削減、シェーダーの最適化、テクスチャの圧縮、ポストプロセッシングの設定を見直すなどがありますが、モバイルの場合は端末の性能に対して非常に高解像度のディスプレイを搭載しているためそれがネックになることがあります。そこで動的に解像度を調整することで性能を確保するという方法があります。詳しくはドキュメントを参照してください。

https://docs.unity.cn/2022.1/Documentation/Manual/DynamicResolution.html

または機種は限定されますが、Adaptive Performanceを使うことで端末の温度と性能の管理を行うことができます。

https://blog.unity.com/ja/technology/higher-fidelity-and-smoother-frame-rates-with-adaptive-performance

以下にUnityにおけるパフォーマンスチューニングを行うヒントとなるリソースを紹介します。

この他にUnityのパフォーマンスチューニングに関する情報は多数ありますので、色々と調べてみてください。

おわりに

製品開発において目標とする性能の達成や維持をすることはとても重要で、性能問題が発生したときはその原因を探るために様々な測定を行うと思います。今回紹介したFrame Timing Manageは、アプリケーションの個々のフレーム中のパフォーマンスに関する詳細なタイミングデータを取得するAPIです。このデータを使用してこれらのフレームを評価し、アプリケーションがパフォーマンス目標を達成できない理由を理解することができます。これはこれで重要なデータですが、これだけで全てのデータを測定できるものでもありません。なので、他の性能測定ツールと合わせて上手く活用して欲しいと思います。

このブログでは今後も様々なUnityの機能について情報を発信していく予定ですのでご期待ください。

この記事はいかがでしたか?