Unity でイントロ付きループBGMの実装、および WebGLだと大抵バグるという話

表題の通り、Unityでイントロ付きループBGMを実装する方法と、その方法の大半はWebGLでバグるという話を書いていきます。

イントロ付きループBGMとは、イントロ部分とループ部分で構成されている曲であり、ループ時に最初からではなくイントロを飛ばした位置からループさせる対応が必要になります。

1週間ゲームジャム「かえす」にて、イントロ付きループBGMを使ったら予想以上にトラブルに見舞われました。
そのため、備忘録としてブログ記事を投稿する事としました。

記事内で使用しているUnityのバージョンは「Unity 2022.3.39f」になります。
また、WebGLのバグは「Unity 2022.3.50f」でも発生している事を確認済です。

今回の記事で使っているBGMは、フリーBGM素材サイト「音楽の卵」様の曲をお借りしております。
音楽の卵:https://ontama-m.com/


結論

  • イントロ部分とループ部分のファイルを分割する場合
    • ループ部分をAudioClip.LoadAudioData();で事前に読み込む
    • IntroAudioSource.PlayScheduled(AudioSettings.dspTime);とLoopAudioSource.PlayScheduled(AudioSettings.dspTime + イントロ部分の時間);を同時に実行
  • RPGツクールの様なループ方法で対応する場合
    • AudioSource.timeSamplesで時間経過を判断する
    • 自作のクラスにint LOOPSTART, LOOPLENGTHを追加して、Updateで計算する
  • WebGLの場合
    • 上記の各方法だと、正常に動作しない
    • ファイル分割する場合はIEnumratorで実行する必要がある
    • RPGツクールの様なループ方法を行う場合、AudioSource.timeSamplesではなくTime.timeで計算する
  • 中~大規模ゲームの場合、「CRIWARE」も選択肢に加えるべき(WebGL対応版は有償)

イントロ付きループBGMの2大ループ手法

この記事では、5種類のループ実装方法を記載します。
ですがその前に、この5種類の実装方法は大別すると2通りのループ手法に分けることが出来ます。
1つは「introファイルとloopファイルに分割する手法」、もう1つは「ループ開始タイミングまで巻き戻す手法」。

それぞれメリットとデメリットがあるため、プロジェクトに合わせて手法を使いこなすのが良さそうです。
どちらの手法を取るにせよ、音源編集ソフト等でBGMのループタイミングを計算する必要がある点には注意してください。

(この記事用にお借りした「音楽の卵」様のサイトでは、ダウンロードしたBGMにogg用のLOOPSTART/LOOPLENGTHを記載したtxtファイルを付属されています。ありがたい♪)

introファイルとloopファイルに分割する(分割ループ)

名の通り、ループ付きBGMを出出しのintro部分とそれ以降のloop部分に分割し、「introファイル->loopファイル->loopファイル->loopファイル」という形でループさせる手法です。

ここから先、この手法を「分割ループ」と称して書き進めます。

メリット

  • ループタイミングの監視が不要
  • 実装が簡単(専用クラスを用意しなくても何とかなる)
  • 綺麗なループになりやすい(音飛びや無音が発生しずらい)

デメリット

  • 新しいイントロ付きループBGMを増やすたびにサウンドファイルの作成する必要がある
    そのため、BGMファイルも余分に増えてしまう
  • イントロからループに繋ぐタイミングで無音が発生する可能性大
  • イントロ部分の再生中にBGMのピッチが変化したり、一時停止したりするとバグる
    (このバグを回避するのは、ものすごく手間がかかる)

ループ開始タイミングまで巻き戻す(oggループ)

Update関数等でBGMの経過時間をチェックして、ループしたいタイミングまでBGMが流れたら、再正位置をループ開始位置まで巻き戻す手法です。

RPGツクールなどで使用可能なoggファイルのLOOPSTART/LOOPLENGTHを、疑似再現させる手法とも言えます。
なのでここから先、この手法を「oggループ」と称して書き進めます。

oggループを実装する場合、専用のサウンド再生クラスを用意しておくと色々便利です。

メリット

  • BGMを分割する必要がない
  • 専用のサウンドクラスを作れば、ループ以外のサウンド機能も追加できたりカスタマイズしやすい
  • 実装次第で、ピッチ変更や一時停止にも対応できる

デメリット

  • ループタイミングの監視が必須(そのため、専用サウンドクラスを作らないと管理やデバッグが面倒になる)
  • 仕様上、ループ終了時間が曲の終了時間と同じだとバグる※1, ※2
  • ループ時に音飛びが発生する可能性あり
  • 音源サンプルや周波数に関する知識がある程度必要
  • より細かい制御を考えるなら、ミドルウェア「CRIWARE」を導入する方が楽

※1 ソースコードの説明は後述しますが、以下の処理を実装する前提。
  (LOOPSTART + LOOPLENGTH)がaudioClip.samplesに近い値だと、source.timeSamplesの値が0に戻ってしまい正常にループしない問題が発生する。

readonly int LOOPSTART = 223255, LOOPLENGTH = 2683306, Hz = 44100;
private void Update() {
    if (source.timeSamples >= LOOPSTART + LOOPLENGTH) {
        source.timeSamples -= LOOPLENGTH;
    }
}

※2 BGM末尾にループ開始の数秒間を付け足すと、※1の問題を解決しつつ、ループ処理が遅延しても違和感なくループできる

イントロ付きループBGMを確実にループさせるために
BGMの最後にループ開始部分のサンプルを追加している

イントロ付きループBGMの実装方法

分割ループとoggループという、2通りの手法があると書きました。
簡単に実装したい場合は分割ループ、細かい制御を行いたい場合はoggループが良さそうでした。
ここからは各手法の実装方法について書いていきます。


分割ループの実装方法

分割ループを実装する方法として、ここでは3パターンのやり方を書いていきます。

なお、大前提としてintroファイルとloopファイルに分割済みという前提です。

共通事項

  • この記事では、introファイルを「introClip」、loopファイルを「loopClip」として扱います
  • 分割ループを行う場合、どの実装方法でも以下の処理を加えてください。※
    • introClip.LoadAudioData();
    • loopClip.LoadAudioData();

※LoadAudioData()関数は、BGMをPlay()する前に事前にサウンドデータを読み込むようにします。
 この関数を使用した場合と未使用の場合では、以下の違いが現れます。

・LoadAudioData()を使用した場合

・未使用の場合

AudioSource.PlayDelayed(introClip.Length);

この記事で紹介する実装方法の中では、一番分かりやすく一番簡単に実装出来る方法です。

ただし特別な理由がなければ、次に説明するPlayScheduled()関数を用いた方が良いでしょう。

    AudioSource source, source2;

    public void PlayBGM(AudioClip introClip, AudioClip loopClip) {
        // 事前にBGMをロードしておく
        // introからloopに遷移する際の無音時間を減らすため
        introClip.LoadAudioData();
        loopClip.LoadAudioData();

        // Playでintroの再生
        source.clip = introClip;
        source.loop = false;
        source.Play();
        // PlayDelayedでloopファイルを遅延再生
        source2.clip = loopClip;
        source2.loop = true;
        source2.PlayDelayed(introClip.length / source.pitch);
    }

AudioSource.PlayScheduled(AudioSettings.dspTime);

PlayDelayedとほぼ似た描き方/似た挙動を行いますが、内部処理が異なります。
こちらの関数を使用する方が、より正確なタイミングでBGMが遅延再生されるらしいです。

「AudioSettings.dspTime」は、UnityのTime.timeとは異なり、音楽用の経過時間を測定しているっぽいです。

    AudioSource source, source2;

    public void PlayBGM(AudioClip introClip, AudioClip loopClip) {
        // 事前にBGMをロードしておく
        // introからloopに遷移する際の無音時間を減らすため
        introClip.LoadAudioData();
        loopClip.LoadAudioData();

        // PlayScheduledでintroの再生
        source.clip = introClip;
        source.loop = false;
        source.PlayScheduled(AudioSettings.dspTime);
        // PlayScheduledでloopファイルを遅延再生
        source2.clip = loopClip;
        source2.loop = true;
        source2.PlayScheduled(AudioSettings.dspTime + (introClip.length / source.pitch));
    }

IEnumerator / yield return new WaitForSecondsRealtime();

先ほどの2パターンは、再生時にループ用の音源を遅延再生させていました。

この方法ではintroファイルを流してから、WaitForSecondsRealtime()でループが始まるタイミングの再生時間まで待機します。
その後にloopファイルを再生する方法をとります。

正直なところこの実装は非推奨だし避けた方がいいのですが、何故この方法を書いているかと言うと……

他2パターンと違い、WebGLで正常に動作する分割ループ実装法だから

WebGL君さぁ……なんでPlayDelayed関数とPlayScheduled関数、両方ともバグらせるの……

そんなわけで、非推奨だけどWebGLで対応したいときの実装方法がこちらになります。

    AudioSource source, source2;

    public IEnumerator PlayBGM(AudioClip introClip, AudioClip loopClip) {
        StartCoroutine(Play(introClip, loopClip));
    }
    IEnumerator Play(AudioClip introClip, AudioClip loopClip) {
        // 事前にBGMをロードしておく
        // introからloopに遷移する際の無音時間を減らすため
        introClip.LoadAudioData();
        loopClip.LoadAudioData();

        // Playでintroの再生
        source.clip = introClip;
        source.loop = false;
        source.Play();

        // introの終了まで待機
        // timeScaleを無視できる「WaitForSecondsRealtime()」を使用する
        float wait = introClip.length / source.pitch;
        yield return new WaitForSecondsRealtime(wait);

        // Playでloopの再生
        source2.clip = loopClip;
        source2.loop = true;
        source2.Play();
    }

このコードを記載するクラスには、MonoBehaviourを継承する必要があります


oggループの実装方法

oggループを実装する方法として、ここでは2パターンのやり方を書いていきます。

C#に慣れた人や、サウンド関連の用語を多少なりとも把握している前提の実装方法です。

共通事項

タイトルにoggと書いていますが、今から実装する方法であればmp3やm4a、wav等でもループ可能です。

どちらも各々が作ったサウンド再生クラスへ追記する、という前提で書いています。
そのため、そのままコピペしても正しく動かない可能性があります。

oggファイルの形式に従い、ループ開始位置を「LOOPSTART」、ループ部分の長さを「LOOPLENGTH」としています。

source.timeSamples >= LOOPSTART + LOOPLENGTH

oggループ実装方法の最適解になります。

特別な理由がなければこの方法で実装しましょう。

    readonry AudioSource source;
    int LOOPSTART, LOOPLENGTH;
    public YourClassName(AudioSource source) {
        this.source = source;
    }

    public void Update() {
        if (source.isPlaying) {
            // 再生中のサンプル数がループタイミングを上回れば、ループ開始位置まで巻き戻す
            if (source.timeSamples >= LOOPSTART + LOOPLENGTH) {
                source.timeSamples -= LOOPLENGTH;
            }
        }
    }
    public void PlayBGM(AudioClip clip, int LOOPSTART, int LOOPLENGTH) {
        // BGMのループ開始位置とループ期間を格納
        this.LOOPSTART = LOOPSTART;
        this.LOOPLENGTH = LOOPLENGTH;

        // BGMを再生
        source.clip = clip;
        source.loop = false;
        source.Play();
    }

Time.TunscaledDeltaTimeの合算 * 44100(Hz) >= LOOPSTART + LOOPLENGTH

非推奨も大概にしろと言いたくなるゴリ押し実装法です
「分かりやすさ」, 「シンプルさ」, 「動作の安定性」の全てにおいて、timeSamples実装法の完全下位互換。

じゃあ何故この書き方を用意しているかと言うと……

AudioSource.timeSamplesは(AudioSource.timeも)WebGLで動かすとバグるから

WebGL君さぁ……なんでAudioSource内部の経過時間計算が、現実時間より約1.1倍ほど早く進むの……
(詳しい内容は後で)

そういう訳で、AudioSource.timeSamplesに依存しない計算方法、Timeクラスと周波数を用いて疑似的にサンプル数を計算する必要があります。

    readonry AudioSource source;
    int LOOPSTART, LOOPLENGTH, Hz;
    float time;
    public YourClassName(AudioSource source) {
        this.source = source;
    }

    public void Update() {
        if (source.isPlaying) {
            // 再生中のサンプル数がループタイミングを上回れば、ループ開始位置まで巻き戻す
            if (time * Hz >= LOOPSTART + LOOPLENGTH) {
                source.timeSamples = (int)((time * Hz) - LOOPLENGTH);
                time = (float)source.timeSamples / Hz;
            }
            time += Time.unscaledDeltaTime * source.pitch;
        }
    }
    public void PlayBGM(AudioClip clip, int LOOPSTART, int LOOPLENGTH, int hz = 44100) {
        // BGMのループ開始位置とループ期間、周波数を格納
        this.LOOPSTART = LOOPSTART;
        this.LOOPLENGTH = LOOPLENGTH;
        Hz = hz;

        // BGMを再生
        source.clip = clip;
        source.loop = false;
        source.Play();
        time = 0;
    }

上記コードにある「Hz」は周波数を表しています。
BGMの周波数はAudioClipのInspectorにある
赤枠部分で確認可能です。

とここで、ある事に気付くはず。
わざわざInspectorから確認しなくても「AudioClip.frequency」を使えば周波数を取得できるのではないか?
私もそう思って、最初はその変数を使用していたのですが……

AudioClip.frequencyはWebGLで取得すると、実際の値と異なる値が返される

WebGL君さぁ……サウンド関連の挙動ガバガバ過ぎません……?

なので、PlayBGM関数に渡す周波数は、直接確認してベタ打ちする必要があります。


Windows版とWebGL版の挙動の違いを体験する

先ほど記載した5パターンのループ方法、およびBGMの時間計測を比較できるミニアプリを作成しました。
クリック、またはタップだけで操作できます。

Windos版とWebGL版で全くと言っていい程に挙動が異なるため、時間があれば両方のプラットフォームでとも確認してみてください。

Wendows版はこちらからダウンロードしてください(約34MB)
https://drive.google.com/drive/folders/1SuecWWjYZbnkvlVMKgv58QJf4A0e7G8V?usp=drive_link

WebGL版はこちらでプレイできます
https://whimsicalcat.sakura.ne.jp/whimdata/wp-content/uploads/SoundLoop/index.html

プレイ画面

動作説明っぽいの

①Time.timeScaleを変更
②AudioSource.pitchを変更
③BGMをPause/UnPauseを変更
④分割ループの各実装を確認
⑤oggループの各実装を確認
⑥BGM再生中の時間を計測

ほんとWebGL君はメチャクチャしてくれます。

もっとも、AudioSource.timeSamplesの計算が正しくないのは最近出現したバグっぽい?
なので、近日中にバグ修正されているんじゃないかとは思います。
https://discussions.unity.com/t/webgl-audiosource-time-incorrect-others-6000-0-5f1/950966


ぶっちゃけ

今回はイントロ付きループBGMの対応だけなので直接コードを作りました。

もし、それ以外の細かいサウンド処理まで行いたい場合は、自力でコードを書くよりも
ミドルウェアの「CRIWARE」を使う方が適切だと思います。
有償版:https://game.criware.jp/products/adx/
無償版:https://game.criware.jp/products/adx-le/

2024/10/12時点ではWebGL版は有償版のみ対応しています。
(そりゃまぁ、あれだけWebGLだけの例外対応を迫られたら有償にもなるわなぁ……と妙に納得する次第)

とはいえ無償版のFAQで「無料版でのWebGLは今後対応予定」みたいな内容が記載されていたので、今後に期待です。

コメントする

CAPTCHA