2023. 5. 20. 00:44ㆍWin32 API
이전 시간에 Win32 API에서 메인 함수가 어떻게 동작하는지에 대한 간단한 흐름을 알아보았고 그 과정에서 만났던 여러 키워드들이 어떤 역할을 하는지 알아보았다. 그 과정에서 switch case문을 통해 메시지를 보고 어떤 명령을 처리하는지 분기를 나누는 코드도 접할 수 있었는데 이번에는 그 메세지들과 그에 따른 처리에 대해서 한 번 다뤄볼 것이다.
1. WM_PAINT 메세지
이전과 같이 프로젝트를 맨 처음 생성하면 아예 Visual Studio에서 자동으로 코드를 생성해준다는 것을 이제는 다 알 것이다. 그 중에서 WndProc라는 함수를 보면 프로그램의 실행 흐름이 case WM_PAINT라는 부분에 도달할 때, 화면에 그림을 그려준다는 주석을 발견할 수 있다. 크게 보면 우리가 게임을 만든다고 할 때, 해당 부분에 우리가 원하는 것을 화면에 출력하는 코드를 작성할 것이라는 소리가 된다.
그럼 여기서 알아야 하는 것이 있다. WM_PAINT라는 메세지는 언제 활성화가 되는걸까? MSDN에 따르면 "무효화 영역(Invalidate Rect)이 발생한 경우" 라고 말하고 있다. 그럼 무효화 영역이라는 것이 뭘까? 아래의 그림을 보자.
대충만 봐도 1번 그림판 위에 2번 그림판이 덮여있는 듯한 형태이다. 다시 말해서 1번 그림판이 2번 그림판에 의해 가려져 있는 상태라는 것이다. 인터넷을 뒤져보면 2번에 의해 가려진 1번 부분 즉, 저 부분을 무효화 영역이라고 이야기하는 것을 찾아볼 수 있다. 다시 말해서 저렇게 다른 창에 가려지고 비활성화가 된 상태에서 다시 1번 그림판을 클릭하면 1번 그림판이 나오고 2번 그림판이 1번 그림판에 의해 가려졌을 때, 1번 그림판 프로그램 코드에서 WM_PAINT라는 메세지가 발생해서 가려졌던 부분을 다시 그려준다는 것이다.
이제 여기서 WM_PAINT라는 케이스가 있는 부분에 중단점을 걸고 프로그램을 실행시켜 보겠다. 우선 프로그램이 실행되었으니 창이 뜰텐데 이걸 다른 창으로 가려본 후에 다시 우리가 만든 프로젝트가 생성한 창을 클릭하는 것이다. 어떻게 되는지 보자.
참 이상한 일이다. 분명 프로그램이 중단점에서 멈춰야 하는데 전혀 그러지 않고 있다. 누군가가 거짓말을 한 것일까? 사실 누구도 거짓말을 한 적이 없다. 다만 시간이 지나서 윈도우라는 운영체제가 업데이트를 거치면서 작동 방식이 바뀌었을 뿐이다. 버전이 올라가면서 비트맵의 형태로 띄워진 창 자체에서 내부적으로 정보를 가지고 있다가 필요할 때, 그 정보를 이용해서 다시 화면에 뿌리는 방식으로 말이다. 그럼 여기서 본인이 만든 프로젝트를 실행한 후에 최소화 버튼을 누른 후에 다시 화면에 띄워보자. 그럼 WM_PAINT에 걸었던 중단점이 걸리게 될 것이다.
2. WM_PAINT 케이스 내부
이쯤되면 WM_PAINT가 얼마나 중요한 부분인지 알게 되었을 것이다. 그런 의미에서 case WM_PAINT: 라는 부분을 좀 더 자세히 들여다 볼 것이다. 아래의 코드를 보자.
switch (message)
{
case WM_COMMAND:
{
int wmId = LOWORD(wParam);
// 메뉴 선택을 구문 분석합니다:
switch (wmId)
{
case IDM_ABOUT:
DialogBox(hInst, MAKEINTRESOURCE(IDD_ABOUTBOX), hWnd, About);
break;
case IDM_EXIT:
DestroyWindow(hWnd);
break;
default:
return DefWindowProc(hWnd, message, wParam, lParam);
}
}
break;
// 이 부분을 자세히 볼 것이다.
case WM_PAINT:
{
PAINTSTRUCT ps;
HDC hdc = BeginPaint(hWnd, &ps);
// TODO: 여기에 hdc를 사용하는 그리기 코드를 추가합니다...
EndPaint(hWnd, &ps);
}
break;
case WM_DESTROY:
PostQuitMessage(0);
break;
default:
return DefWindowProc(hWnd, message, wParam, lParam);
}
모르는 사람이 있을까봐 살짝 짚고 넘어가자면 switch case 안에는 원래 변수를 선언할 수 없다. 그러나 만약 그 안에서 변수를 선언하고 싶은 상황이 있다면 case 안에 { }를 넣은 후에 그 안에 코드를 작성하면 해당 지역에서 사용이 가능한 지역 변수를 선언할 수 있으니 알아놓도록 하자.
이쯤에서 본격적으로 WM_PAINT의 실행을 자세히 볼 건데 BeginPaint라는 함수를 보면 hWnd라는 것을 인자로 전달하는 것을 볼 수 있다. hWnd에 대해 알아볼 필요가 있는데 간략하게 말하면 윈도우 핸들의 역할을 하는 녀석이라고 보면 된다. 우리가 이전에 봤던 CreateWindowW라는 함수가 화면에 띄워질 창을 생성하는 함수이고 그 말은 곧 창을 의미하는 클래스에 따라 객체가 생성되어 메모리에 올라간다는 뜻이 된다.
여기서 주목할 점은 Win32 API를 사용하는 우리는 이런 클래스를 만든 적이 없고 직접 접근해서 사용하지도 못한다는 것이다. 운영체제에서 제공해주는 녀석으로써 우리가 건들면 안되기 때문이다. 이렇게 운영체제에서 제공해주는 객체를 커널 오브젝트(Kernel Object)라고 한다. 운영체제에서 관리하는 오브젝트이기 때문에 사용자가 직접 접근을 하지 못하게 막되 사용은 가능하게 해야하니 hWnd라는 화면에 띄우는 창을 대상으로 하는 키 즉, 핸들을 제공하는 것이다. 윈도우 핸들 뿐만 아니라 다른 커널 오브젝트들도 비슷하게 동작하는 경우가 많으니 이런 방식에 익숙해질 필요가 있다. 아래의 코드를 보자.
// 생략
case WM_PAINT:
{
PAINTSTRUCT ps;
HDC hdc = BeginPaint(hWnd, &ps);
// 사각형을 그리는 코드
Rectangle(hdc, 10, 10, 110, 110);
EndPaint(hWnd, &ps);
}
break;
// 생략
보면 BeginPaint라는 함수의 반환값을 hdc라는 변수가 받아서 사각형을 그릴 때 필요한 인자로 넘겨주고 있다. 사실 이 함수의 반환인 HDC는 Device Context라는 객체의 핸들을 의미하는 것으로 Rectangle이라는 함수에 이 hdc를 넘겨줬다는 것은 "이 핸들이 가지고 있는 Device Context를 이용해서 사각형을 그려주세요." 라는 의미로 보낸 hdc인 것이다. 그리고 이런 애들은 보통 DECLARE_HANDLE이라는 매크로 함수를 통해 정의되어 있다. 아래의 코드를 보자.
// 원래 아래와 같이 한 줄로 되어 있다.
#define DECLARE_HANDLE(name) struct name##__{int unused;}; typedef struct name##__ *name
// 보기 편하게 고치면 다음과 같다.
#define DECLARE_HANDLE(name) \
\
struct name##__\
{\
int unused;\
};\
\
typedef struct name##__ *name
보면 자료형의 이름(HWND라고 가정)을 매크로 함수의 인자로 전달하면 자동으로 해당 자료형의 이름에 맞는 구조체가 생성되는 것을 볼 수 있다. HWND__라는 구조체가 생기고 그 안에는 사용되지 않는다는 의미의 unused라는 멤버가 있으며 해당 구조체에 대한 포인터가 typedef으로 정의되어 있다. 왜 이런 식으로 해놨을까? 왜 다른 대부분의 핸들의 역할을 하는 것들을 죄다 이렇게 만들어 놨을까? 어차피 다 똑같은 4바이트짜리 구조체인데도 말이다.
우선 int unused; 라는 멤버는 본인이 가리킬 객체의 키 값이 들어있다. 그래서 사용자가 건들면 안되고 직접 사용하면 안되는 멤버라는 의미로 저 변수의 이름을 unused로 지었다는 것을 유추할 수 있다. 또한 똑같은 멤버를 가지는 구조체들을 매크로 함수를 사용해서 굳이 다른 이름으로 여러 개를 정의한 이유는 어떤 객체를 의미하는 핸들인지 한 눈에 보기 편하도록 가독성을 끌어올리는 의도로 작성된 코드라는 것도 유추할 수 있다.
3. 렌더링
기왕 hdc라는 변수를 설명할 때, DC에 대해서 언급했으니 좀 더 알아보도록 하자. DC는 컴퓨터 화면에 그림을 그리는데 필요한 데이터의 집합체라고 설명할 수 있다. 일반적으로 우리가 그림을 그린다고 하면 종이, 스케치 북 등 어디에 그림을 그릴지 결정해야 한다. 또한 연필, 펜과 같이 어떤 도구를 이용해서 스케치를 할 것이며 색칠은 무슨 색으로 할 것인지 결정해야 한다. 바로 그런 데이터들의 집합이 바로 DC이다.
DC는 디폴트 값으로 그림을 그리는 목적지를 hwnd로, 그림을 그리는 펜의 색깔은 검은색, 색을 칠하는 브러쉬의 색깔은 흰색으로 설정되어 있다. 그림을 그리는 코드를 추가하는 부분에 Rectangle함수를 이용해서 사각형을 그렸다고 했을 때, 사실 내부적으로 검은 선으로 사각형만 그리고 끝인 것이 아니라 그 안에 흰색을 칠하는 부분이 같이 동작하게 된다. 단지 배경이 흰색이라 색을 칠하지 않은 것처럼 보일 뿐이다.
그럼 이제부터 할 것이 뭔지도 대충 유추할 수 있다. hwnd가 가지고 있는 창에 우리가 직접 어떤 색과 두께로 선을 그리고 어떤 색으로 그림에 색을 칠할 것인지 우리가 직접 소스 코드로 설정을 해서 그림을 그리는 것이다. 그리고 지금부터 우리가 그림을 그리는 것 뿐만 아니라 다른 작업을 할 때에도 마찬가지인데 Win32 API에서 제공하는 것들 즉, 함수들을 외우는 것이 아니라 필요할 때마다 원하는 기능을 인터넷으로 찾고 사용법을 읽고 쓰는 방식으로 진행하게 될 것이다. 그럼 여기서 아래의 코드를 보자.
case WM_PAINT:
{
PAINTSTRUCT ps;
HDC hdc = BeginPaint(hWnd, &ps);
// 직접 펜과 브러쉬를 만들어서 DC에 적용
HPEN hRedPen = CreatePen(PS_SOLID, 1, RGB(255, 0, 0));
HBRUSH hBlueBrush = CreateSolidBrush(RGB(0, 0, 255));
// 기존 펜과 브러쉬 ID값을 저장
HPEN hDefaultPen = (HPEN)SelectObject(hdc, hRedPen);
HBRUSH hDefaultBrush = (HBRUSH)SelectObject(hdc, hBlueBrush);
// 사각형 출력
Rectangle(hdc, 10, 10, 110, 110);
// DC의 펜과 브러쉬를 원래 것으로 되돌림
SelectObject(hdc, hDefaultPen);
SelectObject(hdc, hDefaultBrush);
// 다 쓴 펜, 브러쉬 삭제 요청
DeleteObject(hRedPen);
DeleteObject(hBlueBrush);
EndPaint(hWnd, &ps);
}
아까 전에 WM_PAINT에서 그림을 그리는 코드를 작성할 것이라고 이야기했는데 이런 방식으로 작성된다. BeginPaint라는 함수에 현재 윈도우 핸들과 PAINTSTRUCT를 전달해서 그림을 그리는데 필요한 Device Context의 키라고 했던 HDC를 가져오는 역할을 한다. 여기서 PAINTSTRUCT에 대해 간단히 설명하자면 다음과 같다.
그리기를 요청하는 사각형의 왼쪽 위와 오른쪽 아래 모서리를 클라이언트 영역의 왼쪽 위 모서리를 기준으로 하는 장치 단위로 지정하는 RECT 구조체
출처: https://learn.microsoft.com/ko-kr/windows/win32/api/winuser/ns-winuser-paintstruct
그리고 이 키를 통해 상황에 맞춰서 아래의 함수들을 호출하게 된다. 이런 함수들은 외우는 것이 아니라 필요할 때, 찾아서 어떤 식으로 동작하는지 이해하고 가져다 쓰는 것들이라고 생각하면 된다.
// iStyle: 그림을 그리는 스타일
// cWidth: 선의 굵기
// color: 선 혹은 브러쉬의 색깔
// 그림을 그리는데 필요한 펜을 생성하고 기존에 사용중이었던 펜의 ID를 반환
HPEN CreatePen(int iStyle, int cWidth, COLORREF color);
// 색을 칠하는데 필요한 브러쉬를 생성하고 기존에 사용중이었던 브러쉬의 ID를 반환
HBRUSH CreateSolidBrush(COLORREF color);
// HGDIOBJ는 void* 이다.
// h로 전달된 GID 객체의 ID를 현재 사용중인 객체로 hdc에 해당하는 Device Context에 등록한다.
// 기존에 사용중이었던 펜 혹은 브러쉬와 같은 GDI 객체의 ID를 반환한다.
HGDIOBJ SelectObject(HDC hdc, HGDIOBJ h);
// h로 전달된 GDI 객체의 ID를 hdc에 해당하는 Device Context에서 제거한다.
// 제거가 되면 0이 아닌 값을, 안되면 0을 반환한다.
BOOL DeleteObject(HDC hdc, HGDIOBJ h);
4. WM_KEYDOWN
이제 화면에 뭔가를 그릴 수 있게 되었으니 게임처럼 플레이어의 입력을 받아서 화면에 그려진 무언가를 움직이게 해볼 차례이다. 그러려면 우선 플레이어의 입력을 인식하게 만들 방법이 필요한데 WndProc함수 내에 있는 switch 안에 새로운 케이스인 WM_KEYDOWN을 추가하면 된다. 이러면 플레이어의 입력이 감지되면 해당 케이스가 실행된다. 참고로 이런 입력은 키보드가 눌렸을 때 뿐 아니라 누르고 뗄 때, 마우스 클릭할 때, 다시 뗄 때 등... 많은 케이스가 있으니 적절히 활용하면 될 것이다. 이 부분은 굳이 여기서 보고 외우려 하지 말고 직접 찾아보는 것을 추천한다.
WndProc라는 함수의 매개변수 리스트를 보면 wParam과 lParam이라는 것도 볼 수 있는데 키보드 입력이면 wParam, 마우스 입력이면 lParam에 관련된 값이 입력된다. wParam에 키보드의 어떤 키를 입력했는지에 대한 정보가 들어가고 마우스가 클릭된 위치의 정보가 lParam에 입력된다. 그래서 키보드의 어떤 키가 입력되었는지 switch를 통해 알아낼 수 있다. 단, 아래의 영상 링크의 27:00 부분을 보면 영어 알파벳의 경우에는 대문자만 제대로 인식된다고 한다.
https://www.youtube.com/watch?v=RDJ-yCvT9DM&list=PL4SIC1d_ab-ZLg4TvAO5R4nqlJTyJXsPK&index=4
마우스 입력을 lParam으로 받아오는 경우에는 사실 2개의 변수가 필요하다. 하지만 Win32 API는 1개의 변수만으로 이를 구현했는데 비트 연산을 통해서 4바이트 정수 자료형에서 앞의 2바이트에 마우스의 x좌표, 뒤의 2바이트에 마우스의 y좌표가 저장된다. 또한 이 값을 받아오기 위한 비트 연산을 자동으로 해주는 매크로도 존재하며 x좌표를 받겠다면 LOWORD를 사용하고 y좌표를 받겠다면 HIWORD를 사용해서 lParam을 인자로 전달하면 된다.
자, 그런데 우리가 지금 할 것은 우리가 기존에 그렸던 사각형을 입력에 따라 다른 위치로 이동시켜야 한다. 즉, 입력이 들어오는 것에 따라 현재 그림을 지웠다가 다른 곳에 다시 그려야 한다는 뜻이다. 이걸 위해서 사각형의 현재 위치라는 개념도 필요하고 얼만큼 움직일 것인지에 대한 데이터도 필요할 것이다. 그걸 위해 위치를 표현하는 자료형을 Win32 API에서 제공하는 자료형이 있으니 그것이 바로 POINT라는 구조체이다. 이제 다음 코드를 보자.
// 예제에서 전역 변수로 선언했길래 여기서도 이렇게 했다.
// g_ptObjPos는 사각형의 위치 (사각형의 중앙을 기준)
// g_ptObjScale은 사각형의 가로, 세로 길이
POINT g_ptObjPos = { 500, 300 };
POINT g_ptObjScale = { 100, 100 };
// WndProc함수의 switch 내부라고 가정한다.
// 이제 상수값이 아니라 변수의 값을 이용해서 사각형을 그려준다.
Rectangle(hdc,
g_ptObjPos.x - g_ptObjScale.x / 2,
g_ptObjPos.y - g_ptObjScale.y / 2,
g_ptObjPos.x + g_ptObjScale.x / 2,
g_ptObjPos.y + g_ptObjScale.y / 2);
여기까지 했으면 이제 간단하다. 입력에 대한 부분 즉, case WM_KEYDOWN에 해당하는 부분에 키보드의 입력을 가져와서 case마다 사각형의 좌표만 바꿔주면 된다. 아래의 코드처럼 말이다.
case WM_KEYDOWN:
{
switch (wParam)
{
// 위를 뜻하는 방향키를 입력받으면?
case VK_UP:
// 사각형의 좌표를 위로 10픽셀 이동!
g_ptObjPos.y -= 10;
break;
// 나머지 코드 생략...
}
}
근데 사실 이렇게 해도 막상 프로그램을 실행시켰을 때, 위를 향하는 방향키 버튼을 눌러도 사각형은 전혀 이동하지 않는다. 근데 신기한 것은 창을 최소화 시켰다가 다시 창을 열면 사각형이 이동한 상태가 된다는 것이다... 왜 그럴까? 이유는 간단하다. 사각형을 그리려면 WndProc함수에 있는 switch에 WM_PAINT라는 메세지가 들어와야 하는데 그 메세지가 들어오는 타이밍이 우리가 원하는대로 들어오지 않기 때문이다. 이 메세지가 들어오는 타이밍은 위에서 서술했으니 생략하겠다. 강의에서 제시한 해결책은 무효화 영역이 발생했을 때 나오는 이벤트를 강제로 발생시키는 것이다. 이러면 우리가 원하는대로 방향키를 누를 때마다 해당 방향으로 10픽셀씩 이동하게 된다.
BOOL InvalidateRect(HWND hwnd, const RECT* lpRect, BOOL bErase);
// InvalidateRect 설명
// hwnd: 화면을 그려줄 윈도우의 키
// lpRect: 다시 그려줄 영역을 뜻하며 nullptr 전달 시, 전체 화면을 대상으로 함
// bErase: true면 지웠다가 다시 그리고 false면 그 상태에서 그대로 그린다.
case WM_KEYDOWN:
{
switch (wParam)
{
case VK_UP:
g_ptObjPos.y -= 10;
InvalidateRect(hwnd, nullptr, true); // 여기서 WM_PAINT 이벤트가 강제로 발생!
break;
// VK_LEFT, VK_RIGHT, VK_DOWN에 대한 부분은 스스로 작성해보자.
}
}
'Win32 API' 카테고리의 다른 글
어소트락 게임 아카데미 Win32 API 정주행 3회차 - PeekMessage (0) | 2023.05.20 |
---|---|
어소트락 게임 아카데미 Win32 API 정주행 1회차 - main.cpp (0) | 2023.05.18 |
어소트락 게임 아카데미 Win32 API 정주행 - Intro (0) | 2023.05.18 |