Unity
audio
UniRx
Zenject

クロスフェードをUniRxとZenjectでサクッと書く

目標

Uniry標準のAudio機能と、UniRx&Zenjectセットを組み合わせることでスッキリとクロスフェードを実現させます。
なお本内容のデモをGithubにて公開しておきました。

準備

クロスフェードに必要な情報をScriptableObjectにします。
audioClipとその他情報をくっつけてScriptableObjectに入れちゃえよってUniteLAで言ってた。

MusicParamObject.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Audio;
/// <summary>
/// 楽曲再生に必要な情報オブジェクト
/// </summary>
public class MusicParamObject : ScriptableObject {
    /// <summary>
    /// プログラム内での名前
    /// </summary>
    public string Name;
    /// <summary>
    /// 再生開始時のフェードインカーブ
    /// </summary>
    public AnimationCurve attackCCurve;
    /// <summary>
    /// フェードイン時間 
    /// </summary>
    public float attackTime;
    /// <summary>
    /// フェードアウトカーブ
    /// </summary>
    public AnimationCurve releaseCurve;
    /// <summary>
    ///フェードアウト時間 
    /// </summary>
    public float releaseTime;
    /// <summary>
    /// 再生時につなぐミキサーグループ
    /// </summary>
    public AudioMixerGroup outputMixerGroup;
    /// <summary>
    /// 再生音源
    /// </summary>
    public AudioClip musicClip;
}

設定するとこんな感じになります。
楽曲ごとに気持ちいいリリースタイムを入れておいてあげましょう。

スクリーンショット 2018-02-15 12.21.24.png 

フェードイン、アウトの実装

書くのがめんどくさいフェードイン、アウトをUniRxで書いてみました。

MusicPlayer.cs
        this.UpdateAsObservable ()
            .Select (_ => Time.deltaTime) //deltaを流して
            .Scan ((sum, delta) => sum += delta) //deltaの合計を得て
            .TakeWhile (x => x < timespan) //終了時刻前なら
            .Select (x => x / timespan) //開始から終了までの比率を計算して流して
            .Subscribe ((x) => {
                var ratio = curve.Evaluate (x);//再生位置のカーブ値をとって
                audioSource.volume = ratio;//ボリュームとする
            }, () => { //終わったらcomplete
                onComplete.Invoke ();
            }).AddTo (this);

フェードイン、アウトを同じ表現で書いています。
使わずに書くと、結構めんどくさいというのは音周りの実装をしたことがある方なら痛いほどわかると思います。フェードイン、アウトで別のCoroutine作ったり引数の多い関数を作ったりと非常に面倒なことになります。ついADXとか使おうよという気持ちになります。きっともっといいUniRx式があると思うんですが自分にはこのぐらいが限界でした。

2018/2/22追記

自分で見てても気持ち悪い式だったのでCoroutine + UniRx式に変更しました。
処理全体をUniRxで制御し、ストリームをCoroutineで流してあげる方が良いのではないかなと思います。

MusicPlayer.cs
    /// <summary>
    /// 音量を変化する
    /// </summary>
    /// <param name="curve">変化カーブ</param>
    /// <param name="duration">変化時間</param>
    /// <param name="onComplete">変化完了時処理</param>
    /// <returns></returns>
    IDisposable FadeVolume (AnimationCurve curve, float duration, UnityAction onComplete) {
        var ret = Observable
            .FromCoroutine<float> (o => TransitionVolume (o, curve, duration))
            //TransitionVolumeのOnNextごとに呼ばれる
            .Subscribe (v => {
                audioSource.volume = v;
            }, () => onComplete.Invoke ()).AddTo (this);
        return ret;
    }
    /// <summary>
    /// 変化時間中のカーブ値を流すCoroutine
    /// </summary>
    /// <param name="observer">Coroutine処理のObserver</param>
    /// <param name="curve">変化カーブ</param>
    /// <param name="duration">変化時間</param>
    /// <returns>処理Coroutine</returns>
    IEnumerator TransitionVolume (IObserver<float> observer, AnimationCurve curve, float duration) {
        var timer = duration;
        while (timer > 0) {
            timer -= Time.deltaTime;
            var v = curve.Evaluate (1 - Mathf.Clamp01 (timer / duration));
            observer.OnNext (v);
            yield return null;
        }
        observer.OnCompleted ();
    }

ちなみにここで言いたいことの8割は終わりました。

Zenjectでプールに貯めておいたAudioSourceを使う

Zenjectどこで使うかというと

MusicPlayer.cs
    /// <summary>
    /// 音情報キャッシュ
    /// </summary>
    [Inject] AudioPool audioPool;
    /// <summary>
    /// 初期化処理
    /// </summary>
    /// <param name="music">再生楽曲情報</param>
    void Setup (MusicParamObject music) {
        musicParam = music;
        audioEntity = audioPool.Lend ();
        audioSource = audioEntity.audioSource;
        if (audioSource == null) {
            Debug.LogError ("null source");
        }
    }

の部分です。AudioPoolというやつの中にAudioSouceを持ったGameObjectが詰まっていて、そこからAudioSourceを借りてこようという感じです。ぶっちゃけこのクラスだけが使うならプーリングとか全く必要ないのですが、SEなども全部同じところから取り出して使っていると全体管理の時に便利です。

使い方的にはSingletonで書いてもいいかなというところですが、できればSoundManagerはstaticに置きたくない派です。Zenjectでシーンをまたいで渡せるならそっちで行きたいかなあと思います。

呼び出してみる

準備

MusicController.cs
    /// <summary>
    /// 楽曲情報オブジェクトの取得
    /// </summary>
    /// <param name="name">再生する楽曲情報オブジェクト</param>
    /// <returns></returns>
    MusicParamObject SelectMusic (string name) {
        if (musicParamObjects == null)
            return null;
        var music = musicParamObjects.First (x => x.Name == name);
        return music;
    }

まずScriptableObject化した楽曲情報を取ってきます。
実際に使う時にはResources.Load()とかでやると思います。

再生

MusicController.cs
    List<MusicPlayer> musicPlayers = new List<MusicPlayer> ();  /// <summary>
    /// 再生開始
    /// </summary>
    /// <param name="MusicName">再生する楽曲のプログラム内の名前</param>
    public void Play (string MusicName) {
        var music = SelectMusic (MusicName);
        if (null == music) {
            return;
        }
        var go = diContainer.InstantiatePrefab (musicPlayerPrefab);
        go.transform.SetParent (transform);
        var player = go.GetComponent<MusicPlayer> ();
        player.Play (music);
        musicPlayers.Add (player);
    }

MusicPlayerのPrefabを作ります。diContainerでInstantiateしているのはMusicPlayerのInjectを機能させてあげるためです。再生する側は先ほど取ってきた楽曲情報オブジェクトを渡してあげれば中でフェードインしてくれます。フェードイン時間指定とかマジ嫌い。

再生した後にListに詰めるのは後で止める時に楽かなと思って詰めてます。このListをMusc(BGM)とAmb(環境音)で分けてあげると、クエスト部分は環境音のみ、ボス戦では薄く環境音+BGMとかと言ったやつを作る時に便利です。まあ大体ボス戦だと環境音なんて耳に入らないんですが

停止

MusicController.cs
    /// <summary>
    /// 全部止める
    /// </summary>
    public void StopAll () {
        musicPlayers.ForEach (x => x.Stop ());
        musicPlayers.Clear ();
    }

停止時はStopだけでOKです。楽曲のフェード時間とかって場面に依存するものじゃなく楽曲自体に依存してるはずなのに、コードの中にStop(200)とか書くのって絶対変ですよ。

ちなみにここでは全部止めてますが直前の一個止めるとかでもいいですね。ただ、街のBGM流してる時に街角のラジオからBGM流したいと言った場面では直接musicPlayerをGameObjectが持ってたりするのもアリかなと思います。

まとめ

個人的に実装時に気持ち悪いことになりがちなフェードイン、アウトをそれなりにスッキリ(?)かけて満足しました。UniRx先輩諸兄におかれましてはもっとスパッと書ける式を御教授願えますと幸いです。

参考