🎶

【Unity】サウンドシステムにオブジェクトプール導入【SEManager】

に公開

背景

https://github.com/SunagimoOisii/SoundSystemPlugin_ForUnity

  • 上記ライブラリ開発中、「SE機能にオブジェクトプール導入しよ~」との考えに至った

オブジェクトプールは何故必要か

  • AudioSource 管理に導入したい (Unity でサウンド再生に必要なクラス)
  • SE の場合、瞬時に大量の AudioSource が必要になる状況が頻発する
  • そこで、AudioSource 生成を抑えて使いまわせる設計が有効だと考えた

SE 機能の設計

プログラム関係図(全体)

  • SEManager
    • SE再生, 停止, 中断, 再開を担当する
  • IAudioSourcePool
    • プール操作を抽象化
  • ISoundLoader
    • AudioClip ロードを抽象化
    • 現状は Addressable のみ対応だが、他のロード方法を指定可能にする予定

AudioSourcePool群 詳細

  • _Base

    • プールの初期化, 再初期化を実装
  • _FIFO

  • _Strict

    • プールの扱い方が異なる派生クラス達
  • AudioSourcePoolFactory (表には記載ナシ)

    • 指定の振る舞いのオブジェクトプール(_FIFO, _Strict)を返す

オブジェクトプールの挙動

AudioSourcePool_Base (基本機能)

AudioSourcePool_Base の全容
internal abstract class AudioSourcePool_Base : IAudioSourcePool
{
    protected readonly GameObject sourceRoot;
    protected readonly AudioMixerGroup mixerGroup;

    protected Queue<AudioSource> pool;
    protected readonly int maxSize;
    protected readonly int initSize;
    public IEnumerable<AudioSource> GetAllResources() => pool;

    public AudioSourcePool_Base(AudioMixerGroup mixerG, int initSize, int maxSize)
    {
        pool         = new();
        sourceRoot   = new("SE_AudioSources");
        this.maxSize = maxSize;
        this.initSize = initSize;
        this.mixerGroup = mixerG;

        //プール初期化
        for (int i = 0; i < initSize; i++)
        {
            var source = CreateSourceWithOwnerGameObject();
            pool.Enqueue(source);
        }
    }

    public void Reinitialize()
    {
        Log.Safe("Reinitialize実行");

        //プール内の要素を全て未使用にする
        foreach (var source in pool)
        {
            source.Stop();
            source.clip = null;
        }

        //プールサイズを初期化時の値に戻す
        while (pool.Count > initSize) //超過時
        {
            var source = pool.Dequeue();
            Object.Destroy(source.gameObject);
        }
        while (pool.Count < initSize) //不足時
        {
            pool.Enqueue(CreateSourceWithOwnerGameObject());
        }
    }

    public abstract AudioSource Retrieve();

    protected AudioSource CreateSourceWithOwnerGameObject()
    {
        var obj = new GameObject("SESource");
        obj.transform.parent = sourceRoot.transform;

        var source = obj.AddComponent<AudioSource>();
        source.playOnAwake = false;
        source.outputAudioMixerGroup = mixerGroup;
        return source;
    }
}
  • AudioSource プールを Queue で確保
  • 派生クラスでは Retrieve() の挙動が異なる

_FIFORetrieve()

AudioSourcePool_FIFO の全容
internal sealed class AudioSourcePool_FIFO : AudioSourcePool_Base
{
    public AudioSourcePool_FIFO(AudioMixerGroup mixerG, int initSize,
        int maxSize)
        : base(mixerG, initSize, maxSize)
    {
    }

    public override AudioSource Retrieve()
    {
        Log.Safe("Retrieve実行");

        //未使用のAudioSourceがあれば、それを返す
        for (int i = 0; i < pool.Count; i++)
        {
            var source = pool.Dequeue();
            if (source.isPlaying == false)
            {
                pool.Enqueue(source);
                return source;
            }

            pool.Enqueue(source);
        }

        //プールが最大サイズの場合、最古のものを強制的に中断して利用
        if (pool.Count >= maxSize)
        {
            var oldest = pool.Dequeue();
            oldest.Stop();
            pool.Enqueue(oldest);
            return oldest;
        }
        else //最大サイズ未満なら新規作成
        {
            var created = CreateSourceWithOwnerGameObject();
            pool.Enqueue(created);
            return created;
        }
    }
}
  • プールを回し、非再生状態のものがあればそれを返す
  • 全て再生中で、プールが最大サイズ未満の場合
    • AudioSource を新規作成して返す
  • 全て再生中で、プールが最大サイズの場合
    • 最古 AudioSource を返す (それで再生されていた SE は中断)

_StrictRetrieve()

  • 同時再生 SE 数を制限したい場合に有効。超過時は諦める設計
AudioSourcePool_Strict の全容
internal sealed class AudioSourcePool_Strict : AudioSourcePool_Base
{
    public AudioSourcePool_Strict(AudioMixerGroup mixerG, int initSize,
        int maxSize)
        : base(mixerG, initSize, maxSize)
    {
    }

    public override AudioSource Retrieve()
    {
        Log.Safe("Retrieve実行");

        //未使用のAudioSourceがあれば、それを返す
        for (int i = 0; i < pool.Count; i++)
        {
            var source = pool.Dequeue();
            if (source.isPlaying == false)
            {
                pool.Enqueue(source);
                return source;
            }

            pool.Enqueue(source);
        }

        //プールが最大サイズ未満なら新規作成したものを返す
        if (pool.Count < maxSize)
        {
            var created = CreateSourceWithOwnerGameObject();
            pool.Enqueue(created);
            return created;
        }

        //最大サイズで全て使用中ならnull
        return null;
    }
}
  • プールを回し、未使用のものがあればそれを返す
  • 全て再生中で、プールが最大サイズ未満の場合
    • AudioSource を新規作成して返す
  • 全て再生中で、プールが最大サイズの場合
    • null を返す

SEManager がどのようにオブジェクトプールを利用するか

public async UniTask Play(string resourceAddress,
    float volume, float pitch, float spatialBlend, Vector3 position)
{
    //AudioClip ロード
    var (success, clip) = await loader.TryLoadClip(resourceAddress);
    if (!success) return;

    //プールから AudioSource 取得
    var source = sourcePool.Retrieve();
    if (source == null)
    {
        Log.Warn("AudioSource 取得に失敗したため再生中断");
        return;
    }

    //SE再生設定, 再生実行
    source.pitch        = pitch;
    source.volume       = volume;
    source.spatialBlend = spatialBlend;
    source.transform.position = position;
    source.PlayOneShot(clip);
}

工夫点

プールを Queue で表現した理由

  • 「先頭を借りて末尾に戻す」だけで最古判定が実装できる
    • また、SEManagerAudioSource を返却する処理が必要ない

UnityEngine.Pool.ObjectPool<T> を導入していない理由

  • デフォルトで無制限プール → メモリ爆増リスク
  • 最大数制限や再利用ポリシーを細かく制御したかった
  • 自作プールにしておくと後からログなどを挟みやすい

参考

Discussion