기록공간

8장. 게임을 위한 순차 패턴 - 게임 루프 본문

Game Design Pattern, Logic

8장. 게임을 위한 순차 패턴 - 게임 루프

입코딩 2020. 4. 4. 13:05
반응형

"게임 시간 진행을 유저 입력, 프로세서 속도와 디커플링한다."

 


게임 루프 패턴은 모든 게임에서 사용하며, 어느 것도 서로 똑같지 않고, 게임이 아닌 분야에서는 그다지 쓰이지 않는다는 점에서 전형적인 '게임 프로그래밍 패턴'이다.

 

CPU와의 인터뷰

과거의 프로그램은 한참 기다려야 결과를 볼 수 있었다. 이를 배치 모드 프로그램이라고 한다. 프로그램은 모든 작업이 끝나면 멈췄다. 그렇기 때문에 과거 프로그래머들은 컴퓨터실에 가서 코드를 밀어 넣으면 결과가 나올 때 까지 몇 시간 기다리는 식으로 디버깅이 오래 걸렸다. 

 

즉각적인 피드백을 원했던 프로그래머들은 대화형 프로그램을 만들었다. 초기 대화형 프로그램 중에는 게임도 있었다.

당신은 작은 벽돌 건물 앞 막다른 길에 서 있다. 주변에는 숲이 있다. 
작은 물지기가 건물에서 흘러나와 개울로 향하고 있다.

> 들어간다
당신은 건물안에 들어왔다. 깊은 샘 위에 지운 우물집이었다.

이제 프로그램과 실시간으로 대화를 나눌 수 있게 되었다. 프로그램은 입력을 기다렸다가 응답한다. 그 말에 프로그래머가 다시 대답한다. 이런식으로 서로 번갈아 가며 얘기할 수 있다. 코드로 표현하면 다음과 같다.

while(true)
{
    char* command = readCommand();
    handleCommand(command);
}

 

이벤트 루프

최신 GUI 어플리케이션도 내부를 들여다보면 옛날 어드벤처 게임과 놀랄 정도로 비슷하다. 워드 프로세서만 해도 사용자가 키를 누르거나 클릭하기 전에는 가만히 기다린다.

while(true)
{
    Event* event = waitForEvent();
    dispatchEvent(event);
}

하지만 대부분의 다른 소프트웨어와는 달리, 게임은 유저 입력이 없어도 계속 돌아간다. 아무것도 하지 않은 채로 화면만 보고 있다고 해도 게임 화면은 멈추지 않고 애니메이션과 시각적인 연출을 계속한다.

 

루프에서 사용자 입력을 처리하지만 마냥 기다리고 있지 않는다는 점, 이게 게임 루프의 첫 번째 핵심이다. 루프는 종료하지 않는 한 끊임없이 돌아간다.

while(true)
{
    processInput();
    update();
    render();
}

보통 게임 루프는 위 코드에서 크게 달라지지 않는게 대부분이다. processInput()에서 유저 입력을 처리한다. update() 에서는 게임의 시뮬레이션을 한 단계 시뮬레이션하는데 AI와 물리등을 처리한다. 마지막으로render()는 플레이어가 어떤 일이 벌어지는지 알 수 있도록 게임 화면을 그린다.

 

게임 월드에서의 시간

루프가 입력을 기다리지 않는다면 루프가 도는 데 시간이 얼마나 걸리는지 궁금할 것이다. 게임루프가 돌 때마다 게임 상태는 조금씩 진행된다. 

 

그 동안 플레이어의 실제 시간도 흘러간다. 실제 시간 동안 게임 루프가 얼마나 많이 돌았는지를 측정하면 '초당 프레임 수(FPS)' 를 얻을 수 있다. 게임 루프가 빠르게 돌면 FPS가 올라가면서 부드럽고 빠른 화면을 볼 수 있다. 게임 루프가 느리면 스톱모션 처럼 끊어져 보인다.

 

앞에서 대충 만든 코드는 무조건 빠르게 루프를 돌기 때문에, 두 가지 요인이 프레임 레이트를 결정한다. 하나는 한 프레임에 얼마나 많은 작업을 하는가다. 물리 계산이 복잡하고 게임 객체가 많으며 그래픽이 정교해 CPU, GPU가 계속 바쁘다면 한 프레임에 걸리는 시간이 늘어난다.

 

다른 요인은 코드가 실행되는 플랫폼의 속도다. 하드웨어 속도가 빠르다면 같은 시간에 더 많은 코드를 실행할 것이다. 멀티코어, GPU, 오디오 하드웨어, OS 스케줄러 등도 한 틱에 걸리는 시간에 영향을 미친다.

 

게임 시간 vs 실제 시간

게임 개발 초창기에는 어느 CPU에서 실행될지 정확하게 알 수 있었기 때문에, 특정 CPU 전용 코드를 사용할 수 있었다. 개발자는 한 틱에 얼마나 많은 작업을 할지만 정하면 되었다.

 

하지만 요즘은 종류가 너무나도 다양해져서 개발 중인 게임이 정확히 어느 하드웨어에서 실행될지 정확하게 알 수 없게 되었다. 그렇기 때문에 어느 기계에서도 잘 돌아갈 수 있도록 만들어야 한다.

 

이와 같이, 어떤 하드웨어에서라도 일정한 속도로 실행될 수 있도록 하는 것이 게임 루프의 또 다른 핵심 업무다.

 


게임 루프를 요약하면 다음과 같다.

 

"게임 루프는 게임하는 내내 실행된다. 한 번 돌때마다 멈춤없이 유저 입력을 처리한 뒤 게임 상태를 업데이트하고 게임 화면을 렌더링한다. 시간 흐름에 따라 게임 플레이 속도를 조절한다."


예제 코드

게임 루프 코드는 생각보다 짧다. 여러 구현 방식을 각각의 장단점과 함께 알아보자.

 

최대한 빨리 달리기

while(true)
{
    processInput();
    update();
    render();
}

위에서 봤던 이 코드는 실행 속도를 제어할 수 없다는 문제가 있다. 빠른 하드웨어에서는 무슨 일이 벌어지는지 알 수 없을 정도로 루프가 빠르게 돌아가고, 느린 하드웨어에서는 게임이 느려진다. 콘텐츠, AI, 물리 계산이 많은 지역이나 레벨이 있다면, 그 부분에서만 게임이 느리게 될 것이다.

 

한숨 돌리기

첫 번째 변형에서는 위 문제를 간단하게 해결한다. 게임을 60FPS로 돌린다면 한 프레임에 16ms가 주어진다. 그동안 게임 진행과 렌더링을 다 할 수 있다면 프레임 레이트를 유지할 수 있다. 다음처럼 프레임을 실행 뒤에 다음 프레임까지 남은 시간을 기다리면 된다.

 

코드는 다음과 같다.

while(true)
{
    double start = getCurrentTime();
    processInput();
    update();
    render();
    
    sleep(start + MS_PER_FRAME - getcurrentTime());
}

한 프레임이 빨리 끝나도 sleep() 덕분에 게임이 너무 빨라지지는 않는다. 다만 너무 느려지는 건 막지 못한다. 그래픽과 AI 수준을 낮춰서 한 프레임에 할 일을 줄이는 식으로 문제를 회피할 수는 있다. 하지만 이러면 모든 유저, 빠른 하드웨어에서 플레이하는 유저의 게임플레이 품질에도 영향을 미친다.

 

한 번은 짧게, 한 번은 길게

좀 더 정교하게 만들어보자. 문제는 다음 두 가지로 볼 수 있다.

 

  1. 업데이트할 때마다 정해진 만큼 게임 시간이 진행된다.

  2. 업데이트하는 데에는 현실 세계의 시간이 어느 정도 걸린다.

2번이 1번보다 오래 걸리면 게임은 느려진다. 게임 시간을 16ms 진행하는 데 걸리는 시간이 16ms보다 더 걸리면 따라갈 수가 없다. 하지만 한 번에 게임 시간을 16ms 이상 진행할 수만 있다면, 업데이트 횟수가 적어도 따라갈 수 있다.

 

즉, 프레임 이후 실제 시간이 얼마나 지났는지에 따라 시간 간격을 조절하면 된다. 프레임이 오래 걸릴수록 게임 간격을 길게 잡는다. 필요에 따라 업데이트 단계를 조절할 수 있기 때문에 실제 시간을 따라갈 수 있다. 이런 걸 가변 시간 간격, 혹은 유동 시간 간격이라고 한다.

double lastTime = getCurrentTime();
while(true)
{
    double current = getCurrentTime();
    double elapsed = current - lastTime;
    processInput();
    update(elapsed);
    render();
    lastTime = current;
}

매 프레임마다 이전 게임 업데이트 이후 실제 시간이 얼마나 지났는지를 elapsed에 저장한다. 게임 상태를 업데이트할 때 elapsed를 같이 넘겨주면 받는 쪽에서는 지난 시간만큼 게임 월드 상태를 진행한다.

 

이렇게 하여 게임에서는 두 가지 이점을 얻게 되었다.

  • 다양한 하드웨어에서 비슷한 속도로 게임이 돌아간다.

  • 더 빠른 하드웨어를 사용하는 유저는 더 부드러운 게임플레이를 즐길 수 있다.

하지만 안타깝게도 한 가지 심각한 문제가 숨어 있다. 이 방식을 쓰면 게임이 비결정적이자 불안정하게 된다. 예제로 자세하게 살펴보자.

 

총게임을 1초 동안 50프레임이 실행되는 게임용 PC와 1초 동안 5프레임이 실행되는 사무용 PC가 있고 서로 게임 상에서 네트워크 통신을 한다고 가정해보다. 게임용 PC에서는 1초동안 총알 위치가 50번 업데이트하는 반면, 사무용 PC에서는 5번 밖에 업데이트 하지 못한다. 보통 게임에서는 부동 소수점을 쓰기 때문에 반올림 오차가 생기기 쉽다. 게임용 PC는 연산을 10배 더 많이 하기 때문에 사무용 PC보다 오차가 더 크게 쌓인다. 결국 PC에 따라 같은 총알의 위치가 달라진다. 다음과 같은 이유로 게임 물리 엔진이 있는 게임은 물리가 불안정해진다.

 

따라잡기

가변 시간 간격에 영향을 받지 않는 부분 중 하나가 렌더링이다. 렌더링은 실행되는 순간을 포착할 뿐, 이전 렌더링 이후로 시간이 어느 정도 지났는지는 고려하지 않는다. 그냥 때가 되면 렌더링 할 뿐이다.

 

이 점을 활용해보자. update()를 고정 시간 간격으로 업데이트할 것이다. 하지만 렌더링 간격은 유연하게 만들어 프로세서 낭비를 줄일 것이다.

 

원리는 다음과 같다. 이전 루프 이후로 실제 시간이 얼마나 지났는지를 확인한 후, 게임의 '현재'가 실제 시간의 '현재'를 따라잡을 때까지 고정 시간 간격만큼 게임 시간을 여러 번 시뮬레이션 한다.

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 변수에 저장한다. 이 값은 실제 시간에 비해 게임 시간이 얼마나 뒤처졌는지를 의미한다. 그다음으로 안에서 고정 시간 간격 방식으로 루프를 돌면서 실제 시간을 따라잡을 때까지 게임을 업데이트한다. 다 따라잡으면 렌더링하고 다시 루프를 실행한다. 그림으로 보면 다음과 같다.

 

여기에서 MS_PER_UPDATE는 게임을 얼마나 촘촘하게 업데이트할지에 대한 값이다. 시간 간격이 짧을 수록 실제 시간을 따라잡기가 더 오래 걸리고, 간격이 길수록 게임플레이가 끊겨 보인다. 

 

시간 간격이 너무 짧아지지 않도록 주의해야 한다. 가장 느린 하드웨어에서도 update()를 실행하는 데 걸리는 시간보다는 시간 간격이 커야 한다. 그렇지 않으면 게임 시간은 계속 뒤처지게 된다.

 

다행히 렌더링을 update() 루프에서 빼놓았기 때문에 CPU 시간에 여유가 좀 생겼다. 느린 PC에서는 화면이 조금 끊기겠지만, 결과적으로 안전한 고정 시간 간격을 이용해 여러 하드웨어에서 일정한 속도로 게임을 시뮬레이션할 수 있다.

 

중간에 끼는 경우

아직 lag 시간 문제가 남아 있다. 업데이트는 고정 시간 간격으로 하더라도, 렌더링은 그냥 한다. 즉, 유저 입장에서는 두 업데이트 사이에 렌더링되는 경우가 종종 있다.

 

업데이트는 정확하게 고정 간격으로 진행하지만, 렌더링은 가능할 때마다 한다. 업데이트보다는 빈번하지 않고 간격도 일정하지 않다. 여기까지는 괜찮다. 문제는 항상 업데이트 후에 렌더링되는 건 아니라는 점이다. 예를 들어 세 번째 렌더링은 두 업데이트 사이에 일어난다.

 

총알이 화면을 지나가는 걸 떠올려보자. 첫 번째 업데이트에서는 총알이 화면 왼쪽에 있다. 다음 업데이트에서는 오른쪽에 가 있다. 두 업데이트 중간에 렌더링하기 때문에 유저 있장에서는 화면 가운데 있는 총알을 볼 수 있어야 한다. 하지만 지금 구현에서는 여천히 화면 왼쪽에 있다. 즉, 움직임이 튀어 보인다.

 

다행히 렌더링 할때 업데이트 프레임이 시간적으로 얼마나 떨어져 있는지를 lag 값을 보고 정확하게 알 수 있다. lag 값이 0이 아니고 업데이트 시간 간격보다 적을 때는 업데이트 루프를 빠져나온다. 이때 lag에 있는 값은 다음 프레임까지 남은 시간이다.

 

렌더링할 때는 다음 값을 인수로 넘긴다.

render(lag / MS_PER_UPDATE);

렌더러는 게임 객체들과 각각의 현재 속도를 안다. 총알이 화면 왼쪽으로부터 20픽셀에 있고, 오른쪽으로 프레임당 400픽셀로 이동한다고 해보자. 프레임 중간이라면 render()는 0.5를 인수로 받아서 총알을 한 프레임의 중간인 220픽셀에 그린다.  움직임이 부드러워졌다.

 

물론 이런 보간은 틀릴 수 있다. 총알이 장애물에 부딪혔거나 느려졌을수도 있다. 렌더링에서는 현재 프레임에서의 위치와 다음 프레임에서의 예상 위치를 이용해 위치를 보간한다. 하지만 물리와 AI를 실제로 업데이트하기 전에는 정확한 위치를 알 수 없다. 그래도 보간을 하는게 하지 않아서 움직임이 튀는 것보다는 덜 거슬린다.


자료의 출처는 http://gameprogrammingpatterns.com/game-loop.html 입니다.

 

반응형
Comments