ここではUnrealでWAAPIを使用する際に押さえておくべき情報を紹介します。この記事の内容のほとんどは、AudiokineticのドキュメントやWwise Up On Airビデオ、または他のブログ記事ですでに紹介されていますが、Unreal特有の情報を1か所にまとめて具体例を示しておけば便利だと思いました。
実際にまとめはじめてみると、紹介したいことの大部分をきちんと理解するのが大変でした。情報が分かりにくく、まるで暗号だと思ったものです。何とかやっていくうちに、必要な情報は目の前にあるのに、つながっていなかっただけなのだと分かってきました。
私がC++やJSONのエキスパートならもっと簡単だったでしょう。でも多くのみなさんは私と同じでしょうから、この記事がきっと役に立つはずです。Wwiseを詳しく知っていてC++をそれなりに使えると、この先の話が分かりやすくなります。
目次
Wwise APIとWAAPIの違い
WAAPIを有効にする
WAMPとは
WAAPIをUnrealで使用する
WAAPI Remote Procedure Call
JSONとは
JSONオブジェクトを入力する
必要なIncludesやModulesを追加する
引数の構築と結果データの抽出
WAAPI呼び出しをカプセル化する
オプションフィールドを使用する
SoundEngineの二重性
WAAPIでWAQLを使用する
オブジェクトにデータを設定およびオーディオをインポート
名前の競合、リスト、リストモード
オブジェクトのプロパティを変更する
新たなオブジェクトを作成する
生のJSON文字列をパースする
新たなイベントを作成する
オーディオをインポートする
トピックにサブスクライブする
WAAPIをブループリントに直接使用する
プロファイラコントローラを作成する
Unrealデータアセットに基づきWwise階層を構築する
Wwise APIとWAAPIの違い
そもそもWAAPIとは何なのか。Wwise APIとどう違うのか。
これには私自身の経験不足もあって、理解に少し時間がかかりました。またWAAPIは従来のAPIと同じことが大抵できますが、その逆はそうではありません。このことも理由の一部です。
Wwise APIには、ゲームからWwiseオーディオエンジンに情報を送信するための関数やクラスがすべて含まれています。イベントのポストやRTPCの設定など、おなじみのものもあります。
ドキュメントウェブサイトのWwise SDKセクションにある情報の多くがこのAPIに含まれます。Unrealで作業するなら、Unrealゲームエンジン固有のクラスも含まれます。
一方WAAPI(Wwise Authoring API)は、ゲーム内で実行されるWwiseオーディオエンジンではなく、Wwise Authoring Appと通信します。これはプロジェクト用にサウンドを準備して、ゲームと同期できるようにするソフトウェアです。
WAAPIではさまざまなプログラム言語を使えますが、通常はJSONを使って情報をやりとりします。ほとんどJSONを使用したことがなく、特にUnrealではめったに使用しない私にとっては、これもつまずく原因になりました。
WAAPIを有効にする
WAAPIを使用するには、Wwiseのユーザ設定で有効にする必要があります。ご覧の通りHTTPポートとWAMPポートがありますが、後者に注目しましょう。こちらの方が機能が豊富なため、WAAPIを使う時におすすめです。
WAMPとは
WAMPはWeb Application Messaging Protocolの略で、さまざまなアプリケーション同士の交信に使用される、汎用通信プロトコルです。「Web Application」という表現は奇妙に聞こえるかもしれませんが、WAAPIが実際にサーバー接続のような働きをすることを示唆しています。ゲームビルドをWwise Profilerに接続するのと概念的にはそれほど変わりません。
WAAPIをUnrealで使用する
前述のように、WAAPIは言語やプラットフォームを選びません。いくつか選択肢がある中、UnrealではC++を使って使用できます(ブループリント上で直接使うこともできます)。AudiokineticではUnreal Wwiseプラグインでそのまま使えるクラスを提供しています。FAkWaapiClientに注目して、何ができるのか見てみましょう。この記事ではこのクラスを使った関数をいくつか使用します。
WAMPを使ってWwise Authoring Appと通信するには、通常2つの方法があります。
- Remote Procedure Call(RPC):Wwiseに具体的な情報を要求して答えを取得します。RPCを使ってRTPCを要求できるなんて便利ですよね。
- Subscribe/Publish(Pub/Sub):あるトピックのリッスンを開始し、特定の条件が発生した時にコールバックを取得します。オブザーバーパターンと似ています。
WAAPI Remote Procedure Call
まずはRemote Procedure Callのワークフローからはじめましょう。使用できる関数はすべてこちらでご覧いただけます。
FAkWaapiClient::Callを使って、必要な情報についてWwiseに問い合わせます。このクラスと関数を使うのだと分かるまで少し時間がかかりました。間抜けですが、ドキュメントで見つけられなかったのです。一度見つけてみれば、ビデオやコード例、Wwiseプラグイン内のクラスなどあちこちに目に付きましたが・・・。
とにかくこの関数を使います。理論上はFAkWaapiClientを省略してエンジンに直接呼び出しを行うことができますが、ややこしくなるのでやめておきます。
FAkWaapiClient::Callにオーバーロードが2つあるのが分かります。1つはFStringsを受け取り、もう1つはUnrealネイティブのJSON実装で使用するFJsonObjectを受け取ります。ほとんどの場合―少なくともシンプルな引数を渡す場合は、後者が使いやすいと思います。文字列を直接渡す方法については後ほど説明します。
関数は次のパラメータを取り、成功すればtrueを返します。ここではオプションのパラメータの説明はしませんが、そちらは説明なしでも分かると思います。
- URI:呼び出す関数。
- FAkWaapiClientの場合、「so: ak::wwise::core::profiler::getCursorTime」のような形式にします。
- 引数:上記の関数が期待しているデータ。
- オプション:関数がどのデータを返すのか指定するための追加情報。
- 戻り値:結果の情報。多くのデータ型が含まれます。
引数とオプションのパラメータがTSharedRef<FJsonObject>型である一方、戻り値はTSharedPtr<JsonObject>型になっています。すぐには気づかず、混同して失敗を繰り返してしまいました。みなさんは気をつけてくださいね!
この2つはUnrealスマートポインタライブラリから来ており(詳細はこちら)、主な違いは、TSharedRefはnullにならないオブジェクトを指す必要があることです。const参照としてcall関数に渡すので、これは理にかなっています。TSharedPtrの方は、呼び出しの結果に常にデータが含まれるわけではないため、nullの場合があります。こちらは非const参照として渡されます。
このコード例でもう1つ分かるのは、MakeShared()で作成するJSONオブジェクトとMakeShareable()で作成するJSONオブジェクトがあることです。前者は軽量ですが、オブジェクトにパブリックコンストラクタが必要です。後者は重いものの、プライベートコンストラクタなどカスタム動作に対応しています。今回は両方を互換的に使っていますが、実はこれは、私が一般的なWwiseクラスや私自身の過去のデータからサンプルコードをコピーしてきたせいです。
JSONとは
JSONは「人間に読める」データをさまざまなシステム同士でやりとりするためのオープンスタンダードです(カッコ書きは筆者)。言語を選ばすさまざまなソフトウェアスタックで使用できるため、WAAPIがこれを使用するのも納得です。
ではJSONオブジェクトの中身はどんなものかというと、C# dictionary(またはC++マップ)のように、データをキーと値のペアで格納しています。
キーは常に文字列で、値はプリミティブ型の変数、オブジェクト、またはそのいずれかを含む配列です。下は2つのキーと値のペアを持つJSONオブジェクトの例です。
{ "type" : "Sound", "@volume" , 10.5 }
JSONオブジェクトを入力する
JSONオブジェクトを構築する方法ですが、ドキュメントの例やAudiokineticのWAAPIハンズオンビデオでは、JSON引数は次のような感じで表されています。
{ "parent": "{7A12D08F-B0D9-4403-9EFA-2E6338C197C1}", "type": "Sound", "name": "Boom" }
またはこんな感じです。
{ "objects": [ { "object": "\\Interactive Music Hierarchy\\Default Work Unit", "children": [ { "type": "MusicSegment", "name": "MultiTrack Segment", "import": { "files": [ { "audioFile": "c:\\path\\track1.wav" }, { "audioFile": "c:\\path\\track2.wav" } ] } } ] } ], "onNameConflict": "merge" }
こうしたJSON構造を生の文字列として直接コードに置くこともできますが、特にオブジェクトが大きい場合などは効率性や拡張性に欠けてしまいます。それよりもFJsonObject関数を使って、プログラムでオブジェクトを構築した方がよいでしょう。
こうしたオブジェクトの構築について分かりやすい具体例を示すのも、この記事の目的の1つです。Unreal、C++、WAAPIを使った実際の例を見つけるのは難しいため、JSONオブジェクトの扱いに慣れていなければ、はじめは構文やワークフローが分かりづらいかもしれません。
必要なIncludesやModulesを追加する
先にすすむ前に、今回のUnrealプロジェクトでWwiseとJSONが使えるか確認します。cppファイルの先頭に次のような記述が必要になります。
#include "../Plugins/Wwise/Source/AkAudio/Public/AkWaapiClient.h"
JSON includesはすでに指しているため含める必要はありません。
UnrealProjectName.Build.csなどの名前を付けたプロジェクトのビルドファイルに、Json、JsonUtilities、AkAudioを追加します。
参考までに、今回はWwise 2023.1とUnreal 5.3でビルドしました。
引数の構築と結果データの抽出
まずはgetCursorTime 関数を使ってみましょう。ドキュメントでは次のように説明されています。
ご覧の通りこの関数では、ユーザカーソル(ユーザがプロファイラ上でクリックする箇所)とキャプチャー時間カーソル(現在のキャプチャーがある箇所)のどちらにするかを指定する引数が期待されています。アスタリスク(*)はその引数が必須であることを示します。
最終的に結果のタイムコード値を整数値で取得します。そうなれば成功です。
ではJSONオブジェクトを構築しましょう。次のように引数を宣言します。
TSharedRef<FJsonObject> args = MakeShared<FJsonObject>();
TSharedRefが使われており、これが関数が期待しているものです。そして「MakeShared」関数でオブジェクトを構築します。
JSONオブジェクトができたら、関数に適切な情報を渡せるよう変更を加えましょう。「cursor」型の文字列フィールドを渡して「capture」を選択します。今回はユーザではなくキャプチャー時間を要求することにしました。
これを実行するために、次のようなFJsonObject特有の便利な関数を使えます。
TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); args->SetStringField("cursor", "capture");
ここでも最初は具体例を見つけられず、この2行のコードにたどり着くのに時間がかかりました。GitHubで検索しているとだんだん分かってきて、WAAPI呼び出しをするWwiseクラスも見つけましたが、何を探せばいいかが分かってはじめて見つかるものですね。
さて、オプションと結果のオブジェクトも作成しましょう。フィールドを変更する必要はなく、「result」が異なるポインタ型を使用していることが分かります。
TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TSharedPtr<FJsonObject> result = MakeShared<FJsonObject>();
FAkWaapiClientはシングルトンなので、ただインスタンスを取得し関数を呼び出します。
FAkWaapiClient::Get()->Call(ak::wwise::core::profiler::getCursorTime, args, options, result);
成功するとブール値が返され、それによって期待通りに動いたか確認できます。
成功すれば整数フィールドを取得し、結果のJSON オブジェクトからint値を「抽出」できます。
1つにまとめると次のようになります。
TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); args->SetStringField("cursor", "capture"); TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TSharedPtr<FJsonObject> result = MakeShared<FJsonObject>(); if (FAkWaapiClient::Get()->Call(ak::wwise::core::profiler::getCursorTime, args, options, result)) { if (result) { int rawTime = result->GetIntegerField("return"); } }
これでOKです。今回は結果のJSONオブジェクトに含まれる整数値を見つけるのに、get関数を使いました。
取得した整数値はミリ秒単位の時間なので、タイムコードなど、Unrealでの表示に適した形に変換する必要があります。
WAAPI呼び出しをカプセル化する
WAAPI呼び出しを1つの関数にカプセル化して、ほかのクラスやブループリントから呼び出せるようにする例を見てみましょう。
新しいアクタコンポーネントクラスを作成して次の関数を書いてみました。
bool UMyActorComponent::SendWwiseConsoleMessage(FString MessageToDisplay) { TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); args->SetStringField("message", MessageToDisplay); TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TSharedPtr<FJsonObject> result; return FAkWaapiClient::Get()->Call(ak::soundengine::postMsgMonitor, args, options, result); }
この関数は文字列(表示するメッセージ)を要求し、ブール値を返します。操作が成功すれば true です。
結果をパースする必要がないため、構文とロジックが前よりかなりシンプルです。
ブループリントではこのようになります。
Wwiseキャプチャーログ(プロファイラ)でメッセージを取得できます。
エラーが表示されていますね。しかしこの関数では別のログレベルを選択できません。
重大度レベルを選択するならak.wwise.core.log.addItemを使うこともできますが、その場合、サウンドデザイナーにとって定番ともいえるプロファイラビューにはログできないようです。
オプションフィールドを使用する
より複雑な引数を持つ別の関数にトライしましょう。Wwiseの中で任意のステートグループに現在どのステートが使われているかを知るため、「ak.soundengine.getState」を使うことにします。
ドキュメントを見てみましょう。
ご覧のように引数を渡す方法はいくつかありますが、どれも何らかの方法でステートグループを指す文字列に過ぎません。使用できるオプションもいくつかあります。結果フィールドはこのようになっています。
最初はこれがよく分かりませんでした。オプションと結果が同じように見えたのです。オプションを使うのは結果にどのデータを含めるかをWwiseに正確に伝えるためですが、そのことが分かっていなかったのが理由です。オプションフィールドを空欄にするとどうなるかと言えば、IDと名前という最初の2つのフィールドだけを取得することになります。コードで実験した結果これが分かったのですが、ドキュメントにもこの記載がありました。
「さらに、クエリには次のようなオプションがあります:
return: オブジェクトから何を返すかを指定します。指定しない場合、デフォルトは['id', 'name']です。」
そのようなわけで、前と同様にJSONオブジェクトを構築しつつ、オプションフィールドに型として配列を入れました。そのためオブジェクトの構築が複雑になります。1つのやり方には次のようなものがあります。
TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TArray<TSharedPtr<FJsonValue>> optionsArray { MakeShareable(new FJsonValueString("notes")), MakeShareable(new FJsonValueString("id")), MakeShareable(new FJsonValueString("name")) }; options->SetArrayField("return", optionsArray);
まず通常通りオブジェクトを宣言します。次にデータを含むことになるJsonValueの配列を宣言します。ある意味当然なのですが、その配列がFJsonObjectではなくFJsonValueで構成されるということを理解するのに少し時間がかかりました。
配列を入力し、「return」をキーにしてフィールドを設定しました。オプションの配列の名前が「options」ではなく、「return」なのが紛らわしいと思うのは私だけでしょうか。
一方、この例の引数フィールドはシンプルで、ステートグループを指定するだけです。それにはパスを使います。バックスラッシュ1つだけではエスケープ文字になるので、必ず2つ重ねるようにします。
全体はこのようになります。
FString UMyActorComponent::WaapiCall() { //Args Object TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); args->SetStringField("stateGroup", "\\States\\Default Work Unit\\TestState"); //Options Object TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TArray<TSharedPtr<FJsonValue>> optionsArray { MakeShareable(new FJsonValueString("notes")), MakeShareable(new FJsonValueString("id")), MakeShareable(new FJsonValueString("name")) }; options->SetArrayField("return", optionsArray); TSharedPtr<FJsonObject> result; FString resultString = "Something went wrong..."; if (FAkWaapiClient::Get()->Call(ak::soundengine::getState, args, options, result)) { if (result) { TSharedPtr<FJsonObject> resultingState = result->GetObjectField("return"); FString stateName = resultingState->GetStringField("name"); FString stateNotes = resultingState->GetStringField("notes"); resultString = "We found the state: "; resultString.Append(stateName); resultString.Append(stateNotes); return resultString; } return resultString; } return resultString; }
もちろんこれをステート名やパスを取る関数にすることもできますが、今回の例ではハードコードしました。1つ注意すべきなのは、このケースでは求めているフィールドが結果のJSONオブジェクトに直接含まれるわけではないため、前と同じようにはGetStringFieldを使用できないことです。そのため、キー「return」の下位のオブジェクト値を取得し、その2番目のオブジェクトから求めている情報を取得する必要があります。
これが全く分かりにくかったのです。結果オブジェクト自体を使えるか、オブジェクトに配列が含まれるだろうと考えていたからです。しかしそうではないようです。
とにかく結果の情報を文字列で返せたので、これをプロジェクトに表示できます。今回は名前と注釈のフィールドを使っています。
上記はWwise Authoring Appがゲームと接続されている場合にのみ正確です。接続されていなくても値は取得できますが、それはオーサリングアプリ内にある値で、ゲームが最後に送信した値か、ユーザがSoundcasterで設定した値になります。これには重要な意味がありますので、これから詳しく説明します。
SoundEngineの二重性
WAAPIではなくWwise SoundEngineをUnreal(またはゲームビルド)で使用し、通常のWwise APIを介して特定のステートを取得することもできます。
それには「UnrealProjectName.Build.cs」ファイルに「WwiseSoundEngine」を置いた上で、サウンドエンジンに対し次のようなincludeを使います。
#include "../Plugins/Wwise/Source/WwiseSoundEngine/Public/Wwise/API/WwiseSoundEngineAPI.h"
APIを介してステートをクエリするよう設定できました。表面上は、WAAPIを使って前のセクションで作成したものと機能的によく似ているように見えますが、重要な違いがあります。
AkStateID outState = 0; auto* SoundEngine = IWwiseSoundEngineAPI::Get(); if (SoundEngine) { auto QueryObj = SoundEngine->Query; if (QueryObj) { QueryObj->GetState("TestState", outState); } } if (outState != 0 ) { GEngine->AddOnScreenDebugMessage(-1, 20.0f, FColor::Cyan, FString::SanitizeFloat(outState)); }
最も大きな違いは、WAAPI呼び出しの場合はオーサリングアプリ上で実行されているSoundEngineから情報を取得するため、常に利用できるわけではなく、これを使ってゲームプレイロジックを駆動できない点です。またWAAPIはゲームと接続されている場合のみ正確な情報を提供しますが、この点が開発、テスト、プロファイリングに非常に便利です。
一方、通常のAPI呼び出しはUnrealエディタ、またはゲームビルド自体で実行されているSoundEngineから情報を取得します。ゲーム状況を反映した情報を常に利用できるため、ゲームプレイロジックの駆動に使えますが、取得できる情報に限度があります。
上記が該当するのは、「ak.soundengine」で始まるURIを持つWAAPI 関数の場合、つまり対応する通常のAPIがある場合です。
結論としては、開発時に動作させることができるSoundEngineには、エンジンやゲームで実行されるものとオーサリングアプリで実行されるものの2つがあると覚えておくとよいでしょう。どちらにもクエリできます。ステートを取得する例を使って以下にまとめてみました。
WAAPI経由のGetState | 通常のAPI経由のGetSate | |
---|---|---|
SoundEngine | WwiseAuthoringで実行されるインスタンス | ゲームエンジンまたはゲームビルドで実行されるインスタンス |
精度 | WwiseAuthoringと接続されている場合にのみ正確。 接続が切断されている場合は、切断前にゲームが最後に送信した値か、ユーザがSoundcasterで設定したデータ。 |
ゲーム状況を常に正確に反映。 必要があればゲームプレイの駆動に使用可。 |
利用できるデータ | WwiseAuthoringにアクセスしているため大量のデータ。 | ステートの短いIDのみ。 |
WAAPIでWAQLを使用する
WAQLとはWwise Authoring Query Languageの略で、端的に言えば、極めて具体的な判断基準をモジュール式に組み合わせて、バスやコンテナ、イベントなど、さまざまなWwiseオブジェクトを見つけることができるものです。クエリをフィルタリングして欲しい結果を取得できます。
WAQLの機能とWAAPIを併用すれば、Wwise内のオブジェクトをピンポイントで見つけ、ある種の変更を加えることも可能です。どうやってオブジェクトを取得するかをまず見てみましょう。
WAQL文字列を取るak.wwise.core.object.get関数を使います。この関数はfrom/transformの形式でも機能しますが、古いやり方なので現在はおすすめしません。詳細はこちらをご覧ください。引数は次のようになります。
戻り値は欲しいデータを含む配列です。指定の仕方は、getState関数の時とほぼ同じでオプションフィールドを使います。
WAQL文字列を取り、欲しい情報の文字列を返す関数とブループリントノードを構築しましょう。取得する情報を関数内のパラメータとしてユーザが選択できるようにするなど、さらに改良もできますが、今回はシンプルさを優先します。
では、ヘッダファイルで関数を宣言しましょう。
UFUNCTION(BlueprintCallable, BlueprintCosmetic, Category = "CustomWaapi") FString GetWwiseObjectWAQL(FString WAQL);
次に、前と同様のやり方で関数を定義します。
FString UMyActorComponent::GetWwiseObjectWAQL(FString WAQL) { TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); args->SetStringField("waql", WAQL); TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TArray<TSharedPtr<FJsonValue>> optionsArray { MakeShareable(new FJsonValueString("notes")), MakeShareable(new FJsonValueString("id")), MakeShareable(new FJsonValueString("name")), MakeShareable(new FJsonValueString("path")) }; options->SetArrayField("return", optionsArray); TSharedPtr<FJsonObject> result; FString waqlResult = "Query was not successful"; FAkWaapiClient::Get()->Call(ak::wwise::core::object::get, args, options, result); if (result) { waqlResult = "Query was successful: "; TArray<TSharedPtr<FJsonValue>> resultArray = result->GetArrayField("return"); for (auto arrayMember : resultArray) { FString memberString = arrayMember->AsObject()->GetStringField("name"); memberString.Append(" ("); memberString.Append(arrayMember->AsObject()->GetStringField("path")); memberString.Append("), "); waqlResult.Append(memberString); waqlResult.Append("\n"); } return waqlResult; } return waqlResult; }
よく見ると、結果フィールドをパースする方法が少し違うことが分かります。欲しい情報を持つ配列を含むものの、文字列の配列ではありません。FJsonValueの配列なのでオブジェクトとしてキャストし、1つ1つに対しGetStringFieldを呼び出す必要があります。
では通して実行し、欲しい情報を収集しましょう。このシンプルな例ではすべての情報を文字列に付加していますが、このやり方はほかにも使えるとお気づきになると思います。
いくつかのプロンプトでテストしてみましょう。例えば「$ from type Event」を使ってオブジェクトのすべてのイベントを取得します。
Unrealに表示されるイベントのリストを取得しました。
WAQLは非常に強力で、数多くの機能を備えています。詳しくは基本的な使い方や参照資料でご覧いただけますが、今回はWAAPIをメインに取り上げているため詳細には立ち入りません。
もう1つやってみましょう。
負の値のボリュームを持つサウンドオブジェクトを要求し、期待した結果を得たことが分かります。
ここでのポイントは、ak.wwise.core.object.getを使って具体的なWAQLクエリをWAAPIに渡し、次にこの情報をコードやブループリントに適切な形で使用できるということです。これを応用して、Wwiseでの設定が思った通りにできているかを確認する、ユニットテストのようなものを作ることもできます。例えば、足音システムのスイッチコンテナの入力や設定が適切かテストできます。
また、UnrealからWAAPIを使用しているということを利用し、ゲーム内にあるAkComponentに基づいて情報をリスト化できるのではないかとも考えました。
オブジェクトにデータを設定およびオーディオをインポート
これまでのところは、Wwiseにデータを要求しWwiseから読み込むのが基本でした。しかしWAAPIのすごいところは、Wwiseプロジェクトに変更を加えられる点です。
複雑な階層のワークユニットを数クリックで作成したり、オーディオを体系的にインポートしたり、UnrealデータファイルなどWwise以外の場所に存在するデータからバスシステムを構築したりできるとすれば、いかがでしょう?
レベル固有の(ここではゲーム内のレベル/エリアを意味します)Wwiseイベントの場合は、そのレベル情報を持つUnrealアセットからプログラムで作成できます。レベルの状態や設定を、イベントのアクションに直接反映できるのです。
UnrealアセットをWwiseアセットと同期させることもできます。例えば、Unrealで新しい物理マテリアルを作成するたびに、対応するスイッチをWwiseで作成し適切なスイッチグループに追加することができます。
ではどうやるのでしょうか。ドキュメントには実現に役立つ関数が多数記載されていますが、すぐにわけが分からなくなるはずです。できることとできないことをこちらのページで確認するようおすすめします。
ここでは4つの関数を紹介します。
- ak.wwise.core.object.create:任意の親の子として新たなオブジェクトを作成します。使い方は簡単ですが、制約があるためおすすめしません。
- ak.wwise.core.audio.import:オブジェクトを作成し、そこに新たなオーディオをインポートします。
- ak.wwise.core.object.setProperty:1つのオブジェクトに1つのプロパティを設定します。オプションや戻り値はありません。シンプルで簡単ですが、制約があります。
- ak.wwise.core.object.set:万能な関数で、上記すべてに対応しています。Wwise 2022に登場したばかりで、2023バージョンで強化されました。オブジェクトの一括作成、オーディオのインポート、プロパティの設定など、あらゆる操作が可能になります。非常に強力ですが、構築するJSONオブジェクトや文字列が複雑で手に負えなくなる可能性があります(詳しくは後述します)。
このようにさまざまな関数がありますが、Audiokineticがほとんどのケースでおすすめするのはak.wwise.core.object.setなので、主にこれを使って説明します。
名前の競合、リスト、リストモード
詳細にすすむ前に、すでに存在しているオブジェクトを作成しようとしたらどうなるか確認しておく必要があります。またRTPCなどのプロパティを、ほかのRTPCがすでに存在しているのに設定しようとしたらどうなるでしょうか。
同じ名前の子がオブジェクトにすでに存在する場合どうなるかは、「onNameConflict」フィールドで指定できます。オプションについて見てみましょう(ドキュメントから抜粋)。
- fail:create関数がエラーを返します。(デフォルト)
- replace:宛先のオブジェクトが(子を含め)削除され、新たなオブジェクトが作成されます。
- rename:数字を付加した新しい一意の名前が新たなオブジェクトに自動的に付けられます。
- merge:宛先のオブジェクトをそのまま使用し、指定したプロパティ、参照、子を宛先にマージします。オブジェクトの残りの部分はそのままです。
時と場合に応じて、オブジェクトを削除してゼロから構築する場合(replace)もあれば、自分のJSONオブジェクトと違う値を修正するだけの場合(mergeもあります)。
またオブジェクトには「リスト」という概念もあります。このリストにはサウンドのRTPCやバスのエフェクトなど、さまざまなオブジェクト配列が含まれます。そのため、ak.wwise.core.object.setを使いつつリストの一部を変更したい場合にどうなるかを「listMode」フィールドで指定します。ドキュメントには次のオプションが記載されています。
- append:既存オブジェクトを残したまま新たなオブジェクトをリストに追加します(追加できる場合)。ただし同等のオブジェクトの重複を認めないリストもあります。例えば、RTPCリストでは一部のRTPCプロパティが排他的であるため、そのプロパティのRTPCは1つしか存在できません。
- replaceAll:重複しないよう既存オブジェクトをすべて削除して、新たなオブジェクトを追加します。
オブジェクトのプロパティを変更する
まず、ak.wwise.core.object.setを使って既存オブジェクトを変更する構文を見てみましょう。
ドキュメントには1つ以上のオブジェクトを含むオブジェクト配列を引数に含める必要があると記載されています。FJsonを使ったことがなければ、それを作成する構文が少し分かりづらいでしょう。ここではある1つのサウンドオブジェクトに注釈を加え、ボリュームとローパスフィルタを変更することにします。
ドキュメントの例やAudiokineticのWAAPIビデオをご覧になると分かりますが、どれも生のJSON文字列を使っています。そのため今回の例では次のようになります。
{ "objects": [ { "object": "\\Actor-Mixer Hierarchy\\Default Work Unit\\Sound1", "notes": "Hello!", "@Volume": 15.7, "@LowPass": 25 } ], "onNameConflict": "merge" }
上記を生、つまりリテラルな文字列としてWAAPI 呼び出しに直接使用するか、これまでと同様にJSONオブジェクトを構築するかの2つの選択肢があります。
私が思うには、前者は読みやすいけれども、複雑な構造を作成することになったとたん使いづらくなります。問題は私たちが通常はプログラムで構造を構築することです。そのためJSON関数を使うのが最適かもしれません。
では上記のJSONオブジェクトをどうやって構築するか見てみましょう。最初に引数オブジェクトと変更を加えたいサウンド(現時点では1つのみ)を表す、オブジェクトの両方を宣言します。
TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); TSharedRef<FJsonObject> objectToModify = MakeShared<FJsonObject>();
両方とも通常のJSONオブジェクトです。オブジェクトのフィールドをすべて設定しましょう。
objectToModify->SetStringField("object", "\\Actor-Mixer Hierarchy\\DefaultWorkUnit\\Sound1"); objectToModify->SetStringField("notes", "Hello!"); objectToModify->SetNumberField("@Volume", 15.7f); objectToModify->SetNumberField("@LowPass", 25);
ここで問題があります。引数オブジェクトにこのオブジェクトを追加する必要がありますが、ak.wwise.core.object.setはオブジェクト配列を期待しているため、次のように配列を構築する必要があります。
TArray<TSharedPtr<FJsonValue>> argsArray;
次に配列を入力できるようにするためFJsonValueを使う必要がありますが、あるのはオブジェクトです。そのためFJsonValueObjectを中継に使う必要があります。このようになります。
TSharedRef<FJsonValueObject> ObjectValues = MakeShareable(new FJsonValueObject(objectToModify));
正直言って、上記の行はピンときませんでした。Unrealフォーラムの2015年の投稿で見つけたのですが、機能することは確かです。しばらくして同じやり方で引数を構築しているWwiseプラグインクラスをいくつか見つけ、やっと間違っていないと確信が持てました。もっと早く確認しておけばよかったと思います。FAkWaapiClient、AkWaapiUtils、SWaapiPickerに参考になる例があります。
そういうわけで、このFJsonValueObjectを配列に追加し、その配列を引数オブジェクトに追加します。残りのオブジェクトも作成し、1つにまとめると次のようになります。
void UMyActorComponent::WaapiObjectSetModify() { TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); // The object we want to modify TSharedRef<FJsonObject> objectToModify = MakeShared<FJsonObject>(); objectToModify->SetStringField("object", "\\Actor-Mixer Hierarchy\\Default Work Unit\\Sound1"); objectToModify->SetStringField("notes", "Hello!"); objectToModify->SetNumberField("@Volume", 15.7f); objectToModify->SetNumberField("@LowPass", 25); TArray<TSharedPtr<FJsonValue>> argsArray; TSharedRef<FJsonValueObject> ObjectValues = MakeShareable(new FJsonValueObject(objectToModify)); argsArray.Add(ObjectValues); args->SetArrayField("objects", argsArray); args->SetStringField("onNameConflict", "merge"); TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TSharedPtr<FJsonObject> result; FAkWaapiClient::Get()->Call(ak::wwise::core::object::set, args, options, result); }
できました。ただし、たくさんのオブジェクトを1度の呼び出しで変更するとなれば、コードが途方もなく複雑になりそうだと分かります。上記の関数をカプセル化して、別の場所からオブジェクトごとに呼び出すのが賢いやり方と言えます。また、変更する属性を快適に選択できる方法を追加するとよいでしょう。この例では変更するオブジェクトをハードコードしましたが、WAQLを使えば特定のオブジェクトグループを見つけることもできます。
ところで今回のオブジェクトフィールドに使われている名前には、ak.wwise.core.object.setドキュメントにあるもの(「notes」など)とないものがあります。後者の名前の先頭には「@Volume」のように「@」が付いています。これらはオブジェクト(今回で言えばサウンド)自体から直接来たプロパティです。こうしたオブジェクトとその値はこちらでご覧いただけます。大体の場合、オブジェクト参照ページにあるプロパティ名の先頭に「@」を付けて変更できます。
新たなオブジェクトを作成する
新たなオブジェクトをゼロから作成する例をもう1つ紹介します。JSONオブジェクトの構築は基本的にこれまでと同じなので、詳細には触れません。
オブジェクトを新たに作成する場合は、必ず何かほかの子として作成するようにしてください。この時の親は通常、ワークユニットや仮想フォルダ、または何らかのコンテナになります。ak.wwise.core.object.setでもワークユニットを新たに作成できますが、階層ルートで作成する場合は、「\\Actor-Mixer Hierarchy」のように親と同じルートを使えます。
このデモでは、デフォルトワークユニットの下に2つのサウンドオブジェクトを作成することにします。下記のコードをよく読んでください。
void UMyActorComponent::WaapiObjectSetCreate() { TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); // WorkUnit TSharedRef<FJsonObject> workUnit = MakeShared<FJsonObject>(); workUnit->SetStringField("object", "\\Actor-Mixer Hierarchy\\Default Work Unit"); // Child Sound 1 TSharedRef<FJsonObject> childOne = MakeShared<FJsonObject>(); childOne->SetStringField("type", "Sound"); childOne->SetStringField("name", "GeneratedSoundTest"); TSharedRef<FJsonValueObject> childOneValues = MakeShareable(new FJsonValueObject(childOne)); // Child Sound 2 TSharedRef<FJsonObject> childTwo = MakeShared<FJsonObject>(); childTwo->SetStringField("type", "Sound"); childTwo->SetStringField("name", "GeneratedSoundTestOther"); TSharedRef<FJsonValueObject> childTwoValues = MakeShareable(new FJsonValueObject(childTwo)); // Children Array TArray<TSharedPtr<FJsonValue>> childrenArray; childrenArray.Add(childOneValues); childrenArray.Add(childTwoValues); workUnit->SetArrayField("children", childrenArray); TSharedRef<FJsonValueObject> ObjectValues = MakeShareable(new FJsonValueObject(workUnit)); TArray<TSharedPtr<FJsonValue>> argsArray; argsArray.Add(ObjectValues); args->SetArrayField("objects", argsArray); args->SetStringField("onNameConflict", "merge"); TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TSharedPtr<FJsonObject> result; FAkWaapiClient::Get()->Call(ak::wwise::core::object::set, args, options, result); }
繰り返しとなりますが、明示して説明するために、コードのどの部分も関数にカプセル化していません。しかし上記の方法が実際には使いづらいことは明らかです。
構文とJSONオブジェクトの構築の仕方を紹介しましたが、UnrealなどでWAAPIベースのツールを構築するなら、どうやってWAAPIが理解できる方法でデータを整理したりフォーマットしたりするかが大きな課題になります。どのオブジェクトを作成しどのように構成するのかを、オーディオチームのメンバがどのように決めるか考えてみます。
これはウィザードのような形で実現できるかもしれません。ウィザードを通じて、Unrealにあるデータファイルから情報を読み取ったり、Unrealのレベルやブループリントから直接情報を読み取ったりします。いずれにしろ、モジュール方式でJSONオブジェクトを構築するにはたくさんのヘルパークラスが必要になります。 まずはじめてみるにはご覧いただいた構文で十分でしょう。またこの記事の最後には、上記すべてをまとめた最終的な例もご用意しています。
ただしJSONオブジェクトをコードで構築するのではなく、打ち込んで構築したいという場合は別です。そのやり方を採用する場合はどうやるのか見てみましょう。
生のJSON文字列をパースする
このやり方の最大の利点は、すべての例をWwiseドキュメントから直接コピーペーストでき、ベースとなるコンテンツが豊富にあることです。
ただしニーズに合わせて構造をカスタマイズしづらいことと、複雑になるにつれ「人間に読みやすい」というJSONらしさが失われることが欠点です。引数をプログラムで構築したい場合はもちろん使えません。とはいえインターネット上にはJSON文字列のパースと修正に手軽に使えるツールがたくさんあります。JSONが広く使われているのはこうした利点があるからです。
生つまりリテラルなJSON文字列を取り、WAAPI call関数で使えるJSONオブジェクトを返す小さな関数を構築しました。ご覧ください。
TSharedRef<FJsonObject> UMyActorComponent::ParseJSON(FString RawJson) { // Parsing raw strings into JSON TSharedRef<FJsonObject> argsParsed = MakeShared<FJsonObject>(); TSharedPtr<FJsonObject> argsPtr = MakeShared<FJsonObject>(); TSharedRef<TJsonReader<TCHAR>> JsonReader = TJsonReaderFactory<TCHAR>::Create(RawJson); FJsonSerializer::Deserialize(JsonReader, argsPtr); argsParsed = argsPtr.ToSharedRef(); return argsParsed; }
ご覧の通りJSONオブジェクトへのポインタが与えられるので、それを直接使えます。
WAAPIクライアントcall関数にはオーバーロードがあり、JSONオブジェクトではなく直接文字列を取ることもできるため、上記は不要とも言えますが、両方を組み合わせて使う時には便利です。
ともあれ、次にすすみましょう。この関数がFStringを要求しているのはご覧の通りですが、文字列が適切な形式になっているかどうかが非常に重要です。この文字列がどこにあるかにかかわらず、生の文字列でなくてはなりません。生の文字列でない場合は、適切なエスケープ文字がすべて含まれ、1行で書かれている必要があります。 オンラインJSONパーサーを使えば、人間が読めるJSONをコンパクトな形に変換できます。変換の前と後をご覧ください。
//Before: Normal Json { "objects": [ { "object": "\\Actor-Mixer Hierarchy\\Default Work Unit\\Sound1", "notes": "Hello!", "@Volume": 15.7, "@LowPass": 25 } ], "onNameConflict": "merge" } //After: Compact Json with escape characters {\r\n\t\t\"objects\": \r\n\t\t[\r\n\t\t\t{\r\n\t\t\t\t\"object\": \"\\\\Actor-Mixer Hierarchy\\\\Default Work Unit\\\\Sound1\",\r\n\t\t\t\t\"notes\": \"Hello!\",\r\n\t\t\t\t\"@Volume\" : 15.7,\r\n\t\t\t\t\"@LowPass\": 25\r\n\t\t\t}\r\n\t\t],\r\n\t\t\"onNameConflict\": \"merge\"\r\n}
上記は両方動作します。前者は可読性に優れるものの、生の(リテラルな)文字列を使うところが弱点です。後者は可読性に劣るものの、直接使えます。よりよい方を選びましょう。
新たなイベントを作成する
新たなイベントをプログラムで作成してみましょう。今回の引数オブジェクトには、何らかのイベントの親(ワークユニットなど)であるオブジェクトの配列が含まれるため、少し複雑になるかもしれません。オブジェクトにはそれぞれイベントオブジェクト配列が含まれ、そのイベントオブジェクト配列にイベントアクション配列が含まれます。このように、配列をネストしはじめると複雑になってきます。
こちらの例は、何ができるのか分かりやすいようシンプルにしました。このコードは実際のプロジェクトではあまり使えないかもしれませんが、それでも参考になります。1つのワークユニットに1つのイベントを作成します。イベントには2つのアクションが含まれます。実際のケースではこれをいくつも作成することになります。
まず引数オブジェクトとイベントの親(今回はデフォルトのイベントワークユニット)を宣言します。
TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); // Event Parent TSharedRef<FJsonObject> eventParent = MakeShared<FJsonObject>(); eventParent->SetStringField("object", "\\Events\\Default Work Unit");
次にイベントを構築します。それには追加するアクションの選択方法を決める必要があります。そのため、イベントアクションデータを含む構造体を作成しました。
struct EventActionData { int ActionType = 1; float Delay = 0.0f; int Scope = 0; FString Target = ""; };
次に、アクション配列や設定を渡すとイベントを構築してくれる関数が必要です。次のように記述しました。
TSharedPtr<FJsonValue> UMyActorComponent::BuildEventJson(FString EventName, TArray<EventActionData> Actions, FString Target) { // Event object TSharedRef<FJsonObject> event = MakeShared<FJsonObject>(); event->SetStringField("type", "Event"); event->SetStringField("name", EventName); TArray<TSharedPtr<FJsonValue>> ActionsArray; int number = 0; // Actions for (auto action : Actions) { FString numberString = FString::FromInt(number); TSharedRef<FJsonObject> ActionObject = MakeShared<FJsonObject>(); ActionObject->SetStringField("name", numberString); ActionObject->SetStringField("type", "Action"); ActionObject->SetNumberField("@ActionType", action.ActionType); ActionObject->SetStringField("@Target", Target); ActionObject->SetNumberField("@Scope", action.Scope); ActionObject->SetNumberField("@Delay", action.Delay); TSharedRef<FJsonValueObject> ActionValueObject = MakeShareable(new FJsonValueObject(ActionObject)); ActionsArray.Add(ActionValueObject); number++; } event->SetArrayField("children", ActionsArray); return MakeShareable(new FJsonValueObject(event)); }
上記が何をするのか説明しましょう。ご覧のように、イベント名、実行したいアクションの配列、ターゲット(どのオーディオオブジェクトを再生したり停止したりするか)を渡しています。この例ではシンプルにするためすべてのアクションに同じターゲットを使用しましたが、理論上はEventActionDataオブジェクトを通じて渡されたターゲットを使用することになります。
まずイベントJSONオブジェクトを作成し、型と名前を設定します。そしてアクション配列を反復処理し、必要なデータをすべて含むオブジェクトをアクションごとに作成します。今回はアクションの一部のプロパティのみを使いましたが、プロパティはほかにも多くあります。
各アクションには名前を与えなくてはならないようでした。ドキュメントの例では空の文字列でしたし、私の見る限りオーサリングアプリのどこにも表示されておらず意味がなさそうなので、適当な名前を与えました。
データがすべて揃ったら、このオブジェクトを値オブジェクトに変換して配列に加えます。そしてイベントオブジェクトを設定して返します。
では上記の関数を使ってみましょう。まずアクションデータを設定します。アクションの型とスコープにintが使用されていますが、これは理想的とは言えません。ここではやりませんが、できればこれを列挙型に変えます。
ともあれアクションを作成して配列に追加し、この関数を呼び出すと、そのまま使えるイベントオブジェクトが返されます。
// Create Event One EventActionData ActionPlay; ActionPlay.Delay = 0.5f; EventActionData ActionStop; ActionStop.ActionType = 2; ActionStop.Scope = 1; TArray<EventActionData> ActionsArray = { ActionStop, ActionPlay }; TSharedPtr<FJsonValue> EventOne = BuildEventJson("EventOne", ActionsArray, Target);
これで残りのオブジェクトを構築できます。まずイベント配列を構築します。前述のように、シンプルにするためイベントは1つだけにします。そしてイベント配列をイベントの親に設定し、必要な値オブジェクトへの変更を行います。
最後に引数オブジェクトの最後のフィールドを設定します。追加でイベントを作成する場合は「merge」や「append」を使うとよいでしょう。アクションのターゲットや設定をその場で変更したい場合は「append」ではなく「replaceAll」が便利です。
// Children Array TArray<TSharedPtr<FJsonValue>> eventsArray; eventsArray.Add(EventOne); eventParent->SetArrayField("children", eventsArray); TSharedRef<FJsonValueObject> EventParentValues = MakeShareable(new FJsonValueObject(eventParent)); TArray<TSharedPtr<FJsonValue>> argsArray; argsArray.Add(EventParentValues); args->SetArrayField("objects", argsArray); args->SetStringField("onNameConflict", "merge"); args->SetStringField("listMode", "append"); TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TSharedPtr<FJsonObject> result; FAkWaapiClient::Get()->Call(ak::wwise::core::object::set, args, options, result);
最終的にUnrealからこれを実行すると、次のようなきれいな形になります。
オーディオをインポートする
新たなオーディオをWwiseにインポートする方法を見てみましょう。これまでの例とやることは似ていますが、ファイルを1つインポートするだけなのに作成するオブジェクトや配列の数が多く、ややこしくなりがちです。
ここでも明示的なコードにしましたが、そうしなくてはならないわけではありません。理想を言えば、サウンドの作成とサウンドソースの作成でやり方を変えます。後者ではオーディオファイルに加えてヘルパー構造体や列挙型を定義する場合があります。いずれにしろ、大きなJSONオブジェクトを構造化する基本を知る上で、次のコードは十分練習になると思います。
void UMyActorComponent::WaapiImportAudio() { TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); // Parent TSharedRef<FJsonObject> soundParent = MakeShared<FJsonObject>(); soundParent->SetStringField("object", "\\Actor-Mixer Hierarchy\\Default Work Unit"); // Sound Object TSharedPtr<FJsonObject> soundOne = MakeShared<FJsonObject>(); soundOne->SetStringField("type", "Sound"); soundOne->SetStringField("name", "NewSoundTest"); // AudioFileSource Object TSharedPtr<FJsonObject> sourceOne = MakeShared<FJsonObject>(); sourceOne->SetStringField("type", "AudioFileSource"); sourceOne->SetStringField("name", "mySourceOne"); // Files to import TSharedPtr<FJsonObject> files = MakeShared<FJsonObject>(); // File One TSharedPtr<FJsonObject> fileOne = MakeShared<FJsonObject>(); fileOne->SetStringField("audioFile", "C:\\Users\\example\\Downloads\\exampleSound.wav"); fileOne->SetStringField("originalsSubFolder", "ImportingTest"); TSharedRef<FJsonValueObject> fileOneValueObject = MakeShareable(new FJsonValueObject(fileOne)); // Make Files Array TArray<TSharedPtr<FJsonValue>> filesArray; filesArray.Add(fileOneValueObject); files->SetArrayField("files", filesArray); sourceOne->SetObjectField("import", files); TSharedRef<FJsonValueObject> sourceValueObject = MakeShareable(new FJsonValueObject(sourceOne)); // Make Sources Array TArray<TSharedPtr<FJsonValue>> sourcesArray; sourcesArray.Add(sourceValueObject); soundOne->SetArrayField("children", sourcesArray); TSharedRef<FJsonValueObject> soundValueObject = MakeShareable(new FJsonValueObject(soundOne)); // Make Sounds Array TArray<TSharedPtr<FJsonValue>> soundsArray ; soundsArray.Add(soundValueObject); soundParent->SetArrayField("children", soundsArray); TSharedRef<FJsonValueObject> SoundParentValues = MakeShareable(new FJsonValueObject(soundParent)); TArray<TSharedPtr<FJsonValue>> argsArray; argsArray.Add(SoundParentValues); args->SetArrayField("objects", argsArray); args->SetStringField("onNameConflict", "merge"); args->SetStringField("listMode", "append"); TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TSharedPtr<FJsonObject> result; FAkWaapiClient::Get()->Call(ak::wwise::core::object::set, args, options, result); }
上記には注意点がいくつかあります。audioSourceオブジェクトの「import」フィールドは配列ではなくファイルオブジェクトを取ります。そしてこの「files」オブジェクトに、インポートするすべてのファイルの配列が含まれます。
また、オリジナル内のどのフォルダにファイルをコピーするかを指定できます。通常のサウンドなら、パスは「Originals\SFX」からの相対パスになります。まだ存在しないフォルダがパスに含まれている場合は作成されます。
最終的に上記のコードでWwiseに新規サウンドがすぐ表示されましたが再生はされず、メディアが見つからないというエラーが出ました。Wwiseを再起動するとすべてうまくいき、新しいオーディオを再生できました。どうもバグだと思われます。
トピックにサブスクライブする
最初に紹介したように、WAMPを使ってWwise Authoring Appと通信するには2つの方法があります。
- Remote Procedure Call(RPC):Wwiseに具体的な情報を要求して答えを取得します。
- Subscribe/Publish(Pub/Sub):あるトピックのリッスンを開始し、特定の条件が発生したら通知を受けます。オブザーバーパターンと似ています。
これまでは、RPC(Remote Procedure Call)を使ってWwiseから情報を取得していました。あるデータをある時間に取得するには便利ですが、変更があった時にWwiseから通知を受け取りたい場合もあります。これをSubscribe/PublishというWAMPプロトコルで実現できます。
サブスクライブできるトピックはすべてドキュメントで確認できます。ただここでもこれを動作させる構文が暗号めいている上、ドキュメントに例が記載されていませんでした。Wwiseプラグインクラスを掘り下げていくうちに、やっとヒントになる実装に出会うことができました。
RPCには「call」関数を使いましたが、今回は別の「subscribe」関数を使います。基本的にはまず特定の出来事をサブスクライブし、その出来事が起こったとWwiseから通知があった時に実行したい関数を渡します。
FAkWaapiClient::Subscribeを見てみましょう。この関数は次のパラメータを取ります。
- URI:サブスクライブするトピック。
- オプション:関数がどのデータを返すのか指定するための追加情報。
- コールバック:トピックが発生した時に呼び出したい関数。
- サブスクリプションID:サブスクリプションを区別するためのint。
- 結果:サブスクライブしていた出来事に関する追加情報。
ではこれらの使い方を見てみましょう。ak.wwise.core.profiler.stateChangedがとても分かりやすいので、これを例にします。
この先を理解するには、少なくとも関数ポインタとlambdaについての基本的な知識が必要です。
ここで重要なのはコールバックの構築です。FAkWaapiClient::SubscribeはWampEventCallback型を取ります。これらを作成するにはいくつか方法がありますが、私が一番簡単だと思うのはlambdaを使うことです。そのため次のようにします。
auto callback = WampEventCallback::CreateLambda([this](uint64_t id, TSharedPtr<FJsonObject> jsonObject) { const TSharedPtr<FJsonObject> itemObj = jsonObject->GetObjectField("stateGroup"); if (itemObj != nullptr) { FString StringToPrint = "The state group: "; const FString stateGroupName = itemObj->GetStringField("name"); const TSharedPtr<FJsonObject> state = jsonObject->GetObjectField("state"); const FString stateName = state->GetStringField("name"); subscriptionID = id; StringToPrint.Append(stateGroupName); StringToPrint.Append(" changed to "); StringToPrint.Append(stateName); GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Green, StringToPrint); } });
lambdaに慣れていなければ極めて不可解な構文に見えるでしょう。もしそうならlambdaについて学んでみるようおすすめします。そうすれば理解しやすくなるでしょう。
ご覧のように、コールバック変数と関数(ステートが変化したら実行したい内容)が一度に宣言されています。このケースでは、ステート名とステートグループ名を取得してUnrealエディタに表示します。メンバ変数にサブスクリプションIDが割り当てられていますね。これは後でサブスクリプション解除する時に使用します。
ではオプションオブジェクトを作成しましょう。ステートグループが使用しているステートを知りたいなら、これを指定する必要があります。ほかのフィールドも作成し、subscribe関数を使います。
// Options Object TSharedRef<FJsonObject> options = MakeShareable(new FJsonObject()); TArray<TSharedPtr<FJsonValue>> optionsArray { MakeShareable(new FJsonValueString("path")), MakeShareable(new FJsonValueString("id")), MakeShareable(new FJsonValueString("name")) }; options->SetArrayField("return", optionsArray); TSharedPtr<FJsonObject> result; uint64 SubscriptionId = 0; if (FAkWaapiClient::Get()->Subscribe(ak::wwise::core::profiler::stateChanged, options, callback, SubscriptionId, result)) { GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Green, "Subscribed!"); }
できました。ただし上記を実行するには、WwiseとUnrealが接続されている必要があります。これは前述のようにWAAPIがオーサリングアプリのSoundEngineで動作しているからです。ただしキャプチャーを作成する必要はありません。
関数が何度もトリガされないよう、忘れずにサブスクリプション解除もしましょう。実行の最後でサブスクリプション解除します。
void UMyActorComponent::EndPlay(const EEndPlayReason::Type EndPlayReason) { Super::EndPlay(EndPlayReason); FString out_result = ""; if (subscriptionID != 0 && FAkWaapiClient::Get()->Unsubscribe(subscriptionID, out_result)) { GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Blue, "UnSubscribed!"); } }
WAAPIをブループリントに直接使用する
これまでの説明で、C++でJSONオブジェクトとWAAPIを扱う方法を理解できたと思います。同じことをブループリントでできるとしたらどうでしょう?Unreal向けWwiseプラグインは、WAAPIで呼び出しやサブスクリプションを行う機能を提供しています。私は使いこなしているわけではありませんが、どのようなものかいくつか例をご紹介します。ここまで読みすすんできた方にとっては簡単だと思います。
これは接続状態を取得する例です。
WAAPIの呼び出しに使用するノードがありますね。これに入力として引数とURIを与えます。そのために適切な型の変数を作成します(以下を参照)。
このケースでは引数とオプションが空になっています。URIとフィールド名については、デフォルト値の横のマークを使って、使用できる値のリストを簡単に表示できます。
呼び出しを実行すると、「IsConnected」という名前のブール値フィールドを取得しました。成功です。
もう1つ見てみましょう。特定のオブジェクトを分離するケースです。これは任意のエミッタでそのオーディオだけを再生したい場合に便利です。Wwiseに移動することなく、すべてUnreal から実行できます。
この例の場合、引数オブジェクトを構築します。まず分離したいWwiseオブジェクトを含む、文字列フィールドの配列を設定します。今回は特定の1つのサウンド(「Object to Solo」)を分離します。引数オブジェクトに文字列の配列が設定されましたね。またブール値フィールドをtrueにします。分離はするけれども逆はしないからです。最後に呼び出しをすれば実行されます。
ブループリントだけで、たくさんのことができるとお分かりいただけたでしょう。もちろんC++の方が柔軟で強力ですが、手軽さと便利さならブループリントも負けていません。
プロファイラコントローラを作成する
最後に、より現実的な使用例を2つ紹介したいと思います。Unrealから直接プロファイラキャプチャーを開始するための簡単なhelperを構築してみます。
やり方はケースバイケースでさまざまです。この例では、関数からtrueにできるメンバーbool変数をサンプルクラス内に作成しました。この関数はUnrealコンソールから呼び出すことができます。ゲームワールドのボリュームからキーボードショートカットで呼び出すなど、たくさんの可能性が考えられます。
void UMyActorComponent::WaapiStartCapture() { m_activeWwiseCapture = true; }
変数がtrueになると、ティック時の接続状態を確認できます。
{ // Update connected to Wwise status TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TSharedPtr<FJsonObject> result; FAkWaapiClient::Get()->Call(ak::wwise::core::remote::getConnectionStatus, args, options, result); bool connected; m_isConnectedToWwwise = result && result->TryGetBoolField("isConnected", connected) && connected; }
次は実際の接続状態を確認できる変数、m_isConnectedToWwwiseです。私はこの変数をフレームごとに更新していますが、デバッグビルドに使用するだけでCPU負荷を気にする必要がないからです。接続の確立と切断を示すトピックをAudiokineticが提供し、サブスクライブできるようになることを期待していますが、今のところは提供されていません。
次はティック時にWwiseに接続されていない場合に接続を試みます。ローカルホストに接続を試みていますが、理論上はほかのどのIPでも使えます。
if (!m_isConnectedToWwwise) { TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); args->SetStringField("host", "127.0.0.1"); TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TSharedPtr<FJsonObject> result; if (FAkWaapiClient::Get()->Call(ak::wwise::core::remote::connect, args, options, result)) { GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Green, "Connected To Wwise"); } }
上記のコードは同期接続を実行し、接続が確立されるまでUnrealの実行を停止します。通常1~2秒かかりますが、これも私は気にしていません。開発ツールなので最終版のゲームに関係ないからです。
接続されたら、キャプチャーを開始できます。
{ // Start Capture TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TSharedPtr<FJsonObject> result; FAkWaapiClient::Get()->Call(ak::wwise::core::profiler::startCapture, args, options, result); }
できました。ここでキャプチャーに加えて、Unrealエディタにほかの情報も表示されたら便利だと思いました。例えば再生されているボイスの数や、それに関するデータなどです。情報はティックごとに呼び出され、頻繁に更新されます。
// Get Voices TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); args->SetStringField("time", "capture"); TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TArray<TSharedPtr<FJsonValue>> optionsArray { MakeShareable(new FJsonValueString("isVirtual")), MakeShareable(new FJsonValueString("objectName")), MakeShareable(new FJsonValueString("gameObjectName")), MakeShareable(new FJsonValueString("pipelineID")) }; options->SetArrayField("return", optionsArray); TSharedPtr<FJsonObject> result = MakeShared<FJsonObject>(); if (FAkWaapiClient::Get()->Call(ak::wwise::core::profiler::getVoices, args, options, result)) { auto voices = result->GetArrayField("return"); int numberOfVoices = voices.Num(); int voiceCount = 0; for (auto voice : voices) { FString voiceString = "Voice #"; voiceCount++; voiceString.Append(FString::FromInt(voiceCount)); voiceString.Append(" - "); FString voiceName = voice->AsObject()->GetStringField("objectName"); voiceString.Append(voiceName); voiceString.Append(" - "); FString gameObjectName = voice->AsObject()->GetStringField("gameObjectName"); voiceString.Append(gameObjectName); voiceString.Append(" - "); FString virtualString = "Is Not Virtual"; if (voice->AsObject()->GetBoolField("isVirtual")) { virtualString = "Is Virtual"; } voiceString.Append(virtualString); auto pipelineID = voice->AsObject()->GetIntegerField("pipelineID"); TSharedRef<FJsonObject> argsVolume = MakeShared<FJsonObject>(); argsVolume->SetStringField("time", "capture"); argsVolume->SetNumberField("voicePipelineID", pipelineID); TSharedRef<FJsonObject> optionsVolume = MakeShared<FJsonObject>(); TSharedPtr<FJsonObject> resultVolume = MakeShared<FJsonObject>(); if (FAkWaapiClient::Get()->Call(ak::wwise::core::profiler::getVoiceContributions, argsVolume, optionsVolume, resultVolume)) { if (resultVolume) { auto volume = resultVolume->GetObjectField("return")->GetNumberField("volume"); voiceString.Append(" ("); voiceString.Append(FString::SanitizeFloat(volume)); voiceString.Append(" dB.)"); } } GEngine->AddOnScreenDebugMessage(-1, 0.0f, FColor::Green, voiceString); } GEngine->AddOnScreenDebugMessage(-1, 0.0f, FColor::Green, FString::Printf(TEXT("We found %i voices:"), numberOfVoices)); }
上記のコードはやや複雑なので、軽く説明します。まずgetVoices関数を呼び出すために、必要なオブジェクトを構築します。ご覧の通り、データをいつ取り込むか指定しています。ここでは現在のキャプチャー時間を示す引数として、「capture」を渡しています。またオプションオブジェクトを定義して、使いたい情報をすべて含めました。
オブジェクトの配列ができました。各オブジェクトが1つのボイスです。数をカウントし、表示したいデータをすべて抽出します。今回は各ボイスについて表示したい文字列を構築しています。
ボイスボリュームを取得するにはgetVoiceContributionsの呼び出しが必要で、それにはボイスパイプラインIDが必要です。この呼び出しにより、Voice Inspectorの上部に表示される最終的なボイスボリュームをdB単位で取得できます。
Unrealにはこのように表示され、フレームごとに値が更新されます。
合計CPUも表示されると便利なので、そのために使ったコードも紹介します。
{ // Print total CPU. TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); args->SetStringField("time", "capture"); TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TSharedPtr<FJsonObject> resultCPU = MakeShared<FJsonObject>(); if (FAkWaapiClient::Get()->Call(ak::wwise::core::profiler::getCpuUsage, args, options, resultCPU)) { if (resultCPU) { auto cpuarray = resultCPU->GetArrayField("return"); float totalCpuValue = 0.0f; for (auto cpuElement : cpuarray) { totalCpuValue = totalCpuValue + cpuElement->AsObject()->GetNumberField("percentInclusive"); } FString cpuString = "Total CPU Usage is: "; cpuString.Append(FString::SanitizeFloat(totalCpuValue)); cpuString.Append(" %"); GEngine->AddOnScreenDebugMessage(-1, 0.0f, FColor::Cyan, cpuString); } } }
getCpuUsageはCPU使用率に影響するすべての要素の配列を提供します。そのため、それらをチェックして総合的なCPU値を出します。
Advanced Profilerで確認する場合と同じCPU使用率になる上、Unrealエディタ内で一目で確認できるという利点があります。問題があるとすれば、フレームごとに取得するため数値が忙しく変わり、読み取りづらいことです。そのため一定時間で平均化したくなるかもしれません。
デバッグ結果とCPU値の表示はこのようになります。
上記はあくまでも一例です。表示したい情報はすでにみなさんの頭の中にあるかと思います。
最後に、EndPlayで呼び出す次の関数を紹介します。これによりPIE停止時にキャプチャーを停止し、Wwiseとの接続を切断します。もちろん、必要がなければこの動作は行わなくても構いません。理想を言えば、ゲーム終了時に接続やキャプチャーを停止するかどうかユーザが決められる設定があるとよいでしょう。
void UMyActorComponent::WaapiStopCapture() { m_activeWwiseCapture = false; TSharedRef<FJsonObject> args = MakeShared<FJsonObject>(); TSharedRef<FJsonObject> options = MakeShared<FJsonObject>(); TSharedPtr<FJsonObject> result; if (FAkWaapiClient::Get()->Call(ak::wwise::core::profiler::stopCapture, args, options, result)) { GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Red, "Stopping Capture"); } if (FAkWaapiClient::Get()->Call(ak::wwise::core::remote::disconnect, args, options, result)) { GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Red, "Disconnecting"); } }
Unrealデータアセットに基づき、Wwise階層を構築する
最後に、今回学んだすべての内容を使って、実際のプロジェクトの機能に近いものを構築しました。詳細を説明する前にまずご覧ください。
このゲームでは敵がプロシージャルに生成されます。敵のアクションごとにオーディオを再生しますが、このオーディオはタイプやレア度によって異なる必要があります。そのための1つの方法は、さまざまなスイッチ値を扱う大きなスイッチコンテナ構造を1つのイベントによってトリガし、適切なサウンドを再生させることです。
Unrealから敵データを読み込むと、コードがこの構造をWwise内に自動的に作成します。このデータはスプレッドシートやCSVファイルなど、Unreal以外のデータでも構いません。今回はUnrealをテーマにした記事なので、Unrealデータアセットファイルを使いました。
最初はコード全体を一通り説明しようと思いましたが、長くなりすぎるので大まかに説明しようと思います。コードについてはこちらをご覧ください。ヘッダとcppの両方を含めました。ぜひ読んでみてください。
今回はより体系的に、またモジュール式で記述しました。実際のプロジェクトに近くなっています。さまざまな型のオブジェクトを作成する上で繰り返しが多々あったため、さまざまな汎用helper関数を書いて使用しました。これらはカスタムのWAAPIライブラリ静的クラスにするのが理想的ですが、私の例では同じクラスにしています。
このコードのエラーやnullのチェックはほとんどしていないので、本番環境には対応しておらず、みなさんが手を加えるとすぐエラーやバグが発生すると思います。とはいえ練習には十分なので、見ていきましょう。
UDataAssetから継承したクラスに敵のデータがあります。このクラスには敵のバリアントを表す通し番号付きのデータと、この情報をWwiseに送信するためのすべてのロジックが含まれます。このクラスからアセットを作成します。アセットはゲームのレベルごとに1つにすることもできます。
前述のように、目的はこのデータを使ってスイッチコンテナ階層を作成することです。そのために、Wwise内のどこにゲームシンクとコンテナを作成するか、ユーザが指定する必要があります。また、インポートしたオーディオファイルを入れるフォルダを指定すれば、オリジナルフォルダの中身が乱雑になりません。上記をご覧ください。
データファイルの2つ目のセクションには、敵のすべての情報が含まれます。この情報はゲームデザイナーが入力するはずです。要素のタイプの数、レア度のレベルの数、アクションのタイプの数などです。ここでは例えば、こうしたタイプそれぞれに必ず1つの配列メンバがあるというような仮定をしています。
ご覧のように、各要素で「Update Wwise Structure」を選択できます。選択するとスイッチコンテナ構造にこの要素が含まれます。つまりWwiseに表示したい場合は、このチェックボックスをオンにします。
各オブジェクト(スイッチコンテナオブジェクトまたはSoundSFXオブジェクト)に適用できるオプションのプロパティもあります。分かりやすいように数値で表すプロパティだけを追加しましたが、文字列やブール値のプロパティも簡単に追加できます。ただし、参照やリストの追加(エフェクト共有やRTPCの追加など)はもう少し複雑です。
上記のデータがすべてコードの対象となり、ボタンを押すと即座に必要なオブジェクトすべてがWwiseに入力されます。
コードの中身の方では、カスタムの列挙型と構造体でさらに明確に情報を表示しています。上記すべてを動作させるため、次の関数を構築しました。
- スイッチグループゲームシンクとその子を作成する。
- スイッチコンテナを作成する。
- スイッチの子をスイッチコンテナグループに割り当てる。
- 数値プロパティ(ボリュームやピッチなど)をオブジェクト(スイッチコンテナやランダムコンテナなど)に設定する。
- オーバーライドか付加設定として設定できます。
- 数値プロパティの値をオブジェクトから取得する。
- ゲームパラメータ(RTPC)ゲームシンクを作成する。
- 任意のオブジェクトにRTPCを設定する(RTPCカーブを追加するなど)。
- SoundSFXオブジェクトを作成する。
- オーディオファイルの命名規則に従い、正しい場所にオーディオをインポートする。
私がWAAPIで構築した中では一番規模の大きいコードなので、うまく動作するまで時間がかかりました。引数を何度も構築しなくて済むよう、helper関数は必須だと思います。私ならこれを静的WAAPIヘルパークラスに移行させるでしょう。
上記は動作しますが、妥協しなくてはならない点もいくつかあります。長所と短所を見てみましょう。
長所:
- 最初の構造の組み立てが非常に簡単ですぐにできます。コンテナを何十個も用意する必要はありません。何かをコピーペーストしたりコンテナの名前を変更したりする必要もありません。
- Wwiseの数値プロパティを一度に簡単に変更できます(すべてのデスサウンドを-2dBにするなど)。HDRミキシングに非常に便利です。
- 新たな敵タイプ、レア度、アクションが追加された時に、ワンクリックですべてのコンテナを作成できます。すべて所定の場所に作成されます。
- オーディオがすべて適切な命名規則で1つのフォルダに保存されている場合、オーディオアセットが自動的にインポートされます。
- オーディオをインポートする時、どのオリジナルサブフォルダを使用するか選択できます。
- さらによいことに、そのオーディオをもう一度インポートしてフォルダ内のファイルを置き換える時に、ワンクリックですべてのファイルを置き換え、正しい場所に配置できます。これは非常に楽です。これにソース管理も追加できますが、今回はしませんでした。
- 収拾がつかなくなったら処理全体をWwiseで取り消すことができます。
- ボリューム、ローパス、ピッチなどの変更を付加的に行ったりオーバーライドで行ったりできます。
- 変更を適用すると、プロパティがクリアされるというデフォルト動作になっています。何度も適用することはないと思われるからです。
- メインのトップスイッチコンテナにRTPCを追加すると、すでに存在しているか確認が行われます。そのためコピーが重ねて追加されたり、既存のRTPCが消去されたりすることはありません。
短所:
- Wwiseでコンテナ名に手を加えはじめたとたんにつまずくと思われます。動作の中身を名前で示しているからです。代わりにIDを使った方が確実かもしれません。
- 特定のコンテナにプロパティを設定できるとはいえ、例えば氷タイプの敵のデスアクションだけを変更するような限定的なことはできません。ただし、これは後で追加できます。
- 同じオーディオソース内での別バージョンの維持には対応していません。必要があれば実現できます。
- 敵タイプ、レア度、アクションをデザイナーが削除しても、Wwiseからは削除されません。その方が安全だと思ったからですが、それによりデータファイルとWwiseプロジェクトの同期が取れなくなります。一長一短ですね。
- スイッチコンテナ構造について、タイプ→レア度→アクションという型にはまった階層構造を想定しています。「経過時間」などの新たな特性を追加すると、ものすごく大変ではありませんが、コードの変更が必要になります。タイプではなくレア度を最優先にフィルタリングするなど、特性の順番を変更する時も同様です。特性の作成や移動ができる柔軟なシステムを作ることはできますが、複雑になります。
- RTPCの関数は非常にシンプルなカーブを作成しますが、それを定義する権限をユーザに与えませんでした。必要があればUnrealのfloatカーブを使って定義できます。
- ほかの子スイッチコンテナやオブジェクトに、データアセットを介してRTPCを追加できません。構築しはじめた時に、機能が多すぎて身動きが取れなくなりそうになったため、諦めた部分です。
全体としてはWAAPIで構築できる内容を紹介し、Unrealデータを使ってWwiseに階層を作成する方法を説明する例にふさわしいのではないでしょうか。
リンクおよび参考資料
Wwise Authoring API Reference - Functions
Wwise Authoring API Reference - Topics
Wwise Authoring API Examples Index
オーディオファイルのインポートとストラクチャーの作成
Wwise Project のクエリ
WAQLを使ってみる
Wwise Authoring Query Language (WAQL) リファレンス
Wwiseオブジェクトリファレンス
ak Blog - 新しくなった、Wwise Authoring API
ak Blog - WAAPIの事例と使い方
ak Blog - WAAPIをよりシンプルに
ak Blog - 誰でも使えるWAAPI
ak Blog - 新WAQL(Wwise Authoring Query Language)の紹介
ak Blog - WAAPIとPythonを利用したチーム作業の紹介とその例
Wwise Up On Air - WAAPI (2019)
Wwise Up On Air - WAAPI (2022)
Wwise Up On Air - WAAPI (2024)
C++で文字列リテラルを使用する
JSON formatter
コメント