UE语音管线

VGT 语音管理系统技术详解

前文

在当前工作中，我们可以发现对于游戏语音来说，配置与测试是一个很大的痛点。在我工作的环境里，一个语音从产出资源到最后配到游戏里需要三方人员的协调：策划提供需求 → 音频设计提供资源 → 音频设计配置 Wwise → 程序配置到游戏中 → 策划检验效果。

可以发现这个流程是比较冗长的，而且每次调整都需要多个部门的参与，效率比较低。另外，Wwise 虽然功能强大，但对于复杂的播放逻辑配置起来还是比较繁琐的，特别是涉及到序列播放、条件筛选这些功能时。

因此我们自研了一套 UE 引擎的语音管线 "VGT"，其底层仍然基于 Wwise 进行解码播放（毕竟 Wwise 的音频处理能力还是很强的），但上层我们完全重新设计了资源管理、播放逻辑控制，还加了一些实用的支持性系统（包括蓝图支持，回调系统，Sequence 系统，音画同步系统等）。这套管线不仅解决了上面提到的协作问题，也让我们可以摆脱复杂的 Wwise 工程配置，同时在资源管理和加载效率上也做了很多优化。

核心系统模块

UI 模块

VGT 提供了 UI 界面来帮助用户快速的导入资源与测试播放。考虑到策划同学更习惯用 Excel，所以我们做了 Excel 数据驱动的配置方式。导入的 wav 语音文件会借用 Wwise 的转码工具转换为 wem 格式。

从界面可以看到，整个操作流程还是比较直观的，策划可以直接导入、配置和测试，不需要等程序或者音频设计师的支持。

资源管理模块

大型游戏的语音数量确实是个问题，动辄几万条语音，如果全部加载到内存肯定是不现实的。我们参考了 Wwise 的 SoundBank 思路，设计了 "Module" 架构。

每个 Module 就像一个独立的语音包，可以根据游戏需要动态加载和卸载。比如某个关卡的语音可以打包成一个 Module，进入关卡时加载，离开时卸载。Module 的数据结构定义如下：

Copy
struct VoiceModuleData{
FString moduleName;
FString modulePath;

int32 maxPlayedEventInstanceNum;
int32 maxSavedEventInfoNum;

// 此模块中可播放的语音
TMap<FString, TArray<TSharedPtr<EventData>>> eventMap;
// 此模块中每条语音对应的容器类型（随机容器，顺序容器...）
TMap<FString, int32> eventTypeMap;
}

对于本地转码后的 wem 语音资源，我们给每个文件分配了唯一的 ID，这样可以避免过多的字符串操作带来的性能损耗。同时为了快速定位文件路径，我们设计了一套基于 AVL 树的路径管理系统：

graph TD; A["Root Directory: \RootPath"] --> B["\Path1"]; A --> C["\Path2"]; A --> D["\Path3"]; B --> E["ID区间: [1-100]"]; B --> F["ID区间: [101-200]"]; C --> G["ID区间: [201-300]"]; C --> H["ID区间: [301-400]"]; D --> I["ID区间: [401-500]"]; D --> J["ID区间: [501-600]"]; %% 示意图展示了如何用AVL树组织语音文件的目录结构 %% 每个目录节点下维护多个ID区间 %% 通过平衡树结构实现快速查找

这样设计的好处是，当我们需要根据语音 ID 查找对应文件时，可以快速定位到对应的目录路径，查找效率基本是 O (log n) 级别的。

对于 Event 信息的管理，我们定义了这样的数据结构：

Copy
struct EventInfo{
int32 ResourceID; // 为资源分配的唯一ID
int32 Order; // 顺序播放相关参数
int32 Probability; // 随机播放权重
int32 AdditionalFieldsNum; // 额外筛选条件数量
}

Event 信息会存储在对应的 Module 目录下，按需加载。

资源播放模块

为了减轻内存压力，我们采用了 Streaming 播放的方式。这样就不需要把所有语音文件都预加载到内存中了。

底层播放的实现还是基于 Wwise 的 External Source 机制：

Copy
// 根据音频路径构建externalSource
TArray<AkOSChar> FullPath;
FullPath.Reserve(fullPath.Len() + 1);
FullPath.Append(TCHAR_TO_AK(*fullPath), fullPath.Len() + 1);
AkExternalSourceInfo externalSourceInfo(FullPath.GetData(), externalSrcCookie, AKCODECID_VORBIS);
TArray<AkExternalSourceInfo> externalSources;
externalSources.Add(externalSourceInfo);

//播放
playingID = AkAudioDevice->PostEventOnGameObjectID(eventID, akGameObjectID, AK_EndOfEvent | AK_EnableGetSourcePlayPosition, &FVoicePlayer::OnEventCallback, Data.Get(), externalSources);

但关键是在上层，我们完全自己实现了一套播放逻辑控制系统。这套系统基本上复现了 Wwise 的 Random Container 和 Sequence Container 的功能，但是更加灵活，也更容易扩展。

我们实现了 RandomContainer（随机播放容器）、SequenceContainer（顺序播放容器）和 SingleContainer（单次播放容器）。系统会根据 Module 中存储的 EventType 来判断使用哪种播放策略。

以 RandomContainer 为例，我们实现了完整的权重随机算法，还加了防重复播放的机制：

Copy
float TotalPercentage = 0.0f;
for (const TSharedPtr<RandomEventConfig>& Config : EventConfigs)
{
TotalPercentage += Config->Probability;
}
// 如果所有权重加起来不是100，就归一化处理
if (TotalPercentage != 100.0f)
{
if (fabs(TotalPercentage) < FLT_EPSILON)
{
// 如果都是0，就平均分配
for (const TSharedPtr<RandomEventConfig>& Config : EventConfigs)
{
Config->Probability = (1.0 / EventConfigs.Num()) * 100.0f;
}
}
else
{
// 否则按比例归一化
for (const TSharedPtr<RandomEventConfig>& Config : EventConfigs)
{
Config->Probability = (Config->Probability / TotalPercentage) * 100.0f;
}
}
}

// 筛选出还可以播放的语音（排除已经播放过的）
float AvailableTotalPercentage = 0.0f;
TArray<TSharedPtr<RandomEventConfig>> AvailableConfigs;
for (auto i = 0; i < EventConfigs.Num(); ++i)
{
if (EventConfigs[i]->bShouldReplaced)
{
continue;
}
AvailableTotalPercentage += EventConfigs[i]->Probability;
AvailableConfigs.Add(EventConfigs[i]);
}

// 如果所有语音都播放过了，就重置
if (AvailableTotalPercentage <= 0)
{
ForceRefreshPlaySet();
return PlayVoice(akGameObjectID, trackID, percent, callback, pParam);
}

// 根据权重随机选择
float RandomNumber = FMath::RandRange(0.0f, AvailableTotalPercentage);
float AccumulatedPercentage = 0.0f;
for (const TSharedPtr<RandomEventConfig>& Config : AvailableConfigs)
{
AccumulatedPercentage += Config->Probability;
if (RandomNumber < AccumulatedPercentage)
{
if (Config->bShouldReplaced == true)
{
continue;
}
PlayVoice();
break;
}
}

SequenceContainer 的实现也是类似的思路，维护一个播放索引，按顺序播放语音，支持循环播放、跳转等功能。

为了方便程序调用，我们还设计了一套统一的回调系统。这个系统会集中处理 Wwise 的回调事件，根据不同的上下文触发对应的回调函数，C++ 和蓝图都支持：

性能优化与线程安全

性能优化方面，我们做了几个关键的优化：

首先是延迟加载。我们不会把所有 Module 的信息一次性加载到内存中，而是在需要的时候才加载。

其次是数据格式优化。由于每个 Module 的 Event 信息很多，JSON 反序列化确实是个性能瓶颈。所以我们在打包时会把 JSON 转换为二进制格式，运行时直接映射到虚拟内存中，只解析每个 Event 在数据中的偏移位置，需要的时候直接访问：

Copy
GetEvent(eventName) -> LoadEventOffsetMap() -> LoadBinaryEventConfigMap():

TUniquePtr<FArchive> DataFileReader(IFileManager::Get().CreateFileReader(*EventConfigPath));
DataFileReader->Seek(EventDataOffsetMap[eventName]);
ResultConfig->LoadBinary(DataFileReader);

return ResultConfig;

这样可以大大减少内存占用和加载时间。

线程安全这块，我们采用了模块化的设计，每个线程有自己的 VoicePlayerInstance，减少了锁竞争：

音画同步与 Sequence 支持

除了基础的播放功能，我们还实现了音画同步系统。这个功能主要是为了解决语音与动画、UI 等的精确同步问题，特别是在过场动画中很有用。

我们集成了 UE 的 Sequencer 系统，通过自动计算语音时长，实现了毫秒级的同步精度。系统会自动分析语音文件获取精确时长，然后通过 Ticker 机制实现每帧的同步检查。

另外我们还支持了 TrackID 的概念，可以更好地管理和控制不同音轨的播放，这对于复杂的对话系统来说很有用。

总结

整个 VGT 系统的核心思路就是在 Wwise 的基础上，重新设计了上层的播放逻辑和资源管理，让整个语音系统更加灵活和高效。虽然还有一些模块（比如蓝图支持、多语言管理等）没有在这里详细介绍，但核心的架构和设计思路基本就是这样了。

这套系统最大的价值在于让策划可以独立完成语音配置工作，同时也给程序提供了更加灵活的播放控制能力。当然，性能优化和稳定性也是我们比较关注的点，毕竟游戏中语音系统的稳定性还是很重要的。