Aizu-Progressive xr Lab blog

会津大学のVR部であるA-PxLの部員が持ち回りで投稿していくブログです。部員がそれぞれVRに関する出来事やVRにちなんだことについて学んだことを書いていきます。

「面白法人カヤックVR分室」としても活動しています。詳細はこちら

UniTaskとUniRxで、待ちの処理を実装する(前編:UniTask)

来年度で学部4年になるユムルと言います

UnityのC#でUniTaskとUniRxという便利なライブラリがあるので、
前編と後編に分けて紹介したいと思います

最初に断っておきますが、取りあえずC#を使って、Unity上でプログラムを書ける人向けの記事です

ラムダ式

前提知識としてラムダ式が必要なので説明します
次の構文がこの記事では何回か登場するので、その時は理解できるまでここに戻ってきて欲しいと思います
引数 => 処理と返り値
この構文は、関数を簡単に書く書き方です
関数を定義するところの引数に当たる変数を=>の左側に書いて、処理と返り値は右側に書きます

x => x*2; // 引数がxの変数に渡され、x*2を返す
() => 3; // 引数が無い場合は()で表す
() => { // 処理を挟みたければ{}で囲む
  var a = 3;
  return a; // returnで値を返す
};
(a, b) => { // 引数が複数ある場合は括弧とカンマで指定
  var c = a + b; 
  print(c); // 返り値が必要なければreturnはいらない
};
() => print(0); // 返り値も{}も無いバージョン

引数や返り値の型はどうするの?ってなりますが
ラムダ式とは別のところで指定されているので、
そういうところで使える構文という事です

前編で紹介するのはUniTaskです
もともとプログラミングでasync/awaitという非同期処理の為の構文で、
C#でも言語機能として使えるものがあるのですが、
それをUnityに特化させて便利にしたものになります。

async/awaitというのはUniTaskと同時にここにまとめますが、
その前に非同期処理について

非同期処理

非同期処理というのは、簡単に言えば何か別の事を待って行う処理になります
まず同期処理というのが、プログラムを書いた順番に処理がされていくものです
それに対して非同期処理になると、必ずしも処理を順番に行うわけではなくて
条件が整うのを待って処理を行う必要がある場合に、別の処理をしようという"待ち"が絡んだ処理の事です
例えば、ゲームではグレネードを投げた場合、
時限式ならば一定時間後に
接触式なら別の物に衝突した場合に
爆発するわけですが、
この場合それぞれ、一定時間が経つ, 衝突するという待ちの動作が必要になります

これらの動作を適切に行うのが非同期処理になります

これから紹介するUniTaskはこの非同期処理を効果的に実装するための便利なライブラリです

UniTask

ではUniTaskについて解説をします

ライブラリの導入はこのリンクからダウンロードできるUnityPackageから
Releases · Cysharp/UniTask · GitHub
まだAssetStoreにはありません

このライブラリの機能を使う為には、C#ソースコードのusing UnityEngine等が並んでいるところに、
using UniRx.Async; が必要です

まず、一番最初の例として2秒後にprint出力をさせます

void Update() {
  if (Input.GetKeyDown(KeyCode.A)) {
    Method();
  }
}
async UniTask Method() {
  await UniTask.Delay(2000); // 千分の一秒単位で2秒
  print("2秒たった");
}

これでAキーを押してから2秒経つとprint出力できます
連打したら、すべての連打に対して2秒ずつ待って出力してくれます

メソッドの最初にasyncを付けることで、
そのメソッドは待ちの処理をする事ができる
awaitを付けるとその文で処理が終わるのを待つよという意味になります

using System;
をファイルの最初につけて、 UniTask.Delay(TimeSpan.FromSeconds(2f)
と書くと、ミリ秒ではなくて普通に秒数で指定できます
using System; を書いてしまった場合、UnityEngine.RandomSystem.Randomが競合してRandom.valueがそのまま書けなくなってしまうので
using Rand = UnityEngine.Random;も書いておくと
Rand.valueと書けるようになります

UniTaskには便利な機能があって、 その機能を使うと、Aキーを押したあとに、B、C、D、キーを順番に押すと処理が行われる等の処理も簡単に書くことが出来ます
ラムダ式使ってるので分からなければこの記事の最初に説明が書かれています。ここでは引数無し、boolを返すラムダ式です

void Start() {
  Method();
}
async UniTask Method() {
  await UniTask.WaitUntil(() => Input.GetKeyDown(KeyCode.A));
  await UniTask.WaitUntil(() => Input.GetKeyDown(KeyCode.B));
  await UniTask.WaitUntil(() => Input.GetKeyDown(KeyCode.C));
  await UniTask.WaitUntil(() => Input.GetKeyDown(KeyCode.D));
  print("A, B, C, Dキーが順番に押された");
}

WaitUntilは独自にUpdateのタイミングで条件判定を行ってくれます

また、async/awaitは値を返す事もできます

void Update() {
  if (Input.GetKeyDown(KeyCode.A)) {
    // ラムダ式にもasyncを付けれる
    UniTask.Void(async () => {
      var time = await GetTimeUntilKeyPushDown(KeyCode.B);
      print("Aを押してからBを押すまで" + time + "秒");
    });
  }
}
async UniTask<float> GetTimeUntilKeyPushDown(KeyCode code) {
  var from = Time.time;
  await UniTask.WaitUntil(() => Input.GetKeyDown(code));
  return Time.time - time;
}

UniTask<返り値の型>と書くことでasync内での値を
return 返り値の型の値;
で返すことが出来ます
UniTask.Voidは、UniTask型を返すラムダ式を渡すことでasyncの関数を実行することができ、その返り値は無視します。

async関数はループさせる事ができます

void Start() { Loop(); }
async UniTask Loop() {
  await UniTask.WaitUntil(() => Input.GetKeyDown(KeyCode.A));
  print("Test");
  Loop();
}

これでAを押すたびに出力がされるのが確認できます

ところで、awaitでは何を待っているのだという疑問がありますが
それは、UniTaskの値が確定されるのを待っています
従って、次のように書けば

UniTask<int> waitValue;
void Start() {
  waitValue = new UniTask(async () => {
    await UniTask.Delay(2000);
    return 3;
  });
  Test();
}
async UniTask Test() {
  var v = await waitValue;
  print(v);
}

2秒後に3が出力されるのが確認でき
フィールド等にUniTaskを置いておくことができるという事がわかります
これを利用すると、特定の値の初期化が終わるのを待つ等の処理ができるでしょう

UniTaskは便利な非同期処理のライブラリであることはよくわかるでしょう。しかし、"待ち"の処理をキャンセルする事を考える必要があります

例えばこのような処理で、

void Start() { Test(); }
async UniTask Test() {
  await UniTask.WaitUntil(() => Input.GetKeyDown(KeyCode.A));
  print("A");
}

シーンを再生し、Aキーを押す前にGameObjectを削除してみましょう
そして、Aキーを押してみましょう・・・
Aが出力されてしまいましたね。アタッチしたGameObjectがDestroyされたのですから、処理はされないという感覚を持ってしまうところですが、そういうわけにはいきませんでした

これはUniTask.WaitUntilで使ってるUpdateがGameObjectに紐づくものではないからです
ということで、取り敢えずはGameObjectのDestroyに合わせてWaitUntilをキャンセルさせてみましょう
using UniRx.Async.Triggersが必要です

void Start() {
  Test(this.GetCancellationTokenOnDestroy()).Forget();
}
async UniTask Test(CancellationToken token) {
  await UniTask.WaitUntil(() => Input.GetKeyDown(KeyCode.A),
    cancellationToken: token);
  print("A");
}

このように、CancellationTokenというものを指定することになります
Destroy時のTokenはthisから取得できます
この場合のthisは(Componentを継承したMonoBehaviorを継承した)実装時のクラスになります
すべてのComponentから取得できます
asyncメソッド呼び出し時に、Forgetを書いていますが これを消すと、Cancel時にエラーが起きます。

まあ、いちいちcancellationTokenを指定するのは面倒と感じるかもしれませんが、便利な機能を使っている分多少の面倒は仕方なしと割り切る必要はあるかもしれません

さて、次のように自分のタイミングでキャンセルをすることもできます

CancellationTokenSource cancellation = null;
void Update() {
  if (Input.GetKeyDown(KeyCode.A)) {
    cancellation?.Cancel();
    cancellation = new CancellationTokenSource();
    Test(cancellation.Token).Forget();
  }
}
async UniTask Test(CancellationToken token) {
  await UniTask.Delay(2000, cancellationToken: token);
  print("A");
}

cancellation?.Cancel()?.はnullチェックをして、nullでなければ続きの部分を実行するものです
Aキーを連打したときに、一番最後に押したタイミングから2秒後に出力があります

ここらで前編は終わりにします
UniTaskについては検索すれば記事は結構出てくるので 後はそれで学べます

後編ではUniRxというものについて話をします
UniRx単体ではちょっと便利くらいのものかもしれませんが
UniTaskと組み合わせると強いです
UniTaskとUniRxで、待ちの処理を実装する(後編:UniRx) - Aizu-Progressive xr Lab blog

[追記 03/29]
後編ではUniRxと組み合わせる事で、UpdateはCollisionEnter等の動作を絡めたコードの解説をしているのですが、UniTaskのみで十分にそのようなコードが書ける事が分かってきたので続けて書きます

UniTaskのみで、そのようなループをするコードを書くときはwhile文を使います

async UniTask UpdateAndCollisionLoop(CancellationToken token) {
  // 指定のTagの物体と衝突があるまで待つ
  var trigger = this.GetAsyncCollisionTrigger();
  while(true) {
    var col = await trigger.OnCollisionEnterAsync(token);
    if (col.gameObject.tag == "Test") break;
  }  

  var count = 0;
  // このループ内がUpdateループになる
  while(true) { 
    print(count++);
    if (Input.GetKeyDown(KeyCode.Space)) {
      break;
    }
    await UniTask.DelayFrame(0, cancellationToken:token);
  }
  // Updateでは0からインクリメントされた値が出力される
  // Spaceキーを押すとUpdateループを抜ける
}

このようにすると、衝突を検知しつつUpdateループに繋げる事ができます
とは言え、UniTaskのみでは
ループをやめるタイミングをもう少し変わったものにすることができないですし、
毎ループごとにUniTask側でクラスのインスタンスを生成することにはなりますから、
そういうのを気にするのなら後編で書いているUniRxを使うほうがよかったりします

[さらに追記 04/01]
注意点があります
UniTaskawaitせずに、呼んでしまった場合にはNullReferenceException等のエラーが起きても、Unityのエラーとして出力されないようです。警告の方に少しだけ形を変えて出力されるので、場合によってはデバッグがしにくくなってしまいます
その場合、UniTaskSchedulerUnobservedExceptionWriteLogTypeというところを、
UnityEngine.LogType.Warningから、UnityEngine.LogType.Exceptionに書きかえてしまうば直ってくれるようです

会津大学VR部の部員が持ち回りで投稿していくブログです。特にテーマに縛りを設けずに書いていきます!