TL;DR —
PeekMessage로 윈도우 메시지를 처리하고 대기 중인 메시지가 없을 때update()와render()를 실행하는 게임 루프를 구성했습니다. 게임 상태는 정해진 순서로 갱신하고, 일반·반전·회전 스프라이트는 목적에 맞는GDI복사 함수와 중간 버퍼를 거쳐 하나의 백 버퍼에 합성했습니다.
Table of contents
Open Table of contents
들어가며
이 프로젝트는 WinAPI와 GDI로 2D 게임 클라이언트의 기본 실행 구조를 직접 구성한 메탈슬러그 모작입니다. 앞선 글에서 스프라이트 데이터와 파트 애니메이션을 다뤘다면, 이번 글에서는 그 데이터가 매 프레임 어떤 순서로 갱신되고 화면에 출력되는지 설명합니다.
핵심은 다음 두 가지였습니다.
- 윈도우 메시지 처리와 게임 프레임 진행을 하나의 루프에서 연결합니다.
- 스프라이트 변환과 월드·UI 출력을 버퍼별로 나눠 마지막에 한 번 합성합니다.
화면에 포함된 원작 리소스는 비상업적 학습용 모작에 사용했으며, 이 글에서는 직접 구성한 프레임 루프와 렌더링 흐름만 다룹니다.
사전 지식: 윈도우 메시지 루프와
HDC, 비트맵 복사에 대한 기본 이해를 전제로 합니다.
1. 메시지가 없을 때 게임 프레임을 진행했습니다
일반적인 윈도우 프로그램은 메시지 큐에서 메시지를 꺼내 처리합니다. 게임은 입력이나 창 메시지가 없을 때도 오브젝트와 애니메이션을 계속 갱신해야 하므로, 메시지가 들어올 때까지 기다리는 방식 대신 PeekMessage를 사용했습니다.
while (msg.message != WM_QUIT)
{
if (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE))
{
if (!TranslateAccelerator(msg.hwnd, hAccelTable, &msg))
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
else
{
Core::GetInstance()->progress();
}
}Main.cpp
처리할 메시지가 있으면 윈도우 프로시저로 전달하고, 메시지가 없으면 Core::progress()가 한 프레임을 진행합니다.
메시지 큐 확인
+-- 메시지 있음 --> TranslateMessage / DispatchMessage
`-- 메시지 없음 --> Core::progress
+-- update
`-- render
이 구조를 선택한 이유는 윈도우의 입력·창 관리 흐름을 유지하면서도 게임 로직이 지속적으로 실행되도록 만들기 위해서입니다. 프레임 시간은 TimeManager가 갱신하고, 이동과 애니메이션은 이 시간 값을 사용했습니다.
2. 갱신 순서를 하나의 프레임 계약으로 정했습니다
Core::update()는 각 시스템을 임의로 호출하지 않고 데이터가 필요한 순서에 맞춰 실행했습니다.
TimeManager::GetInstance()->update();
KeyManager::GetInstance()->update();
SceneManager::GetInstance()->update();
ColliderManager::GetInstance()->update();
SoundManager::GetInstance()->update();
mCamera::GetInstance()->update();
GameInterface::GetInstance()->update();
EventManager::GetInstance()->update();Core.cpp
프레임 안의 흐름을 풀면 다음과 같습니다.
시간 -> 입력 -> 씬 update/finalUpdate -> 충돌 -> 사운드
-> 카메라 -> UI -> 지연 이벤트 반영
SceneManager는 현재 씬의 모든 오브젝트에 update()를 호출한 뒤 finalUpdate()를 호출합니다. 오브젝트의 finalUpdate()에서는 중력, 강체 이동, 콜라이더의 최종 위치처럼 앞선 상태 변화에 의존하는 값을 정리합니다. 그다음 ColliderManager가 갱신된 위치를 기준으로 충돌을 검사합니다.
오브젝트 생성·삭제와 씬 전환은 요청 즉시 목록을 바꾸지 않고 EventManager에 모았다가 프레임 갱신의 마지막에 반영했습니다. 씬이 오브젝트 목록을 순회하는 도중 컨테이너가 바뀌는 상황을 피하고, 한 프레임에서 발생한 상태 변화를 정해진 지점에 모아 적용하기 위한 선택이었습니다.
3. 백 버퍼에 한 프레임을 완성한 뒤 출력했습니다
Core::init()은 윈도우 HDC와 호환되는 비트맵과 메모리 DC를 만들어 백 버퍼로 사용했습니다. render()는 백 버퍼를 먼저 지운 뒤 씬, 카메라, UI, 화면 전환 효과 순서로 그립니다.
gg->fillRectangle(0, 0, resolution.x, resolution.y, RGB::WHITE);
SceneManager::GetInstance()->render(*gg);
mCamera::GetInstance()->render(*gg);
GameInterface::GetInstance()->render(*gg);
BaseRenderer::GetInstance()->updateFade();
BitBlt(hdc, 0, 0, resolution.x, resolution.y,
backBuffer, 0, 0, SRCCOPY);Core.cpp
각 오브젝트를 바로 윈도우 DC에 그리지 않고 백 버퍼에서 한 프레임을 완성한 뒤 마지막에 BitBlt로 복사했습니다. 여러 오브젝트를 그리는 중간 결과가 창에 노출되지 않으므로 화면을 한 번에 교체할 수 있습니다.
월드 맵은 별도의 mMap::mDC에 구성했습니다. 맵과 월드 오브젝트를 먼저 이 버퍼에 그린 뒤, 카메라 위치와 배율을 적용해 백 버퍼로 복사했습니다. UI는 월드 카메라의 영향을 받지 않도록 백 버퍼에 직접 합성했습니다.
4. 스프라이트 변환에 따라 중간 버퍼를 선택했습니다
변환이 없는 스프라이트는 원본 텍스처의 프레임 영역을 TransparentBlt로 목적지에 바로 복사했습니다. 프레임 데이터에 저장된 투명색과 일치하는 픽셀은 제외했습니다.

일반 스프라이트의 버퍼 합성 순서
좌우·상하 반전은 StretchBlt의 원본 너비나 높이에 음수 값을 전달해 중간 renderDC에 뒤집어 그렸습니다. 변환된 결과를 다시 TransparentBlt로 맵 또는 백 버퍼에 합성했습니다.

반전 결과를 중간 버퍼에 만든 뒤 투명색을 적용해 합성하는 순서
회전은 프레임의 중심과 회전 기준점을 이용해 왼쪽 위, 오른쪽 위, 왼쪽 아래 세 점을 계산했습니다. PlgBlt는 이 세 점이 만드는 평행사변형에 원본 비트맵을 배치하고, 완성된 중간 버퍼를 다시 투명 합성했습니다.
원본 프레임
+-- 일반 --> TransparentBlt ------------------------+
+-- 반전 --> StretchBlt -> renderDC -> TransparentBlt
`-- 회전 --> PlgBlt -> renderDC -> TransparentBlt
v
맵 또는 백 버퍼

회전 결과를 중간 버퍼에서 만든 뒤 최종 목적지에 합성하는 순서
모든 스프라이트를 같은 함수로 처리하기보다, 변환이 필요 없는 경우에는 바로 복사하고 반전·회전일 때만 중간 버퍼를 사용했습니다. 최종 출력 경로는 다시 맵 버퍼와 백 버퍼로 모이므로 이후 카메라와 UI 합성 방식은 동일하게 유지할 수 있었습니다.
5. 입력부터 화면 출력까지 한 프레임으로 연결했습니다
플레이어가 이동하며 총을 발사한 한 프레임은 다음 순서로 처리됩니다.
KeyManager가 현재 키 상태를 갱신합니다.- 플레이어와 무기 상태가 바뀌고 오브젝트의 최종 위치가 계산됩니다.
- 갱신된 콜라이더 위치로 충돌을 검사합니다.
- 생성·삭제 요청을 프레임 마지막에 반영합니다.
- 맵과 오브젝트를 월드 버퍼에 그립니다.
- 월드 버퍼와 UI를 백 버퍼에 합성합니다.
- 완성된 백 버퍼를 윈도우
DC로 복사합니다.
메시지 처리, 게임 상태 갱신, 렌더링을 하나의 순서로 정하면서 각 시스템이 어느 시점의 데이터를 읽는지가 명확해졌습니다. 버퍼 역시 텍스처 변환, 월드 구성, 최종 출력이라는 역할로 나눠 같은 프레임 안에서 연결했습니다.
정리하며
핵심 요약:
PeekMessage로 윈도우 메시지 처리와 게임 프레임 진행을 연결했습니다.- 시간·입력·씬·충돌·이벤트 순서를 정해 시스템이 갱신된 데이터를 읽도록 구성했습니다.
- 생성·삭제와 씬 전환은 프레임 마지막에 반영했습니다.
- 월드와 UI를 백 버퍼에 합성한 뒤
BitBlt로 한 번에 출력했습니다. - 일반 복사와 반전·회전 변환을 나누고 필요한 경우에만 중간 버퍼를 사용했습니다.
이 구조를 구현하며 입력과 상태 변화가 어떤 순서로 렌더링 결과가 되는지 확인할 수 있었습니다. 각 기능을 개별적으로 만드는 것보다 한 프레임의 계약과 버퍼의 역할을 정하는 것이 2D 클라이언트 실행 구조의 중심이었습니다.
참고 자료
- Microsoft Learn — PeekMessageW — 메시지 큐를 확인하고 메시지를 가져오는
Win32API 동작을 확인했습니다. - Microsoft Learn — BitBlt — 원본
DC의 비트맵 영역을 목적지DC로 복사하는 동작을 확인했습니다. - Microsoft Learn — StretchBlt — 원본과 목적지 크기의 부호 차이를 이용한 비트맵 반전 동작을 확인했습니다.
- Microsoft Learn — PlgBlt — 세 목적지 점으로 정의한 평행사변형에 비트맵을 복사하는 동작을 확인했습니다.
이 게시물은 학습한 내용을 바탕으로 초안을 작성한 뒤, LLM의 도움을 받아 내용을 검수하고 다듬어 완성되었습니다.