Unityを触り始めた頃って、つい何でもかんでも Update() に書いてしまいがちですよね。

「一定間隔で処理したい」「BGMのリズムに合わせてエフェクトを出したい」みたいなときに、

  • Update() の中で timer += Time.deltaTime; を書きまくる
  • あちこちのスクリプトがそれぞれ勝手にBPM計算をする
  • BGMのテンポを変えたら、全部のスクリプトを書き換えるハメになる

こうなると、すぐに「Godクラス」や「スパゲッティUpdate」が出来上がってしまいます。

この記事では、「BGMのBPMに合わせて、一定間隔でシグナル(イベント)を発行するだけ」に責務を絞ったコンポーネント BeatSyncer を作ります。
エフェクト再生・ライトの点滅・敵のスポーンなどは 別コンポーネント にし、BeatSyncerが発行するイベントを受け取るだけ、という構成にしていきましょう。


【Unity】BGMのBPMにガッチリ同期!「BeatSyncer」コンポーネント

BeatSyncer は、

  • AudioSource で再生中のBGMの BPM を元に
  • 「1拍ごと」「小節ごと」などのタイミングで
  • UnityEvent を発行してくれる「リズム同期用タイマー」

です。
このコンポーネント自体は「シグナルを出すだけ」に徹し、
「何をするか(エフェクト・アニメ・敵の行動など)」は別スクリプトに任せる設計にします。


フルコード:BeatSyncer.cs


using System;
using UnityEngine;
using UnityEngine.Events;

/// <summary>
/// BGMのBPMに合わせて、ビートごとにイベントを発行するコンポーネント。
/// - AudioSource の再生時間と BPM から、現在のビートを計算
/// - 1拍ごと、指定ビートごとに UnityEvent を呼び出す
/// - 責務は「ビートの検出とイベント発行」のみに限定
/// </summary>
[RequireComponent(typeof(AudioSource))]
public class BeatSyncer : MonoBehaviour
{
    /// <summary>
    /// 1分あたりの拍数(BPM)。曲ごとにインスペクタから設定する。
    /// 例: 120f, 128f, 140f など
    /// </summary>
    [SerializeField] private float bpm = 120f;

    /// <summary>
    /// 1小節あたりの拍数。4/4拍子なら 4、3/4拍子なら 3 など。
    /// 主に「小節の頭」イベント用。
    /// </summary>
    [SerializeField] private int beatsPerBar = 4;

    /// <summary>
    /// 曲の先頭からビートが始まるタイミング(秒)。
    /// 曲頭に無音やカウントインがある場合に調整する。
    /// </summary>
    [SerializeField] private float songOffsetSeconds = 0f;

    /// <summary>
    /// 何ビートごとに OnBeat イベントを発行するか。
    /// 1 なら毎拍、2 なら2拍ごと、0.5 なら8分音符ごと、など。
    /// </summary>
    [SerializeField] private float beatInterval = 1f;

    /// <summary>
    /// ビートの先読み時間(秒)。
    /// 0.05 ~ 0.1 秒程度にすると、アニメやエフェクトを事前に仕込める。
    /// </summary>
    [SerializeField] private float lookAheadSeconds = 0f;

    /// <summary>
    /// 毎ビートで発火するイベント。
    /// 引数には「ビート番号(0スタート)」が渡される。
    /// </summary>
    [SerializeField] private UnityEvent<int> onBeat = new UnityEvent<int>();

    /// <summary>
    /// 小節の頭で発火するイベント。
    /// 引数には「小節番号(0スタート)」が渡される。
    /// </summary>
    [SerializeField] private UnityEvent<int> onBar = new UnityEvent<int>();

    /// <summary>
    /// デバッグ用に、現在のビート情報をインスペクタに表示するか。
    /// </summary>
    [SerializeField] private bool showDebugInfo = false;

    // 内部参照
    private AudioSource audioSource;

    // 現在までに発行したビート / 小節のインデックス
    private int lastEmittedBeatIndex = -1;
    private int lastEmittedBarIndex = -1;

    // 1ビートの長さ(秒): 60 / BPM
    private float SecondsPerBeat => 60f / Mathf.Max(bpm, 0.0001f);

    private void Awake()
    {
        audioSource = GetComponent<AudioSource>();
    }

    private void Update()
    {
        // AudioSource が再生されていない場合は何もしない
        if (audioSource.clip == null || !audioSource.isPlaying)
        {
            return;
        }

        // 再生時間(秒)を取得
        float songTime = audioSource.time;

        // オフセットを考慮した「ビート計算用時間」
        float timeForBeat = songTime - songOffsetSeconds;

        // 曲の頭より前(オフセットのせいでマイナスになる)なら、まだビートを発行しない
        if (timeForBeat < 0f)
        {
            return;
        }

        // 先読み時間を考慮した「ターゲット時間」
        float targetTime = timeForBeat + lookAheadSeconds;

        // 何ビート進んでいるか(小数含む)
        float beatPosition = targetTime / SecondsPerBeat;

        // interval ごとのビートインデックスを計算
        // 例) beatInterval = 2 のとき、0,2,4,6... のタイミングで発火
        float effectiveBeat = beatPosition / Mathf.Max(beatInterval, 0.0001f);
        int currentBeatIndex = Mathf.FloorToInt(effectiveBeat);

        // まだ新しいビートに到達していなければ何もしない
        if (currentBeatIndex <= lastEmittedBeatIndex)
        {
            return;
        }

        // 何個分のビートを飛ばしてしまったか(フレーム落ち対策)
        int beatsToEmit = currentBeatIndex - lastEmittedBeatIndex;

        for (int i = 0; i < beatsToEmit; i++)
        {
            int beatIndex = lastEmittedBeatIndex + 1 + i;

            // 実際のビート番号(intervalを戻す)
            int actualBeatIndex = Mathf.RoundToInt(beatIndex * beatInterval);

            // ビートイベントを発行
            onBeat.Invoke(actualBeatIndex);

            // 小節の頭かどうかを判定
            if (beatsPerBar > 0 && actualBeatIndex % beatsPerBar == 0)
            {
                int barIndex = actualBeatIndex / beatsPerBar;

                // 小節イベントは「新しい小節」に入ったときだけ発行
                if (barIndex > lastEmittedBarIndex)
                {
                    onBar.Invoke(barIndex);
                    lastEmittedBarIndex = barIndex;
                }
            }
        }

        // 最後に発行したビートインデックスを更新
        lastEmittedBeatIndex = currentBeatIndex;
    }

    #region 公開API

    /// <summary>
    /// BPM をランタイムで変更したい場合に呼び出す。
    /// 例: 曲の途中でテンポが変わる演出など。
    /// </summary>
    /// <param name="newBpm">新しい BPM</param>
    public void SetBpm(float newBpm)
    {
        bpm = Mathf.Max(newBpm, 0.0001f);
    }

    /// <summary>
    /// 現在のビート位置(小数含む)を取得する。
    /// 他のコンポーネントで「今どれくらいビートが進んでいるか」を参照したいとき用。
    /// </summary>
    public float GetCurrentBeatPosition()
    {
        if (audioSource.clip == null)
        {
            return 0f;
        }

        float songTime = audioSource.time;
        float timeForBeat = songTime - songOffsetSeconds;
        if (timeForBeat < 0f) return 0f;

        return timeForBeat / SecondsPerBeat;
    }

    /// <summary>
    /// 現在の小節番号(0スタート)を取得する。
    /// beatsPerBar が 0 以下の場合は常に 0 を返す。
    /// </summary>
    public int GetCurrentBarIndex()
    {
        if (beatsPerBar <= 0)
        {
            return 0;
        }

        float beatPos = GetCurrentBeatPosition();
        return Mathf.FloorToInt(beatPos / beatsPerBar);
    }

    #endregion

#if UNITY_EDITOR
    // デバッグ表示用。エディタ上でのみ動作。
    private void OnDrawGizmosSelected()
    {
        if (!showDebugInfo)
        {
            return;
        }

        if (audioSource == null)
        {
            audioSource = GetComponent<AudioSource>();
        }

        float beatPos = GetCurrentBeatPosition();
        int barIndex = GetCurrentBarIndex();

        string info = $"BPM: {bpm}\n" +
                      $"BeatPos: {beatPos:F2}\n" +
                      $"BarIndex: {barIndex}\n" +
                      $"Interval: {beatInterval}\n" +
                      $"Offset: {songOffsetSeconds} sec";

        // Sceneビュー左上にラベルを表示
        UnityEditor.Handles.BeginGUI();
        GUILayout.BeginArea(new Rect(10, 10, 250, 80), "BeatSyncer Debug", GUI.skin.window);
        GUILayout.Label(info);
        GUILayout.EndArea();
        UnityEditor.Handles.EndGUI();
    }
#endif
}

使い方の手順

BeatSyncer は「BGMを再生しているオブジェクト」にアタッチして使うのが基本です。具体的な例として、プレイヤー周りのUIエフェクト、敵の行動、動く床をリズム同期させるケースで説明します。

手順①:BGM用のGameObjectに BeatSyncer を追加

  1. シーン内に BGM 再生用の GameObject(例: BGMPlayer)を作成します。
  2. BGMPlayerAudioSource コンポーネントを追加し、BGMの AudioClip を設定します。
  3. 同じオブジェクトに、上記の BeatSyncer コンポーネントを追加します。
  4. インスペクタで以下を設定します:
    • Bpm: 曲の BPM(例: 128)
    • Beats Per Bar: 拍子(4/4なら 4)
    • Song Offset Seconds: 曲頭の無音やカウントインがある場合に微調整(なければ 0)
    • Beat Interval: 1 なら毎拍、0.5 なら8分音符ごと、2 なら2拍ごと、など

手順②:プレイヤーのUIエフェクトをビート同期させる

例として、「ビートごとにHPバーの枠を少しだけ拡大する」エフェクトを作ってみます。


using UnityEngine;

/// <summary>
/// ビートごとにUIをポンっと拡大させる簡単なサンプル。
/// BeatSyncer の onBeat にこのメソッドを登録して使う。
/// </summary>
public class BeatPulsingUI : MonoBehaviour
{
    [SerializeField] private RectTransform target;
    [SerializeField] private float scaleAmount = 1.1f;
    [SerializeField] private float returnSpeed = 5f;

    private Vector3 defaultScale;

    private void Awake()
    {
        if (target == null)
        {
            target = GetComponent<RectTransform>();
        }
        defaultScale = target.localScale;
    }

    private void Update()
    {
        // 毎フレーム、元のスケールに戻していく
        target.localScale = Vector3.Lerp(target.localScale, defaultScale, returnSpeed * Time.deltaTime);
    }

    // BeatSyncer の onBeat(int) に登録して使う
    public void OnBeat(int beatIndex)
    {
        // 単純にポンっと大きくする
        target.localScale = defaultScale * scaleAmount;
    }
}

使い方:

  • HPバーなどのUIオブジェクトに BeatPulsingUI をアタッチ。
  • BGMPlayer の BeatSyncer インスペクタで On Beat イベントに HPバーをドラッグ。
  • 関数として BeatPulsingUI.OnBeat を選択します。

これで、BGMのリズムに合わせてHPバーがポンポンと脈打つようになります。

手順③:敵の行動をビートに合わせる

次に、「2拍ごとに敵がジャンプする」みたいな単純な行動を作ります。


using UnityEngine;

/// <summary>
/// ビートごとに敵をジャンプさせるサンプル。
/// BeatSyncer の onBeat に登録して使う。
/// </summary>
[RequireComponent(typeof(Rigidbody))]
public class BeatJumpEnemy : MonoBehaviour
{
    [SerializeField] private int jumpEveryBeats = 2; // 何拍ごとにジャンプするか
    [SerializeField] private float jumpForce = 5f;

    private Rigidbody rb;

    private void Awake()
    {
        rb = GetComponent<Rigidbody>();
    }

    // BeatSyncer の onBeat(int) に登録
    public void OnBeat(int beatIndex)
    {
        // 指定ビートごとにだけジャンプ
        if (beatIndex % jumpEveryBeats != 0)
        {
            return;
        }

        // 上方向にインパルスを与える
        rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);
    }
}

使い方:

  • 敵キャラのプレハブに RigidbodyBeatJumpEnemy をアタッチ。
  • BGMPlayer の BeatSyncer の On Beat に敵オブジェクトを登録し、BeatJumpEnemy.OnBeat を選択。
  • jumpEveryBeats を 2 にすると「2拍ごと」、4 にすると「小節ごと」にジャンプします。

手順④:動く床を小節単位で動かす

今度は「小節の頭ごとに床の位置を切り替える」例です。


using UnityEngine;

/// <summary>
/// 小節の頭ごとに床の位置をトグルするサンプル。
/// BeatSyncer の onBar に登録して使う。
/// </summary>
public class BarTogglePlatform : MonoBehaviour
{
    [SerializeField] private Transform platform;
    [SerializeField] private Vector3 positionA;
    [SerializeField] private Vector3 positionB;

    private bool toggle;

    private void Awake()
    {
        if (platform == null)
        {
            platform = transform;
        }
    }

    // BeatSyncer の onBar(int) に登録
    public void OnBar(int barIndex)
    {
        toggle = !toggle;
        platform.position = toggle ? positionA : positionB;
    }
}

使い方:

  • 動く床のオブジェクトに BarTogglePlatform をアタッチ。
  • インスペクタで positionA, positionB を設定。
  • BGMPlayer の BeatSyncer の On Bar に床オブジェクトを登録し、BarTogglePlatform.OnBar を選択。

これで、「ビート同期のロジック」は BeatSyncer に、「具体的な動き」は各コンポーネントに分離され、かなりスッキリした構成になります。


メリットと応用

メリット①:プレハブが「リズム対応済みパーツ」になる

BeatSyncer は「ビートのシグナルを出すだけ」なので、

  • ビートで光るライトプレハブ
  • ビートで動く床プレハブ
  • ビートごとに攻撃する敵プレハブ

といった「リズム対応済みパーツ」をどんどん増やせます。
レベルデザイナーは、これらのプレハブをシーンに置いて BeatSyncer のイベントに紐付けるだけで、BGMと同期したギミックを簡単に量産できます。

メリット②:BPM変更に強い

BPM を変えたいときは、BeatSyncer の bpm を 1箇所変えるだけでOKです。
個々のスクリプトでタイマー管理をしていないので、

  • 曲差し替え
  • BPMの微調整
  • テンポチェンジ演出

に柔軟に対応できます。

メリット③:Godクラス化を防げる

「BPMを元にしたタイミング計算」は BeatSyncer の責務。
「そのタイミングで何をするか」は、それぞれの小さなコンポーネントの責務。
この分割によって、Update が肥大化した Godクラスを避けられます。

改造案:特定ビート範囲だけ有効にする

例えば「サビの部分だけエフェクトを出したい」といったときに、
BeatSyncer から渡される beatIndex を使って、処理を限定することができます。


using UnityEngine;

/// <summary>
/// 指定したビート範囲のときだけ有効になるフィルタコンポーネント。
/// BeatSyncer の onBeat に登録して使い、その中から別のイベントを発行してもよい。
/// </summary>
public class BeatRangeFilter : MonoBehaviour
{
    [SerializeField] private int startBeat = 32;  // 有効開始ビート
    [SerializeField] private int endBeat = 64;    // 有効終了ビート(含む)

    // ここに、実際に動かしたいコンポーネントを参照させる
    [SerializeField] private BeatPulsingUI pulsingUI;

    // BeatSyncer の onBeat(int) に登録
    public void OnBeat(int beatIndex)
    {
        if (beatIndex < startBeat || beatIndex > endBeat)
        {
            return;
        }

        // 範囲内のビートだけ、UI を脈打たせる
        if (pulsingUI != null)
        {
            pulsingUI.OnBeat(beatIndex);
        }
    }
}

このように、「ビートをいつ・どの範囲で使うか」も別コンポーネントに切り出していくと、
プロジェクト全体がコンポーネント指向で整理されて、あとからの調整や差し替えがとても楽になります。

BeatSyncer をベースに、自分のゲームに合ったリズムギミックをどんどん積み重ねていってみてください。