前文#
現在の作業では、ゲーム音声に関して、設定とテストが大きな痛点であることがわかります。私の作業環境では、音声がリソースとして出力されてから最終的にゲームに組み込まれるまでに、三者の調整が必要です:企画が要求を提供 → 音声デザインがリソースを提供 → 音声デザインが Wwise を設定 → プログラムがゲームに組み込む → 企画が効果を検証します。
このプロセスは比較的冗長であることがわかります。
そのため、私たちは UE エンジンの音声パイプライン「VGT」を独自に開発しました。これは、Wwise のデコード再生に基づいており、上層ではリソース管理、再生ロジック、およびいくつかのサポートシステム(ブループリントサポート、コールバックシステム、シーケンスシステム、音画同期システムなど)をカスタマイズしています。このパイプラインは、上記の痛点を解決するだけでなく、煩雑な Wwise 設定プロセスを回避し、高効率のリソース管理と読み込みなどの特徴を実現しました。
核心システムモジュール#
UI モジュール#
VGT は、ユーザーがリソースを迅速にインポートし、テスト再生を行うための UI インターフェースを提供します。また、Excel データ駆動で設定を行います。インポートされた wav 音声ファイルは、Wwise のトランスコードツールを借用して wem 形式に変換されます。
リソース管理モジュール#
大規模なゲームでは、音声の数が非常に多くなる可能性があり、リソースの保存や読み込みはメモリに大きな負担をかけます。
そのため、VGT では「モジュール」のアーキテクチャを採用しており、Wwise の SoundBank に似ています。各モジュールは完全に独立しており、ゲーム内でのオンデマンド読み込みやアンロードが可能です。また、実行時データの読み込み、音声エンティティの管理、イベント再生の状態もモジュール単位で行われます。モジュールのデータ構造の簡略版定義は以下の通りです:
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 範囲を維持します。これにより、ストリーミング再生時に ID に基づいて完全なファイルパスを迅速に見つけることができます。
上記の図は、各ディレクトリノードがいくつかの ID 範囲を維持しているシンプルなディレクトリ構造の例を示しています。音声 ID に基づいて対応するファイルを検索する必要がある場合、迅速に対応するディレクトリパスを特定し、O (log n) の検索効率を実現します。
イベント情報管理について、まず VGT システム内のイベントのデータ構造定義はおおよそ以下の通りです:
struct EventInfo{
int32 ResourceID; // リソースに割り当てられたユニークID
int32 Order; // 順次再生かどうか
int32 Probability; // ランダム再生かどうか
int32 AdditionalFieldsNum; // 追加のフィルタ条件
}
イベント情報表は対応するモジュールディレクトリに保存され、必要に応じて読み込みまたはアンロードされます。
リソース再生モジュール#
メモリの負担を軽減するために、VGT ではストリーミング再生方式を採用し、音声をメモリに読み込む際の負担を軽減します。
以下は簡略版の底層再生ロジックです:
// 音声パスに基づいて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);
上層では、ランダム再生と順次再生の 2 つのロジックを構築し、モジュール内に保存された EventType 表に基づいてどちらの方法を呼び出すかを判断します。以下は簡略版の例で、主にランダム再生ロジックを示しています。
ランダム再生:
float TotalPercentage = 0.0f;
for (const TSharedPtr<RandomEventConfig>& Config : EventConfigs)
{
TotalPercentage += Config->Probability;
}
// すべての確率の合計が100でない場合、正規化します
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 では、より効率的なパフォーマンスを実現するために、すべてのモジュール情報表を一度にメモリに読み込むのではなく、必要なときにのみ読み込むようにしています。さらに、各モジュールのイベント情報が非常に多いため、json 形式から逆シリアル化する際のパフォーマンスも痛点です。そのため、パッケージ化時に json 形式をバイナリ形式に変換し、モジュールを読み込む際には逆シリアル化を行わず、仮想メモリに直接マッピングし、各イベントのデータ内のオフセット位置を解析して直接アクセスを実現し、パフォーマンスの消費を減少させます。以下は擬似コードです:
GetEvent(eventName) -> LoadEventOffsetMap() -> LoadBinaryEventConfigMap():
TUniquePtr<FArchive> DataFileReader(IFileManager::Get().CreateFileReader(*EventConfigPath));
DataFileReader->Seek(EventDataOffsetMap[eventName]);
ResultConfig->LoadBinary(DataFileReader);
return ResultConfig;
スレッド安全性に関しては、構造図は以下の通りです:
まとめ#
以上は VGT プロジェクトの核心モジュールの紹介です。実際には、音画同期やシーケンスモジュールなど、他にも多くのモジュールが未掲載です。今後、時間があればさらに詳しく紹介する予定です。