게임 루프 패턴, Game Loop Pattern
게임 시간 진행을 유저 입력, 프로세서 속도와 디커플링한다.
게임 루프 패턴은 거의 모든 게임에서 사용된다.
CPU와의 인터뷰
즉각적인 피드백을 원했던 프로그래머들은 대화형 프로그램을 만들었다. 초기 대화형 프로그램 중에는 게임도 있었다.
옛날 어드벤쳐 게임을 살펴보자.
작은 벽돌 건물 앞 막다른 길에 서 있다.
> 들어간다
당신은 건물 안에 들어왔다.
이제 프로그램과 실시간으로 대화를 나눌 수 있게 되었다. 프로그램은 입력을 기다렸다가 응답한다.
코드로 표현하면 다음과 같다.
while(true) {
char* command = readCommand();
handleCommand(command);
}
이벤트 루프
최신 GUI 애플리케이션도 내부를 들여다보면 엣날 어드벤쳐 게임과 비슷하다. 워드 프로세서만 해도 사용자가 키를 누르거나 클릭하기 전에는 가만히 기다린다.
while(true) {
Event* event = waitForEvent();
dispatchEvent(event);
}
GUI 애플리케이션 역시 문자 입력 대신 마우스나 키보드 입력 이벤트를 기다린다는 점 외에는 기본적으로 사용자 입력을 받을 때까지 멈춰 있는 옛날 텍스트 어드벤처와 동작 방식에서 별 차이가 없다.
하지만 대부분의 다른 소프트웨어와는 달리, 게임은 유저 입력이 없어도 계속 돌아간다. 아무것도 하지 않은 채로 화면만 보고 있다고 해도 게임 화면은 멈추지 않고 애니메이션과 시각적 연출을 계속한다.
루프에서 사용자 입력을 처리하지만 마냥 기다리고 있지 않다는 점. 이게 루프의 첫 번째 핵심이다. 루프는 끊임없이 돌아간다.
while(true) {
processInput();
update();
render();
}
processInput() 에서는 이전 호출 이이후로 들어온 유저 입력을 처리한다. update() 에서는 게임 시뮬레이션을 한 단계 하는데 시뮬레이션 하는데 AI와 물리를(보통 이 순서다.) 처리한다. 마지막으로 render() 는 플레이어가 어떤 일이 벌어지는지 알 수 있도록 게임 화면을 그린다.
게임 월드에서의 시간
루프가 입력을 기다리지 않는다면 루프가 도는 데 시간이 얼마나 걸리는지 궁금할 것이다. 게임 루프가 돌 때마다 게임 상태는 조금씩 진행한다.
그동안 플레이어의 실제 시간도 흘러간다. 실제 시간 동안 게임 루프가 얼마나 많이 돌았는지를 측정하면 초당 프레임 수(frame per second) FPS를 얻을 수 있다.
두 가지 요인이 FPS를 결정한다. 하나는 한 프레임에 얼마나 많은 작업을 하는가와 코드가 실행되는 플랫폿의 속도다.
어떤 하드웨어에서라도 일정한 속도로 실행될 수 있도록 하는 것이 게임 루프의 또다른 핵심 업무다.
패턴
게임 루프는 게임하는 내내 실행된다. 한 번 돌때마다 멈춤 없이 유저 입력을 처리한 뒤 게임 상태를 업데이트하고 게임 화면을 렌더링한다. 시간 흐름에 따라 게임플레이 속도를 조절한다.
주의사항
게임 루프는 전체 게임 코드 중에서도 가장 핵심에 해당한다. 10%코드가 프로그램 실행 시간 90%를 차지한다고들 한다. 게임 루프는 코드는 분명 그 10%에 들어가기 때문에 최적화를 고려해 깐깐하게 만들어야 한다.
플랫폼의 이벤트 루프에 맞춰야 할 수도 있다.
그래픽 UI와 이벤트 루프가 들어 있는 OS나 플랫폼에서 게임을 만들 경우는, 애플리케이션 루프가 두 개 있는 셈이므로 잘 맟춰야 한다.
- 윈도우 API: PeekMessage()
- 웹 브라우저: requestAnimationFrame()
예제 코드
게임 루프에서는 AI와 렌더링 같은 게임 시스템을 진행하지만 게임 루프 패턴에만 집중하기 위해 예제에서는 가상의 함수를 호출한다.
최대한 빨리 달리기
while(true) {
processInput();
update();
render();
}
이 방식은 게임 실행 속도를 제어할 수 없다는 문제가 있다. 하드웨어에 따라 속도가 다르다. 콘텐츠, AI, 물리 계산이 많은 지역이나 레벨이 있다면, 그 부분에서만 게임이 느리게 실행될 것이다.
한숨 돌리기
게임을 60FPS로 돌린다면 한 프레임에 16ms가 주어진다. 그동안 게임 진행과 렌더링을 다 할 수있다면 프레임 레이트를 유지할 수 있다. 다음 처럼 프레임을 실행한 뒤에 다음 프레임까지 남은 시간을 기다리면 된다.
while(true) {
double start = getCurrentTime();
processInput();
update();
render();
sleep(start + MS_PER_FRAME - getCurrentTime());
}
한 프레임이 빨리 끝나도 sleep() 덕분에 게임이 너무 빨라지지 않는다. 다만 너무 느려지는건 막지 못한다.
한 번은 짧게, 한 번은 길게
위의 문제는 결국 다음 두 가지로 볼 수 있다.
- 업데이트 할 때마다 정해진 만큼 게임 시간이 진행된다.
- 업데이트하는 데는 현실 세계의 시간이 어느 정도 걸린다.
2번이 1번보다 오래 걸리면 게임은 느려진다. 게임 시간을 16ms 진행하는 데 걸리는 시간이 16ms보다 더 걸리면 따라갈 수가 없다. 하지만 한 번에 게임 시간을 16ms 이상 진행할 수만 있다면, 업데이트 횟수가 적어도 따라갈 수 있다.
즉, 프레임 이후로 실제 시간이 얼마나 지났는지에 따라 시간 간격을 조절하면 된다. 프레임이 오래 걸릴수록 게임 간격을 길게 잡는다. 필요에 따라 업데이트 단계를 조절할 수 잇기 때문에 실제 시간을 따라 갈 수 있다. 이런 걸 가변 시간 간격이라고 한다.
double lastTime = getCurrentTime();
while(true) {
double current = getCurrnetTime();
double elapsed = current - lastTime;
processInput();
update(elapsed);
render();
lastTime = current;
}
매 프레임마다 이전 게임 업데이트 이후 실제 시간이 얼마나 지났는지를 elapsed에 저장한다.
게임 사앹를 업데이트할 때 elapsed를 같이 넘겨주면 받는 쪽에서는 지난 시간만큼 게임 월드 상태를 진행한다.
게임에서 총알이 날아다닌다고 해보자. 고정 시간 간격에서는 매 프레임마다 총알 속도에 맟춰서 총알을 움진인다. 가변 시간 간격에서는 속도와 지나간 시간을 곱해서 이동 거리를 구한다. * 시간 간격이 커지면 총알을 더 많이 움직인다.* 짧고 빠른 간격으로 20번 업데이트하든, 크고 느린 간격으로 4번 업데이트하든 상관없이 총알은 같은 실제 시간 동안 같은 거리를 이동한다.
- 다양한 하드웨어에서 비슷한 속도로 게임이 돌아간다.
- 더 빠른 하드웨어를 사용하는 유저는 더 부드러운 게임플레이를 즐길 수 있다.
하지만 안타깝게도 네트워크 게임일 경우 PC에 따라 같은 총알의 위치가 달라진다는 심각한 문제가 숨어 있다.
따라잡기
가변 시간 간격에 영향을 받지 않는 부분 중 하나가 렌더링이다. 렌더링은 실행되는 순간을 포착할 뿐, 이전 렌더링 이후로 시간이 어느 정도 지났는지는 고려하지 않는다. 그냥 때가 되면 렌더링할 뿐이다.
이 점을 활용해보자. 모든 걸 간단하게 만들고 물리, AI도 좀더 안정적으로 만들기 위해 고정 시간 간격으로 업데이트할 것이다. 하지만 렌더링 간격은 유연하게 만들어 프로세서 낭비를 줄일 것이다.
원리는 다음과 같다. 이전 루프 이후로 실제 시간이 얼마나 지났는지를 확인한 후, 게임의 '현재'가 실제 시간의 '현재'를 따라잡을 때까지 고정 시간 간격만큼 게임 시간을 여러 번 시물레이션한다.
double previous = getCurrentTime();
double lag = 0.0;
while(true) {
double current = getCurrentTime();
double elapsed = current - previous;
previous = current;
lag += elapsed;
processInput();
while(lag >= MS_PER_UPDATE) {
update();
lag -= MS_PER_UPDATE;
}
render();
}
프레임을 시작할 때마다 실제 시간이 얼마나 지났는지를 lag 변수에 저장한다. 이 값은 실제 시간에 비해 게임 시간이 얼마나 뒤처졌는지를 의미한다. 그담으로 안에서 고정 시간 간격방식으로 루프를 돌면서 실제 시간을 따라잡을 때까지 게임을 업데이트한다. 다 따라잡은 후에는 렌더링하고 다시 루프를 실행한다.
여기에서 시간 간격(MSPERUPDATE)은 더 이상 시각적 프레임 레이트가 아니다. 게임을 어마나 촘촘하게 업데이트할지에 대한 값일 뿐이다. 시간 간격이 짧을수록 실제 시간을 따라잡기가 더 오래 걸리고, 시간 간격이 길수록 게임플레이가 끊겨 보인다.
또한 시간 간격이 너무 짧아지지 않도록 주의해야 한다. 가장 느린 하드웨어에서도 update()를 실행하는 데 걸리는 시간보다는 시간 간격이 커야한다. 그렇지 않으면 게임 시간은 꼐속 뒤처지게 된다.
중간에 끼는 경우
아직 자투리 시간 문제가 남아 있다. 업데이트는 고정 시간 간격으로 하더라도, 렌더링은 그냥 한다. 즉, 유저 입장에서는 두 업데이트 사이에 렌더링되는 경우가 종종 있다.
그림에서 업데이트는 정확하게 고정 간격으로 진행하지만, 렌더링은 가능할 때마다 한다. 문제는 항상 업데이트 후에 렌더링 하는 건 아니라는 점이다. 예를 들어 세 번째 렌더링은 두 업데이트 사이에 일어난다.
첫 번째 업데이트에서는 총알이 왼쪽에 있고, 다음 업데이에서는 오른쪽에 가있다고 가정해보자. 두 업데이트 중간에 렌더링하기 때문에 유저 입장에서는 화면 가운데 있는 총알을 볼 수 있어야 하지만 지금 구현에서는 여전히 화면 왼쪽에 있다.
다행이 렌더링할 때 업데이트 프레임이 시간적으로 얼마나 떨어져 있는지를 lag 값을 보고 정확하게 알 수 있다. lag 값이 0이 아니고 업데이트 시간 간격보다 적을 때는 업데이트 루프를 빠져나온다. 이때 lag에 있는 값은 다음 프레임까지 남은 시간이다.
렌더링할 때는 다음 값을 인수로 넘긴다.
render(lag / MS_PER_UPDATE); // 0 ~ 1 사이의 값
렌더러는 게임 객체들과 각각의 현재 속도를 안다. 총알이 화면 왼쪽으로부터 20픽셀에 있고, 오른쪽으로 프레임당 400픽셀로 이동한다고 해보자. 프레임 중간이라면 render()는 0.5를 인수로 받아서 총알을 한 프레임의 중간인 220픽세렝 그린다.
보간은 어느 정도 추측을 하기 때문에 틀릴 수 있따. 다행이 이런 위치 보간은 그다지 눈에 띄지 않는다. 적어도 보간을 하지 않아서 움직임이 튀는 것보다는 덜 거슬린다.
More
이 포스트의 글과 그림의 출처는 http://gameprogrammingpatterns.com/game-loop.html 입니다.
by 소년코딩
추천은 글쓴이에게 큰 도움이 됩니다.
악플보다 무서운 무플, 댓글은 블로그 운영에 큰 힘이됩니다.