UE5와 C++를 활용한 WAAPI 입문

오디오 프로그래밍 / 게임 오디오

이 글에는 Unreal에서 WAAPI를 사용하는 데 필요한 모든 정보가 담겨 있습니다. 이 글의 대부분은 Audiokinetic 문서, Wwise 업 온 에어 영상, AK 블로그에 이미 소개된 정보를 바탕으로 구성되어 있습니다. 그럼에도 불구하고 Unreal에 특화된 구체적인 예제를 포함해서 관련된 모든 정보를 한곳에 모아 제공하는 것이 여전히 유익할 것이라고 생각했습니다.

이 여정을 시작했을 때는 대부분의 작업을 어떻게 해야 할지 감이 잘 안 잡혔습니다. 일부 정보들은 불분명하고 때로는 난해하게 느껴지기도 했습니다. 그래서 억지로 계속 시도해 보고 직접 만들어도 보았더니, 이미 필요한 모든 요소들은 다 있는데 제가 그것을 제대로 연결하지 못하고 있었다는 걸 깨닫게 됐습니다.

물론 제가 C++와 JSON에 능숙했다면 훨씬 수월했겠지만, 저처럼 그렇지 않은 분들께는 이 글은 도움이 될 수 있을 거라고 생각합니다. 이 글을 잘 따라오려면, 이상적으로는 Wwise에 대한 탄탄한 지식과 C++에 대한 어느 정도의 숙련도가 필요합니다.

목차

Wwise API와 WAAPI의 차이점
WAAPI 활성화하기
WAMP란 무엇인가요?
Unreal에서 WAAPI 사용하기
WAAPI 원격 프로시저 호출
JSON 이해하기
JSON 오브젝트 구성하기
필요한 Include와 Module 추가하기
인자 구성 및 결과 데이터 추출
WAAPI 호출 캡슐화하기
옵션 필드 사용하기
SoundEngine의 이중성
WAAPI에서 WAQL 사용하기
오브젝트에 데이터 설정 및 오디오 가져오기
이름 충돌, 리스트 및 리스트 모드
오브젝트 속성 수정하기
새로운 오브젝트 생성하기
원시 JSON 문자열 파싱하기
새로운 Event 생성하기
오디오 가져오기
토픽 구독하기
Blueprint에서 WAAPI를 직접 사용하기
Profiler 컨트롤러 생성하기
Unreal 데이터 애셋 기반 Wwise 계층 구조 구성하기

Wwise API와 WAAPI의 차이점

우선 WAAPI란 무엇일까요? Wwise API와 동일한 것일까요?

이 부분은 한동안 저를 혼란스럽게 했는데, 한편으로는 제 경험 부족 때문이었고, 또 한편으로는 WAAPI가 일반적인 API가 할 수 있는 작업들을 대부분 수행할 수 있지만 그 반대는 그렇지 않았기 때문이었습니다.

Wwise API는 게임에서 Wwise 오디오 엔진으로 정보를 전달할 때 사용하는 모든 함수와 클래스들을 포함하고 있습니다. 여기에는 이벤트를 게시하거나 RTPC를 설정하는 등의 친숙한 작업들이 포함됩니다.

이 API에는 문서 웹사이트의 Wwise SDK 섹션에서 볼 수 있는 대부분의 정보가 포함되어 있습니다. 우리는 Unreal을 사용하고 있기 때문에, 게임 엔진 구현에서 사용하는 전용 클래스들도 여기에 포함할 수 있습니다.

반면에 WAAPI (Wwise 저작 API)는 게임 내에서 실행되는 Wwise 오디오 엔진이 아니라, Wwise 저작 도구와 통신할 수 있도록 설계된 API입니다. 즉, 프로젝트에 사용할 사운드와 게임 싱크를 준비할 때 사용하는 소프트웨어를 의미합니다.

WAAPI는 다양한 프로그래밍 언어를 지원하며, 일반적으로는 JSON을 통해 정보를 주고받습니다. 이전까지 JSON을 거의 다뤄본 적이 없었고 Unreal에서의 활용 경험은 더 없었던 저에게는 이것이 또 하나의 진입 장벽이었습니다.

WAAPI 활성화하기

WAAPI를 사용하려면 Wwise 사용자 환경 설정에서 해당 기능을 활성화해야 합니다. 여기에는 HTTP 포트와 WAMP가 있습니다. 이 중 WAMP 포트는 더 많은 기능을 제공하며, 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 값을 요청할 수도 있습니다. 놀랍죠?
  • 구독/발행 (Subscribe/Publish, Pub/Sub): 특정 토픽을 구독하여 해당 이벤트가 발생했을 때 콜백을 통해 알림을 받는 방식입니다. 관찰자 패턴(observer pattern)과 유사한 방식입니다.

WAAPI 원격 프로시저 호출 

원격 프로시저 호출(RPC) 방식부터 살펴보겠습니다. 먼저 여기를 잠깐 살펴보세요. 사용 가능한 모든 함수 목록을 확인할 수 있습니다. 

FAkWaapiClient::Call을 사용해 Wwise에 원하는 정보를 요청합니다. 저는 어떤 클래스와 함수를 사용해야 하는지 알아내는 데 시간이 꽤 걸렸습니다. 바보 같다고 하셔도 어쩔 수 없지만, 문서에 이에 대한 명확한 설명이 없었기 때문입니다. 한번 알고 나니, 영상이나 코드 예제, Wwise 플러그인 내부 클래스 등에서 이 함수가 자주 눈에 띄기 시작했습니다.

아무튼, 우리가 필요한 함수는 바로 이것입니다. 이론적으로는 FAkWaapiClient를 거치지 않고 엔진에 직접 호출할 수도 있지만, 굳이 복잡하게 만들 필요는 없습니다.

FAkWaapiClient::Call에는 두 가지 함수 오버로드가 있습니다. 하나는 FStrings을 인자로 받고, 다른 하나는 Unreal 네이티브 JSON 구현에 해당하는 FJsonObject를 인자로 받습니다. 전달하는 인자가 단순한 경우에는 후자의 방식이 대부분의 상황에서 더 편리합니다. 문자열을 직접 전달하는 방식은 뒤에서 더 다루겠습니다.

img2

이 함수는 다음의 매개 변수를 받으며 성공하면 true를 반환합니다. 여기서는 선택 매개 변수에 대해서는 다루지 않겠습니다. 하지만 별다른 설명이 없어도 충분히 이해할 수 있을 것입니다.

  • URI: 호출할 특정 함수를 나타냅니다.
    • FAkWaapiClient에서는 형식이 다음과 같아야 합니다: ak::wwise::core::profiler::getCursorTime.
  • Arguments: 함수에 전달해야 하는 데이터입니다.
  • Options: 함수가 반환할 데이터를 조정하기 위한 추가 정보입니다.
  • Return: 함수 실행 결과로 반환되는 정보입니다. 다양한 타입의 데이터가 포함될 수 있습니다.

인자(arguments)와 옵션(options) 매개 변수는 TSharedRef<FJsonObject> 타입인 반면, 반환값은 TSharedPtr<JsonObject>타입이라는 점에 주목하세요. 저는 이 부분을 제대로 확인하지 않아 한동안 혼동했고, 그로 인해 오류가 계속 발생했었습니다. 저처럼 되지 마세요!

이 타입들은 Unreal의 스마트 포인터 라이브러리에서 제공되며 자세한 내용은 여기에서 확인할 수 있습니다. 여기서 요점만 언급하면: TSharedRef와 TSharedPtr의 주요 차이점은 TSharedRef는 null이 아닌 오브젝트를 반드시 가리켜야 한다는 점입니다. 이 오브젝트를 Call 함수에 상수(const) 참조로 전달하기 위해 직접 생성하기 때문에 생기는 자연스런 제약입니다. 반면에 TSharedPtr는 null을 가리킬 수 있습니다. 호출 결과가 항상 데이터를 포함하는 것은 아니며 이 포인터는 일반 참조로 전달되기 때문입니다.

코드 예제에서 또 주목할 점은, 어떤 경우에는 JSON 오브젝트를 MakeShared()로 만들고 다른 경우에는 MakeShareable()로 만든다는 것입니다. 전자는 더 가볍지만 오브젝트에 public 생성자가 있어야 하고, 후자는 무겁긴 하지만 private 생성자 또는 커스터마이징된 동작을 지원합니다. 우리 목적에는 어느 쪽을 사용해도 무방합니다. 사실 저도 Wwise 기본 클래스 예제나 이전에 작성한 코드를 복사해 붙여넣다 보니 두 방식을 거의 구분 없이 사용해왔습니다.

JSON 이해하기

JSON는 서로 다른 시스템 간에 '사람이 읽기 쉬운' 데이터를 주고받기 위한 개방형 표준 형식입니다. WAAPI에서 JSON을 사용하는 것은 언어에 독립적이어서 다양한 소프트웨어 환경에서 활용할 수 있어 합리적인 선택입니다.

JSON 오브젝트 안에는 무엇이 들어 있을까요? 데이터를 키-값(key-value) 쌍으로 저장한다는 점에서 JSON 오브젝트는 C#의 딕셔너리(dictionary) 또는 C++의 맵(map)과 비슷하다고 볼 수 있습니다.

'키'는 항상 문자열이며, '값'은 기본형 변수, 오브젝트 또는 이러한 값들을 포함하는 배열이 될 수 있습니다. 아래는 두 개의 키-값 쌍을 포함한 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 함수를 사용하여 오브젝트를 코드로 생성하는 것입니다.

이 글을 쓰는 주된 이유 중 하나는 이러한 오브젝트를 어떻게 구성하는지에 대한 실용적인 예시를 제공하기 위함입니다. Unreal, C++, WAAPI를 함께 사용하는 실제 사례를 찾는 것은 쉽지 않으므로 JSON 오브젝트를 다뤄본 경험이 많지 않다면 처음에는 구문이나 작업 과정이 다소 까다롭게 느껴질 수 있습니다.

필요한 Include와 Module 추가하기

본격적으로 시작하기에 앞서 Unreal 프로젝트에서 Wwise와 JSON을 사용할 수 있도록 설정해야 합니다. cpp 파일 상단에 아래와 같은 코드를 추가해야 합니다: 

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

위 Include에 이미 JSON이 포함되어 있으므로 JSON 관련 include를 별도로 추가할 필요는 없습니다.

Json, JsonUtilities, AkAudio 모듈을 반드시 프로젝트 빌드 파일에 추가해야 합니다. 이 파일의 이름은 보통 UnrealProjectName.Build.cs와 같은 형식을 따릅니다.

참고로, 이 글에 사용된 모든 예제는 Wwise 2023.1과 Unreal 5.3을 기반으로 작성되었습니다.

인자 구성 및 결과 데이터 추출

첫번째 예제로 getCursorTime 함수를 사용해 보겠습니다. 문서에는 다음과 같이 설명되어 있습니다:

Wwise getCursorTime function

보시다시피, 이 함수는 인자를 하나 받는데 Profiler에서 사용자가 클릭한 위치인 사용자 커서를 사용할지, 또는 현재 캡처가 진행 중인 위치인 캡처 시간 커서를 사용할지를 결정합니다. 별표(*)는 해당 인자가 항상 필수임을 나타냅니다.

결과적으로 반환되는 타임코드 값을 정수 형태로 얻게 됩니다. 모든 것이 문제없이 작동한다면 바로 이런 결과가 나올 겁니다!

그럼 이제 JSON 오브젝트를 만들어봅시다. 인자는 다음과 같이 선언할 수 있습니다:

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

함수가 TSharedRef 타입을 요구하기 때문에 그대로 사용하고 있는 것을 볼 수 있습니다. 그런 다음 'MakeShared' 함수를 사용하여 오브젝트를 생성합니다.

이제 JSON 오브젝트를 생성했으니, 이를 수정하여 함수에 적절한 정보를 전달해야 합니다. 여기서 'cursor' 타입의 특정 문자열 필드를 전달해야 하며, 사용자 커서가 아닌 캡처 시간을 찾고자 하기 때문에 'capture'를 선택해야 한다는 점을 확인할 수 있습니다.

이를 위해 FJsonObject에 기본적으로 제공되는 몇 가지 유용한 함수들을 다음과 같이 사용할 수 있습니다:

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

저는 위의 두 줄 코드를 이해하는 데 시간이 좀 걸렸습니다. 다시 말하지만, 처음에는 참고할 만한 예제를 찾기 어려웠어요. GitHub에서 검색을 해보면서 영감을 얻을 수 있었고 WAAPI 호출을 수행하는 Wwise 클래스들도 찾을 수 있었습니다. 하지만 이 역시 무엇을 찾아야 할지 알고 나서야 비로소 찾을 수 있었습니다.

이제 옵션(option)과 결과(result)를 위한 오브젝트들도 생성해봅시다. 여기서는 어떤 필드도 수정할 필요가 없으며, 'result'는 다른 타입의 포인터를 사용하고 있다는 점도 확인할 수 있습니다.

TSharedRef<FJsonObject> options = MakeShared<FJsonObject>();
TSharedPtr<FJsonObject> result = MakeShared<FJsonObject>();

마지막으로, FAkWaapiClient는 싱글톤(singleton)이므로 인스턴스를 가져와서 함수를 호출할 수 있습니다. 

FAkWaapiClient::Get()->Call(ak::wwise::core::profiler::getCursorTime, args, options, result);

이 함수는 성공 시 bool 값을 반환하므로, 이를 통해 작업이 예상대로 작동하는지 확인할 수 있습니다.

성공했다면, 결과 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");
	}
}

성공했습니다! 결과 JSON 오브젝트에 반드시 담겨있을 정수 값을 가져오기 위해 get 함수를 사용한 것을 확인할 수 있습니다.

여기서 얻은 정수 값은 밀리초 단위의 시간입니다. 이제 이 값을 타임코드나 필요한 형식으로 변환한 후, Unreal에서 화면에 출력하는 등 유용하게 활용할 수 있습니다. 

WAAPI 호출 캡슐화하기 

이번에는 WAAPI 호출을 다른 클래스나 블루프린트에서 호출할 수 있는 함수로 캡슐화하는 예제를 살펴보겠습니다.

새로운 Actor Component 클래스를 만들고 다음과 같은 함수를 작성했습니다:

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인 bool 값을 반환합니다.

결과를 따로 파싱할 필요가 없기 때문에 구문과 로직이 이전보다 훨씬 간단해졌습니다.

블루프린트 코드는 이렇습니다:

블루프린트에서 WAAPI 호출 캡슐화하기

그리고 이 메시지는 Wwise 캡처 로그(Profiler)에서 확인할 수 있습니다:

이 메시지는 오류(error)로 표시되며, 이 함수는 다른 로그 수준을 선택하는 기능은 아직 지원하지 않습니다.

로그의 심각도(severity) 수준을 선택하고 싶다면 ak.wwise.core.log.addItem을 사용할 수 있습니다. 다만, 이 함수는 사운드 디자이너가 가장 자주 확인하는 위치인 Profiler 뷰에는 로그를 표시할 수 없는 것으로 보입니다.

옵션 필드 사용하기

이제 더 복잡한 인자를 요구하는 다른 함수를 사용해 보겠습니다. 예를 들어, Wwise에서 특정 State Group에 대해 현재 어떤 State가 사용 중인지 알고 싶어 'ak.soundengine.getState' 함수를 사용해보겠습니다.

먼저 문서를 살펴보겠습니다:

Using-the-options-field-WAAPI

보시다시피, 인자는 여러 방식으로 전달할 수 있지만, 결국 어떤 식으로든 특정 State Group을 가리키는 문자열입니다. 또한 사용 가능한 몇 가지 옵션도 있습니다. 결과 필드는 다음과 같습니다:

WAAPI-result-field

처음에는 이 부분이 좀 헷갈렸습니다. 옵션과 결과가 뭔가 비슷해 보이지 않나요? 이는 옵션을 사용해 Wwise에 결과에 포함할 데이터를 정확히 지정하기 때문입니다. 그럼 옵션 필드를 비워두면 어떻게 될까요? 이 경우에는 처음 두 필드, 즉 ID와 이름만 얻게됩니다. 저느 처음엔 코드를 직접 실험하면서 알아냈지만, 이후로 문서에서 다음과 같은 문구를 발견했습니다: 

“또한, 쿼리는 다음과 같은 상세 내용을 지정하는 옵션이 있습니다:
return: 오브젝트로부터 무엇을 반환할 지 지정합니다.. 특별히 지정된 게 없으면 기본 설정으로 ['id, 'name']이 됩니다.”

따라서 이전과 같이 JSON 오브젝트를 구성하면 되는데 여기서 옵션 필드는 배열 타입이라는 점을 확인할 수 있습니다. 이 때문에 오브젝트를 좀 더 복잡한 방식으로 구성해야 합니다. 한 가지 방법은 다음과 같습니다:

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'이라는 점이 다소 혼란스럽게 느껴지는데, 어쩌면 저만 그런 걸 수도 있겠네요.

반면, 인자(argument) 필드는 비교적 단순하며 이 경우에는 State Group만 지정하면 됩니다. 이를 위해 State Group의 경로(path)를 사용하겠습니다. 백슬래시 한번은 이스케이프 문자를 의미하므로 반드시 두 번 써야 한다는 점을 기억하세요.

전체 코드는 다음과 같은 형태가 됩니다:

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

물론 이 과정을 함수로 만들어 State 이름이나 경로를 인자로 받을 수도 있습니다만 이번 예제는 간단히 하드코딩했습니다. 한 가지 까다로운 점은, 이 경우 결과 JSON 오브젝트에 우리가 찾는 필드가 직접 포함되어 있지 않기 때문에 이전과 같이 GetStringField를 그대로 사용할 수 없다는 점입니다. 우리가 해야 할 일은 'return' 키에 있는 오브젝트를 값으로 가져온 다음, 그 두 번째 오브젝트에서 필요한 데이터를 추출하는 것입니다. 

이 부분은 전혀 직관적이지 않았습니다. 처음에는 결과 오브젝트 자체를 그대로 사용하거나, 오브젝트 안에 배열이 포함되어 있을 거라고 예상했지만 실제로는 그렇지 않았습니다.

어쨌든 이제 결과 정보를 문자열로 반환할 수 있게 되었으며, 프로젝트에서 화면 출력 등 원하는 방식으로 활용할 수 있습니다. 이 예제에서는 이름(name)과 노트(notes) 필드를 사용하고 있습니다..

WAAPI-returning-the-string

위 내용은 Wwise 저작 도구가 게임에 연결되어 있을 때만 정확한 결과를 반환한다는 점에 유의하세요. 연결이 끊긴 상태에서도 값을 받을 수는 있지만, 이 값은 Wwise 저작 도구 내에 있는 데이터일 뿐이며, 게임에서 전송한 마지막 값이거나 Soundcaster를 통해 수동으로 설정한 값일 수 있습니다. 이는 꽤 중요한 의미를 가지므로 지금부터 좀 더 자세히 살펴보겠습니다.

SoundEngine의 이중성

WAAPI를 사용하는 대신, Unreal(또는 게임 빌드)에서 Wwise SoundEngine을 통해 일반 Wwise API를 사용하여 특정 State값을 얻을 수 있습니다..

이를 위해서는 'UnrealProjectName.Build.cs' 파일에 'WwiseSoundEngine' 모듈이 포함되어 있어야 하며, 사운드 엔진을 참조하기 위해 다음과 같은 Include 구문을 작성해야 합니다:

#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는 게임에 연결되어 있을 때만 정확한 정보를 제공한다는 것입니다. 따라서 WAAPI는 주로 개발, 테스트, 프로파일링에 유용하게 활용됩니다.

반면, 일반적인 API 호출은 Unreal 에디터나 게임 빌드 내에서 실행 중인 SoundEngine으로부터 직접 정보를 가져옵니다. 이러한 이유로 일반 API는 항상 사용 가능하고 게임 상황을 반영하기 때문에, 게임플레이 로직을 구동하는데 활용할 수 있습니다. 다만, 얻을 수 있는 정보는 제한적입니다.

앞서 언급한 모든 내용은 'ak.soundengine'으로 시작하는 URI를 가진 WAAPI 함수에 해당된다는 점을 기억해 두세요. 이러한 함수들은 모두 일반 API에 대응되는 함수들이 존재합니다.

결론적으로, 개발 중에는 엔진/게임에서 실행되는 SoundEngine과 저작 도구에서 실행되는 SoundEngine, 이렇게 두 가지가 작동하고 있다는 점을 염두에 두는 것이 좋습니다. 우리는 양쪽 SoundEngine 모두 쿼리할 수 있습니다. State를 얻는 예제를 기반으로 정리하면 다음과 같습니다: 

  WAAPI를 통한 GetState 호출 일반 API를 통한 Getstate 호출
SoundEngine Wwise 저작 도구에서 실행 중인 인스턴스 게임 엔진 에디터 또는 게임 빌드에서 실행 중인 인스턴스
정확도 Wwise 저작 도구에 연결되어 있을 때만 정확한 정보를 제공함.
연결이 끊어진 경우, 게임이 연결 해제 전에 보냈던 마지막 데이터 또는 SoundCaster에서 사용자가 설정한 데이터를 제공함.
항상 게임 상황을 정확하게 반영함.
원할 경우 게임플레이를 제어하는 데 활용할 수 있음.
사용 가능한 데이터 Wwise 저작 도구에 접근하기 때문에 많은 데이터 확인 가능함. State의 Short ID만 확인 가능함.

 

WAAPI에서 WAQL 사용하기

WAQL은 Wwise Authoring Query Language(Wwise 저작 쿼리 언어)입니다. 간단히 말하면 WAQL은 구체적이고 모듈화된 기준을 통해 Bus, Container, Event 등의 다양한 Wwise 오브젝트를 찾을 수 있게 해줍니다. 원하는 결과를 얻기 위해 쿼리를 추가적으로 필터링할 수 있습니다.

WAQL의 강력한 기능을 WAAPI와 함께 활용하면 Wwise 내에서 매우 구체적인 오브젝트를 찾은 다음 원하는 방식으로 수정하는 것도 가능합니다. 먼저 오브젝트를 어떻게 가져오는지 살펴보겠습니다.

여기에서는 WAQL 문자열을 인자로 받을 수 있는 ak.wwise.core.object.get 함수를 사용할 것입니다. 이 함수는 from/transform 형식도 지원하지만 이 방식은 구식이라 현재는 권장하지 않는 방식입니다. 자세한 내용은 여기를 확인하세요. 인자는 다음과 같은 형식을 따릅니다:

using-ak-wwise-core-object-get  

결과는 우리가 원하는 데이터를 포함한 배열 형태로 반환됩니다. 어떤 데이터를 받을지 어떻게 정할까요? getState 함수에서 했던 것과 매우 유사하게 옵션 필드를 사용해서 지정할 수 있습니다.

result-ak-wwise-core-object-get

그럼 이제 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로 이루어진 배열이기 때문에 각 항목을 오브젝트로 형변환한 뒤 GetStringField를 호출해야 합니다.

따라서 배열을 순회하면서 필요한 정보를 수집하면 됩니다. 이 간단한 예제에서는 모든 정보를 하나의 문자열로 이어붙이고 있지만, 이를 활용해 훨씬 더 다양한 작업을 할 수 있다는 건 쉽게 짐작할 수 있을 겁니다.

그럼 몇 가지 프롬프트로 테스트해보겠습니다. 예를 들어, '$ from type Event' 를 사용하면 프로젝트에 있는 모든 Event를 가져올 수 있습니다.

Get-Wwise-Object-WAQL-Unreal

그리고 실제로 Unreal에 Event 목록이 출력됩니다.

Wwise-events-printed-Unreal

WAQL은 매우 강력하고 유연합니다. 여기 소개 자료레퍼런스 문서를 확인해보세요. 하지만 이 글에서는 WAAPI에 더 집중하고자 하기 때문에 더 자세히 다루지는 않겠습니다.

딱 하나만 더 해보죠:

WAAPI-component-Blueprint

볼륨이 음수인 Sound 오브젝트를 요청했고 예상한 대로 결과가 나오는 것을 볼 수 있습니다.

Wwise-Unreal-successful-query

여기서 중요한 점은 ak.wwise.core.object.get을 통해 매우 구체적인 WAQL 쿼리를 WAAPI에 전달할 수 있으며, 이렇게 얻은 정보를 코드나 블루프린트에서 원하는 방식으로 활용할 수 있다는 것입니다. 가능한 활용 사례로는 Wwise 설정이 예상한 대로 되어 있는지를 검증하기 위해 일종의 단위 테스트 같은 도구를 구축하는 것이 있습니다. 예를 들어, 발자국 사운드 시스템에서 Switch Container들이 올바르게 채워지고 설정되어 있는지를 확인하는 경우입니다.

또한 Unreal에서 WAAPI를 사용하여 게임 내에 있는 AkComponent들을 기반으로 정보를 열거해볼 수도 있을 것입니다.

오브젝트에 데이터 설정 및 오디오 가져오기

지금까지는 Wwise에 데이터를 요청하는 방식, 즉 Wwise의 정보를 '읽는' 작업을 했습니다. 하지만 WAAPI의 진정한 강점은 Wwise 프로젝트를 수정할 수 있다는 점입니다.

몇 번의 클릭만으로 복잡한 계층 구조의 Work Unit을 생성하고, 오디오를 체계적으로 가져오며, Unreal 데이터 파일과 같은 Wwise 외부에 존재하는 데이터를 기반으로 Bus 시스템을 구축하는 작업을 상상해보세요.

게임 내 레벨(또는 구역) 전용 Wwise Event를 사용하는 경우, 이 레벨 정보를 담고 있는 Unreal 애셋을 기반으로 Wwise Event들을 프로그래밍으로 생성할 수도 있습니다. 레벨 조건이나 설정 값을 Event에 적용할 Action으로 직접 변환할 수도 있습니다.

또한 Unreal 애셋과 Wwise 애셋을 동기화하는 작업도 수행할 수 있습니다. 예를 들어, Unreal에서 새로운 물리 머터리얼을 생성할 때마다 이에 대응하는 Switch가 Wwise에 생성되고 적절한 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: 생성 함수가 오류를 반환합니다. (기본값)
  • replace: 대상 위치에 있는 오브젝트(하위 오브젝트 포함)가 삭제되고 새 오브젝트가 생성됩니다.
  • rename: 이름에 번호가 추가되어 고유한 이름이 새 오브젝트에 자동으로 지정됩니다.
  • merge: 대상 위치의 오브젝트는 재사용되며, 지정된 속성, 참조, 하위 오브젝트만 대상 오브젝트에 병합되고 나머지는 그대로 유지됩니다.

보시다시피, 경우에 따라 오브젝트를 완전히 삭제하고 처음부터 다시 생성할 수도 있고(replace), JSON 오브젝트에서 변경된 값만 수정할 수도 있습니다(merge).

한편, 오브젝트는 '리스트' 라는 개념도 사용합니다. 이러한 리스트는 Sound에 설정된 RTPC나 Bus에 적용된 Effect처럼, 다양한 오브젝트 배열을 포함합니다. 따라서 ak.wwise.core.object.set을 사용할 때 이러한 리스트를 수정하고자 한다면 'listMode' 필드를 통해 동작 방식을 지정할 수 있습니다. 문서를 살펴보면 옵션은 다음과 같습니다:

  • append: 기존 오브젝트는 그대로 유지하면서, 가능한 경우 새 오브젝트를 리스트에 추가합니다. 일부 리스트는 동등한 오브젝트의 중복을 허용하지 않을 수 있습니다: 예를 들어, RTPC 리스트의 일부 RTPC 속성은 상호 배타적이기 때문에 해당 속성을 가진 RTPC는 하나만 존재할 수 있습니다.
  • replaceAll: 기존 오브젝트를 모두 제거하고 중복 제한 조건에 따라 새 오브젝트를 추가합니다.

오브젝트 속성 수정하기

우선, 이미 존재하는 오브젝트를 수정하는 데 ak.wwise.core.object.set을 사용해 보면서 구문을 살펴보겠습니다.

using-ak-wwise-core-object-set

문서를 살펴보면 인자는 최소 하나 이상의 오브젝트를 포함하는 오브젝트 배열이 필요하다는 것을 알 수 있습니다. FJson을 처음 사용하는 경우 이러한 구문을 작성하는 것이 다소 까다로울 수 있습니다. 예를 들어, 특정 Sound 오브젝트에 노트를 추가하고, 볼륨과 로우패스 필터 양을 변경하고 싶다고 가정해봅시다.

문서 예제나 Audiokinetic의 WAAPI 관련 영상들을 보면, 항상 원시 JSON 문자열을 사용합니다. 이 예제에서는 다음과 같습니다:

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

두 가지 옵션이 있습니다. 위의 내용을 그대로 원시(raw) 또는 리터럴 문자열로 WAAPI 호출에 사용하거나, 지금까지 해온 것처럼 JSON 오브젝트를 생성하는 방식이 있습니다.

제 생각에는 첫번째 방식이 가독성이 더 좋습니다. 다만 복잡한 구조를 만들기 시작하면 오히려 이해하기 어려워질 수 있습니다. 하지만 이 방식은 일반적으로 우리가 원하는 구조를 프로그래밍으로 생성해야 한다는 점에서 한계가 있습니다. 그래서 JSON 함수를 활용하는 것이 더 적합한 선택입니다.

그럼 이제 위의 JSON 오브젝트를 어떻게 만드는지 살펴보겠습니다. 먼저 인자 오브젝트와 수정하려는 Sound 오브젝트(우선 하나만)를 선언합니다.

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 타입이 필요한데, 현재 가지고 있는 것은 일반 JSON 오브젝트입니다. 이를 위해 중간 단계인 FJsonValueObject를 사용해야 합니다. 따라서 다음과 같이 작성합니다:

TSharedRef<FJsonValueObject> ObjectValues = MakeShareable(new FJsonValueObject(objectToModify));

솔직히, 위의 코드는 저에게 전혀 직관적으로 다가오지 않았습니다. 2015년 Unreal 포럼 게시물에서 찾은 내용인데, 어쨌든 잘 작동하더라고요. 이 방식을 한동안 사용해 본 후, 동일한 방식으로 인자를 구성하는 Wwise 플러그인 클래스 예시를 발견했고, 덕분에 제가 올바른 길을 가고 있다는 확신을 얻었습니다. 좀 더 일찍 확인해봤더라면 좋았을 텐데요! FAkWaapiClient, AkWaapiUtils, SWaapiPicker에 몇 가지 좋은 예제들이 있습니다.

이제 FJsonValueObject를 배열에 추가하고, 그 배열을 다시 인자 오브젝트에 추가하기만 하면 됩니다. 마지막으로 나머지 오브젝트들을 생성하면 이제 끝입니다! 전체 코드는 다음과 같습니다:

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'와 같은 루트를 부모로 지정하면 됩니다.

아무튼, 이번 데모에서는 기본 Work Unit 아래에 Sound 오브젝트 두 개를 생성해보겠습니다. 아래 코드를 천천히 읽어보며 따라해 보시기 바랍니다:

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

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

	// 하위 계층 사운드 1
	TSharedRef<FJsonObject> childOne = MakeShared<FJsonObject>();
	childOne->SetStringField("type", "Sound");
	childOne->SetStringField("name", "GeneratedSoundTest");
	TSharedRef<FJsonValueObject> childOneValues = MakeShareable(new FJsonValueObject(childOne));

	// 하위 계층 사운드 2
	TSharedRef<FJsonObject> childTwo = MakeShared<FJsonObject>();
	childTwo->SetStringField("type", "Sound");
	childTwo->SetStringField("name", "GeneratedSoundTestOther");
	TSharedRef<FJsonValueObject> childTwoValues = MakeShareable(new FJsonValueObject(childTwo));

	// 하위 계층 배열
	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가 이해할 수 있는 방식으로 데이터를 구조화하고 형식을 구성하는 일입니다. 오디오 팀 구성원이 어떤 오브젝트를 생성하고 그것들을 어떻게 구성할지 결정하는 과정을 생각해보세요.

이 작업은 일종의 마법사(wizard) 같은 형태로 구현할 수도 있습니다. Unreal 내에 존재하는 데이터 파일에서 정보를 읽어오거나, Unreal 레벨이나 블루프린트에서 직접 정보를 추출할 수도 있습니다. 어떤 방식이든, JSON 오브젝트를 모듈화된 방식으로 구성하려면 여러 개의 헬퍼 클래스(helper class)가 필요할 것입니다. 지금까지 소개한 구문으로도 시작하기에 충분합니다. 이 글의 마지막에는 지금까지 다룬 모든 내용을 종합한 예제도 함께 제공합니다.

물론... 코드로 JSON 오브젝트를 구성하는 대신, 수동으로 직접 작성하는 방식을 선호할 수도 있습니다. 이 경우에는 어떻게 하면 되는지 알아보겠습니다.

원시 JSON 문자열 파싱하기

이 방식의 가장 큰 장점은 Wwise 문서에 있는 모든 예제를 그대로 복사해서 붙여넣을 수 있다는 점입니다. 즉, 참고할 수 있는 예제 자료가 많다는 뜻이죠. 

단점이 있다면 구조를 필요에 맞게 수정하는 것이 그다지 편하지 않다는 점입니다. 복잡도가 높아질수록 JSON이 본래 가진 '사람이 읽기 쉬운' 특성도 무색해집니다. 물론 이 방식은 인자를 프로그래밍으로 생성하려는 경우에는 적합하지 않습니다. 하지만 요즘은 JSON 문자열을 파싱하고 수정하는데 활용할 수 있는 온라인 도구들이 많이 있습니다. 이것이 바로 JSON이 범용 프로토콜이기 때문에 얻을 수 있는 이점입니다.

그래서 저는 WAAPI 호출에 바로 사용할 수 있도록, 원시 또는 리터럴 JSON 문자열을 JSON 오브젝트로 변환해주는 간단한 함수를 만들어 보았습니다. 다음 예제를 살펴보겠습니다:

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 파서(parser)를 활용할 수 있습니다. 변환 전과 후를 확인해보세요:

//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} 

위 두 가지 방법 모두 사용할 수 있습니다. 전자는 가독성이 좋지만 원시(리터럴) 문자열을 다뤄야 하므로 번거로울 수 있습니다. 후자는 가독성이 많이 떨어지지만 바로 사용할 수 있습니다. 어느쪽이든 단점은 있으니 편한 쪽을 고르세요!

새로운 Event 생성하기

이제 새로운 Event를 프로그래밍으로 생성하는 방법을 살펴보겠습니다. 이 작업은 인자 오브젝트에 Event의 상위 오브젝트(예: Work Unit)들이 담긴 오브젝트 배열이 포함되기 때문에 조금 복잡해질 수 있습니다. 각 오브젝트는 Event 오브젝트 배열을 포함하고, 각 Event는 다시 Event Action 배열을 포함합니다. 이처럼 배열을 중첩하기 시작하면 구조가 점점 복잡해집니다.

다음은 구현 예제이며, 무엇이 가능한지 보여주기 위해 최대한 단순하게 구성했습니다. 이 코드는 실제 프로젝트에서 활용하기에 실용성이 떨어지지만 여전히 유익한 정보가 담겨있습니다. 하나의 Work Unit 내에 Event 하나를 생성하겠습니다. 이 Event에는 두 개의 Action이 포함됩니다. 물론 실제 활용 사례에서는 위와 같은 항목들을 원하는 개수만큼 생성할 수 있어야 합니다.

먼저, 인자 오브젝트와 Event의 상위 오브젝트를 선언합니다. 여기서 상위 오브젝트는 Default Work Unit을 사용할 것입니다.

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

// Event의 상위 오브젝트
TSharedRef<FJsonObject> eventParent = MakeShared<FJsonObject>();
eventParent->SetStringField("object", "\\Events\\Default Work Unit");

이제 Event를 만들겠습니다. 이를 위해서는 이 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을 사용하여 단순화했지만, 이론적으로는 EventActionDate 오브젝트를 통해 개별 Target을 사용할 수 있습니다.

먼저 Event용 JSON 오브젝트를 생성하고, 그 타입과 이름을 설정합니다. 그런 다음, Action 배열을 반복하면서 각 Action마다 필요한 데이터를 담은 오브젝트를 생성합니다. 저는 Action의 일부 속성들만 사용했지만, 물론 사용할 수 있는 속성은 더 많습니다.

문서의 예제에서는 빈 문자열을 사용하고 있지만, 실제로는 각 Action에 이름을 지정해야 하는 것 같습니다. 제가 보기엔 이 이름은 쓸모가 없고, 저작 도구 어디에도 표시되지 않기 때문에 각 Action에 임의의 이름을 붙였습니다.

모든 데이터를 설정한 후, 이 오브젝트를 값 오브젝트(ActionValueObject)로 변환하고 배열에 추가합니다. 그런 다음 Event 오브젝트를 설정하고 반환합니다. 

좋습니다. 이제 위의 함수를 사용해 보겠습니다. 먼저, 몇 가지 Action 데이터를 설정하겠습니다. 보시다시피, Action Type과 Scope에 정수형을 사용하는 것은 이상적인 방식은 아닙니다. 이 예제에서는 그렇게 하지 않았지만, 가능하면 열거형으로 정의하는 것이 좋습니다.

어쨌든, 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 배열을 상위 오브젝트로 설정하고, 이를 모두 값 오브젝트로 변환합니다.

이제 인자 오브젝트에 마지막 필드들을 설정하면 됩니다. 'merge'와 'append'는 Event을 누적 방식으로 생성할 수 있기 때문에 일반적으로 권장되는 선택입니다. 실행 중에 Action의 Target이나 기타 설정을 변경하려는 경우에는 'append' 대신 'replaceAll' 옵션을 사용하는 것이 더 적합합니다.

// 하위 계층 배열
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에 새로운 오디오를 가져오는 방법도 살펴보겠습니다. 이전 예제들과 매우 유사한 방식으로 작동하지만, 단 하나의 파일을 가져오는 데도 생성해야 하는 오브젝트와 배열의 수가 많아져 금방 혼란스러워질 수 있습니다.

여기서도 학습을 돕기 위해 명시적으로 코드를 작성했지만, 실제 프로젝트에서는 이렇게 작성하지 않는 것이 좋습니다. 이상적으로는 Sound를 생성하는 메소드와 Sound Source를 생성하는 메소드를 각각 따로 두는 것이 좋습니다. 또는 오디오 파일을 정의하는 메소드나 이를 보조하는 구조체와 열거형을 만들어 두는 것도 좋은 방법입니다. 어쨌든, 아래 코드는 방대한 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>();

	// 파일
	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));

	// 파일 배열 만들기
	TArray<TSharedPtr<FJsonValue>> filesArray;
	filesArray.Add(fileOneValueObject);
	files->SetArrayField("files", filesArray);

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

	// 소스 배열 만들기
	TArray<TSharedPtr<FJsonValue>> sourcesArray;	
	sourcesArray.Add(sourceValueObject);
	soundOne->SetArrayField("children", sourcesArray);
	TSharedRef<FJsonValueObject> soundValueObject = MakeShareable(new FJsonValueObject(soundOne));

	// Sound 배열 만들기
	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);
}

위 코드에는 주의할 몇 가지 사항이 있습니다. audioSounce 오브젝트 내의 'import' 필드는 배열이 아니라 files 오브젝트를 받는다는 점에 주목하세요. 그리고 이 'files' 오브젝트는 가져올 모든 파일이 포함된 배열이 들어 있습니다.

또한, Originals 폴더 내에서 파일을 복사할 하위 폴더를 직접 지정할 수도 있습니다. 일반적인 사운드의 경우, 경로는 'Originals\SFX' 기준의 상대 경로입니다. 경로에 아직 존재하지 않는 폴더가 포함되어 있다면 해당 폴더는 자동으로 생성됩니다.

마지막으로 위 코드는 정상적으로 작동하며 새로운 사운드도 Wwise에 즉시 표시되지만, 재생이 되지 않고 'media not found' 오류가 발생합니다. 이 경우 Wwise를 재시작하면 문제가 해결되며 정상적으로 오디오를 재생할 수 있습니다. 아무래도 이것은 버그로 보입니다.

토픽 구독하기

처음에 설명했듯이 WAMP를 사용하여 Wwise 저작 도구와 통신하는 두 가지 방법이 있습니다:

  • 원격 프로시저 호출 (RPC): Wwise에 특정한 정보를 요청하고 이에 대한 응답을 받는 방식입니다. 
  • 구독/발행 (Pub/Sub): 특정 토픽(topic)을 구독하여 해당 이벤트가 발생할 때 자동으로 알림을 받는 방식. 관찰자 패턴(observer pattern)과 유사한 방식입니다.

지금까지는 Wwise로 부터 정보를 가져오기 위해 RPC(원격 프로시저 호출) 방식을 사용해 왔습니다. 이 방식은 특정 시점에 특정 데이터를 가져오고자 할 때 유용하지만, 때로는 Wwise에서 무언가가 변경되었을 때 이를 알려주기를 원하기도 합니다. 이 기능은 Subscribe/Publish(구독/발행)라고 불리는 또 다른 WAMP 프로토콜을 통해 구현할 수 있습니다.

문서에서 구독 가능한 모든 토픽을 확인할 수 있습니다. 역시나 이 작업을 수행하기 위한 구문은 처음에는 다소 난해하게 느껴지며, 문서에도 예제가 없습니다. Wwise 플러그인 클래스들을 살펴보던 중, 참고할 만한 몇 가지 구현 예시를 발견할 수 있었습니다.

RPC에서는 'Call' 함수를 사용했지만, 이제는 'Subscribe' 라는 다른 함수를 사용해야 합니다. 기본 개념은 먼저 특정 이벤트를 구독한 뒤, Wwise에서 해당 이벤트가 발생했을 때 실행되기를 원하는 함수를 전달하는 것입니다.

WampEventCallback

그럼 FAkWaapiClient::Subscribe 함수를 살펴보겠습니다. 이 함수는 다음과 같은 매개 변수를 받습니다:

  • URI: 구독하려는 특정 토픽입니다.
  • Options: 함수가 반환할 데이터를 조정하기 위한 추가 정보입니다.
  • Callback: 토픽이 발생했을 때 호출하고자 하는 함수입니다.
  • SubscriptionID: 서로 다른 구독을 구분할 수 있도록 해주는 정수 값입니다.
  • Result: 구독한 항목에 대한 추가 정보가 담긴 결과 값입니다.

좋습니다. 이제 위의 내용을 어떻게 사용하는지 살펴보겠습니다. 예제로는 비교적 단순한 ak.wwise.core.profiler.stateChanged를 사용하겠습니다.

예제를 제대로 따라 하려면 최소한 함수 포인터람다에 대한 기본적인 이해가 필요합니다.

가장 중요한 것은 콜백(callback)을 만드는 것입니다. FAkWaapiClient::Subscribe 함수는 WampEventCallback 타입의 인자를 받습니다. 콜백을 생성하는 여러 가지 방법이 있지만, 제가 찾은 가장 쉬운 방법은 람다를 사용하는 것이었습니다. 따라서 다음과 같이 작성합니다:

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

람다에 익숙하지 않다면 해당 구문이 '매우' 낯설게 보일 수 있습니다. 그렇다면 람다에 대해 먼저 학습해 보시기를 권장합니다. 그러면 전체 흐름이 좀 더 잘 이해될 것입니다.

보시다시피, 콜백 변수와 실행할 함수(즉, State가 변경되었을 때 실행할 동작)를 하나의 선언문에서 함께 정의하고 있습니다. 이 경우, State와 State Group의 이름을 가져와 Unreal 에디터에 출력하면 됩니다. 나중에 구독을 해제할 수 있도록, Subscription 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에서 동작하기 때문입니다. 그렇다고 해서 반드시 Wwise에서 Capture를 수행할 필요는 없습니다.

State-group-Unreal

잊기 전에, 함수가 여러 번 실행되는 것을 방지하기 위해 구독 해제도 함께 진행하겠습니다. 여기서는 End Play에서 구독을 해제하고 있습니다:

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와 Args를 인자로 입력해야 합니다. 이를 위해 아래와 같이 적절한 타입의 변수를 생성할 수 있습니다.

URI-WAAPI

이 경우, Args와 Options는 비어 있습니다. URI와 필드 이름 모두에 대해 기본값 옆에 있는 매우 유용한 헬퍼를 클릭하여 활용 가능한 모든 목록을 살펴보겠습니다:

Unreal-Choose-URI

호출을 완료한 후에는 'IsConnected' 라는 이름의 bool 필드만 가져오기만 하면 됩니다!

하나 더 살펴보겠습니다. 여기서는 특정 오브젝트를 솔로(Solo)로 출력하게 만들고자 합니다. 예를 들어, 특정 이미터에서 재생 중인 오디오만 솔로로 듣고 싶을 때 유용하게 사용할 수 있습니다. Wwise로 갈 필요 없이 Unreal에서 바로 모든 작업을 할 수 있습니다.

이 예제에서는 Args 오브젝트를 생성해야 합니다. 먼저, 솔로로 설정할 Wwise 오브젝트들을 담을 문자열 배열 필드를 설정합니다. 이 경우에는 단순히 특정 사운드('Object to Solo')만 솔로로 설정하고 있습니다. 이 문자열 배열이 Args 오브젝트에 어떻게 설정되는지 살펴보세요. 또한, 솔로를 켜고 싶은 것이므로 bool 필드는 true로 설정합니다. 마지막으로 호출을 완료하면 원하는 대로 동작합니다!

Unreal_Blueprint_Call_WAAPI

블루프린트로도 다양한 작업을 수행할 수 있다는 점을 느끼셨기를 바랍니다. 물론 C++이 더 높은 유연성과 강력한 기능을 제공하지만, 간단한 도구나 헬퍼를 만들 때는 블루프린트만으로도 충분합니다.

Profiler 컨트롤러 생성하기 

마무리하기 전에 실제 활용에 가까운 두 가지 예제를 더 소개하겠습니다. Unreal에서 Profiler Capture를 직접 시작할 수 있는 간단한 헬퍼를 만들어 보겠습니다.

요구 사항에 따라 이 작업을 수행할 수 있는 방법은 다양합니다. 이 예제에서는 클래스 내에 bool 타입의 멤버 변수를 만들어 함수에서 이를 true로 설정할 수 있도록 했습니다. 이 함수는 Unreal 콘솔에서 호출하거나, 키보드 단축키를 통해 실행하거나, 게임 월드 내 특정 볼륨에서 트리거하는 등 다양한 방식으로 사용할 수 있습니다. 다양한 가능성이 있습니다!

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

변수가 true로 설정되면, Tick에서 연결 상태를 확인합니다:

{
    // 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_isConnectedToWwise라는 추가 변수가 생겼습니다. 이 변수는 매 프레임마다 업데이트되지만, 디버그 빌드에서만 사용되므로 CPU 사용량은 크게 문제되지 않습니다. 이상적으로는 Audiokinetic에서 연결이 수립되거나 끊어졌을 때 알림을 받을 수 있는 구독 가능한 토픽이 제공되면 좋겠지만, 현재로서는 아직 그런 기능이 없는 것 같습니다.

다음으로, 역시 Tick에서 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 에디터에 추가 정보를 표시하는 것이 좋겠습니다. 예를 들어, 현재 재생 중인 보이스 수와 각 보이스에 대한 데이터를 표시할 수 있습니다. 이 작업은 매 틱마다 한 번씩 호출되므로 정보가 자주 업데이트됩니다.

// Voice 얻기
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 함수를 호출해야 하며, 이 함수에는 voicePipelineID가 필요합니다. 이 호출을 통해 Voice Inspector 상단에 표시되는 최종 보이스 볼륨(dB)을 얻을 수 있습니다.

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 값을 합산하고 있습니다. 

결과적으로 이 값은 Advanced Profiler에서 볼 수 있는 CPU 사용량과 동일하지만, Unreal 에디터 내에서 한눈에 확인할 수 있다는 장점이 있습니다. 다만 이 값을 매 프레임마다 가져오면 수치가 너무 빠르게 변동해서 가독성이 떨어질 수 있으므로, 일정 시간 동안 평균을 내는 것이 좋습니다.

CPU 값을 포함한 디버그 화면은 다음과 같습니다:

Total-CPU-usage-Wwise-Unreal

어쨌든 위의 내용은 단순한 예시일 뿐이며 여기에 표시하고 싶은 다른 정보들을 이미 떠올리고 있을 것입니다.

마지막으로, PIE 중지 시 캡처를 중단하고 Wwise와의 연결을 끊기 위해 EndPlay에서 호출하는 함수를 만들었습니다. 물론 원하지 않는다면 이 작업을 할 필요는 없습니다. 이상적으로는, 게임이 종료 시 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 계층 구조 구성하기

마침내 지금까지 배운 내용을 바탕으로 실제 프로젝트에 더 가까운 확장된 시스템을 구현해 보았습니다. 설명에 앞서, 먼저 어떤 모습인지 살펴보겠습니다:


적들이 절차적으로 생성되는 게임이 있습니다. 각 적의 행동마다 오디오를 재생하고 싶지만, 이 오디오는 적의 유형과 희귀도에 따라 달라져야 합니다. 이를 처리하는 한 가지 방법은 하나의 이벤트로 큰 Switch Container 구조를 사용하고, 여기에 다양한 Switch 값을 설정해 적절한 사운드를 재생하는 것입니다.

따라서 Unreal에서 적 데이터를 불러와 Wwise 내에 이 구조를 자동으로 생성하는 코드를 구현하는 것이 목표입니다. 이 데이터는 스프레드시트나 CSV 파일 등 Unreal 외부에 존재할 수 있습니다. 어쨌든 이 글은 Unreal을 중점적으로 다룬 튜토리얼이므로 Unreal 데이터 애셋 파일을 사용했습니다.

처음에는 전체 코드를 단계별로 설명하려 했지만, 너무 길어져서 전체적인 흐름만 간략히 설명하겠습니다. 코드를 직접 보고 싶다면 여기에서 확인할 수 있습니다. 헤더 파일과 CPP 파일을 모두 포함했습니다. 꼭 한 번 읽어보시기를 적극 권장합니다.

이번에는 모든 내용을 보다 체계적이고 모듈화된 방식으로 작성했습니다. 실제 프로젝트 구조에 더 가까운 형태입니다. 다양한 타입의 오브젝트를 생성하는 과정에서 반복 작업이 많아, 이를 처리하기 위한 다양한 범용 헬퍼 함수들을 작성했습니다. 이상적으로는 이러한 함수들이 커스텀 WAAPI 라이브러리의 정적 클래스에 포함되는 것이 좋지만, 제 예제에서는 모두 동일한 클래스에 모아두었습니다.

이 코드는 오류나 null 체크를 거의 수행하지 않았기 때문에 실제 프로젝트에 바로 사용하기에는 적합하지 않습니다. 사용자가 다양한 조작을 시도하면 곧바로 오류나 버그가 발생할 수 있습니다. 어쨌든 이 과정은 저에게 좋은 학습 경헙이 되었고, 이제 어떤 결과가 나왔는지 함께 확인해보겠습니다.

적 데이터는 UDataAsset을 상속받은 클래스에 정의되어 있습니다. 이 클래스에는 적의 변형(variant) 정보를 설명하는 모든 직렬화된 데이터와 이 정보를 Wwise로 전달하는 모든 로직을 포함하고 있습니다. 이 클래스로부터 애셋을 생성할 수 있고, 게임의 각 레벨당 하나씩 애셋을 사용할 수도 있습니다.

Assets-per-level

앞서 언급했듯이, 이 툴의 목표는 모든 데이터를 활용하여 Switch Container 계층 구조를 자동으로 생성하는 것입니다. 이를 위해 사용자가 Wwise 내에서 필요한 Game Sync와 컨테이너를 생성할 위치를 지정할 수 있습니다. 또한 사용자는 가져올 오디오 파일이 포함된 폴더도 지정할 수 있고, 정리를 위해 사용되는 Originals 폴더도 지정할 수 있습니다. 위에 표시된 모든 데이터를 확인해보세요.

Enemy-data-Wwise

두 번째 섹션에는 모든 적 정보가 포함되어 있습니다. 이 정보는 아마도 게임 기획자가 입력하게 될 것입니다. 예를 들어 속성 타입, 희귀도 등급, 적의 행동 유형이 각각 몇 개인지에 대한 정보입니다. 이 시스템은 각 타입마다 반드시 하나의 배열 멤버가 있어야 한다는 등의 몇 가지 가정을 전제로 합니다.

보시다시피, 각 구성 요소마다 Switch Container 구조에 포함시킬지 여부를 설정할 수 있는 'Update Wwise Structure' 항목이 있습니다. 다시 말해, 이 요소를 Wwise에 포함하고 싶다면 이 항목을 체크하세요.

Wwise-Unreal-optional-properties

각 오브젝트(Switch Container 또는 SoundSFX 오브젝트)에는 선택적으로 적용 가능한 속성들도 있습니다. 단순화를 위해 숫자 기반 속성만 지원하도록 했지만, 문자열이나 불리언(boolean) 기반 속성으로도 쉽게 확장할 수 있습니다. 하지만 참조나 리스트(예: Effect Share Set 또는 RTPC) 같은 항목을 지원하려면 조금 더 복잡한 작업이 필요합니다.

위의 모든 데이터는 코드에서 처리되며, 버튼을 누르면 Wwise에 필요한 모든 오브젝트가 자동으로 생성됩니다.

코드 측면에서는 정보를 보다 명확하게 표현하기 위해 커스텀 열거형과 구조체를 사용하고 있습니다. 위의 모든 기능이 정상적으로 작동하도록 하기 위해 다음과 같은 함수들을 만들었습니다:

  • Switch Group Game Sync와 그 하위 항목을 생성하는 함수.
  • Switch Container를 생성하는 함수.
  • Switch Container 그룹에 Switch를 할당하는 함수.
  • Switch 또는 Random Container와 같은 오브젝트에 볼륨이나 피치와 같은 숫자 속성을 설정하는 함수.
    • 이 속성들은 오버라이드 또는 가산 방식으로 설정할 수 있음.
  • 임의의 오브젝트에서 숫자 속성 값을 얻어오는 함수.
  • RTPC Game Sync를 생성하는 함수.
  • 특정 오브젝트에 RTPC를 설정하는 함수(RTPC 커브 포함).
  • SoundSFX 오브젝트를 생성하는 함수.
  • 오디오 파일의 명명 규칙을 기반으로 오디오를 적절한 위치에 가져오는 함수.

이번 작업은 확실히 제가 WAAPI로 구현한 가장 큰 규모의 작업이었으며, 모든 것이 제대로 작동하도록 만드는 데 꽤 많은 시간이 걸렸습니다. 반복적으로 인자를 구성하는 작업을 줄이기 위해서 헬퍼 함수는 반드시 필요하다고 느꼈습니다. 아마도 이 부분은 정적 WAAPI 헬퍼 클래스로 분리하게 될 것 같습니다.

위의 시스템이 작동하기는 하지만, 몇 가지 절충 사항이 있었습니다. 아래에 장단점들을 정리해보았습니다:

장점:

  • 구조를 처음으로 만드는 작업은 매우 빠르고 간단합니다. 수십 개의 컨테이너를 하나하나 손볼 필요가 없습니다. 복사, 붙여넣기나 컨테이너 이름 변경 같은 작업도 할 필요 없습니다.
  • Wwise의 숫자 속성을 일괄 수정할 수 있습니다. 예를 들어, 모든 Death Sound를 -2dB로 설정할 수 있습니다. 이는 HDR 믹싱에 매우 유용합니다.
  • 새로운 적 타입, 희귀도, 액션이 추가될 경우 이 시스템은 모든 컨테이너를 한 번의 클릭으로 생성해줍니다. 모든 항목이 제자리에 배치됩니다.
  • 모든 오디오가 하나의 폴더에 있고, 적절한 명명 규칙을 따르고 있다면, 모든 오디오 애셋이 자동으로 가져와집니다.
  • 오디오를 가져올 때 사용할 Originals 하위 폴더를 선택할 수 있습니다.
  • 더 좋은 점은, 오디오를 다시 작업해서 해당 폴더 안의 파일들을 교체한 경우, 클릭 한 번으로 모든 파일이 교체되어 적절한 위치로 이동된다는 것입니다. 정말 유용한 기능이죠! 소스 컨트롤 기능도 추가할 수 있었지만, 이번에는 포함하지 않았습니다.
  • 실수가 발생해도 Wwise에서 전체 과정을 되돌릴 수 있습니다.
  • 볼륨, 로우패스, 피치 등의 변경은 가산(additive) 방식이나 오버라이드(override) 방식으로 적용할 수 있습니다.
  • 변경 사항을 적용하면 기본적으로 속성값은 초기화됩니다. 동일한 값을 반복 적용할 필요는 없다고 판단했기 때문입니다. 
  • 최상위 Switch Container에 RTPC를 추가할 경우 이미 존재하는지 확인하여 중복 추가나 기존 항목의 덮어쓰기를 방지합니다.

단점:

  • Wwise에서 컨테이너 이름을 수정하기 시작하면 문제가 발생할 수 있습니다. 이 시스템은 이름을 기반으로 동작하기 때문입니다. ID를 기반으로 동작하도록 하면 시스템을 더 견고하게 만들 수 있지 않을까요?
  • 특정 컨테이너에 속성을 설정할 수는 있지만, 예를 들어 Ice 타입 적의 Death 액션만 수정하는 것과 같은 매우 구체적인 설정은 불가능합니다. 물론 이러한 기능은 추후에 추가할 수도 있습니다.
  • 동일한 오디오 음원 내에서 서로 다른 버전을 유지하는 기능은 지원하지 않습니다. 이것도 필요하다면 구현은 가능합니다.
  • 기획자가 적 타입, 희귀도, 액션을 삭제하더라도 시스템은 이를 Wwise에서 제거하지 않습니다. 저는 이 방식이 더 안전하다고 판단했지만, 그로 인해 데이터 파일과 Wwise 프로젝트 간의 동기화가 맞지 않을 수도 있습니다. 결국은 절충의 문제입니다.
  • 이 시스템은 Switch Container 구조에 대해 Type->Rarity->Action 순서의 고정된 계층 구조를 전제로 합니다. 'Age'와 같은 새로운 속성을 추가하려면 코드 수정이 필요하지만, 그렇게 어려운 작업은 아닙니다. 속성의 순서를 변경하는 경우에도 마찬가지로 코드 수정을 해야 합니다. 예를 들어, Type 대신 Rarity를 먼저 필터링하려는 경우입니다. 속성을 자유롭게 생성하고 순서를 바꿀 수 있는 유연한 시스템도 구현은 가능하지만, 훨씬 더 복잡해집니다.
  • RTPC 함수는 매우 단순한 커브를 생성하며 사용자에게 이를 정의할 수 있는 기능은 제공하지 않았습니다. 필요하다면 Unreal의 Float Curve를 통해 구현할 수 있습니다.
  • 데이터 애셋을 통해 다른 하위 Switch Container나 오브젝트에 RTPC를 추가하는 것은 불가능합니다. 처음에는 이 기능도 구현하려 했지만, 점점 기능이 너무 늘어나서 어느 시점에서는 범위를 조절할 필요가 있었습니다.

전반적으로 이 시스템은 WAAPI를 활용해 어떤 작업을 구현할 수 있는지, 그리고 Unreal 데이터를 활용해 Wwise의 계층 구조를 구성하는 방법을 보여주는 훌륭한 사례라고 생각합니다.

링크 및 참고 자료

WAAPI RPC 함수
WAAPI Subscribe 토픽
WAAPI 예제
오디오 파일 가져오기 및 구조체 생성하기

Wwise 쿼리하기
WAQL 소개
WAQL 레퍼런스

Wwise 오브젝트 레퍼런스

AK 블로그 - WAAPI 소개
AK 블로그 - 단계별 WAAPI 예제
AK 블로그 - WAAPI 간소화하기
AK 블로그 - 누구나 사용할 수 있는 WAAPI
AK 블로그 - WAQL 소개
AK 블로그 - WAAPI와 Python으로 툴 만들기

Wwise 업 온 에어 - WAAPI (2019)
Wwise 업 온 에어 - WAAPI (2022)
Wwise 업 온 에어 - WAAPI (2024)

C++에서 문자열 리터럴(string literal) 다루기
JSON Formatter

하비에르 주머(Javier Zumer)

시니어 테크니컬 사운드 디자이너

Supermassive Games(슈퍼매시브 게임즈)

하비에르 주머(Javier Zumer)

시니어 테크니컬 사운드 디자이너

Supermassive Games(슈퍼매시브 게임즈)

하비에르(Javier)는 영국에 위치한 Supermassive Games에서 시니어 테크니컬 사운드 디자이너로 근무하고 있습니다. 그는 선형 미디어의 사운드 디자인과 믹싱 분야에서 경력을 쌓았으며, 항상 게임 오디오에 관심이 많았고 여러 인디 타이틀을 통해 경험을 쌓기 시작했습니다. 게임 업계로 완전히 전향한 후, 그는 사운드 시스템의 작동 원리에 대한 궁금증을 바탕으로 점점 더 기술적인 분야에 매력을 느끼게 되었습니다. 현재 그는 Supermassive games의 다양한 오디오 시스템을 유지 및 확장하는 일과, 이러한 시스템에서 사용되는 일부 애셋을 제작하는 일에 시간을 할애하고 있습니다.

 @JavierZumer

댓글

댓글 달기

이메일 주소는 공개되지 않습니다.

다른 글

Mystralia의 마법적이고 역동적인 음악 사운드스케이프 만들기

Mages of Mystralia는 주인공 지아(Zia)가 마법의 기술을 배우는 매력적이고 다채로운 액션 어드벤처 게임입니다. Borealys Games의 작곡자이자 사운드...

23.6.2020 - 작성자: 안토이네 바숀(ANTOINE VACHON)

UI 설계 관점에서 UI 오디오 접근하기 - 제 1부

어떤 게임에서는 사용자 인터페이스가 플레이어의 전체 경험에 궁극적으로 영향을 거의 미치지 않습니다. 수많은 모바일 게임과 같이 어떤 게임에서는 거의 전체 게임플레이 동안 UI를...

14.10.2020 - 작성자: 조셉 마척(JOSEPH MARCHUK)

Wwise+GME 게임 음성 솔루션: 다양한 음성 플레이 대방출, 생생한 몰입감 선사

AppAnnie2021 모바일 게임 리포트는 강력한 소셜 인터랙션 속성을 가진 배틀 그라운드, 슈팅 및 온라인 MOBA가 플레이어들의 사랑을 많이 받았으며 게임 시간 증가를...

12.1.2022 - 작성자: Tencent Cloud

‘잇 테이크 투(It Takes Two)’ 사운드 비하인드 스토리 | Hazelight 오디오 팀과의 Q&A

Hazelight Studios(헤이즈라이트 스튜디오)에서 제작한 잇 테이크 투(It Takes Two)는 분할 스크린 액션 어드벤처 플랫폼 협동 게임입니다. 이 게임은 엄청나게...

5.4.2022 - 작성자: Hazelight (헤이즐라이트)

AudioLink로 떠나는 여행

지난 10월 게임사운드콘(GameSoundCon)에서 저는 호텔 근처 고급 샌드위치 가게에서 데미안(Damian)과 점심을 먹고 있었습니다. 예상하셨겠지만 저희는 오디오 기술에...

10.6.2024 - 작성자: 피터 "pdx" 드레셔 (Peter "pdx" Drescher)

Scars Above(스카스 어보브)의 오디오 최적화 모범 사례

소개 이 글에서는 게임 스카스 어보브(Scars Above)를 프로파일링하고 오디오를 최적화하는 데 적용한 다양한 원칙을 설명해드리려고 합니다. 사운드 디자이너분들에게 저희가 겪은...

12.3.2025 - 작성자: 밀란 앤틱(Milan Antić)

다른 글

Mystralia의 마법적이고 역동적인 음악 사운드스케이프 만들기

Mages of Mystralia는 주인공 지아(Zia)가 마법의 기술을 배우는 매력적이고 다채로운 액션 어드벤처 게임입니다. Borealys Games의 작곡자이자 사운드...

UI 설계 관점에서 UI 오디오 접근하기 - 제 1부

어떤 게임에서는 사용자 인터페이스가 플레이어의 전체 경험에 궁극적으로 영향을 거의 미치지 않습니다. 수많은 모바일 게임과 같이 어떤 게임에서는 거의 전체 게임플레이 동안 UI를...

Wwise+GME 게임 음성 솔루션: 다양한 음성 플레이 대방출, 생생한 몰입감 선사

AppAnnie2021 모바일 게임 리포트는 강력한 소셜 인터랙션 속성을 가진 배틀 그라운드, 슈팅 및 온라인 MOBA가 플레이어들의 사랑을 많이 받았으며 게임 시간 증가를...