前文#
在当前工作中,我们可以发现对于游戏语音来说,配置与测试是一个很大的痛点。在我工作的环境里,一个语音从产出资源到最后配到游戏里需要三方人员的协调:策划提供需求 → 音频设计提供资源 → 音频设计配置 Wwise → 程序配置到游戏中 → 策划检验效果。
可以发现这个流程是比较冗长的。
因此我们自研了一套 UE 引擎的语音管线 “VGT”,其底层基于 Wwise 解码播放,上层则自定义了资源管理,播放逻辑与一些支持性系统(包括蓝图支持,回调系统,Sequence 系统,音画同步系统等)。这套管线除了解决了上述痛点之外,也避免了繁琐的 Wwise 配置流程,实现了高效的资源管理加载等特点。
核心系统模块#
UI 模块#
VGT 提供了 UI 界面来帮助用户快速的导入资源与测试播放。同时通过 Excel 数据驱动来配置。导入的 wav 语音文件会借用 Wwise 的转码工具转换为 wem 格式
资源管理模块#
由于在大型游戏中,语音数量可能会到一个很恐怖的级别,无论是资源储存还是加载都会给内存带来很大的压力。
因此 VGT 中采用了 “Module” 的架构,类似于 Wwise 中的 SoundBank,每个 module 完全独立,可以在游戏中实现按需加载,卸载。同时运行时数据的加载,声音实体的管理,事件播放的状态也以 module 为单位。其中 module 的数据结构简略版定义如下:
struct VoiceModuleData{
FString moduleName;
FString modulePath;
int32 maxPlayedEventInstanceNum;
int32 maxSavedEventInfoNum;
// 此模块中可播放的语音
TMap<FString, TArray<TSharedPtr<EventData>>> eventMap;
// 此模块中每条语音对应的容器类型(随机容器,顺序容器...)
TMap<FString, int32> eventTypeMap;
}
对于本地转码后的 wem 语音资源来说,分配 ID 作为唯一标识符,减少过多字符串带来的性能消耗。同时也更方便根据 ID 区间来管理文件路径:VGT 采用路径与 ID 区间映射的方式来构建 AVL 树,每个路径下维护多个 ID 区间,这样在 Streaming 播放时根据 ID 就可以快速查找到构建完整文件路径。
上图展示了一个简单的目录结构示例,每个目录节点下维护着若干 ID 区间。当需要根据语音 ID 查找对应文件时,可以快速定位到对应的目录路径,实现 O (log n) 的查找效率。
对于 Event 信息管理,首先 VGT 系统中对于 Event 的数据结构定义大概如下:
struct EventInfo{
int32 ResourceID; // 为资源分配的唯一ID
int32 Order; // 是否为顺序播放
int32 Probability; // 是否为随机播放
int32 AdditionalFieldsNum; // 额外筛选条件
}
Event 信息表是储存在对应的 Module 目录下,根据需要进行加载或卸载。
资源播放模块#
为了减轻内存压力,VGT 中采用了 Streaming 播放的方式,减轻语音加载到内存中的压力。
下面是简略版的底层播放逻辑:
// 根据音频路径构建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);
在上一层我们构建了随机播放和顺序播放两种逻辑,根据 Module 模块里储存的 EventType 表来判断调用哪种方法。以下是精简版示例,主要展示随机播放逻辑
随机播放:
float TotalPercentage = 0.0f;
for (const TSharedPtr<RandomEventConfig>& Config : EventConfigs)
{
TotalPercentage += Config->Probability;
}
// If the sum of all probabilities is not 100, normalize them
if (TotalPercentage != 100.0f)
{
if (fabs(TotalPercentage) < FLT_EPSILON)
{
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;
}
}
在构建完随机,顺序播放逻辑之后,我们还需要设计一套回调系统来方便程序的调用。VGT 的回调系统主要是设计了一个集中处理 Wwise 回调事件的方法,根据上下文来触发我们设定好的回调,在 C++ 和蓝图中都是这样的设定。
性能优化与线程安全#
在 VGT 中为了实现更高效的性能,首先我们并不会把所有的 Module 信息表一次性加载到内存中,而是在有需要的时候再进行加载。其次由于每个 Module 的 Event 信息十分多,将其从 json 格式反序列化的时候对于性能也是一个痛点,因此我们在打包时会将 json 格式转化为二进制格式,并且在加载模块时并不会去解析反序列化,而是直接映射到虚拟内存中,仅解析每个 Event 在数据中的偏移位置来实现直接访问的效果,减少了性能的消耗,以下为 sudo code:
GetEvent(eventName) -> LoadEventOffsetMap() -> LoadBinaryEventConfigMap():
TUniquePtr<FArchive> DataFileReader(IFileManager::Get().CreateFileReader(*EventConfigPath));
DataFileReader->Seek(EventDataOffsetMap[eventName]);
ResultConfig->LoadBinary(DataFileReader);
return ResultConfig;
关于线程安全,结构图如下:
总结#
以上是对整个 VGT 项目的核心模块介绍,实际上还有很多其他模块没有列出,比如音画同步和 Sequence 模块。如果后续有时间作者会进一步完善介绍。