Integrating Wwise Into a Custom Engine II: Using the Preprocessor
The Wwise audio engine created by Audiokinetic is an extremely powerful and helpful piece of middleware used by hundreds of sound designers across the game industry and beyond. The documentation for Wwise provides easy step-by-step procedures for integration into the most commonly used game engines, Unity and Unreal, so that programmers can easily add sound to their games, but for teams creating their own custom game engine, the process is much more difficult. Even though the documentation provided by Audiokinetic gives most of the information, it doesn't have everything. The goal of this article is to:
- Use the preprocessor to implement Wwise Events, RTPCs, Switches, and other Wwise controls into the engine at compile time.
The content of this article assumes that the reader has knowledge of C++ and the C preprocessor. Much of this information is probably most useful to students implementing custom game engines who want to integrate Wwise as their middleware. Because of the way that the following is set up, it is largely unsustainable and not flexible enough for larger scale custom engines, but perfect for student projects and smaller scale custom engines.
Benefits of Using Integral Types
If you read my last article on integrating Wwise into a custom game engine, then you'll remember how every time that the audio implementer wanted to send an event to Wwise telling it to play a sound, they had to give it the exact string of the event name that they wanted to send. Likewise, if they wanted to manipulate an RTPC or set a Switch in a Switch Group, then they had to know all the strings beforehand. This can be problematic for a few reasons.
- If the strings aren't pulled from some database and are hand-typed, there's a lot of room for human error.
- Wwise uses some hashing algorithm internally to lookup the event from the string its given, whereas the sound engine already uses IDs by default, and you won't use as much CPU on converting from strings to those IDs if you just use the IDs in your code.
The C Preprocessor
Each compiler handles the preprocessor a little differently, so for the purposes of this article, I'll be using the Microsoft compiler, because of its common use in game development. This article assumes that you have some basic knowledge of how the C preprocessor works, but I'll give a refresher here; here are some things that the preprocessor can do.
Stringification
In a macro definition, putting a # in front of a variable will convert it into a string. For example:
#define STRINGIFY(var) #var
void Foo()
{
printf("%s", STRINGIFY(egg));
}
calling Foo will print out the word "egg."
Token Pasting
In a macro definition, putting two # symbols between a variable and anything else will concatenate those symbols together. For example:
#define PASTE(a, b) a##b
void Foo()
{
printf("%s", STRINGIFY(PASTE(egg, man)));
}
calling Foo will print out the word "eggman."
#include-ing
Whenever you #include a file, that entire file will be copy/pasted there.
I don't know if there's a design pattern associated with the following technique or if there's a name for it, but it's something that I've started to use in my code more frequently and it will be used extensively throughout this article. I'll call it the Registry pattern just so I have a name for it.
// in some RegistryFile.h:
REGISTER_VALUE(Father)
REGISTER_VALUE(Mother)
REGISTER_VALUE(Son)
REGISTER_VALUE(Daughter)
REGISTER_VALUE(Dog)
REGISTER_VALUE(Anteater)
//...
//in some another file:
// define REGISTER_VALUE from the other file to be an enum value
// that adheres to your coding standard for enums.
#define REGISTER_VALUE(role) fr_##role ,
enum FamilyRole
{
// by #include-ing here, it copies that whole file inside this enum.
# include "RegistryFile.h"
// with RegistryFile.h pasted directly within the enum,
// your IDE's intellisense will tell you it has the following:
// fr_Father ,
// fr_Mother ,
// fr_Son ,
// fr_Daughter ,
// fr_Dog ,
// fr_Anteater ,
// max value of the enum, so that you know how many roles there are
fr_FamilyRoleCount
};
#undef
You can un-define something after defining it to be something else! Very helpful if you want to reuse your macro definitions from a Registry file being used in the Registry pattern.
Variadic Macros
If you want a #define to take any number of parameters, then they'll be defined as something that looks like this:
#define MACRO(param1, ...)
There's only one point of access to the values being passed to the macro, and to access them, you use the symbol __VA_ARGS__. For example, if I wanted to wrap the printf function with a macro, I'd have to use a variadic macro that would look something like this:
#define PRINT(fmt, ...) printf(fmt, __VA_ARGS__)
With variadic macros, we should now know enough to understand what the preprocessor is doing in the next steps.
Step 1: Extracting Information from Wwise
First we have to extract the information that we want from Wwise to make our Registry files that will be used by the Registry pattern. There are many ways to do this, but I'll detail just one here. In the Wwise build, you can set it up to output a file called "Wwise_IDs.h." This file contains easy access to the internal integral type IDs used by Wwise. Accessing the information in this file can be tedious in code because of the several layers of namespace qualifications that you have to go through before being able to access the information, but we can use this file to generate our Registry files.
Using C# (because I wanted practice writing in C#), I wrote a tool that parses that file and spits out the registry files for my sound designer to run in their Wwise build process. The files that it outputs look like this for EVENTS, GAME_PARAMETERS, BANKS, BUSSES, and AUX_BUSSES:
// in AudioEVENTSRegistry.h ...
///////////////////////////////////////////////////////
// THIS FILE IS AUTOMATICALLY GENERATED BY AUDIO TOOLS
// MODIFY AT YOUR PERIL
REGISTER_AUDIO_EVENTS(ANCHOR_LAND_FAILURE)
REGISTER_AUDIO_EVENTS(ANCHOR_LAND_SUCCESS)
REGISTER_AUDIO_EVENTS(ANCHOR_RETRACT)
REGISTER_AUDIO_EVENTS(ANCHOR_THROW)
REGISTER_AUDIO_EVENTS(HUD_NO_STRING)
REGISTER_AUDIO_EVENTS(HUD_PICKUP)
//...
and like this for SWITCHES and STATES:
// in AudioSTATESRegistry.h ...
///////////////////////////////////////////////////////
// THIS FILE IS AUTOMATICALLY GENERATED BY AUDIO TOOLS
// MODIFY AT YOUR PERIL
REGISTER_AUDIO_STATES(MUSIC_STATES, COMBAT, CREDITS, EXPLORING, LEVEL, LEVEL_LOSE, LEVEL_WIN, MAINMENU, MENU)
REGISTER_AUDIO_STATES(TESTSTATE, TEST1, TEST2, TEST3)
Step 2: Helpful Macros
For most of the types of Wwise info to Register into your engine, it'll be relatively straightforward. However, in the case of Switches and States, things are a little more complicated. There can be any number of states or switches that correspond with their parent switch- or state-group. This is where we'll have to use variadic macros to parse out information. We'll need some useful macros:
// the expand macro is used a lot simply becau(se of the way that
// the Microsoft compiler handles macro expansion
// if you were to use the GNU compiler, you probably wouldn't need this
#define EXPAND(x) x
// this macro can define the number of arguments are contained within
// __VA_ARGS__... up to a point.
// this version can detect up to 15 arguments.
// however its pretty easy to add more if your sound designer has more
// than 15 switches attached to a switch group.
#define _NUM_VA_ARGS(_1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, N, ...) N
#define NUM_VA_ARGS(...) EXPAND(_NUM_VA_ARGS(__VA_ARGS__, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0)
// the following was taken from a Stack Overflow answer from
// User luser droog
// xpaste is also a Microsoft compiler specific expansion
#define PASTE(a, b) a ## b
#define XPASTE(a, b) PASTE(a, b)
// these are macros that apply a given macro (M) on x number of arguments
#define APPLY_0(M)
#define APPLY_1(M, a) M(a)
#define APPLY_2(M, a, b) M(a) M(b)
#define APPLY_3(M, a, b, c) M(a) M(b) M(c)
// continue through APPLY_15 ...
// these are macros that apply a given macro (M) on x number of arguments
// with a constant arg common between each call
#define APPLY_1_0(M, arg)
#define APPLY_1_1(M, arg, a) M(arg, a)
#define APPLY_1_2(M, arg, a, b) M(arg, a) M(arg, b)
#define APPLY_1_3(M, arg, a, b, c) M(arg, a) M(arg, b) M(arg, c)
// continue through APPLY_1_15 ...
// helper expanders
#define APPLYX(APP, M, ...) EXPAND(APP(M, __VA_ARGS__))
#define APPLYX_1(APP, M, arg, ...) EXPAND(APP(M, arg, __VA_ARGS__))
// the compilation of each of the above macros
// applies a macro to all arguments passed in as __VA_ARGS__
#define APPLY_ON_EACH(M, ...) APPLYX(EXPAND(XPASTE(APPLY_, NUM_VA_ARGS(__VA_ARGS__))), M, __VA_ARGS__)
#define APPLY_ON_EACH_1(M, arg, ...) APPLYX_1(EXPAND(XPASTE(APPLY_1_, NUM_VA_ARGS(__VA_ARGS__))), M, arg, __VA_ARGS__)
Step 3: Figure out what you need
There are different ways to go about binding the Wwise IDs depending on what you need. Here are a few scenarios that you might come across:
- Binding the IDs to a scripting language like Lua.
- Needing both the capability of displaying the names of the Wwise controls in an editor while using integral types internally.
- Use the IDs for scripting in C++.
For my uses in my last custom engine, I needed both to be able to display the events in an editor and had to bind the IDs to Lua. So for Step 4, I'll show the steps I went through to achieve both of those goals.
Step 4: Bind the IDs
In order to bind both the names of the controls and the IDs of the controls at the same time to be displayed in an editor, I used a system of parallel arrays that were tied together via an enum. This pattern was used for AUX_BUSSES, BANKS, BUSSES, EVENTS, and GAME_PARAMETERS. In my code I've defined AUX_BUSSES as Audio Environments and GAME_PARAMETERS as Audio RTPCs. I'll give the basic example here with Audio Events:
// in AudioIDInclude.h
#pragma once
#include "External/Wwise_IDs.h"
#include <vector>
using AudioControlID = AkUniqueID;
namespace Audio
{
# define REGISTER_AUDIO_EVENTS(name) ae_##name ,
// define the enum of eventsenum AudioEvent
{
# include "RegistryFiles/Audio/AudioEVENTSRegistry.h"
ae_AudioEventMax
};
# undef REGISTER_AUDIO_EVENTS
# define REGISTER_AUDIO_EVENTS(name) #name ,
// global list of event names (should be declared in a .cpp file // and externed in the header file)// the index of the name is the corresponding enum valconst char* g_audioEventNames[] =
{
# include "RegistryFiles/Audio/AudioEVENTSRegistry.h""AudioEventMax"
};
# undef REGISTER_AUDIO_EVENTS
# define REGISTER_AUDIO_EVENTS(name)AK::EVENTS:: name ,
// global list of event IDs// the index of the ID is the corresponding enum valconst std::vector<AudioControlID> g_audioEventIDs =
{
# include "RegistryFiles/Audio/AudioEVENTSRegistry.h"0
};
# undef REGISTER_AUDIO_EVENTS
// define it to be nothing after using it
# define REGISTER_AUDIO_EVENTS
}
With this implementation, you can change the Emit function in your AudioEmitter to look something like this:
void AudioEmitter::Emit(Audio::AudioEvent eventEnum)
{
int index = static_cast<int>(eventEnum);
AK::SoundEngine::PostEvent(Audio::g_audioEventIDs[index], m_audioObject);
}
and from a Lua script, calls to this function can look like:
local audioEmitter = gameObject.AudioEmitter
if(audioEmitter ~= nil) then
audioEmitter:Emit(AudioEvent.ae_HUD_PICKUP)
end
which is a pretty simple interface to use!
The same can be done for the SetRTPC function on the AudioObject base class that we defined in the previous article: instead of using a string name for the RTPC, we can define it as an enumeration and use it to index into a table of IDs.
An even simpler version (if you wanted to just bind them to scripting) would be binding the values within that enumeration to be the values from the Wwise IDs file:
# define REGISTER_AUDIO_EVENTS(name) ae_##name = AK::EVENTS:: name ,
// define the enum of eventsenum AudioEvent : AudioControlID
{
# include "RegistryFiles/Audio/AudioEVENTSRegistry.h"
ae_AudioEventMax = 0
};
# undef REGISTER_AUDIO_EVENTS
// in AudioEmitter.cpp
void AudioEmitter::Emit(Audio::AudioEvent eventEnum)
{
AK::SoundEngine::PostEvent(eventEnum, m_audioObject);
}
Switches and States
Setting switches and states are a step up in difficulty from the rest of the types of controls, because each switch/state group can have any number of sub-switches/states and each of those sub-switches/states have their own IDs and names. Simply binding them to a scripting language or to C++ scripting is fairly straightforward, but binding them to be displayed in editor takes some legwork. I'll first go over binding them to scripting.
Recall: switches were registered like this:
REGISTER_AUDIO_SWITCHES(SWITCH_GROUP, SWITCH1, SWITCH2, SWITCH3, ETC)
so binding the SwitchGroup and its Switches should be pretty easy:
// ...
#define REGISTER_AUDIO_SWITCHES(name, ...) as_##name = AK::SWITCHES:: name :: GROUP
enum AudioSwitchGroup : AudioControlID
{
# include "RegistryFiles/Audio/AudioSWITCHESRegistry.h"
as_AudioSwitchGroupMax = 0
};
#undef REGISTER_AUDIO_SWITCHES
// helper macro:
#define CONCAT_SWITCHES(name, arg) asb_##name##_##arg = AK::SWITCHES:: name ::SWITCH:: arg ,
#define REGISTER_AUDIO_SWITCHES(name, ...) APPLY_ON_EACH_1(CONCAT_SWITCHES, name, __VA_ARGS__)
enum AudioSwitchBinding : AudioControlID
{
# include "RegistryFiles/Audio/AudioSWITCHESRegistry.h"
asb_SwitchBindingMax = 0
};
#undef REGISTER_AUDIO_SWITCHES
Because this is all done in the preprocessor step, your IDE's Intellisense/Autocomplete feature will be able to detect these for you so you'll never have to actually type out the full names of the AudioSwitchBindings.
As for binding the switches and states to be viewable in editor, well that has been left as an exercise for the reader. It is fully doable using the tools discussed here along with making heavy use of list-initialization.
Conclusions
All of this black magic that we've done with macros is fine and dandy, but what does it gain us in the end?
- You can trigger audio events and audio controls easily now with Intellisense! No more manual typing of the string names of audio triggers.
- Speed increase... probably. For low scale, student games made in under a full year, the speed increase is probably negligible. Hashing using the IDs is definitely faster, although by how much, I am not certain. I haven't personally profiled using strings vs. using IDs. On AudioKinetic's Integration Walkthrough page for loading banks, they do mention that "a small amount of CPU is required to convert strings to the IDs used by the sound engine. However, most games will not notice the difference in CPU usage unless thousands of strings are converted per game frame. [...] In most cases, you can get away with using strings; but, if you game is very squeezed in terms of memory and CPU, you might want to consider using IDs."
In the end, this has mostly been an exercise in discovery rather than definite way to make your Wwise experience with your custom engine easier. Another issue with this approach is that every time that the sound designer exports new Wwise IDs, or adds new Events or RTPCs, you'll have to recompile everything that uses the Registry file, which is potentially dozens of files. The only way around this is to have your sound designer create all their anticipated Events and Wwise controls at the very beginning of the project, and never rescope the sound design of the project, which, although doable, is undesirable and inflexible.
For large scale, commercial custom engines, this pattern is not sustainable, but for student projects and small custom engines, using the preprocessor to define your Wwise IDs might be exactly what you need.