[Unity] Adaptive Performanceを試してみた

本記事は、[Craft Egg Advent Calendar 2021](https://qiita.com/advent-calendar/2021/craftegg)の12/23の記事です。
12/22の記事は @kai_yamamoto さんの「Unity で音声通話を簡単に導入してみる」でした。

# はじめに
モバイルゲーム開発の悩みのタネの一つとして、発熱によるパフォーマンスの低下があります。
UnityのAdaptive Performanceを使用すると、端末温度や電力状態の計測が行えるとの情報を得たので、何かに使えないかと思い調べてみる事にしました。

# Adaptive Performanceの主な特徴
- 端末温度の状態変化をコールバックで受け取れる
- ボトルネックがCPU/GPUのいずれなのかが取得出来る
- CPU/GPUレベルの変更が可能
- CPU/GPUブーストモードが使用可能
- グラフィックス品質の自動調整が可能
- **[現状Galaxy端末のみ対応（※2021/12現在）](https://developer.samsung.com/galaxy-gamedev/adaptive-performance.html#Supported-Devices)**

端末の制限がきついですね・・現段階ではアプリのパフォーマンス対策機能として組み込むのは難しそうです。
将来的にはiOSや様々な端末で使えると嬉しいですね。

Adaptive Performanceの基本的な用途としては、端末温度やCPU/GPUのボトルネック状態のイベントを受け取り、端末の負荷が下がる処理（LOD、解像度、フレームレートの変更など）を行う事になるかと思います。

# 検証環境
- Unity: 2020.3.19f1
- Adaptive Performance: 2.2.1
- Adaptive Performance Samsung Android: 2.2.1
- Galaxy S20 (SC-51A)

# 使い方
PackageManagerからAdaptive Performanceのサブシステムをインストールします。
（ここではAdaptive Performance Samsung Android）
Adaptive Performance本体はサブシステムと依存関係になっているため、サブシステムのインストール時に自動的にインストールされます。
また、サンプルを試してみたい方は、PackageManagerのメニューからImportするとプロジェクトへの追加が容易に行えます。
![Sample.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2335920/a50fdd04-63f9-4b8c-350d-513a6105dcfd.png)
  

Packageインストール後に、使用するProviderをProject Settingsからインストールします。
（ここではSamsung Android Provider）
地味な項目ですが、これを忘れてしまうと対応デバイスとして認識されなくなってしまいます。
![Provider.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2335920/030e0aff-fca4-011b-ab71-48d5d7cd4465.png)

# 基本構成
シンプルな実装例としては以下のようになります。
`UnityEngine.AdaptivePerformance.Holder.Instance.Active`がtrueであればデバイスがサポートされているので、コールバックを設定して、端末温度とボトルネックの状態に応じて負荷を下げる／元に戻すような処理を実装します。

```csharp
using UnityEngine.AdaptivePerformance;

public class AdaptivePerformanceController : MonoBehaviour
{
    IAdaptivePerformance ap = null;

    public void Initialize()
    {
        ap = Holder.Instance;

        // デバイスがAdaptivePerformanceをサポートしているか
        if (!ap.Active)
            return;

        ap.ThermalStatus.ThermalEvent += OnThermalEvent;
        ap.PerformanceStatus.PerformanceBottleneckChangeEvent += OnBottleneckChange;
    }

    // 発熱状態のイベント
    void OnThermalEvent(ThermalMetrics thermalMetrics)
    {
        switch (thermalMetrics.WarningLevel)
        {
            case WarningLevel.NoWarning:
                // 平常時
                break;

            case WarningLevel.ThrottlingImminent:
                // 発熱し始め
                break;

            case WarningLevel.Throttling:
                // 発熱状態
                break;
        }
    }

    // ボトルネック状態のイベント
    void OnBottleneckChange(PerformanceBottleneckChangeEventArgs ev)
    {
        switch (ev.PerformanceBottleneck)
        {
            case PerformanceBottleneck.GPU:
                // GPUバウンド
                break;

            case PerformanceBottleneck.CPU:
                // CPUバウンド
                break;

            case PerformanceBottleneck.TargetFrameRate:
                // フレームレートバウンド
                break;
        }
    }
}
```

# サンプルを読んでみる
Packageに含まれるサンプルには参考になるコードが多いので、一部抜粋しつつ読んでみたいと思います。

### Thermal Sample
![ThermalSample.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2335920/7e9f021a-a1ba-fc35-4967-9f552dd604b5.jpeg)
↑発熱している状態のキャプチャ

```csharp
void OnThermalEvent(ThermalMetrics ev)
{
    switch (ev.WarningLevel)
    {
        case WarningLevel.NoWarning:
            objectFactory.LimitCount = originalLimitCount;
            break;
        case WarningLevel.ThrottlingImminent:
            objectFactory.LimitCount = originalLimitCount / 4;
            break;
        case WarningLevel.Throttling:
            objectFactory.LimitCount = originalLimitCount / 100;
            break;
    }
}
```
描画コストがかかるオブジェクトを一定間隔毎に生成し、発熱量が増えてきたらオブジェクトを破棄して温度調節する構成になっています。
（オブジェクト数をLimitCount以下に抑制している）
手元の端末では、ThrottlingImminentに達した時点でオブジェクト数が減って温度が下がるため、Throttlingになる事はありませんでした。  

```csharp
temperatureTrendSlider.value = ap.ThermalStatus.ThermalMetrics.TemperatureTrend;
temperatureLevelSlider.value = ap.ThermalStatus.ThermalMetrics.TemperatureLevel;
```

- TemperatureTrend
  - 現在の温度変化の指標で、-1.0〜1.0の間で変動
  - 0が平常
  - 1.0に近づくほど温度が上昇傾向にある
- TemperatureLevel
  - 現在の正規化された温度レベルで、0〜1.0の間で変動
  - 0が平常
  - 1.0の場合、デバイスがサーマルスロットリング状態

サンプルでは、温度変化の指標をリアルタイムでスライダーに反映しています。
TemperatureTrendで温度の上昇/下降傾向を確認、TemperatureLevelで実際の温度状態を確認出来ます。
ThermalEventに限らずこまめに値を監視したい場合は、これらの数値を指標に出来そうです。

余談ですが、サポート端末だとしても部屋の気温が低いとなかなか発熱しないので、冬場に試す場合は端末を布に包んだりして温めてあげる必要があります（実際、筆者の部屋では発熱の確認に時間がかかりました・・）  
### Bottleneck Sample
![BottleneckSample.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2335920/d36d36f0-ebad-c4ee-7195-a066feb60362.jpeg)
↑GPUバウンド状態のキャプチャ

```csharp
void OnBottleneckChange(PerformanceBottleneckChangeEventArgs ev)
{
    DisableAllBottlenecks();
    switch (ev.PerformanceBottleneck)
    {
        case PerformanceBottleneck.CPU:
            Activate(CPUBound);
            break;
        case PerformanceBottleneck.GPU:
            Activate(GPUBound);
            break;
        case PerformanceBottleneck.TargetFrameRate:
            Activate(TargetFrameRateBound);
            break;
        case PerformanceBottleneck.Unknown:
            Activate(UnknownBound);
            break;
    }
}
```
ボトルネックの変化をUIに表示しています。  
CPU/GPUの負荷に余裕がある場合はPerformanceBottleneck.TargetFrameRateに移行するので、CPU/GPUバウンドが発生したらそれぞれの負荷を下げる処理を行い、PerformanceBottleneck.TargetFrameRateに移行したら低負荷にした処理を徐々に戻すといった使い方になると思います。  

サンプルでは、TargetFrameRate状態からCPU/GPUに負荷をかける処理を行い、CPU/GPU/フレームレートバウンドの状態が変化する事を確認出来るようになっています。

```csharp
avgCPUfloat = ap.PerformanceStatus.FrameTiming.AverageCpuFrameTime;
avgGPUfloat = ap.PerformanceStatus.FrameTiming.AverageGpuFrameTime;
avgFramefloat = ap.PerformanceStatus.FrameTiming.AverageFrameTime;

avgCPUTime.text = $"{(avgCPUfloat * 1000):F2} ms";
avgGPUTime.text = $"{(avgGPUfloat * 1000):F2} ms";
avgFrameTime.text = $"{(avgFramefloat * 1000):F2} ms";
```
CPU/GPU時間の取得はどのようにしているのか気になりましたが、`Holder.Instance.PerformanceStatus.FrameTiming`からそれぞれ取得出来るようになっていました。  
エディタに繋いでProfilerを確認すれば可視化する事は出来ますが、Adaptive Performanceは実機上で値が取得出来るので、動的にアプリの挙動を変えられるのが強みかと思います。  

# Automatic Performance Control Sample
このサンプルでは、高負荷（High Level）と中程度（Mid Level）の描画モードが時間で切り替わり、AutomaticPerformanceControlによるCPU/GPUレベルの動的変化が確認出来るようになっています。
Throttlingが発生した場合にtargetFrameRateを45に設定、Adaptive Performanceのレベル調整による端末の冷却を待ちます。
![AutomaticPerformanceControlSample.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2335920/7f948339-1e67-1f85-3eae-56045191ddad.jpeg)
上記はThrottling発生時のキャプチャですが、targetFrameRateが45かつTemperatureのTrendが下降傾向にある事が確認出来ます。
currentのCPU/GPULevel値が-1になっていますが、これは[UnknownPerformanceLevel](https://docs.unity3d.com/Packages/com.unity.adaptiveperformance@2.2/api/UnityEngine.AdaptivePerformance.Constants.html#UnityEngine_AdaptivePerformance_Constants_UnknownPerformanceLevel)で、サポート端末でもThrottling発生時にこの値になるようです。  

```csharp
ap.DevicePerformanceControl.AutomaticPerformanceControl = AutoControlMode;  // default: true
```
Adaptive Performanceでは、ボトルネックに応じてCPU/GPUレベルの調整を自動的に行う設定がデフォルトになっています。
AutomaticPerformanceControlをfalseに設定する事で、マニュアルモードに切り替える事も可能です。
その場合は、`DevicePerformanceControl.CpuLevel`, `GpuLevel` の値を端末状態に応じて自分で設定する事になるかと思います。


# IndexerとScaler
Indexerは発熱状態とパフォーマンス状態の指標となるステータスを提供してくれる機能、
ScalerはFramerateやResolution等の品質を設定値に応じて自動調整する機能です。
![IndexerAndScaler.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2335920/d4185939-fda3-ee09-c6d7-b2f679cbf56d.png)
設定はProjectSettingsから行います。
Scalerを使用する場合は、IndexerのActiveをtrueにしておく必要があります。
Scalerで有効にした品質パラメータのみAdaptivePerformanceによる自動調整が入ります。

標準で用意されているScaler以外にも、自前でScalerを作成する事も出来ます。

```csharp
using UnityEngine;
using UnityEngine.AdaptivePerformance;

public class CustomScaler : AdaptivePerformanceScaler
{
    public override ScalerVisualImpact VisualImpact => ScalerVisualImpact.High;
    public override ScalerTarget Target => ScalerTarget.GPU;
    public override int MaxLevel => 4;
    public override float MinBound => 0;
    public override float MaxBound => 4;

    // --- 追加 ---
    // Scaler名
    public override string Name => "CustomScaler";

    // Scalerを有効にする
    public override bool Enabled => true;
    // ------------

    protected override void OnDisabled()
    {
        QualitySettings.masterTextureLimit = m_DefaultTextureLimit;
    }

    protected override void OnEnabled()
    {
        m_DefaultTextureLimit = QualitySettings.masterTextureLimit;
    }

    protected override void OnLevel()
    {
        float oldScaleFactor = Scale;
        float scaleIncrement = (MaxBound - MinBound) / MaxLevel;

        Scale = scaleIncrement * (MaxLevel - CurrentLevel) + MinBound;

        if (Scale != oldScaleFactor)
            QualitySettings.masterTextureLimit = (int)MaxBound-((int)(MaxBound*Scale));
    }
}
```
これはドキュメントのサンプルコードに少し手を加えたコードになります。  
作成したScalerはProject Settings上に表示されないのと、Enabledでtrueを返すようにしないとScalerが有効にならないため注意が必要です（※v2.2.1調査時）。  

ゲーム独自のScalerを定義してカスタマイズする事で、細かいニーズに沿った動的パフォーマンス制御が行えそうです。

# おわりに
Adaptive Performanceは、端末の状態を計測・制御するツールとして良さそうなAPIを備えていますが、やはり現状だと対応端末が少なすぎるのがネックですね・・・
アプリへの導入が検討されるのはまだ先になりそうです。  
また、今回試してはいないのですが、CPU/GPUの時間計測だけであれば[FrameTimingManager](https://docs.unity3d.com/ja/2020.3/ScriptReference/FrameTimingManager.html)が使えそうなので（これも端末の制限がありますが・・）、そちらを検討するのも良いかもしれません。  

明日は@ce_tsuneさんの記事です！