UE5.8 音频性能与内存优化:从一次池溢出说起

📑 目录

一、故事起点:音频池溢出的那个周五

周五下午五点半,测试服突然卡死。大厅里所有玩家还在战斗,但所有音效都消失了,只剩下背景音乐孤独地循环。打开日志,满屏都是同一句话:

SoundCue instance pool exhausted, failing to play …

同事小李把截图发到大群。那天正好是一个多人副本上线前最后一次压测,二十名玩家挤在一个大厅里,开枪、爆炸、脚步、技能提示音同时炸开。音频线程的实例池被撑爆,新的声音进不来,老的声音也停不掉。界面没有声音反馈,玩家以为游戏已经掉线,纷纷重连,结果又把服务器压了一波。

我远程连上机器,打开 Session Frontend 里的 MemReport,发现真正的音频常驻内存到了 420MB。策划的原话是,这次副本要热闹一点。没人仔细算过,热闹的成本是两百多根同时播放的 SoundCue。

这次事故之后,我们重新梳理了 UE5.8 的音频管线,从 Cook 到运行时逐一排查。本文把整理结果写下来,覆盖压缩格式、并发控制、实例限制、Streaming 策略、内存预算、平台差异、分析工具和一份可直接贴进工作流的检查清单。

二、UE5.8 音频管线的整体结构

UE5.8 的音频系统仍然以 SoundCue、SoundWave、ActiveSound 三层为核心。SoundWave 是源文件,SoundCue 是播放逻辑,ActiveSound 是运行时实例。SoundConcurrency 卡在 SoundCue 与 ActiveSound 之间,决定哪些实例可以真的进入混音器。

flowchart TD
    subgraph source ["原始资产"]
        wav[".wav 源文件"]
        cue["SoundCue 蓝图"]
    end
    subgraph cook ["Cook 阶段"]
        compress["压缩与平台编码"]
        bank["SoundBank 打包"]
    end
    subgraph runtime ["运行时"]
        pool["ActiveSound 池"]
        mixer["AudioMixer"]
        output["平台音频后端"]
    end
    wav --> compress
    cue --> compress
    compress --> bank
    bank --> pool
    pool --> mixer
    mixer --> output

这个图里没有画 SoundConcurrency,它更像一个门控。下面几节会专门讲它。

一个基础公式

未经压缩的内存占用可以直接估算:

\text{内存}(\text{字节}) = \frac{f_s \times b \times c \times t}{8}

其中 fsf_s 是采样率,bb 是位深,cc 是通道数,tt 是时长(秒)。一段 44.1kHz44.1\,\text{kHz}16bit16\,\text{bit}、双声道、十秒的音效,未压缩大约 1.68MB1.68\,\text{MB}。如果是 5.15.1 声道、48kHz48\,\text{kHz}24bit24\,\text{bit} 的环境循环,一分钟就要 49.8MB49.8\,\text{MB}。这就是为什么项目里的环境音几乎都走 Streaming。

2.1 为什么声音会吃这么多内存

很多人以为音频就是个小头。实际上一旦开始堆叠多声道、高采样率、长循环,内存会指数级上升。再加上 UE5.8 的 AudioMixer 会为每个 Source 维护解码缓冲、3D 衰减参数、混响发送、Submix 路由,一个 ActiveSound 的真实开销往往比 SoundWave 本身还大。周五的 420MB 里,源文件只占一小半,剩下是实例缓冲和混响延迟线。真正搞清楚这些开销分布,才能决定该压哪一头,而不是无脑降音质或砍数量。

三、音频压缩格式的选择

UE5.8 支持的压缩格式随平台变化。PC 与主机常见的是 Vorbis、Opus、ADPCM 和 PCM。选择时不能只看压缩比,还要考虑解码开销、CPU 占用、循环点精度和平台硬解支持。

flowchart TD
    A["选择压缩格式"] --> B{是否需要无缝循环?}
    B -->|"是"| C["ADPCM / PCM"]
    B -->|"否"| D{对 CPU 是否敏感?}
    D -->|"敏感"| E[ADPCM]
    D -->|"不敏感"| F{音质要求?}
    F -->|"高"| G["Opus / Vorbis"]
    F -->|"中"| H[Vorbis]
    F -->|"低"| I[ADPCM]

Vorbis 压缩比高,但软解会带来额外 CPU 开销。ADPCM 解压几乎免费,很多移动平台都支持硬解,适合短音效和武器音效。Opus 在 UE5.8 里的支持更完整,适合语音和音乐。PCM 只有在调试或者极短 UI 音时才值得考虑。

我们的项目里把音效分成三类:

  • UI 与交互反馈:短、频繁、低延迟,走 ADPCM。
  • 武器与爆炸:中等长度、需多层随机,走 Vorbis 质量 40 左右。
  • 语音与剧情音乐:长、对音质敏感,走 Opus 或外部 OGG Streaming。

压缩比 RR 与解码后峰值内存 MM 的关系可以写成:

M=SR×αM = \frac{S}{R} \times \alpha

SS 是源文件大小,α\alpha 是解码缓冲系数,Vorbis 通常在 1.21.21.51.5 之间。别只看 Cook 后 pak 变小,运行时的解码缓冲和解码线程开销同样是成本。

3.1 压缩质量的 AB 测试

不要凭感觉定压缩质量。我们曾经把 Vorbis 质量从 40 降到 20,pak 体积少了 35%,但策划听不出明显差异。测试方法是在游戏里做盲听:同一音效随机播放高低两种质量,让音效师打分。如果 20 分以上听不出,就用低质量。这个流程每季度跑一次,避免无效的高码率占用内存。

四、Sound Concurrency 并发控制

回到周五的事故。二十名玩家同时开枪,每把枪有 Fire、Tail、Mech 三层,每层又触发多个 Random 节点。没有并发限制时,单个事件能在几帧内繁衍出十几个 ActiveSound。Sound Concurrency 就是用来刹车的。

sequenceDiagram
    participant E as "游戏事件"
    participant C as SoundConcurrency
    participant P as "ActiveSound 池"
    participant M as AudioMixer

    E->>C: 请求播放 枪声 Layer A
    C->>P: 查询当前同组实例数
    P-->>C: 已达 Max Count
    C->>P: 按 ResolutionRule 停止最安静实例
    P-->>C: 腾出槽位
    C->>P: 创建新实例
    P->>M: 提交到混音器

Sound Concurrency 的关键参数有这几个:

  • Max Count:同一并发组最多同时存在多少个实例。
  • Limit To Owner:是否按拥有者拆分计数。玩家自己的枪声和队友的枪声应该分别限制。
  • Resolution Rule:超出上限时怎么办。常用的是 Stop Quietest、Stop Lowest Priority、Prevent New。
  • Retrigger Time:同一实例再次触发前的最小间隔,防止连点造成爆音。

我们给武器开火建立了一个叫 Weapon_Fire 的并发组,Max Count 设为 4,Limit To Owner 打开。这样每个玩家最多同时听到 4 层自己的枪声,其余被截断或按音量替换。大厅里二十人不会叠加成 80 层,而是被限制在每人 4 层。

一个常见的坑是 Resolution Rule 选错。Stop Quietest 适合环境音,武器音效如果选了 Stop Oldest,可能会把刚开火的音头切掉,听起来像啪的一声被吞了。我们后来改成 Stop Quietest 加 Volume Weighting,优先级用 P=Vatt+PriorityP = V_{att} + \text{Priority} 综合评估,VattV_{att} 是衰减后音量,Priority 是资产里填的优先级值。

4.1 并发组的设计粒度

并发组不要建得太粗,也不要太细。太粗会出现不同武器互相抢槽位,太细会让管理变成灾难。我们的做法是按玩家感知分组:Weapon_Fire、Weapon_Explosion、Footstep、Skill_Cast、UI_Alert、Voice_Dialogue、Ambience_Loop。每个组有独立的 Max Count 和 Resolution Rule。

同一个 SoundCue 也可以挂多个 Concurrency 组。比如一个爆炸音效同时属于 Weapon_Explosion 和 Ambience_Large,前者限制数量,后者保证大环境音总量不超标。UE5.8 会取所有组里最先触发的限制。

4.2 Retrigger Time 与连点问题

连点武器最容易出现音头重叠。没有 Retrigger Time 时,玩家快速点击会在同一帧内创建多个实例,听起来是浑浊的一团。把 Retrigger Time 设为 50ms50\,\text{ms}100ms100\,\text{ms},可以让相同 Layer 在音尾自然衰减期间不再新建实例。

Retrigger Time 也不能设得太大。如果超过 200ms,玩家会感觉射击反馈变慢,特别是在高射速武器上。我们的突击步枪用 60ms,手枪用 80ms,狙击枪因为射速慢,直接用 150ms。

五、实例限制与优先级

并发组管的是同类声音,但整个引擎还有一个全局 ActiveSound 池。UE5.8 的默认池大小可以在 AudioSettings 里调整,字段叫 MaximumConcurrentStreams 和 Max Active Sounds。这个数字不是越大越好,每个 ActiveSound 都占内存和 CPU,超出的实例会直接失败播放。

graph LR
    A["全局 ActiveSound 池"] --> B["音乐 / 环境"]
    A --> C["语音 / 剧情"]
    A --> D["武器 / 技能"]
    A --> E["UI / 反馈"]
    B -->|"预算"| F[30%]
    C -->|"预算"| G[20%]
    D -->|"预算"| H[35%]
    E -->|"预算"| I[15%]

我们的预算分配大致是上图这样。实际运行时,Audio 线程每帧都会按优先级排序,低优先级的实例先被回收。优先级由三部分决定:

  1. 资产里 SoundBase Priority 的基值。
  2. SoundConcurrency 里的 Volume Weighting。
  3. 到听者距离的衰减,通常用 A(d)=11+(d/r)2A(d) = \frac{1}{1 + (d/r)^2} 这种逆平方或线性模型。

不要在所有音效上都填 Priority = 1.0,那样等于没填。我们把 UI 提示设为 2.02.0,剧情语音 1.51.5,玩家自己的脚步声 1.21.2,环境循环 0.60.6,远处的爆炸 0.40.4。这样池子紧张时,系统知道该先保谁。

5.1 优先级饥饿

如果环境循环优先级太低,而武器音效一直占满池子,环境音会被持续挤掉,玩家会感觉场景突然安静。我们加了最低保留槽位:环境音至少保留 6 个 Source,UI 至少保留 4 个。这样即使武器很忙,基础层也不会完全消失。

六、Streaming 与预加载的权衡

长音频有两种加载思路。预加载是一口气读到内存,播放延迟低,但占用大。Streaming 是按块读,内存小,但播放有 IO 延迟,且对磁盘和文件系统有持续压力。

flowchart TD
    subgraph memory ["内存模式"]
        preload["预加载 SoundWave"]
        stream["Streaming SoundWave"]
    end
    subgraph disk ["IO 模式"]
        oneshot["一次性读取"]
        chunk["分块读取"]
    end
    preload --> oneshot
    stream --> chunk

决策依据主要看时长和播放频率:

  • 小于 3 秒、频繁触发:预加载,ADPCM 或 Vorbis。
  • 3 到 30 秒、循环:看平台内存,宽裕就预加载,紧张就 Streaming。
  • 大于 30 秒、音乐或语音:一律 Streaming。

Streaming 还有两个细节。一个是 Stream Cache Size,默认可能只有几 MB,音乐一多就会频繁换页。我们在 DefaultEngine.ini 里按平台调整:

[/Script/Engine.AudioSettings]
StreamCacheSizeKB=8192

另一个是 Seek 操作。UE5.8 的 Streaming SoundWave 支持 Seek,但 Seek 会触发一次 IO 跳跃,如果文件在机械硬盘或者 Switch 卡带上,卡顿明显。剧情对话的 Seek 我们只在菜单回放里用,战斗中禁用。

七、音频内存预算与监控

音频内存没有银弹,只有预算。我们的预算分三层:源资产内存、解码缓冲内存、运行时实例内存。总预算在 PC 上定 300MB,主机 250MB,Switch 120MB,手机 80MB。每周 CI 跑一次 MemReport -full,把音频部分截出来和预算表对比。

内存预算的数学表达可以写成:

Btotal=Bpreload+Bstream+Binstance+BreverbB_{\text{total}} = B_{\text{preload}} + B_{\text{stream}} + B_{\text{instance}} + B_{\text{reverb}}

BpreloadB_{\text{preload}} 是预加载 SoundWave 占用,BstreamB_{\text{stream}} 是 Streaming 缓冲,BinstanceB_{\text{instance}} 是 ActiveSound 和 SoundSource 开销,BreverbB_{\text{reverb}} 是混响效果器的延迟线。混响在 UE5.8 里默认用 Submix Effect,延迟线长度取决于采样率和混响时间,一个 3 秒的立体声混响在 48kHz48\,\text{kHz} 下就要 1.15MB1.15\,\text{MB}

Cook 完成后可以用 UnrealPak 看音频在 pak 里的体积,但这不等于运行时内存。真正的内存要用 stat audio 和 memreport 一起看。

7.1 自动化报警

我们把 MemReport 里的 Audio 行用脚本解析,超过预算 90% 就在企业微信报警。脚本逻辑很简单,读取日志里的 Audio 字段,和项目里的 budget.json 比较。这样策划新增一段长音乐时,第二天就能收到提醒,而不是等到压测才爆发。

7.2 关卡切换时的峰值

音频内存最容易在关卡切换时飙高。旧关卡的 SoundWave 还没卸载,新关卡的背景音乐已经开始 Streaming,两头的缓冲同时存在。我们在切换点手动调用 Garbage Collection,并把 Stream Cache 清空。UE5.8 里可以用 UKismetSystemLibrary::CollectGarbage 加上 FlushStreamedFilesInPackage 配合完成。

八、平台音频差异

不同平台的音频后端和内存策略差别很大。

PC 用 XAudio2,支持软解和任意声道,内存最宽裕,也最容易偷懒。主机上 PS5 的 Audio3D 有硬件加速,3D 音效尽量走硬件路径;Xbox 的 XAudio2 也有类似能力。Switch 的内存和 CPU 都紧张,长音频必须 Streaming,短音效用 ADPCM 并开硬件解码。手机上 Android 的 OpenSL ES / AAudio 对低延迟支持参差不齐,要避免过多 3D 音源。

我们在 Switch 上踩过一个坑:某段背景音乐用了 5.1 声道的 Vorbis,Switch 不支持 5.1 软解实时上混,结果 Cook 时默默转成了 Stereo,但 SoundWave 上的 IsAmbisonics 标记没清,运行时报错。解决方法是给 Switch 单独建一个平台覆盖,把音乐改成 Stereo Opus Streaming。

平台差异还体现在采样率。主机很多场景跑 48kHz48\,\text{kHz},手机为了省电可能只跑 44.1kHz44.1\,\text{kHz}。如果源文件是 48kHz48\,\text{kHz},在手机端播放时会重采样,消耗 CPU。可以在 Cook 时按平台生成不同采样率的版本。移动端还要特别注意蓝牙耳机的延迟,部分 Android 机型在 A2DP 下延迟高达 200ms 以上,涉及节奏反馈的音效最好做本地预缓冲。

8.1 平台覆盖示例

在项目的 DefaultEngine.ini 里,可以按平台写 Audio 覆盖:

[/Script/Engine.AudioSettings]
+PlatformSettings=(PlatformName="Switch", StreamCacheSizeKB=4096, MaxConcurrentStreams=64)
+PlatformSettings=(PlatformName="Android", StreamCacheSizeKB=6144, MaxConcurrentStreams=96)

这样 Switch 的 Streaming 缓冲更小,并发数也更低,避免内存爆掉。

九、分析工具

UE5.8 里分析音频性能和内存的工具主要有这些:

  • stat audio:看 ActiveSound 数量、SoundSource 数量、CPU 时间。
  • stat soundwaves:看当前加载的 SoundWave 数量和内存。
  • MemReport -full:导出完整内存报告,搜索 Audio 段。
  • Session Frontend Memory:图形化查看内存增长曲线。
  • SoundCue Editor 的 Play 计数:看一个 Cue 被触发的次数。

最常用的是 stat audio。重点关注三个数字:

  1. Active Sounds:当前活跃的 ActiveSound 数量。
  2. Sources:实际进入平台后端的音源数,可能小于 Active Sounds。
  3. Audio Thread Time:音频线程每帧耗时,主机上超过 2ms2\,\text{ms} 就要警惕。

还有一个冷门但好用的命令:au.Dump.ActiveSounds 1,会把当前所有 ActiveSound 打印到日志,包括优先级、音量、所属 SoundCue。周五那次我们就是靠它定位到某个武器 Cue 一次触发了 14 个实例。

9.1 自定义内存 Dump

如果 stat 不够细,可以在项目里加一个控制台命令,遍历所有 USoundWave 并打印加载状态和内存占用。下面是一段参考实现:

void UMyAudioDebug::DumpSoundWaves()
{
    for (TObjectIterator<USoundWave> It; It; ++It)
    {
        USoundWave* Wave = *It;
        const float MB = Wave->GetResourceSizeBytes(EResourceSizeMode::Exclusive) / 1024.f / 1024.f;
        UE_LOG(LogTemp, Log, TEXT("%s: %.2f MB, Streaming=%d"),
            *Wave->GetName(), MB, Wave->IsStreaming());
    }
}

这个命令在编辑器里跑一遍,经常能发现策划忘记关 Streaming 的长音效,或者程序员预加载了不该预加载的音乐。我们把它绑定到了一个控制台命令 au.DumpSoundWaves,每次提交音频资源前QA会跑一遍,输出超过 5MB 的资产就直接打回。

十、可直接用的优化检查清单

  • [ ] 所有短音效使用 ADPCM 或低质量 Vorbis,Cook 后检查 pak 体积。
  • [ ] 所有长音频(超过 30 秒)开启 Streaming,并设置合理的 Stream Cache。
  • [ ] 每个频繁播放的 SoundCue 都挂上 SoundConcurrency,Max Count 不超过 8。
  • [ ] Limit To Owner 在玩家相关音效上打开,避免多人场景叠加。
  • [ ] Resolution Rule 根据音效类型选择,武器避免 Stop Oldest。
  • [ ] 全局 ActiveSound 池大小按平台设置,PC 不超过 256,主机 192,Switch 128。
  • [ ] 为每个音效类别分配 Priority,避免所有资产填 1.0。
  • [ ] 平台覆盖里检查采样率和声道数,Switch 和移动端避免 5.1 与 48kHz48\,\text{kHz} 不兼容。
  • [ ] 每周用 MemReport -full 核对音频内存,超预算 10% 就报警。
  • [ ] 每次压测前跑 stat audio,Active Sounds 超过池大小 80% 就排查。

10.1 谁该对音频内存负责

这个问题在事故后吵了两天。最后定下的规则是:音效师对源文件质量和长度负责,客户端程序员对压缩设置和 Streaming 策略负责,策划对播放频率和并发需求负责。三方在每次新增音频资产时都要在检查清单上签字。责任不划清,优化就会互相推诿。清单存在飞书文档里,每条音频资产的状态都对应一行记录,谁改的、什么时候改的、有没有超预算,一目了然。

十一、一个完整的排查流程

当再次遇到音频卡顿或池溢出时,可以按下面步骤排查,避免慌乱。

打开 stat audio,记录 Active Sounds 和 Sources 的峰值。如果 Sources 接近或达到上限,说明池子确实满了。接着执行 au.Dump.ActiveSounds 1,把日志里实例数最多的 SoundCue 列出来。然后检查这些 Cue 是否挂了 SoundConcurrency,Limit To Owner 是否开启。再打开 stat soundwaves,看有没有不该预加载的长音效占用了大量内存。最后用 MemReport -full 确认总音频内存是否超出平台预算。

这个流程在我们项目里已经写进了音频 QA 规范,并成为版本封包前的必做项。每次版本封包前,音频师和客户端程序员各跑一遍,签字确认没有异常。事实证明,每次花二十分钟跑这套流程,能避免周五晚上那种全员加班的事故。

结语

周五的事故后来没有再发生。不是因为把音频全关了,而是给每类声音划清了边界:什么时候进内存、什么时候被替换、什么时候直接丢弃。音频优化和渲染优化一样,核心都是做预算、做取舍。把这套检查清单贴进项目的音频规范,每次压测前对照一遍,就能多一分底气。优化不是一次性任务,而是每次新增资产时都要重复的小动作。长期坚持做下来,音频这块就不会再成为上线前的拦路虎。