Amebientのベースシステム

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の動作自体がそこまでの精度を保証してないのだと私は解釈しました。解決することはできませんでした…。

[^1]: [日記 0611](https://phi16.hatenablog.com/entry/2020/06/11/031823)

&nbsp;
そして、それだけではありません。**VRChatには同期の概念があります。**各プレイヤーの時間軸を合わせなきゃいけないんです。
絶対時間 (`DateTime.Now`) を使いたいと最初は思っていましたが、コンピュータ間で全然違うこともわかってしまいました。確実な調整は諦めて、大まかに**展開が一致する**ことを目指すことにしました。
実際にやったのは**ワールドに入ったタイミングでmasterから同期信号を飛ばす**というものです。しかし`SendCustomNetworkEvent`には情報が乗りません。そこで、誰かがワールドに入ったときにmasterは「以降3回、8拍の切り替わりのタイミングで信号を送る」ということにしました。Amebientの時間軸は実は128拍で一周するようになっていて、**つまり16個のメソッドが生えています**。これを受け取ったnon-masterは時間軸にオフセットを乗せて解釈するようになります。
概ね合う…ように見えるんですが、まぁズレます。SendCustomNetworkEvent自体がそう早くないです。handshakeみたいなことをやろうかとも思ったんですがあまり意味ないかなと思った。
実際、ワールドに滞在しているとラグが発生するたびに時間軸はどうしようもなくズレていくみたいなのです。メニュー開いたらズレます。諦めです。

&nbsp;
で、これで良い…ように見えたんですが、このせいで**めちゃくちゃなバグを引き起こす**羽目になりました。**世界の時間が止まってしまった**のです。[^2]
AudioSourceの同時再生可能数には限度があり、それを越えた場合再生が停止します。**例えLoopが付いていても**。そして**人が居るとそれぞれにAudioSourceが付いています**。結果としてその時間を司るはずのAudioSourceが停止するので、全ギミックの動作が止まる (雨も空中で静止する) という現象が発生しました。ついでに「masterでの動作が止まる」のでlate-joinerは初期化信号を貰えず、永遠に停止したままにもなってました。そのせいで原因特定がちょっと遅れた…。
これに対してはこの大事なAudioSourceの**priorityをちゃんと0にしてあげる**ことで対応できました。そうやって使うんだね…。ちなみに代わりにメニューを開いたときの音が途切れるようになりました。知らん。

[^2]: [日記 0719](https://phi16.hatenablog.com/entry/2020/07/19/044352)

時間軸の整備に関してはこんなところですね…。この処理をするのが`Metronome`というUdonになってます。各楽器はこいつに「私はいつ鳴らすべきか」を訊くことで (できるだけ) 正確なタイミングで音を鳴らしています。

## 判定を取る

後はまぁ普通のUnityプログラミングです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88634/80be3278-f447-8b61-acde-faa68040829b.png)
雨が落ちる場所にはそれぞれ`Rain`というUdonが存在していて、天井から床までのCapsuleColliderが付いています。ちなみに原点の位置を指定すると勝手に伸びる最大長までのCapsuleColliderを生成する仕組みが`Rain Baker`というC#スクリプトに入ってます。
そして各楽器には`Percussion`というUdonが付いていて、AudioSourceをおおよそ2つ持っています (トタン板だけはでかいので3つ)。

「雨粒が楽器に当たる瞬間を知りたい」わけですが… まず原則として、**雨粒が当たる対象となる楽器は天井から下方にRaycastして最初に当たったもの**です。`Rain`は自身に衝突する物体が変化する度にRaycastをして衝突対象を保持しています。
**しかしそれだけではありません**。Amebientでは**水が汲める**のです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88634/24983a0b-90f7-aaef-a668-119a368fe18d.png)
この場合の衝突対象は**水面**です。だから唯の`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/s<sup>2</sup>で計算すれば落下時間がわかります。`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`) の描画側が自由に読めるようになっているのです。

[^3]: これは厳密には正しくありませんが、まぁそのうち書きます。

そうすると時刻を与えれば「今雨粒がどの位置に来て、跳ねてから何秒後か」が確定するので、うまくシェーダを書いてあげるといい感じになる、という実装をしています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88634/60b9b2fa-bd56-4a84-8978-eab06adc20a7.png)
ちなみに先の通りイベントは1フレーム間に1個しか消費されないので、物体を沢山の雨の近くでぶんぶん振り回したりすると消費の遅れが見えることがあるかもしれません。雨1個に対しては1個しかイベントが生成されないようにはなっていますけど… 実は「音を出したときの空気のパーティクル」もこのイベントの仕組みを使ってるので割と足りなかったりするんですよね。コンソールの`EventQueue`はこの残りイベント数のことだったりします。

そんなとこかなー…

あ、あとhapticsの話がありますね。pickupされている`Percussion`が音を鳴らす場合、**音が鳴るタイミングで**`PlayHaptics`を発行する必要があります (haptics欲しいですよね)。ただこれは当然`PlayDelayed`を発行したタイミングではないので、これもイベントリストを用意してちまちま処理しています。この処理は精度がそんなに要らないので`Time.time`を使ったりしていますね。リズムに合わせて楽器が振動するのを感じられるのは結構良い体験です。です。

## まとめ

一番難しかったのは時間を測るところで、それ以外は**ぐっと組む**みたいな感じですよね…。実際そこだけ検証を結構多めにやってました。

汎用的な動きをするように仕組みを作っておくと後からいろいろ付け足しやすくて便利なんですよね。…この記事で紹介したのは**基礎**の動きなので。次の私の回では基礎に収まらない諸々の挙動について書いていきます。