時間差 N-way 螺旋弾幕攻撃を非同期ストリーム (IUniTaskAsyncEnumerable / IAsyncEnumerable) を使って実装する【Unity Advent Calender 2021】

本記事は Unity Advent Calender 2021 その2 の 25日目の記事になります.
なんか記事タイトルが色々とてんこもりになってしまいました笑

デモ動画

シューティングゲームによくある円形弾幕・螺旋弾幕・N-way 弾幕の掛け合わせです.
加えて N-way 弾の発射に時間差がある版もあります.

これらを非同期ストリーム (IUniTaskAsyncEnumerable / IAsyncEnumerable) で実装してみます.

検証環境

  • Unity 2021.2.7f1

後述しますが,本デモは Unity 2021.2 以降 ではないと動作しません.

サンプルプログラムと解説

早速ですが,サンプルプログラムと解説を載せます.
大方の処理の流れは理解できるようにコメントを書きました.
多少弧度法や極座標などの数学の知識が必要ですが,そこは各々調べていただければ…

BulletSpawner.cs

弾生成工場オブジェクト用のスクリプト

using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using Cysharp.Threading.Tasks;
using Cysharp.Threading.Tasks.Linq;
using UnityEngine;

namespace Deniverse
{
    public sealed class BulletSpawner : MonoBehaviour
    {
        [SerializeField, Tooltip("発射開始ディレイ")] float _startTime = 1;
        [SerializeField, Tooltip("発射間隔")] float _interval = 0.2f;
        [SerializeField, Tooltip("発射開始の始点角度")] float _initialDegree = 90;
        [SerializeField, Tooltip("スパイラル弾の度数間隔")] float _diffDegree = 15;
        [SerializeField, Tooltip("N-way 弾の数")] int _way = 5;
        [SerializeField, Tooltip("N-way 弾の度数間隔")] float _wayDegree = 30;
        [SerializeField, Tooltip("N-way 弾の発射ディレイ")] float _wayInterval = 0.1f;
        [SerializeField, Tooltip("発射数")] int _shotCount = 20;
        [SerializeField, Tooltip("発射用オブジェクトのプレハブ")] GameObject _bulletPrefab;
        [SerializeField, Tooltip("発射速度スカラー")] float _speed = 3;
        [SerializeField, Tooltip("発射音")] AudioSource _fireSfxSource;

        /// <summary>
        /// 円形弾幕の発射
        /// </summary>
        public void FireCircleBullet()
        {
            // 効果音
            _fireSfxSource.PlayOneShot(_fireSfxSource.clip);

            for (var i = 1; i <= _shotCount; i++)
            {
                // 中心軸の方向 (度数 → ラジアン変換)
                var direction = (_initialDegree + i * _diffDegree) * Mathf.Deg2Rad;

                // 指定した回数分,弾を生成する (way >= 1) N-way 弾
                foreach (var rad in CalculateWayRadians(direction, _wayDegree, _way))
                {
                    Bullet.Instantiate(_bulletPrefab, transform.position, FromPolarToCartesian(_speed, rad));
                }
            }
        }

        /// <summary>
        /// 螺旋弾幕の同時発射
        /// </summary>
        /// <param name="token">CancellationToken</param>
        public async UniTask FireSpiralBulletAsync(CancellationToken token)
        {
            await UniTaskAsyncEnumerable
                // 一定時間後に実行
                .Timer
                (
                    // dueTime で指定した時間後にループ処理開始
                    dueTime: TimeSpan.FromSeconds(_startTime),
                    // period で指定した時間ごとにループ処理が実行
                    period: TimeSpan.FromSeconds(_interval),
                    // updateTiming で指定したタイミングで実行 (Rigidbody 関連の処理を行いたいので,ここでは FixedUpdate を利用)
                    updateTiming: PlayerLoopTiming.FixedUpdate
                )
                // shotCount で指定した回数だけループ処理を実行
                .Take(_shotCount)
                // ソース (AsyncUnit) をインデックスに射影変換 (for 文の i を作るためのフィルタリング)
                .Select((_, i) => i)
                // ループを実行
                .ForEachAsync(i =>
                {
                    // 中心軸の方向 (度数 → ラジアン変換)
                    var direction = (_initialDegree + i * _diffDegree) * Mathf.Deg2Rad;

                    // 効果音
                    _fireSfxSource.PlayOneShot(_fireSfxSource.clip);

                    // 指定した回数分,弾を生成する (way >= 1) N-way 弾
                    foreach (var rad in CalculateWayRadians(direction, _wayDegree, _way))
                    {
                        Bullet.Instantiate(_bulletPrefab, transform.position, FromPolarToCartesian(_speed, rad));
                    }
                }, token);
        }

        /// <summary>
        /// 螺旋弾幕の時間差発射
        /// </summary>
        /// <param name="token">CancellationToken</param>
        public async UniTask FireSpiralDelayBulletAsync(CancellationToken token)
        {
            await UniTaskAsyncEnumerable
                // 一定時間後に実行
                .Timer
                (
                    // dueTime で指定した時間後にループ処理開始
                    dueTime: TimeSpan.FromSeconds(_startTime),
                    // period で指定した時間ごとにループ処理が実行
                    period: TimeSpan.FromSeconds(_interval),
                    // updateTiming で指定したタイミングで実行 (Rigidbody 関連の処理を行いたいので,ここでは FixedUpdate を利用)
                    updateTiming: PlayerLoopTiming.FixedUpdate
                )
                // shotCount で指定した回数だけループ処理を実行
                .Take(_shotCount)
                // ソース (AsyncUnit) をインデックスに射影変換 (for 文の i を作るためのフィルタリング)
                .Select((_, i) => i)
                // ループを非同期に実行
                .ForEachAwaitWithCancellationAsync(async (i, ct) =>
                {
                    // 中心軸の方向 (度数 → ラジアン変換)
                    var direction = (_initialDegree + i * _diffDegree) * Mathf.Deg2Rad;

                    // 効果音
                    _fireSfxSource.PlayOneShot(_fireSfxSource.clip);

                    // 指定した回数分,弾を非同期に生成する (way >= 1) N-way 弾
                    await foreach (var rad in CalculateWayRadiansAsync(direction, _wayDegree, _way, _wayInterval, ct))
                    {
                        Bullet.Instantiate(_bulletPrefab, transform.position, FromPolarToCartesian(_speed, rad));
                    }
                }, token);
        }

        /// <summary>
        /// N-way 弾の発射方向(ラジアン)を計算する
        /// </summary>
        /// <param name="baseRadian">中心軸の方向(ラジアンの初期値)</param>
        /// <param name="wayDegree">N-way 弾の度数間隔</param>
        /// <param name="ways">N-way 数</param>
        /// <returns>発射方向</returns>
        static IEnumerable<float> CalculateWayRadians(float baseRadian, float wayDegree, int ways)
        {
            for (var i = 0; i < ways; i++)
            {
                yield return baseRadian + (i - (wayDegree / 2f - wayDegree * ways / 2f) + wayDegree * i) * Mathf.Deg2Rad;
            }
        }

        /// <summary>
        /// N-way 弾の発射方向(ラジアン)を非同期に計算する
        /// </summary>
        /// <param name="baseRadian">中心軸の方向(ラジアンの初期値)</param>
        /// <param name="wayDegree">N-way 弾の度数間隔</param>
        /// <param name="ways">N-way 数</param>
        /// <param name="interval">発射ディレイ</param>
        /// <param name="token">CancellationToken</param>
        /// <returns>発射方向</returns>
        static async IAsyncEnumerable<float> CalculateWayRadiansAsync(float baseRadian, float wayDegree, int ways, float interval, [EnumeratorCancellation] CancellationToken token)
        {
            for (var i = 0; i < ways; i++)
            {
                yield return baseRadian + (i - (wayDegree / 2f - wayDegree * ways / 2f) + wayDegree * i) * Mathf.Deg2Rad;
                await UniTask.Delay(TimeSpan.FromSeconds(interval), cancellationToken: token);
            }
        }

        /// <summary>
        /// 極座標P(r, Θ) → 直交座標P(x, y, z) (Vector3) の生成
        /// <remarks>直交座標(x, y, z)で返ってくるが,ここでは高さ方向を無視し,zx 平面のみを考慮</remarks>
        /// </summary>
        /// <param name="radius">極座標の距離r</param>
        /// <param name="theta">極座標の偏角Θ</param>
        /// <returns>直交座標(x, y, z)</returns>
        static Vector3 FromPolarToCartesian(float radius, float theta) => new (radius * Mathf.Cos(theta), 0f, radius * Mathf.Sin(theta));
    }
}

IUniTaskAsyncEnumerable

こちらのとりすーぷさんの記事がわかりやすいので読むとよいです.

qiita.com

IUniTaskAsyncEnumerable は UniTask 2 から実装されたもので,
非同期ストリーム IAsyncEnumerable<T> を UniTask として実装されたものです.
IAsyncEnumerable<T> を利用することで,非同期処理を複数個まとめて扱うことができます.

もうちょっと細かく説明すると IObservable<T> は Push-type Observer Pattern に対して,
IAsyncEnumerable<T> は Pull-type Observer Pattern です.以下の参考文献を提示して説明を割愛します.

stackoverflow.com

IUniTaskAsyncEnumerable は LINQ も使えるので,フィルタリングができます.
以下のサンプルでは TimerTakeSelect を利用していますね.

await UniTaskAsyncEnumerable
    // 一定時間後に実行
    .Timer
    (
        // dueTime で指定した時間後にループ処理開始
        dueTime: TimeSpan.FromSeconds(_startTime),
        // period で指定した時間ごとにループ処理が実行
        period: TimeSpan.FromSeconds(_interval),
        // updateTiming で指定したタイミングで実行 (Rigidbody 関連の処理を行いたいので,ここでは FixedUpdate を利用)
        updateTiming: PlayerLoopTiming.FixedUpdate
    )
    // shotCount で指定した回数だけループ処理を実行
    .Take(_shotCount)
    // ソース (AsyncUnit) をインデックスに射影変換 (for 文の i を作るためのフィルタリング)
    .Select((_, i) => i)

ForEachAsync

IUniTaskAsyncEnumerable の値を同期的に消費します.サンプルでは N-way 弾を同期に発射する際に利用しています.

await UniTaskAsyncEnumerable
    // 一定時間後に実行
    .Timer
    (
        // dueTime で指定した時間後にループ処理開始
        dueTime: TimeSpan.FromSeconds(_startTime),
        // period で指定した時間ごとにループ処理が実行
        period: TimeSpan.FromSeconds(_interval),
        // updateTiming で指定したタイミングで実行 (Rigidbody 関連の処理を行いたいので,ここでは FixedUpdate を利用)
        updateTiming: PlayerLoopTiming.FixedUpdate
    )
    // shotCount で指定した回数だけループ処理を実行
    .Take(_shotCount)
    // ソース (AsyncUnit) をインデックスに射影変換 (for 文の i を作るためのフィルタリング)
    .Select((_, i) => i)
    // ループを実行
    .ForEachAsync(i =>
    {
        // 中心軸の方向 (度数法 → 弧度法変換)
        var direction = (_initialDegree + i * _diffDegree) * Mathf.Deg2Rad;

        // 効果音
        _fireSfxSource.PlayOneShot(_fireSfxSource.clip);

        // 指定した回数分,弾を生成する (way >= 1) N-way 弾
        foreach (var rad in CalculateWayRadians(direction, _wayDegree, _way))
        {
            Bullet.Instantiate(_bulletPrefab, transform.position, FromPolarToCartesian(_speed, rad));
        }
    }, token);

ForEachAwaitAsync / ForEachAwaitWithCancellationAsync

IUniTaskAsyncEnumerable の値を非同期に消費します.サンプルでは N-way 弾を非同期に発射する際に利用しています.
キャンセル処理も考慮する場合は,ForEachAwaitCancellationAsync を利用した方がよさそうな感じがあります.

    // ループを非同期に実行
    .ForEachAwaitWithCancellationAsync(async (i, ct) =>
    {
        // 中心軸の方向 (度数 → ラジアン変換)
        var direction = (_initialDegree + i * _diffDegree) * Mathf.Deg2Rad;

        // 効果音
        _fireSfxSource.PlayOneShot(_fireSfxSource.clip);

        // 指定した回数分,弾を非同期に生成する (way >= 1) N-way 弾
        await foreach (var rad in CalculateWayRadiansAsync(direction, _wayDegree, _way, _wayInterval, ct))
        {
            Bullet.Instantiate(_bulletPrefab, transform.position, FromPolarToCartesian(_speed, rad));
        }
    }, token);

IAsyncEnumerable

IAsyncEnumerable は C# 8.0 かつ .NET Standard 2.1 で使える機能です.
Unity は一応 Unity 2020.2 で C# 8.0 がサポートされていますが,.NET Standard 2.0 だったので利用できませんでした.

Unity 2021.2 では C# 9.0 サポートのみならず,.NET Standard 2.1 にも対応されるようになったので,
IAsyncEnumerable が利用できるようになりました.

IAsyncEnumerable を非同期イテレータとして活用することで,N-way 弾の各弾の発射の際に時間差を生じるようにしています.

初見だと非同期ストリームのイメージがしにくかったんですが,
通常の IEnumerable を foreach で回すと,円形を描くように空間方向に繰り返していくイメージとすれば,
IAsyncEnumerable で await foreach で回すと,上記に加えて時間方向にも繰り返していくことで
螺旋を描いていくというイメージを作ってみたらなんとなく理解できました(合っているか微妙).

f:id:xrdnk:20211225030940j:plain

        /// <summary>
        /// N-way 弾の発射方向(ラジアン)を非同期に計算する
        /// </summary>
        /// <param name="baseRadian">中心軸の方向(ラジアンの初期値)</param>
        /// <param name="wayDegree">N-way 弾の度数間隔</param>
        /// <param name="ways">N-way 数</param>
        /// <param name="interval">発射ディレイ</param>
        /// <param name="token">CancellationToken</param>
        /// <returns>発射方向</returns>
        static async IAsyncEnumerable<float> CalculateWayRadiansAsync(float baseRadian, float wayDegree, int ways, float interval, [EnumeratorCancellation] CancellationToken token)
        {
            for (var i = 0; i < ways; i++)
            {
                yield return baseRadian + (i - (wayDegree / 2f - wayDegree * ways / 2f) + wayDegree * i) * Mathf.Deg2Rad;
                // interval 秒分のディレイを入れる
                await UniTask.Delay(TimeSpan.FromSeconds(interval), cancellationToken: token);
            }
        }

IAsyncEnumerable に繰り返し処理を実行したい場合は await foreach と書きます.
ForEachAwaitWithCancellationAsync の中で利用しています.

// 指定した回数分,弾を非同期に生成する (way >= 1) N-way 弾
await foreach (var rad in CalculateWayRadiansAsync(direction, _wayDegree, _way, _wayInterval, ct))
{
    Bullet.Instantiate(_bulletPrefab, transform.position, FromPolarToCartesian(_speed, rad));
}

Bullet.cs

弾オブジェクト用のスクリプト

using System.Threading;
using Cysharp.Threading.Tasks;
using Cysharp.Threading.Tasks.Linq;
using UnityEngine;

namespace Deniverse
{
    [RequireComponent(typeof(Rigidbody))]
    [RequireComponent(typeof(Collider))]
    public sealed class Bullet : MonoBehaviour
    {
        /// <summary>
        /// 移動速度ベクトル
        /// </summary>
        Vector3 Velocity { get; set; }

        void Start()
        {
            var token = this.GetCancellationTokenOnDestroy();

            MoveAsync(token).Forget();
        }

        /// <summary>
        /// 移動処理
        /// </summary>
        /// <param name="token">CancellationToken</param>
        async UniTask MoveAsync(CancellationToken token) =>
            await UniTaskAsyncEnumerable
                .EveryUpdate(PlayerLoopTiming.FixedUpdate)
                .ForEachAsync(_ => transform.position += Velocity * Time.deltaTime, token);

        /// <summary>
        /// 移動速度を設定した上で弾を生成
        /// </summary>
        /// <param name="prefab">弾のプレハブ</param>
        /// <param name="position">発射初期位置</param>
        /// <param name="velocity">移動速度(初速度)ベクトル</param>
        /// <returns></returns>
        public static Bullet Instantiate(GameObject prefab, Vector3 position, Vector3 velocity)
        {
            var go = Instantiate(prefab, position, Quaternion.identity);
            var bullet = go.GetComponent<Bullet>();
            bullet.Velocity = velocity;
            return bullet;
        }

        /// <summary>
        /// カメラ表示範囲から出た時に自身を破壊
        /// </summary>
        void OnBecameInvisible()
        {
            Destroy(gameObject);
        }
    }
}

今回デモのため,生成と破壊に Instantiate と Destroy を利用していますが,
パフォーマンスを考慮するとオプジェクトプーリングで作った方がいいと思います.

SpawnerTestView.cs

using Cysharp.Threading.Tasks;
using UniRx;
using UnityEngine;
using UnityEngine.UI;

namespace Deniverse
{
    /// <summary>
    /// 弾幕攻撃の動作確認用
    /// </summary>
    public sealed class SpawnerTestView : MonoBehaviour
    {
        [SerializeField] Button _buttonFireCircle;
        [SerializeField] Button _buttonFireSpiral;
        [SerializeField] Button _buttonFireDelaySpiral;
        [SerializeField] BulletSpawner _bulletSpawner;

        /// <summary>
        /// AsyncReactiveCommand 用の sharedCanExecuteSource
        /// </summary>
        readonly BoolReactiveProperty _gate = new(true);

        void Start()
        {
            // ボタン押下時に円形弾幕攻撃を実行
            // AsyncReactiveCommand を利用して実行完了までボタンを非活性にする
            _buttonFireCircle.BindToOnClick(_gate, _ =>
            {
                _bulletSpawner.FireCircleBullet();
                return Observable.ReturnUnit();
            });

            // ボタン押下時に N-way 螺旋弾幕攻撃を実行
            // AsyncReactiveCommand を利用して実行完了までボタンを非活性にする
            _buttonFireSpiral.BindToOnClick(_gate, _ =>
            {
                var ct = this.GetCancellationTokenOnDestroy();
                return _bulletSpawner.FireSpiralBulletAsync(ct).ToObservable().AsUnitObservable();
            });

            // ボタン押下時に時間差 N-way 螺旋弾幕攻撃を実行
            // AsyncReactiveCommand を利用して実行完了までボタンを非活性にする
            _buttonFireDelaySpiral.BindToOnClick(_gate, _ =>
            {
                var ct = this.GetCancellationTokenOnDestroy();
                return _bulletSpawner.FireSpiralDelayBulletAsync(ct).ToObservable().AsUnitObservable();
            });
        }
    }
}

AsyncReactiveCommand

非同期処理の実行中はボタンを非活性状態にするという処理をするために,
ここでは UniRx の AsyncReactiveCommand を利用しています.知らない方はこちらを参照ください.

xrdnk.hateblo.jp

// ボタン押下時に時間差 N-way 螺旋弾幕攻撃を実行
// AsyncReactiveCommand を利用して実行完了までボタンを非活性にする
_buttonFireDelaySpiral.BindToOnClick(_gate, _ =>
{
    var ct = this.GetCancellationTokenOnDestroy();
    return _bulletSpawner.FireSpiralDelayBulletAsync(ct).ToObservable().AsUnitObservable();
});

終わりに

学習コストは高いですが,UniTask を利用することで時間操作能力を身に着けたような気分になるのでオススメです.

参考文献

qiita.com

qiita.com

ufcpp.net