phi16です。Amebient Advent Calendar 3日目です。ベースシステム、つまり「水滴が金属に当たって音がなる」部分の実装の話を書きます。今までの記事とは大きく変わって、ほぼプログラム設計の話です。
時間を測る
決まったタイミングで音を鳴らす為には、ちゃんと時間を測れなければいけません。ちゃんとっていうのは、音が綺麗にリズムに乗るようにという意味です。
まず、Unityで音に関する処理を行っている仕組みは**Time.time
とは違う時間軸を持っています。つまりTime.time
は使ってはいけません。実際ラグが生じるたびに段々ズレるんです、これ。
音の時間を司る軸の名前はAudioSettings.dspTime
**なんですが、なんとこれはUdonで使えません。まぁ理由はわからないでもないです。
よってまず時間軸の整備からやらないといけなかったのです。
Amebientで行った方法は、5秒の無音音声を永遠にループさせ続けるというものです。再生中のAudioSourceにはtimeSamples
という名前で音声データのサンプル数を得る方法が用意されています。これが段々増えていく様子を監視しておくというわけです。
ループが起きると「前回からの差分」がほぼ**-5秒になるので、それを検知して (-2.5秒以下の場合に) ループ回数のカウンタを回しています。
これで確かに時間軸になってくれるんですが、ただ、これに合わせてPlayDelayed
を行ってみても0.02秒**ズレることがあります1。これは恐らくUnityの動作自体がそこまでの精度を保証してないのだと私は解釈しました。解決することはできませんでした…。
そして、それだけではありません。VRChatには同期の概念があります。各プレイヤーの時間軸を合わせなきゃいけないんです。
絶対時間 (DateTime.Now
) を使いたいと最初は思っていましたが、コンピュータ間で全然違うこともわかってしまいました。確実な調整は諦めて、大まかに展開が一致することを目指すことにしました。
実際にやったのはワールドに入ったタイミングでmasterから同期信号を飛ばすというものです。しかしSendCustomNetworkEvent
には情報が乗りません。そこで、誰かがワールドに入ったときにmasterは「以降3回、8拍の切り替わりのタイミングで信号を送る」ということにしました。Amebientの時間軸は実は128拍で一周するようになっていて、つまり16個のメソッドが生えています。これを受け取ったnon-masterは時間軸にオフセットを乗せて解釈するようになります。
概ね合う…ように見えるんですが、まぁズレます。SendCustomNetworkEvent自体がそう早くないです。handshakeみたいなことをやろうかとも思ったんですがあまり意味ないかなと思った。
実際、ワールドに滞在しているとラグが発生するたびに時間軸はどうしようもなくズレていくみたいなのです。メニュー開いたらズレます。諦めです。
で、これで良い…ように見えたんですが、このせいでめちゃくちゃなバグを引き起こす羽目になりました。世界の時間が止まってしまったのです。2
AudioSourceの同時再生可能数には限度があり、それを越えた場合再生が停止します。例えLoopが付いていても。そして人が居るとそれぞれにAudioSourceが付いています。結果としてその時間を司るはずのAudioSourceが停止するので、全ギミックの動作が止まる (雨も空中で静止する) という現象が発生しました。ついでに「masterでの動作が止まる」のでlate-joinerは初期化信号を貰えず、永遠に停止したままにもなってました。そのせいで原因特定がちょっと遅れた…。
これに対してはこの大事なAudioSourceのpriorityをちゃんと0にしてあげることで対応できました。そうやって使うんだね…。ちなみに代わりにメニューを開いたときの音が途切れるようになりました。知らん。
時間軸の整備に関してはこんなところですね…。この処理をするのがMetronome
というUdonになってます。各楽器はこいつに「私はいつ鳴らすべきか」を訊くことで (できるだけ) 正確なタイミングで音を鳴らしています。
判定を取る
後はまぁ普通のUnityプログラミングです。
雨が落ちる場所にはそれぞれRain
というUdonが存在していて、天井から床までのCapsuleColliderが付いています。ちなみに原点の位置を指定すると勝手に伸びる最大長までのCapsuleColliderを生成する仕組みがRain Baker
というC#スクリプトに入ってます。
そして各楽器にはPercussion
というUdonが付いていて、AudioSourceをおおよそ2つ持っています (トタン板だけはでかいので3つ)。
「雨粒が楽器に当たる瞬間を知りたい」わけですが… まず原則として、雨粒が当たる対象となる楽器は天井から下方にRaycastして最初に当たったものです。Rain
は自身に衝突する物体が変化する度にRaycastをして衝突対象を保持しています。
しかしそれだけではありません。Amebientでは水が汲めるのです。
この場合の衝突対象は水面です。だから唯のRaycast
ではダメで、RaycastAll
をやって、金属か水面で最も近いものを計算しています。
さらに、実はこれは両立します。あまり気づかれてないと思うんですが、「めちゃくちゃ薄い水面に落ちた水滴」は金属音も水音も鳴らします (鳴りそうだったので)。具体的には1cmの深さまでは金属まで届きます (cross-fadeします)。その為に「金属の衝突点」と「水面の衝突点」を分離して持っていたりします。
そんなこんなで衝突対象がわかります。負荷軽減の為に色々やってるっぽいけどそれはそのうち書きますね。
追記: 12/12のアップデートでちょっと壊れたのを治したのでそれについて。
Rain
もPercussion
も、まず自分に当たる可能性のある物体の候補をOnEnterTrigger
とOnExitTrigger
の追跡によって保持しています。
しかし「最初から重なっている場合」、Udonの起動が終了する前に判定が発生するのでイベントが呼ばれないことがあるのです。
それを防ぐ為に元々Rain
のCapsuleColliderはdisableにしておいて、Start
時にenableにする処理をしていました。
が、これでは**Rain
は起動したがPercussion
が起動していない場合にPercussion
側でOnEnterTrigger
が発生しないことになってしまいます。
長いことこのケースが発生していなかったみたいなんですが、今回の変更で何故か**恒常的に発生するようになってしまったっぽいです。
対処としてはPercussion
のMeshColliderにも同じ仕組みを入れること。元々Udon上では入ってたんですけど、デフォルトでMeshColliderがenableになっていたので働いていなかったようでした。
リズムを取る
まず、各Rain
は自身がどのタイミングで水滴を落とすかを知っています。この情報はリズムタイプと位相によって識別され、例えば「16拍に1回」「4拍周期の特定パターン」みたいなのが20通りくらいあって、さらに位相情報によって「16拍周期、先頭から3拍目」みたいなことが出来ます。
加えて、ある落下距離で丁度のタイミングで音を鳴らす為に、Rain Baker
が自動的に「天井からRain
オブジェクト原点までの距離」に応じて位相を調節しています。エントランスの音楽は最初のあの乱雑配置で丁度になるるように出来ていますが、他の雨はY=0
で丁度になるものが殆どだったりします (雷のタイミングと合致するんですよ)。
さて、これを使って音を鳴らします。衝突位置がわかると天井からの距離がわかるので、重力加速度9.8m/s2で計算すれば落下時間がわかります。Rain
そのものの位相と足し合わせてオリジナルのリズムデータと比較、次に鳴らす拍を計算することが出来ます。
Percussion
は「自身に初めに衝突するRain
」全てに対してこれを計算し、その中でも最も早いRain
を候補として選定。そのタイミングで鳴らすことにするわけですが…
まず音のタイミングを指定して鳴らしたい場合、一般にできる限り早くスケジューリングすることが望ましいです。ボタン押下くらいならPlayOneShot
でいいんですが、リズムに乗せる場合は精度が足りません (高々0.01秒です)。
なのでPlayScheduled
によって時間を絶対指定したいんですが、UdonだとAudioSettings.dspTime
を使えないのでPlayDelayed
で代用します。これは大した問題ではありません。
この「できるだけ」早く再生したいという気持ちに対し、衝突直前に楽器を移動したりすることの方が問題で。スケジュールされた時には確かに再生されるはずだったんだけど、プレイヤーのインタラクションによってスケジュールが崩れうるのです。それはそうです。
Amebientでは0.1秒後までのスケジュールをするようになっています。つまりその間に缶を動かしても、当たったことになっちゃいます (逆に当たらないことになることはあんまりないと思う)。精密にやるならスケジュールのリセットをするべきかもしれませんが、そう簡単でもないですからね… (例えば缶を上に動かしたらスケジュールを早めないといけないんですよね)。まぁ、問題ない範囲だと思います。
そうやって音を鳴らしたら、雨の最終発音時刻を更新。リズムを計算する際には最終発音時刻と現在時刻より後で、一番最初の発音を計算するようにしています。
この工程は延々とUpdate
内で行われていてアレなんですがこれについてもそのうち書きますね…。
音と見た目
金属音はPercussion
、水音はRain
が担っていて (Rain
もAudioSourceを持っています)、各々が鳴らすタイミングがわかるとMetronome
に再生を依頼します (Metronome
で遅延量の再計算を行うので、Udonのラグを減らすためにAudioSourceを直接渡しています)。ちなみに「AudioSourceを持っている」っていうのはcomponentが付いているのではなくAudioSourceの付いた子GameObjectを持っているという意味で、各AudioSourceは音再生直前に水滴の衝突位置に移動しています。スケジュール対象となるAudioSourceは「今暇なやつ」(isPlaying
がfalse
)を適当に選んでいます (無かったら鳴らすのを諦めます)。
あと衝突点の法線 hitInfo.normal
を使って「傾いていると音が小さくなる」ようにもなってたりしますね。
ちなみに水音はRain
によって音の高さが違ったりします。これはRain Baker
がランダムに割り振るのでワールドとしては固定ですね。
さて、音は出せたんですが見た目もやらなきゃいけません。細かい話はそのうち書くんですけど、Amebientのあの雨の見た目は全部GPUがやってます。お分かりの通り、ですけど。
そしてパーティクルシステム的なものでもありません。あれは時刻に対して見た目が確定する物体なので3、その必要がないのです。
Rain
はRaycastによる衝突計算の結果をWaterServer
というUdonに送信します (ギャグネームね)。こいつはイベントをキューとして管理して、1フレーム毎にCustomRenderTextureのパラメータとしてイベントを送信していきます (そうでしか送れないので)。このイベント情報には「衝突距離」と「衝突法線」が入っていて (ちなみに「水面であるかどうか」は衝突法線の情報に紛れ込ませてあります)、それを雨 (DropRain
) の描画側が自由に読めるようになっているのです。
そうすると時刻を与えれば「今雨粒がどの位置に来て、跳ねてから何秒後か」が確定するので、うまくシェーダを書いてあげるといい感じになる、という実装をしています。
ちなみに先の通りイベントは1フレーム間に1個しか消費されないので、物体を沢山の雨の近くでぶんぶん振り回したりすると消費の遅れが見えることがあるかもしれません。雨1個に対しては1個しかイベントが生成されないようにはなっていますけど… 実は「音を出したときの空気のパーティクル」もこのイベントの仕組みを使ってるので割と足りなかったりするんですよね。コンソールのEventQueue
はこの残りイベント数のことだったりします。
そんなとこかなー…
あ、あとhapticsの話がありますね。pickupされているPercussion
が音を鳴らす場合、音が鳴るタイミングでPlayHaptics
を発行する必要があります (haptics欲しいですよね)。ただこれは当然PlayDelayed
を発行したタイミングではないので、これもイベントリストを用意してちまちま処理しています。この処理は精度がそんなに要らないのでTime.time
を使ったりしていますね。リズムに合わせて楽器が振動するのを感じられるのは結構良い体験です。です。
まとめ
一番難しかったのは時間を測るところで、それ以外はぐっと組むみたいな感じですよね…。実際そこだけ検証を結構多めにやってました。
汎用的な動きをするように仕組みを作っておくと後からいろいろ付け足しやすくて便利なんですよね。…この記事で紹介したのは基礎の動きなので。次の私の回では基礎に収まらない諸々の挙動について書いていきます。