Game Programming in C++ - Day 17

이응민·2024년 9월 19일
0

Game Programming in C++

목록 보기
17/21

Day 17 오디오

오디오 구축하기

가장 기본적인 오디오 시스템은 필요에 따라 WAV나 OGG 파일 같은 독립실행형 사운드 파일을 로드하고 재생한다. 이런 접근법은 간단한 2D 게임에서는 완벽히 수용되겠지만 이 정도로는 한계가 있다. 대부분 싱글 게임에서는 하나의 사운드 파일만을 가지고 있지는 않다. 게임에 주변을 돌아다니는 캐릭터가 있다고 하면 캐릭터의 발이 땅에 부딪힐 때마다 발자국 소리를 재생해야한다. 반복적으로 재생한느 발자국 소리 사운드 파일이 오직 하나뿐이라면 이 소리만 계속해서 반복될 것이다. 적어도 하나의 발자국 소리 사운드보다는 다양성을 위해 10개의 여러 사운드 파일이 있는 것이 좋을 것이다. 또한 플레이언느 다양한 표면 위를 걸을 수 있는데 잔디를 걸을 때의 발소리는 콘크리트 위에서의 발자국 소리와는 다르게 들려야 할 것이다. 이 경우 게임은 플레이어가 걷고 있는 표면을 기반으로 발자국 소리를 저확히 선택하는 방법이 필요하다. 또 다른 고려 사항으로 게임은 제한된 수의 사운드만 동시에 플레이할 수 있다는 걸 들 수 있다. 사운드 채널을 사용하면 재생 중인 사운드를 추적할 수 있는데 채널 수는 제한을 가진다. 임의의 시간에 화면상의 몇몇 적이 있는 게임에서 각각의 적이 개별 적으로 발자국 소리를 내면 플레이어의 귀를 완전히 뒤덮을 뿐 아니라 이용 가능한 모든 채널을 차지할 수 있다. 적을 공격한느 플레이어 캐릭터의 특정 사운드는 적의 발자국 소리보다 훨씬 더 중요하다. 따라서 사운드는 우선 순위도 가져야한다. 3D 게임에서 사운드를 생각해보면 한쪽 벽에 벽난로가 있다고 했을때 모든 스피커에서 소리가 같은 볼륨으로 재생되면 플레이어가 어디에 있던 소리가 동일하게 들린다. 이것은 매우 비현실적이다. 게임은 플레이어와 벽난로 사이의 거리를 고려해서 이 거리를 기반으로 볼륨을 계산하는 작업이 필요하다. 그래서 게임이 사운드를 올바르게 재생하려면 추가적인 정보가 필요하다.

FMOD

Firelight Technologies가 제작한 FMOD는 비디오 게임에서 인기 있는 사운드 엔진 중 하나다. FMOD는 윈도우, 맥, 리눅스, iOS, 안드로이드, HTML5 그리고 최신 콘솔을 포함한 대부분의 게임 플랫폼을 지원한다. FMOD는 별도의 두 가지 컴포넌트가 있다.

  • FMOD 스튜디오 : 사운드 디자이너를 위한 외부 저작 툴
  • FMOD API : FMOD를 사용하는 게임에 제공되는 API

FMOD 스튜디오 툴은 사운드 디자이너에게 강력한 기능을 제공해주며 수많은 기능의 구현을 가능하게 해준다. 사운드 이벤트(sound event)는 하나 이상의 사운드 파일과 연결할 수 있으며, 이러한 사운드 이벤트는 동작을 동적으로 조정할 수 있는 파라미터를 가진다. FMOD 스튜디오는 또한 디자이너가 다양한 소리를 믹싱한느 방법을 제어 가능하게 해준다. 예를 들어 디자이너는 음악과 사운드 효과음을 별도의 트랙에 놓고 트랙의 볼륨을 개별적으로 조정할 수 있다. FMOD API는 두 부분으로 구성된다. FMOD 로우 레벨 API는 FMOD의 토대에 해당한다. FMOD 저수준 API는 사운드를 로드하고 연주하며 채널을 관리하는 기능을 포함한다. 또한 3D 환경에서 사운드를 갱신하거나 사운드에 디지털 효과음을 추가하는 중의 기능도 포함하고 있다. 저수준 API를 그대로 사용하면 문제는 없지만, 그 경우 FMOD 스튜디오에서 생성한 모든 이벤트는 사용할 수 없다. FMOD 스튜디오에 대한 지원은 FMOD 스튜디오 API를 요구한다. 이 API는 FMOD 저수준 API에 기반을 둔다. 하지만 FMOD 스튜디오 API를 사용한다고 해서 오디오 프로그래머가 저수준 API에 접근할 수 없는 것은 아니다.

오디오 시스템 생성

Renderer 클래스가 Game 클래스와는 별도로 구성된 것처럼 오디오를 다루는 새로운 AudioSystem 클래스를 작성하는 것이 좋다. 별도의 AudioSystem 클래스 작성은 FMOD API의 호출이 코드 전반에 걸쳐있지 않게 해준다.

초기의 AudioSystem 클래스 선언

class AudioSystem
{
public:
	AudioSystem(class Game* game);
    ~AudioSystem();
    
    bool Initialize();
    void Shutdown();
    void Update(float deltaTime);
private:
	class Game* mGame;
    // FMOD 스튜디오 시스템
    FMOD::Studio::System* mSystem;
    // FMOD 저수준 시스템 (거의 접근할 일은 없음)
    FMOD::System* mLowLevelSystem;
}

위 코드는 AudioSystem의 초기 선언을 보여준다. Initialize, Shutdown, 그리고 Update 함수의 선언은 현시점에서 표준을 지키고있다. 멤버 변수는 저수준 API 시스템뿐만 아니라 FMOD 스튜디어 시스템에 대한 포인터를 포함한다. 대부분의 경우에 mSystem 포인터를 사용하겠지만, 이 포인터 목록에는 mLowLevelSystem도 포함한다. 헤더 파일 fmod_studio.hpp는 FMOD 스튜디오 API 타입을 정의한다 하지만 이 파일을 포함하지 않기 위해 AudioSystem.h에서는 FMOD 타입에 대한 전방 선언을 한다. 이렇게 하면 AudioSystem.cpp에만 FMOD 헤더를 포함하면 된다.

전방 선언

namespace FMOD 
{
	class System;
	namespace Studio 
	{
		class Bank;
		class EventDescription;
		class EventInstance;
		class System;
		class Bus;
	};
};

AudioSystem::Initialize에서 다루는 FMOD 초기화는 몇 가지 단계가 필요하다. 먼저 에러에 대한 로깅을 설정하기 위해 Debug_Initialize 함수를 호출한다.

FMOD::Debug_Initialize(
	FMOD_DEBUG_LEVEL_ERROR, // 에러일 경우에만 로그를 남긴다
	FMOD_DEBUG_MODE_TTY // stdout으로 로그 출력
);

Debug_Initialize의 첫 번째 파라미터는 로깅 메세지의 로깅 레벨 수준을 제어한다. 두번째 파라미터는 로그 메시지를 쓸 위치를 지정한다. 위 코드의 경우 로그 메시지를 stdout에 쓴다. 또한 커스텀 디버그 코드가 있는 게임에서는 FMOD 로그 메시지 출력을 커스텀 콜백 함수에서 처리하도록 선언하는 것도 가능하다. 다음으로 FMOD 스튜디오 시스템의 인스턴스를 생성한다.

FMOD_RESULT result;
result = FMOD::Studio::System::create(&mSystem);
if (result != FMOD_OK) {
	SDL_Log("Failed to create FMOD system: %s", FMOD_ErrorString(result));
	return false;
}

함수는 반환값으로 FMOD_RESLUT를 반환한다. FMOD 함수는 항상 결과값을 반환해서 호출자에게 FMOD 함수 호출 결과를 알려준다. FMOD_ErrorString 함수는 에러 코드를 가독성있는 메시지로 변환한다. AudioSystem::Initialize는 FMOD 시스템 생성에 실패하면 false를 리턴한다. 시스템을 생성한 후 다음 단계는 FMOD 시스템의 initialize 함수를 호출하는 것이다.

result = mSystem->initialize(
	512, 					 // 동시에 출력할 수 있는 사운드의 최대 갯수
	FMOD_STUDIO_INIT_NORMAL, // 기본 설정 사용
	FMOD_INIT_NORMAL,        // 기본 설정 사용
	nullptr 				 // eoqnqns nullptr
);
// result == FMOD_OK 인지 검증...

여기서 첫 번째 파라미터는 최대 채널수를 지정한다. 다음 두 파라미터는 FMOD 스튜디오와 FMOD 저수준 레벨 API의 동작을 조정한다. 지금은 기본 파라미터를 사용한다. 추가적인 드라이버 데이터를 사용하려는 경우 마지막 파라미터를 사용할 수 있지만, 일반적으로는 사용되지 않기떄문에 nullptr로 지정한다. 마지막으로 초기화를 완료한 다음 저수준 시스템 포인터를 얻어서 저장한다.

mSystem->getLowLevelSystem(&mLowLevelSystem);

AudioSystem의 Shutdown과 Update 함수에서는 각각 하나의 함수 호출을 하도록 작성한다. Shutdown 함수는 mSystem->release()를 호출하고 Update 함수는 mSystem->update() 함수를 호출한다. FMOD는 각 프레임마다 한 번씩 update 함수 호출을 요구한다. 이 함수는 3D 오디오 계산 갱신과 같은 작업을 수행한다. 이제 Renderer처럼 Game에 AudioSystem 포인터를 멤버 변수로 추가한다.

AudioSystem* mAudioSystem;

그런 다음 Game::Initialize에서 객체를 생성하고 mAudioSystem->Initialize()를 호출한다. 그리고 UpdateGame에서는 mAudioSystem->Update(deltaTime)을 호출하고 Shutdown에서는 mAudioSystem->Shutdown을 호출한다. Game::GetAudioSystem 함수는 편의를 위해 구현했으며, AudioSystem을 반환한다. 이제 FMOD를 초기화하고 업데이트가 가능해졌다.

뱅크와 이벤트

FMOD 스튜디오에서 이벤트(event)는 게임에서 재생하는 사운드에 해당한다. 이벤트는 여러 개의 관련 사운드 파일과 파라미터, 이벤트 타이밍에 대한 정보 등을 가진다. 게임에서는 사운드 파일을 직접 재생하기보다는 이 이벤트를 통해서 사운드 파일을 재생한다. 뱅크(bank)는 이벤트나 샘플 데이터, 그리고 스트리밍 데이터를 담고있는 컨테이너이다. 샘플 데이터(sample date)는 이벤트가 참조하는 원본 오디오 데이터이다. 이 데이터는 사운드 디자이너가 FMOD 스튜디오로 임포트한 사운드 파일(WAV, OGG)애서 가져온다. 런타임 시 샘플 데이터는 미리 로드되거나 필요에 따라 로드된다. 그래서 이벤트는 관련 샘플 데이터가 메모리에 존재하기 전까지는 재생할 수 없다. 스트리밍 데이터는 한 번에 작은 크기로 메모리를 스트림되는 샘플 데이터다. 스트리밍 데이터(streaming data)를 사용하는 이벤트는 데이터를 미리 로드할 필요없이 사운드 재생이 가능하다. 일반적으로 음악 및 대화 파일이 스트리밍 데이터로 사용된다. 사운드 디자이너는 FMOD 스튜디오에서 한 개 이상의 뱅크를 생성해야한다. 그리고 게임은 이 뱅크들을 런타임 시 로드한다. 게임이 뱅크를 로드하고 나면 이 뱅크 내부에 포함된 이벤트의 접근이 가능해진다. FMOD는 이벤트와 관련된 2개의 클래스를 가진다. EventDescription 클래스는 샘플 데이터, 볼륨 설정, 파라미터 등 이벤트와 관련된 정보를 포함한다. EventInstance 클래스는 이벤트의 활성화된 인스턴스다. 그리고 이벤트를 재생한다. 다시 말해서 EventDescription은 이벤트의 타입과 같으며 EventInstance는 타입의 인스턴스라고 할 수 있겠다. 예를 들어 폭발 이벤트가 있다면 전역적으로 하나의 EventDescription을 가질 것이다. 하지만 활성화된 폭발 인스턴스의 수에 따라 EventInstance는 여러 개가 된다. 로드된 뱅크와 이벤트를 사용하기 위해 AudioSystem의 private 데이터에 2개의 맵을 추가한다.

// 로드된 뱅크를 관리하는 맵
std::unordered_map<std::string, FMOD::Studio::Bank*> mBanks;
// 이벤트 이름과 EventDescription 맵
std::unordered_map<std::string, FMOD::Studio::EventDescription*> mEvents;

두 맵에는 키값으로 문자열을 가진다. mBanks에서 문자열은 뱅크의 파일 이름이다. 그리고 mEvents에서 문자열은 이벤트를 위해 FMOD가 할당한 이름이다. FMOD 이벤트는 경로 형식으로 이름을 가진다. 예를 들면 event:/Explosion2D와 같은 형식을 갖는다.

뱅크 로딩과 언로딩
뱅크를 로딩하려면 mSystem 객체에서 loadBankFile 함수를 호출해야한다. 그러나 이 함수를 호출한다고 해서 샘플 데이터를 로드하는 것은 아니고 이벤트와 관련된 부분에 쉽게 접근할 수 있는 것도 아니다.

void AudioSystem::LoadBank(const std::string& name) {
	// 두 번 로딩되지 않게 한다
	if (mBanks.find(name) != mBanks.end()) {
		return;
	}

	// 뱅크 로드
	FMOD::Studio::Bank* bank = nullptr;
	FMOD_RESULT result = mSystem->loadBankFile(
		name.c_str(), // 뱅크 파일의 이름
		FMOD_STUDIO_LOAD_BANK_NORMAL, // 일반적인 방식으로 로딩
		&bank // 뱅크 포인터 저장
	);

	const int maxPathLength = 512;
	if (result == FMOD_OK) {
		// 뱅크를 맵에 추가
		mBanks.emplace(name, bank);
		// 스트리밍 형식이 아닌 모든 샘플 데이터를 로드
		bank->loadSampleData();
		// 뱅크의 이벤트 갯수를 얻는다
		int numEvents = 0;
		bank->getEventCount(&numEvents);
		if (numEvents > 0) {
			// 뱅크에서 이벤트 디스크립션 리스트를 얻는다.
			std::vector<FMOD::Studio::EventDescription*> events(numEvents);
			bank->getEventList(events.data(), numEvents, &numEvents);
			char eventName[maxPathLength];
			for (int i = 0; i < numEvents; i++) {
				FMOD::Studio::EventDescription* e = events[i];
				// event:/Explosion2D 같은 이벤트의 경로를 얻는다
				e->getPath(eventName, maxPathLength, nullptr);
				// 이벤트를 맵에 추가한다
				mEvents.emplace(eventName, e);
			}
		}
	}
}

위 코드처럼 loadBankFile 함수 하나만을 호출하기보다는 좀 더 많은 일을 하는 LoadBank라는 새로운 함수를 AudioSystem에 작성해두면 좋다. 뱅크가 로드되면 mBanks 맵으로 뱅크를 추가한다. 그런 다음 뱅크에 대한 새플 데이터를 로드한다. 그리고 getEventCount와 getEventList를 사용해서 뱅크에 있는 모든 이벤트 디스크립션에 관한 리스트를 얻고 이 이벤트들에 쉽게 접근할 수 있도록 mEvents 맵에 추가한다. 비슷하게 AudioSystem::UnloadBank 함수를 구현한다. 이 함수는 처음에 mEevents 뱅크에서 뱅크의 모든 이벤트를 제거한다. 그리고 뱅크를 언로드하고 mBanks 맵에서 뱅크를 제거한다. 손쉬운 정리를 위해서 AudioSystem::UnloadAllBanks 함수를 작성한다. 이 함수는 모든 뱅크를 언로드하며 mEvents와 mBanks를 정리한다. 모든 FMOD 스튜디오 프로젝트는 "Master Bank.bank"와 "Master Bank.strings.bank"라는 2개의 기본 뱅키 파일을 가진다. FMOD 스튜디오 라이브러리는 런타임 시에 두 마스터 뱅크를 먼저 로드하지 않았다면 다른 뱅크나 이벤트에 액세스할 수 없다. 마스터 뱅크는 항상 존재하므로 다음 코드처럼 AudioSystem::Initialize에서 두 마스터 뱅크를 로드한다.

// 마스터 뱅크 로드
LoadBank("Assets/Master Bank.strings.bank");
LoadBank("Assets/Master Bank.bank");

마스터 뱅크를 로드할 때는 마스터 스트링 뱅크를 먼저 로드한다. 마스터 스트링 뱅크는 FMOD 스튜디오 프로젝트에서 작성한 모든 이벤트와 다른 데이터를 사람이 읽을 수 있는이름을 포함한 특별한 뱅크다. 이 뱅크를 로드하지 않으면 코드상에서 이름으로 뱅크나 이벤터에 접근하는 것이 불가능하다. 이름을 사용하지 못하면 코드에서는 GUID(전역적으로 고유한 ID)를 사용해서 모든 FMOD 스튜디오 데이터에 접근해야하기 때문에 마스터 스트링 뱅크 로딩은 선택 사항이지만 스트링을 로드해두면 AudioSystem의 손쉬운 구현이 가능하므로 로드하는 것이 좋다.

이벤트 인스턴스를 생성하고 재생하기
FMOD EventDescription이 주어지면 createInstance 멤버 함수를 사용해서 해당 이벤트에 대한 FMOD EventInstance를 생성한다. EventInstance가 생성됐으면 인스턴스의 start 함수로 이벤트 재생을 시작한다. AudioSystem의 PlayEvent 함수의 최초 구현은 다음과 같다.

void AudioSystem::PlayEvent(const std::string& name) {
	// 이벤트가 존재하는지 확인
	auto iter = mEvents.find(name);
	if (iter != mEvents.end()) {
		// 이벤트 인스턴스를 생성한다
		FMOD::Studio::EventInstance* event = nullptr;
		iter->second->createInstance(&event);
		if (event) {
			// 이벤트 인스턴스를 시작한다
			event->start();
			// release는 이벤트 인스턴스가 정지할 때 이벤트 소멸자 실행을 예약한다
            // 반복되지 않는 이벤트는 자동으로 정지한다
            event->release();
		}
	}
}

위 PlayEvent는 사용하기 간단하지만 FMOD의 기능을 드러내지 못하는게 문제다. 예를 들어 이벤트가 반복되는 이벤트인 경우 이벤트를 정지시킬 방법이 없다. 또한 이벤트 파라미터를 설정할 방법이 없으며, 이벤트 볼륨을 변경하는 방법 등도 존재하지 않는다. PlayEvent로부터 직접 EventInstance 인스턴스를 반환하고 싶을 수도 있다. 그러면 호출자는 모든 FMOD 멤버 함수 접근할 수 있다. 하지만 이 방법은 이상적이지 못하다. 왜냐하면 FMOD API를 오디오 시스템 외부에 노풀하기 떄문이다. 이런 상황은 사운드를 간단히 재생하거나 정지하길 원하는 프로그래머가 FMOD API에 대해 알아야한다는 걸 뜻한다. 또한 원본 포인터 노풀은 FMOD의 이벤트 인스턴스에 대한 메모리 정리 방법 때문에 위험할 수 있다. FMOD는 이벤트의 release 함수가 호출되면 이벤트가 멈춘 후에 메모리에서 이벤트를 해제한다. 그리고 호출자가 EventInstance 포인터에 접근 가능하고 이벤트가 해체된 뒤 EventInstnace를 역참조하는 상황이 발생하면 메모리 접근 위반의 원인이 된다. release 호출을 건너뛰는 것도 좋은 생각이 아니다. 왜냐하면 시간이 지남에 따라 메모리 누수가 발생하기 때문이다. 따라서 다른 해결책이 필요하다.

SoundEvent 클래스

PlayEvent에서 직접 EventInstance 포인터를 반환받아 사용하는 대신 개발자가 정수 ID를 통해서 각 활성화된 이벤트 인스턴스에 접근하게 한다. 그리고 활성화된 이벤트를 다루기 위해 SoundEvent라는 새 클래스를 작성한다. 이 클래스는 정수 ID를 사용해서 이벤트를 참조한다. PlayEvent에서는 SoundEvent의인스턴스를 반환한다. 모든 인스턴스를 관리하기 위해 AudioSystem은 부호 없는 정수형과 이벤트 인스턴스에 대한 새로운 맵이 필요하다.

std::unordered_map(unsigned int, FMOD::Studio::EventInstance*> mEventInstance;

또한 0으로 초기화된 sNextID 정적 변수도 추가한다. PlayEvent가 이벤트 인스턴스를 생성할 때마다 sNextID값을 증가시키고 이벤트 인스턴스를 새로운 ID와 함께 맵에 추가한다.

위 코드처럼 특정 이벤트 인스턴스와 연관된 ID를 가진 SoundEvent 객체를 리턴한다. sNextID가 unsigned int 타입이므로 ID는 PlayEvent를 40억 회 이상 호출한 다음 처름부터 반복한다. 이 부분은 항상 염두에 둬야 할 사항이다. 바뀐 PlayEvent 함수에서는 더이상 release를 호출하지 않는다. 대신에 AudioSystem::Update가 더이상 필요없는 이벤트 인스턴스를 정리한다. 프레임마다 Update는 getPlayBackState를 사용해서 맵의 각 이벤트 인스턴스 재생 상태를 검사한다. 그런 다음 Update는 정지 상태에 있는 이벤트 인스턴스만을 해제하고 맵에서 이벤트를 제거한다. 이렇게 구현하면 이벤트 정지와 역참조의 타이밍 문제가 발생하지 않음을 보장해준다. 호출자가 이벤트를 계속 유지하길 원한다면 이벤트를 정지시키는 대신에 일시 중단 상태로 두면된다.

다음으로 AudioSystem에 ID를 파라미터로 받는 GetEventInstance 헬퍼 함수를 추가한다. ID가 맵에 존재한다면 이 함수는 해당 EventInstance 포인터를 반환한다. 그렇지 않으면 GetEventInstance는 nullptr을 반환한다. 여러 클래스가 이벤트 인스턴스에 접근하는 걸 막기 위해 GetEventInstance 함수는 AudioSystem의 protected 영역에 선언하지만 SoundEvent는 이 함수로의 접근이 필요하므로 SoundEvent는 AudioSystem의 friend로 선언한다.

위 코드는 SoundEvent의 선언을 보여준다. 멤버 데이터에는 AudioSystem 포인터와 ID가 있다. 기본 생성자는 public인 반면 파라미터를 같는 생성자는 protected다. AudioSystem이 SoundEvent의 friend이므로 오직 AudioSystem만이 이 생성자에 접근할 수 있으며, 이는 AudioSystem만이 SoundEvents에 ID를 할당할 수 있음을 뜻한다. SoundEvent의 함수 나머지 부분은 사운드 이벤트를 일시 중단하거나 볼륨 변겨으 이벤트 파라미터 설정과 같은 다양한 이벤트 인스턴스 기능들에 대한 wrapper다. 대부분의 SoundEvent 멤버 함수 구현부는 구문이 매우 유사하다. 멤버 함수들은 GetEventInstance를 호출해서 EventInstance 포인터를 얻는다. 그런 다음 EventInstance의 일부 함수를 호출한다. 예를 들어 SoundEvent::SetPaused의 구현은 아래 코드와 같다.

코드는 mSystem과 event 포인터가 null이 아닌지를 확인한다. 이렇게 하면 ID가 맵에 존재하지 않아도 함수는 크래시(crash)되지 않을 것이다. 비슷하게 SoundEvent::IsValid 함수는 mSystem이 null이 아니고 ID가 AudioSystem 이벤트 인스턴스 맵에 있는 경우에만 true를 반환한다. 이제 지금까지 구축한 코드를 사용하면 재생을 시작한 후 이벤트를 제어하는 것이 가능하다. 예를 들어 다음 코드는 Music이라는 이벤트 재생을 시작하고 mMusicEvent에 SoundEvent를 저장한다.

mMusicEvent = mAudioSystem->PlayEvent("event:/Music");

다른 곳에서는 Music 이벤트를 다음과 같이 일시 중단 상태로 전환할 수 있다.

mMusicEvent.SetPaused(!mMusicEvent.GetPaused());

SoundEvnet의 추가로 이제 2D 오디오에 대한 FMOD의 꽤 괜찮은 통합 버전을 가지게 되었다.

3D 위치 기반 오디오

3D 게임에서 대부분의 사운드 효과음은 위치(position)에 기반한다. 이는 벽난로와 같은 게임 세계에 있는 오브젝트가 소리를 낸다는 것을 뜻한다. 게임에는 이 소리를 듣는 리스너(listener)가 가상 마이크로폰으로 존재한다. 예를들어 리스너가 벽난로를 향하고 있다면 벽난로가 앞에 있는 것처럼 소리가 들려야한다. 리스너가 벽난로를 등졌을 경우 벽난로는 리스너 뒤편에서 소리를 내야한다. 또한 위치 기반 오디오는 리스너가 사운드로부터 더 멀리 떨어짐에 따라 사운드의 불륨이 감소하거나 줄어들게 한다. 감쇄 함수(falloff function)는 사운드의 볼륨이 리스너가 어떻게 감소하는지를 설명한다. FMOD 스튜디오 3D 사운드 이벤트는 사용자가 설정할 수 있는 감쇄 함수를 가진다. 위치 기반 오디오의 효과는 출력 장치 3개 이상 구성된 서라운드 사운드 시스템에서 그 효과가 여실이 드러난다. 예를 들어 일반적인 5.1채널 구성은 저주차 음향을 위한 서브우퍼(또는 LFE)와 정면-왼쪽, 정면-중심, 정면-오른쪽, 후면-왼쪽, 후면-오른쪽 스피커로 구축된다.

게임상의 벽난로 예에서 플레이어가 화면상의 벽난로로 향한다면 플레이어는 정면 스피커부터 소리가 나오는 걸 기대할 것이다. 다행스럽게도 FMOD는 위치 기반 오디오 지원을 내장하고 있다. 이 기능을 게임에 통합하려면 리스너와 모든 활성화된 3D 이벤트 인스턴스에 대한 위치와 방향 데이터를 제공해야한다. 이를 위해서는 3가지 작업이 필요하다.

  • 리스너를 설정

  • SoundEvent에 위치 기반 기능 추가

  • 액터와 사운드 이벤트를 연관시키는 AudioComponent 제작

    기본 리스너 설정

기본 리스너를 설정하는 일반적인 방법은 카메라를 리스너로 사용하는 것이다. 이 경우에 리스너의 위치는 세계에서 카메라의 위치이며, 리스너의 방향은 카메라의 방향이다. 이러한 접근법은 1인칭 카메라 시점의 게임에서 잘 동작한다. 하지만 3인칭 카메라는 고려해야할 추가적인 이슈가 있다. 3D 위치 기반 오디오 라이브러리(FMOD뿐만 아니라)를 사용할 때 주의해야할 점은 라이브러리가 게임과는 다른 좌표시스템을 사용한다는 것이다. 예를 들어 FMOD는 +z가 전방이고 +x가 오른쪽 그리고 +y가 위쪽인 왼손 좌표계를 사용한다. 그러나 게임은 +x가 전방, +y가 오른쪽그래서 게임에서 FMOD로 좌표와 방향을 넘길 때 좌표를 반환해야한다. 이 작업은 Vector3와 FMOD의 벡터 타입 간 변환을 할 때 일부 요소의 교환을 필요로 한다. 이를 돕기 위해 VecToFMOD 헬퍼 함수를 추가한다.

다음으로 AudioSystem에 SetListener라는 함수를 추가한다.

이 함수는 뷰 행렬을 인자로 받아서 뷰 행렬로부터 리스너의 위치와 전방 벡터 그리고 상향 벡터를 구해서 FMOD에 전달한다. 뷰 행렬을 인자로 받기 때문에 렌더러에 뷰행렬을 설정하는 코드 부분에서 SetListener를 호출한다. 그런데 뷰 행렬은 세계 공간을 뷰 공간으로 변환하는 행렬이고 우리가 필요로 하는 것은 카메라의 세계 공간상의 정보다. 그래서 뷰 행렬로부터 리스너의 세계 공간 정보를 얻기 위해 추가 작업을 한다. 먼저 뷰 행렬의 역행렬을 구한다. 이 뷰행렬의 역행렬에서 네 번째 행의 처음 세 요소는 카메라의 세계 공간 위치에 해당한다. 세번째 행의 처음 세 요소는 전방 벡터이며 두 번째 행의 처음 세 요소는 상향 벡터에 해당한다. 이렇게 얻은 3개의 벡터 모두에 FMOD 좌표 체계로 변환시키는 VecToFMOD 헬퍼 함수를 적용한다. SetListener는 현재 FMOD_3D_ATTRIBUTES의 속도 파라미터를 모두 0으로 설정한다. 이것은 사운드 이펙트의 도플러 효과를 활성화 할 때만 의미를 가진다.

SoundEvent에 위치 기반 기능 추가

각 EventInstance는 자신의 세계 위치와 방향을 나타내는 3D 속성을 가진다. 이 3D 속성을 다룰 수 있도록 SoundEvent 클래스에 새로운 함수 Is3D와 Set3DAttirbutes 함수를 추가한다. FMOD 스튜디오에서 사운드 이벤트를 생성할 때 이벤트는 2D이거나 3D일 수 있다. Is3D 함수는 이벤트가 3D이면 true를 반환한다. 그렇지 않으면 false를 반환한다. Set3DAttributes 함수는 세계 변환 행렬을 인자로 받아 행렬을 FMOD의 3D 속성으로 변환한다. 이 함수를 사용하면 액터의 세계 변환 행렬을 전달받아 이벤트의 위치와 방향을 갱신하는 것이 간단해진다. 이 함수는 행렬의 역행렬을 구하는 것이 필요치 않다. 왜냐하면 행렬이 세계 변환 행렬이기 때문이다. 그러나 여전히 게임과 FMOD 간 좌표 체계 변환은 필요하다.

액터와 사운드 이벤트를 연관시키는 AudioComponent 작성

AudioComponent 클래스가 제 기능을 수행하려면 사운드 이벤트와 특정 액터와 연관시켜야한다. 액터가 움직이면 AudioComponent는 관련 이벤트의 3D 속성을 갱신한다. 또한 액터가 죽으면 액터와 관련된 사운드 이벤트를 중단해야한다.

AudioComponent::PlayEvent 함수는 먼저 AudioSystem의 PlayEvent 함수를 호출한다. 그런 다음 이벤트가 3D인지 아닌지 여부를 확인해서 두 벡터 중 어디에 SoundEvent를 저장할지를 결정한다. 마지막으로 이벤트가 3D이면 SoundEvent의 Set3DAttributes 함수를 호출한다.

AudioComponent::Upate 함수는 더 이상 유효하지 않은 mEvents2D나 mEvents3D의 모든 이벤트를 제거한다(IsValid가 false를 반환). 다음으로 OnUpdateWorldTransform 함수를 오버라이드한다. 소유자 액터는 세계 변환 행렬을 계산할 때마다 OnUpdateWorldTransform 함수를 호출해서 각 컴포넌트에 호출한다. AudioComponent에서는 세계 변환이 변경될 때마다 mEvents3D에 있는 모든 3D 이벤트의 3D 속성을 갱신해야한다.

마지막으로 AudioComponent::StopAllEvents는 두 벡터(m2DEvents, m3DEvents)에 있는 모든 이벤트의 중단을 호출하고 벡터를 클리어한다. AudioComponent의 소멸자가 이 함수를 호출하지만 게임이 액터의 사운드 이벤트를 중단하고자 하는 상황에서도 이 함수가 호출될 수 있다. 지금까지의 구현을 통해 이제 AudioComponent를 액터에 붙여서 사운드 이벤트를 재생하는 것이 가능해졌다. AudioComponent는 상황에 따라 관련 이벤트의 3D 속성을 자동으로 갱신한다.

3인칭 게임상의 리스너

카메라 위치와 방향을 직접 사용하는 리스너는 카메라가 플레이어의 캐릭터의 시점에 있는 1인칭 게임에서는 잘 동작한다. 하지만 카메라가 플레이어 캐릭터를 따라가는 3인칭 게임에서는 그렇게 간단하지 않다.

위 그림은 3인칭 게임을 측면에서 바라본 것이다. 플레이어 캐릭터는 위치 P에 있고 카메라는 위치 C에 있다. 위치 A는 플레이어 캐릭터 바로 옆에 있는 사운드 이펙트를 나타낸다. 위치 B는 카메라에 가까이 있는 사운드 이펙트다. 이제 리스너가 카메라 위치와 방향을 사용한다고 하면 사운드 A와 B 모두 앞에서 들리는 것처럼 느껴질 것이다. 이 상황은 사운드 이펙트가 화면상에서 보이므로 괜찮다.그래서 플레이어는 사운드를 앞에서 인식해도 문제는 없다. 하지만 사운드 B는 사운드 A보다 더 크게 소리가 날 것이다. 상식적으로 플레이어 바로 옆에있는 소리가 더 크게 들려야 하므로 이 상황은 문제가 있다. 그리고 사운드 B가 없더라도 플레이어 바로 옆에 있는 사운드는 감쇄가 작용되 소리가 거의 들리지 않을 수도 있따. 카메라의 위치가 방향대신에 플레이어의 위치와 방향을 사용하면 사운드 A는 사운드 B보다 소리가 커진다. 그러나 사운드 B는 플레이어 뒤쪽에 있으므로 뒤에서 소리가 난다. 하지만 사운드 B는 화면상에 보이므로 플레이어 뒤에서 소리가 들리는 것은 어색하다. 사운드 B도 플레이어 앞에서 소리가 나는 것처럼 처리되어야한다. 즉 우리가 기대하는 것은 플레이어 위치를 기반으로 한 감쇄지만 방향에 대해서는 카메라 기반인 것이다. 가이 솜버그(Guy Sombrrg)는 이 문제를 해결하기 위한 솔루션을 제시했다. 플레이어 위치를 P, 카메라 위치를 C, 사운드 위치를 S라고 할 때 먼저 두 벡터를 계산한다. 한 벡터는 카메라에서 사운드까지의 벡터이고, 또 다른 벡터는 플레이어에서 사운드로의 벡터이다.

PlayerToSound=SPPlayerToSound=S-P
CameraToSound=SCCameraToSound=S-C

PlayerToSoundPlayerToSound 벡터의 길이는 감쇄 거리다. 정규화된 CameraToSoundCameraToSound 벡터는 사운드의 방향이다. 정규화된 CameraToSoundCameraToSound 벡터에 PlayerToSoundPlayerToSound의 길이를 곱하면 사운드의 가상 위치를 얻는다.

VirtualPos=PlayerToSoundCameraToSoundCameraToSoundVirtualPos=\lvert\lvert PlayerToSound\lvert\rvert\frac{CameraToSound}{\lvert\lvert CameraToSound\lvert\rvert}


위 그림에서의 가상 위치는 사운드의 올바른 방향과 감쇄를 보여준다. 리스너 그 자체는 이전처럼 카메라를 직접 사용한다. 하지만 이 접근법은 사운드의 세계 위치가 차폐와 같은 요인이 적용된다면 성립되지 않을 수도 있다.

도플러 효과

길모퉁이에 서 있다고 했을때 경찰차가 다가올때는 사이렌 소리의 피치가 증가한다. 거꾸로 경찰차가 지나가면 소리의 피치는 감소한다. 이 상황은 실제 도플러 효과의 한 예이다.

도플러 효과(Doffler effect)는 음파가 대기를 통과하는데 시간이 걸리기 때문에 발생한다. 경찰차가 가까워질수록 각 음파는 가까이서 시작하고 이는 파동이 더 빨리 도착하다는 것을 뜻한다. 이로 인해 주파수가 증가해 피치가 높아진다. 자동차가 리스너 바로 옆에 있을때는 소리의 원래 피치가 들린다. 마지막으로 자동차가 리스너를 지나갈때는 반대의 효과가 일어난다. 음파는 더 먼곳에서 도착하므로 피치가 낮아진다. 도플러 효과는 모든 유형의 파동에 적용되지만 음파에서 그 효과를 쉽게 관찰할 수 있다. 게임에서 도플러 효과는 차량과 같은 오브젝트에 사실적인 효과음을 만들어낸다. FMOD는 자동적으로 도플러 이동에 따른 피치를 계산한다. FMOD에 setListenerAttributes와 set3DAttributes에 올바른 속도로 넘겨주기만 하면 된다. 올바른 속도를 구하려면 힘을 활용한 올바른 물리 기반 이동 접근이 필요하다. 또한 저수준 API를 통해 접근할 수 있는 추가 도플러 매개변수가 있다. set3DSettings 함수는 다음과 같은 파라미터를 설정할 수 있다.

mLowLevelSystem->set3Dsettings(
	1.0f,	// 도플러 스케일 1 = 정상, 1보다 더 크면 과장된 소리를 낸다
    50.0f	// 게임 단위의 크기 = 1미터 
	1.0f 	// 도플러와 관계 없음 1로 남겨 둔다.
);

믹싱 및 이펙트

디지털화된 사운드의 이점 중 하나는 재생하는 동안 조작이 쉽다는 것이다. 리스너의 상대적인 사운드 위치를 기반으로 해서 우리는 이미 사운드를 조작했었다. 디지털 신호 처리(DSP, Digital Signal Processing) 용어는 신호에 대한 연산 조작을 뜻한다. 오디오에서 볼륨을 조정하거나 신호의 피치를 조정하는 것은 DSP의 한 예이다. 게임에서 2가지 일반적은 DSP는 리버브(reverb)와 이퀄라이제이션(equalization)이다. 리버브(반향)는 밀폐된 구역에서 소리가 울리는 것을 시뮬레이션한다. 예를 들어 동굴 내부에서의 사운드 효과음은 파형이 벽에서 반사되므로 반향음을 가진다. 한편으로 이퀄라이제이션은 사운드 볼륨 레벨을 설정된 범위로 표준화시킨다. FMOD 스튜디오는 DSP 이펙트 체인 설정을 가능하게 해준다. 사운드는 출력에 앞서 신호를 수정하는 여러 단계를 거친다. 각 사운드 이펙트는 자신만의 DSP 체인을 가져도 되겠지만 일반적으로 여러 사운드를 유형별로 그룹화한다. 그러고 난 뒤 각 사운드 그룹에 개별적인 DSP 이펙트 체인을 설정해서 사운드 효과를 적용한다.

버스

FMOD 스튜디오에서 버스(bus)는 사운드의 그룹화를 뜻한다. 예를들어 개발자는 사운드 이펙트에 대한 버스, 음악에 대한 버스, 대화에 대한 버스를 가질 수 있따. 각각의 버스는 자신이 부착된 다양한 DSP 효과를 개별적으로 가진다. 그리고 실행 시에 버스를 조정한느 것이 가능하다. 예를 들어 많은 게임에서는 여러 카테고리의 사운드에 별도의 볼륨 슬라이더를 제공한다. 이 기능은 버스를 사용하면 구현하기가 쉽다. 기본적으로 모든 프로젝트는 루트 경로가 bus:/.인 마스터 버스를 가진다. 하지만 사운드 디자이너는 원하는 수만큼 버스를 추가할 수 있따. 뱅크 로드 시 이벤트 디스크립션을 로드하는 것 처럼 버스도 동시에 로드한다. 먼저 AudioSystem에 버스 맵을 추가한다.

std::unordered_map<std::string, FMOD::Studio::Bus*> mBuses;

그런 다음 다음 뱅크를 로드할 때 mBuses에 추가할 버스 리스트를 얻기위해 벙크의 getBusCount와 getBusList를 호출한다. 다음으로 버스를 제어하기 위해 AudioSystem에 다음과 같은 함수를 추가한다.

float GetBusVolume(const std::string& name) const;
bool GetBusPaused(const std::string& name) const;
void SetBusVolume(const std::string& name, float volume);
void SetBusPaused(const std::string& name, bool pause);

이번 게임에서는 마스터, SFX(Sound Effect), 음악의 3가지 버스가 있는데 발소리, 벽난로 루프 및 폭발음을 포함한 사운드 이펙트는 SFX 버스를 통과하고 배경 음악은 음악 버스를 통과한다.

스냅샷

FMOD에서 스냅샷(snapshot)은 버스를 제어하는 특별한 유형의 이벤트다. 스냅샷도 이벤트이므로 같은 이벤트 인터페이스를 사용하며, 기존의 PlayEvent 함수에서도 스냅샷이 잘 작동한다. 유일한 차이는 스냅샷의 경로가 event:/. 대신 snapshot:/로 시작한다는 것이다. 이번 게임에서 스냅샷을 활용해서 SFX 버스의 리버브를 활성화한다.

차폐

작은 아파트에 살고 있는데 옆집에서 파티가 열렸다. 파티 음악은 시끄럽고 벽을 통해 들려오는데 그 노래 소리는 벽을 통해 들릴 때는 저음이 주를 이루며 고주파 영역의 소리는 듣기 어렵다.

위 그림의 (a)처럼 이런 현상을 차폐 현상(sound occlusion)이라고 한다. 소리 차폐는 사운드가 소리 진원지에서 리스너로의 직접적인 경로가 없을때 발생한다. 대신 소리는 리스너에 도달하기 위해 일부 물질을 통과해야 한다. 소리 차폐의 뚜렷한 결과는 고주파 사운드의 볼륨을 줄이는 로우 패스 필터(low pass filter)다. 차폐를 구현하려면 2가지의 별도의 작업이 필요하다.

  • 차폐의 감지
  • 차폐가 적용된 사운드 수정

차폐의 감지에 대한 한가지 방법은 위 그림의 (b)처럼 소리 진원지와 리스너 주변 원호 사이에 선분을 그리는 것이다. 모든 선분이 물치에 부딪힘 없이 리스너에 도달할 수 있다면 차폐가 없다. 이런 형태 감지는 충돌 계산을 요구한다. FMOD에서 차폐를 가진 사운드를 수정하는 것은 간단하다. 하지만 저수준 API를 호출해야한다. 우선 FMOD를 초기화 할때 소프트웨어 로우 패스 필터링을 활성화한다.

result = mSystem->initialize(
	512,
    FMOD_STUDIO_INIT_NORMAL,
    FMOD_INIT_CHANNEL_LOWPASS,
    nullptr,
);

다음으로 차폐에 영향을 받는 각 이벤트 인스턴스에 차폐 파라미터를 설정한다. 예를 들어 다음 코드는 이벤트에 차폐를 활성화한다.

// 채널 그룹을 사용할 수 있도록 커맨드를 비운다
mSystem->flushCommands();
// 이벤트로부터 채널을 얻는다
FMOD::ChannelGroup* cg = nullptr;
event->getChannelGroup(&cg);
// 차폐 인자를 설정한다 - occFactor는 0.0(차폐 없음)에서 1.0 (완전한 차폐)값을 가진다
cg->set3DOcclusion(occFactor, occFactor)

0개의 댓글