void Update() { transform.Translate(0, 0, Time.deltaTime); }
しかし、経験豊富な開発者からすれば、このコードはちょっとモヤモヤします。
「…System.Reflectionだ!そうだろう!!」…いえ、UnityはSystem.Reflectionを使ってメソッドを調べて毎回呼んでいる訳ではありません。
代わりに、任意の型のMonoBehaviourが初めてその基底のスクリプトにアクセスしたときに、スクリプティングランタイム(MonoもしくはIL2CPP)によって何かのマジックメソッドが定義されているかを調査され、この情報がキャッシュされます。もしMonoBehaviourが特定のメソッドを持っていたら所定のリストに追加されます。例えばスクリプトがUpdateメソッドを持っていたら「毎フレームUpdateを呼ぶべきスクリプトのリスト」に追加されるわけです。
ゲーム中は、Unityは短にこのリストをイテレーションしてメソッドを呼んでいきます - シンプルです。また、これがUpdateメソッドのアクセス権がpublicであろうとprivateであろうと関係ない理由でもあります。
実行順はスクリプト実行順設定(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#の世界のメソッドを呼び出しているということにあり、そこにはコストがあるのです。では、実際のこのコストを見てみましょう。
このブログ記事のために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デバイスで動作させました。時間の計測方法は以下の通りです:
Unity version: 5.2.2f1
iOS version: 9.0
ぎゃあ!こいつは長いな!テストになにか問題があったに違いない!
じつは、Script Call Optimization を Fast but no Exceptions に変更するのを忘れていました。さて気を取り直して設定をしたので、これがどのくらいのインパクトをパフォーマンスに与える話なのか、IL2CPPならもう忘れてもいいようなことなのか、ということが分かる準備が出来ました。
OK、だいぶ良くなりました。IL2CPPではどうでしょうか?
ここで2つのことが見えてきます:
Unityが中でなにをしているのか、ほどなく説明します。ただし、とりあえず今のところはマネージャーコードを変更して5倍速く動くようにしましょう!
もしまだこの素晴らしいIL2CPP Internalsシリーズのブログ記事を読んでいないのであれば、このブログ記事を読み終わったらすぐに読む事をオススメします!
もしあなたが10000個の要素をもつリストをイテレートしたい場合は、Listの代わりに配列を使う方がよいでしょう。なぜならその方が生成されるC++コードがよりシンプルになり、また配列アクセスの方が単純に高速だからです。
次のテストでわたしは List<ManagedUpdateBehavior>
を ManagedUpdateBehavior[]
に変更しました。
うーん、こっちの方が全然いいですね!
更新情報: 配列を使ったテストをMono上で行った結果は 0.23ms でした。
私たちは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スキルを解放して、みなさんにも何が起きているか分かりやすいようにプロファイル結果を色分けしてみました。
ちょうど中程のところでUpdateメソッド - IL2CPP的にはUpdateBehavior_Update_m18 が呼ばれています。しかし、そこにたどり着く前にUnityは様々なことを行っているのがわかります。
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かかっています。
下記のカラフルで素敵なテーブルに結果をまとめました。
では、Time Profilerを使ってマネージャーのテストをしてみましょう。スクリーンショットから同様のメソッドがあることがわかります。もっとも、いくつかのメソッドは1msに満たないため表示すらされていません。ただし、ほとんどの処理時間はUpdateMe関数(IL2CPP的にはManagedUpdateBehavior_UpdateMe_m14)が占めています。それと、IL2CPPが配列がNULLに対してイテレートしていないことを保証するために挿入したNULLチェックもあります。
次のイメージは同じルールで色付けしています。
さて、どう思いますか?ちょっとしたメソッドの呼び出し時間について気を配るべきだと思いますか?
正直なところ、このテストは完全にフェアなテストとは言えません。Unityはあなたとあなたのゲームが意図しない振る舞いやクラッシュにはまり込まないように、とてもいい仕事をしています。このゲームオブジェクトはアクティブ?あれ、今回のUpdateのループ中に破棄しなかったっけ?このオブジェクトにはそもそもUpdateメソッドが存在する?MonoBehaviourインスタンスがこのUpdateの処理中に生成されたどうしたらいい? - わたしのマネージャースクリプトはこういう問題については一切の対応をしておらず、単純にオブジェクトのリストをイテレートしてアップデートのための処理を呼んでいるだけです。
現実の世界ではマネージャースクリプトはもっと複雑で処理時間を要するものになるでしょう。しかし、今回のケースでは私がこのプロジェクトの開発者で、私のコードが何をするべきなのかよく知っており、どういう振る舞いが可能で、どういうことがゲーム上では起きないかということも知った上でマネージャークラスを設計することができます。Unityは残念ながらそうした知識を持っていません。
もちろん、すべてはあなたのプロジェクト次第です。しかし現実に目を向けると非常にたくさんのGameObjectをシーン上に配置してすべてのオブジェクトで毎フレーム何らかのロジックを実行しているようなゲームを見ることは稀ではないのです。大体はそれらは大した影響はなにもないようなものですが、数が非常に大きくなってくると、数千のオブジェクトに対してUpdateメソッドを呼ぶコストはだんだん大きくなり、目に見えるようになってきます。この状態ではもしかしたらすでにゲームの設計をリファクタリングしてマネージャーパターンを導入するには遅すぎるということもあるかもしれません。
データ的にはこの記事の通りですので、次のプロジェクトの開始時にはどうするべきか考えてみてはいかがでしょうか。