Unity を検索

Unityにはメッセージングシステムと呼ばれる、ゲームの実行中に特定のイベントが発生した時に自動的に呼ばれる魔法のメソッドを定義できる機能が備わっています。これはとてもシンプルかつ簡単なコンセプトなので、特に初心者にはありがたい機能です。たとえば短に下記のようにUpdateメソッドを定義するだけで、自動的に毎フレーム呼び出されます!

void Update() {
    transform.Translate(0, 0, Time.deltaTime);
}

しかし、経験豊富な開発者からすれば、このコードはちょっとモヤモヤします。

  1. このメソッドは具体的にどのようにして呼び出されているかが明確でない。
  2. シーンに複数のオブジェクトがあった時、どのような順序でこのメソッドが呼び出されるのかが明確でない。
  3. このコーディングスタイルはインテリセンスが効かない。

Updateはどのように呼ばれているのか

「…System.Reflectionだ!そうだろう!!」…いえ、UnityはSystem.Reflectionを使ってメソッドを調べて毎回呼んでいる訳ではありません。

代わりに、任意の型のMonoBehaviourが初めてその基底のスクリプトにアクセスしたときに、スクリプティングランタイム(MonoもしくはIL2CPP)によって何かのマジックメソッドが定義されているかを調査され、この情報がキャッシュされます。もしMonoBehaviourが特定のメソッドを持っていたら所定のリストに追加されます。例えばスクリプトがUpdateメソッドを持っていたら「毎フレームUpdateを呼ぶべきスクリプトのリスト」に追加されるわけです。

ゲーム中は、Unityは短にこのリストをイテレーションしてメソッドを呼んでいきます - シンプルです。また、これがUpdateメソッドのアクセス権がpublicであろうとprivateであろうと関係ない理由でもあります。

Updateはどのような順番で実行されるのか

実行順はスクリプト実行順設定(Editメニュー > Project Settings > Script Execution Order)で設定されます。1000個のスクリプトの実行順序をマニュアルで設定するのはあまりいい方法とは言えないかもしれませんが、とある1つのスクリプトが他のよりも先に呼ばれてほしい、という程度なら現実的でしょう。もちろん、将来的にはもっと便利なやり方、たとえばアトリビュートで設定出来るようにするとかで実行順序を設定できるようにしたいと考えています。

インテリセンスが効かない

UnityでC#のスクリプトを書く時、私たちは誰もがIDE的な何かを使っています。ほとんどのIDEは呼び出し元がハッキリしないマジックメソッド的なものが好きではありません。しばしばワーニングの原因にもなりますし、コードを追う作業も大変になります。

時にデベロッパーにはMonoBehaviourを拡張する抽象クラスを作り、それにBaseMonoBehaviourとかそういった名前をつけて、プロジェクト内の全てのスクリプトをこのクラスを継承するように書くようにする人がいます。この方法では基本的な機能を仮想メソッドとして下記のように定義します:

public abstract class BaseMonobehaviour : MonoBehaviour {
    protected virtual void Awake() {}
    protected virtual void Start() {}
    protected virtual void OnEnable() {}
    protected virtual void OnDisable() {}
    protected virtual void Update() {}
    protected virtual void LateUpdate() {}
    protected virtual void FixedUpdate() {}
}

この構造はあなたのプロジェクトでMonoBehaviour を使う時によりロジカルな構造を導入しますが、一つ欠点があります。おそらく、すでにお気づきだとは思いますが…

全てのMonoBehaviourがUnityが内部で使用するUpdateリストに入り、これらの全てのメソッドが全てのスクリプトについて各フレームで実行され、そしてほとんどのメソッドでは何もしないのです!

空のメソッドについてなぜ気にする必要しなくちゃならないかって?確かにそう思う人もいるかもしれません。問題はこれらのメソッドはC++の世界からマネージドC#の世界のメソッドを呼び出しているということにあり、そこにはコストがあるのです。では、実際のこのコストを見てみましょう。

Updateを10000回呼ぶ

このブログ記事のためにGithubに小さなサンプルプロジェクトを作成しました。このプロジェクトには2つのシーンがあり、デバイスでタップするかエディターで何かキーを入力すると変わります:

(1) 最初のシーンでは下記のようなコードを持つ10000個のMonoBehaviour クラスが作成されます:

private void Update() {
    i++;
}

(2) 2つめのシーンでは10000個の MonoBehaviourが作成されますが、Updateを使う代わりに別のメソッドとしてUpdateMeというメソッドを作成し、下記のような要領でマネージャースクリプトからこれを毎フレーム呼び出すようにしています:

private void Update() {
    var count = list.Count;
    for (var i = 0; i < count; i++) list[i].UpdateMe();
}

このテストプロジェクトはDevelopment Modeを使用せず、Releaseの設定でMonoとIL2CPPにそれぞれコンパイルされ、2つのiOSデバイスで動作させました。時間の計測方法は以下の通りです:

  1. 最初のUpdateが実行された時にStopwatchを起動 (Script Execution Order で設定)、
  2. LateUpdateでStopwatchを停止、
  3. 数分間のあいだ計測を続け、平均を出す。

Unity version: 5.2.2f1
iOS version: 9.0

Mono

ぎゃあ!こいつは長いな!テストになにか問題があったに違いない!

じつは、Script Call OptimizationFast but no Exceptions に変更するのを忘れていました。さて気を取り直して設定をしたので、これがどのくらいのインパクトをパフォーマンスに与える話なのか、IL2CPPならもう忘れてもいいようなことなのか、ということが分かる準備が出来ました。

Mono (fast but no exceptions)

OK、だいぶ良くなりました。IL2CPPではどうでしょうか?

IL2CPP

ここで2つのことが見えてきます:

  1. この最適化方法はIL2CPPでも変わらず有効です。
  2. IL2CPPはまだ改善の余地があるということです。このブログ記事を書いているあいだもスクリプティング&IL2CPPチームはパフォーマンス改善のために奮闘しており、実際最新のスクリプティングブランチではこのテストは35%高速に動作しました。

Unityが中でなにをしているのか、ほどなく説明します。ただし、とりあえず今のところはマネージャーコードを変更して5倍速く動くようにしましょう!

インターフェイス呼び出し、バーチャル呼び出しと配列アクセス

もしまだこの素晴らしいIL2CPP Internalsシリーズのブログ記事を読んでいないのであれば、このブログ記事を読み終わったらすぐに読む事をオススメします!

もしあなたが10000個の要素をもつリストをイテレートしたい場合は、Listの代わりに配列を使う方がよいでしょう。なぜならその方が生成されるC++コードがよりシンプルになり、また配列アクセスの方が単純に高速だからです。

次のテストでわたしは List<ManagedUpdateBehavior>ManagedUpdateBehavior[] に変更しました。

うーん、こっちの方が全然いいですね!

更新情報: 配列を使ったテストをMono上で行った結果は 0.23ms でした。

Instrumentを使って調査する

私たちはC++からC#の関数を呼ぶのは速くないということを発見しましたが、ではUnityはこれらのオブジェクトのUpdateを呼び出すときに実際には何をしているのかということを調べていきましょう。一番簡単な方法はApple InstrumentsのTime Profilerを使うことです。

注:このテストはMonoとIL2CPPの比較テストではありません - 以下に説明されているほとんどのことはMonoのiOSビルドでも当てはまる内容です。

わたしはTime Profilerを有効にしてIPhone 6上でテストを起動し、数分間分のデータを記録してそのうちの1分間を詳しく調べてみました。私たちにとって興味があるポイントは以下の行から始まる全ての部分です:
void BaseBehaviourManager::CommonUpdate<BehaviourManager>()

まだInstrumentsを使ったことがない方のために説明をすると、右側には関数が実行時間順にソートされ、その中から呼ばれている関数へと繋がっています。一番左側のカラムは関数が使用したミリ秒単位のCPU時間と関数(とその関数内で呼んだ別の関数)が占めた時間の占有率(%)です。実験中、CPUがすべてUnityの処理によって占められているわけではなかったのですが、60秒間の期間中、約10秒がUpdateの処理によって占められていました。今回探りたいのは、この部分は一体どういう処理によって占められているのかということです。

わたしは自分に秘められた禁断のPhotoshopスキルを解放して、みなさんにも何が起きているか分かりやすいようにプロファイル結果を色分けしてみました。

UpdateBehavior.Update()

ちょうど中程のところでUpdateメソッド - IL2CPP的にはUpdateBehavior_Update_m18 が呼ばれています。しかし、そこにたどり着く前にUnityは様々なことを行っているのがわかります。

すべてのBehaviorsをイテレートする

UnityはすべてのBehavior インスタンスを訪ねてアップデートを行います。特別なイテレータークラスであるSafeIteratorは、誰かが突然リストに登録されている次のアイテムを削除することにしても問題が起きないよういい感じに処理してくれます。すべてのBehavior インスタンスをイテレートするだけで全処理時間 9979ms のうちの 1517ms を占めています。

呼び出しが有効かどうかをチェックする

次に、Unityは初期化され、かつStartメソッドが呼ばれた後のアクティブなゲームオブジェクトの有効なメソッドを呼び出している、ということを確認するために幾つかのチェック処理を行います。Updateの処理中に破棄したGameObjectのせいでゲームがクラッシュしたりして欲しくないですよね?そういうチェックが全体の9979msのうち、2188msを占めています。

メソッドの呼び出しを準備する

Unityはネイティブコードからマネージドコードの呼び出しを行うためにScriptingArgumentsとともにScriptingInvocationNoArgsインスタンスを作成し、IL2CPPバーチャルマシンに(scripting_method_invoke 関数を通して)メソッドの呼び出しを指示します。このステップは全体の9979msのうち2061msを占めています。

メソッドを呼び出す

scripting_method_invoke関数は渡された引数が有効かをチェックし(900ms)、その上でIL2CPPバーチャルマシンのRuntime::Invokeメソッドを呼びます(1520ms)。まずRuntime::Invokeは渡されたメソッドが存在するかどうかをチェックします(1018ms)。そして、生成されたRuntimeInvoker関数を指定の関数シグネチャについて呼び出します(283ms)。これが最終的にUpdate関数の呼び出しになり、Time Profilerによれば実行には42msかかっています。

下記のカラフルで素敵なテーブルに結果をまとめました。

マネージャーを使ったUpdate

では、Time Profilerを使ってマネージャーのテストをしてみましょう。スクリーンショットから同様のメソッドがあることがわかります。もっとも、いくつかのメソッドは1msに満たないため表示すらされていません。ただし、ほとんどの処理時間はUpdateMe関数(IL2CPP的にはManagedUpdateBehavior_UpdateMe_m14)が占めています。それと、IL2CPPが配列がNULLに対してイテレートしていないことを保証するために挿入したNULLチェックもあります。

次のイメージは同じルールで色付けしています。

さて、どう思いますか?ちょっとしたメソッドの呼び出し時間について気を配るべきだと思いますか?

このテストに関するちょっとした意見

正直なところ、このテストは完全にフェアなテストとは言えません。Unityはあなたとあなたのゲームが意図しない振る舞いやクラッシュにはまり込まないように、とてもいい仕事をしています。このゲームオブジェクトはアクティブ?あれ、今回のUpdateのループ中に破棄しなかったっけ?このオブジェクトにはそもそもUpdateメソッドが存在する?MonoBehaviourインスタンスがこのUpdateの処理中に生成されたどうしたらいい? - わたしのマネージャースクリプトはこういう問題については一切の対応をしておらず、単純にオブジェクトのリストをイテレートしてアップデートのための処理を呼んでいるだけです。

現実の世界ではマネージャースクリプトはもっと複雑で処理時間を要するものになるでしょう。しかし、今回のケースでは私がこのプロジェクトの開発者で、私のコードが何をするべきなのかよく知っており、どういう振る舞いが可能で、どういうことがゲーム上では起きないかということも知った上でマネージャークラスを設計することができます。Unityは残念ながらそうした知識を持っていません。

結局、どうしたらいいの?

もちろん、すべてはあなたのプロジェクト次第です。しかし現実に目を向けると非常にたくさんのGameObjectをシーン上に配置してすべてのオブジェクトで毎フレーム何らかのロジックを実行しているようなゲームを見ることは稀ではないのです。大体はそれらは大した影響はなにもないようなものですが、数が非常に大きくなってくると、数千のオブジェクトに対してUpdateメソッドを呼ぶコストはだんだん大きくなり、目に見えるようになってきます。この状態ではもしかしたらすでにゲームの設計をリファクタリングしてマネージャーパターンを導入するには遅すぎるということもあるかもしれません。

データ的にはこの記事の通りですので、次のプロジェクトの開始時にはどうするべきか考えてみてはいかがでしょうか。

2015年12月23日 カテゴリ: テクノロジー | 9 分 で読めます