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 模塊。如果後續有時間作者會進一步完善介紹。

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。