menu
 

快速了解如何在 UE5 中结合 C++ 使用 WAAPI

音频编程 / 游戏音频

各位可以通过这篇博文快速了解如何在 Unreal 中使用 WAAPI。其中的大部分内容在 Audiokinetic 文档和 Wwise Up On Air 视频中都有提及。不过,我觉得还是有必要把各种相关知识整理到一起,并提供一些针对 Unreal 的具体示例以便大家学习借鉴。

其实在我自己当初学习的过程中,很多操作也是好不容易才搞明白。感觉有些资料说的不是很清楚,有时候理解起来还挺费心思的。不过,随着了解到的知识越来越多,并慢慢开始动手真正去实践,我发现很多东西都摆在那里,只是需要自己去领会和贯通。

当然,假如我对 C++ 和 JSON 很熟,当时学起来应该会容易很多。不过各位估计也不怎么熟悉,所以也许这篇博文能帮到您。理想情况下,读者最好具备扎实的 Wwise 知识和良好的 C++ 编程技能。这样才好跟着文中所讲的内容进行操作。

目录

Wwise API 和 WAAPI
启用 WAAPI
何为 WAMP?
在 Unreal 中使用 WAAPI
WAAPI 远程过程调用
了解 JSON
填充 JSON 对象
添加必要的头文件和模块
构建参数并提取生成的数据
封装 WAAPI 调用
使用 options 字段
两个 SoundEngine
结合 WAQL 使用 WAAPI
在对象上设置数据和导入音频
名称冲突、列表和列表模式
修改对象的属性
创建新的对象
解析原始 JSON 字符串
创建新的 Event
导入音频
订阅主题
直接在 Blueprint 上使用 WAAPI
创建性能分析器控制器
通过 Unreal 数据素材构建 Wwise 层级结构

Wwise API 和 WAAPI

首先,我们要搞明白 WAAPI 是什么。它跟 Wwise API 是一回事吗?

这个问题让我也困惑了好一阵子。一方面是因为自己缺乏经验,另一方面是 WAAPI 确实可以实现很多常规 API 具备的功能。不过,反过来并非如此。

Wwise API 包含所有用于从游戏向 Wwise 音频引擎发送信息的函数和类。常用情形包括发送 Event、设置 RTPC 等等。

该 API 涵盖了文档网站 Wwise SDK 版块阐释的大部分信息。因为我们使用的是 Unreal,所以还可在此包含专门用于游戏引擎实现的类。

WAAPI (Wwise Authoring API) 则允许与 Wwise 设计工具而非在游戏端运行的 Wwise 音频引擎通信。Wwise 设计工具就是我们用来为项目创建声音和 Game Sync 的软件。

WAAPI 可以跟很多编程语言结合使用,并且其一般通过 JSON 接收和发送信息。这对我来又是一道坎儿,因为我几乎没用过 JSON,更没有结合 Unreal 用过。

启用 WAAPI

我们需要先在 Wwise 的 User Preferences 窗口中启用才能使用 WAAPI。如您所见,这里设有 HTTP port 和一个 WAMP port。我们对后者比较感兴趣,因为它可以实现更多功能。事实上,这也是推荐的 WAAPI 通信方式。

启用 WAAPI

何为 WAMP?

WAMP 全称 Web Application Messaging Protocol。它是一种用于在不同应用程序之间通信的通用通信协议。Web Application 这部分听起来可能有一点奇怪,但也暗示了 WAAPI 本质上就像服务器连接一样。其实就跟将游戏构建连接到 Wwise Profiler 的概念并差不多。

在 Unreal 中使用 WAAPI

正如之前所说,WAAPI 并不受语言和平台限制;所以,我们可以在 Unreal 中结合 C++ 一起使用(甚至可以直接用在 Blueprint 上)。另外,Audiokinetic 提供了一个可以用在 Unreal Wwise 插件中的类。有关如何使用这个类的详细信息,请参阅 FAkWaapiClient 相关内容。在本文中,我们会用到该类中的几个函数。

通常,我们可以通过两种方式来使用 WAMP 与 Wwise 设计工具进行通信:

  • 远程过程调用 (RPC):藉此,可向 Wwise 询问一些具体信息并获得答复。比如,使用 RPC 请求获取 RTPC。很方便。
  • 订阅/发布 (Pub/Sub):藉此,可订阅特定的主题。一旦发生目标事件,我们就会收到通知。就跟观察者模式一样。

WAAPI 远程过程调用 

我们先来说说有关如何使用远程过程调用的流程。在此之前,各位可点击此处快速查看所有可供使用的函数。

我们可以使用 FAkWaapiClient::Call 向 Wwise 询问自己想要的信息。当时,我花了一番周折才弄明白要使用的类和函数。不怕各位笑话,当时我在文档中真没找到这方面的明确信息。找到之后发现,哪都能看到它:视频里、示例代码中、Wwise 插件的类中…

总之,这就是我们要用的函数。理论上来说,也可绕过 FAkWaapiClient 直接向引擎发起调用,只不过没必要把事情搞得那么复杂。

注意,FAkWaapiClient::Call 有两个重载,分别接收 FStrings 和 FJsonObject,其中,后者属于 Unreal 原生的 JSON 实现。另外,我发现在大部分情况下后者使用起来更为便捷,至少在所传递的参数比较简单的情况下是这样。稍后会详细介绍如何直接传递字符串。

img2

所以,该函数接收以下参数。若调用成功,则返回 true。可选参数在这就不说了,这方面应该不用多解释。

  • uri:要调用的特定函数
    • 对于 FAkWaapiClient,格式要像下面这样:ak::wwise::core::profiler::getCursorTime。
  • args:上述函数要接收的数据。
  • options:该附加信息用于修改函数要返回哪些数据。
  • return:最终获得的信息。当中可能包含各种类型的数据。

注意,args 和 options 参数为 TSharedRef<FJsonObject> 类型,return 则为 TSharedPtr<JsonObject> 类型。我当时肯定分不清啊,所以有阵子就搞混了,结果出现一大堆错误。各位可别学我!

它们都是 Unreal 智能指针类型(详请点击此处)。不过,两者之间有明显的区别:TSharedRef 指向的对象不能为空。这一点不难理解。我们创建这类指针就是为了将其作为常量引用传给 Call 函数。另一方面,TSharedPtr 指向的对象则可以为空。因为调用的结果不一定包含数据,而且这类指针是作为非常量引用传递的。

注意,我的代码中有时还会使用 MakeShared() 创建 JSON 对象,并使用 MakeShareable() 创建其他对象。前者的开销相对比较小,但对象必须具有公共构造函数;后者开销较大,但支持私有构造函数和其他自定义行为。对我们来说,两种都可以用。事实上,我一直都是混着用的。之所以如此,主要是因为我经常会从原生 Wwise 类和自己之前的代码中复制粘贴示例代码。

了解 JSON

JSON 是一种用于在不同系统之间交换“用户可读”数据的开放标准(引号部分为笔者所加)。WAAPI 之所以使用这种格式,主要是因为它不受语言限制,可被很多不同的软件堆栈使用。

话虽如此,但 JSON 对象中有什么呢?我们可以把它看作以键值对形式存储数据的 C# 字典或 C++ 映射。

“键”必须是字符串,“值”则可为原始变量、对象,或者包含变量或对象的数组。在以下示例中,JSON 对象包含两个键值对:

{
    "type" : "Sound",
    "@volume" , 10.5
}

填充 JSON 对象

那我们如何构建这些 JSON 对象呢?就文档中的示例或 AK 的 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 函数来构建对象。

我写这篇博文的一个主要目的是提供一些具体的例子来阐释如何构建这些对象。我找了一下但没找到在 Unreal 中结合 C++ 使用 WAAPI 的实际例子。所以,如果没有太多操作 JSON 对象的经验,可能要花点工夫熟悉相关的语法和工作流程。

添加必要的头文件和模块

在开始之前,我们要确保可在 Unreal 工程中使用 Wwise 和 JSON。为此,需在 cpp 文件顶部添加类似以下代码: 

#include "../Plugins/Wwise/Source/AkAudio/Public/AkWaapiClient.h"

不需要添加任何 JSON 头文件,因为以上代码已经指向它了。

不过,要将 Json、JsonUtilities 和 AkAudio 添加到工程构建文件。该文件的名称大概像这样:UnrealProjectName.Build.cs。

顺便说一句,我的所有示例都是基于 Wwise 2023.1 和 Unreal 5.3 构建的。

构建参数并提取生成的数据

我们先以 getCursorTime 函数为例说一下。文档里是这样写的:

Wwise getCursorTime 函数

如您所见,该函数会接收一个参数并以此确定是需要 User Time Cursor(用户在性能分析器上单击的位置)还是 Capture Time Cursor(当前捕获所处的位置)。星号 (*) 表示该参数为必填项。

最终,我们将获得一个以整数形式表示的时间码值。如果没什么差错,结果就会是这样!

接下来,我们构建 JSON 对象。为此,可像下面这样声明参数:

TSharedRef<FJsonObject> args = MakeShared<FJsonObject>();

注意,我们使用了 TSharedRef,因为这是函数要求的参数类型。然后,使用了 MakeShared 函数来构建对象。

现在我们有了 JSON 对象,接下来要对其进行修改,以向函数传递相应信息。注意,我们需要传递一个 cursor 类型的字符串字段。对此,我们选择了 capture。之所以如此,是因为我们要查的是 Capture Time Cursor 而非 User Time Cursor。

为此,我们可以像下面一样使用 FJsonObject 原生的一些实用函数:

TSharedRef<FJsonObject> args = MakeShared<FJsonObject>();
args->SetStringField("cursor", "capture");

我费了一番心思才写出上面两行代码,因为刚开始我找不到任何具体的例子。在 GitHub 上搜了之后,我才慢慢有了一点头绪。而且,我还找到了一些执行 WAAPI 调用的 Wwise 类。当然,这是因为这时候我已经知道自己要找什么了!

接下来,我们也为 options 和 result 创建相应的对象。注意,我们在这里并不需要修改任何字段;另外,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 值。

完整代码就像下面这样:

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");
	}
}

就这么简单!注意,这次我们使用了 get 函数来查找所生成 JSON 对象要包含的整数值。

我们在这里获取的 int 值是以毫秒表示的时间。接下来,只需将其转换为时间码或其他所需格式并执行相应操作即可。比如,在 Unreal 中将其输出到屏幕上。 

封装 WAAPI 调用 

下面我们再来看个例子。在这个例子中,我们会将 WAAPI 调用封装成可通过其他类或从 Blueprint 调用的函数。

为此,我创建了新的 Actor 组件类并编写了以下函数:

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。

语法和逻辑比之前还要简单,因为甚至都不需要解析结果。

在 Blueprint 上看起来就像这样:

通过 Blueprint 封装 WAAPI 调用

最终,我们在 Wwise 捕获日志 (Profiler) 中看到了以下消息:

这条消息被显示为了错误。目前还无法通过该函数选择不同的日志级别。

当然,您可以使用 ak.wwise.core.log.addItem 来选择严重级别。不过,这样恐怕无法在性能分析器视图中记录日志,且声音设计师会有很大概率看不到相应消息。

使用 options 字段

现在,我们再来看一个参数更为复杂的函数。比方说,我们想使用 ak.soundengine.getState 来了解 Wwise 中某个 State Group 当前所用的 State。

我们来看一下文档里是怎么说的:

Using-the-options-field-WAAPI

如您所见,我们可以用几种不同的方式传递参数,但都会返回指向 State Group 的字符串。除此之外,options 字段也是可选的。最终的 result 字段就像下面这样:

WAAPI-result-field

刚开始的时候,我感到很费解。options 和 result 怎么看着差不多啊。后来我才明白,这是因为我们使用了 options 参数来准确告诉 Wwise 自己希望 result 包含哪些数据。那如果将 options 字段留空呢?这样的话就只会获取前两个字段:ID 和 name。我用代码试了一下就发现了这一点,但后来我在文档中看到了以下说明:

“除此之外,还可在查询中运用选项并做相应的指定:
return:指定要从对象返回的内容。若未指定,则默认为 ['id', 'name']。”

为此,我们像之前一样构建 JSON 对象。只不过对于 options 字段,我们将类型设为了数组。对此,我们需要以更复杂的方式来构建对象。比如像下面这样:

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 作为键值来设置字段。我发现 optionsArray 的名称是 return 而不是 options 什么的。好奇怪。难道只有我这样觉得吗?

另一方面,这里的 args 字段非常简单。所以,我们只需指定 State Group 即可。对此,我们可以使用它的路径。记住,所有反斜杠都要成对使用,因为单个反斜杠是转义字符

完整代码就像下面这样:

FString UMyActorComponent::WaapiCall()
{
	// args 对象
	TSharedRef<FJsonObject> args = MakeShared<FJsonObject>();
	args->SetStringField("stateGroup", "\\States\\Default Work Unit\\TestState");

	// options 对象
	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;
}

当然,您也可以将其封装成接收 State 名称或路径的函数。只不过在这个例子中,我直接将其硬编码了。但是,这样生成的 Json 对象并不直接包含我们要找的字段,所以我们不能像之前那样使用 GetStringField。对此,我们需要获取键值为 return 的对象,然后再从该次级对象获取有效数据。

刚开始我根本不知道要这样做,我以为可以直接使用 result 对象,或者对象会包含数组,但事实上并不是这样。

不管怎样,我们现在可以返回包含所生成信息的字符串了。之后,还可在工程中显示这些信息。在此,我选用了 name 和 notes 字段。

WAAPI-returning-the-string

注意,只有将 Wwise 设计工具连接到游戏,以上代码产生的结果才是准确的。在断开连接的时候,依然会获得一个值。不过,这个值就只是设计工具中的值。它可能是游戏发送的最后一个值,也可能是刚刚通过 SoundCaster 设置的值。这一点要特别注意,后面我会详细说明。

两个 SoundEngine

除了 WAAPI,我们还可以在 Unreal 或游戏构建中使用 Wwise SoundEngine 来通过常规 Wwise API 获取特定 State。

为此,需在 UnrealProjectName.Build.cs 文件中加入 WwiseSoundEngine,然后像下面这样引用声音引擎头文件:

#include "../Plugins/Wwise/Source/WwiseSoundEngine/Public/Wwise/API/WwiseSoundEngineAPI.h"

Wwise-SoundEngine-Unreal-get-particular-state

现在,我们可以通过 API 查询 State 了。从功能上看,这跟我们在上一节使用的 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 Editor 或游戏构建端运行的 SoundEngine 获取信息。所以,它们可以用来驱动玩法逻辑,因为相关信息是实时可用的,且能反映游戏中发生的状况,但是可获取的信息是有限的。

记住,对于 URI 以 ak.soundengine 为开头的 WAAPI 函数,前面所说的都是适用的,因为所有这些函数都有与之对应的常规 API 函数。

总之,在开发当中要记住有两个 SoundEngine 在运行:引擎端或游戏端运行的 SoundEngine 和设计工具端运行的 SoundEngine。我们可以对其中任何一个进行查询。下面同样以 GetState 为例做下总结: 

  通过 WAAPI 执行 GetState 通过常规 API 执行 GetState
SoundEngine Wwise 设计工具端运行的实例 游戏引擎编辑器端或游戏构建端运行的实例
信息准确性 只有在连接到 Wwise 设计工具时才准确。
在断开连接的时候,将提供游戏在断开前最后发送的数据,或用户通过 SoundCaster 设置的数据。
总能准确反映游戏状况。
必要时可用来驱动玩法。
可用数据 大量数据(因为在访问 Wwise 设计工具)。 只有 State 的 short ID。

 

结合 WAQL 使用 WAAPI

WAQL 全称 Wwise Authoring Query Language。简而言之,它允许使用非常具体且模块化的条件来查找各种不同的 Wwise 对象(如总线、容器或 Event)。您可以进一步筛选查询,以获取自己想要的结果。

我们可以结合 WAQL 利用 WAAPI 的强大功能,来查找 Wwise 中非常具体的对象,甚至以某种方式对其进行修改。我们先来看看如何以此获取对象。

在此,我们使用 ak.wwise.core.object.get,因为该函数可以获取 WAQL 字符串。注意,该函数同样适用于 from/transform 格式。只不过这种方法比较老,现在已经不再推荐使用。您可以点击此处了解更多信息。总之,参数就像下面这样:

using-ak-wwise-core-object-get  

返回结果是一个包含任何所需数据的数组。那么,我们怎么决定都要返回哪些数据呢?对此,可像 getState 函数一样添加 options 字段。

result-ak-wwise-core-object-get

接下来,我们构建相应的函数以及 Blueprint 节点,以获取 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;
}

仔细看的话会发现,result 字段的解析方式有点不一样。它包含一个数组,里面有我们想要的信息。但其并非字符串数组,而是 FJsonValue 数组。所以,我们需要将其转换为对象,然后分别调用 GetStringField。

对此,我们只需遍历数组来收集自己想要的信息即可。在这个简单的示例中,我只是将所有信息附加到了字符串,但事实上还可以用它来做很多事情。

我们可以通过一些提示词进行测试。比如,使用 $ from type Event 来获取工程中的所有 Event。

Get-Wwise-Object-WAQL-Unreal

最终,我们在 Unreal 中显示了一系列 Event。

Wwise-events-printed-Unreal

WAQL 功能强大、用途广泛。您可以点此查看简介,或点此查看相关参考。对此,我们就不展开说了,因为这里要重点介绍的是 WAAPI。

下面我们再来看个例子:

WAAPI-component-Blueprint

在本例中,我们要找到一个音量为负的声音对象。如您所见,我们得到了想要的结果。

Wwise-Unreal-successful-query

由此可见,我们可以通过 ak.wwise.core.object.get 将非常具体的 WAQL 查询传给 WAAPI,然后按照自己想要的方式在代码或 Blueprint 中使用这些信息。举个例子,您可以以此构建类似于单元测试的工具,确保在 Wwise 中按照预期设定相关设置。比如,正确填充和配置脚步声系统中的 Switch Container。

当时我还在想,其实可以在 Unreal 中借助 WAAPI 来基于游戏中的 AkComponent 列出相关信息。

在对象上设置数据和导入音频

到目前为止,我们一直都是在向 Wwise 请求数据,所以本质上是在读取 Wwise 的数据。但是,WAAPI 的主要功能在于对 Wwise 工程做实际的修改。

试想一下,只需单击几下就能创建具有复杂层级结构的 Work Unit,系统化地成批导入音频,或者从 Wwise 之外的数据源(如 Unreal 数据文件)构建总线系统。

如果要使用特定于关卡(我在这里指的是游戏中的关卡或区域)的 Wwise Event,我们可以基于包含关卡信息的 Unreal 素材来以编程的方式创建这些 Event。当然,我们可以直接将关卡条件和设置转化为对 Event 的操作。

除此之外,还可让 Unreal 素材与 Wwise 素材保持同步。比如,想象一下每次我们在 Unreal 中创建新的 Physical Material 时,都会在 Wwise 中创建对应的 Switch,并将其添加到相应的 Switch Group。

很好,但是该怎么操作呢?从文档来看,很多函数都可以帮我们实现上述各种功能。但是,操作起来很快会变得越来越复杂。建议各位阅读此页面,了解相关功能和限制。

不管怎样,我想重点说说下面四个函数:

  • ak.wwise.core.object.create:创建新的对象并将其作为给定父对象的子对象。使用起来非常简单,但是存在一定限制,所以不再推荐使用。
  • ak.wwise.core.audio.import:创建对象并为其导入新的音频。
  • ak.wwise.core.object.setProperty:为单个对象设置单项属性。没有 options,也不用 return。简单易用,但功能有限。
  • 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 具有此类属性。
  • replaceAll:移除所有现有对象,并添加新的对象(对重复项有限制)。

修改对象的属性

首先,我们使用 ak.wwise.core.object.set 来修改现有对象以了解相应语法。

using-ak-wwise-core-object-set

从文档来看,您会发现参数要包含一个对象数组,而该对象数组至少得包含一个对象。如果以前没有用过 FJson,可能要花点工夫熟悉相应语法。比方说,我们要修改某个声音对象:添加音符、更改音量或调节低通滤波幅度。

如果查看文档示例或 Audiokinetic WAAPI 视频,就会发现它们使用的全都是原始 JSON 字符串。在我们的示例中,看起来就像下面这样:

{
    "objects": [
        {
            "object": "\\Actor-Mixer Hierarchy\\Default Work Unit\\Sound1",
            "notes": "Hello!",
            "@Volume": 15.7,
            "@LowPass": 25
        }
    ],
    "onNameConflict": "merge"
}

我们有两种选择:直接将上述字符串作为原始或字面字符串用在 WAAPI 调用中,或者按照与之前相同的方式构建 JSON 对象。

在我看来,前一种方式更具可读性。不过一旦要创建复杂的结构,后面确实会变得不太好理解。关键在于,我们通常希望以编程的方式构建结构,因此使用 JSON 函数可能是最好的选择。

我们来看看如何构建上述 JSON 对象。首先,声明 args 对象和要修改的声音对象(目前只有一个)。

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);

这个时候就轮到棘手的部分了。现在,我们要以某种方式将对象添加到 args 对象,但 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 添加到数组并将数组添加到 args 对象。最后,再创建其余的对象。搞定!完整代码就像下面这样:

void UMyActorComponent::WaapiObjectSetModify()
{
	TSharedRef<FJsonObject> args = MakeShared<FJsonObject>();

	// 要修改的对象
	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);
}

很好。代码可以正常运行。不过想必各位也看得出来,要是想只用一个调用就修改大量对象,代码会变得异常复杂。更好的方法是将上述函数封装起来,然后从其他地方基于对象加以调用。或许还可提供更为便捷的方式,以便用户选择要修改哪些属性。在本例中,我们直接把要修改的对象硬编码了。不过,其实也可使用 WAQL 来查找一组非常特定的对象。

最后,可以看到有些对象字段使用了 ak.wwise.core.object.set 文档中列出的名称(如 notes)的,有些对象字段则使用了别的名称。就是那些名称前带有 @ 的对象字段(如 @Volume)。这些都是对象本身(本例中为 Sound)应用的属性。您可以在这里找到所有这些不同的对象及对应的值。一般来说,可在属性名称前添加 @ 来更改各对象参考页面列出的属性。

创建新的对象

下面我们再来看个从头开始创建新对象的例子。至于如何创建 JSON 对象我就不多说了,因为流程跟前面说的基本上是一样的。

记住,在要创建新的对象时,请将其创建为其他对象的子对象。这里的父对象通常为 Work Unit、Virtual Folder 或各种类型的容器。除此之外,也可通过 ak.wwise.core.object.set 创建新的 Work Unit。要想放在层级结构的根目录下,可使用这个根目录作为父目录(如 \\Actor-Mixer Hierarchy)。

总之,为了方便本次演示,我们要在 Default Work Unit 下创建两个声音对象。不妨仔细阅读并试着理解以下代码:

void UMyActorComponent::WaapiObjectSetCreate()
{
	TSharedRef<FJsonObject> args = MakeShared<FJsonObject>();

	// Work Unit
	TSharedRef<FJsonObject> workUnit = MakeShared<FJsonObject>();
	workUnit->SetStringField("object", "\\Actor-Mixer Hierarchy\\Default Work Unit");

	// Child One
	TSharedRef<FJsonObject> childOne = MakeShared<FJsonObject>();
	childOne->SetStringField("type", "Sound");
	childOne->SetStringField("name", "GeneratedSoundTest");
	TSharedRef<FJsonValueObject> childOneValues = MakeShareable(new FJsonValueObject(childOne));

	// Child Two
	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 关卡或 Blueprint 读取信息。不管怎样,我们都需要一些辅助类来以模块化的方式构建 JSON 对象。我在这里展示了各种语法以便各位有个简单的了解,甚至还在文章结尾贴了完整的示例来做综合的展示。

除非…不想用代码构建 JSON 对象,而是想通过键入来手动构建。搞不好真的有人想这样做。那我们来看看怎么实现吧。

解析原始 JSON 字符串

这样做的最大优点是可以直接复制粘贴 Wwise 文档中的各项示例。所以,我们至少有很多内容可以拿来参考。

不太好的地方在于不方便根据自己的需要修改结构,而且随着结构越来越复杂后面维护起来也颇为费力。当然,要想以编程方式构建参数,这种方法肯定是行不通的。不过,网上有很多用来解析和修改 JSON 字符串的工具可供选用。这就是 JSON 作为一种通用协议的优势所在。

所以,我创建了一个小型函数用以接收原始或字面 JSON 字符串,并输出 JSON 对象以供 WAAPI 调用函数使用。下面我们来看一下:

TSharedRef<FJsonObject> UMyActorComponent::ParseJSON(FString RawJson)
{
	// 将原始字符串解析为 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 客户端调用函数也有个重载,可以直接接收字符串而非 JSON 对象,所以可能并不需要上面的函数。但若两种方法混着用,该函数可能会很有用。

不管怎样,我们还要做一件事。如您所见,我的函数请求获取 FString,但该字符串的格式要正确。不管该字符串存在于何处,都要确保它是原始字符串,或者包含相应的转义字符,同时在一行之内予以定义。另外,还可使用在线 JSON 解析器来获取用户可读的 JSON 并将其转化为紧凑版本。下面来看下前后对比:

// 之前:普通 Json
{
	"objects": 
	[
		{
			"object": "\\Actor-Mixer Hierarchy\\Default Work Unit\\Sound1",
			"notes": "Hello!",
			"@Volume": 15.7,
			"@LowPass": 25
		}
	],
	"onNameConflict": "merge"
} 

// 之后:紧凑 Json(带转义字符)
{\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} 

以上两种方案都是可以的。前者更具可读性,但要处理原始或字面字符串,可能会比较麻烦。后者几乎不可读,但可以直接使用。有利有弊,自己掂量。

创建新的 Event

现在,我们来看看如何以编程方式创建新的 Event。这样操作起来可能会有点复杂。因为我们的 args 对象会包含对象数组,而这些对象算是 Event 的父对象(如 Work Unit)。每个父对象都包含一系列 Event 对象,Event 对象又包含一系列 Event Action。所以,随着数组的层层嵌套,操作起来会变得越来越复杂。

下面举例展示了如何做到这一点。这里只是为了展示一下,所以代码写得比较简单。虽然不好用到实际项目中,但拿来当参考还是可以的。在此,我们只在一个 Work Unit 中创建一个 Event。该 Event 将包含两个 Action。当然,在实际应用中要能根据需要创建任意数量的上述对象。

首先,我们声明 args 对象和 Event 的父对象(本例中为 Default Work Unit)。

TSharedRef<FJsonObject> args = MakeShared<FJsonObject>();

// Event Parent
TSharedRef<FJsonObject> eventParent = MakeShared<FJsonObject>();
eventParent->SetStringField("object", "\\Events\\Default Work Unit");

接下来,我们要构建 Event。为此,我们得设法选择要向其添加哪些 Action。最终,我创建了包含 Event Action 数据的结构:

struct EventActionData
{
	int ActionType = 1;
	float Delay = 0.0f;
	int Scope = 0;
	FString Target = "";
};

现在,我们要通过函数来传递一系列 Action 和其他设置并以此构建 Event。为此,我编写了以下代码:

TSharedPtr<FJsonValue> UMyActorComponent::BuildEventJson(FString EventName, TArray<EventActionData> Actions, FString Target)
{
	// Event 对象
	TSharedRef<FJsonObject> event = MakeShared<FJsonObject>();
	event->SetStringField("type", "Event");
	event->SetStringField("name", EventName);

	TArray<TSharedPtr<FJsonValue>> ActionsArray;
	int number = 0;

	// Action
	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));
}

下面我们来看看以上代码要做什么。如您所见,我们传递了 Event 名称、要执行的一系列 Action 以及 Target(要执行播放、停止等操作的音频对象)。本例为了简化对所有 Action 使用了相同的 Target,不过理论上要使用通过 EventActionData 对象传递的 Target。

首先,我们创建了 Event 对象,并设置了类型和名称。然后,我们遍历了 Action 数组,并为每个 Action 创建了包含各种必要数据的对象。在这里,我只用了 Action 的部分属性。事实上还有更多,只是这里没用到。

虽然文档的示例中使用的是空字符串,但我觉得有必要给每个 Action 命个名。在我看来,这些名称其实并没有什么实际用处,也不会显示在设计工具的任何地方,所以我就将就着给它们取了个名称。

在设置好所有数据之后,我们把对象转成值对象,然后将其添加到数组中。最后,设置 Event 对象并将其返回。

很好。下面我们来使用上述函数。首先,我们设置一些 Action 数据。如您所见,Action 的类型和作用域都是 int 值。这样不怎么好。我没在这里修改,但如果可能的话,我会改用枚举值。

总之,我们创建 Action 并将其添加到数组中,然后就可以调用我们的函数,进而输出随时可用的 Event 对象。

// 创建 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);

现在,我们可以构建其余的对象了。首先,我们构建 Event 数组。正如我之前所说,代码写得比较简单,我只用了一个 Event。然后,我们将 Event 数组设为 Event 的父对象,并根据需要转换为值对象。

现在,我们终于可以设置 args 对象的其余字段了。在这最好使用 merge 和 append,来确保以累加的方式创建 Event。若要临时更改 Target 或 Action 的其他设置,也可以由 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-Event

导入音频

下面我们再来看看如何将新的音频导入到 Wwise 中。这当中的实现方式跟前面的例子差不多。只不过之前只导入了一个文件,现在需要创建大量对象和数组。所以,有时候可能会搞混。

同样,我采用了显式的代码,不过各位可别这么做!理想情况下,我们会分别用不同的方法创建声音和音频源。或许,还会用另一方法定义音频文件以及辅助结构和枚举值。不管怎样,要想了解构建大型 JSON 对象的基本方法,我觉得下面的代码还是有一定参考价值的:

void UMyActorComponent::WaapiImportAudio()
{
	TSharedRef<FJsonObject> args = MakeShared<FJsonObject>();

	// 父对象
	TSharedRef<FJsonObject> soundParent = MakeShared<FJsonObject>();
	soundParent->SetStringField("object", "\\Actor-Mixer Hierarchy\\Default Work Unit");

	// Sound 对象
	TSharedPtr<FJsonObject> soundOne = MakeShared<FJsonObject>();
	soundOne->SetStringField("type", "Sound");
	soundOne->SetStringField("name", "NewSoundTest");

	// AudioFileSource 对象
	TSharedPtr<FJsonObject> sourceOne = MakeShared<FJsonObject>();
	sourceOne->SetStringField("type", "AudioFileSource");
	sourceOne->SetStringField("name", "mySourceOne");

	// 要导入的文件
	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));

	// 创建 Files 数组
	TArray<TSharedPtr<FJsonValue>> filesArray;
	filesArray.Add(fileOneValueObject);
	files->SetArrayField("files", filesArray);

	sourceOne->SetObjectField("import", files);
	TSharedRef<FJsonValueObject> sourceValueObject = MakeShareable(new FJsonValueObject(sourceOne));

	// 创建 Sources 数组
	TArray<TSharedPtr<FJsonValue>> sourcesArray;	
	sourcesArray.Add(sourceValueObject);
	soundOne->SetArrayField("children", sourcesArray);
	TSharedRef<FJsonValueObject> soundValueObject = MakeShareable(new FJsonValueObject(soundOne));

	// 创建 Sounds 数组
	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 对象而非数组。然后,这个 files 对象包含囊括了所有待导入文件的数组。

另外,还可决定要将文件复制到 originals 下的哪个文件夹。若是普通声音,路径将是相对于 Originals\SFX 的位置。若路径中包含尚不存在的文件夹,则会自动创建对应的文件夹。

最终,上面的代码在我这儿跑起来了,新声音会马上显示在 Wwise 中,但是播放的时候没反应,系统提示没有找到媒体。如果重新启动 Wwise,又什么问题都没有了,能正常播放新的音频。看来应该是哪里有漏洞。

订阅主题

正如之前所说,我们可以通过两种方式来使用 WAMP 与 Wwise 设计工具进行通信:

  • 远程过程调用 (RPC):藉此,可向 Wwise 询问一些具体信息并获得答复。 
  • 订阅/发布 (Pub/Sub):藉此,可订阅特定的主题。一旦发生目标事件,我们就会收到通知。就跟观察者模式一样。

到目前为止,我们一直都在用 RPC (Remote Procedure Call) 从 Wwise 获取信息。这样方便在特定时间点获取某些数据,同时让 Wwise 告诉我们何时发生变化。对此,我们可以通过另一 WAMP 协议 Pub/Sub 来实现。

文档中,我们可以看到所有允许订阅的主题。同样,刚开始可能要花点工夫熟悉相关的语法。但是,文档中并没有包含相应的示例。在仔细研究 Wwise 插件类之后,我找到了一些实现方法,并从中获得了些许灵感。

之前,我们使用了 Call 函数来执行 RPC。现在,我们要使用另一函数:Subscribe。基本思路是,首先订阅某个事件并传递一个函数,然后在 Wwise 表示事件发生时执行函数。

WampEventCallback

下面我们来看看 FAkWaapiClient::Subscribe。该函数接收以下参数:

  • uri:要订阅的特定主题。
  • options:该附加信息用于修改函数要返回哪些数据。
  • callback:在主题发生时要调用的函数。
  • subscriptionID:用于区分不同订阅的 int 值。
  • result:关于所订阅主题的一些额外信息。

很好。我们来看看如何使用以上参数。在此,我以 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 不熟悉,它的语法看着会很怪。倘若如此,建议先了解一下。这样操作起来会顺畅一些。

如您所见,我们在单个声明中创建了回调变量和函数(在 State 发生变化时执行相应操作)。在这种情况下,只需抓取 State 和 State Group 名称,然后将其输出到 Unreal Editor 上。注意,我们同时将订阅 ID 指派给了成员变量,以便稍后利用其来取消订阅。

下面我们来创建 options 对象。在此,我们要做出相应指定以确定 State Group 当前所用的 State。然后,创建其他字段并使用 Subscribe 函数:

// options 对象
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 上运行的。不过,在此并不需要进行捕获。

State-group-Unreal

趁现在还记得,我们先来取消订阅以免函数被多次触发。在这里,我就直接在 EndPlay 时取消订阅:

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!");
    }
}

直接在 Blueprint 上使用 WAAPI

现在,我们对如何使用 C++ 管理 JSON 对象和 WAAPI 有了很好的了解。如果我说所有这些都可通过 Blueprint 来实现呢?借助为 Unreal 开发的 Wwise 插件,我们完全可以通过 WAAPI 进行调用和订阅。我还没有广泛运用这种方法,但我可以给大家举几个例子,让各位对此有个简单的了解。如果是从头读到这的,接下来就比较简单了。

以下示例展示了如何获取连接状态:

Using-WAAPI-directly-Blueprints

如您所见,我们可以通过这里的节点调用 WAAPI。不过,我们需要给它提供参数和 URI 来作为输入。为此,我们可以创建适当类型的变量(如下所示)。

URI-WAAPI

在本例中,args 和 options 参数都是空的。注意,对于 URI 和字段名称,我们可以单击默认值旁边的这个 Help 按钮来显示一系列可选用的值:

Unreal-Choose-URI

在执行调用之后,我们只需抓取名为 IsConnected 的布尔字段即可!

下面我们再来看个例子。在此,我们要 Solo 特定的对象。这样非常方便 Solo 某个发声体播放的音频:无需转到 Wwise 操作,在 Unreal 中就可完成。

在本例中,我们要构建 args 对象。为此,我们先设置一个字符串字段数组(其中包含要 Solo 的 Wwise 对象)。在本例中,我只 Solo 一个特定的声音 (Object to Solo)。注意,这个字符串数组设在 args 对象上。另外,因为要执行 Solo 操作,所以我们将布尔字段设为了 true。最后,执行调用。轻松搞定!

Unreal_Blueprint_Call_WAAPI

由此可见,只用 Blueprint 就能做很多事情。当然,C++ 功能更强大也更灵活。但对于一款便捷工具,Blueprint 已经足够了。

创建性能分析器控制器 

在结束之前,我想再展示两个更贴近现实的用例。下面我们来创建一个简单的辅助函数,以直接通过 Unreal 启动性能分析器捕获会话。

实现方法有很多,取决于具体要求。对于本例,我在示例类中定义了一个布尔成员变量。我们可以通过函数将其设为 true。该函数则可通过 Unreal 控制台、键盘快捷键、游戏世界中的特定几何体等来调用。总之,实现方法很多!

void UMyActorComponent::WaapiStartCapture()
{
    m_activeWwiseCapture = true;
}

在将变量设为 true 后,我们会在每个时钟周期检查连接状态:

{
    // 更新与 Wwise 的连接状态
    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。我们可以通过它来了解实际的连接状态。我每帧都会更新这个变量,因为它只用于 Debug 版本,所以我并不在意 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 的执行,直到建立连接。这通常需要一两秒钟的时间。同样,我对此并不在意。因为这充其量就是个临时开发工具,不会以任何方式出现在最终游戏中。

在建立连接之后,就可以开始捕捉了:

{
    // 开始捕获
    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 Editor 上显示一些额外的信息。比如,我们可以显示正在播放的声部数量及其各自的数据。每个时钟周期都会调用一次,来确保及时更新相应的信息。

// 获取声部
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 作为参数来传递。也就是说,我们使用的是当前的捕获时间。另外,我们还定义了 options 对象,以使其包含所要使用的全部信息。

这样可以输出一个对象数组,其中每个对象代表一个声部。我们会统计共有多少个声部,并提取想要显示的所有数据。在本例中,我要为每个声部构建一个要输出的字符串。

注意,要获取声部音量,需调用 getVoiceContributions。为此,需要获取声部管线 ID。通过该调用,我们可以获取最终声部音量 (dB),以便显示在 Voice Inspector 顶部。

在 Unreal 中看起来是这样的,并且每帧都会对数值进行更新:

Two-voices-Unreal

我觉得最好还能显示总计 CPU 用量,所以编写了以下代码:

{
    // 输出总计 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 值求和即可。

结果显示的 CPU 用量跟 Advanced Profiler 相同,好处是可在 Unreal Editor 中快速调取相应数值。不过有个问题:如果每帧都获取该数值,会跳得太快而应接不暇。所以,我们取的都是一段时间之内的平均值。

下图显示了 Debug 版本的 CPU 用量:

Total-CPU-usage-Wwise-Unreal

不管怎样,上面只是一个例子。在实际项目中,肯定会想显示更多信息。

最后,我创建了以下函数来在 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 层级结构

最后,我用自己学到的东西构建了一个规模更大、看起来更接近真实项目的素材结构。在详细说明之前,我们先简单看一下:


我们有一款游戏,其中的敌人是以程序化的方式生成的。我们希望在敌人每次做出动作时播放音频,但该音频需要因类型和稀有度而有所不同。其中一种解决办法是让 Event 事件播放一个大的 Switch Container 结构。同时,我们依靠不同的 Switch 值来获得正确的声音。

我的想法是让代码从 Unreal 读取敌人数据并在 Wwise 中自动创建这种结构。这些数据可以存储在任何地方,甚至可以存储在 Unreal 之外(如电子表格或 CSV 文件中)。不管怎样,因为这是一个专注于 Unreal 的教程,所以我选择了 Unreal 数据素材文件。

我本来打算全部都讲一遍,但是代码写的实在太长了,所以在此只概括地说一下。若想查看整段代码,请点击此处。我在代码中包含了头文件和 cpp。强烈建议大家也这么做。

这次写的代码更加系统化、模块化。而且,更接近在实际项目中的样子。在创建不同类型的对象时,会执行很多重复的操作。为此,我编写了一些通用的辅助函数。理想情况下,这些函数会放到自定义 WAAPI 库静态类中。但是在我的示例中,全都放在了同一个类中。

注意,我几乎没怎么做错误或 null 检查,所以代码肯定没法用于实际制作。而且,一旦用户开始到处捣鼓,肯定会出现错误或漏洞。不管怎样,这都是个很好的学习机会。所以,我们还是来看看吧。

敌人数据放在从 UDataAsset 继承的类中。这个类包含用来描述敌人的所有序列化数据以及关于将此信息发给 Wwise 的整套逻辑。藉此,我们可以通过该类创建各种素材(或许可每个游戏关卡创建一种素材)。

Assets-per-level

正如之前所说,我们的目标是使用所有这些数据创建一个 Switch Container 层级结构。为此,我们要求用户指定希望在 Wwise 中的哪个地方创建必要的 Game Sync 和容器。另外,用户还可指定包含待导入音频文件的文件夹,并使用原始素材文件夹来保持结构整洁有序。具体可以查看上面的各项数据。

Enemy-data-Wwise

除此之外,数据文件还要包含敌人的各项信息。这些信息一般由游戏设计师填写。比如,有多少元素类型、有多少稀有等级以及有多少动作类型。系统会做出一些假设。比如,每种类型都要有一个数组成员。

如您所见,对于每个元素,都可选择是否要 Update Wwise Structure。也就是说,我们的 Switch Container 结构将包含这个元素。换句话说,您可以通过勾选此方框来确保将该元素出现在 Wwise 中。

Wwise-Unreal-optional-properties

除此之外,还可向每个对象(Switch Container 或 SoundSFX 对象)应用可选属性。为简单起见,我只添加了对基于数值的属性的支持。不过,对基于字符串和布尔值的属性基本上也是一样的。但是,添加对引用或列表的支持会稍微复杂一些(比如添加 Effect ShareSet 或 RTPC)。

上述所有数据都会被代码考虑在内。我们只需点击一下按钮,Wwise 就会马上填充所有必要对象。

代码层面,我们使用了自定义枚举值和结构来以更清晰的方式显示信息。为了实现上述功能,我构建了相应函数以便执行以下操作:

  • 创建 "Switch Group" Game Sync 及其子对象。
  • 创建 Switch Container。
  • 将 Switch 指派给 Switch Container。
  • 在对象(如 Switch Container 或 Random Container)上设置数值属性(如 Volume 或 Pitch)。
    • 这些属性可以覆盖或累加。
  • 从任意对象获取数值属性值。
  • 创建 "Game Parameter" Game Sync (RTPC)。
  • 在某个对象上设置 RTPC(包括添加 RTPC 曲线)。
  • 创建 SoundSFX 对象。
  • 按照音频文件的命名规范将音频导入到正确的位置。

这无疑是我用 WAAPI 构建的最复杂的素材结构。我费了好大一番功夫才让一切正常运行。建议大家一定要用辅助函数,以免一遍又一遍地构建参数。要是我的话,估计会将其移到静态 WAAPI 辅助类。

虽然上述系统能正常运行,但我还是要做出一些取舍。下面我们来看看它都有哪些优点和缺点:

优点:

  • 第一次构建结构很快也很简单。不用创建十好几个容器。无需复制粘贴、重命名容器…
  • 可以轻松地一次性修改数值 Wwise 属性。比如,把所有死亡声音调到 -2 dB。这在 HDR 混音时会很有用。
  • 如果添加新的敌人类型、稀有度或动作,系统会在正确的位置一键创建所有容器。
  • 在采用正确的命名规范的情况下,若将全部音频放在同一文件夹中,系统会自动导入所有的音频素材。
  • 在导入音频时,可选择使用哪个原始子文件夹。
  • 如果对音频进行第二次处理并替换文件夹中的文件,只需单击一下就可替换所有文件并移到正确的位置。这样可以节省很多时间。当然,还可添加到版本控制系统,不过在这我并没有这样做。
  • 如果操作失误,可在 Wwise 中撤消整个过程。
  • Volume、Lowpass、Pitch 等属性的变化可以累加或覆盖。
  • 在应用更改后,默认会将属性清空,因为谁都不想反复地应用这些更改。 
  • 在将 RTPC 添加到最上面的主 Switch Container 时,会检查该 RTPC 是否已经存在,以免叠加在一起或覆盖现有 RTPC。

缺点:

  • 在 Wwise 中乱命名容器的话会出问题,因为我们都是靠名称来确定操作对象。或许,依靠 ID 可以让系统变得更加稳健?
  • 虽然确实可在特定容器中设置属性,但是没有办法做到特别具体。比方说,只修改冰系敌人的 Death 动作。不过,稍后可以加进来。
  • 不支持在同一音频源内保留不同的版本。当然,真要做的话,还是能做到的。
  • 如果设计师移除敌人类型、稀有度或动作,系统不会将其从 Wwise 中删除。我本来以为这样会更安全,但这也意味着数据文件跟 Wwise 工程可能会不同步。具体就看怎么权衡了。
  • 系统假定 Switch Container 的结构就是 Type->Rarity->Action。要更改代码才能添加 Age 之类的新特征,虽然操作起来不是很难,但是也挺费事。对改变特征的顺序来说也是一样。比如,首先筛选 Rarity 而非 Type。我们可以构建一个允许自由创建和移动特征的灵活系统,只不过会比较复杂。
  • RTPC 函数创建的曲线很简单,而且用户没有办法进行定义。如果有必要的话,可通过 Unreal 浮点曲线来实现。
  • 无法通过数据素材将 RTPC 添加到其他子级 Switch Container 或对象。我已经在构建这个功能了,但是现在加的功能太多了,所以我要把有些功能砍掉。

总的来说,我觉得这是个很好的例子。各位可藉此了解如何通过 WAAPI 构建各种东西,并利用 Unreal 数据来填充 Wwise 中的层级结构。

链接和资源

WAAPI 远程调用函数
WAAPI 订阅主题
WAAPI 示例
导入音频和创建结构

查询 Wwise
WAQL 简介
WAQL 参考

Wwise 对象参考

AK 博客 - 隆重推出 WAAPI
AK 博客 - 手把手教你用 WAAPI
AK 博客 - 简化 WAAPI
AK 博客 - 人人都能用 WAAPI
AK 博客 - WAQL 简介
AK 博客 - 关于如何在团队工作环境中使用 WAAPI 和 Python

Wwise Up On Air - WAAPI (2019)
Wwise Up On Air - WAAPI (2022)
Wwise Up On Air - WAAPI (2024)

通过 C++ 处理字符串字面量
JSON Formatter

Javier Zumer

高级技术音频设计师

Supermassive Games

Javier Zumer

高级技术音频设计师

Supermassive Games

哈维尔•祖默 (Javier Zumer) 目前在 Supermassive Games (UK) 担任高级技术音频设计师。早前他曾从事线性媒体的声音设计和混音工作,不过一直以来对游戏音频的开发都比较感兴趣,并在多款独立游戏的开发中积累了一定的经验。在转行专职从事游戏开发后,出于自己在技术方面的偏好,他逐渐向技术音频设计倾斜。现在,他主要负责诸多音频系统的维护和扩展,同时也会为这些系统创建一些音频素材。

 @JavierZumer

评论

留下回复

您的电子邮件地址将不会被公布。

更多文章

《ABZÛ》– 在游戏中为成千上万条鱼设计音频所面临的挑战

看到这幅画面,你首先会想到什么?

30.6.2020 - 作者:史蒂夫·格林 (Steve Green)

游戏声音工作原理与优化的经验分享

前言 我是一名在心动网络从事音频相关工作的技术人员,有的人称我们为音频程序员,有的人称我们为技术音频,也有的称我们为TA(Tech...

2.12.2020 - 作者:吴明辉

为 Wwise 2021.1 构建插件 | 第 1 部分:背景和目标

大家可能不知道,Wwise 生态系统其实具有很强的可扩展性。有时,各公司要为其项目构建定制的插件,供应商会将自研插件迁移到 Wwise。对此,我们必然要提供相应的支持。新的 Wwise...

27.9.2021 - 作者:米歇尔•多奈斯 (Michel Donais)

Jurassic World Evolution 2

Frontier Developments 由大卫•布拉本 (David Braben) 于 1994...

8.6.2023 - 作者:邓肯•麦金农 (Duncan Mackinnon)

《遍体鳞伤(Scars Above)》中的音频优化实践

简介 在本文中,我会试着阐释团队在对《Scars...

14.10.2024 - 作者:Milan Antić

浅谈最近在 Heavy 中对 Wwise 支持所做的更新

目录 简介 安装和使用 新增功能概要 添加了对更多声道配置的支持 随意发送 Event 并设置 RTPC 打包插件以便通过 Audiokinetic Launcher 安装 结语 ...

5.12.2024 - 作者:尤金•乔尔内 (Eugene Cherny)

更多文章

《ABZÛ》– 在游戏中为成千上万条鱼设计音频所面临的挑战

看到这幅画面,你首先会想到什么?

游戏声音工作原理与优化的经验分享

前言 我是一名在心动网络从事音频相关工作的技术人员,有的人称我们为音频程序员,有的人称我们为技术音频,也有的称我们为TA(Tech...

为 Wwise 2021.1 构建插件 | 第 1 部分:背景和目标

大家可能不知道,Wwise 生态系统其实具有很强的可扩展性。有时,各公司要为其项目构建定制的插件,供应商会将自研插件迁移到 Wwise。对此,我们必然要提供相应的支持。新的 Wwise...