UnityでBGMを扱い始めると、つい「全部ひとつのスクリプトのUpdateに書いてしまう」ことが多いですよね。
シーンごとのBGM管理、フェード処理、エリアごとの音量調整…これらを1つのGodクラスに詰め込むと、

  • ちょっと仕様を変えただけで他の機能が壊れる
  • シーンが増えるほどスクリプトが肥大化して追えなくなる
  • プレハブ単位での調整・再利用が難しくなる

音まわりは「雰囲気を作る重要要素」なので、レベルデザインに合わせてサクッと調整できる構成にしておきたいところです。
そこでこの記事では、「エリアに入ったらBGMをクロスフェードで切り替える」という責務だけに絞った

BGMTransition コンポーネント

を用意して、BGMプレイヤー本体ときれいに分離してみましょう。
BGMの再生・停止は「親のBGMプレイヤー」、エリアごとの音量やクロスフェード制御は「BGMTransition」に任せる構成です。

【Unity】エリアごとにBGMをクロスフェード!「BGMTransition」コンポーネント

ここでは、以下のような構成を想定します。

  • 親オブジェクトに「BGMプレイヤー」用コンポーネント(AudioSource2つでクロスフェード)
  • 各エリアのトリガーに「BGMTransition」コンポーネント(どの曲に、どのくらいの時間で切り替えるかを設定)

この記事だけで完結するように、まずは「親のBGMプレイヤー」から実装していきます。


BGMPlayer: BGM再生とクロスフェードを担当するコンポーネント


using UnityEngine;

namespace AudioSample
{
    /// <summary>
    /// シーン全体のBGM再生を担当するコンポーネント。
    /// 2つのAudioSourceを使ってクロスフェードを行う。
    /// 
    /// - BGMTransition から呼び出される想定
    /// - シーン内に1つだけ置いておき、DontDestroyOnLoad してもOK
    /// </summary>
    [RequireComponent(typeof(AudioSource))]
    public class BGMPlayer : MonoBehaviour
    {
        [Header("クロスフェード設定")]
        [SerializeField] private float defaultFadeDuration = 1.5f;

        // 内部で使う2つのAudioSource
        private AudioSource _sourceA;
        private AudioSource _sourceB;

        // 現在どちらがメインとして再生中か
        private bool _isUsingA = true;

        // 現在再生中のフェードコルーチン
        private Coroutine _fadeCoroutine;

        private void Awake()
        {
            // 既存のAudioSourceをAとして扱う
            _sourceA = GetComponent<AudioSource>();
            _sourceA.playOnAwake = false;
            _sourceA.loop = true;

            // B用のAudioSourceを追加
            _sourceB = gameObject.AddComponent<AudioSource>();
            _sourceB.playOnAwake = false;
            _sourceB.loop = true;

            // 初期音量は0にしておく
            _sourceA.volume = 0f;
            _sourceB.volume = 0f;
        }

        /// <summary>
        /// 即座にBGMを切り替える(フェードなし)。
        /// 主にデバッグやタイトル画面用。
        /// </summary>
        public void PlayImmediate(AudioClip clip, float volume = 1f)
        {
            if (clip == null)
            {
                Debug.LogWarning("[BGMPlayer] PlayImmediate に null クリップが渡されました。");
                return;
            }

            // 進行中のフェードを止める
            if (_fadeCoroutine != null)
            {
                StopCoroutine(_fadeCoroutine);
                _fadeCoroutine = null;
            }

            AudioSource active = GetActiveSource();
            AudioSource inactive = GetInactiveSource();

            // アクティブ側で新しい曲を再生
            active.clip = clip;
            active.volume = volume;
            active.Play();

            // もう片方は停止
            inactive.Stop();
            inactive.clip = null;
            inactive.volume = 0f;
        }

        /// <summary>
        /// クロスフェードでBGMを切り替える。
        /// BGMTransition から主に呼び出されるメソッド。
        /// </summary>
        /// <param name="clip">切り替え先のBGMクリップ</param>
        /// <param name="targetVolume">切り替え後の最終音量</param>
        /// <param name="fadeDuration">フェード時間。0以下なら defaultFadeDuration を使用</param>
        public void CrossFadeTo(AudioClip clip, float targetVolume, float fadeDuration = -1f)
        {
            if (clip == null)
            {
                Debug.LogWarning("[BGMPlayer] CrossFadeTo に null クリップが渡されました。");
                return;
            }

            if (fadeDuration <= 0f)
            {
                fadeDuration = defaultFadeDuration;
            }

            // 進行中のフェードがあれば止める
            if (_fadeCoroutine != null)
            {
                StopCoroutine(_fadeCoroutine);
            }

            _fadeCoroutine = StartCoroutine(CrossFadeRoutine(clip, targetVolume, fadeDuration));
        }

        /// <summary>
        /// 現在再生中のBGMを取得(null の可能性あり)。
        /// デバッグ用や、同じ曲なら切り替えないなどの判定に使える。
        /// </summary>
        public AudioClip GetCurrentClip()
        {
            AudioSource active = GetActiveSource();
            return active.clip;
        }

        /// <summary>
        /// 現在のBGM音量(アクティブ側のAudioSourceのvolume)を取得。
        /// </summary>
        public float GetCurrentVolume()
        {
            return GetActiveSource().volume;
        }

        private System.Collections.IEnumerator CrossFadeRoutine(
            AudioClip nextClip,
            float targetVolume,
            float duration)
        {
            AudioSource from = GetActiveSource();
            AudioSource to = GetInactiveSource();

            // すでに同じクリップなら、単に音量だけ合わせる
            if (from.clip == nextClip)
            {
                float startVolume = from.volume;
                float time = 0f;

                while (time < duration)
                {
                    time += Time.deltaTime;
                    float t = time / duration;
                    from.volume = Mathf.Lerp(startVolume, targetVolume, t);
                    yield return null;
                }

                from.volume = targetVolume;
                _fadeCoroutine = null;
                yield break;
            }

            // 切り替え先AudioSourceを準備
            to.clip = nextClip;
            to.volume = 0f;
            to.Play();

            float fromStartVolume = from.volume;
            float toStartVolume = 0f;
            float timeElapsed = 0f;

            // クロスフェード
            while (timeElapsed < duration)
            {
                timeElapsed += Time.deltaTime;
                float t = timeElapsed / duration;

                // 線形補間。必要ならイージングに変えてもOK
                float fromVol = Mathf.Lerp(fromStartVolume, 0f, t);
                float toVol = Mathf.Lerp(toStartVolume, targetVolume, t);

                from.volume = fromVol;
                to.volume = toVol;

                yield return null;
            }

            // 最終状態を確定
            from.volume = 0f;
            from.Stop();
            from.clip = null;

            to.volume = targetVolume;

            // アクティブソースを切り替え
            _isUsingA = !_isUsingA;

            _fadeCoroutine = null;
        }

        private AudioSource GetActiveSource()
        {
            return _isUsingA ? _sourceA : _sourceB;
        }

        private AudioSource GetInactiveSource()
        {
            return _isUsingA ? _sourceB : _sourceA;
        }
    }
}

BGMTransition: エリアごとのBGMと音量を制御するコンポーネント

次に、エリア移動時に親の BGMPlayer を操作してクロスフェードを行うコンポーネントです。
このコンポーネントは「どの曲に、どの音量で、どのくらいの時間で切り替えるか」だけを責務とします。


using UnityEngine;

namespace AudioSample
{
    /// <summary>
    /// エリアに入ったとき、親の BGMPlayer に対して
    /// BGM のクロスフェード指示を送るコンポーネント。
    /// 
    /// - トリガーコライダーを持つエリアオブジェクトにアタッチして使う
    /// - BGMPlayer は親階層(または手動指定)に配置しておく
    /// </summary>
    [RequireComponent(typeof(Collider))]
    public class BGMTransition : MonoBehaviour
    {
        [Header("BGM 設定")]
        [Tooltip("このエリアで再生したいBGMクリップ")]
        [SerializeField] private AudioClip bgmClip;

        [Tooltip("このエリアでのBGM音量")]
        [SerializeField] [Range(0f, 1f)] private float targetVolume = 1f;

        [Header("フェード設定")]
        [Tooltip("クロスフェードにかける時間(秒)")]
        [SerializeField] private float fadeDuration = 2f;

        [Header("プレイヤー判定")]
        [Tooltip("侵入を検知するタグ。プレイヤー用に \"Player\" など")]
        [SerializeField] private string playerTag = "Player";

        [Header("BGM プレイヤー参照")]
        [Tooltip("未指定の場合は親階層から自動検索します")]
        [SerializeField] private BGMPlayer bgmPlayer;

        [Header("一度だけ再生するかどうか")]
        [Tooltip("true の場合、このエリアに入った最初の1回だけ反応する")]
        [SerializeField] private bool triggerOnlyOnce = false;

        private bool _hasTriggered = false;
        private Collider _collider;

        private void Reset()
        {
            // Reset はコンポーネント追加時に自動で呼ばれる
            // トリガーとして扱いたいので isTrigger を有効化しておく
            _collider = GetComponent<Collider>();
            if (_collider != null)
            {
                _collider.isTrigger = true;
            }
        }

        private void Awake()
        {
            _collider = GetComponent<Collider>();

            if (!_collider.isTrigger)
            {
                Debug.LogWarning("[BGMTransition] このコンポーネントはトリガーコライダーでの使用を推奨します。isTrigger を true に設定してください。", this);
            }

            // bgmPlayer が未設定なら、親階層から自動で探す
            if (bgmPlayer == null)
            {
                bgmPlayer = GetComponentInParent<BGMPlayer>();
                if (bgmPlayer == null)
                {
                    Debug.LogWarning("[BGMTransition] 親階層から BGMPlayer を見つけられませんでした。インスペクターから手動で設定してください。", this);
                }
            }
        }

        private void OnTriggerEnter(Collider other)
        {
            // プレイヤータグで判定
            if (!other.CompareTag(playerTag))
            {
                return;
            }

            if (triggerOnlyOnce && _hasTriggered)
            {
                return;
            }

            if (bgmPlayer == null)
            {
                Debug.LogWarning("[BGMTransition] BGMPlayer が設定されていません。", this);
                return;
            }

            if (bgmClip == null)
            {
                Debug.LogWarning("[BGMTransition] bgmClip が設定されていません。", this);
                return;
            }

            // すでに同じクリップが再生されているなら、音量だけ合わせるなどの最適化も可能
            AudioClip current = bgmPlayer.GetCurrentClip();
            if (current == bgmClip)
            {
                bgmPlayer.CrossFadeTo(bgmClip, targetVolume, fadeDuration);
            }
            else
            {
                // 親のBGMPlayerにクロスフェードを依頼
                bgmPlayer.CrossFadeTo(bgmClip, targetVolume, fadeDuration);
            }

            _hasTriggered = true;
        }

        /// <summary>
        /// インスペクターで値を変更したときに呼ばれる。
        /// エディタ上での設定ミスを軽減するためのチェック。
        /// </summary>
        private void OnValidate()
        {
            if (fadeDuration < 0f)
            {
                fadeDuration = 0f;
            }

            if (_collider == null)
            {
                _collider = GetComponent<Collider>();
            }

            if (_collider != null && !_collider.isTrigger)
            {
                // トリガーでない場合は警告を出すだけにしておく
                // (物理衝突を使いたいケースもあるかもしれないため)
                Debug.LogWarning("[BGMTransition] 通常は isTrigger = true を推奨します。", this);
            }
        }
    }
}

使い方の手順

ここからは、具体的な使用例を交えながらセットアップ手順を見ていきましょう。

手順①:BGMPlayer をシーンに配置する

  1. 空の GameObject を作成し、名前を BGMManager などにします。
  2. BGMPlayer コンポーネントをアタッチします。
  3. AudioSource コンポーネントが自動で付くので、Output をオーディオミキサーに接続したい場合はここで設定します。
  4. Default Fade Duration(defaultFadeDuration)をお好みで調整します(例: 1.5〜3秒)。

このオブジェクトはシーン内に1つだけ置いておき、必要なら DontDestroyOnLoad(gameObject) を追加してシーンをまたいで使うのもアリです。

手順②:エリア用のトリガーを作成する

例:プレイヤーがフィールドから洞窟に入ったらBGMを切り替えたいケース

  1. 空の GameObject を作成し、名前を FieldArea にします。
  2. BoxCollider などの Collider を追加し、Is Trigger にチェックを入れます。
  3. BGMTransition コンポーネントをアタッチします。
  4. インスペクターで以下を設定します:
    • Bgm Clip: フィールド用BGM(例: FieldTheme
    • Target Volume: 1.0 など(最大音量)
    • Fade Duration: 2.0 など(2秒でクロスフェード)
    • Player Tag: プレイヤーオブジェクトのタグ(デフォルトで Player
    • Bgm Player: BGMManager をドラッグ&ドロップして参照をセット

同様に、洞窟用に CaveArea オブジェクトを作成し、BGMTransition に洞窟BGM(CaveTheme など)を設定しておきます。

手順③:プレイヤーオブジェクトのタグを設定する

  1. プレイヤーキャラクターの GameObject を選択します。
  2. インスペクター上部の TagPlayer に設定します。
  3. もし独自のタグ名を使う場合は、BGMTransition 側の Player Tag も同じ文字列に変更します。

これで、プレイヤーが FieldAreaCaveArea の順に移動すると、フィールドBGMから洞窟BGMへ、指定した秒数でクロスフェードするようになります。

手順④:応用例(動く床や敵のエリアでも利用)

  • 動く床の上に乗ったときだけ、BGMを少しだけボリュームアップする
    動く床オブジェクトに BGMTransition をアタッチし、targetVolume = 1.2 などに設定しておけば、床に乗った瞬間だけBGMを少し持ち上げる演出ができます。
  • ボス部屋に入った瞬間にボスBGMへ切り替える
    ボス部屋の入口にトリガーを設置し、BGMTransition にボスBGMを設定、triggerOnlyOnce = true にしておけば、再入場時に何度もトリガーされるのを防げます。
  • 安全地帯に入るとBGMを小さくする
    安全エリア用のトリガーに BGMTransition を付けて、bgmClip は「今の曲と同じ」、targetVolume だけ低め(0.3 など)にしておけば、曲を変えずに雰囲気だけ落ち着かせることもできます。

メリットと応用

BGMPlayerBGMTransition を分けることで、次のようなメリットがあります。

  • プレハブ単位での管理が楽
    ダンジョン、街、フィールドなどのエリアプレハブに BGMTransition を仕込んでおけば、シーンに配置するだけでBGM演出も一緒に付いてきます。
  • レベルデザイナーがインスペクターだけで調整できる
    曲の差し替え、音量、フェード時間はすべてインスペクターで設定可能なので、コードを触らずに雰囲気調整ができます。
  • 責務が分離されていて保守しやすい
    クロスフェードのロジックは BGMPlayer に閉じ込めているので、将来的にイージングを変えたり、ミュート機能を追加したりしても、各エリアのスクリプトをいじる必要はありません。

さらに、BGMTransition は「エリア侵入をトリガーにして BGMPlayer に命令を送る」だけのシンプルな責務なので、

  • トリガーではなくボタン入力で発火させる
  • カットシーンの開始と同時に発火させる

といった拡張も簡単に行えます。

改造案:フェード完了後にイベントを呼ぶ

例えば、「ボスBGMへの切り替えが完了したタイミングでライト演出を始めたい」といったケースでは、BGMPlayer にコールバックを受け取る関数を追加するのが便利です。


public void CrossFadeToWithCallback(
    AudioClip clip,
    float targetVolume,
    float fadeDuration,
    System.Action onComplete)
{
    if (clip == null)
    {
        Debug.LogWarning("[BGMPlayer] CrossFadeToWithCallback に null クリップが渡されました。");
        return;
    }

    if (fadeDuration <= 0f)
    {
        fadeDuration = defaultFadeDuration;
    }

    if (_fadeCoroutine != null)
    {
        StopCoroutine(_fadeCoroutine);
    }

    // ラップ用コルーチン
    _fadeCoroutine = StartCoroutine(CrossFadeWithCallbackRoutine(clip, targetVolume, fadeDuration, onComplete));
}

private System.Collections.IEnumerator CrossFadeWithCallbackRoutine(
    AudioClip nextClip,
    float targetVolume,
    float duration,
    System.Action onComplete)
{
    // 既存の CrossFadeRoutine の中身をほぼ流用してOK
    yield return CrossFadeRoutine(nextClip, targetVolume, duration);

    // フェード完了後にコールバックを呼ぶ
    onComplete?.Invoke();
}

このように、小さな責務ごとにコンポーネントを分けておくと、あとから「ちょっとしたフック」を追加するのも楽になります。
BGM周りがゴチャついてきたと感じたら、ぜひ今回のような構成で整理してみてください。