banner
KiWi

KiWi的博客

Don't box me in with labels, I'm capable of anything I choose to pursue
wechat
email

UE语音管线

VGT 语音管理系统技术详解#

前文#

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

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

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

核心系统模块#

UI 模块#

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

image

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

资源管理模块#

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

每个 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,这样可以避免过多的字符串操作带来的性能损耗。同时为了快速定位文件路径,我们设计了一套基于 AVL 树的路径管理系统:

Mermaid Loading...

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

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

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

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

资源播放模块#

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

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

// 根据音频路径构建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 为例,我们实现了完整的权重随机算法,还加了防重复播放的机制:

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++ 和蓝图都支持:

image 1

性能优化与线程安全#

性能优化方面,我们做了几个关键的优化:

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

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

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

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

return ResultConfig;

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

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

image 2

音画同步与 Sequence 支持#

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

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

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

总结#

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

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

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