Unityを触り始めた頃、効果音の再生処理を全部ひとつの Update に書いてしまって、だんだん何をしているスクリプトなのか分からなくなった……という経験はないでしょうか。
「ジャンプ音」「攻撃音」「被ダメージ音」など、全部を1つの巨大スクリプトで管理し始めると、バグの温床になるだけでなく、あとから効果音を追加したり調整したりするのがとてもつらくなります。
さらに、同じ音を PlayOneShot で連打してしまい、「爆発音が20個同時に鳴って耳が痛い」「足音がぐちゃぐちゃに重なってしまう」といった問題も発生しがちです。
そこでこの記事では、「同じ音が同時に鳴りすぎないように制限する」ことだけに責務を絞ったコンポーネント
「AudioPool(同時発音制限)」 を紹介します。
効果音の同時発音数をスマートに管理して、耳に優しいサウンドを実現していきましょう。
【Unity】耳に優しいSE管理!「AudioPool(同時発音制限)」コンポーネント
以下のコンポーネントは、
- 同じ
AudioClipごとに「同時再生の上限数」を設定 - 上限を超えた再生リクエストは無視
- 内部で
AudioSourceをプール(使い回し)
というシンプルなルールで、同時発音を制御します。
プレイヤーの足音、銃声、敵のダメージ音など、「鳴りすぎ注意」なSEにピッタリですね。
フルコード:AudioPool.cs
using System;
using System.Collections.Generic;
using UnityEngine;
namespace AudioUtility
{
/// <summary>
/// 同時発音数を制限しながらAudioSourceをプールするコンポーネント。
/// - AudioClipごとに同時再生上限を設定可能
/// - 上限を超えた再生要求は無視(耳に優しい)
/// - 小さな責務:SEの「同時発音制限」だけを担当
///
/// ※使い方は記事内「使い方の手順」を参照してください。
/// </summary>
[DisallowMultipleComponent]
public class AudioPool : MonoBehaviour
{
/// <summary>
/// クリップごとの同時発音上限設定。
/// インスペクタで設定して使います。
/// </summary>
[Serializable]
private class ClipLimit
{
[Tooltip("対象となるAudioClip")]
public AudioClip clip;
[Tooltip("このクリップの同時再生上限数")]
[Min(1)]
public int maxSimultaneousPlays = 3;
}
[Header("AudioSource プール設定")]
[SerializeField]
[Tooltip("プールしておくAudioSourceの初期数。\n足りなくなった場合は自動で増えます。")]
private int initialPoolSize = 8;
[SerializeField]
[Tooltip("自動で増やせるAudioSourceの最大数。\n0以下にすると無制限になります。")]
private int maxPoolSize = 32;
[Header("クリップごとの同時発音上限")]
[SerializeField]
[Tooltip("同時発音上限を設定したいAudioClipを登録してください。")]
private List<ClipLimit> clipLimits = new List<ClipLimit>();
/// <summary>
/// プールしているAudioSource一覧
/// </summary>
private readonly List<AudioSource> _audioSources = new List<AudioSource>();
/// <summary>
/// AudioClipごとの同時再生数カウンタ
/// </summary>
private readonly Dictionary<AudioClip, int> _playingCountByClip = new Dictionary<AudioClip, int>();
/// <summary>
/// AudioClipごとの同時再生上限
/// </summary>
private readonly Dictionary<AudioClip, int> _limitByClip = new Dictionary<AudioClip, int>();
/// <summary>
/// 再生終了を監視するための一時リスト
/// </summary>
private readonly List<AudioSource> _tempCheckList = new List<AudioSource>();
private void Awake()
{
// インスペクタで設定した上限情報をDictionaryに詰め替える
InitializeLimitDictionary();
// AudioSourceプールを初期化
InitializePool();
}
private void Update()
{
// 毎フレーム、再生が終わったAudioSourceを検出してカウンタを減らす
UpdatePlayingCounts();
}
#region 初期化
/// <summary>
/// クリップごとの上限Dictionaryを初期化
/// </summary>
private void InitializeLimitDictionary()
{
_limitByClip.Clear();
foreach (var clipLimit in clipLimits)
{
if (clipLimit.clip == null)
{
continue;
}
// 同じクリップが複数登録されていた場合は、最後の設定を採用
_limitByClip[clipLimit.clip] = Mathf.Max(1, clipLimit.maxSimultaneousPlays);
}
}
/// <summary>
/// AudioSourceプールを初期化
/// </summary>
private void InitializePool()
{
// 既にAudioSourceがアタッチされている場合も考慮して一旦収集
GetComponents(_audioSources);
// 既存のAudioSourceがinitialPoolSizeより少なければ、追加で生成
int toCreate = Mathf.Max(0, initialPoolSize - _audioSources.Count);
for (int i = 0; i < toCreate; i++)
{
var src = gameObject.AddComponent<AudioSource>();
src.playOnAwake = false;
_audioSources.Add(src);
}
}
#endregion
#region 公開API
/// <summary>
/// 効果音を再生するための公開メソッド。
///
/// - 同時発音上限を超えている場合は再生しません。
/// - 利用可能なAudioSourceがなければ、必要に応じて増やします。
///
/// 戻り値:
/// - 実際に再生されたAudioSource(再生されなかった場合はnull)
/// </summary>
/// <param name="clip">再生したいAudioClip</param>
/// <param name="volume">音量 (0.0〜1.0)</param>
/// <param name="pitch">ピッチ (1.0で等倍)</param>
public AudioSource Play(AudioClip clip, float volume = 1f, float pitch = 1f)
{
if (clip == null)
{
Debug.LogWarning("[AudioPool] 再生しようとしたAudioClipがnullです。");
return null;
}
// 同時発音制限をチェック
if (!CanPlayClip(clip))
{
// 制限により再生しない
return null;
}
// 空いているAudioSourceを取得 or 生成
AudioSource source = GetAvailableSource();
if (source == null)
{
// これ以上増やせない場合は再生を諦める
return null;
}
// AudioSourceの設定
source.clip = clip;
source.volume = Mathf.Clamp01(volume);
source.pitch = pitch;
// 再生
source.Play();
// 再生中カウンタを増やす
IncrementPlayingCount(clip);
return source;
}
#endregion
#region 内部ロジック:同時発音制限
/// <summary>
/// このクリップを再生してよいかどうかを判定
/// </summary>
private bool CanPlayClip(AudioClip clip)
{
// 上限設定がない場合は「制限なし」とみなす
if (!_limitByClip.TryGetValue(clip, out int limit))
{
return true;
}
_playingCountByClip.TryGetValue(clip, out int currentCount);
// 現在の再生数が上限未満であればOK
return currentCount < limit;
}
/// <summary>
/// 再生開始時にカウンタを増やす
/// </summary>
private void IncrementPlayingCount(AudioClip clip)
{
if (!_playingCountByClip.TryGetValue(clip, out int current))
{
current = 0;
}
_playingCountByClip[clip] = current + 1;
}
/// <summary>
/// 再生終了時にカウンタを減らす
/// </summary>
private void DecrementPlayingCount(AudioClip clip)
{
if (clip == null)
{
return;
}
if (!_playingCountByClip.TryGetValue(clip, out int current))
{
return;
}
current--;
if (current <= 0)
{
_playingCountByClip.Remove(clip);
}
else
{
_playingCountByClip[clip] = current;
}
}
/// <summary>
/// 毎フレーム、再生が終わったAudioSourceをチェックしてカウンタを更新
/// </summary>
private void UpdatePlayingCounts()
{
_tempCheckList.Clear();
// 一旦、再生中のAudioSourceだけを抽出
foreach (var src in _audioSources)
{
if (src != null && src.isPlaying)
{
_tempCheckList.Add(src);
}
}
// 再生中だったもののうち、今フレームで止まったものを検出
foreach (var src in _tempCheckList)
{
if (!src.isPlaying && src.clip != null)
{
// 再生終了としてカウンタを減らす
DecrementPlayingCount(src.clip);
// clipを外しておくと、後から再生終了判定するときに安全
src.clip = null;
}
}
}
#endregion
#region 内部ロジック:AudioSourceプール
/// <summary>
/// 使用可能なAudioSourceを取得。
/// 空きがなければ、必要に応じて新規作成します。
/// </summary>
private AudioSource GetAvailableSource()
{
// まず、再生していないAudioSourceを探す
foreach (var src in _audioSources)
{
if (src == null)
{
continue;
}
if (!src.isPlaying)
{
return src;
}
}
// すべて再生中だった場合、増やせるかチェック
if (maxPoolSize > 0 && _audioSources.Count >= maxPoolSize)
{
// これ以上増やせない
return null;
}
// 新しいAudioSourceを追加
var newSource = gameObject.AddComponent<AudioSource>();
newSource.playOnAwake = false;
_audioSources.Add(newSource);
return newSource;
}
#endregion
#if UNITY_EDITOR
private void OnValidate()
{
// インスペクタ変更時に、上限Dictionaryを更新しておく
InitializeLimitDictionary();
// 初期プールサイズが負にならないように
if (initialPoolSize < 0)
{
initialPoolSize = 0;
}
}
#endif
}
}
使い方の手順
ここでは、代表的な使用例として「プレイヤーの足音」「銃声」「敵のダメージ音」を例にします。
-
GameObjectにAudioPoolを追加する
- 空のGameObjectを作成し、名前を
AudioManagerなどにします。 - そこに上記の
AudioPoolコンポーネントをアタッチします。 Initial Pool Sizeは 8〜16 程度から始めると無難です。
- 空のGameObjectを作成し、名前を
-
同時発音上限を設定する
Clip Limitsのサイズを増やし、足音SE、銃声SE、ダメージSEなどを登録します。- 例えば:
- 足音SE:Max Simultaneous Plays = 3
- 銃声SE:Max Simultaneous Plays = 5
- ダメージSE:Max Simultaneous Plays = 2
- 登録されていないクリップは「制限なし」で再生されます。
-
スクリプトからAudioPoolを呼び出す
例えば、プレイヤーの足音を鳴らすコンポーネントを以下のように分離しておくと、責務がはっきりしてきます。using UnityEngine; using AudioUtility; // AudioPoolのnamespace public class PlayerFootstep : MonoBehaviour { [SerializeField] private AudioPool audioPool; // 再生に使うAudioPool [SerializeField] private AudioClip footstepClip; [SerializeField] private float interval = 0.3f; private float _timer; private void Update() { // ここでは例として「常に歩いている」前提で一定間隔で足音を鳴らす _timer += Time.deltaTime; if (_timer >= interval) { _timer = 0f; // AudioPool経由で再生 audioPool.Play(footstepClip, 0.8f, 1f); } } }AudioPoolをインスペクタでAudioManagerオブジェクトにドラッグ&ドロップして参照させます。- 足音の同時発音数は AudioPool 側で自動的に制限されます。
-
他のSEも小さなコンポーネントから呼び出す
同じように、銃声や敵ダメージ音も「小さな再生コンポーネント」に分けておくと、プレハブ化や差し替えが楽になります。
using UnityEngine; using AudioUtility; public class EnemyDamageSound : MonoBehaviour { [SerializeField] private AudioPool audioPool; [SerializeField] private AudioClip damageClip; // 例: ダメージを受けたときに呼ばれるメソッド public void OnDamaged() { audioPool.Play(damageClip, 1f, 1f); } }敵プレハブにこのコンポーネントを付けておけば、どれだけ敵がいても
「同じダメージ音が同時に鳴りすぎない」状態を簡単に保てます。
メリットと応用
AudioPool を導入するメリットは、単に「音がうるさくない」だけではありません。
- 責務が分離される
「いつ音を鳴らすか」は各コンポーネント(足音、攻撃、ダメージ)に任せ、
「どれだけ鳴らしてよいか」はAudioPoolに任せる、という役割分担ができます。
これにより、Godクラス化しがちな「サウンドマネージャー巨大スクリプト」を避けられます。 - プレハブがシンプルになる
敵プレハブに「ダメージ音を鳴らすだけの小さなコンポーネント」を付けておけば、
サウンドの仕様変更があっても、AudioPool側の設定を少し変えるだけで対応可能です。 - レベルデザインがやりやすい
大量に敵を出すステージや、爆発が連続するシーンでも、
クリップごとの同時発音上限を調整するだけで「耳に優しいバランス」を維持できます。
レベルデザイナーがインスペクタの数値をいじるだけで調整できるのも嬉しいポイントですね。
応用として、例えば「同時発音制限に引っかかったときに、もっとも古い再生を止めて新しい音を優先する」といった挙動に変えることもできます。
以下はその一例で、CanPlayClip の代わりに使うようなイメージです。
/// <summary>
/// 上限に達していた場合、最も古く再生された同一クリップを止めてから再生する例。
/// (シンプルにするため、AudioSourceの「開始時間」を記録していると仮定)
/// </summary>
private AudioSource ForcePlayByStoppingOldest(AudioClip clip)
{
// まず、現在再生中の同じクリップのAudioSourceを探す
AudioSource oldest = null;
float oldestTime = float.MaxValue;
foreach (var src in _audioSources)
{
if (src == null || !src.isPlaying || src.clip != clip)
continue;
// 再生開始からの経過時間が最も長いものを古いとみなす
float time = src.time;
if (time < oldestTime)
{
oldestTime = time;
oldest = src;
}
}
if (oldest != null)
{
// 一番古いものを止めてから新しい再生に使う
oldest.Stop();
DecrementPlayingCount(clip); // カウンタも忘れずに減らす
return oldest;
}
// 該当がなければ通常ルートで空きAudioSourceを取得
return GetAvailableSource();
}
このように、AudioPool 自体は「同時発音制限+プール」という小さな責務に絞りつつ、
必要に応じて内部ロジックを差し替えていくことで、自分のゲームに最適なサウンド挙動を作っていけます。
巨大なサウンドマネージャーを1つ作るのではなく、小さなコンポーネントを組み合わせていくスタイルで、気持ちのいい音まわりを育てていきましょう。
