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

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

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

1.12 윈도우즈 기반 게임을 위한 선형적 프로그래밍 모델

Javier F. Otaegui, Sabarasa Entertainment
jJavier(at)sabarasa.com.ar

DOS가 세상을 지배하던 시절, 우리는 게임을 거의 선형적인 방식으로 프로그래밍했다. DirectX로 게임을 만들지 않으면 안되는 시절이 오면서, 우리는 윈도우즈의 메시지 펌프(message pump)라는 괴물을 만나게 되었다. 메시지 펌프라는 구조는 게임 프로그래밍에는 전혀 맞지 않는다. 이 글에서는 메시지 펌프를 캡슐화하고 선형적 프로그래밍 모델(linear programming model)을 제공하기 위한 효율적인 방법을 이야기하겠다. 그리고 그를 이용해서 Alt-Tab에 의한 응용 프로그램 전환이 제대로 지원되도록 하는 방법과 손실된 표면(lost surface)을 제대로 복구하는 방법도 이야기하겠다.

선형적으로 프로그래밍해 본 독자라면 이 글에서 말하는 방법의 중요성을 쉽게 이해할 수 있을 것이다. 만일 윈도우즈로부터 게임 프로그래밍을 시작한 독자라면 게임 프로그래밍에서의 메시지 펌프를 별로 불편하게 생각하지 않겠지만, 일단 선형적 프로그래밍을 경험해 본다면 메시지 펌프로 다시 돌아가는 일이 없을 것이다. 메시지 펌프의 거대한 유한 상태 기계보다는 선형적 모델이 훨씬 더 깔끔하고 이해하기 쉬우며 디버깅도 편하다. 좀 더 선형적인 방식으로 작업을 하면 설계나 프로그래밍, 디버깅 시간을 크게 줄일 수 있으며, 골치도 덜 아프다.

게임 세계의 갱신

요즘 게임들은 UpdateWorld 같은 이름의 함수를 가지곤 한다. 그런 함수는 메시지 펌프 안에 있는 응용 프로그램의 핵심부에 위치하며, 메시지를 받지 않았을 때에도 무조건 호출되곤 한다. 언뜻 생각하기에, UpdateWorld 함수를 만드는 것은 매우 간단해 보일 것이다. 모든 전역 변수들, 표면들, 그리고 인터페이스들이 이미 초기화되어 있을 것이므로, 그 함수 안에서는 그냥 그것들을 갱신하고 그리기만 하면 된다. 이 정도면 매우 간단한 일이지만, 이는 게임에 오직 한 종류의 화면만 존재하며, 컷씬(cut-scene)도 없고, 메뉴나 옵션도 없는 경우에만 해당하는 이야기이다.

문제는, UpdateWorld가 결국에는 종료되어서 메시지 펌프로 돌아가야만 시스템으로부터 전달된 다음 메시지를 처리할 수 있다는 점이다. 이 때문에 DOS 시절처럼 하나의 연속적인 for 루프 안에서 모든 것을 처리하는 것이 불가능하다. DOS 게임들에서는 메시지 펌프로 돌아가서 다음 메시지를 받아올 필요가 없기 때문에 항상 선형적인 프로그래밍이 가능하며, 따라서 모든 서브루틴들은 자신에 필요한 모든 루프들이나 지연들, 또는 컷씬들을 제한없이 가질 수 있었다. 그냥 해당 코드를 서브루틴 안에 집어넣기만 하면 되었던 것이다. 그러나 메시지 펌프는 끊임없이 조사되어야 할 필요가 있기 때문에, 루프가 한 번 돌 때마다 매번 메시지 펌프로 돌아가야 한다. 여러 종류의 게임 화면들이 존재하는 경우 매 번의 반복마다 함수가 반환되어야 한다는 것은 매우 번거로운 제약이다.

이를 해결하는 한 가지 방법은 응용 프로그램의 모든 서브루틴들을 하나의 유한 상태 기계로 만드는 것이다. 각 서브루틴은 자신의 내부 상태를 간직하며, 그 상태에 기반해서 어떠한 작업을 처리하거나 다른 서브루틴들을 호출한다. 그러한 다른 서브루틴들 역시 하나의 유한 상태 기계로 작동하며, 자신의 작업을 마쳤으면(즉 더 이상 수행해야 할 상태들이 남아있지 않게 되면) 자신을 호출한 서브루틴에게 '이제 네 상태에 맞게 일을 진행하라'라는 뜻의 값을 반환해야 한다. 물론 작업을 마친 각 서브루틴이 자신의 상태를 0으로 다시 설정해야 응용 프로그램이 그 서브루틴을 다시 호출할 수 있게 된다.

만일 그런 식으로 작동하는 서브루틴들이 30-40개 정도에 이르며 각 서브루틴마다 20여개의 상태들을 가지고 있다면, 응용 프로그램은 속을 알 수 없는 거대한 괴물이 되어 버린다. 디버깅은 고사하고 코드를 읽으면서 실행의 흐름을 쫓아가는 것도 매우 어려운 일이 될 것이다. 유한 상태 프로그래밍 모델은 옛날의 선형적인 DOS 프로그램에서 쓰이던 단순한 모델보다 훨씬 더 복잡하다.

해결책: 다중 스레딩

메시지 펌프로부터, 그리고 궁극적으로는 복잡한 유한 상태 프로그래밍 모델로부터 벗어날 수 있는 좋은 대안은 바로 다중 스레딩 모델(multithreading model)이다.

윈도우즈는 다중 스레딩을 지원한다. 이는 하나의 응용 프로그램이 내부적으로 여러 개의 스레드들을 동시에 실행시킬 수 있다는 뜻이다. 개념은 매우 간단하다 - 메시지 펌프를 하나의 스레드에 넣고 게임을 다른 스레드에 넣자는 것이다. 메시지 펌프를 초기(메인) 스레드에 그대로 두고, UpdateWorld 함수를 게임 스레드에 넣으면 UpdateWorld를 가장 최소한의 형태(선형적인 프로그래밍 모델)로 유지할 수 있다. 다음은 게임 스레드를 초기화하는데 필요한 doInit 함수이다.


HANDLE hMainThread;	// 메인 스레드 핸들

	static BOOL
	doInit( ... )
	{
		... // DirectX와 기타 필요한 것들을 초기화한다...

		DWORD tid;

		hMainThread=CreateThread( 0,
			0,
			&MainThread,
			0,
			0,
			&tid);

		return TRUE;
	}

MainThread는 다음과 같이 정의된다.


	DWORD WINAPI
	MainThread( LPVOID arg1 )
	{
		RunGame();
		PostMessage(hwnd, WM_CLOSE, 0, 0);
		return 0;
	};

MainThread는 RunGame 함수를 호출하며, 그 함수가 반환되면 WM_CLOSE 메시지를 전송해서 메시지 펌프 스레드의 실행을 종료시킨다.

초기화 코드

그런데 초기화 코드(DirectX 초기화 코드 등)는 doInit 함수에 넣어야 할까, 아니면 RunGame 함수에 넣어야 할까? WM_CLOSE에 대한 모든 종료 코드를 메시지 처리부에 집어 넣는다면 초기화 코드는 doInit 함수에 집어 넣는 것이 좋을 것이다. 그러나 모든 초기화 코드를 RunGame 함수에 넣는 것도 나쁘지 않다 - 그렇게 되면 게임에 관련된 모든 중요한 코드를 RunGame 함수, 즉 새로운 선형적 게임 프로그래밍 함수에 포함시킬 수 있게 된다.

"Alt-Tab" 문제

윈도우즈는 모든 응용 프로그램들이 서로 협조적으로 작동하기를 바란다. 이를 위해 꼭 해결해야 하는 문제는, Alt-Tab으로 프로그램을 전환하는 작동이 제대로 일어나도록 하는 것이다. 예를 들어서 게임을 실행하다가도 Alt-Tab을 눌러서 메일을 점검하고, 다시 Alt-Tab을 눌러 게임으로 돌아올 수 있게 만들어야 하는데, 이를 제대로 또는 '우아하게' 허용하는 게임들보다는 아예 Alt-Tab을 막아버리는 게임들이 더 많은 것 같다.

다중 스레드 모델의 경우 SuspendThread와 ResumeThread 같은 표준 스레드 함수들을 이용할 수도 있지만, Alt-Tab 문제를 깔끔하게 해결하기에는 많은 무리가 따른다. 그 대신 스레드간의 통신을 위한 도구인 이벤트를 사용하기로 하자. 이벤트(event)는 서로 다른 스레드들을 동기화시키는데 사용할 수 있는 깃발(flag) 같은 것이다. 실행을 잠시 중단해야 할 필요가 있는 경우 메인 프로그램이 깃발을 번쩍 들어 올리면, 게임 스레드가 그 깃발을 보고 자신의 작업을 중단시키는 방식이라고 생각하면 된다.

게임의 시작 시점에서 수동 재설정 이벤트(manual reset event)를 하나 만든다. 이 이벤트는 프로그램이 비활성화되면 거짓으로, 다시 활성화되면 참으로 설정되어야 한다. 게임 스레드의 메인 루프에서는 이벤트가 참인지 거짓인지에 따라 게임의 실행을 재개하거나 중지한다.

이벤트를 만들기 위해서는 우선 HANDLE 형의 전역 변수가 필요하다.


	HANDLE	task_wakeup_event;

이벤트를 생성하고 설정할 때에는 다음과 같이 CreateEvent라는 함수를 사용한다.


	task_wakeup_event =
		CreateEvent(
			NULL,		// 보안 특성들 없음
			TRUE,		// 수동 재설정을 활성화
			FALSE,		// 초기 상태 = 통지받지 않은 상태
			NULL		// 이름 없음
		);

대부분의 게임들에는 새 화면을 그리는 역할을 담당하며 메인 루프 안에서 매번 호출되는 함수가 존재한다. 예를 들어 DirectX 게임이라면 기본 버퍼와 후면 버퍼를 플립시키는 함수가 반드시 있을 것이다. 이러한 함수는 끊임없이 호출되므로, 이벤트의 변경을 감지하는 코드를 넣기에 안성마춤이다. 다음과 같은 코드를 넣으면 된다.


WaitForSingleObject( task_wakeup_event, INFINITE );

운영체제가 다른 응용 프로그램을 활성화할 때마다 스레드를 멈춰야 하는데, 그러려면 윈도우 프로시저 루프 안에서 APP_ACTIVATE 메시지를 잡아내고 현재 이 응용 프로그램이 활성화되어 있는지를 확인해야 한다. 만일 비활성화 상태라면 게임의 실행을 중단(종료가 아니라 유보)해야 하는데, 다음을 호출하면 된다.


ResetEvent( task_wakeup_event );

다시 실행을 재개할 때에는 다음을 호출한다.


SetEvent( task_wakeup_event );

이처럼 몇 줄의 코드만 추가하면, 사용자가 Alt-Tab을 눌렀을 때 게임이 자연스럽게 실행을 멈추므로(더 이상 CPU를 잡아먹지 않게 된다), 사용자는 다른 응용 프로그램들을 사용할 수 있게 된다. 만일 게임이 비활성화된 상태에서도 게임 세계가 계속 갱신되어야 한다면, 게임 스레드 전체가 아니라 렌더링 파이프라인만 유보시키고 게임 세계의 갱신 루틴은 계속 실행되게 만들면 된다. 이러한 이벤트 모델은 응용 프로그램에 필요한 스레드들이 여러 개라고 해도 얼마든지 적용할 수 있다(각 스레드마다 이런 식으로 새로운 이벤트들을 집어 넣으면 된다).

손실된 표면의 처리

게임의 그래픽이 그려지는 표면(surface)이 비디오 메모리에 존재한다면, 응용 프로그램이 비활성화될 때 표면이 손실되는 문제가 생긴다. 선형 프로그래밍 모델의 경우 서브루틴이 실행되는 도중에 게임이 비활성화되면 모든 표면들이 손실되는 사태가 발생할 수 있다.

이러한 상황에 대한 해결책은 여러 가지가 있는데, 그 중 하나가 명령 패턴(Command pattern)이다[GoF94]. 그러나 명령 패턴은 코드를 불명확하게 만들 수 있으며, 이 글이 주되게 이야기하는 것도 '좀 더 명료한 게임 프로그래밍 모델'에 관한 것이므로 명령 패턴은 피하기로 하겠다. 이 글에서 제시하는 방법은 콜백 함수와 lpVoid 쌍을 담는 스택을 관리하고 표면이 복구될 필요가 있을 때마다 그 스택에 쌓인 콜백 함수를 호출하는 것이다. 표면이 복구되어야 하면 콜백_함수(lpVoid)를 호출한다. 이 때 lpVoid 매개변수에는 필요한 모든 표면들에 대한 포인터들이 포함될 수 있다. 이런 방식이 선형적 프로그래밍 모델에 좀 더 적합하다.

예를 들어서, 게임의 스플래시 화면(splash screen. 역주: 프로그램이 처음 시작될 때 표시되는 화면. 흔히 지루한 로딩을 감추기 위한 용도로 쓰인다)을 표시하는 Splash라는 서브루틴이 있다고 하자. 스플래시 화면이 떠 있는 상태에서 사용자가 Alt-Tab을 눌러 다른 프로그램으로 갔다가 다시 게임으로 돌아왔다면, 게임은 다시 스플래시 화면을 표시해야 한다. 그런데 만일 Alt-Tab 과정에서 표면이 손실되었다면 이를 다시 복구해야 할 것이다. 위에서 말한 스택 방식을 사용한다면, 코드는 다음과 같은 모습이 될 것이다.


	int LoadSplashGraphics( lpvoid Params )
	{

		Surface *pMySurface;
		pMySurface = (Surface *) Params;

		//  ...
		//  (파일로부터 그래픽 데이터를 읽어온다)
		return 1;
	}

	int Splash()
	{
		Surface MySurface;

		// 함수를 스택에 넣는다.
		gReloadSurfacesStack.Push( &LoadSplashGraphics, &MySurface );

		// 제일 처음에 그래픽을 로드하는 것도 있지 말아야 한다.
		LoadSplashGraphics( &MySurface );

		// ... 기타 스플래시 화면에서 필요한 일들을 수행한다...

		// 함수를 뽑는다.
		gReloadSurfaceStack.Pop();
	}

표면을 다루는 각 서브루틴마다 이런 식으로 자신에게 필요한 표면 복구용 함수를 스택에 집어 넣으면, 복구 순서가 틀려서 화면이 깨지는 일을 방지할 수 있다. 스택 대신 컬렉션 클래스를 이용한 구현도 생각할 수 있겠지만, 표면의 복구 문제는 함수 호출들의 중첩(nesting)에 크게 관련된 것이며, 함수 호출의 중첩에 대한 가장 효율적이고 명확한 해법은 스택이다.

참고자료