의도
게임 시간 진행을 유저 입력, 프로세서 속도와 디커플링 한다.
동기
대화형 프로그램
게임 루프에 대해서 살펴보기 전에, 과거에는 프로그램이 배치 모드로 동작했다. 코드를 입력해 넣은 뒤 한참 뒤에 결과를 볼 수 있었다. 즉각적인 피드백을 원했던 프로그래머들은 대화형 프로그램(interactive)을 만들었다. 초기 대화형 프로그램 중에선 게임도 있었다. 예를 들면 다음과 같은 텍스트 어드벤처 게임이다.
당신은 던전 안에서 해골 전사와 마주쳤다. 해골 전사는 뼈 부딪히는 소리를 내며 당신에게 다가오고 있다.
> 공격한다
당신은 해골 전사를 공격해 산산조각 냈다!
경험치 10 획득
이제 프로그램과 실시간으로 대화를 나눌 수 있게 되었다. 프로그램은 입력을 기다렸다가 응답한다. 코드로 표현하면 다음과 같다.
while(true)
{
string command = readCommand(); // 명령어를 입력받는다.
handleCommand(command); // 명령어를 처리한다.
}
최신 GUI 애플리케이션도 명령어 입력 대신 마우스나 키보드 입력 이벤트를 기다린다는 점 외에는 기본적으로 사용자 입력을 받을 때까지 멈춰 있는 옛날 텍스트 어드벤처와 동작 방식이 별 차이가 없다.
게임
하지만 대부분의 다른 소프트웨어와는 달리, 게임은 유저 입력이 없어도 계속 돌아간다. 유저가 아무것도 하지 않은 채로 화면만 보고 있어도 게임 화면은 멈추지 않고 애니메이션과 시각적 연출을 계속해서 화면에 그린다(렌더링 한다). 루프에서 사용자 입력을 처리하지만 그것을 마냥 기다리지는 않는 것이 첫 번째 핵심이다. 루프는 끊임없이 돌아간다.
while(true)
{
processInput(); // 이전 호출 이후 들어온 유저 입력을 처리한다.
update(); // 게임을 시뮬레이션한다.
render(); // 게임 화면을 그린다.
}
update()
함수는 게임 시뮬레이션을 한 단계 시뮬레이션하는데, AI와 물리 등을 처리한다. 이는 업데이트 메서드 패턴을 쓰기 좋다.
상용 엔진의 예
Unity에서는 Update()
를, Unreal Engine에서는 Tick()
을 매 프레임마다 호출한다. 다만 상용 엔진에서는 물리 처리에 따라 호출되는 시기를 처리 전, 처리 중, 처리 후 등의 여러 개로 나눠 놓았다. 또 반드시 매 프레임마다 한 번씩 호출되는 것도 아니다. 더욱 자세한 건 Unity 문서나 Unreal Engine의 문서를 살펴보는 게 도움이 될 것이다.
게임 시간 vs 실제 시간
루프가 입력을 기다리지 않는다면 루프가 도는데 시간이 얼마나 걸리는지 궁금할 것이다. 게임 루프가 돌 때마다 게임 상태는 조금씩 진행된다. 게임의 NPC 입장에서는 게임 시간이 흘러가고 있는 셈이다. 게임 루프가 한 바퀴 도는 것을 보통 Tick
이나 Frame
이라고 부른다. 그동안 유저의 실제 시간도 흘러간다.
실제 시간 동안 게임 루프가 얼마나 많이 돌았는지를 측정하면 초당 프레임 수(frames per second, FPS)
를 얻을 수 있다. 게임 루프가 빨리 돌면 FPS
가 높아지면서 부드럽고 빠른 화면을 볼 수 있다. 게임 루프가 느리면 스톱모션 영화처럼 뚝뚝 끊어져 보인다.
앞에서 만든 루프 코드는 무조건 빠르게 루프를 돌기 때문에, 두 가지 요인이 프레임 레이트(frame rate)
를 결정한다.
- 한 프레임에 얼마나 많은 작업을 하는가? 물리 계산이 복잡하고 게임 객체가 많으며 그래픽이 정교해 CPU와 GPU가 계속 바쁘다면 한 프레임에 많은 작업을 하고 있으므로 걸리는 시간이 늘어난다.
- 코드가 실행되는 플랫폼의 속도. 하드웨어 속도가 빠르다면 같은 시간에 더 많은 코드를 실행할 것이다. 멀티코어, GPU, 전용 오디오 하드웨어, OS 스케줄러 등도 한 프레임에 걸리는 시간에 영향을 미친다.
과거에는 NES나 애플 II용 게임은 특정 CPU에서 실행되었기 때문에 두 번째 요인은 정해져 있었다. 개발자는 단지 게임이 원하는 속도를 낼 수 있도록 매 프레임마다 작업을 얼마나 할지를 고민해야 했다. 같은 게임을 더 빠른, 혹은 더 느린 기계에서 실행하면 게임 속도도 같이 빨라지거나 느려졌다. 때문에 옛날 PC에는 터보 버튼이 있어 새로 나온 빠른 PC에서 옛날 게임을 실행했을 때는 터보 버튼을 꺼 PC를 느리게 만들었다!
실제 게임에서의 예시
루카스아츠에서 개발한 클릭 어드벤처 게임 '그림 판당고'에서는 이것 때문에 현대 컴퓨터로는 게임 진행이 불가능한 버그가 있다. 주인공은 지게차를 타고 화물 엘리베이터에 탑승해서 엘리베이터를 올라간다. 올라가는 도중 지게차를 조작해서 특정 층에 걸쳐놔야 한다. 하지만 게임의 엘리베이터 속도가 CPU 속도에 비례하기 때문에 너무 빨라 불가능하다! 리마스터판에서는 이런 문제를 해결했지만, 여전히 본편에서는 문제가 발생하고 있어 강제로 CPU 속도를 낮추는 프로그램을 사용하지 않으면 진행이 불가하다고 한다. 관련 해결은 나무 위키 그림 판당고 문서에 나와있다.
요즘은 개발 중인 게임이 정확히 어느 하드웨어에서 실행될지 알 수 없기 때문에, 어떤 하드웨어에서라도 일정한 속도로 실행될 수 있도록 하는 것이 게임 루프의 업무이다. 게임 플레이 1초 동안 실행되는 Tick
수는 컴퓨터마다 크게 다르기 때문에, DeltaTime
(게임 루프가 호출되었을 때 이전 게임 루프 호출 이후 지난 시간)을 사용해서 FPS와 무관한 동작을 처리한다. 예를 들어 매 프레임마다 캐릭터의 x좌표를 움직이는 코드를 작성해보자.
float x = 0;
float speed = 10;
void update(float deltaTime)
{
// update가 호출될 때 마다 x좌표에 speed 값을 더해준다.
// 1초에 60번 호출하는 컴퓨터에서는 speed가 60번 더해질 것이다.
// 1초에 30번 호출하는 컴퓨터에서는 speed가 30번 더해질 것이다.
// 캐릭터는 전자의 컴퓨터에서 2배 빨리 움직인다.
x += speed;
// 반면 speed에 deltaTime를 곱해주면?
// 1초에 60번 호출하는 컴퓨터는 DeltaTime이 1/60이다. 따라서 speed의 1/60을 60번 더한다.
// 1초에 30번 호출하는 컴퓨터는 DeltaTime이 1/30이다. 따라서 speed의 1/30을 30번 더한다.
// 캐릭터는 두 컴퓨터에서 모두 동일한 속도로 움직인다.
x += speed * deltaTime;
}
차이점을 알겠는가?
패턴
게임 루프는 게임하는 내내 실행된다. 한 번 돌 때마다 멈춤 없이 유저 입력을 처리한 뒤 게임 상태를 업데이트하고 게임 화면을 렌더링 한다. 시간 흐름에 따라 게임 플레이 속도를 조절한다.
언제 쓸 것인가?
게임 엔진을 쓰고 있다면 직접 게임 루프를 작성하진 않을지 몰라도 여전히 어딘가에는 게임 루프가 있다. 턴제 게임에는 게임 루프가 필요 없다고 생각할지도 모르겠지만, 게임의 시각 및 청각 상태는 계속 바뀌어야 한다. 따라서 유저의 턴이 끝날 때까지 게임이 기다리는 동안에도 루프는 계속해서 돌며 애니메이션과 음악을 갱신해준다.
주의 사항
게임 루프는 전체 게임 코드 중에서도 핵심에 해당한다. 파레토 법칙(20%의 코드가 프로그램 실행 시간의 80%를 차지한다, 또는 20%의 코드가 시스템 부하의 80%를 차지한다)을 생각해봤을 때 게임 루프 코드는 분명 그 20% 안에 들어간다. 따라서 최적화를 고려해서 깐깐하게 만들어야 한다.
참고 문헌
한빛미디어, 게임 프로그래밍 패턴