이 글은 조만간 출판될 Game Programming Gems 2 한국어판(류광 역, 정보문화사)의 일부입니다. 이 글에 대한 모든 권리는 정보문화사가 가지고 있으며, 사전 허락 없이는 웹 사이트 게시를 비롯한 어떠한 형태의 재배포도 금지되어 있습니다.

이 글은 최종 교정 전의 상태이므로 오타나 오역이 있을 수 있습니다. 또한 웹 페이지의 한계 상, 실제로 종이에 인쇄된 형태와는 다를 수 있습니다.

Game Programming Gem 2 한국어판에 대한 정보는 GPG 스터디(http://www.gpgstudy.com/)에서 얻을 수 있습니다.

6.1 게임 오디오 설계 패턴

Scott Patterson
scottp(at)tonebyte.com

설계 패턴(design pattern)이라는 개념은 최근 수년간 대단히 대중화되었다. 사실 설계 패턴은 훨씬 전부터 쓰여왔던 것이며, 최근에 이르러서 좀 더 잘 식별, 분류되고 있는 중이다. 객체 지향적 프로그래밍 언어들이 설계 패턴의 구현과 연관되어 있는 것은 당연하나, 객체 지향적이라고 분류되지 않는 프로그래밍 언어들 중에도 설계 패턴의 구현을 제공하는 것들이 있다. 설계 패턴은 코드 시스템을 구축할 때 어떠한 착상이나 영감을 얻기 위한 출처로 쓰이기도 한다. 이 글에서는 오디오 인터페이스 설계에서 설계 패턴이 어떤 아이디어를 제공할 수 있는지 살펴보겠다. 이 글은 게임 프로그래머들의 요구에 의해 오디오 인터페이스를 설계하는 개발자를 중심에 둔 것이다. 그러한 개발자는 프로그래머들에게 편리하고, 유연하고, 강력한 오디오 인터페이스를 제공해야 한다. 이후의 글에서 '사용자' 또는 'API 사용자'는 오디오 인터페이스를 사용하는 프로그래머들을 의미한다. 그럼 몇 가지 설계 패턴들을 간단히 요약하고, 그 패턴들이 오디오 인터페이스 설계에 어떻게 적용될 수 있는지 살펴보자.

(역주: 이 글에 나오는 한글화된 패턴 이름들은 일종의 제안일 뿐이다. GpGiki 의 "패턴 이름 한글화" 페이지에서 설계 패턴들의 이름을 한글화하는 작업이 진행되고 있다).

가교(Bridge)

"추상을 그의 구현으로부터 분리(decouple)해서 그 둘이 독립적으로 변경될 수 있게 한다".

사운드 식별자

추상(abstraction, 抽象)을 그의 구현(implementation, 具現)으로부터 분리하는 한 가지 효과적인 방법은, 클래스 포인터나 참조(reference) 같은 것들 대신 식별자(identifier)를 넘겨주는 것이다. 식별자는 숫자일 수도 있고 문자열일 수도 있다. 오디오의 경우 식별자는 개별 사운드에 대한 식별자를 의미한다. 예를 들어, 사운드 재생을 시작하거나 중지할 때, 사운드 데이터에 대한 메모리를 넘겨주는 대신 그 데이터를 가리키는 식별자를 사운드 시스템에 넘겨주면, 사운드 시스템이 사운드를 실제로 재생, 중지하는 구체적인 세부 사항이 API 사용자(프로그래머)와 완전히 분리된다.


void StartSound( int nSoundId );
void StopSound( int nSoundId );

그리고 사운드의 로드나 접근에도 이러한 식별자를 적용할 수 있다. 예를 들면 특정 사운드를 로드하거나 언로드하는 함수들은 다음과 같은 모습이 될 것이다.


void LoadSound( int nSoundId );
void UnloadSound( int nSoundId );

사운드들의 집합 역시 식별자를 사용할 수 있다. 다음은 일단의 사운드들을 한 번에 로드하거나 언로드하는 함수들의 예이다.


void LoadSoundCollection( int nSoundCollectionId );
void UnloadSoundCollection( int nSoundCollectionId );

주어진 사운드가 로드되었는지를 알려주는 것도 유용한 기능이다.


bool IsSoundLoaded( int nSoundId );

사용자가 아직 로드되어 있지 않은 사운드를 재생하려 했다면, 아무 소리도 내지 않거나, 또는 에러 사운드를 재생할 수도 있다.

퍼사드(facade)

"한 서브시스템 안의 일단의 인터페이스들에 대한 하나의 통합된 인터페이스를 제공한다. 퍼사드는 서브시스템을 좀 더 사용하기 쉽게 만드는 고수준 인터페이스를 정의한다".

서브시스템의 제어

게임 프로그래머들이 사용할 오디오 API를 작성하는 경우, 목표는 오디오 시스템의 모든 지루한 복잡성을 숨기고 게임의 모든 부분들의 요구를 만족시키는 것이다. 이러한 목표를 가진 API를 작성하는 것은 퍼사드 설계 패턴을 가진 클래스를 설계하는 것와 매우 비슷하다. 오디오 시스템의 복잡성은 게임 코드의 복잡성으로부터 은폐되며, 오디오 API는 두 시스템들 사이의 연결을 제공한다.

사운드를 재생하기 위한 방법은 여러 가지일 수 있으나, 어떤 방법이든 동일한 인터페이스를 통해서 제어할 수 있게 해야 오디오 서브시스템의 사용이 쉬워진다. 다음은 마스터 볼륨을 설정하거나 조회하는 단순한 두 개의 함수들이다. 사용자의 입장에서는 한 번의 함수 호출이지만, 실제로는 이 함수들이 내부적으로 하나 이상의 오디오 서브시스템들을 갱신할 수도 있다.


float SetMasterVolume( float fVolume );
float GetMasterVolume();

마스터 볼륨이 변하면 하드웨어 신디사이저, 소프트웨어 신디사이저, 스트리밍 관련 시스템 등이 모두 갱신되어야 하나, 사용자의 입장에서는 그냥 한 번의 호출로 끝난다. 즉 모든 복잡성이 은폐되는 것이다. 현재 재생중인 모든 사운드들을 중지시키는 함수 역시 퍼사드 패턴의 예가 될 것이다.


void StopAllSounds();

조합(composite)

"객체들을 트리 구조로 조합해서 부분-전체 계통구조를 만든다. 조합 패턴은 사용자가 개별 객체들과 그 객체들의 조합을 일관적인 방식으로 다룰 수 있게 한다".

엔진 제어

자동차 엔진 같은 것은 여러 가지 사운드들로 조합된다. 자동차 엔진은 부르릉, 윙윙, 덜커덕 등등 다양한 소리를 낸다. 음량이나 높이 등 그러한 소리의 여러 인자들은 엔진 종류, 스로틀 수준, 동력 수준, 현재 속력 같은 게임의 여러 인자들과 연결된다. 이러한 복잡한 관계를 은폐하고 특정 종류의 엔진에 국한된 함수들을 제공할 수 있게 하는 것이 조합 패턴이다.


void StartEngine( CarInstance_t *pObject );
void UpdateEngine( CarInstance_t *pObject );
void StopEngine( CarInstance_t *pObject );

내부 오디오 코드는 매개 변수로 주어진 CarInstance_t 객체의 상태와 인자들은 해석해서 적절한 사운드를 재생한다. 오디오 시스템 내부에서 개별 사운드 객체를 다루든 아니면 사운드 객체들의 조합을 다루든 상관없이, API 사용자는 동일한 방식으로 엔진을 다룰 수 있게 된다.

주변음 제어

주변음(ambient sound) 처리 역시 조합을 통해서 단순화될 수 있다. 밀림 같은 환경을 흉내낸다고 하면, 다양한 동물 소리들을 무작위적으로 재생하게 될 것이다. 이 경우에는 Jungle_t 같은 구조체를 만드는 대신 플레이어가 밀림 지역으로부터 얼마나 떨어져 있는지, 그리고 동물들이 얼마나 흥분해 있는지를 뜻하는 매개변수들로 주변음을 제어하는 것이 더 바람직할 것이다.


void StartJungle( float fDistance, float fActivity );
void UpdateJungle( float fDistance, float fActivity );
void StopJungle();

대리(proxy)

"다른 객체에 대한 제어에 접근할 수 있게 하는 대리자를 제공한다".

핸들

핸들은 다른 객체를 제어할 수 있는 접근 수단이다. 어떤 사운드의 특정 인스턴스를 재생했을 때, 재생 시작 이후에도 그 인스턴스를 계속 제어할 수 있어야 하는 경우가 있다. 예를 들어서 시간이 지남에 따라 음원의 3D 위치를 변화시킨다던가, 음량이나 높이, 팬(pan)을 조정해야 할 수도 있다. 이 경우 재생 시작 함수는 하나의 핸들을 돌려주고, 갱신이나 정지 함수들은 그 핸들을 통해서 해당 인스턴스를 제어한다.


Handle_t StartHandledSound( int nSoundId, const ControlParams_t &cp);
void UpdateHandledSound( Handle_t hSound, const ControlParams_t &cp);
void StopHandledSound( Handle_t hSound );

핸들에 대한 추가적인 내용은 [Bilas00]를 참고하기 바란다.

장식자(decorator)

"객체에 동적으로 추가적인 책임(responsibility)들을 부여한다. 장식자는 기능성 확장을 위한 서브클래싱에 대한 유연한 대안을 제공한다".

사용자 데이터

동적인 책임과 연관을 객체에 부여하는 한 가지 방법은 사용자 데이터 접근을 제공하는 것이다. API 사용자가 사운드의 개별 인스턴스에 대해 설정할 수 있는 하나의 사용자 데이터 필드를 제공한다면, API 사용자는 그 사운드 인스턴스들에 대해 자신이 임의로 정한 또 다른 책임들을 부여할 수 있게 된다. API는 다음과 같은 사용자 데이터의 접근을 위한 함수들을 제공해야 할 것이다.


void SetHandledSoundUserData( Handle_t hSound, UserData_t UserData );
UserData_t GetHandledSoundUserData( Handle_t hSound );

콜백

또한 사운드 인스턴스마다 개별적인 콜백 필드를 제공할 수도 있다. 이러한 콜백 함수는 사운드 전체가 한 번 재생될 때마다, 또는 특정 시간동안 재생되었을 때마다 호출되도록 하면 될 것이다.


void SetHandledSoundCallback( Handle_t hSound, CallbackFuncPtr_t pCB );
void ClearHandledSoundCallback( Handle_t hSound );

명령(command)

"하나의 요청(request)을 하나의 객체로 캡슐화 한다. 그렇게 함으로써 서로 다른 요청들, 요청의 큐잉 및 기록에 대해 클라이언트를 인자화(parameterize)할 수 있고, 또한 작업의 되돌리기(undo) 기능도 가능해진다".

명령 큐

오디오 API에 대한 호출들을 하나의 명령 큐에 집어 넣고, 그것을 게임 프레임 당 하나씩 처리하게 할 수 있다. 이 큐를 들여다보면 한 프레임 안에서 동일한 사운드가 여러 번 호출되지는 않았는지, 또는 재생을 시작했으나 소리도 내지 못하고 정지되지는 않았는지 등을 알아낼 수 있다. 또한 이러한 큐는 디버깅에도 매우 유용하다.

음성 대사 시스템

명령 큐잉은 음성 대사(speech) 시스템에 도움이 된다. 대부분의 경우 한 번에 한 캐릭터씩 대사를 하게 해야 하므로, 큐를 통해서 대사를 할 캐릭터들의 순서를 정할 수 있다. 음성 대사를 건너뛰어야 하는 경우(사용자가 특정한 키를 눌렀다던가 등등)라면 큐를 모두 비우면 된다.


void PostSpeechRequest( int nSpeakerId, int nPhrase );
void ClearSpeechQueue();

기억(memento)

"캡슐화를 위반하지 않고도 객체의 내부 상태를 조회하거나 외부화한다. 그렇게 함으로써 이후에 객체를 현재의 상태로 복원할 수 있다".

일시 정지와 재시작

모든 사운드들을 일시 정지시킬 때, 현재 상태에 대한 핸들을 돌려준다. 그리고 이후에 그 핸들을 이용해서 정지된 부분부터 다시 시작한다.


StateHandle_t PauseAllSounds();
void RestartAllSounds( StateHandle_t hState );

감시자(observer)

"한 객체의 상태가 변했을 때, 그 사실이 그에 의존하는 모든 객체들에게 자동적으로 통지되어서 갱신될 수 있도록, 객체들 사이의 일 대 다 의존성을 정의한다."

동적 종류

재생을 시작한 사운드의 종류 번호를 넘겨주면 이후에 그 사운드 및 그와 동일한 종류의 사운드들을 참조할 수 있다. 예를 들어서, 어떤 캐릭터를 위한 동일한 종류의 사운드들을 모두 재생하기 시작했다면, 이후에 그 종류 번호를 이용해서 그 사운드들을 단 한 번의 호출로 중지시킬 수 있다.


void StartSoundWithType( int nSoundId, int nTypeId );
void StopAllSoundsWithType( int nTypeId );

또한 특정 종류의 사운드들을 모두 갱신할 수도 있다.


void UpdateSoundsWithType( int nTypeId, const ControlParams_t &cp);

그리고 기본 우선 순위나 기본 볼륨 같은 속성들을 특정 종류에 연관시키는 것도 유용할 것이다.


void SetDefaultPriorityForType( int nTypeId, int nDefaultPriority );
void SetDefaultVolumeForType( int nTypeId, float fDefaultVolume );

이는 버퍼가 모자랄 때 어떤 사운드를 재생시켜야 할 것인가를 결정하는 어떠한 우선 순위 시스템이 존재한다고 가정한 것이다.

커다란 진흙덩이(또는 스파게티 코드)

마지막으로 언급할 설계 패턴은 '커다란 진흙 덩이'(Big Ball of Mud)라고 하는 것이다. 이 이름은 Brian Foote와 Joseph Yoder가 쓴 동명의 논문에서 비롯된 것으로, 그 논문은 프로젝트의 진행을 가로막을 수 있는 나쁜 패턴들(역주: 흔히 안티 패턴(anti-pattern)이라고 한다)을 재치있고도 사려깊게 설명한다. 커다란 진흙 덩이는 반드시 피해야 할, 그러나 피하기가 쉽지 않으며 항상 만나게 되는 패턴이다. 설계 패턴을 알든 알지 못하든, 프로젝트를 진행하는 과정에서 '커다란 진흙 덩이'로 이르게 하는 일련의 패턴들(Throwaway Code, Piecemeal Growth, Keep It Working, Shearing Layers, Sweeping It Under the Rug)이 생겨나게 되는데, 우리가 할 수 있는 일은 그것들을 최대한 피하는 것이다.

오디오 시스템 개발자로써 우리가 목표로 잡아야 할 것은, 오디오 시스템의 내부 코드가 커다란 진흙 덩이가 되지 않도록 하는 것이다. 그리고 개발팀의 일원으로써의 또 다른 목표는, 나머지 게임 코드가 커다란 진흙 덩이가 되지 않도록 하는 것이다. 오디오 API안의 어떤 기능들을 어떻게 외부에 제공할 것이냐에 따라 코드의 전체적인 조직화가 달라질 수 있다. 또한 오디오 문제들의 디버깅에 도움이 되는 API 기능들을 제공함으로서 조직화에 도움을 줄 수도 있다. 따라서, 진흙 덩이를 피할 수 없다고 해도, 오디오 API를 통해서 늪을 헤쳐나가는데 도움을 줄 수 있다.

통계 함수들

통계 함수들은 흔히 만나는 문제들을 해결하는데 도움이 된다. 현재 재생 중인 음성들의 갯수 같은 어떤 구체적인 요구에 대한 답을 돌려줄 수도 있고, 또한 사운드 상태 같은 좀 더 일반적인 요청에 대한 정보를 하나의 문자열로서 돌려줄 수도 있을 것이다.


int GetNumberOfSoundsPlaying();
bool IsSoundPlaying( int nSoundId );
string GetAudioStatus();
void GetDescriptionsOfSoundsPlaying( list<string> &StringList );

기록 함수들

오디오 시스템의 활동을 기록하는 시스템을 제공하는 것도 도움이 된다. 이러한 기록 시스템의 경우 기록의 대상(표준 출력 장치, 파일, 렌더링 화면 등)을 선택할 수 있게 하는 것이 좋다. 또한 어떤 종류의 정보를 얼마나 자세히 기록할 것인지를 선택할 수 있게 하는 것도 필요하다. 예를 들어서 오디오 API 호출들과 인자들만 기록하게 할 수도 있고, 내부적인 사운드 버퍼 할당 정보나 기타 내부적 상태 정보까지도 기록하게 할 수도 있어야 할 것이다.


void EnableLogging();
void DisableLogging();
void SetLoggingDetailLevel( int nDetailLevel );
void SetLoggingOutputType( int nOutputType );

시스템 비활성화

오디오 시스템에 뭔가 문제가 있는지 알아보는 한 가지 간단한 방법은, 오디오 시스템을 가동시켰을 때와 그렇지 않을 때의 차이를 보는 것이다. 오디오 시스템이 비활성화되면, 오디오 API 함수를 호출해도 아무 일도 일어나지 않게 한다. 그런 상태에서도 여전히 문제가 발생한다면, 적어도 오디오 시스템이 원인은 아닌 것이다. 이런 식으로 서브시스템들을 비활성화시킬 수 있으면 게임 디버깅이 좀 더 간단해진다.


void AudioSystemEnable();
void AudioSystemDisable();

결론

몇 가지 설계 패턴들을 근거로 해서 게임 오디오 API에 집어 넣을 만한 여러 유용한 기능들을 나열해 보았다. 이러한 점들을 고려해서 오디오 시스템을 작성한다면 다른 프로그래머들에게 좋은 평가를 얻을 수 있을 것이다.

참고자료