banner
KiWi

KiWi的博客

这里是一个搞技术的音频er的网站
wechat
email

UE语音管线 (一)

前文#

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

可以发现这个流程是比较冗长的。

因此我们自研了一套 UE 引擎的语音管线 “VGT”,其底层基于 Wwise 解码播放,上层则自定义了资源管理,播放逻辑与一些支持性系统(包括蓝图支持,回调系统,Sequence 系统,音画同步系统等)。这套管线除了解决了上述痛点之外,也避免了繁琐的 Wwise 配置流程,实现了高效的资源管理加载等特点。

核心系统模块#

UI 模块#

VGT 提供了 UI 界面来帮助用户快速的导入资源与测试播放。同时通过 Excel 数据驱动来配置。导入的 wav 语音文件会借用 Wwise 的转码工具转换为 wem 格式

image

资源管理模块#

由于在大型游戏中,语音数量可能会到一个很恐怖的级别,无论是资源储存还是加载都会给内存带来很大的压力。

因此 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 就可以快速查找到构建完整文件路径。

Mermaid Loading...

上图展示了一个简单的目录结构示例,每个目录节点下维护着若干 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++ 和蓝图中都是这样的设定。

image 1

性能优化与线程安全#

在 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;

关于线程安全,结构图如下:

image 2

总结#

以上是对整个 VGT 项目的核心模块介绍,实际上还有很多其他模块没有列出,比如音画同步和 Sequence 模块。如果后续有时间作者会进一步完善介绍。

加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。