详解自制 Unity 音频播放库「Narimono」的内部设计!句柄与同时播放数的机制
自作Unityサウンド再生ライブラリ「Narimono」の内部設計を解説!ハンドルと同時再生数の仕組み
引言
你好,我是空音!
在上一篇文章中,我介绍了面向 Unity 的音频播放库「Narimono(鳴物)」的发布。
这次作为第二弹,我想更详细地介绍一下 Narimono 的内部设计与实现!
这次要介绍的是开发过程中我特别在設計上费心思考的以下三点。
- 用于安全操作已播放声音的「句柄与世代编号」
- 在不中断声音的情况下限制同时播放数量的「上限处理」
- 支撑淡入淡出与3D音效的「Voice更新处理」
1. Narimono的整体结构
Narimono 的整体结构如下。
AudioSystem(MonoBehaviour)
├─ AudioVoice × Max Voice Count
│ ├─ AudioSource(子オブジェクト)
│ └─ AudioVolumeFade(フェードの状態)
├─ 再生の判定(Cue・カテゴリ・全体の上限)
└─ AudioStats(計測結果)
| 类 | 役割 | 公開 |
|---|---|---|
AudioSystem |
播放与停止的入口。进行上限判定,并执行每帧更新。 | 公開 |
AudioPlayback |
指向所播放音频的句柄 | 公開 |
AudioVoice |
将 AudioSource 与播放状态整合在一起的、同时播放的一个槽位 | 内部 |
AudioVolumeFade |
用于在一定时间内改变音量的结构体 | 内部 |
其中用户直接接触的是AudioSystem和AudioPlayback,而AudioVoice和AudioVolumeFade则设为internal。
另外,Narimono 中所说的发声单元并不是角色的声音,而是“可同时发声的一个槽位”。这里与电子乐器的“同时发音数”含义相同。
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,而是复用空闲的 voice,从而消除了每次播放时的分配开销。
另外,还为 AudioSystem 添加了 [DefaultExecutionOrder(-100)]。这是为了让即使从其他组件的 Awake() 调用 Play(),voice 也已经处于生成完毕的状态。
voice 的查找采用线性搜索
查找空闲 voice 或最旧声音的处理,全部都是从数组开头依次检查的线性搜索。
voice 的数量最多也就几十个,因此比起用字典或列表来管理,直接遍历数组更简单,也不会产生分配。
Cue 和类别中 Count 系、Find 系的处理以相似的形式并列在一起,但如果用委托来共通化就会产生分配,因此特意将它们分开。
3. 旧句柄误操作了其他声音的问题
复用 AudioSource 引发的问题
复用 voice 会引发以下问题。
1. 音Aを2番のボイスで再生する(ハンドルA)
2. 音Aが鳴り終わり、2番のボイスが空く
3. 音Bを、空いた2番のボイスで再生する
4. 古いハンドルAで Stop() を呼ぶ
→ 2番のボイスで鳴っている音Bが止まってしまう
如果句柄只持有“是第几号 voice”,就无法区分该 voice 是否已被复用于其他声音。
让句柄持有世代编号
因此,句柄(AudioPlayback)除了 voice 编号之外,还持有世代编号。
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 |
世代编号不会回退,即使停止也不会初始化。
判定句柄是否指向当前的播放
用 handle 进行操作时,通过 voice 的Matches()确认 handle 是否指向当前正在播放的内容。
public bool Matches(AudioPlayback playback)
{
return IsAssigned && Index == playback.VoiceIndex && Generation == playback.Generation;
}
例如,对于 2 号 voice,会按如下方式判定。
| 出来事 | voice 的世代编号 | 結果 |
|---|---|---|
| 播放声音 A(handle A=2 号・世代 1) | 0 → 1 | |
Stop(ハンドルA) |
1 | 停止后,voice 变为空闲 |
再来一次Stop(ハンドルA)
|
1 | 由于 voice 空闲,所以什么都不做 |
| 播放声音B(句柄B=2号・世代2) | 1 → 2 | |
旧的Stop(ハンドルA)
|
2 | 由于世代编号不一致,所以什么都不做 |
| 开始声音B的淡出 | 2 → 3 | 句柄B也会失效 |
IsAssigned和世代编号各自区分不同的情况。
- 世代编号:用于区分 voice 被复用于其他声音,或因淡出而被释放的情况
- IsAssigned:用于区分声音已停止、voice 处于空闲状态的情况(因为在停止时不会推进世代编号)
仅靠其中任意一方,无法区分所有情况。
淡出开始时也要推进世代编号的理由
已经开始淡出的声音,对库来说就是「已经放手的声音」。
如果使用方对正在淡出的声音再次Pause()或Stop(),好不容易进行的淡出就会中途停止。因此,在开始淡出的时点推进世代编号,并使此前的句柄失效。
没有做成全局唯一 ID 的理由
也可以把句柄做成「全局唯一的 ID」,但那样的话,为了从 ID 查找 voice 就需要一个字典。
如果做成 voice 编号与世代编号的组合,用数组下标就能一次取出 voice,因此既不需要查找也不需要分配。
此外,AudioPlayback 实现了 IEquatable<AudioPlayback>,因此在 == 中比较或用作 Dictionary 的键也不会发生装箱。
4. 播放判定流程
Play()按以下顺序进行判定,如果不满足条件,则不播放并返回AudioPlayback.None。
- 参数与 Cue 的设置是否正确
-
是否已经过
Min Interval - 距离监听器是否在
Max Distance以内(仅限 3D) - Cue 的上限
- Category 的上限
- 是否有空闲的 Voice
4〜6 是通过确保 Voice 的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 的上限而停止旧声音时,由于该声音也属于同一 Category,Category 的同时播放数也会减少 1。因此,不会出现刚通过 Cue 的上限腾出空位后、又因 Category 的上限再停止一个声音的情况。
Min Interval 仅在成功播放时记录
用于Min Interval判定的播放时刻,只在实际成功播放时才记录。因为如果连因上限等原因而未播放的声音的时刻也记录下来,就会导致到下一次播放为止产生不必要的等待。
另外,由于Min Interval为0的Cue无需判定,因此不记录播放时刻,也不进行词典的检索和更新。
5. 同时播放数的计数方式
在统计同时播放数时,使用的是语音的IsActive。
/// <summary>割り当て済みで、フェードアウト中でもないかどうか。</summary>
public bool IsActive => IsAssigned && !IsFadingOut;
各语音状态的处理方式如下。
| 语音状态 | 是否计入同时播放数 | 是否正在使用语音 |
|---|---|---|
| 再生中 | 计数 | 正在使用 |
| 一時停止中 | 计数 | 正在使用 |
| 淡出中 | 不计入 | 正在使用 |
| 空闲 | 不计入 | 未使用 |
正在淡出的声音已经是即将消失的声音,因此不计入同时播放数。不过,在播放结束之前会继续使用 voice。“是否计入”和“是否正在使用 voice”并不一致,这是考虑后续上限处理时的重要要点。
寻找最古老的声音
达到上限时要停止的声音,是开始播放的顺序(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是一种行为,用于避免单纯停止旧声音时出现“啪”一下中断的问题。
但是,在让旧声音淡出的期间,该声音仍会继续占用 voice。要播放新声音,还需要另一个空闲 voice。
最初,我们采用的实现是:在没有空闲 voice 时,停止正在淡出的声音来腾出位置。但这样一来,淡出中的声音还是会“啪”一下中断,准备 FadeOutOldest 就失去意义了。
因此,我们改为在没有空闲 voice 时,不开始淡出,而是丢弃新声音。取而代之,我们让空闲 voice 不足这一点可以通过警告日志和测量结果得知。
不进行淡出时不需要空闲 voice
当旧声音处于暂停中,或淡出秒数为 0 时,即使淡出也听不到,因此直接当场停止。这种情况下 voice 会立即空出来,所以不需要新的空闲 voice。
关于「能否淡出」的判定,统一集中在语音侧的CanFadeOut()这一处。
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 音效的距离判定
在播放前和播放中共判定两次
3D 音效会在以下两个时机判定与听者之间的距离。
| 时机 | 处于听不到的距离时 |
|---|---|
| 再生前 | 不播放 |
| 播放中(每帧) | 淡出后释放 |
播放过程中,即使声音远离到听不见的距离,也不当场停止,是因为按照Logarithmic的衰减,即使到Max Distance音量也不会变为0,而突然停止有时会听起来像是断掉了。
距离比较时使用sqrMagnitude,省去了平方根的计算。
自动查找监听器
监听器可以通过SetListener()指定,但如果没有指定,则会自动查找场景内的AudioListener。由于在找不到时每帧都查找会造成负担,因此重新查找最多每秒进行 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、把上限范围做成专用的枚举类型等方式,让只有运行时才会发现的错误能够在编译时被发现。 -
把判定集中到一处
像「是否计入同时播放数」用IsActive、「能否淡出」用CanFadeOut()这样,避免在多个地方写相同的判定。 -
消除播放时的内存分配
通过复用 voice、结构体形式的淡出、数组的线性查找等方式,消除了每次播放时的内存分配。 -
不让声音中断
在处理上限或按距离停止时,尽量使用淡出,避免声音「啪」地一下中断。
11. 当前实现中的限制与今后的课题
当前的实现存在以下限制。
-
尚未支持按重要度进行打断
达到上限时停止哪个声音,仅按照开始播放的顺序来选择。按声音的重要度进行选择,是今后的课题。 -
初始化前的调用
如果在AudioSystem的Awake()被调用之前进行操作,由于语音尚未生成,会发生异常。 -
淡入淡出的曲线
音量是线性变化的。线性降低音量时,听感上有时会觉得在结尾处突然消失,因此希望将来能够指定曲线。 -
语音数量非常多时
语音的检索采用线性查找,因此在达到数百个的规模时就需要重新审视。目前设想的数十个左右的规模则没有问题。
结语
感谢您读到最后!
这次,作为自制声音播放库“Narimono”的内部设计,我介绍了句柄与世代编号、上限的处理、voice 的更新处理!
声音播放乍看之下只是“用 AudioSource 播放”的功能,但一旦把复用、同时播放数量和淡入淡出组合起来,需要考虑的事情就会一下子变多。尤其是,我学到了要确认为避免某个问题而设计的机制,是否在别的地方引发了同样的问题的重要性。
Narimono 的源代码已在 GitHub 上公开,感兴趣的人一定要去看看!
如果有感想或改进建议,欢迎在 Zenn 的评论或 GitHub 上告诉我!
Input 与 Update 把昨天的知识,变成今天的技术
来源:zenn · 游戏开发社区 · zenn.dev