|
함수명 | 기본 구조 |
Init() | 초기화 함수 |
Update() | 데이터 갱신 함수 |
Render() | 화면 출력 함수 |
Release() | 해제 함수 |
[표 3-1] 함수명
■ C언어를 이용한 게임 기본 구조의 제작
이제 [표 3-1]에 정의한 함수들로 [그림 3-1]과 같은 프로그래밍 구조를 만들어 보자.
게임 프로그래밍을 하기 전에 이와 같은 구조를 만드는 것은 다음과 같은 이유 때문이다.
요즘 게임을 제작할 때 혼자서 제작하는 경우는 드물다. 그만큼 게임 제작에 들어갈 코드 규모가 상당히 커졌고 분업화되었다.
이러한 환경에서 하나의 게임을 여러 사람이 나누어 작업하는 경우를 생각해 보자.
코드를 통합하여 하나의 게임으로 만들기가 쉬울까? 만약 서로 다른 환경에서 작성한 코드라면 환경 설정 문제로 그래픽 엔진
(DirectX, OpenGL)이 다운되는 경우가 많을 것이다.
이러한 이유 때문에 게임의 기본 구조를 설명하고 그 구조를 만들어 모든 게임에 적용하는 것이다.
이 개념은 이 장의 마지막에 제작하는 ‘06 프레임워크’ 에서 자세히 살펴볼 것이다.
구분 | 역할 |
엔진 프로그래머 | DirectX와 OpenGL 그래픽 엔진을 이용하여 게임 전체에 적용될 라이브러리를 제작하거나 게임의 기본 환경을 만드는 역할을 한다. |
툴 프로그래머 | Win32 API, MFC, C#등을 이용하여 게임 저작 툴을 만들며 엔진 프로그래머들이 만든 라이브러리를 이용하여 게임 환경을 툴에서 테스트할 수 있도록 한다. |
UI 프로그래머 | 게임에 사용되는 버튼, 마우스, 인벤토리창등과 같은 인터페이스를 만드는 역할을 한다. 기본 환경은 엔진 프로그래머들이 제공하는 것을 사용한다. |
이펙트 프로그래머 | 게임에 사용될 각종 효과를 제작하며 기본 환경은 엔진 프로그래머들이 제공하는 것을 사용한다. |
네트워크 프로그래머 | 온라인 서버에 대한 모든 프로그래밍을 담당한다. |
[표 3-2] 게임 프로그래머의 역할
C언어로 구성한 게임 기본 구조
[그림 3-1]을 구성하기 위해 필요한 문법은 함수와 반복문 외에는 다른 것이 없음을 알 수 있다. 먼저 이 개념부터 확인하자.
프로그램은 순차적인 실행을 한다. 그 실행은 main() 함수 안에서 이루어진다.
이 점을 이해하는가? 그렇다면 [그림 3-1]에 대응되는 함수 4개는 다음 [소스 3-1]과 같이 나열할 수 있다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | #include <stdio.h>
void Init() { } void Update() { } void Render() { } void Release() { }
int main(void) { Init(); // 초기화 Update(); // 데이터 갱신 Render(); // 화면 출력 Release(); // 해제 return 0; } |
[소스 3-1] 기본 구조를 제작하기 위한 함수 선언
[소스 3-1]을 실행하게 되면 프로그램 시작과 동시에 종료하게 된다. 이것은 18행부터 21행까지를 순차적으로 실행한 후에 22행의
return 0;을 만나기 때문이다.
기본적으로 위의 4개의 함수는 정확히 한 번씩 실행되고 종료하게 된다.
이 구조를 보면 Init()와 Release() 함수는 제대로 실행한 것이지만 Update()와 Render()는 그 역할을 수행하지 못한 것임을 알 수
있다.
[그림 3-1]에서와 같이 Update()와 Render()가 무한 반복될 수 있도록 반복문을 적용해 보자. 반복문에 사용되는 구문으로는
while문과 for문, do while문이 있다.
그 중 while문 또는 for문을 무한 반복문에 사용하도록 한다.
반복문을 무한 반복하게 만드는 방법은 반복의 조건에 해당되는 부분을 항상 1로 만들거나 조건 자체를 두지 않는 방법이 있다.
아래의 [표 3-3]의 예는 모두 무한 반복을 하는 형식이다.
while( 1 ) {
} | for( ; ; ) {
} |
[표 3-3] 무한 반복문
이 무한 반복문을 [소스 3-1]에 적용해 보면 아래 [소스 3-2]와 같다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | #include <stdio.h>
void Init() { } void Update() { } void Render() { } void Release() { }
int main(void) { Init(); // 초기화
while( 1 ) { Update(); // 데이터 갱신 Render(); // 화면 출력 }
Release(); // 해제 return 0; } |
[소스 3-2] 완성된 기본 구조
[그림 3-1]을 소스로 옮긴 [소스 3-2]를 비교해 보면 그 구조가 코드로 잘 옮겨진 것을 알 수 있다.
여기까지가 개념을 코드로 옮긴 것이며 이것이 바로 프로그래밍이다.
즉 개념을 설계하고 코드를 적용할 수 있는 능력이 바로 프로그래밍 능력이다.
02 대기
[소스 3-2]에 게임 기본 구조가 잘 적용되어 있지만 콘솔창에서 이 구조를 사용하기에는 적잖은 문제가 있다.
기본적으로 게임은 화면에 이미지를 그리고 지우는 과정을 반복한다.
이때 DirectX와 OpenGL과 같은 그래픽 엔진은 이 과정을 빠르게 할 수 있도록 해주지만 우리가 제작하려는 콘솔창에서 이 과정을
실행하면 화면의 깜빡임이 발생하게 된다.
그 이유는 콘솔창에서는 고속으로 화면을 지우고 그리게 하는 부분이 없기 때문이다.
이 과정은 Render() 함수에서 일어나게 되는데 while문의 반복 속도는 CPU성능에 비례하므로 CPU 성능이 좋으면 좋을수록
깜빡임은 더욱 많아지게 된다.
이때 비디오 카드의 속도와 모니터와의 속도차이도 이와 같은 현상을 가속화시킨다.
이런 문제를 해결하기 위해 한 장면을 출력한 후에 약간의 대기 시간을 두어 실행속도를 조금 늦추는 방법을 사용하면 CPU성능이
좋아도 일정한 횟수이상을 반복하지 않게 된다.
일반적으로 인간은 1초에 30번 이상의 화면 전환이 발생하면 착시현상에 의해 부드럽게 연결된 동작으로 인식하게 된다.
이 원리를 이용하여 기본 구조에서 발생하는 깜빡임을 최소화하고 게임 진행 속도를 제어하면 다음과 같다.
1초에 약 30번의 화면 전환이 일어나도록 하기 위해서는 while문의 시작 부분부터 Render()가 끝나는 곳까지 걸린 시간을 이용한다.
컴퓨터에 사용하는 시간은 밀리세컨드(millisecond) 이므로 1초는 1000 밀리세컨드가 된다.
이 과정에서 1초에 30번 실행되기 위해서는 한번 화면이 출력되는데 약 33 밀리세컨드가 걸리면 된다. 즉 1초인 1000 밀리세컨드를
30으로 나누면 한 장면을 출력하는데 걸리는 시간이 아래 [표 3-4]와 같이 나오게 된다.
[표 3-4] 한 장면을 출력하는데 걸리는 시간
[그림 3-2] 한 장면을 출력하는데 걸린 시간의 영역
이를 구현하기 위해서 한 장면 출력하는데 걸리는 시간이 약 33 밀리세컨드 미만이면 대기 상태에 있게 하고, 33 밀리세컨드 이상이면 while문을 다시 반복하게 하면 1초에 약 33번을 반복하게 된다. 여기까지의 사항을 코드로 나타내면 아래 [소스 3-3]과 같다.
1 2 3 4 5 6 7 8 9 | while( 1 ) { CurTime = clock(); // 현재 시각 if( CurTime - OldTime > 33 ) { OldTime = CurTime; break; } } |
[소스 3-3] 대기 코드
4행을 보면 현재 시각과 이전 시각의 차이를 계산하여 33 밀리세컨드이면 6행과 같이 현재 시각을 이전 시각을 저장하는 OldTime에
대입하여 이때의 시각에서부터 다음 현재 시각까지의 차이를 계산할 수 있게 한다.
그리고 7행의 break를 만나면 1행의 무한 반복을 종료한다.
이와 같은 전체 과정을 코드로 나타내면 아래 [소스 3-4]와 같다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | #include <stdio.h> #include <time.h>
void Init() { } void Update() { } void Render() { } void Release() { }
int main(void) { clock_t CurTime, OldTime; Init(); // 초기화
OldTime = clock(); while( 1 ) { Update(); // 데이터 갱신 Render(); // 화면 출력
while( 1 ) // 대기 상태 진입 { CurTime = clock(); if( CurTime - OldTime > 33 ) { OldTme = CurTime; break; } } }
Release(); // 해제 return 0; } |
[소스 3-4] 대기 상태가 적용된 게임 기본 구조의 전체 소스
위의 23행부터 37행까지의 내용은 다음 코드로 바꾸어도 된다.
1 2 3 4 5 6 7 8 9 10 11 12 13 | while( 1 ) { OldTime = clock();
Update(); // 데이터 갱신 Render(); // 화면 출력
while( 1 ) // 대기 상태 진입 { CurTime = clock(); if( CurTime - OldTime > 33 ) break; } } |
[소스 3-5] 대기 코드(2)
03 게임 요소 동기화
게임은 캐릭터, 패턴, 충돌, 인공지능과 같은 많은 요소로 이루어져 있다. 그 중에서 주인공은 플레이어에 의해 움직이며, 그 외의 요소들은 자체적으로 설정된 시간 또는 값에 의해 스스로 움직인다.
만약 요소 간에 움직임의 기준이 다르다면 엉뚱한 시간에 개별적인 동작을 하게 될 것이다.
그래서 동기화는 게임 프로그래밍에선 필수이다.
동기화하는 방법으로 여러 가지가 있지만 대표적인 두 가지 방법만 살펴보자.
프레임값을 기준으로 하는 방법
이 방법은 화면 전환이 일어날 때마다 값을 1씩 증가하는 프레임 변수를 두고 현재 프레임과 이전 프레임의 차이를 계산하여
이동 또는 데이터를 갱신하는 방법을 말한다.
예를 들어 1초에 대략 30번 화면 전환이 발생하고 프레임 변수값이 60이 되었다면 경과된 시간은 2초가 된다.
이와 같은 계산으로 프레임에 따른 적 캐릭터의 출현을 [그림 3-3]과 같이 설정할 수 있으며 각 게임 요소를 프레임으로 동기화시킨다.
[그림 3-3] 프레임에 따른 캐릭터 출력
이 과정을 코드로 옮기면 [소스 3-5]와 같다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 | #include <stdio.h> #include <time.h>
int g_nFrameCount; // 프레임 변수
void Init() { } void Update() { } void Render() { } void Release() { }
int main(void) { clock_t CurTime, OldTime; Init(); // 초기화
OldTime = clock(); while( 1 ) { Update(); // 데이터 갱신 Render(); // 화면 출력
while( 1 ) // 대기 상태 진입 { CurTime = clock(); if( CurTime - OldTime > 33 ) { OldTme = CurTime; break; } } g_nFrameCount++; }
Release(); // 해제 return 0; } |
[소스 3-5] 프레임을 기준으로 동기화하기 위한 기본 구조
위의 소스 4행을 보면 g_nFrameCount 변수가 전역 변수로 선언된 것을 볼 수 있다.
이처럼 전역 변수로 선언한 이유는 여러 개체에서 이 변수값을 참조하여 동기화하기 때문이다. 39행을 보면 한 장면의 출력이 끝나면
g_nFrameCount값은 1씩 증가한다.
시간 간격을 이용하는 방법
이 방법은 오늘날의 대부분의 게임에서 사용하는 방법이며 앞으로 제작하는 게임에 주로 사용하는 방법이다.
시간 간격을 이용한 방법으로 적 캐릭터의 이동에 대해 살펴보자.
적 캐릭터마다 이동하는 시각이 다르다면 적 캐릭터는 이전에 이동한 시각과 현재 시각 차이를 계산하여 이동을 결정하게 된다.
이때 이동이 결정되면 현재 시각은 다음 이동을 위해 이전 이동 시각의 변수에 저장하여 다음번 시간 차이를 계산할 때 사용하게
된다.
이를 간단히 코드로 나타내면 아래 [소스 3-6]과 같다.
1 2 3 4 5 6 7 8 9 | CurTime = clock(); // 현재 시각
if( CurTime - OldMoveTime > 200 ) { OldMoveTime = CurTime; // 캐릭터 이동 nX++; nY++; } |
[소스 3-6] 시간 간격에 의한 적 캐릭터의 이동
아래 [그림 3-4]는 게임이 시작된 시각과 현재 시각의 차이를 계산하여 적 캐릭터가 출현하는 예이다.
[그림 3-4] 시간 차이에 따른 캐릭터 출현
[그림 3-4]에서 맨 처음 두 개의 적 캐릭터 출현 시각이 2000이라는 것은 게임이 시작된 후 2초일 때 출현하는 것을 의미한다.
만약 경과된 시간이 2100이라면 맨 앞의 두 마리 적 캐릭터는 출현하게 되며 나머지 적 캐릭터는 대기 상태에 놓이게 된다.
04 키보드 처리
화면으로부터 입력 받은 키 값을 처리하려면 먼저 키 입력을 감지해야 한다. 이를 위해 C 표준 함수인 _kbhit()와 _getch()를
활용하여 보자.
이 함수의 사용 방법은 2장에서 이미 다루었던 내용이므로 생략하도록 하고 프로그래밍적인 구조에 대해 집중해 보자.
키 입력은 게임의 흐름을 바꿀 수 있는 유일한 수단이다.
즉 게임을 종료하거나 주인공을 이동시키는 역할을 하며 이와 같은 동작은 데이터를 변경하게 한다.
그러므로 키 처리는 데이터 갱신인 Update() 함수에서 처리할 수 있지만 여기서는 키 처리 부분과 Update()에서 주요하게 다루는
충돌, 이동, 인공지능, 네트워크, 물리 등 을 구분하여 프로그래밍한다.
_kbhit()와 _getch()는 모두 입력 스트림을 사용하므로 키보드 버퍼의 내용에 예민하게 반응한다.
기본적인 흐름은 무한 반복문을 실행하면서 키 입력이 있으면 키보드 버퍼로부터 입력된 키 값을 가져와 처리하는 것이다.
이와 같은 기본적인 내용을 코드로 옮기면 아래 [소스 3-7]과 같다.
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 | #include <stdio.h> #include <conio.h> #include <time.h>
void Init() { } void Update() { } void Render() { } void Release() { }
int main(void) { int nKey; clock_t CurTime, OldTime; Init(); // 초기화
OldTime = clock(); while( 1 ) { if( _kbhit() ) { nKey = _getch(); if( nKey == 'q' ) break; switch( nKey ) { case 'j' : break; case 'l' : break; } }
Update(); // 데이터 갱신 Render(); // 화면 출력
while( 1 ) // 대기 상태 진입 { CurTime = clock(); if( CurTime - OldTime > 33 ) { OldTme = CurTime; break; } } }
Release(); // 해제 return 0; } |
[소스 3-7] 키 입력 처리
위의 소스에서 키 입력 처리는 27행부터 39행까지이다.
그 중에서 30행과 31행의 내용이 32행부터 38행의 switch()문 안으로 들어가야 할 것으로 생각할 수 있다.
이와 같이 switch()문에서 제외시킨 이유는 다른 키 입력에 비해 가장 우선순위가 높기 때문이다.
'q'키는 게임을 종료하는 키로 많이 사용되며 이 키가 눌려지면 게임 종료와 함께 main()을 종료하게 된다. 이처럼 게임 종료 키는
다른 키에 비해 가장 우선순위가 높으므로 가장 먼저 처리하기 위해 switch()문에서 제외시킨 것이다.
31행의 break는 25행의 while문을 종료하게 만드는 가장 간단한 방법이다.
31행의 break;가 실행되고 나면 제어는 55행으로 가서 Release()와 return 0;을 실행하고 프로그램은 종료된다.
05 게임 프로그래밍 용어
여기서 언급하는 게임 프로그래밍 용어는 앞으로 제작할 게임과 DirectX와 OpenGL, 스마트폰 게임을 제작할 때 공용으로 사용되는
용어이다.
이런 포괄적인 내용을 설명하는 이유는 다음 절에서 구현하게 될 프레임워크에 개념적으로 사용되기 때문이다.
여기서 언급하는 용어는 전부 번역된 용어로 소개하고 있지만 대부분 번역된 용어보다는 영문 용어 자체를 많이 사용하고 있으므로
양쪽 용어를 모두 익혀두도록 하자.
전위 버퍼(primary buffer)
전위 버퍼를 전위면이라고도 한다.
이 전위 버퍼는 화면과 일대일 대응되는 메모리를 말하며 그래픽 카드의 메인 메모리의 일부분이다.
현재 모니터의 해상도를 1024*768에서 1920*1080 으로 변경한다면 이 전위 버퍼에 해당되는 메모리 또한 늘어나게 된다.
또한 한 점의 색상을 출력하기 위한 비트수가 16 비트 또는 32 비트인가에 따라서도 크기는 달라진다.
전위 버퍼에 저장되는 모든 데이터는 색상 정보이며 모니터는 이 전위 버퍼의 내용을 그대로 색상으로 출력한다.
[그림 3-5] 전위 버퍼
후위 버퍼(back buffer)
후위 버퍼는 전위 버퍼와 동일한 특성을 가진 메모리를 말한다.
전위 버퍼는 모니터와 일대일 대응되므로 컴퓨터를 동작함과 동시에 그래픽 카드에 생성이 되지만 후위 버퍼는 따로 생성이 된다.
후위 버퍼는 그래픽 카드 메모리에 생성될 수 있지만 시스템 메모리에도 생성될 수 있다. 후위 버퍼는 전위 버퍼와 특성이 같으므로
전위 버퍼와 연결하여 사용하며 주로 시스템 메모리 보다는 그래픽 카드 메모리에 생성하여 빠른 화면 전환을 하기 위해 사용된다.
후위 버퍼는 그래픽 엔진(DirectX, OpenGL)에서도 많이 언급이 되는데 후위 버퍼라는 용어보다는 백 버퍼라는 용어를 많이 사용한다.
[그림 3-6] 후위 버퍼
이중 버퍼링(double buffering)
이중 버퍼링은 두 개의 버퍼를 이용하여 화면을 전환하는 방법을 말한다.
여기서 사용되는 두 개의 버퍼는 전위 버퍼와 후위 버퍼를 말하며 후위 버퍼의 내용을 전위 버퍼에 복사하여 출력하는 것을 이중 버퍼링이라고 한다.
그러면 왜? 이와 같은 방식이 필요한가를 생각해 보자.
전위 버퍼가 모니터와 일대일 대응되고 있는 순간에 전위 버퍼의 내용을 변경하게 되면 변경된 내용의 일부만 보여 지거나
이로 인해 깜빡임 또는 화면이 찢어지는 테어링(tearing) 현상이 발생된다. 또한 모니터의 화면 속도와 메모리 간의 속도 차이로
이런 현상이 발생하기도 한다.
이 현상을 줄이기 위해서 백 버퍼에 다음에 그려질 모든 내용을 미리 그려놓고 전위 버퍼에 한꺼번에 복사하는 방식을 사용한다.
이 복사 방식은 메모리와 메모리간의 복사이므로 앞에 언급한 테어링 현상과 깜빡임을 상당히 줄여준다.
[그림 3-7] 이중 버퍼링
페이지 전환(page flipping)
이중 버퍼링에 의해 메모리를 복사하는 방식을 사용하였지만 이 방식도 복사하는데 시간이 걸린다.
페이지 전환은 오늘날 모든 게임에서 사용하는 방식으로 실제로 전위 버퍼와 후위 버퍼간의 메모리 복사가 아닌 화면과 일대일 대응하는 메모리의 시작 주소를 바꾸는 방식이다. 예를 들어 현재 비디오 메모리의 0xa000:0000 번지가 전위 버퍼의 시작 메모리 주소라면 대기하고 있던 백 버퍼의 0xb800:0000을 전위 버퍼의 메모리 주소로 설정하여 한번은 백 버퍼의 내용을 출력하게 하고 다음번에는 그와 반대로 설정하여 출력하게 한다.
이와 같은 반복 동작은 전위 버퍼의 메모리와 백 버퍼의 메모리를 번갈아 가면서 출력하게 하는데 이 동작을 페이지 전환이라고 한다.
[그림 3-8] 페이지 전환
|