UniTaskを利用してメトロノーム(シーケンサー)を作ろうとして失敗した話

覚書です。それなりに自分なりに調査、検証をおこないましたが、間違っていることが含まれるかもしれませんので、随時修正が入るかもしれないことをご了承ください。(自信満々に間違ったこと書いてある記事も世の中に山ほどありますが!)

きっかけ

Unity界隈で以前からやたらに話を聞くようになったUniTaskについて知っておきたいと考えたときに、何か作ってみるのが一番早かろう、ということで思い立ったのがシーケンサーの制作でした。

ゲーム状態の更新は基本的に画面描画に同期したタイミング(=update関数)内でおこなえばよいわけですが、音楽に関わる発音処理だけは画面描画とは無関係に更新が必要です。(あとは物理計算など漸近的な処理もですが)

フレームレートのことを意識したプログラミングをしたことのある方であればご想像いただけると思いますが、上限60FPSでupdateが実行されたとしても、その間隔は16.66…msです。
発音タイミングがupdateタイミングから一瞬でも遅れると、次のタイミングまで16ms以上の遅延が発生することになります。
目視で16msの遅れを知覚することはなかなか難しいですが、不思議なことに、聴覚には16msの遅れはかなりはっきりと知覚できます。

なので、描画タイミングとは無関係に処理実行が可能、つまり非同期処理がシーケンサー制作の実装のためには向いているのでは、というのが思い立った理由です。

余談ですが、60FPSのゲームで30フレームごとに1拍発音するとBPM120、24フレームごとだとBPM150、20フレームごとだとBPM180になるため、ファミコンのようにCPUで音を鳴らしているハードのゲームではこれらのBPMがよく使われていました。(処理落ちすると曲が遅くなるゲームがあるのはそのせいです)
さらに1拍を16分音符4つや3連符で鳴らすことを考えると、1拍のフレーム数が3と4の公倍数であるBPM150や180は扱いやすいです。

考え方のアプローチ

Unityでメトロノームを制作する場合、一般的には「曲のテンポとは同期していないupdate関数内で、CPU時間を測り、AudioSource.PlayScheduled()を使って発音タイミングを調整する」という方法が定石になるかと思います。 (こちらの記事を参考にさせていただきました)

今回は「UniTaskを利用した非同期処理の実装を知る」ことが目的ですので、「曲のテンポと同じタイミングで更新される非同期処理を実行し、その中で発音する」という逆のアプローチで実現しようとしました。

で、結論

結論なんですが、「UniTaskではupdateタイミングを無視した非同期処理は書けない」という結論が自分の中では導き出されました。
UniTaskのドキュメントにも、「UniTaskはPlayerLoop上で実行される」と書かれているので、つまりはそういうことかと思いますが、Unityのupdate関数やfixedUpdate等、(自分で選ぶことはできますが)決められたタイミングでUniTaskの状態が更新されるようなので、(やりようはあるのかもしれませんが)updateタイミングの制約から逃れることはできませんでした。

実際に、

{
    Application.targetFrameRate = 60;                     //16.6msごとにupdate
    var delayTask = UniTask.Delay(125);                   //125ms待つ非同期処理開始
    double start = Time.realtimeSinceStartupAsDouble;     //処理計測開始
    await delayTask;                                      //呼び出し元に処理を戻して非同期処理の完了待ち
    double end = Time.realtimeSinceStartupAsDouble;       //処理計測終了
    UnityEngine.Debug.Log($"elapsed time:{end - start}"); //計測結果出力
}

という感じのコードを書いて試してみましたが、Delayに125msを指定しているにも関わらず、出力された処理時間は

elapsed time:0.132949700000609

になりました。(単位はmsです)
多少の誤差はありますが、これは16.6…の倍数の133.3…に近い値です。
なまじ、DelayFrame()とDelay()の両方が用意されているため、Delay()の方は正確にms単位で遅延できるのかしらと思いましたがそれは単なるミスリードでした。

分かったこと・教訓

詳しいことはまたちゃんとした記事にしたいとは思っているのですが、今回いろいろ調査してみて分かったことです。

  • ゼロからUniTaskのことを知りたいのであれば「UniTask」で検索する前に「async await C#」で検索しろ

    • 非同期処理の本質を無視した「UniTaskを使えばこんなことが!」な記事が多すぎて、本質理解のためにはほとんど参考にならない

    • 「UniTask」という言葉がバズワードっぽく使われてしまっているために遠回りしてしまったが、本質的にはC#のTaskとほぼ同じと考えてよいので、そちらの情報を漁った方がいい

  • UniTaskで絶対時間的なタイミング管理(=ゲーム的に重要なタイミングを司る処理)には使うな

    • 先述の通り、定期的に繰り返し実行したい処理の場合、何も考えずに実装するとDelay時間のズレが増幅していく

      • で、そういうのをちゃんとしようとすると、結局コード量は同期処理で実装するのとそれほど変わらなくなってくる

    • なので、素直にupdate内でタイマー変数を管理した方が、処理落ちが起きた場合や、後々の仕様変更や調整には強い

    • ロード待ち、通信待ちのように、毎回処理時間が安定しない(かつ基本的にエラー以外でキャンセルする必要がない)ようなバックエンド処理を裏で回しつつ何かしたいときの終了待ちや、いろんなところから自由に呼び出される(同じプロジェクト内で他人が意識せず書いている)処理に対するイベントトリガーとしての使い方が基本になると思われる

さいごに

繰り返しになりますが、大した時間もかけずに調査した結果を記事にしているので、日常的にUniTaskをバリバリ使っている人からすれば、ツッコミどころも多い記事かもしれません。
ただ、今回思ったこととして、「やっぱりWebの情報だけでは限界がある」というのがありますので、詳しい方にぜひお話を聞いてみたいとも思いました。
ぜひマサカリを投げていただけると幸いです。


余談

ちなみに、調査し始めの頃にチャットAIにUniTaskを使ったコードを生成させてみましたが、プロンプトが悪かったのか、私の理解が不十分だったのか、警告がわんさか出るコードを吐いたので捨てました。
ある程度理解した上であれば自分で直して使えるかもしれませんが…
そう考えると、どうせAIに書かせるようになってくるのだから後でデバッグしやすい書き方でいいのでは、みたいな気持ちにもなりました。

参考にしたサイト

「非同期処理」という言葉自体の意味が分かっていることが前提です。
それについての説明ページは端折っています。

必要最低限のことがまとまっていますので、最初はこちらから読むのがよいかと。

理解すべき重要事項」という項目で、async、awaitが一体何をするためのものなのか、という最初の一歩の話が書かれています。

async/awaitを使用した際のコードの処理の流れが図解されています。


この記事が気に入ったらサポートをしてみませんか?