Amebient Advent Calendar 19日目。Udon周りの話をします。
初期実装
今までの記事で長々と実装方針を書いてきたわけですが、これらをナイーブに実装するととても無駄が多いです。ナイーブさにも依りますが。
かわいいね pic.twitter.com/cHTK53xnu6
— phi16 (@phi16_) July 17, 2020
18.1msは大分やばいです。そうだね。
いまここ pic.twitter.com/rUDbmRcfLq
— phi16 (@phi16_) July 17, 2020
諸々弄ってそこそこ減らしました。完璧ではないとは言え、まぁ努力がある程度報われたくらいの領域です。
一般に、高速化をする際には何らかの「仮定」が必要です。こういう条件が成り立つから処理を省いて良い、という考え方をします。
今回Amebientで行うこととなった「仮定」は以下です。
- 楽器に当たってない雨・雨に当たってない楽器は何もしなくて良い
- 楽器や水面(水量)はpickupされてなければ動かない
- 楽器と雨の組み合わせが固定なら鳴るリズムも同じ
- これはバルブから出る水滴に関しては成り立ちません
- 多くの楽器の移動が同時に発生することはあまり無い
- バケツや飯盒や水面の揺れはそのうち収束する
- 世界遷移は滅多に起きない
こういう暗黙知を実際に顕在化させることが、エンジニアリングです。多分。
Updateの削減
前述の仮定は「常時行わなければならない処理はそう多くない」ことを導きます。各UdonBehaviourではUpdate
内で延々処理をしていたんですが、それらを削減することを考えてみることにします。
例えばバケツの取っ手。率直に書くと毎フレーム物理計算をすることになりますが、概ね静止しているので処理は削って良いはず。
そして動き出す瞬間はpickupされたタイミングです。とは言え、他人から見るとそれは検知できない (イベント送れば別だけど) ので「楽器の位置が変化したタイミング」を使うことにします1。
Vector3 moveDirection = center - nextCenter;
if(stability > 10 && moveDirection.magnitude < 0.0001f) {
return;
}
center = nextCenter;
安定性パラメータstability
を導入して、「動いてるときは0
、静止していると増加」させることにしました。バケツの取っ手が静止していることは「角度変化がほぼ無いこと」で判断できます…が、どうやら私は「角度が限界まで来ていること」で判断しているようです。なんでだろう2。
静止してそうならstability
を0
に。そうでないなら1
増やす、ことで静止時にUpdate
内の処理の殆どがスキップできるようになります。物理演算系だとsleepとか言ったりしますね。バルブ (ValveHandle
) にも同じ様な処理が入っています。
他にも鉄パイプ (Mallet
) はpickup
されてなければ何も処理しない3とか、静止した金属 (Instrument
) は「音を発音する予定が無い」なら何も処理しないとか (鉄パイプで叩かれたときのSendCustomNetworkEvent
で起きます) やっています。
配管が鳴らなかった原因 pic.twitter.com/F1JS3cVtKk
— phi16 (@phi16_) July 18, 2020
この辺の処理は条件の優劣関係をそこそこ考えないといけないので大変です (ちゃんと考えればいいだけですけど)。これは全ての初期化を行う前に「音発音予定かどうか」をチェックしていて、音発音は初期化が行われないと実行されないようになっているので、何も行われなくなったというやつです。かなしいね。
リズムに乗った環境音 (RhyAmbience
) でも似たようなことをします。これは発音タイミングが確定しているので、次の発音直前まで何もしなくて良いんです。
if (minDelay >= 1.0f) {
waitTime = Time.time + Mathf.Min(2, minDelay) - 0.9f;
return;
}
次回の発音が1秒以降である場合、最大1秒待機します。…なんでだろう。変化が発生するとしたら世界状態が変わる時、ではあるんですけど雷の音は「世界状態が変わった64拍後」くらいに起きるはず。恐らくTime.time
の精度がちょっと怖かったんだと思います (ラグる度に音声系とズレるので)。もうちょっと長めに時間取っても良かった気はする。
まぁ最大のボトルネックは勿論Percussion
です。今までの処理軽減系が大体入っています。
- 世界が終わってたら処理終了
- 雨に当たっておらず、鉄パイプの衝突も無ければ処理終了
- 次に発音するタイミングの直前4まで処理をしない
しかし**Percussion
は突然状況が変わることがあります**。顕著なのがバルブの水量変化。または「上方にあるPercussion
が抜かれて突然自身に雨粒が衝突するようになるケース」とか。
丁寧に「関わりそうなGameObjectに対してイベントを飛ばす」のもありですが、面倒だったのでシーンに存在するPercussion
全てにイベントを飛ばすことにしました。ついでにRain
にもイベントが飛んでいます。…軽減した方が良かったかもしれない。まぁ実装した時にはそほど問題ないと判断したんだと思います。
雨に当たっているPercussion
は、自身の移動を検知するとMetronome
にイベントを飛ばし、それはいろんな対象にブロードキャストされます。Percussion
を移動している時間はさほど多くないという判断です。
少なくともこの辺の処理をする前よりは大分軽くなりました。
実際に最終的なプロファイルを見るとこんな感じになってます。…ひどいですね。
極稀に動くUdonBehaviourでも、まずUdonを起動するコストがあるのでそれすら無いことが勿論望ましいです…。
Update
を各々に書くのではなく誰かにまとめて発行してもらう (ことでオンオフ制御を丁寧にやる) ことも出来はすると思うんですけど…。「起きなければならない条件」みたいなものを丁寧にまとめて上げないといけないのでちょっと複雑です。
まぁ緩めにしても大きな削減にはなりそうだな…。ちょっと今度やろうかな…。
相互作用
複数のUdonが協調動作をすることがそこそこ求められている本作ですが、一般にUdonから他Udonを呼ぶのはちょっと悩ましいものです。
public class A : UdonSharpBehaviour {
public int F(int x) {
return x;
}
}
public class B : UdonSharpBehaviour {
private A a;
private int u, v;
private int F(int x) {
return x;
}
public void G() {
u = a.F(0);
v = F(0);
}
}
2つの異なるF
の呼び出しは、次のようにコンパイルされます。
# u = a.F(0);
PUSH, a
PUSH, __0_const_intnl_SystemString
PUSH, __0_const_intnl_SystemInt32
EXTERN, "VRCUdonCommonInterfacesIUdonEventReceiver.__SetProgramVariable__SystemString_SystemObject__SystemVoid"
PUSH, a
PUSH, __1_const_intnl_SystemString
EXTERN, "VRCUdonCommonInterfacesIUdonEventReceiver.__SendCustomEvent__SystemString__SystemVoid"
PUSH, a
PUSH, __2_const_intnl_SystemString
PUSH, __0_intnl_SystemObject
EXTERN, "VRCUdonCommonInterfacesIUdonEventReceiver.__GetProgramVariable__SystemString__SystemObject"
PUSH, __0_intnl_SystemObject
PUSH, __0_intnl_SystemInt32
EXTERN, "SystemConvert.__ToInt32__SystemObject__SystemInt32"
PUSH, __0_intnl_SystemInt32
PUSH, u
COPY
# v = F(0);
PUSH, __0_const_intnl_SystemInt32
PUSH, __0_x_Int32
COPY
PUSH, __0_const_intnl_exitJumpLoc_UInt32
JUMP, 0x00000008
PUSH, __0_intnl_returnValSymbol_Int32
PUSH, v
COPY
変数の場合はこう。
# u = a.x;
PUSH, a
PUSH, __0_const_intnl_SystemString
PUSH, __0_intnl_SystemObject
EXTERN, "VRCUdonCommonInterfacesIUdonEventReceiver.__GetProgramVariable__SystemString__SystemObject"
PUSH, __0_intnl_SystemObject
PUSH, u
EXTERN, "SystemConvert.__ToInt32__SystemObject__SystemInt32"
# v = x;
PUSH, x
PUSH, v
COPY
Udonは唯のVMで、最適化がばりばり行われるとかもありません。**命令数が速度に直結します。**加えて、関数呼び出しから発生するSendCustomEvent
は「相手のUdonBehaviourの起動」も行います。
つまりは相互作用にはランタイムのコストがめちゃ掛かるということです。でもそれは適切に分割されたソースコードの生む開発効率の向上との相関です。私は最適化の為に構造を壊すようなことはしたくないので、これらのコストは受け入れた上で、できる限り軽減することを考えました。…そんなにちゃんとはしてないけど。
例えば「全ての初期化」。AmebientではMetronome
が全Udonの総括をしていますが、UdonBehaviourの初期化順は制御可能なものではありません。よって各UdonはMetronome
が起きたことを検知する必要があります。
まずそれぞれのUdonは単純にGameObject.Find("Metronome").GetComponent<Metronome>()
によってMetronome
の参照を拾っています。わざわざ手で割当したくはなかったし、もうちょっと厳格にチェックするべきかと考えてもまぁどうせStart
時に読むだけだからアバターの影響は恐らく少ない。いまのとこ動いてるし。
そしてUpdate
内で次のコードが動いています。
if(!initialized) {
if(metronome.initialized) {
initialized = true;
} else return;
}
まず毎フレーム動かしたくはないです。でもそれをしないのは結構難しいと思う。いつMetronomeの初期化が終わるかというのはUdonの初期化以上に「いつmasterから開始信号が送られてくるか」でもあるので、やるならMetronome
が各Udonに向かって信号を発信しなきゃいけない。
でも誰に発信すべきか、というのは指定が難しくて… まぁシーン上に存在する全てのUdonBehaviourにイベントを飛ばすのはありかもしれない。それ以外は出来ないと思います、各Udonは自分自身の存在をMetronome
に伝えることが出来ないのです (Metronome
の初期化が遅い可能性がある為)。
…あ、でも、未初期化のUdonのpublic変数にちゃんと書き込みができるなら (これ がちゃんと治ってるなら)、外部からMetronome
の持つ「初期化対象リスト」をがしがし編集しちゃう、っていうのもありなのかもしれない…。
まぁ、それは次何か作る時に考えるとして。
毎フレーム動かすとなると、当然そのコストが小さいほど嬉しいです。metronome.initialized
には一度trueになったら以後trueという法則があります。この仮定によって、「自前でinitialized
という変数を持っておいて以後それを参照する」ことができます。命令数は半分(5/14)くらいになります5。
これは「全てのUdonにわざわざinitialized
変数と更新検知機構を入れる労力」との対価なので、まぁ見合うのならやるといいのだと思います。
そしてもう一つ。Metronome
の初期化以外の状態検知に関しては、Udonの実行順とかは関係無い話になります。
「世界状態の変化を検知する」のは別Udonの変数を読みに行っても可能ですが、それ以上に変化させた主体が検知を通達するのが自然と言えます。
要はpull型とpush型というやつです。特にpollingするとコストになるのでやっぱりpush型が望ましいと思うのです。
というわけで、起動した各UdonはMetronome
に「自身をListenerとして登録」してもらいます。
public void AddListener(UdonSharpBehaviour b) {
if(listenerCount >= listeners.Length) {
bool[] us = new bool[listenerCount*2];
UdonSharpBehaviour[] bs = new UdonSharpBehaviour[listenerCount*2];
for(int i=0;i<listenerCount;i++) {
bs[i] = listeners[i];
}
listeners = bs;
}
int ix = listenerCount;
listeners[ix] = b;
listenerCount++;
}
private void Emit(string name) {
for(int i=0;i<listenerCount;i++) {
listeners[i].SendCustomEvent(name);
}
}
これは可変長配列の実装も含んでおりますね。まぁこうして好きな相手に好きなタイミングでメッセージを送ることができます。
例えば「楽器の配置が変わった」「バルブの水量が変わった」「世界遷移が発生した」「世界が終わった」「水没した」とか色々あります。各Udon側は好きにpublicメソッドを定義しておくだけです。
**まぁ今考えるとSetProgramVariable
を直接叩いたほうが速い気がしますけど。**実行速度的な意味で。
それこそデータ渡せるしな…。次に作る場合の反省点がどんどん溜まってきますね。
というか高速化もうちょっと頑張っていいんじゃないか?という気がしてきた。まだまだやれますね…。
全体的に丁寧にアセンブリを見るか丁寧にprofilerを見るのが何よりも正しいと思います。
まとめ
大枠の話に留まってしまいましたが、まぁ思想面の話として…。
Rain
の内部処理では「複数の楽器が同時に動くことは少ない」ことから「1F中に更新チェックする楽器は1つにする」みたいなことをやってたりもしてますが、まぁ小さな話…。それが高速化に寄与しているかは正直わかりません。
ギミック屋 (?) としてはギミックの重さで体験が阻害されるのは大分悲しいと思うのでもうちょっと軽くしてあげたかった気持ちはすごいあります…。
自分でこの文章書いてて色々改善点見つけてしまったのでいつか治したいです。まぁ描画コストも相当あるのでこういうところだけではないんですけどね…。どっちも作ったの私だが。
おわりです。