详解自制 Unity 音频播放库「Narimono」的内部设计!句柄与同时播放数的机制
自作Unityサウンド再生ライブラリ「Narimono」の内部設計を解説!ハンドルと同時再生数の仕組み
はじめに
こんにちは、空音です!
前回の記事では、Unity向けのサウンド再生ライブラリ「Narimono(鳴物)」を公開したことを紹介しました。
今回はその第二弾として、Narimonoの内部設計や実装について、もう少し詳しく紹介していきたいと思います!
今回取り上げるのは、開発中に特に設計を考えた次の3つです。
- 再生した音を安全に操作するための「ハンドルと世代番号」
- 音を途切れさせずに同時再生数を制限する「上限の処理」
- フェードと3Dサウンドを支える「ボイスの更新処理」
1. Narimonoの全体の構成
Narimonoは、次のような構成になっています。
AudioSystem(MonoBehaviour)
├─ AudioVoice × Max Voice Count
│ ├─ AudioSource(子オブジェクト)
│ └─ AudioVolumeFade(フェードの状態)
├─ 再生の判定(Cue・カテゴリ・全体の上限)
└─ AudioStats(計測結果)
| クラス | 役割 | 公開 |
|---|---|---|
AudioSystem |
再生・停止の窓口。上限の判定と、毎フレームの更新を行う | 公開 |
AudioPlayback |
再生した音を指すハンドル | 公開 |
AudioVoice |
AudioSourceと再生状態をまとめた、同時再生の1枠 | 内部 |
AudioVolumeFade |
音量を一定時間かけて変化させる構造体 | 内部 |
このうち利用者が直接触るのはAudioSystemとAudioPlaybackで、AudioVoiceとAudioVolumeFadeはinternalにしています。
なお、Narimonoでいうボイスは、キャラクターの声ではなく「同時に鳴らせる音の1枠」のことです。電子楽器の「同時発音数」と同じ意味で使っています。
2. AudioSourceを使い回すボイス
起動時にまとめて生成する
AudioSystemは、Awake()でMax Voice Countの数だけボイスを生成します。
private void CreateVoices()
{
_voices = new AudioVoice[_maxVoiceCount];
for (int i = 0; i < _voices.Length; i++)
{
var voiceObject = new GameObject($"AudioVoice_{i}");
voiceObject.transform.SetParent(transform);
var audioSource = voiceObject.AddComponent<AudioSource>();
audioSource.playOnAwake = false;
_voices[i] = new AudioVoice(i, audioSource);
}
}
再生のたびにGameObjectやAudioSourceを作らず、空いているボイスを使い回すことで、再生のたびのアロケーションをなくしています。
また、AudioSystemには[DefaultExecutionOrder(-100)]を付けています。ほかのコンポーネントのAwake()からPlay()が呼ばれても、ボイスが生成済みになるようにするためです。
ボイスの検索は線形探索
空いているボイスや、最も古い音を探す処理は、すべてボイスの配列を先頭から調べる線形探索です。
ボイスの数は多くても数十個程度なので、辞書やリストで管理するよりも、配列をそのまま調べるほうが単純で、アロケーションも発生しません。
CueとカテゴリでCount系・Find系の処理が似た形で並んでいますが、デリゲートで共通化するとアロケーションが発生するため、あえて分けています。
3. 古いハンドルが別の音を操作してしまう問題
AudioSourceを使い回すことで起きる問題
ボイスを使い回すと、次のような問題が起きます。
1. 音Aを2番のボイスで再生する(ハンドルA)
2. 音Aが鳴り終わり、2番のボイスが空く
3. 音Bを、空いた2番のボイスで再生する
4. 古いハンドルAで Stop() を呼ぶ
→ 2番のボイスで鳴っている音Bが止まってしまう
ハンドルが「何番のボイスか」だけを持っていると、そのボイスが別の音に使い回されたことを区別できません。
ハンドルに世代番号を持たせる
そこで、ハンドル(AudioPlayback)には、ボイスの番号に加えて世代番号を持たせています。
public readonly struct AudioPlayback : IEquatable<AudioPlayback>
{
/// <summary>再生できなかった場合に返される無効なハンドル</summary>
public static AudioPlayback None => default;
/// <summary>再生に使われているボイスの番号</summary>
internal int VoiceIndex { get; }
/// <summary>ハンドルを発行したときのボイスの世代番号。0は無効なハンドルを表す。</summary>
internal int Generation { get; }
/// <summary>有効なハンドルかどうか。ハンドルが指す音が再生中かどうかは表さない。</summary>
public bool IsValid => Generation != 0;
}
ボイスも自分の世代番号を持っていて、次のタイミングで1つ進めます。
| タイミング | 世代番号 |
|---|---|
| 新しい音の再生を開始したとき | +1 |
| フェードアウトを開始したとき | +1 |
世代番号は戻すことがなく、停止しても初期化しません。
ハンドルが今の再生を指しているかを判定する
ハンドルで操作するときは、ボイスのMatches()で、ハンドルが今の再生を指しているかを確認します。
public bool Matches(AudioPlayback playback)
{
return IsAssigned && Index == playback.VoiceIndex && Generation == playback.Generation;
}
たとえば、2番のボイスでは次のように判定されます。
| 出来事 | ボイスの世代番号 | 結果 |
|---|---|---|
| 音Aを再生する(ハンドルA=2番・世代1) | 0 → 1 | |
Stop(ハンドルA) |
1 | 停止して、ボイスが空く |
もう一度Stop(ハンドルA)
|
1 | ボイスが空いているので何もしない |
| 音Bを再生する(ハンドルB=2番・世代2) | 1 → 2 | |
古いStop(ハンドルA)
|
2 | 世代番号が一致しないので何もしない |
| 音Bのフェードアウトを開始する | 2 → 3 | ハンドルBも無効になる |
IsAssignedと世代番号は、それぞれ別の状況を見分けています。
- 世代番号:ボイスが別の音に使い回されたことや、フェードアウトで手放されたことを見分ける
- IsAssigned:音が止まってボイスが空いていることを見分ける(停止では世代番号を進めないため)
どちらか片方だけでは、すべての状況を見分けられません。
フェードアウトの開始でも世代番号を進める理由
フェードアウトを始めた音は、ライブラリにとって「もう手放した音」です。
フェードアウト中の音を利用側がもう一度Pause()したりStop()したりすると、せっかくのフェードが途中で止まってしまいます。そこで、フェードアウトを始めた時点で世代番号を進め、それまでのハンドルを無効にしています。
全体で一意なIDにしなかった理由
ハンドルを「全体で一意なID」にする方法もありますが、その場合はIDからボイスを探すために辞書が必要になります。
ボイスの番号と世代番号の組にしておけば、配列の添字で一発でボイスを取り出せるため、検索もアロケーションも必要ありません。
また、AudioPlaybackにはIEquatable<AudioPlayback>を実装しているため、==での比較やDictionaryのキーとして使ってもボックス化が発生しません。
4. 再生の判定の流れ
Play()は、次の順に判定して、条件を満たさなければ再生せずにAudioPlayback.Noneを返します。
- 引数とCueの設定が正しいか
-
Min Intervalが経過しているか - リスナーから
Max Distance以内か(3Dのみ) - Cueの上限
- カテゴリの上限
- 空いているボイスがあるか
4〜6はボイスを確保するTryReserveVoice()でまとめて処理しています。
private bool TryReserveVoice(AudioCueBase cue, out AudioVoice voice)
{
voice = null;
// Cueの同時再生数が上限に達している場合は、Cueの設定に従って空きを作る。
if (CountActiveVoicesOfCue(cue) >= cue.MaxInstances && !TryMakeRoomInCue(cue)) return false;
// カテゴリの同時再生数が上限に達している場合は、カテゴリの設定に従って空きを作る。
// Cueの上限で空きを作った場合は同じカテゴリの音も1つ減っているため、ここでは上限に達していない。
AudioCategory category = cue.Category;
if (CountActiveVoicesOfCategory(category) >= category.MaxPlaybackCount && !TryMakeRoomInCategory(cue)) return false;
if (!TryFindFreeVoice(out voice))
{
ReportNoFreeVoice(cue);
return false;
}
return true;
}
Cueの上限で古い音を止めた場合、その音は同じカテゴリの音でもあるため、カテゴリの同時再生数も1つ減ります。そのため、Cueの上限で空きを作った直後に、カテゴリの上限でもう1つ音を止めることは起きません。
Min Intervalは再生できたときだけ記録する
Min Intervalの判定に使う再生時刻は、実際に再生できたときだけ記録しています。上限などで再生しなかった音の時刻まで記録すると、次の再生まで不必要に待たされてしまうからです。
また、Min Intervalが0のCueは判定する必要がないため、再生時刻を記録せず、辞書の検索と更新も行いません。
5. 同時再生数の数え方
同時再生数を数えるときは、ボイスのIsActiveを使っています。
/// <summary>割り当て済みで、フェードアウト中でもないかどうか。</summary>
public bool IsActive => IsAssigned && !IsFadingOut;
ボイスの状態ごとの扱いは、次のとおりです。
| ボイスの状態 | 同時再生数に数えるか | ボイスを使っているか |
|---|---|---|
| 再生中 | 数える | 使っている |
| 一時停止中 | 数える | 使っている |
| フェードアウト中 | 数えない | 使っている |
| 空き | 数えない | 使っていない |
フェードアウト中の音は、もう消えていく音なので同時再生数には数えません。ただし、鳴り終わるまではボイスを使い続けます。「数えるかどうか」と「ボイスを使っているかどうか」が一致しないのが、この後の上限の処理を考えるうえで大事なポイントです。
最も古い音を探す
上限に達したときに止める音は、再生を開始した順番(StartOrder)が最も小さい、フェードアウト中でない音です。
StartOrderは全ボイス共通の通し番号で、ボイスごとの世代番号とは別のものです。世代番号はハンドルの判定に、StartOrderは古さの比較に使っています。
6. 上限に達したときの処理
上限に達したときの挙動(AudioOverflowPolicy)は、TryApplyOverflowPolicy()で処理しています。
case AudioOverflowPolicy.FadeOutOldest:
{
// フェードアウト中の音はボイスを使い続けるため、新しい音には別の空きボイスが必要になる。
// 空きがない場合にフェードアウト中の音を切って枠を作ると音がブチッと途切れるため、
// フェードアウトを始めずに新しい音を捨てる。
// 一時停止中などで即座に停止する場合は、そのボイスが空くため空きボイスは要らない。
if (oldestVoice.CanFadeOut(fadeOutDuration) && !TryFindFreeVoice(out _))
{
ReportNoFreeVoice(cue);
return false;
}
oldestVoice.BeginFadeOut(fadeOutDuration);
_stats.RecordEviction(cue, scope);
return true;
}
フェード中の音を切らない
FadeOutOldestは、古い音を単純に止めると「ブチッ」と途切れる問題を避けるための挙動です。
しかし、古い音をフェードアウトさせている間も、その音はボイスを使い続けます。新しい音を鳴らすには、もう1つ空きボイスが必要です。
最初は、空きボイスがないときにフェードアウト中の音を止めて枠を作る実装にしていました。しかし、それではフェード中の音が「ブチッ」と途切れてしまい、FadeOutOldestを用意した意味がなくなってしまいます。
そこで、空きボイスがないときは、フェードアウトを始めずに新しい音のほうを捨てるようにしました。代わりに、空きボイスが足りていないことは警告ログと計測結果で分かるようにしています。
フェードしない場合は空きボイスがいらない
古い音が一時停止中の場合や、フェードアウトの秒数が0の場合は、フェードしても聞こえないため、その場で停止します。この場合はボイスがすぐに空くので、新しい空きボイスは必要ありません。
「フェードアウトできるかどうか」の判定は、ボイス側のCanFadeOut()の1か所にまとめています。
public bool CanFadeOut(float duration)
{
// NaNも比較を満たさないため、フェードアウトできないものとして扱う。
return !IsPaused && duration > 0f;
}
AudioSystemとAudioVoiceで同じ条件を別々に書くと、片方だけ直したときに食い違ってしまうためです。
上限の範囲を型で表す
Cueとカテゴリのどちらの上限に達したかは、AudioLimitScopeという列挙型で表しています。
internal enum AudioLimitScope
{
/// <summary>Cueごとの上限</summary>
Cue,
/// <summary>カテゴリごとの上限</summary>
Category,
}
最初は、計測の記録に使う「再生しなかった理由」の列挙型(AudioDiscardReason)を流用していました。しかし、その列挙型には「最短間隔」や「距離」など、上限とは関係のない値も含まれています。ありえない値を渡せてしまわないように、上限の範囲だけを表す型に分けました。
7. フェードの実装
AudioVolumeFade
フェードの状態は、AudioVolumeFadeという構造体にまとめています。
internal struct AudioVolumeFade
{
private float _fromVolume;
private float _toVolume;
private float _duration;
private float _elapsed;
/// <summary>音量を変化させている途中かどうか。</summary>
public bool IsRunning { get; private set; }
public void Begin(float fromVolume, float toVolume, float duration)
{
_fromVolume = fromVolume;
_toVolume = toVolume;
_duration = duration;
_elapsed = 0f;
IsRunning = true;
}
public float Advance(float deltaTime)
{
_elapsed += deltaTime;
if (_elapsed >= _duration)
{
IsRunning = false;
return _toVolume;
}
return Mathf.Lerp(_fromVolume, _toVolume, _elapsed / _duration);
}
}
「開始時の音量から目標の音量まで変化させる」という形にしているため、フェードインとフェードアウトを同じ仕組みで扱えます。構造体にしているので、フェードを開始してもアロケーションは発生しません。
フェードインの途中で止めた場合
フェードアウトは、開始した時点の音量から下げています。
// フェードイン中の場合も、その時点の音量から下げる。
_fade.Begin(_audioSource.volume, 0f, duration);
フェードインの途中で止めた場合でも、音量が急に飛ぶことはありません。
一時停止中はフェードを進めない
フェードの更新は、一時停止中は行いません。
public void UpdateFade(float deltaTime)
{
if (!_fade.IsRunning || IsPaused) return;
_audioSource.volume = _fade.Advance(deltaTime);
if (IsFadingOut && !_fade.IsRunning) Stop();
}
ポーズメニューでBGMを一時停止したときに、裏でフェードだけが進んでしまうと、再開したときにはもう音が消えている、ということが起きてしまうためです。
スケールされない時間を使う
フェードにはTime.unscaledDeltaTimeを使っています。Time.timeScaleを0にしてゲームをポーズしている間も、BGMのフェードなどは進めたいからです。
8. 3Dサウンドの距離判定
再生前と再生中の2回判定する
3Dサウンドは、次の2つのタイミングでリスナーとの距離を判定しています。
| タイミング | 聞こえない距離だった場合 |
|---|---|
| 再生前 | 再生しない |
| 再生中(毎フレーム) | フェードアウトして解放する |
再生中に聞こえない距離まで離れた音をその場で止めないのは、Logarithmicの減衰ではMax Distanceでも音量が0にならず、急に止めると途切れて聞こえることがあるためです。
距離の比較にはsqrMagnitudeを使い、平方根の計算を省いています。
リスナーを自動で探す
リスナーはSetListener()で指定できますが、指定がない場合はシーン内のAudioListenerを自動で探します。見つからないときに毎フレーム探すと負荷になるため、探し直すのは最大で1秒に1回にしています。
追従対象が破棄されたことを検知する
Transformに追従させて再生した音は、追従対象が破棄されるとフェードアウトして終了します。
public void UpdateFollow()
{
if (!_isFollowing || !IsActive) return;
// Unityのオブジェクトは破棄されるとnullと等価になる。
// 最初から追従していない場合と区別するため、追従中かどうかは_isFollowingで別に持つ。
if (_followTarget == null)
{
BeginFadeOut(Category.FadeOutDuration);
return;
}
_transform.position = _followTarget.position;
}
Unityのオブジェクトは、破棄されるとnullと比較して等しくなります。ただし、それだけでは「最初から追従していない」場合と区別できないため、追従中かどうかを_isFollowingで別に持っています。
ボイスの3D設定は毎回設定し直す
ボイスは2Dと3Dの両方に使い回されます。2Dで再生するときも、前回の3D設定が残らないように、spatialBlendを毎回設定し直しています。
また、弾丸のような高速な音源でピッチが変わらないように、ドップラー効果は使っていません。
9. 計測機能をリリースビルドから取り除く
計測機能は、上限値を調整するための開発用の機能です。リリースビルドで処理が残らないように、記録のメソッドにはConditional属性を付けています。
[System.Diagnostics.Conditional("UNITY_EDITOR"), System.Diagnostics.Conditional("DEVELOPMENT_BUILD")]
internal void RecordPlay(AudioCueBase cue, int cueActiveCount, int categoryActiveCount)
{
// ...
}
Conditional属性を付けたメソッドは、シンボルが定義されていないビルドでは、呼び出しそのものがコンパイル時に取り除かれます。このとき、呼び出しの引数も評価されません。
_stats.RecordPlay(cue, CountActiveVoicesOfCue(cue), CountActiveVoicesOfCategory(cue.Category));
そのため、記録のためだけに同時再生数を数え直すCountActiveVoicesOfCue()なども、リリースビルドでは実行されません。
なお、using System.Diagnostics;を書くとUnityEngine.Debugと名前がぶつかるため、属性は完全修飾名で書いています。
10. 実装で意識したこと
Narimonoの実装では、次のことを意識しました。
-
間違った使い方を型で防ぐ
2Dと3DのCueを型で分けたり、上限の範囲を専用の列挙型にしたりして、実行して初めて気づくミスをコンパイル時に見つけられるようにしました。 -
判定を1か所にまとめる
「同時再生数に数えるか」はIsActive、「フェードアウトできるか」はCanFadeOut()のように、同じ判定を複数の場所に書かないようにしました。 -
再生時のアロケーションをなくす
ボイスの使い回し、構造体のフェード、配列の線形探索などで、再生のたびのアロケーションをなくしました。 -
音を途切れさせない
上限の処理や距離による停止では、できるだけフェードアウトを使い、音が「ブチッ」と途切れないようにしました。
11. 現在の実装における制限と今後の課題
現在の実装には、次のような制限があります。
-
重要度による割り込みには未対応
上限に達したときに止める音は、再生を開始した順番だけで選んでいます。音の重要度による選択は、今後の課題です。 -
初期化前の呼び出し
AudioSystemのAwake()が呼ばれる前に操作すると、ボイスが生成されていないため例外が発生します。 -
フェードのカーブ
音量は直線的に変化します。音量を直線的に下げると、耳には終盤で急に消えたように聞こえることがあるため、カーブを指定できるようにしたいと考えています。 -
ボイスの数が非常に多い場合
ボイスの検索は線形探索のため、数百個の規模になると見直しが必要になります。現在想定している数十個程度の規模では問題ありません。
おわりに
最後まで読んでいただき、ありがとうございました!
今回は、自作サウンド再生ライブラリ「Narimono」の内部設計として、ハンドルと世代番号、上限の処理、ボイスの更新処理を紹介しました!
サウンド再生は、一見すると「AudioSourceで鳴らすだけ」の機能ですが、使い回しや同時再生数、フェードを組み合わせると、考えることが一気に増えると感じました。特に、ある問題を避けるための仕組みが、別の場所で同じ問題を起こしていないかを確認することの大切さを学びました。
NarimonoのソースコードはGitHubで公開しているので、興味のある方はぜひ覗いてみてください!
感想や改善案などがあれば、ZennのコメントやGitHubで教えていただけると嬉しいです!
InputとUpdate 昨日の知識を、今日の技術へ
来源:zenn · 游戏开发社区 · zenn.dev