VGT 語音管理系統技術詳解#
前文#
在當前工作中,我們可以發現對於遊戲語音來說,配置與測試是一個很大的痛點。在我工作的環境裡,一個語音從產出資源到最後配到遊戲裡需要三方人員的協調:策劃提供需求 → 音頻設計提供資源 → 音頻設計配置 Wwise → 程序配置到遊戲中 → 策劃檢驗效果。
可以發現這個流程是比較冗長的,而且每次調整都需要多個部門的參與,效率比較低。另外,Wwise 雖然功能強大,但對於複雜的播放邏輯配置起來還是比較繁瑣的,特別是涉及到序列播放、條件篩選這些功能時。
因此我們自研了一套 UE 引擎的語音管線 "VGT",其底層仍然基於 Wwise 進行解碼播放(畢竟 Wwise 的音頻處理能力還是很強的),但上層我們完全重新設計了資源管理、播放邏輯控制,還加了一些實用的支持性系統(包括藍圖支持,回調系統,Sequence 系統,音畫同步系統等)。這套管線不僅解決了上面提到的協作問題,也讓我們可以擺脫複雜的 Wwise 工程配置,同時在資源管理和加載效率上也做了很多優化。
核心系統模塊#
UI 模塊#
VGT 提供了 UI 界面來幫助用戶快速的導入資源與測試播放。考慮到策劃同學更習慣用 Excel,所以我們做了 Excel 數據驅動的配置方式。導入的 wav 語音文件會借用 Wwise 的轉碼工具轉換為 wem 格式。
從界面可以看到,整個操作流程還是比較直觀的,策劃可以直接導入、配置和測試,不需要等程序或者音頻設計師的支持。
資源管理模塊#
大型遊戲的語音數量確實是一個問題,動輒幾萬條語音,如果全部加載到內存肯定是不現實的。我們參考了 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 樹的路徑管理系統:
這樣設計的好處是,當我們需要根據語音 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++ 和藍圖都支持:
性能優化與線程安全#
性能優化方面,我們做了幾個關鍵的優化:
首先是延遲加載。我們不會把所有 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,減少了鎖競爭:
音畫同步與 Sequence 支持#
除了基礎的播放功能,我們還實現了音畫同步系統。這個功能主要是為了解決語音與動畫、UI 等的精確同步問題,特別是在過場動畫中很有用。
我們集成了 UE 的 Sequencer 系統,通過自動計算語音時長,實現了毫秒級的同步精度。系統會自動分析語音文件獲取精確時長,然後通過 Ticker 機制實現每幀的同步檢查。
另外我們還支持了 TrackID 的概念,可以更好地管理和控制不同音軌的播放,這對於複雜的對話系統來說很有用。
總結#
整個 VGT 系統的核心思路就是在 Wwise 的基礎上,重新設計了上層的播放邏輯和資源管理,讓整個語音系統更加靈活和高效。雖然還有一些模塊(比如藍圖支持、多語言管理等)沒有在這裡詳細介紹,但核心的架構和設計思路基本就是這樣了。
這套系統最大的價值在於讓策劃可以獨立完成語音配置工作,同時也給程序提供了更加靈活的播放控制能力。當然,性能優化和穩定性也是我們比較關注的點,畢竟遊戲中語音系統的穩定性還是很重要的。