Unity2017 Game Optimization 2017 Second edition読んだ& 最初の100ページくらいの粗訳

概要

Unity 2017 Game OptimizationというUnityの最適化にフォーカスした本が出ています。ちょうど年末年始休みだったこともあり、ざっと読んでいました。 結構読み応えのある本で、知らなかったことも多くて役に立ちました。

https://www.amazon.co.jp/exec/obidos/ASIN/B076T4TW9G/

  • 1章: プロファイル計測
  • 2章: MonoBehaviourの最適化
  • 3章: Bathingの最適化
  • 4章: Artの最適化
  • 5章: 物理演算の最適化
  • 6章: 動的なグラフィックス最適化
  • 7章: XR向け最適化
  • 8章: C# メモリマネジメント
  • 9章: 他のtips集

という量で350ページです。日本語版無いの?と聞かれることもあったので冒頭2章までくらいの粗訳を書いてみました。読んで面白そうだと思ったら是非皆様の本棚にも加えてみてはどうでしょうか。個人的なおすすめは7章のXR向けパフォーマンスチューニングと8章のメモリ最適化です。

前書きとか

対象読者

Unityの殆どの機能を使ったことがある上級Unity開発者向けです。C# についてある程度理解している事を前提としています、またシェーダー最適化について初歩のCg言語の知識を要求します。

Chapter1: Persuing Performance Problems

ソフトウェアにおけるパフォーマンス計測は極めて科学的なプロセスで行います。最初にメモリ使用量やCPU使用率などの値の最大/最小値を計測します。 次に対象プラットフォームでの計測用シナリオ(最小の構成サンプル)を作り、ボトルネックを計測します。問題の箇所が特定できたら、その原因を見つけ出し、コードを書き換えて再計測を行うことを繰り返していきます。

もちろんゲーム開発は芸術的な側面を多く備えていますが、こういった地道なパフォーマンスチューニングを行うことは大切です。

くれぐれもボトルネックが"実際に存在して"そのボトルネックを特定するまで、雰囲気で最適化などを行わないようにして下さい。

この章では

  • UnityProfilerによってどうやってパフォーマンスデータを集めるか
  • プロファイラのデータを解析する方法
  • 個別のボトルネック原因を切り分ける方法

について説明します。

Unity Profiler

UnityProfilerはUnityEditorに組み込みで入っているパフォーマンス計測用の機能です。多くのプラットフォームで実行中の負荷を(リモートで)見ることが出来るため、パフォーマンス上のボトルネックを絞り込むためにもよく使います、

  • CPU consumption (per-major subsystem)
  • Basic and detailed rendering and GPU information
  • Runtime memory allocations and overall consumption
  • Audio source/data usage
  • Physics Engine (2D and 3D) usage
  • Network messaging and operation usage
  • Video playback usage
  • Basic and detailed user interface performance (new in Unity 2017)
  • Global Illumination statistics (new in Unity 2017)

などの項目ごとの計測値を取得できます。

Unityから実行ファイルをDevelopmentBuildでビルドすると(Build Settingsで指定できます)UnityEditorからリモートで実行中のプロファイラが読み取れます。(実機計測) もちろんプロファイラに接続している間は余分にCPUとメモリに負荷が掛かるため正確なパフォーマンスは計測できませんが、それでもUnityEditor上で実行してプロファイラで計測するよりも正確な計測が出来ます。

1行ごとにコードを追いかけて観察するよりも、まずはざっとプロファイラを起動してどのあたりが重そうかの当たりを付けましょう。

tips:Editor上の方が速い処理 エディタ上の方が速い処理もあるので気をつけましょう。ビビりますよね。オーディオやプレハブのSerializedなんかは顕著に速い(実機だとエディタ上より遅い)ので、ちゃんと計測するときにはプロファイラはビルドした実行ファイルにリモートで接続しましょう。

Launching Profiler

各プラットフォームごとのプロファイラ接続方法 Standalone, WebGL,iOS,Androidを含みます。

  • iOSのプロファイルはMacOS上のUnityEditorでしか出来ない事

  • WebGLのプロファイルをするためには該当ブラウザを閉じてからビルド&ランする必要があること(既に開いたブラウザではプロファイラが繋がらない)

  • 54998-55511 のUnityEditorのUDPポートをOS上で開放しておくこと(じゃないとプロファイラがAndroidiOSから繋がらない)

あたりに注意して下さい。

Profiler Window

プロファイラの各windowの見方を説明しています。

DeepProfilingはInvoke(コルーチン)や単発の重い処理なども捕まえることが出来ます。ただし大きなプロジェクトだとメモリ使用量が一気に増えてクラッシュしてしまうかもしれません、その場合は後述するように特定箇所にだけ計測コードを入れることによって測定します。

オーディオは見落とされがちですが、ディスクアクセス、RAMへのアクセス、ランタイムでの計算処理(CPU)と広範な負荷が掛かるため、ボトルネックになりえます。計測を怠らないようにしてください。

Unity2017からUIタブが出来たので、(uGUIを使っているなら)確認すべきです。uGUIは容易に非効率なことが出来てしまうためです。

Best approaches to performance analysis

普段から統一したプロジェクト設定やアセットの使い方をしている場合は、計測したときのボトルネックが見つけやすくなり、それによって主な作業は改善だけになります。 とはいえ「簡潔で」「拡張性に優れて」「速い」コードでプロジェクト全体を構成するのは現実的にとても難しいです。どれか一個を満たすのはまだ何とかなりますが、2個を満たすのはコスト的に難しいと判断されがちで、3個とも満たすのは殆ど不可能と言って良いでしょう。

パフォーマンス計測をしたり、チューニングを行う際によくありがちなのが幽霊(存在しない原因)を追いかけることです。 以下のチェックリストを常に気にとめて下さい。

  • 対象のスクリプトがシーン上から呼ばれているか
  • 対象のスクリプトは意図した回数だけ呼ばれているか
  • イベント実行順は正しいか
  • コードの改変は最小限か(DebugLogも仕込みすぎると凄く重い)
  • 内部状態の差異を最小限に減らす
  • 外部状態の差異を最小限に減らす (他のアプリの起動状況が変わってないか、メモリスワップが起きていないか、バックグラウンドでダウンロードが走っていないか)

それぞれの具体例が書かれています。 とりあえずパフォーマンス計測やパフォーマンスチューニング中は VSyncを切っておく ことは注意して下さい。WaitForTargetFPSを追いかけるのは無駄です。

特定の処理部分だけをプロファイラで捕まえるために以下のようにBeginSampleとEndSampleを使うことを覚えて下さい。

void DoSomethingCompletelyStupid() {  
Profiler.BeginSample("My Profiler Sample");  
List<int> listOfInts = new List<int>();  
for(int i = 0; i < 1000000; ++i) {   
 listOfInts.Add(i);  
}  
Profiler.EndSample();
}

また、プロファイラに提供する為の処理などで処理が重くなることを避けるためにカスタムしたパフォーマンス影響が最小限のプロファイルを作っておきましょう。

tips メンバ変数はアンダースコア始まりにしておくとローカル変数と見分けやすくて良いです。

using System;
using System.Diagnostics;
public class CustomTimer : IDisposable {  
    private string _timerName;  
    private int _numTests;  
    private Stopwatch _watch;  
    
    // give the timer a name, and a count of the  
    // number of tests we're running  
    public CustomTimer(string timerName, int numTests) { 
        _timerName = timerName;

        _numTests = numTests;    
        if (_numTests <= 0) {      
            _numTests = 1;    
        }    
        _watch = Stopwatch.StartNew();  
    }    
    // automatically called when the 'using()' block ends    
    public void Dispose() {    
        _watch.Stop();    
        float ms = _watch.ElapsedMilliseconds;
        UnityEngine.Debug.Log(string.Format("{0} finished: {1:0.00} " +
        "milliseconds total, {2:0.000000} milliseconds per-test " +
        "for {3} tests", _timerName, ms, ms / _numTests, _numTests));    
        }
    }
}

こんな感じで使います。

const int numTests = 1000;
using (new CustomTimer("My Test", numTests)) {  
    for(int i = 0; i < numTests; ++i) {    
        TestFunction();  
    }
} // the timer's Dispose() method is automatically called here

Final thoughts on Profiling and Analysis

無駄な最適化を行うためのリソースをいかに減らすか はとても大切です。時間は有限なので無駄な努力はすべきではありません。

プロファイラーを理解せよ

もし本書を今まで読んでいて知らないことがあった場合は、1時間かけて新規プロジェクトを作成してUnityプロファイラの各機能を実際触って確認して下さい。結果的にプロファイラの使い方を覚えることは時間の節約になります。

大きなパフォーマンススパイクの後はプロファイラの縦軸が一気に伸びるのでちょっとした負荷の増減が見えなくなります。気をつけましょう。(プロファイラは直近300フレームだけ表示されています)

ノイズを減らせ

上の方でも説明したとおり、計測の度に数値が変わったりしないように同じシーン、同じタイミングでの計測を行うようにしましょう。 他の影響によってシーンが軽くなったり重くなったりしてしまうと、絞り込みに失敗します。

問題となる箇所に注力せよ

ノイズを減らし、同じシーン、同じタイミングで計測すれば、残った物がissueでしょうか、いえ、その中でも特に問題になっている場所を見つけるべきです。 テストコードを追加したせいで、そのテストコード自体が重い、というパターンもしばしばあります(Debug.Logが重い点に留意してください)

同時に複数箇所のボトルネックを潰そうとはせず、まず一カ所を直して計測して、問題が解決してから別のボトルネックに取りかかって下さい。

Chapter 2:Scripting Strategies

Unity特有のGameObjectやMonobehavuiourによって起きる問題について論じます。C# 言語自体やMonoについての最適化はChapter8を参照して下さい。

  • Accessing Components
  • Component callbacks (Update(), Awake(), and so on)
  • Coroutines
  • GameObject and Transform usage
  • Interobject communication
  • Mathematical calculations
  • Deserialization such as Scene and Prefab loading

などについて解説します。

Componentへのアクセス

GetComponentを毎フレーム行うのはとても遅いですが

(TestComponent)GetComponent("TestComponent")

が一番遅いです。 また、

GetComponent<ComponentName>()

が最速です。

Remove empty callback definitions

使っていないStart()やUpdate()などを全部削りましょう。

void\s*Update\s*?\(\s*?\)\s*?\n*?\{\n*?\s*?\}

void\s*Start\s*?\(\s*?\)\s*?\n*?\{\n*?\s*?\}

なんかをエディタ拡張から呼ぶ仕組みを作っておきましょう。

Cache Component references

毎フレームアクセスするのは止めて初回時にGetComponentでキャッシュするようにしましょう。

また、Update内で計算をするときは、計算に使っている値が前回と変わっていなければ処理をスキップするようにしておきましょう。

Share calculation output

XMLのパース結果やAIの計算結果、Raycastの結果などは各オブジェクトごとに持つのでは無く、単一の計算結果を他のスクリプトから参照するようにして下さい。

Update, Coroutines, and InvokeRepeating

0.2秒ごとにProcessAI()をしたいとき、以下のようなパターンを使います。

private float _aiProcessDelay = 0.2f;
private float _timer = 0.0f;
void Update() {  
  _timer += Time.deltaTime;  
  if (_timer > _aiProcessDelay) {
     ProcessAI();
    _timer -= _aiProcessDelay;
  }
}

Coroutineでも勿論同じ事が出来ますが、以下の3点からUpdateに上記処理を書く事を推奨します。 1. Update()とyield WaitForEndOfFrameでは後者のほうが3倍のオーバーヘッドがあります。 2. コルーチンはMonoBehaviourのenableかどうかとか 無関係に 動き続けます。(意図しないバグを生みます) 3. コルーチンはGameObjectを非アクティブにした後に再度アクティブにしても、リスタートしてくれません。(特に知らず知らずの内に親GameObjectをSetActiveして、意図せぬバグを生みます)

コルーチンでないと実現しにくい処理の待ち合わせなどはコルーチンを使うべきですが、定期実行にコルーチンは使わないようにしましょう。

InvokeRepeatingを使う処理も、1.2.の理由により推奨しません。

Faster GameObject null reference checks

GameObjectやMonoBehaviourのnullチェックは重いです。

if(gameObject != null) は思ったより重いです。(オーバーロードしてるから) if (!System.Object.ReferenceEquals(gameObject, null)) の方が倍速いです。

ただし、ナノ秒レベルの誤差なので、きわめて多く数のnullチェックが毎フレーム走る、とかでないなら普通の書き方で進めるべきです。(ボトルネックになったら書き換えましょう)

Avoid retrieving string properties from GameObjects

GameObjectのtagとnameはstring型です。これはNative Manage Bridgeな処理が走ります(つまり、通常の文字列比較よりもさらに多くのオーバーヘッドがありますし、GCAllocが生まれます)

tagについては gameObject.tag=="Player" みたいなプロパティアクセスではなくCompareTag()の関数を使うことで上記のNative-Managed Bridgeを避けられます。(GCAllocが生まれません)

Use appropriate data structures

System.CollectionsにあるListと Dictionary<K,V>の話です。 ListよりもDictionaryの方が速い(enumや適切なkeyを設定するなら)ので、要素数が多い場合はListを使わずにdictionaryにすることを検討してください。

Avoid re-parenting Transforms at runtime

Transformの親子構造変更は(再帰的に行うので)めちゃくちゃ重いです。特にシーンのRootに近い部分で親子構造を動的に差し替える時は注意しましょう。 どうしても必要であるならTransformのhierarchyCapacityプロパティの数値を増やしておきましょう。

Consider caching Transform changes

Transformのpositionやrotationなどグローバル座標系の値を取得するときは、親オブジェクトを辿って計算します。UnityのTransformの内部ではローカルの座標系の値しか保持しておらず、グローバル座標系の値を使うときはローカル座標系の値を合計するということです。なのでヒエラルキー上の階層が深いTransformのグローバル座標系の値を読み書きするのは見た目以上にコストが掛かります。ローカル座標系の値を使えるなら、そうすべきです。

また、Transform上の値(位置や回転やスケール)を書き換えると、内部的にColliderやRigidbody、Light、cameraに通知が行われます。(Unity5.4より前と比べて、5.4以降はこのコストが下がったようですが、依然として気をつけましょう)

同じフレーム内で何度もTransformの値を書き換えると、その度に上に書いたような処理が走るので、可能な限り計算結果を最後にTransformに代入するようにしましょう。 フレーム内で一度しか書き換えないようにするには、例えば以下のようにフラグで管理しておく、などです。

private bool _positionChanged;
private Vector3 _newPosition;
public void SetPosition(Vector3 position) {
  _newPosition = position;
  _positionChanged = true;
}
void FixedUpdate() {
  if (_positionChanged) {
    transform.position = _newPosition;
    _positionChanged = false;
  }
}

Avoid Find() and SendMessage() at runtime

SendMessage()やGameObject.Find()は極めてコストが重い処理です。SendMessage()は普通に関数を呼び出す2000倍重く、gameObject.Find()はシーンのヒエラルキーが複雑になればなるほど、処理が重くなります。GameObject.Find()をStartやAwakeで一度だけ使う、という事は許されますが、その場合でも処理負荷が非常に高いことを念頭に置いて下さい。(ZenjectなどのDIや、自前の登録システム製作を検討しても良いかも)

SendMessage()やFind()の多用はプロトタイピングの時には便利ですが、初級より先のプロジェクトで使うべきではありません。Unityの人も各種講演やドキュメントでこれらのオーバーヘッドについて繰り返し注意喚起しています。

オブジェクト間の通信についてはパフォーマンス面だけでなく、システム設計の点でも重要なので、もう少し掘り下げてみましょう。

オブジェクト間通信

最悪の例

最初に最悪の実装例(FindとSendMessageを使う)を挙げます。

こういう敵を管理するマネージャクラスがあって EnemyManagerという空のGameObjectに貼り付けているとします。

using UnityEngine;
using System.Collections.Generic;
class EnemyManagerComponent : MonoBehaviour {
  List<GameObject> _enemies = new List<GameObject>();

  public void AddEnemy(GameObject enemy) {
    if (!_enemies.Contains(enemy)) {
      _enemies.Add(enemy);
    }
  }

  public void KillAll() {
    for (int i = 0; i < _enemies.Count; ++i) {
      GameObject.Destroy(_enemies[i]);
    }
    _enemies.Clear();
  }

}

例えば敵の出現をさせるときは、敵の出現位置に以下のような関数を呼ぶスクリプトを作って置いたりします。

//敵のプレハブをアタッチしておく
[SerializeField]
  GameObject _enemyPrefab;
  
//指定した数の敵を出現させて、上に書いたEnemyManagerComponentに敵を登録する
  public void CreateEnemies(int numEnemies) {
    for(int i = 0; i < numEnemies; ++i) {
    GameObject enemy = (GameObject)GameObject.Instantiate(_enemyPrefab,
                       5.0f * Random.insideUnitSphere,
                       Quaternion.identity);
    string[] names = { "Tom", "Dick", "Harry" };
    enemy.name = names[Random.Range(0, names.Length)];
    GameObject enemyManagerObj = GameObject.Find("EnemyManager");
    enemyManagerObj.SendMessage("AddEnemy",
                                enemy,
                                SendMessageOptions.DontRequireReceiver);  }}

見るからにつらい、重そうな作りです…Forループの中でFind()が呼ばれていたら確実にまずい兆候です。

(上のstring []namesの位置が気になる方もいるかもしれませんが、現代的なコンパイラはforループ内で値が変わっていない変数は再生成せずに賢く使い回してくれます。そのため、可読性を重視してこの位置に書いています)

とりあえずあまりにもパフォーマンス効率が悪いので、CreateEnemies()の関数をせめてGetComponentを使ってキャッシュするようにしましょう。

public void CreateEnemies(int numEnemies) {
  GameObject enemyManagerObj = GameObject.Find("EnemyManager");
  EnemyManagerComponent enemyMgr =enemyManagerObj.GetComponent<EnemyManagerComponent>();
  string[] names = { "Tom", "Dick", "Harry" };
  for(int i = 0; i < numEnemies; ++i) {
    GameObject enemy = (GameObject)GameObject.Instantiate(_enemyPrefab,
                        5.0f * Random.insideUnitSphere,
                        Quaternion.identity);
    enemy.name = names[Random.Range(0, names.Length)];
    enemyMgr.AddEnemy(enemy);
  }
}

このGetComponentでキャッシュしたバージョンで完成とすることも出来ます。(シーン開始時にだけ呼ばれる、そしてロード時間を気にしないコンテンツの場合は)

しかし多くの場合においてゲーム中に敵が生成されたり、それぞれのオブジェクト間の通信が必要になります。

以下の実装例ではEnemyManagerComponentへの敵の登録やコントロールを行ういくつかの実装パターンを作ってみます。それらはGetComponentだけを使ったバージョンと比べてカップリングやカプセル化を改善しています。(Findを使わないようにしています)

  • Assign references to preexisting objects
  • Static Classes
  • Singleton Components
  • A global Messaging System

Assign references to preexisting objects

[SerializeField]つけてインスペクタ上で全部設定するパターン。一番シンプル。 こんな感じのスクリプトを作って空のゲームオブジェクトにアタッチしておくだけです。

using UnityEngine;
public class EnemyCreatorComponent : MonoBehaviour {
  [SerializeField] private int _numEnemies;
  [SerializeField] private GameObject _enemyPrefab;
  [SerializeField] private EnemyManagerComponent _enemyManager;

  void Start() {
    for (int i = 0; i < _numEnemies; ++i) {
      CreateEnemy();
    }
  }  

 public void CreateEnemy() {
    _enemyManager.CreateEnemy(_enemyPrefab);
  }
}

この方式でもパフォーマンス面では問題がなくなります。ただしenemyprefabをインスペクタから何でも設定できてしまうので、設定ミスに気をつけて下さい。

この方式の欠点はインスペクタの設定ミスが起きやすいこと、シリアライズ出来る型に制約があることなどです。(どちらも工夫すれば対応できます)

static class

はい。Findしなくてもよくなりますが、ソフトウェア工学において顰蹙を買うstaticなManagerクラスです。どこからでも参照できるので書き換えられて、デバッグを困難にします。

とはいえ、コスト的には十分軽いので、継続的な開発ではないなら、これはこれで…

Singletonパターン

いつものやつ "Some objects were not cleaned up when closing the scene. (Did you spawn newGameObjects from OnDestroy?)" をUnityEditor上で見かけることになるので、その対処もしましょう。

private bool _alive = true;
void OnDestroy() { _alive = false; }
void OnApplicationQuit() { _alive = false; }

こうしておいて、以下のように生存確認をします。

public static bool IsAlive {  get {    if (__Instance == null)      return false;    return __Instance._alive;  }}

Global Messaging System

いよいよ本題です。

上記の手法にはそれぞれ利点と欠点がありましたが、オブジェクト間の通信は最終的にメッセージシステムを自作するというアプローチがあります。 オブジェクトはメッセージを送信、受信(あるいは両方)し、受信側が自身の状態に合わせてメッセージを処理していきます。送り手側は受け手がだれであるかを気にせずメッセージの一括送信が出来るようになっている感じです。

この方式は今までの中で最も複雑で、実装やメンテナンスコストもかかりますが、長期的に見て最も良い解決策となりあなたのゲームの複雑性を抑えてくれるでしょう。

送りたいメッセージは色々な形式が考えられます、データや値、参照、受け手の情報などです。しかしまずは最もシンプルな文字列で考えましょう。

例えばこういったシンプルなMessageクラスを定義します。

public class Message{
public string type;
public Message(){ type = this.GetType().Name;}
}

このMessageクラスはコンストラクタ内でtypeをキャッシュします。以前の章で説明したようにstringを何度もアロケートすることを避けるために、最初に型名を取得して以降は使いまわすことでGCAllocを最小限にします。

このMessageクラスから派生したすべてのクラスは、typeにアクセスすることで自身の型情報を知ることが出来ます。

メッセージを送る側、MessagingSystemクラスのことを考えましょう。こういった要素を備えていてほしいです。 - グローバルからアクセスできる - どんなオブジェクト(MonoBehaviourの継承をしていても、していなくても良い)もリスナーに登録、削除が可能(オブザーバーパターン) - オブジェクトを登録することで他のオブジェクトからのメッセージ受信が出来るようになる - MessageSystemに登録済みのすべてのオブジェクトに一括でメッセージを送る手段があり、それは十分に高速である

要件を掘り下げていきます。

A globally accessible object

Messaging System自体はSingletonです。もしプロジェクトを進めていくうちに複数のMessageSystemが必要になったら生成と破棄をランタイムで出来るようにして、がんばってSingleton依存の場所をリファクタリングすることになり、これは結構大変です。しかし今回はシンプルにシングルトンにすることで得られる利点の方が大きいと考えてMessegingSystemクラスはシングルトンとして作ります。

Registration

「どんなオブジェクトも登録、削除が可能」「登録したらメッセージのやりとりが出来るようになる」のはMessagingSystemにいくつかのpublicメソッドを実装する必要があります。特別に名前をつけたdelegateを使うことでコードベースが理解しやすくなります。

幾つかのケースにおいて、全ての登録済みリスナーに通知メッセージを送りたくなります(例えば「敵が生成された!!」は幾つものオブジェクトに関係するので全体に送る)。また、特定のリスナーにだけメッセージを送りたい場合もあります(例えば敵AのHPバーを減らして!!の場合は敵AのHPバーオブジェクトにだけ送りたい)。

メッセージのやり取りに定義済みのdelegateを使うことで、引数や戻り値を使うことが出来ます。 これにより戻り値を見る事でシンプルに処理すべきメッセージがあるか、メッセージが無い場合は最小のCPUサイクルで次のリスナーに向かうことが出来ます。

///こんなデリゲートを定義しておきます
public delegate bool MessageHandlerDelegate(Message message);

リスナー側で実装すべき関数とかは追って説明します。

Message Processing

メッセージシステムへの登録削除、リスナーの受信イベントを上の方で説明しました。残りはメッセージの処理です。 一度にめちゃくちゃ大量のメッセージが生まれてしまった場合、処理落ちしてしまいます。それを避けるためにMonobehaviourのeventコールバック(これはUpdate中に呼ばれるので時間計測が出来て、多すぎたメッセージを途中で切り上げて次フレームに回せる)を使います。

これはStaticなシングルトンでも実現できますが、その場合はGod-classになることが目に見えています。今回はそれを避けるためにSingletonComponentを作成して、同じような処理を実現します。両者の違いとしては後者の方が他のオブジェクトのコントロール下におけるという点があり、例えばシーンをまたぐ時に破壊されない、シャットダウン時に再生成されないなどの利点があります。

このSingletonComponent方式はおそらくベストの方法です。それぞれのオブジェクトの独立性を維持できます。例えばゲームがポーズ中でも、このシステムは動き続けてUIボタンによるメッセージを貯めておいてポーズ終了後に実行する、なども実現できます。 実際にコードで見ていきましょう。

Implementing the Messaging System

singletonComponentなMessagsingSystemの実装を以下に書きます。 登録の仕組みだけです。

using System.Collections.Generic;
using UnityEngine;


public class MessagingSystem : SingletonComponent<MessagingSystem> {
    public static MessagingSystem Instance
    {
        get { return ((MessagingSystem)_Instance); }
        set { _Instance = value; }
    }

    private Dictionary<string, List<MessageHandlerDelegate>> _listenerDict = new Dictionary<string, List<MessageHandlerDelegate>>();

    public bool AttachListener(System.Type type, MessageHandlerDelegate handler) {
        if (type == null) {
            Debug.Log("MessagingSystem: AttachListener failed due to having no " + 
                      "message type specified");
            return false;
        }

        string msgType = type.Name;
        if (!_listenerDict.ContainsKey(msgType)) {
            _listenerDict.Add(msgType, new List<MessageHandlerDelegate>());
        }

        List<MessageHandlerDelegate> listenerList = _listenerDict[msgType];
        if (listenerList.Contains(handler)) {
            return false; // listener already in list
        }

        listenerList.Add(handler);
        return true;
    }

}

listenerDict はtypeごとにListのMessageHandlerDelegateを保持しているDictionaryです。これによって特定のtypeのメッセージが来た時に、各type内のリストを走査してメッセージを送ることが出来ます。 このlistenerDictへの登録を行う部分がAttachListner関数で、辞書に含めている2個のパラメータが引数になっています。

Message queuing and processing

メッセージを処理落ちしない範囲で処理していく(あまりに大量のメッセージが来たら次フレームに回す)ためのメッセージキューと処理の部分を作ります。

 //こういうところに格納する
private Queue<Message> _messageQueue = new Queue<Message>();
 
//キューへの登録はここから行う
public bool QueueMessage(Message msg) {
        if (!_listenerDict.ContainsKey(msg.type)) {
            return false;
        }
        _messageQueue.Enqueue(msg);
        return true;
    }

QueueMessage()でメッセージを追加します。内部ではシンプルに対象のメッセージtypeが存在しているかどうかだけをチェックしています。

追加したメッセージを取り出して処理する部分はUpdate()内で行います。

//最大処理時間(16ms以上経ったらキューに中身が残っていても処理を打ち切り、次フレームに持ち越す
private const int _maxQueueProcessingTime = 16667;
//メッセージを取り出して処理している時間を測るための計測タイマー
private System.Diagnostics.Stopwatch timer = new System.Diagnostics.Stopwatch();

 void Update() {
//タイマー開始
        timer.Start();
        while (_messageQueue.Count > 0) {
            if (_maxQueueProcessingTime > 0.0f) {
              //処理落ちしそうなら途中で中断
                if (timer.Elapsed.Milliseconds > _maxQueueProcessingTime) {
                    timer.Stop();
                    return;
                }
            }

            Message msg = _messageQueue.Dequeue();
            if (!TriggerMessage(msg)) {
                Debug.Log("Error when processing message: " + msg.type);
            }
        }
    }

TriggerMessage()というのが実際メッセージをリスナーに届ける部分で、後で出てきます。Update()内でメッセージを取り出すことで、処理落ちを防いでいる、ということが分かっていれば大丈夫です。

using System.Diagnostics;に注意 これをusingすると、Debug.Logがコンフリクトしちゃうので、その場合はUnityEngine.Debug.Log()って書く必要があります。それはつらいので、上のコードのStopWatchを定義しているところは、using無しでSystem.Diagnostics.Stopwatch と書いています。

最後にTriggerMessage(リスナーにメッセージを振り分ける部分)の実装です。

   public bool TriggerMessage(Message msg) {
        string msgType = msg.type;
        if (!_listenerDict.ContainsKey(msgType)) {
            Debug.Log("MessagingSystem: Message \"" + msgType + "\" has no listeners!");
            return false; // no listeners for message so ignore it
        }

        List<MessageHandlerDelegate> listenerList = _listenerDict[msgType];

        for (int i = 0; i < listenerList.Count; ++i) {
            //この行が処理の実体
            if (!listenerList[i](msg)){
            Debug.LogWarning("error occured");
        }                
        }
        return true;
    }

リスナーからtypeでフィルタして、フィルタ済みの各オブジェクトにdelegateでmsgを発火しています。

また、こういうキューだけではなく、直接その場で発火してほしいイベントを処理するためのTriggerEvent()も用意しておきましょう。割り込み処理や、フレーム落ちしてほくないメッセージをやり取りするために使います(注意:TriggerEventの使用は最小に留めて、多用しないこと!)

例えば、ゲーム内で2個のオブジェクトが破壊され、爆発エフェクトをその瞬間にパーティクルで発生させます。こういうときにTriggerEventで無理やりその場処理させる必要があります。逆に時間に厳密じゃない処理(例:ポップアップメッセージの表示)はQueueEvent()を使って行いましょう。

frame-critical(厳密なタイミング制御が必要)かどうかを意識して2種のイベント発生手段を使い分けましょう。

Implementing custom message

カスタムメッセージを作ります。

//敵を作れ、というメッセージ
public class CreateEnemyMessage : Message {}

//敵を作ったぞ、というメッセージ
public class EnemyCreatedMessage : Message {

    public readonly GameObject enemyObject;
    public readonly string enemyName;

    public EnemyCreatedMessage(GameObject enemyObject, string enemyName) {
        this.enemyObject = enemyObject;
        this.enemyName = enemyName;
    }
}

CreateEnemyMessageは最小のメッセージで、 EnemyCreatedMessageは生成したEnemyのGameObjectと名前の参照を持っています。 メッセージ内部の変数は、可能な限りpublicだけでなく、readonlyをつけるようにしておきましょう。安全です(メッセージをあちこち動かしている間に、中身の変数が変わったら怖いので)。

Message sending

定義したメッセージを送るには、こんな感じです。

void Update() {
        if (Input.GetKeyDown(KeyCode.Alpha1)) {
            MessagingSystem.Instance.QueueMessage(new CreateEnemyMessage());            
        }
}

試しに1キーを押しても何も起きません、それはリスナーがいないからです。リスナーの登録は次に説明します。

Message registration

こんな感じです。

public class EnemyManagerWithMessagesComponent : MonoBehaviour {

    private List<GameObject> _enemies = new List<GameObject>();
    [SerializeField] private GameObject _enemyPrefab;

    void Start() {
    //メッセージリスナーの登録(CreateEnemyMessageが来たら、このクラスのHandleCreateEnemy()を呼ぶ)
        MessagingSystem.Instance.AttachListener(typeof(CreateEnemyMessage), this.HandleCreateEnemy);
        
    }

//CreateEnemyで呼ばれる中身、プレハブから敵を生成して、EnemyCreatedMessageを送出する。
    bool HandleCreateEnemy(Message msg) {
        CreateEnemyMessage castMsg = msg as CreateEnemyMessage;
        string[] names = { "Tom", "Dick", "Harry" };
        GameObject enemy = GameObject.Instantiate(_enemyPrefab, 5.0f * Random.insideUnitSphere, Quaternion.identity);
        string enemyName = names[Random.Range(0, names.Length)];
        enemy.gameObject.name = enemyName;
        _enemies.Add(enemy);
        MessagingSystem.Instance.QueueMessage(new EnemyCreatedMessage(enemy, enemyName));
        
    return true;
    }

   
    public void AddEnemy(GameObject enemy) {
        if (_enemies.Contains(enemy)) {
            return;
        }
        _enemies.Add(enemy);
    }

    void OnDestroy() {
        if (MessagingSystem.IsAlive) {
            MessagingSystem.Instance.DetachListener(typeof(EnemyCreatedMessage), this.HandleCreateEnemy);
        }
    }
}
public class EnemyCreatedListenerComponent : MonoBehaviour {

    void Start () {
        //メッセージリスナーの登録(EnemyCreatedMessageが来たら、このクラスのHandleEnemyCreated()を呼ぶ)
        MessagingSystem.Instance.AttachListener(typeof(EnemyCreatedMessage), HandleEnemyCreated);
    }
    
    //メッセージの受け手、キャストすることで名前にもアクセスできる
    bool HandleEnemyCreated(Message msg) {
            EnemyCreatedMessage castMsg = msg as EnemyCreatedMessage;
            Debug.Log(string.Format("A new enemy was created! {0}", castMsg.enemyName));
        return true;
    }

    void OnDestroy() {
        if (MessagingSystem.IsAlive) {
            MessagingSystem.Instance.DetachListener(typeof(EnemyCreatedMessage), this.HandleEnemyCreated);
        }
    }
}

EnemyManagerWithMessagesComponentクラスは初期化時にCreateEnemyMessageを購読して、受信したらHandleCreateEnemy()を呼ぶようMessageSystemに登録します。

登録済みなので bool HandleCreateEnemy(Message msg)内のmsg型のキャストは型チェックやnullチェック無しで行えます。これは速いです。 上記のようにMessageTypeは細分化して登録して、それぞれに1個の関数だけを割り当てるようにした方が、巨大な関数を作りにくくなります。 (もちろん、複数の関数をデリゲートとして登録することもできます)

また、bool HandleEnemyCreatedがprivateな事にも注目してください。これにより、外部から直接呼ばれる危険性を避けることが出来ます。

ここまで解説してきて「コード量が無駄に多い」と感じる方もいるかもしれませんが、こういったMessageSystemを組んでおくことで、将来のメンテナンスや、デバッグ時にとても大きな恩恵を受けることができます。急に人員追加された時でも、コードがドキュメントとして機能し、最小限の時間で理解してくれることでしょう。

今回の例ではメッセージデリゲート内で別メッセージを発火しています。以下の場所です。

 MessagingSystem.Instance.QueueMessage(new EnemyCreatedMessage(enemy, enemyName));

ここで送ったEnemyCreatedMessageの中身はタダのDebugLogで、あまり有益さはありませんが、実際のアプリケーションでは例えばEnemyCreatedListenerComponent はUIシステムだったりして、その場合は敵の名前やHPなんかを表示してやる(場合によってはUIのパネルをInstanciateしてやる)などの用途に使うことを想像してみてください。

敵の出現状況を表示するUIシステムが、他のシステムから完全に切り離されて存在出来ることに気付くことでしょう。これがこのMessagingSystemの強力な点です。

////ここサンプルプロジェクトを動かすデモがある

Message cleanup

Messageオブジェクトはクラスで出来ています。なので動的にメモリを確保して、処理が終わったらすぐに消えてGCのマークがされます。 GCのマーク!! GC.Collect()が呼ばれる!と気付いた方はさすがで、長時間動かし続けるアプリケーションだと問題になります。とはいえこのMessagingSystemをGCが発生するから使わない、というよりは、控えめに、適切に呼ぶように気を付ければ問題はないと思います。(毎フレームアップデートで呼ばなければいけないメッセージがある場合、設計を疑ってください)

また、メッセージ自体よりもリスナーの登録解除の方が大切です。

MessagingSystemクラスに以下のように登録を解除する関数を書いておいて

  public bool DetachListener(System.Type type, MessageHandlerDelegate handler) {
        if (type == null) {
            Debug.Log("MessagingSystem: DetachListener failed due to having no " + 
                      "message type specified");
            return false;
        }

        string msgType = type.Name;

        if (!_listenerDict.ContainsKey(type.Name)) {
            return false;
        }

        List<MessageHandlerDelegate> listenerList = _listenerDict[msgType];
        if (!listenerList.Contains(handler)) {
            return false;
        }
        listenerList.Remove(handler);
        return true;
    }

EnemyManagerWithMessagesComponentクラスのOnDestroyで

  void OnDestroy() {
        if (MessagingSystem.IsAlive) {
            MessagingSystem.Instance.DetachListener(typeof(EnemyCreatedMessage), this.HandleEnemyCreated);
        }
    }

このように登録を解除するようにしましょう。 IsAliveはSingletonComponentクラスで定義されているプロパティで、このチェックを行うことでアプリケーション終了時にSingletonComponentが再生成されるのを防ぐことが出来ます。

Wrapping up the Messaging System

長い解説にお付き合いありがとうございます。これでついに我々は完全なグローバルメッセージシステムを手に入れました!適切なinterfaceを備えたいかなるクラスでもクラス間でメッセージのやり取りを行うことが出来ます。 特徴として

  • typeを使う事でsenderとlistenerのキャストが安全に行えて、senderとlistenerそれぞれがお互いのクラスの参照を持たなくて済む
  • 単なるdelegateであり、UnityEvent等ではないので、MonoBehaviour等に依存せず、あらゆるクラスで使うことが出来る
  • 1メッセージを100の受け手に送る場合も、100メッセージをそれぞれ1個の受け手に送る場合も、ほとんど同じ処理速度で十分速い(毎フレーム数100個程度でも大丈夫)

を備えています。

とはいえこのMessagingSystemが完璧なわけではなく、大量のメッセージをやり取りする場合などで不満が出てくるかもしれません。以下に改良する余地を書きます。

  • メッセージを送る時にdelay秒数情報を付加する(delay秒数後にlistenerに届けてイベントを発火する)
  • リスナーに優先度を追加する。これにより、あるイベントが発生したときに優先処理をすべきリスナーというような表現が出来る
  • リスナーの追加時やメッセージ受信時にチェックを追加する

今回実装したメッセージシステムで十分なことが大半なので、続きの最適化の話をします。

訳者注: https://www.dropbox.com/s/5wju2w8vur8lagn/MessagingSystem.unitypackage?dl=0 にMessageSystem部分のみを抜き出し日本語コメントを追加したサンプルプロジェクトをアップしています。Unity2017.2以降です。

不要なスクリプトやオブジェクトを無効にする

大きなシーンや、オープンワールドのゲームを作っているとシーンが重くなります。Update()や定期実行されるスクリプトはコンパクトなゲームなら問題ないですが、大きなシーンになると無視できない重さになります。 しかし例えばプレイヤーから見えないくらい遠くにあるオブジェクトのスクリプトは必要でしょうか?

Disabling objects by visibility

MonoBehaviourは見えなくなったか、見えるようになったかをイベントハンドラで捕まえることが出来ます。例えるならMonoBehaviourのオクルージョンカリングのような処理を書けます。OnBecameVisible()と OnBecameInvisible()が該当します。(もちろん、これらのイベントを捕まえるためには、スクリプトのアタッチ先のGameObjectにRendererコンポーネントが必須です)

注意:このOnBecameInvisibleなどはシーンビューのカメラにも適用されてしまうので、Unityエディタ上でこの章の内容を追試するときは、シーンビューのカメラをすごく遠く、何も映っていないようにしてください。もしくはシーンビューを閉じてください

例えばこういったシンプルにUpdateを止めることが考えられます。

void OnBecameVisible() { enabled = true; }
void OnBecameInvisible() { enabled = false;} 

Disabling objects by distance

可視、不可視以外にも距離によってUpdate()を止める方法もあります。

Consider using distance-squared overdistance

ところで距離を求める時に

float distance = (transform.position – other.transform.position).Distance();
if (distance < targetDistance) {  
    // do stuff
}

とやると、Distance()の中でルートの計算が走ってかなり重いです。 以下のようにsqrMagnitudeを使い、比較対象側を二乗することで殆ど同じ結果を、より高速に得られます。(ほとんど同じ、というのは浮動小数点誤差についてです)

float distanceSqrd = (transform.position –other.transform.position).sqrMagnitude;
if (distanceSqrd < (targetDistance * targetDistance)) {  
// do stuff
}

とりあえずルート計算がある処理(距離を求めたりする)時には、上のような書き換えが出来ることを覚えておきましょう。

Minimize Deserialization behavior

Resources.Load()のデシリアライズや、深い階層構造のprefabインスタンスは重いので、ゲーム中にスパイクが起きる様ならシーンの最初に全部デシリアライズだけやってしまうというのも手です。

Load serialized objects asynchronously

Resources.LoadAsync() を使う方がResources.Load()よりマシです。

Keep previously loaded serialized objects inmemory

Resource.Unload()を無駄に呼ばないことによって、デシリアライズ済みのオブジェクトをメモリにキャッシュできます。

Move common data into ScriptableObjects

パラメータなどはプレハブから逃がしてScriptableObjectにしましょう。

Load scenes additively and asynchronously

シーンのロードは同期と非同期があり、処理速度自体は同期の方が速い。しかしユーザ体験を阻害する(読み込み中に完全に止める)ので普段は非同期ロードを使うべき(LoadLevelAsync)。同期(普通のロード)は例えばメインメニューに戻るとき等、少しでも速く絵を出すことが価値に繋がる時だけにしましょう。

Custom update layer

void Update()を毎フレームではなく、決まった頻度で定期実行する仕組みを作ります