今回は Beat Saber mod を作るにあたって、わたしがやっているテクニック的なことを書いてみようと思います。mod を作らない方には不要な知識ではありますが、こんなことをしてるんだ、と興味を持っていただければ幸いです。
Resources.FindObjectsOfTypeAll
Beat Saber 内で使用されているオブジェクトを簡単に拾える FindObjectsOfTypeAll、Beat Saber mod でもよく使用される関数ですが、とてつもなく遅いです。
例えば、Beat Saber のメニュー画面足元にある、バージョンを表示しているオブジェクト SetApplicationVersionText を FindObjectsOfTypeAll で 1000 回取得してみると、どのくらいの時間がかかるでしょうか。(1 回でなく 1000 回なのは、誤差を小さくするため)
----
Stopwatch sw = new Stopwatch();
sw.Restart();
for (int i = 0; i < 1000; i++)
{
setApplicationVersionText =
Resources.FindObjectsOfTypeAll()
.FirstOrDefault();
}
sw.Stop();
Logger.Debug($"{sw.ElapsedMilliseconds}ms");
(以降のコード例ではループの部分は書きませんが、1000 回実行した時間だと思ってください)
----
(1000回あたり)
4770ms
4567ms
4620ms
----
結果は 3 回計って (わたしの PC / i7 8700, RTX 2060S で) だいたい 4.6 秒、つまり、1 回あたりにすると 4.6ms かかっている、ということになります。
1ms は 1/1000 秒なので、5/1000 秒くらい無視してもいい処理時間では?と思われるかもしれません。たしかにこの処理が、初期化時の 1 回だけ呼ばれるものであれば、目くじらたてるほどではないと思います。
気をつけなければならないのは、ループ内や、毎フレーム実行されるような処理です。VR の HMD が 90Hz (1 秒間に 90 回画面更新) で動作している場合、1 フレームの全処理が 1000ms ÷ 90 ≒ 11ms に完了しないと処理落ちが発生してしまいます。これが 144Hz になるとさらに厳しくなり 7ms になります。そのような状況で、たった 1 つの関数で 4.6ms かかっていたら話になりませんね。
また、初期化時の 1 回だけとしても、チリも積もればで、何十もの mod がそれぞれ内部で何回もこのような呼び出しを行っていると、ゲーム (譜面) 開始時にカクっと、プレーヤーが体感できるレベルの処理落ちが発生してしまうかもしれません。
Object.FindObjectOfType
上記の FindObjectsOfTypeAll と似た関数ですが、こちらはアクティブなオブジェクトのみ 1 つだけを取得する、という形になります。こちらのほうが若干速いです。
----
setApplicationVersionText =
UnityEngine.Object.FindObjectOfType();
----
(1000回あたり)
3360ms
3194ms
3177ms
----
GameObject.Find + GetComponent
あらかじめオブジェクトが存在する GameObject の名前 (またはフルパス) がわかっていれば、GameObject.Find と GetComponent を組み合わせることで、前述の方法と比べて 100 倍以上の高速化になります。
----
GameObject g = GameObject.Find("Version");
setApplicationVersionText =
g.GetComponent();
----
(1000回あたり)
6ms
6ms
6ms
----
偶然、Version という名前で、関係のない GameObject が存在した場合、そちらがひっかかってしまう可能性があります。それを嫌う場合は、フルパスで指定します。
----
GameObject g =
GameObject.Find("Wrapper/MenuEnvironmentCore/PlayersPlace/Version");
setApplicationVersionText = g.GetComponent();
----
(1000回あたり)
15ms
15ms
15ms
----
上記の GameObject.Find はアクティブなオブジェクトしか検索できません。非アクティブの GameObject を拾いたい場合は、Scene.GetRootGameObjects のルートから、自力でフルパスをたどって検索します。フルパスで検索する場合は、(アクティブ、非アクティブに関係なく) GameObject.Find より自力のほうが高速になります (5ms)。
ちなみにフルパスは、下記のようなプログラムを書けばわかります。(Runtime Unity Editor を導入して検索するのもあり)
----
string GetFullPath(Transform transform)
{
string path = transform.name;
Transform parent = transform.parent;
while (parent != null)
{
path = $"{parent.name}/{path}";
parent = parent.parent;
}
return path;
}
setApplicationVersionText =
Resources.FindObjectsOfTypeAll()
.FirstOrDefault();
Logger.Debug(GetFullPath(setApplicationVersionText .transform));
----
(出力)
Wrapper/MenuEnvironmentCore/PlayersPlace/Version
----
アップデート対策
FindObjectsOfTypeAll よりも GameObject.Find + GetComponent のほうがはるかに高速なことが分かると思いますが、GameObject の名前やフルパスは、Beat Saber のバージョンアップで気まぐれに変更される場合もあります (クラス名自体も変更される可能性はありますが)。
アップデート対策として、まず GameObject.Find + GetComponent で取得してみて、見つからなかった場合は情報ログを出しつつ FindObjectsOfTypeAll で確実に取得、という二段構えにしておくと良いと思います。
ライブラリの使用
Zenject、SiraUtil、BSUtils、BSML などのライブラリで、必要なオブジェクトを効率的に取得できる場合もあります。わたしも自分の mod 用のライブラリを作成して、複数の mod で同じような処理を何度も実行する必要がないようにしています。
フィールドも取得してみます。さきほどの SetApplicationVersionText のバージョンを表示する TextMeshPro が protected フィールド _versionText に入っているので、こちらを取得するコードを考えます。
まず、通常の C# のリフレクションを使った方法です。なお、ここからは 1000 回だと 1ms 程度になってしまい差がでないので、1000000 回実行の時間を計測します。
----
Type type = setApplicationVersionText.GetType();
FieldInfo field =
type.GetField("_versionText", BindingFlags.Instance | BindingFlags.NonPublic);
textMeshPro = (TextMeshPro)field.GetValue(setApplicationVersionText);
----
(1000000回あたり)
922ms
929ms
900ms
----
BSIPA には、リフレクションによる取得をデリゲートで保持し、Dictionary としてキャッシュして、2 回目以降の実行を高速化してくれる仕組みがあります。こちらを使ってみましょう。これを使用するには using IPA.Utilities; が必要です。
----
textMeshPro =
setApplicationVersionText.GetField("_versionText");
----
(1000000回あたり)
2302ms
2295ms
2144ms
----
あ、あれ?遅くなっちゃった…。そんなばかなと思って何度も試しましたが、変化ないようです。リフレクションによるフィールド探索より、Dictionary の探索のほうが重いんでしょうか?あるいは、このキャッシュ処理、スレッドセーフになるように設計されているので、そのあたりが重い可能性もあります。
SetApplicationVersionText クラスの場合、フィールドは _versionText が 1 つあるだけなので、リフレクションの探索も比較的速いのかもしれません。まあ、そういうこともある、ということで…。
BSIPA には、リフレクションのデリゲートを変数として保持しておいて、そちらを呼び出す方法もあります。とくに理由がなければ、こちらを使うのが良いでしょう。
----
(クラスのメンバ変数として)
FieldAccessor.Accessor textMeshProAccessor = FieldAccessor.GetAccessor("_versionText");
----
textMeshPro = textMeshProAccessor(ref setApplicationVersionText);
----
(1000000回あたり)
2ms
2ms
2ms
----
この場合、クラスのメンバ変数が初期化されるときに、一回だけリフレクションの処理が走りますが、ループ内はデリゲートで比較的高速になります。
たとえば、先日わたしがリリースした NalulunaCutScore という mod を例にとってみます。この mod は、ノーツを切るたびにスコアが表示されては消える、という mod ですが、どのような処理が考えられるでしょうか。
シンプルに考えると、ノーツを切ったときに、スコアを表示する描画オブジェクトを作成し、時間経過でそれを破棄するような処理が考えられます。もちろん、この作り方でも意図通りに動くはずです。
ただ、ゲームのプレイ中、極力処理の負荷を減らしたいとき、描画オブジェクトの作成という比較的コストがかかる処理を行うことは避けたいですし、メモリの確保、破棄を繰り返すのも、プチフリの原因になるので、避けられるものであれば避けたほうが良いです。
この場合、あらかじめ描画オブジェクトを十分な数だけ前もって作成しておき、必要になったとき (この場合、ノーツが切られたとき) に、あらかじめ作成されたものを表示し、不要になったときは破棄せずに非表示にするだけ、という処理に置き換えることで、効率的に処理できるようになります。(ちなみに、前もって作成した数で足りなかったときは、仕方ないので随時追加で作成します)
わたしの (一時期以降の) mod では、こういった再利用できるオブジェクトはメニュー画面時に作成しておき、譜面の開始、終了時も再作成、破棄しないようにしています。根拠はないですが、Beat Saber を長時間プレイ継続しているとだんだん重くなってくるのは、譜面の開始、終了、リトライのたびに、Beat Saber 本体や mod が無数のオブジェクトの作成、破棄を毎回繰り返しているのが一因な気もするので…。
えらそうにテクニックの紹介をさせていただきましたが、実際のところ、わたしのすべての mod がこのように作られているわけではないのが恥ずかしいところです。特に初期に作ったものは、今から見ると非効率な部分があると思います。
いくらプレイ時間を重ねてもちっとも上達しない Beat Saber と違って(?)、プログラミングは時間をかければかけただけ知識が積み重なっていくものなので、今から考えると作り直したいと思っている mod も多いです。
今は、新しい mod かプラットフォームを毎月の特典としており、わたしの時間の大半がそちらに割かれていますが、今度いつかのタイミングで、今月の特典は新規 mod ではなく、この mod を一から作り直しさせてほしい、というお願いさせていただくかもしれません。だめかな…?
Neko-Hangten
2022-12-29 08:07:44 +0000 UTCJAN
2022-12-23 03:47:08 +0000 UTC