Lyra音频模块深度分析:从「能出声」到「音效分级管控」的工程化之路
目录
- 你调的是音量,引擎调的是架构
- 模块总览:麻雀虽小,五脏俱全
- 数据层:先把「配置文件」写好------ULyraAudioSettings
- 逻辑层:谁在背后默默拨动音量旋钮------ULyraAudioMixEffectsSubsystem
- 数据流全景:一条音量值经历了什么才有你听到的声音
- HDR/LDR动态音效链:同一个场景,不同「解析度」的声音
- 加载屏幕的「声音帘幕」:不该出声的时候就别出声
- 数据结构设计:两个Struct就能撑起一套音频配置
- 设计模式与架构思想
- 最佳实践与扩展思路
- 写在最后
你调的是音量,引擎调的是架构
你有没有翻过大部分游戏里的音频设置面板?至少五个滑块:总体音量、音乐、音效、对话、语音聊天。玩家用手轻轻一拖,滑块的 0 到 100 就变成了一段听得见的舒适区。
看似简单。但这五个滑块背后,其实藏着一整套分级控制、动态切换、生命周期管理 的系统工程。音量值怎么从 UI 传到引擎而不阻塞主线程?加载屏幕怎么让背景音自动「让位」而不用写一堆 if-else?HDR 和 LDR 两种音频输出模式怎么一键切换?
Lyra 的 Audio 模块给了一套小而精的答案。代码量不大------两个头文件、两个实现文件------但每一行都能在真实项目里直接复用。今天我们就把它从头到尾剖开来看。
模块总览:麻雀虽小,五脏俱全
文件清单
Audio/
├── LyraAudioSettings.h / .cpp # 音频配置的数据载体
├── LyraAudioMixEffectsSubsystem.h / .cpp # 核心调度逻辑
四个文件,两个类,加上两个辅助数据结构。但就这点东西,把音量分级控制、加载屏幕音频联动、HDR/LDR音效链切换全给你包圆了。
核心组件关系图
┌────────────────────────────────────────────────────────────┐
│ UDeveloperSettings │
│ (UE5 基类) │
└──────────────────────┬─────────────────────────────────────┘
│ 继承
▼
┌────────────────────────────────────────────────────────────┐
│ ULyraAudioSettings │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Mix 配置层(3个 Bus Mix): │ │
│ │ - DefaultControlBusMix → 默认基础混音 │ │
│ │ - LoadingScreenControlBusMix → 加载屏幕专用混音 │ │
│ │ - UserSettingsControlBusMix → 用户音量设置混音 │ │
│ ├──────────────────────────────────────────────────────┤ │
│ │ Control Bus 层(5个独立总线): │ │
│ │ - Overall / Music / SoundFX / Dialogue / VoiceChat │ │
│ ├──────────────────────────────────────────────────────┤ │
│ │ Submix Effect Chain 层(HDR / LDR 两套配置): │ │
│ │ - HDRAudioSubmixEffectChain │ │
│ │ - LDRAudioSubmixEffectChain │ │
│ └──────────────────────────────────────────────────────┘ │
└──────────────────────┬─────────────────────────────────────┘
│ 作为配置源被读取
▼
┌────────────────────────────────────────────────────────────┐
│ UWorldSubsystem (UE5 基类) │
└──────────────────────┬─────────────────────────────────────┘
│ 继承
▼
┌────────────────────────────────────────────────────────────┐
│ ULyraAudioMixEffectsSubsystem │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 运行时持有的引用(全部 Transient): │ │
│ │ - DefaultBaseMix / LoadingScreenMix / UserMix │ │
│ │ - 5 个 ControlBus 强指针 │ │
│ │ - HDRSubmixEffectChain[] / LDRSubmixEffectChain[] │ │
│ ├──────────────────────────────────────────────────────┤ │
│ │ 核心方法: │ │
│ │ - PostInitialize() 配置加载 & 回调注册 │ │
│ │ - OnWorldBeginPlay() 激活Mix & 应用用户设置 │ │
│ │ - ApplyDynamicRangeEffectsChains() HDR/LDR 切换 │ │
│ │ - ApplyOrRemoveLoadingScreenMix() 加载屏幕音频联动 │ │
│ └──────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
看完这个图,你可能已经猜到这套设计的分工了:ULyraAudioSettings 管「配什么」,ULyraAudioMixEffectsSubsystem 管「什么时候配」。一个负责数据的静态定义,一个负责运行时的动态调度。这个设计理念贯穿了整个 Lyra 项目。
数据层:先把「配置文件」写好------ULyraAudioSettings
为什么继承 UDeveloperSettings 而不是直接塞 ini
不少项目喜欢把所有配置用 GConfig->GetString() 在代码里硬薅。小项目这么干问题不大,但配置项一多,散落在各处的 GetString 调用就变成了一团找不到头的毛线。
Lyra 把音频配置集中在一个 UDeveloperSettings 子类里,这样做有两个立竿见影的好处:
- 编辑器里就能配------打开 Project Settings,直接搜 "LyraAudioSettings",所有字段都有 UI 界面,策划和音频设计师点点鼠标就能调
- 自动持久化 ------
config = Game, defaultconfig标记让所有改动自动保存到DefaultGame.ini,不用手写配置文件
来看看它的完整面貌:
cpp
UCLASS(config = Game, defaultconfig, meta = (DisplayName = "LyraAudioSettings"))
class LYRAGAME_API ULyraAudioSettings : public UDeveloperSettings
{
GENERATED_BODY()
public:
// ===== Mix 配置层 =====
UPROPERTY(config, EditAnywhere, Category = MixSettings)
FSoftObjectPath DefaultControlBusMix;
UPROPERTY(config, EditAnywhere, Category = MixSettings)
FSoftObjectPath LoadingScreenControlBusMix;
UPROPERTY(config, EditAnywhere, Category = UserMixSettings)
FSoftObjectPath UserSettingsControlBusMix;
// ===== 独立音量总线 =====
UPROPERTY(config, EditAnywhere, Category = UserMixSettings)
FSoftObjectPath OverallVolumeControlBus;
UPROPERTY(config, EditAnywhere, Category = UserMixSettings)
FSoftObjectPath MusicVolumeControlBus;
UPROPERTY(config, EditAnywhere, Category = UserMixSettings)
FSoftObjectPath SoundFXVolumeControlBus;
UPROPERTY(config, EditAnywhere, Category = UserMixSettings)
FSoftObjectPath DialogueVolumeControlBus;
UPROPERTY(config, EditAnywhere, Category = UserMixSettings)
FSoftObjectPath VoiceChatVolumeControlBus;
// ===== Submix 效果链 =====
UPROPERTY(config, EditAnywhere, Category = EffectSettings)
TArray<FLyraSubmixEffectChainMap> HDRAudioSubmixEffectChain;
UPROPERTY(config, EditAnywhere, Category = EffectSettings)
TArray<FLyraSubmixEffectChainMap> LDRAudioSubmixEffectChain;
};
一个容易被忽略的细节:全部用软引用
你可能注意到了,所有字段都用的是 FSoftObjectPath。这是一种「按需加载」的策略------引擎启动时只是记下这些资源的路径,并不会立刻把它们塞进内存。直到子系统在 PostInitialize() 里调用 TryLoad() 的时候,才是真正从硬盘里取出来的时候。
你可以把它想象成图书馆的索书号:你拿着索书号(FSoftObjectPath)可以随时去书架上把书取下来(TryLoad()),但没必要一到图书馆就把所有书都抱在怀里。
三个 Mix、五个 Bus、两套 Chain ------ 配置的三层结构
把 ULyraAudioSettings 的配置拆开来看,实际上分了三层:
| 层级 | 包含内容 | 作用 |
|---|---|---|
| Mix 层 | DefaultControlBusMix、LoadingScreenControlBusMix、UserSettingsControlBusMix | 定义「什么时候」用哪套混音规则 |
| Bus 层 | Overall、Music、SoundFX、Dialogue、VoiceChat 五条 Control Bus | 定义「调什么」------每条总线分管一种音频类型 |
| Chain 层 | HDR / LDR 两套 Submix Effect Chain | 定义「输出给什么效果」------处理最终的音频输出 |
这三层的关系是这样的:Mix 控制 Bus 的增益值,Bus 控制对应音频类别的最终音量,Chain 则在音频输出的最末端叠加一层效果处理。整个通路一环扣一环,缺了哪一截声音都不对。
逻辑层:谁在背后默默拨动音量旋钮------ULyraAudioMixEffectsSubsystem
如果说 ULyraAudioSettings 是菜单,那 ULyraAudioMixEffectsSubsystem 就是那个拿着菜单给你准备一桌菜的厨师。它继承自 UWorldSubsystem,这意味着它的生命周期跟 World 绑定------World 创建它自动创建,World 销毁它自动销毁,你一行管理代码都不用写。
生命周期六步走
这个子系统的生命周期是理解整个模块的关键。六个关键节点,串成一条线:
World 创建
│
▼
ShouldCreateSubsystem() ← 只允许 Game / PIE 两种 World 类型
│
▼
Initialize() ← 调用基类,此时系统还很「早期」
│
▼
PostInitialize() ← ★★★ 关键节点 ★★★
│ 从 ULyraAudioSettings 加载所有配置
│ 注册 LoadingScreen 回调
│
▼
OnWorldBeginPlay() ← ★★★ 关键节点 ★★★
│ 激活 Mix、应用用户音量
│ 应用 HDR/LDR 效果链
│
▼
Deinitialize() ← 清理回调、移除 LoadingScreen Mix
ShouldCreateSubsystem:别在编辑器预览窗口里创建音频系统
cpp
bool ULyraAudioMixEffectsSubsystem::ShouldCreateSubsystem(UObject* Outer) const
{
bool bShouldCreateSubsystem = Super::ShouldCreateSubsystem(Outer);
if (Outer)
{
if (UWorld* World = Outer->GetWorld())
{
bShouldCreateSubsystem = DoesSupportWorldType(World->WorldType) && bShouldCreateSubsystem;
}
}
return bShouldCreateSubsystem;
}
bool ULyraAudioMixEffectsSubsystem::DoesSupportWorldType(const EWorldType::Type World) const
{
return (World == EWorldType::Game || World == EWorldType::PIE);
}
这看起来是一小段代码,但它解决了一个很实际的问题:编辑器里预览材质或动画的窗口(EWorldType::EditorPreview)不需要音频系统。如果不过滤 World 类型,每个编辑器子窗口都会创建一个音频子系统,浪费资源不说,还可能引发意料之外的行为。PIE(Play In Editor)保留是因为开发者在编辑器里跑游戏时也需要完整的音频体验。
PostInitialize:一口气把所有配置加载进来
这是整个文件里最长的一个函数,但逻辑非常线性------拿到 ULyraAudioSettings,逐个 TryLoad(),Cast 转换,存到成员变量。我们看几个关键的片段:
cpp
void ULyraAudioMixEffectsSubsystem::PostInitialize()
{
if (const ULyraAudioSettings* LyraAudioSettings = GetDefault<ULyraAudioSettings>())
{
// 加载 DefaultControlBusMix
if (UObject* ObjPath = LyraAudioSettings->DefaultControlBusMix.TryLoad())
{
if (USoundControlBusMix* SoundControlBusMix = Cast<USoundControlBusMix>(ObjPath))
{
DefaultBaseMix = SoundControlBusMix;
}
else
{
ensureMsgf(SoundControlBusMix, TEXT("Default Control Bus Mix reference missing ..."));
}
}
// ... 同样模式加载 LoadingScreenMix、UserMix
// ... 同样模式加载 OverallControlBus、MusicControlBus 等 5 条 Bus
// 加载 HDR Submix Effect Chain(遍历)
for (const FLyraSubmixEffectChainMap& SoftSubmixEffectChain :
LyraAudioSettings->HDRAudioSubmixEffectChain)
{
FLyraAudioSubmixEffectsChain NewEffectChain;
if (UObject* SubmixObjPath = SoftSubmixEffectChain.Submix.LoadSynchronous())
{
if (USoundSubmix* Submix = Cast<USoundSubmix>(SubmixObjPath))
{
NewEffectChain.Submix = Submix;
for (const TSoftObjectPtr<USoundEffectSubmixPreset>& SoftEffect :
SoftSubmixEffectChain.SubmixEffectChain)
{
if (UObject* EffectObjPath = SoftEffect.LoadSynchronous())
{
if (USoundEffectSubmixPreset* Preset = Cast<USoundEffectSubmixPreset>(EffectObjPath))
{
NewEffectChain.SubmixEffectChain.Add(Preset);
}
}
}
}
}
HDRSubmixEffectChain.Add(NewEffectChain);
}
// LDR 同 HDR 加载逻辑...
}
// 注册 LoadingScreen 回调
if (ULoadingScreenManager* LoadingScreenManager =
UGameInstance::GetSubsystem<ULoadingScreenManager>(GetWorld()->GetGameInstance()))
{
LoadingScreenManager->OnLoadingScreenVisibilityChangedDelegate().AddUObject(
this, &ThisClass::OnLoadingScreenStatusChanged);
ApplyOrRemoveLoadingScreenMix(LoadingScreenManager->GetLoadingScreenDisplayStatus());
}
}
这里有两个细节值得一提:
第一,Submix Effect Chain 用的是 LoadSynchronous() 而不是 TryLoad()。 为什么?因为 Effect Chain 是在 PostInitialize 阶段一次性全部加载的,这个时机还在游戏开始之前,同步加载不会造成玩家可感知的卡顿。而 Mix 和 Bus 用 TryLoad() 就够了------它们只是轻量级的控制参数,对时机没那么严格。
第二,ensureMsgf 的用法。 它在 Shipping 构建里会自动编译为无操作,但在开发和测试构建里会弹一个断言窗口,告诉你「Default Control Bus Mix reference missing from Lyra Audio Settings」。这对配置驱动的系统特别有用------配置配错了,不用对着崩溃日志猜,编辑器直接告诉你缺了什么。
OnWorldBeginPlay:游戏正式开始前的最后准备
cpp
void ULyraAudioMixEffectsSubsystem::OnWorldBeginPlay(UWorld& InWorld)
{
if (const UWorld* World = InWorld.GetWorld())
{
// 1. 激活默认基础 Mix
if (DefaultBaseMix)
{
UAudioModulationStatics::ActivateBusMix(World, DefaultBaseMix);
}
// 2. 读取用户设置
if (const ULyraSettingsLocal* LyraSettingsLocal = GetDefault<ULyraSettingsLocal>())
{
if (UserMix)
{
UAudioModulationStatics::ActivateBusMix(World, UserMix);
if (OverallControlBus && MusicControlBus && SoundFXControlBus &&
DialogueControlBus && VoiceChatControlBus)
{
const FSoundControlBusMixStage OverallStage =
UAudioModulationStatics::CreateBusMixStage(
World, OverallControlBus, LyraSettingsLocal->GetOverallVolume());
const FSoundControlBusMixStage MusicStage =
UAudioModulationStatics::CreateBusMixStage(
World, MusicControlBus, LyraSettingsLocal->GetMusicVolume());
// ... 其余三条 Bus
TArray<FSoundControlBusMixStage> AllStages;
AllStages.Add(OverallStage);
AllStages.Add(MusicStage);
AllStages.Add(SoundFXStage);
AllStages.Add(DialogueStage);
AllStages.Add(VoiceChatStage);
UAudioModulationStatics::UpdateMix(World, UserMix, AllStages);
}
}
// 3. 应用 HDR/LDR 效果链
ApplyDynamicRangeEffectsChains(LyraSettingsLocal->IsHDRAudioModeEnabled());
}
}
}
这个函数的执行顺序本身就是在表达设计意图:先激活默认 Mix,确保有声音 → 再激活用户 Mix,用玩家设置的音量覆盖默认值 → 最后应用 HDR/LDR 效果链。每一层都在上一层的基础上叠加,而不是互相覆盖。这是一个典型的「分层覆盖」模式------默认值打底,用户值覆盖,输出效果再套一层。
数据流全景:一条音量值经历了什么才有你听到的声音
把前面说的串起来,画一张完整的数据流图:
玩家拖动「音乐音量」滑块
│
▼
ULyraSettingsLocal::SetMusicVolume(0.8) ← 保存到本地配置
│
▼
ULyraAudioMixEffectsSubsystem::OnWorldBeginPlay()
│
│ UAudioModulationStatics::CreateBusMixStage(
│ World, MusicControlBus, 0.8)
│
▼
FSoundControlBusMixStage ← 封装「哪条Bus + 什么值」
│
│ 放入数组,和其他 Bus Stage 一起
│ UAudioModulationStatics::UpdateMix(World, UserMix, AllStages)
│
▼
Audio Modulation 系统 ← UE5 内置音频调制层
│
│ 将增益值写入对应的 Control Bus
│
▼
Audio Mixer 渲染 ← 实际处理音频信号
│
│ 音乐声道的振幅 = 原始 × 0.8
│
▼
Submix Effect Chain(HDR/LDR) ← 最后的输出效果处理
│
▼
你听到的音乐声 🎵
这条链路上没有一步是自己凭空造出来的------每一步都依赖 UE5 内建的音频调制(Audio Modulation)和音频混合器(Audio Mixer)系统。Lyra 做的事情其实是给这套底层系统包装了一层配置驱动的外壳,让策划和设计师能用编辑器的 UI 来操控,而不是对着 C++ 函数参数发愁。
HDR/LDR 动态音效链:同一个场景,不同「解析度」的声音
HDR Audio 这个概念可能很多人第一次听说。简单类比一下:就像显示器有 HDR(高动态范围)和 SDR(标准动态范围),音频也有。HDR 音频保留了更大的响度动态范围,枪声更「爆炸」,脚步声更细微,整体更有层次感。LDR(低动态范围)则压缩了这个范围,所有声音都被拉到相近的响度------在电视扬声器或嘈杂环境里反而更实用。
切换逻辑:不是简单的「删了再建」
cpp
void ULyraAudioMixEffectsSubsystem::ApplyDynamicRangeEffectsChains(bool bHDRAudio)
{
TArray<FLyraAudioSubmixEffectsChain> AudioSubmixEffectsChainToApply;
TArray<FLyraAudioSubmixEffectsChain> AudioSubmixEffectsChainToClear;
if (bHDRAudio)
{
AudioSubmixEffectsChainToApply.Append(HDRSubmixEffectChain);
AudioSubmixEffectsChainToClear.Append(LDRSubmixEffectChain);
}
else
{
AudioSubmixEffectsChainToApply.Append(LDRSubmixEffectChain);
AudioSubmixEffectsChainToClear.Append(HDRSubmixEffectChain);
}
// 找出需要单独清除的 Submix(不会被新效果链覆盖的)
TArray<USoundSubmix*> SubmixesLeftToClear;
for (const FLyraAudioSubmixEffectsChain& EffectChainToClear : AudioSubmixEffectsChainToClear)
{
bool bAddToList = true;
for (const FLyraAudioSubmixEffectsChain& SubmixEffectChain : AudioSubmixEffectsChainToApply)
{
if (SubmixEffectChain.Submix == EffectChainToClear.Submix)
{
bAddToList = false;
break;
}
}
if (bAddToList)
{
SubmixesLeftToClear.Add(EffectChainToClear.Submix);
}
}
// 应用新的效果链
for (const FLyraAudioSubmixEffectsChain& SubmixEffectChain : AudioSubmixEffectsChainToApply)
{
if (SubmixEffectChain.Submix)
{
UAudioMixerBlueprintLibrary::SetSubmixEffectChainOverride(
GetWorld(), SubmixEffectChain.Submix,
SubmixEffectChain.SubmixEffectChain, 0.1f);
}
}
// 清除剩余的不再使用的效果链
for (USoundSubmix* Submix : SubmixesLeftToClear)
{
UAudioMixerBlueprintLibrary::ClearSubmixEffectChainOverride(
GetWorld(), Submix, 0.1f);
}
}
这个算法看似简单,但实际上做了一个精巧的优化:不去清除那些即将被新效果链覆盖的 Submix。
举个例子:假设 HDR 配置里对 MasterSubmix 设了压缩器 A,LDR 配置里也对 MasterSubmix 设了压缩器 B。当你从 HDR 切到 LDR 时,如果「先清除 HDR 的压缩器 A,再应用 LDR 的压缩器 B」,中间会有一瞬间 MasterSubmix 上什么效果都没有------玩家可能听到一声短暂的爆音。
正确的做法是:直接对 MasterSubmix 设置新覆盖链,让引擎内部平滑过渡。0.1f 那个参数就是过渡时间(秒),效果链在 100 毫秒内从旧的淡出、新的淡入。而那些 HDR 有、LDR 没有的 Submix(比如 AmbientSubmix),才需要单独清除。
一个实际的业务场景
你在游戏设置里勾选了「HDR 音频」。设置面板调用 ULyraSettingsLocal::SetHDRAudioModeEnabled(true),然后通知音频子系统调用 ApplyDynamicRangeEffectsChains(true)。整个过程玩家听的感受是:声音在 0.1 秒内自然地「打开」了动态范围,而不是突兀地跳变。这个细节对沉浸感的破坏或提升,往往就在毫秒之间。
加载屏幕的「声音帘幕」:不该出声的时候就别出声
加载画面一出来,背景里可能还在播着上一关残留的环境音、UI 点击的反馈音。这些声音忽然出现忽然消失,听起来就像是游戏在「卡壳」。
Lyra 的方案很直接:加载屏幕显示的时候,激活一个特殊的 LoadingScreen Mix,主动「降噪」。加载屏幕消失的时候,再把它移除。
cpp
void ULyraAudioMixEffectsSubsystem::OnLoadingScreenStatusChanged(bool bShowingLoadingScreen)
{
ApplyOrRemoveLoadingScreenMix(bShowingLoadingScreen);
}
void ULyraAudioMixEffectsSubsystem::ApplyOrRemoveLoadingScreenMix(bool bWantsLoadingScreenMix)
{
UWorld* World = GetWorld();
if (bAppliedLoadingScreenMix != bWantsLoadingScreenMix && LoadingScreenMix && World)
{
if (bWantsLoadingScreenMix)
{
UAudioModulationStatics::ActivateBusMix(World, LoadingScreenMix);
}
else
{
UAudioModulationStatics::DeactivateBusMix(World, LoadingScreenMix);
}
bAppliedLoadingScreenMix = bWantsLoadingScreenMix;
}
}
这个标志位 bAppliedLoadingScreenMix 比你想的重要
cpp
if (bAppliedLoadingScreenMix != bWantsLoadingScreenMix && LoadingScreenMix && World)
这一行做了三次检查:
-
状态真的有变化吗? 如果之前已经应用了 LoadingScreenMix,回调又告诉你「加载屏幕正在显示」,那就什么都不做。没有这个检查,你会反复
ActivateBusMix,虽然 UE5 内部大概率会去重,但没必要让它做这判断。 -
LoadingScreenMix 加载成功了吗? 如果配置没配好(
LoadingScreenMix == nullptr),别执行后面的逻辑,不然ActivateBusMix(nullptr)可能会让你的调试日志多一条 Warning。 -
World 还存在吗? 反初始化的时候 World 可能正在销毁,
GetWorld()可能返回nullptr。
这三个检查合在一起就一句话,但它帮你挡掉了配置缺失、重复操作、时序错误三种常见问题。工程代码和水课代码的差别,往往就体现在这些细节上。
回调注册与清理:成对出现的才是好习惯
cpp
// 注册 --- PostInitialize()
LoadingScreenManager->OnLoadingScreenVisibilityChangedDelegate().AddUObject(
this, &ThisClass::OnLoadingScreenStatusChanged);
// 清理 --- Deinitialize()
LoadingScreenManager->OnLoadingScreenVisibilityChangedDelegate().RemoveAll(this);
在 PostInitialize 里注册回调,在 Deinitialize 里用 RemoveAll(this) 全部注销。这是委托系统里最常见的坑:你要是只注册不清除,World 销毁后 LoadingScreenManager 可能还会试图调用一个已经不存在的 Subsystem 上的回调------轻则 Warn,重则崩。
数据结构设计:两个 Struct 就能撑起一套音频配置
FLyraSubmixEffectChainMap:编辑期的「配置蓝图」
cpp
USTRUCT()
struct LYRAGAME_API FLyraSubmixEffectChainMap
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, meta = (AllowedClasses = "/Script/Engine.SoundSubmix"))
TSoftObjectPtr<USoundSubmix> Submix = nullptr;
UPROPERTY(EditAnywhere, meta = (AllowedClasses = "/Script/Engine.SoundEffectSubmixPreset"))
TArray<TSoftObjectPtr<USoundEffectSubmixPreset>> SubmixEffectChain;
};
这个 Struct 存活在 ULyraAudioSettings 的 TArray 里,是 HDR 和 LDR 效果链的配置载体。注意 meta = (AllowedClasses = ...) 这个细节------它在编辑器里限制了资源选择器只显示正确类型的资产,音频设计师不会不小心把一个材质贴图拖到 Submix 配置框里。
它使用 软引用 (TSoftObjectPtr),意味着只是「记下地址」,不会触发加载。真正的同步加载发生在 PostInitialize() 的遍历中。
FLyraAudioSubmixEffectsChain:运行期的「执行载体」
cpp
USTRUCT()
struct FLyraAudioSubmixEffectsChain
{
GENERATED_BODY()
UPROPERTY(Transient)
TObjectPtr<USoundSubmix> Submix = nullptr;
UPROPERTY(Transient)
TArray<TObjectPtr<USoundEffectSubmixPreset>> SubmixEffectChain;
};
它跟 FLyraSubmixEffectChainMap 字段几乎一样,但有几个关键区别:
| 属性 | FLyraSubmixEffectChainMap | FLyraAudioSubmixEffectsChain |
|---|---|---|
| 存储类型 | TSoftObjectPtr(软引用) |
TObjectPtr(强引用) |
| 生命周期 | 编辑期持久化 | Transient,仅运行时 |
| 作用 | 配置描述(「想要什么」) | 运行时持有(「现在有什么」) |
| 是否触发加载 | 否 | 是(通过 LoadSynchronous 转换而来) |
这种「配置 → 运行时」的镜像结构是 UE5 中常见的设计模式。配置层用软引用避免启动时加载全部资源,运行时层用强引用保证 GC 不会在你用的时候把资源回收了。两者之间的转换发生在 PostInitialize() 里,这就是唯一的「加载窗口」。
设计模式与架构思想
1. 「配置驱动」------ 所有行为由数据定义,而非代码分支
你在代码里看不到一行 if (IsHDRMode()) { doA(); } else { doB(); } 的分支。HDR 和 LDR 的行为差异完全由 ULyraAudioSettings 里配的两套 FLyraSubmixEffectChainMap 数组决定。ApplyDynamicRangeEffectsChains() 做的事情只是「把对应的那套数组拿出来,应用上去」。
这意味着------添加新的音频输出模式不需要改一行逻辑代码。在配置里加一套新的 Effect Chain 数组,在子系统里加一个枚举值和对应的分支,改的量极小。
2. 「分层覆盖」------ 默认值 → 用户值 → 效果链
DefaultBaseMix(激活) ← 第一层:保证能出声
↓
UserMix(激活 + 用用户设置覆盖)← 第二层:个性化调整
↓
Submix Effect Chain(应用) ← 第三层:最终效果处理
每一层只关注自己的事。改默认 Mix 不影响用户音量设置,改音量值不影响效果链。这是软件工程里最基本的「关注点分离」,但在游戏音频系统里能做到这一点的项目其实不多。
3. 「观察者模式」------ 加载屏幕联动
ULoadingScreenManager (Subject)
│
│ OnLoadingScreenVisibilityChangedDelegate
│
▼
ULyraAudioMixEffectsSubsystem (Observer)
│
│ OnLoadingScreenStatusChanged()
│
▼
ApplyOrRemoveLoadingScreenMix()
音频子系统不需要知道「加载屏幕为什么要显示」------可能是切关卡、可能是进了过场动画、可能是网络重连。它只需要知道「加载屏幕现在显示了吗?显示→激活 Mix,不显示→移除 Mix」。这就是观察者模式的精髓:解耦事件的触发方和处理方。
4. 「WorldSubsystem 生命周期」------ 零手动内存管理
选择 UWorldSubsystem 而不是自己 new/delete,好处是生命周期完全自动化。World 创建 → 自动创建 Subsystem → World 销毁 → 自动销毁 Subsystem。你不需要在 GameMode::BeginPlay 里创建,也不需要担心内存泄漏。
最佳实践与扩展思路
如果你要在自己的项目里复用这套设计
需要保留的:
UDeveloperSettings作为配置载体------编辑器可配 + 自动持久化UWorldSubsystem管理运行时行为------零成本生命周期- 软引用 (
FSoftObjectPath/TSoftObjectPtr) 作为配置层的资源引用方式 ensureMsgf在开发期校验配置完整性
可以考虑调整的:
| 当前做法 | 可替换方案 | 适用场景 |
|---|---|---|
LoadSynchronous() 加载 Effect Chain |
改为异步加载 + Bundle | 资源量大、加载耗时明显时 |
| 固定的 5 条 Control Bus | 改为可配置的数组 | 需要动态增减音频类别时 |
| HDR/LDR 二选一 | 改为多级音频质量档 | 主机/PC 性能差异大时 |
扩展方向
- 音频预设系统------不只是 HDR/LDR,可以定义「电影模式」「电竞模式」「沉浸模式」等多套预设,每套预设包含不同的 Mix + Bus + Chain 组合
- 运行时热切换 Mix ------目前 Mix 只在
OnWorldBeginPlay和加载屏幕切换时改变。可以扩展为响应更多事件,比如进入战斗、进入水下、死亡回放 - 音频 Debug 面板------把当前激活的 Mix、Bus 增益值、Effect Chain 状态可视化输出到屏幕,方便音频设计师调参
- 动态音量归一化------在用户音量设置的超低和超高区间加平滑曲线,避免 0→1 的线性放大导致某些频率段失真
写在最后
Lyra 的 Audio 模块代码量不多,但我们从头到尾捋下来发现,它其实把一套「专业游戏的音频管控应该怎么做」的思路干干净净地展示了出来:
- 配置和数据分离。 别在代码里写死音量值、路径、效果链,交给
UDeveloperSettings让非程序员去配。 - 分层思考。 默认值是一层,用户设置是一层,效果处理是一层。别把三层搅在一起。
- 系统之间用委托解耦。 音频子系统不需要知道 LoadingScreenManager 的内部逻辑,反过来也一样。一个委托就够。
- 配置驱动而非代码分支。 HDR 和 LDR 的差异不在
if-else里,在配置数组里。加新模式几乎不动逻辑代码。
最后说一句可能最重要的话:音频系统的好坏,玩家说不出来,但一定能感觉到。 音量过渡不平滑、加载屏幕有杂音、总音量调了音乐没变化------这些小问题不会让玩家关游戏,但它们会一点一点累积成「这个游戏感觉不太专业」的印象。Lyra 的这套 Audio 模块,恰恰是在处理这些「感觉问题」------而处理「感觉问题」,才是一个高级工程师的价值所在。