DirectX11 2D 롤플레잉 게임 만들기 2 WinApi 기본 코드 분석

Visual Studio에서 Windows Desktop용 개발 프로젝트를 ‘새로 만들기’ 하면 기본적으로 보이는 코드가 있다.

이는 Windows상에 존재하는 모든 프로그램들(게임, MS오피스, 웹브라우저, 백신, 동영상 플레이어 등)이 Windows와 소통하기 위해 갖춰야 하는 최소한의 양식.

우리의 DirectX 게임도 Windows 프로그램이기에(현재까지는) 이 소통 양식을 알아볼 필요가 있다.

사실 DirectX 자체가 Windows나 Xbox 콘솔과 한몸이다. DirectX를 한다는 것은 Windows기반 PC게임을 만든다는 것. (Xbox는 약간… 망해서 …)

만약 PC 환경과 모바일 환경(안드로이드, iOS)을 자유롭게 이동 할 수 있는 멀티 플랫폼 게임을 만들고 싶다면 유니티나 언리얼 엔진을 추천한다. (이는 상용화된 게임 엔진의 최대 강점이다.)

wWinMain() 함수

Visual Studio 프로젝트를 다음 순서로 생성하면 초기 코드가 만들어진다.

처음에 이 코드를 보면 지루하고 복잡해 보이지만 몇 가지 코드 구조를 이해하고 나면, 코드가 마치 하얀 캔버스처럼 보일 것이다.

  • 새 프로젝트 만들기 -> C++ 선택 -> Windows 데스크톱 어플리케이션 -> 프로젝트이름 입력 -> 만들기

4 라인 기본적인 헤더 파일들을 담은 헤더 파일 framework.h 넘어가자.

5 라인 “프로젝트 명.h” Visual Studio는 최소한의 UI 형태들 (Resource 들)을 만들어 두고, 번호를 붙여둔다. 예를 들어 아래 about box는 104번일 수 있다. 이 번호가 써있는 파일.

이런 UI들은 단순 그림이라기 보다는 작은 조각들의 합으로 구성되어있다

종료(x) 버튼, 최대화 버튼, 프레임 틀, 메뉴바 등등.. 우리가 원하는 창을 만들어 추가 할 수도 있다

기본 Resource 중 하나인 About Box

20라인

C 언어에서는 코드의 시작점(Entry Point)이 void main()이나 int main() 임을 이미 알 것이다.

윈도우 플랫폼용 애플리케이션에서는 wWinMain()이다. 사실 이름은 중요한 건 아니다 이 프로젝트를 통해 만들어지는 실행파일인 .exe파일에서 이 함수는 컴파일 후, 그저 어떤 메모리 번지수로 바뀔 뿐이다. (시작하는 메모리 번지수)

필요하다면 몇 가지 조작을 통해 이 엔트리 포인트 이름을 바꿀 수도 있다.

APIENTRY는 현재 __stdcall라는 예약어의 다른 말이다.

함수들이 다른 함수를 콜 하거나 거기서 빠져나올 때(리턴), 이런 호출과 리턴에 대한 방식이 몇 가지 있다. 어셈블리어 레벨에서는 이 방식에 따라 행동하는게 미세하게 다르다. (늘 하는 말이지만 모든 코드는 결국 어셈블리어로 번역된다.)

호출 받는 함수쪽에서 동작하는 방식이 호출하는 함수가 기대하는 것과 다를 때 문제가 생길 수 있다.

이를 미연에 방지하기 위해 Windows는 하나를 정해 놓은 것. wWinMain() 함수는 Standard Call 방식이다.

hInstance 인스턴스

20라인 wWinMain() 함수의 첫 번째 인자 _in_ HINSTANCE hInstance는 이 프로그램이 실행되었을 때 Windows에서 실시간으로 할당받는 ID

“나는 현재 윈도우에서 1032번 프로세스입니다.”

_in_이라는 키워드를 이용해서, 이 인자가, Input용 인자임을 살짝 명시해줬다. 이런 부가 설명을 잘 명시해두면 컴파일할 때나 런타임 상황에서 약간의 어드벤티지가 있다고 한다. 별로 중요하지 않으므로 나는 늘 생략하겠다.(–;)

HINSTANCE형 자체는 구조체이다. 이 “HINSTANCE”라는 글자에 커서를 놓고 F12를 누르면 구조체 내용이 보인다. 의미있는 멤버는 int 형 변수 하나다. 그게 ID (되돌아 올땐 Ctrl + -)

아래는 리눅스 환경을 설명할 때 사용한 도식이다. 현재로선 Windows도 똑같다고 보면 된다.

Windows의 핵심 코드인 커널은 PC상의 모든 프로그램을 시작부터 종료까지 관리하고 구동시킨다..

구동의 시작은 해당 프로그램을 하드디스크에서 메모리로 로드(Load)해주고 프로세스화 시켜주는 것. 이를 위해 적절히 PC의 메모리를 할당해준다

이후 각 프로세스들(프로그램들)은 OS가 만들어준 자기만의 메모리 공간 속에서 동작하게 된다.

다수의 프로세스를 관리하기 위해 커널(OS, Windows)은 모든 프로세스들에 관한 정보 리스트를 관리하며 그 리스트 엔트리들안에 ID값이 써있다. 이 값을 wWinMain() 함수 첫 번째 인자(hInstance)로 받는 것.

참고로 hInstance가 그렇듯 이런 인자 앞에 h라는 알파벳이 많이 붙는데 이는 핸들이라는 단어(handle)의 약자이며, 덩치가 큰 무엇인가를 가리키는 번호들를 Windows에서 핸들이라고 많이 부른다.

wWinMain() 함수의 두 번째 인자 역시 HINSTANCE 형식의 프로세스 아이디 값인데 변수명에 prev(이전의, previous)가 붙어 있다.

커널(WIndows)이 관리해주는 프로세스 목록(위 그림 참고)에서 순서상 바로 앞에 있는 프로세스 ID도 넘겨주는 것.

이런 리스트들은 링크드 리스트라는 자료 구조형를 이루는데, 모르면 넘어가자, 일단 별거 아니다.

_in_ LPWSTR lpCmdLine

아래는 윈도우의 cmd 창에서 “dir /w”라는 명령을 사용했을 때 결과다. 

dir 역시 프로그램이다.

이때 이 프로그램을 구동시키기 위해 “dir /w”라는 커맨드를 사용한 것

wWinMain()의 3번째 인자는 이 프로그램이 구동될 때 내부적으로 사용된 “dir /w”같은 게 들어온다.

아이콘을 더블 클릭해서 프로그램을 실행했어도 이런게 존재한다. (예를 들어 내 프로젝트는 “LastKingStandingA(.exe)”이 사용된 명령이 될 것이다)

_in_ int CmdShow 보통 상수값 SW_SHOW이 들어온다. SW_SHOW는 아마 5이지만. 그건 중요하지 않다. 그냥 SW_SHOW를 의미하는 수가 들어오고 “이 프로그램의 윈도우를 일반적인 방식으로 보여줘라”라는 정의.

또 다른 상수 중 SW_MINIMIZE, SW_HIDE는 창을 최소화하거나, 숨기라는 의미.

라인 25, 26 

만약 함수가 받은 인자가 내부에서 한 번도 쓰이지 않을 경우 컴파일할 때 warning 메시지가 뜨는데 이를 방지하기 위해서 임의로 살짝 사용하는 척 하고있다. UNREFERENCED_PARAMETER() 매크로는 이를 위해 쓰인다.

라인 31, 32

Visual Studio로 프로젝트를 생성할 때 프로젝트명을 기입하면 이는 이 프로그램 윈도우의 타이틀이 된다. szTitle와, szWindowsClass에 넣어두는 함수다. 이후 szWindowClass는 아래 이 윈도우의 모양을 정의하는 클래스 구조체에서 사용된다.

MyRegisterClass()

라인 33에서 MyRegisterClass() 함수를 호출하고 있고 이 함수는 코드 내에 구현되어있다.

83 라인 WNDCLASSEXW라는 구조체를 하나 조립한 후 이를 RegisterClasssExW()라는 함수의 인자로 넣어주는 게 보인다. 이때 여기서 리턴되는 결과값은 wWinMain() 함수의 33라인에서는 받아쓰지 않고 있다.

따라서 이 83라인의 함수는 만들어진 구조체에 대한 추가적인 가공을 한 후 그것을 윈도우 레지스트리의 어딘가에 등록해 두거나, Windows 커널 영역 내에서 이 프로세스 관리를 위한 리스트 엔트리를 만드는데 재료로 쓰일 것으로 보인다.

마이크로소프트의 관련 문서를 검색하면 이 함수는 그저 “윈도 클래스를 등록한다 이후 CreateWindow(), CreateWindowEX() 함수에서 이렇게 등록된 걸 사용한다”라고만 나와있다.

참고로 커널 입장에서는 어떤 앤트리를 만들어서 관련 데이터 리스트에 껴넣어 두는 게 등록이다.

그리고 이 함수가 만드는 이 구조체 WNDCLASSEXW wcex;가 문서 내용을 통해 윈도우 클래스라고 불리는 것을 알 수 있으며, 앞으로 이 등록 정보가 필요하면 hInstance 같은 키값을 이용해서 찾을 수 있을 것으로 보인다.

WNDCLASSEX 구조체는 해당 어플리케이션이 만드는 윈도우 창에 대한 기본 설정 값들이며. 아래와 같은 것들이 들어있다.

71 라인 CS_HREDRAW, CS_VREDRAW는 이 프로그램이 만들려고 하는 윈도우의 창이 가로 세로로 자유롭게 늘어났다 줄어들 수 있는 성질임을 명시한다.(마우스 드래그 시) REDRAW는 다시 그리는 게 가능하다는 뜻으로 보면 된다.

72 라인 ‘메시지 처리를 위한 콜백 함수’라고 보면 된다. 아래서 설명한다.

75 라인 만들어지는 윈도우 창이 어떤 프로그램에 속하게 되는지 hInstance를 넣어줌으로써 명시해주고 있다.

76 라인 이 프로그램의 아이콘(큰 아이콘)에 대한 이미지 정보

77 라인 마우스 커서가 이 윈도우 안으로 들어왔을 때 사용할 커서의 모양을 등록해주고 있다.

79 라인 보통 윈도우 상단에 보이는 메뉴바에 대한 기본 설정값을 넣어주고 있다.

80 라인 이 윈도우에 지어준 이름 중 하나를(전역 변수 szWindowClass) 넣어주고 있다. (윈도우 클래스명)

81 라인 이 윈도우 화면의 좌측 상단에 작은 아이콘이 하나 들어간다. 이 아이콘의 그림 데이터를 등록해주고 있다.

이후 wWinMain() 함수의 36 라인에 Initinstance() 함수가 있는데 이 함수는 우리 코드에 정의되어있다.

우리가 위에서 윈도우 클래스 구조체를 등록할 때 이 등록정보가 CreateWindowsEX()에서 사용된다는 설명을 확인했었다. 100 라인에 이 함수가 보인다(어미가 EX대신 W이긴 하나 매크로다. 정의를 따라가면 원래 함수로 define 되어있다.) 다시 말해 위에서 실제 조립되고 등록된 윈도우 클래스 정보를 통해 윈도우를 만들어주는 함수임을 알 수 있다.

함수의 인자로 hInstance 값이 보이며 프로그램 타이틀 문자열과, 윈도 클래스 이름인 szWindowClass 값도 보인다. 등록된 윈도우 클래스 정보가 어디 있는지는 모르겠지만 충분히 찾을 수 있을 것으로 보인다.

이후 108 라인과 109 라인에서 만들어진 윈도를 화면에 출력하는 함수가 보인다. ShowWindow()는 화면에 출력하는 함수이고 UpdateWindow() 함수는 새로 만들어진 이 윈도우에 생성하자마자 뭔가를 바꿔 그려줄 게 있을 경우를 대비해서 한번 갱신해주고 있다.

프로세스의 내부 모습

우리가 어떤 프로그램을 실행하면 보통 그 프로그램에 대한 윈도우 창을 볼 수 있게 된다.

예를 들어 F5를 눌러 지금 코드를 실행시키면 이런 하얀 창이 뜬다. 하지만 이는 껍데기일 뿐이다.

우린 내부적으로 이 프로그램이 어떻게 돌고 있으며 Windows 플랫폼으로부터 무엇들을 제공받아서 이렇게 실행되고 있는지 알 필요가 있다.

아래는 우리가 Windows 플랫폼에서 요구하는 규칙에 맞춰 프로그램을 만들 때 그것이 윈도우로부터 제공받을 수 있는 것들의 일부이다.

우측 메모리 스택에서 위쪽 연두색 영역은, 일반 프로세스가 접근할 수 없는 커널 고유 영역.

이는 cpu 모드가 특권 모드일 때(다 같은 말 : 커널 모드일때, privilige mode일때, level 0일때) (매 순간 파바박~ 하고 바뀜) 산하의 프로세스들을 관리해주기 위해 사용되는 영역

따라서 어떤 프로세스들도 0x00000000부터 일정양의 숫자를 주소로 사용하지 않는다. 커널이 쓰는 숫자인 것.

특권 모드(커널 모드, privilige mode)에서의 커널은 어떤 숫자건 주소로 다 쓴다. 산하 프로세스를 자기 마음데로 다룰 수 있다. 다만 프로세스가 다운되면 커널 책임.

이때 프로세스들 끼리는 사용하는 주소 번호가 겹칠것이다. 하지만 모두 독립된 가상의 주소 공간을 사용하므로 숫자가 겹치든 말든 충돌할 일은 없다.

예를들어 프로세스A가 0 xF0001234번지를 사용했는데 프로세스B도 0 xF0001234번지를 사용했다면, 이는 OS가 제공해주는 가상 주소 공간이라 상관 없으며, 실제 데이터는 물리 주소 상의 어딘가에 둘 다 적절히 저장되어 있을 것이다.

이 포스팅 상단에 있는 그림을 보면 이해가 쉬울 것이다.

이렇게 서로 충돌하지 않게 해주는 능력은 현대의 CPU와 커널간의 적절한 협업을 통해 이루어지며 커널의 가장 중요한 역할로 자리잡고 있다.

이 구조 속에서 우리의 프로그램은 아래와 같이 메모리를 사용한다.

메모리에 담기는 것

우리 코드에서 new라는 키워드를 통해 클래스 객체를 만들면 이는 비교적 복잡한 할당 절차를 통해 heap 영역에 저장된다 (유연하고 동적인 부분)

평범하게 사용하는 int나 flaot등은 stack영역 에 저장된다. (유연하고 동적인 부분)

static변수나 const변수는 data영역과 bss영역에 저장된다. (딱딱하고 경직된 부분, UI리소스들도 data에 있을 것이다)

우리의 프로그래밍 코드들은 text 영역에 저장된다.(박제되어있다)

게임을 만들기 위해 import 하는 각종 라이브러리(lib, dll)들은 (API 함수) stack영역과 heap 영역 사이 빈공간에 배치된다.

메시지 큐

메시지 큐는 힙 영역에 있을 것이다. (공개를 해야 알지..) Windows는 각 프로세스들이 현재 해야 할일을 이벤트라는 이름의 데이터로 그들 각자가 가지고 있는 메시지 큐에 적절히 넣어준다.

각 프로그램들은, 특권 모드가 끝나고, CPU를 자신이 쓸차례가 오는 매순간, 이 큐를 살펴보고 자신이 할 일을 하나하나 꺼내서 하게 된다.

  • 현재 프로젝트에 쓰이는 lib ,dll 파일 목록은 Visual Studio 화면 우측의 솔루션 탐색기에 내 프로젝트 명을 오른클릭하고 속성 -> 링커 -> 입력을 누르면 추가 종속성이라는 항목에 나온다. kernel32.lib나 user32.lib는 WinAPI를 제공받기 위한 필수 라이브러리다. 메시지 큐 관련해서 프로그램들이 해야할 동작들이 명세어되있다.
이 이미지는 대체 속성이 비어있습니다. 그 파일 이름은 directx30.png입니다

메시지 큐와 우리코드

앞서 말한 데로 Windows는 각 애플리케이션에게 ID값 (hInstance)을 주고, 메모리를 할당해 주며, 또 한 가지 메시지 큐를 할당해 준다.

만약 우리가 저 윈도우 창안에서 마우스 클릭이나 키보드 입력 등 어떤 행동을 했을 때 이런 이벤트 정보들은 커널에 의해 해당 프로그램의 메시지 큐 안으로 들어가게 되며, 이 메시지(이벤트) 정보들은 프로그램이 꺼내서 사용해주길 기대하면서 대기상태로 쌓여있다.

그리고 우리의 프로그램은 무한 루프를 돌며 이 메시지 정보를 주기적으로 가급적이면 신속하게 가져오려고 하고 있으며 위 코드 43~53 라인의 while문이 이에 해당한다. 

참고로 41 라인의 Accelerators() 함수는 Ctrl + A, Ctrl + C 같은 프로그램의 기본 단축키 정보들을 가져오는 함수이다. 게임 만들 때는 필요 없으므로 이후 지울 것이다.

46 라인 메시지 큐에 이벤트가 들어있다면 그것을 가져오고 없다면 거기서 멈춤 상태로 대기하고 있다. 여기서 메시지를 가져올 경우 msg 변수에 이를 넣게 되는데 43라인에 MSG라는 데이터형을 가진 것이 보인다.

48 라인 이 메시지의 종류가 키 입력이고 단축키 관련 이벤트인지 확인한다. 아니면 false를 리턴하고 넘어간다. 41 라인 Accelertor() 함수에서 리턴 받은 hAccelTable이라는 변수는 딱 봐도 왠지 단축키 목록 넣어놨을 거 같은 자료형이다.

50, 51 라인 이 두 함수는 같이 쓰이는 게 공식화되어있어 보인다. TranslateMessage() 함수는 받은 메시지가 마우스 이벤트인지, 키보드 이벤트인지, 키보드 이벤트이면 구체적으로 어떤 이벤트인지 조금 더 해석해서 msg 구조체를 조정해주는 역할을 한다고 하며 이렇게 조정된 정보를 가지고 DispatchMessage() 함수는 내 프로그램의 적절한 함수에게 이 메시지를 전달한다.

이때 DispatchMessage 함수는 적절한 함수가 무엇인지 어떻게 알 수 있을까? 위의 MyRegister() 함수에서 윈도 클래스라는 구조체를 조립해서 등록했었다. 이때 72라인에서 WndProc라는 함수 포인터를 이 윈도우 클래스에 꼽아놓은 게 보인다. 이 함수는 우리 코드 안에 있으며 아래와 같다.

여기서 주의 깊게 볼 것은 124라인에 보이는 이 함수의 인자들이다. 이는 바로 위의 코드에서 메시지 받는다고 무한루프를 돌고 있을 때 사용되는 인자 msg의 자료형 MSG(메시지 구조체)와 상관있다.

이처럼 DispatchMessage() 함수는 등록된 정보에서 우리 프로그램의 메시지 핸들러 함수를 찾아 그쪽으로 메시지를 던져준다.

128 라인 WM_COMMAND 메시지는 우리 윈도우 상단에 메뉴를 클릭했을 때 발생하는 메시지이다 이를 처리하기 위한 코드다. 메뉴 항목을 처리해야 하는지에 대한 정보는 wParam 인자로 들어오는 걸 알 수 있다. 그중 About 다이얼로그를 처리해 주거나 종료 메뉴를 처리해주는 코드가 보인다.

145 라인 WM_PAINT 메시지는 화면의 내용을 다시 그려줘야 할 때 발생하는 메시지이다. 예를 들어 내 윈도우를 다른 어떤 윈도우가 덮었다가 그것이 치워졌을 때 우린 가려졌던 부분을 다시 그려야 한다. 이때 발생한다.

153 라인 종료 버튼이 눌러졌을 때 발생한다.

우린 이제 이 무한히 메시지를 받고 있는 순환 문과 메시지를 받아 처리하고 있는 WndProc() 함수 등을 가공해서 프로그램을 만들 필요가 있다. 단, 게임은 매우 빠른 처리를 요구하므로 이 단순한 메시지 처리 루프만을 사용하지는 않는다. 앞으로 그런 것들을 하나하나 만들어가 보자.

Leave a Comment