게임 개발 기능반 회고

고등학교 때 약 2년 정도 혼자 게임을 만들고 있었다.

혼자 이것저것 만들어보는 것도 나쁘지는 않았는데, 어느 순간부터는 같이 개발해보고 싶다는 생각이 들었다. 그래서 게임 개발에 관심이 있고 작업 방식도 어느 정도 맞는 친구들을 모아서 학교에서 게임 개발 기능반을 만들었다.

당시 2학년 때 GIGDC에서 입상했던 것도 학교에 기능반을 만들어 달라고 이야기할 때 어느 정도 도움이 됐다. 그렇게 약 1년 동안 기능반 활동을 하면서 여러 게임을 만들었고, 지역 대회에서는 2등, 전국 대회에서는 5등을 했다.

지금 생각하면 결과 자체는 조금 아쉽다. 그래도 1년 동안 했던 개발 경험은 꽤 오래 남아 있다.

특히 이때는 Unity나 Unreal 같은 엔진을 사용하지 않고 C++과 DirectX 9를 사용했다. 처음부터 게임 엔진을 만드는 수준은 아니었지만, 윈도우 창부터 그래픽 출력, 게임 루프, 충돌 처리 같은 것들을 직접 붙여야 했다.

DirectX로 게임을 처음부터 만들어보기

기능반에서 가장 많이 했던 건 DirectX를 이용한 게임 개발이었다.

처음 프로젝트를 만들면 지금처럼 Unity 프로젝트가 바로 만들어지는 게 아니라 그냥 Visual Studio 프로젝트 하나가 있었다. Windows 환경에서 빈 프로젝트를 만들고 Win32 윈도우를 생성한 다음 Direct3D 장치를 초기화하는 것부터 시작했다.

wWinMain에서 시작해서 WNDCLASSEX를 등록하고 RegisterClassEx, CreateWindow 등을 이용해 HWND를 만들었다. 그 다음 Direct3DCreate9D3DPRESENT_PARAMETERS, CreateDevice를 이용해 Direct3D 디바이스를 만들었다.

처음에는 이런 코드들이 왜 필요한지도 잘 몰랐다. 그냥 예제 코드를 보고 따라 치는 부분도 있었다.

게임 루프도 직접 만들었다.

Clear -> BeginScene -> Update / Render -> EndScene -> Present

대략 이런 식으로 한 프레임이 돌아갔다. Windows 메시지는 PeekMessage로 처리했고, 게임 업데이트와 렌더링을 직접 연결했다.

렌더링에 필요한 기능들도 따로 묶었다. LPD3DXSPRITE를 이용한 2D 스프라이트 출력이나 LPD3DXFONT를 이용한 글자 출력 등을 RenderManager 쪽에서 관리했다.

지금 보면 별거 아닌 것처럼 보일 수도 있는데, 당시에는 화면에 이미지 하나 띄우는 것도 꽤 번거로웠다. Unity에서는 Sprite 하나 넣고 Transform을 움직이면 되는 일이었는데, 여기서는 내가 직접 어떤 장치에서 어떤 리소스를 사용해서 그릴지 신경 써야 했다.

그때부터 게임 엔진이 내부에서 상당히 많은 일을 대신하고 있다는 걸 조금씩 알게 됐다.

게임 루프와 Delta Time

처음에는 프레임 시간 처리도 제대로 하지 못했다.

GetTickCount64()를 이용해서 이전 프레임과 현재 프레임의 시간 차이를 구하고 그걸 Delta Time으로 사용했다. 나중에는 DXUT의 fElapsedTime도 사용했다.

처음에는 캐릭터 이동을 그냥 이런 식으로 처리했다.

Position += Speed

당연히 컴퓨터가 빠르면 더 많이 움직이고 느리면 덜 움직였다. 당시에는 왜 같은 게임인데 컴퓨터에 따라 속도가 달라지는지 제대로 이해하지 못했다.

그래서 나중에는

Position += Speed * DeltaTime

형태로 바꿨다.

지금 생각하면 정말 기본적인 내용인데, 직접 프레임에 따라 움직이는 게임을 만들어보니까 이 차이가 확실하게 와닿았다.

시간을 기준으로 게임이 진행된다는 것도 이때 제대로 이해했다.

충돌 처리와 간단한 물리

물리 엔진을 사용하지 않았기 때문에 충돌도 직접 처리했다.

2D에서는 주로 AABB 충돌을 사용했다.

rect1.left < rect2.right &&
rect1.top < rect2.bottom &&
rect1.right > rect2.left &&
rect1.bottom > rect2.top

이런 식으로 두 사각형이 겹치는지를 확인했다.

원형 충돌이 필요한 경우에는 두 원의 중심 사이 거리를 구해서 반지름의 합과 비교했다. 이때 매번 sqrt를 사용하는 것보다 거리의 제곱을 비교하는 방식도 사용했다.

그런데 충돌했다고 판정하는 것만으로는 게임이 이상하게 움직였다.

예를 들어 적이 플레이어에게 부딪혔다고 했을 때, “충돌했다”는 결과만 가지고는 플레이어가 실제로 어디로 밀려나야 하는지 알 수 없었다.

그래서 간단한 충돌 반응도 직접 만들었다.

두 오브젝트의 위치 차이로 방향을 구하고, 겹친 만큼 위치를 밀어내거나 속도에 영향을 주도록 했다. 공격을 맞으면 플레이어가 뒤로 밀려나는 넉백도 이런 식으로 처리했다.

중력과 점프도 별도의 물리 엔진 없이 만들었다.

Velocity.y += Gravity * DeltaTime;
Position += Velocity * DeltaTime;

점프할 때는 위쪽 방향의 초기 속도를 넣었다.

Velocity.y = JumpForce;

그 뒤에는 중력이 계속 적용되면서 속도가 줄어들고 다시 떨어졌다. 바닥에 닿으면 위치를 보정하고 수직 속도를 초기화했다.

지금 보면 굉장히 단순한 시스템이다. Rigidbody나 Solver 같은 게 있는 것도 아니고, 모든 상황을 처리할 수 있는 물리 시스템도 아니다.

그냥 게임에 필요한 만큼만 만든 간단한 물리였다.

그래도 이걸 직접 만들어본 뒤에 Unity의 Rigidbody나 Collider를 사용했을 때는 조금 다르게 보였다. 예전에는 그냥 “충돌을 알아서 처리해주는 컴포넌트” 정도로 생각했다면, 이후에는 위치와 속도, 중력, 충돌 판정, 충돌 후 반응이 어떻게 연결되는지 먼저 생각하게 됐다.

2D 게임을 만들면서 붙인 것들

기능반에서는 2D 슈팅 게임도 만들었다.

총알을 생성하고 관리하는 BulletManager를 만들고 적과 보스의 공격 패턴도 넣었다. 플레이어가 일반적으로 한 발을 쏘는 것뿐만 아니라 Control 키를 누르면 여러 방향으로 총알이 나가도록 만들기도 했다.

5-way 형태로 0도, ±10도, ±20도 방향으로 총알을 발사했다.

이때는 회전값과 방향 벡터를 연결해서 생각해야 했다. 나중에 3D를 하면서 sin, cos를 이용해 방향 벡터를 만드는 것도 비슷한 맥락에서 이해할 수 있었다.

적의 등장이나 보스 패턴, 배경과 블록, 사운드, 애니메이션 같은 것들도 하나씩 붙였다.

코드가 커지면서 자연스럽게 Manager가 많이 생겼다.

PlayerStatManager, CollisionManager, SceneManager 같은 식이었다.

당시에는 Singleton이나 전역 Manager를 상당히 많이 사용했다. 빠르게 기능을 만들기에는 편했다. 어디서든 접근할 수 있으니까 새로운 기능을 붙이는 속도도 빨랐다.

다만 프로젝트가 커지면서 서로 의존하는 코드가 점점 늘어났다. 나중에 다른 프로젝트를 만들면서 구조를 조금씩 바꾸게 된 이유이기도 하다.

FSM을 직접 만들어보기

기능반에서 게임을 만들면서 상태 관리가 필요해졌고 FSM도 직접 만들었다.

예를 들어 캐릭터라면

AT_NORMAL, AT_MOVE, AT_ATTACK, AT_DIE

같은 상태를 만들었다.

각 상태에 들어갈 때 실행할 함수와 업데이트할 함수, 나올 때 실행할 함수를 따로 두었다.

함수 포인터를 이용해서 상태별 함수를 연결했고, 상태가 바로 바뀌지 않고 다음 상태로 변경할 내용을 예약해두는 방식도 사용했다.

이게 필요했던 이유가 있었다.

게임 업데이트 도중에 상태를 바로 바꿔버리면 현재 실행 중인 로직과 새 상태의 로직이 한 프레임 안에서 섞일 수 있었다. 그래서 OnChangeReserveState 같은 방식으로 다음 상태를 예약해두고 정해진 시점에 변경하도록 했다.

이 구조는 캐릭터뿐만 아니라 Scene 상태에도 비슷하게 적용했다.

게임을 만들다 보니 결국 상태를 나누는 기준이 계속 생겼고, “현재 무엇을 하고 있는가”를 기준으로 코드를 나누는 게 생각보다 중요하다는 걸 알게 됐다.

3D를 하면서 수학을 다시 보게 됐다

2D 게임을 어느 정도 만들고 나서는 3D도 만졌다.

DirectX에서 3D를 구현하면서 World, View, Projection을 거치는 과정도 직접 다뤘다.

처음에는 행렬이 나오면 그냥 공식을 외우는 느낌이었다. 그런데 실제로 모델을 움직이고 카메라를 움직여보니까 왜 이런 행렬이 필요한지 조금씩 보였다.

D3DXMatrixLookAtLH를 이용해 카메라를 만들고, World-View-Projection을 적용해서 3D 공간의 물체를 화면에 출력했다.

.x 형식의 메시 파일을 불러와 DrawSubset(i)로 메시를 그렸고, Material과 Texture도 같이 관리했다.

캐릭터가 바라보는 방향을 계산할 때는 회전값에 sin, cos를 적용해서 방향 벡터를 만들었다.

2D에서 총알을 특정 각도로 발사했던 경험이 여기서 다시 연결됐다.

그리고 행렬 연산 순서가 바뀌면 결과가 완전히 달라지는 것도 직접 겪었다.

transform = center * scale * rotation * translate;

같은 식으로 변환을 조합했는데, 행렬 곱셈은 순서를 바꿔도 같은 결과가 나오는 게 아니었다. 그래서 Scale, Rotation, Translation을 어떤 순서로 적용하는지가 실제 화면에서 바로 드러났다.

수학 책에서 봤을 때는 그냥 공식이었는데, 모델이 엉뚱한 위치로 날아가거나 회전이 이상하게 되는 걸 보고 나서야 이게 실제 개발에서 쓰이는 거라는 느낌이 들었다.

기능반에서 사용했던 간단한 게임 프레임워크

대회를 준비하면서 매번 프로젝트를 처음부터 만들 수는 없었다.

그래서 어느 정도 공통으로 사용할 수 있는 구조를 만들었다.

상태 관리는 함수 포인터 기반 FSM으로 구성했고, 오브젝트와 컨트롤러도 어느 정도 분리했다.

예를 들어 CUnitObj가 실제 유닛의 데이터나 표현을 담당하고 CUnitController가 행동을 결정하는 식이었다.

컨트롤러에서 SetGoalPos 같은 방식으로 목표 위치를 설정하고, 이동 상태에서 목표에 도착하면 AT_MOVE에서 AT_NORMAL로 넘어가는 식으로 동작을 구성했다.

리소스도 하나씩 관리하지 않고 CMeshFileMgr, CTextureMgr, CSpriteMgr 같은 Manager를 만들어 공통으로 사용했다.

데이터도 코드에 전부 넣지 않으려고 했다.

GetPrivateProfileString을 이용해서 INI 파일에서 값을 읽고 SObjData, SRenderData 같은 구조체에 데이터를 넣었다.

예를 들어

Data/ObjData/test.ini

Data/RenderData/test.ini

같은 식으로 파일을 분리했다.

오브젝트의 종류나 이미지, 메시 경로처럼 자주 바뀌는 내용을 코드에서 직접 수정하지 않아도 되게 만들려고 했다.

지금 생각하면 아주 거창한 데이터 드리븐 구조는 아니지만, 그때는 이런 식으로 코드와 데이터를 나누는 것 자체가 꽤 큰 변화였다.

메모리와 리소스 관리에서 많이 깨졌다

C++로 게임을 만들면서 가장 많이 신경 썼던 것 중 하나가 메모리였다.

총알이나 적을 생성할 때 new를 사용하고 필요 없어지면 삭제해야 했다. DirectX 리소스는 Release()를 호출해야 했고, SAFE_DELETE, SAFE_RELEASE 같은 매크로도 사용했다.

문제는 실수하기 쉬웠다는 것이다.

어디선가 delete를 빼먹거나 Release를 하지 않으면 문제가 생겼고, 반대로 잘못 해제하면 크래시가 발생했다.

처음에는 기능을 만드는 것만 신경 썼는데 프로젝트가 커지니까 객체의 생성과 삭제 시점을 같이 생각해야 했다.

특히 총알처럼 생성과 삭제가 반복되는 객체는 매번 newdelete를 하는 것보다 미리 만들어놓고 재사용하는 Object Pooling 같은 방식이 왜 필요한지도 나중에 이해하게 됐다.

당시에는 완벽한 메모리 관리 구조를 만든 것은 아니었다. 오히려 실수를 많이 하면서 객체의 생명주기를 생각하게 된 쪽에 가깝다.

시간 안에 만들어야 한다는 것

대회용 게임을 만들면서는 코드 구조만 보고 개발할 수 없었다.

시간이 부족했다.

평소라면 구조를 다시 만들거나 클래스를 분리했을 부분도 그냥 빠르게 구현해야 하는 경우가 있었다. 반대로 한 번 만들어두면 여러 프로젝트에서 다시 사용할 수 있는 부분은 최대한 공통화했다.

처음에는 Singleton과 전역 Manager를 많이 사용했는데, 빠르게 만드는 데에는 확실히 편했다.

하지만 기능이 많아질수록 의존 관계가 복잡해졌다. 그래서 이후에는 오브젝트와 컨트롤러를 나누거나 FSM을 공통화하는 식으로 조금씩 구조를 바꿨다.

지금도 모든 프로젝트에서 처음부터 완벽한 구조를 만드는 게 맞다고 생각하지는 않는다.

그때는 대회까지 남은 시간이 더 중요했던 경우도 있었고, 실제로 만들어서 동작하는 게임이 필요했다.

다만 시간이 지나고 코드를 다시 보면 왜 그때 그런 구조를 사용했는지, 어떤 부분은 굳이 그렇게까지 만들 필요가 없었는지도 보이기 시작했다.

이것도 기능반에서 얻은 경험 중 하나였다.

Unity를 다시 봤을 때

기능반 이후 Unity를 사용하면서 느낌이 조금 달라졌다.

Unity에서는 GameObject를 만들고 Component를 붙이고 Rigidbody나 Collider를 사용하면 예전에는 복잡하게 직접 구현해야 했던 부분들이 어느 정도 준비되어 있었다.

그렇다고 Unity가 쉬워졌다는 의미보다는, 내부에서 무슨 일이 일어날지 조금 상상할 수 있게 됐다는 쪽에 가까웠다.

DirectX에서 직접 게임 루프를 돌리고, 렌더링하고, 행렬을 계산하고, 충돌을 처리해봤기 때문에 Unity에서 제공하는 기능들이 그냥 마법처럼 느껴지지는 않았다.

물론 실제 Unity 내부 구현이 내가 만들었던 코드와 똑같은 것은 아니다.

그래도 예전에 직접 만들었던 것들이 있으니까 엔진이 제공하는 기능을 사용할 때 “이 기능이 내부적으로 어떤 문제를 해결하기 위해 존재하는가”를 생각할 수 있게 됐다.

1년을 돌아보면서

지역 대회 2등, 전국 대회 5등이라는 결과만 놓고 보면 조금 아쉽다.

당시에는 더 높은 순위를 기대했기 때문에 결과가 나오고 나서 아쉬움이 꽤 남았다.

그래도 지금 와서 생각해보면 순위만 가지고 이 1년을 판단하기는 어렵다.

혼자 게임을 만들던 것에서 시작해서 직접 사람을 모으고 학교에 기능반을 만들었고, C++과 DirectX만 가지고 게임을 처음부터 만들어봤다.

게임 루프부터 렌더링, 충돌, 간단한 물리, FSM, 리소스 관리, 2D와 3D 수학까지 직접 부딪혀봤다.

물론 지금 기준으로 보면 부족한 부분도 많다. 구조도 완벽하지 않았고, 메모리 관리 때문에 삽질한 적도 많았다. 시간에 쫓겨 급하게 작성한 코드도 있었다.

그런데 오히려 그런 경험이 나중에 개발할 때 도움이 됐다.

Unity 같은 엔진을 사용할 때도 기능을 그냥 가져다 쓰는 것에서 끝내지 않고, 이 기능이 어떤 문제를 해결하려고 만들어졌는지 한 번 더 생각하게 됐기 때문이다.

기능반에서 1년 동안 했던 개발을 하나하나 떼어놓고 보면 작은 기능들이 많다. 하지만 지금까지 개발하면서 가장 기억에 남는 건 그 기능들을 직접 만들면서 게임이 여러 시스템이 붙어서 돌아간다는 걸 알게 된 것이다.

그 경험 자체는 지금도 꽤 소중하게 생각하고 있다.