今回、わたしの mod で使っている共有ライブラリを 1.37.1 対応するのに合わせて、処理の見直しを行っているので、以前にやったテストの追試をしてみました。前回より若干速い Zenject によるオブジェクト探索のベンチマークも加えています。

今回は Beat Saber mod を作るにあたって、わたしがやっているテクニック的なことを書いてみようと思います。mod を作らない方には不要な知識ではありますが、こんなことをしてるんだ、と興味を持っていただければ幸いです。 オブジェクトの取得 Resources.FindObjectsOfTypeAll Beat Saber 内で使用されているオブジェ...
ゲーム中 (譜面プレイ中) に、音や時間進行を制御する AudioTimeSyncController を取得する処理を 1000 回実行して、所要時間を比較します。コードとしては下記のような形になります。
----
sw.Restart();
for (int i = 0; i < 1000; i++)
{
a = Resources.FindObjectsOfTypeAll().FirstOrDefault();
}
sw.Stop();
----
FindObjectsOfTypeAll: 2816.0181ms
----
Resources.FindObjectsOfTypeAll().FirstOrDefault();
----
よくあるやり方ですが、とてつもなく遅いです。
FindObjectOfType: 2275.4955ms
----
Object.FindObjectOfType();
----
上の UnityEngine.Resourcesではなく、UnityEngine.Object のほうにある関数。こちらのほうが若干速くなりますが、非アクティブなオブジェクトや、リソースとして存在するオブジェクトの取得はできない制限があります。
Find+GetComponent: 4.6498ms
----
GameObject.Find("/Wrapper/StandardGameplay/GameplayCore/SongController").GetComponent();
----
GameObject を検索してから、その中の Component を拾う方法。あらかじめ、どこに AudioTimeSyncController があるかを調べておく必要がありますが、上 2 つよりも数百倍速くなります。
ただし、GameObject.Find は非アクティブなオブジェクトを見つけられません。非アクティブなオブジェクトが必要な場合は、Scene.GetRootGameObjects から、自力でフルパスをたどって検索します。
DiContainer.Resolve: 0.8870ms
----
(事前処理、ここは所要時間に含まない)
DiContainer container = GameObject.Find("/GameCore/SceneContext").GetComponent().Container;
----
container.Resolve();
----
今回最速の方法になります。こちらもあらかじめ、どのオブジェクトがどのコンテナに入っているのか調べておく必要があります。
最速ではありますが、どのオブジェクトに対しても使えるわけではありません。コンテナに Bind されていないものは拾えませんし、Bind されたときの設定によっては、Resolve で欲しい既存のオブジェクトが返るのではなく、新しいオブジェクトが生成されてしまう場合もあります。
さきほどの AudioTimeSyncController が Private 変数として保持している AudioSource を取得する処理を 100000 回実行して、所要時間を比較します。AudioSource をとると、曲を止めたり、音量を変更したり、といったことが可能になります。コードとしては下記のような形になります。
----
sw.Restart();
for (int i = 0; i < 100000; i++)
{
s = (AudioSource)a.GetType().GetField("_audioSource", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(a);
}
sw.Stop();
----
GetType+GetField+GetValue: 92.2571ms
----
a.GetType().GetField("_audioSource", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(a);
----
Private なフィールドを Reflection で読みに行く一般的なやり方。Reflection は遅い、と言われるように、そこそこ時間がかかります。
GetValue: 20.3264ms
----
(事前処理、ここは所要時間に含まない)
FieldInfo fi = typeof(AudioTimeSyncController).GetField("_audioSource", BindingFlags.Instance | BindingFlags.NonPublic);
----
fi.GetValue(a);
----
Reflection も、GetField まで済ませたものを事前に保持しておけば、数倍速くなります。
ReflectionUtil.GetField: 10.5960ms
----
using IPA.Utilities;
----
(事前処理、ここは所要時間に含まない)
a.GetField("_audioSource");
----
a.GetField("_audioSource");
----
BSIPA に入っている ReflectionUtil の GetField。内部的には、初回実行時にフィールドに直接アクセスする Delegate (IL を使って動的にコード生成したもの) を生成して Dictionary に登録し、2 回目以降の実行が高速化される仕組みです。なので、初回は事前処理として、所要時間から外して計測しました。
Delegate 部分は、ほぼフィールドへの直接アクセスになる割に、Reflection と比べて速度があまり伸びません。Dictionary で Delegate を探索するのが遅いのかもしれません。
ReflectionUtil.FieldAccessor: 0.2785ms
----
using IPA.Utilities;
----
(事前処理、ここは所要時間に含まない)
FieldAccessor.Accessor accessor = FieldAccessor.GetAccessor("_audioSource");
----
accessor(ref a);
----
上の ReflectionUtil の GetField が生成していた Delegate を変数として保持して、ループで呼び出します。ほぼフィールドへの直接アクセスになるので、かなり高速になります。
Direct: 0.2356ms
----
a._audioSource;
----
フィールドへの直接アクセス、当然最速になります。こうして比較すると、上の FieldAccessor も若干のオーバーヘッドがある程度で、ほぼ直接アクセスになっているのがわかりますね。
通常はクラスのプライベート変数に外部からアクセスすることはできませんが、AssemblyPublicizer という参照先アセンブリをすべて public 化するツールを使うと、このように元は private 変数のものも普通に拾えるようになります。
ReflectionUtil.GetField を使う場合、必ず存在するフィールドを指定して使う必要があります。存在しないフィールド名を指定すると、2 回目で Beat Saber がフリーズします。気をつけましょう。
----
Debug.Log($"test1");
try { a.GetField("_not_found_"); } catch { }
Debug.Log($"test2");
try { a.GetField("_not_found_"); } catch { }
Debug.Log($"test3"); // ここにはこない。上でフリーズ
----
プログラミングに興味のない方には、よくわからない記事だったかもしれませんが、こんな実験をしながら mod を作っています。すこしでも良いものを作っていけるよう、これからもがんばります!