Game Loop Pattern(게임 루프 패턴)

2024. 6. 17. 16:25Game Develop/Design Pattern

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

 

게임 루프 패턴은 게임 시간 진행을 유저 입력, 프로세서 속도와 디커플링 하기 위해서 만들어 졌다.

 

예전 프로그램은 배치 모드 프로그램으로 코드를 작성하고 버튼을 누르면 한참 기다려야 결과를 볼 수 있었고, 작업이 끝나고 나면 프로그램은 멈췄다. vs의 콘솔창도 배치 모드 프로그램의 일종이다.

아무튼 이런 비효율적인 프로그램 대신에 즉각적인 피드백을 원했던 개발자들이 대화형 프로그램을 만들었다. 초기 대화형 프로그램 중에는 게임도 있었다. 작자가 예전에 만들었던 텍스트 RPG도 여기 해당된다.

프로그램은 입력을 기다렸다가 응답한다. 입력이 없으면 프로그램은 가만히 기다린다. 코드로 표현하면 다음과 같다.

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

 

최신 GUI 앱도 옛날 어드벤처 게임과 비슷하다.

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

이또한 문자 입력 대신 마우스나 키보드 입력 이벤트를 기다린다는 점 외에는 별반 차이가 없다.

 

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

 

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

 

While(true){
    processInput(); // 이전 호출 이후로 들어온 유저 입력 처리
    update(); // 게임 시뮬레이션을 한 단계 시뮬레이션, AI와 물리 처리
    render(); // 플레이어가 어떤 일이 벌어지는지 알 수 있도록 게임 화면을 그림
}

 

게임 월드에서의 시간은 초당 프레임 수(frame per second, FPS)에 결정된다. FPS란 실제 시간 동안 게임 루프가 얼마나 많이 돌았는지를 나타내는 단위이다. 게임 루프가 빠르게 돌면 FPS가 올라가면서 부드럽고 빠른 화면을 볼 수 있는 반면, 게임 루프가 느리면 스톱모션 영화처럼 뚝뚝 끊어져 보인다.

 

위의 코드는 루프를 무조건 빠르게 돌기 때문에 두가지 요인이 프레임 레이트를 결정한다.

하나는 한 프레임에 얼마나 많은 작업을 하는가다. 물리 계싼이 복잡하고 게임 객체가 많으며 그래픽이 정교해 CPU, GPU가 계속 바쁘다면 한 프레임에 걸리는 시간이 늘어난다.

 

다른 요인은 코드가 실행되는 플랫폼의 속도다. 하드웨어의 속도가 빠르다면 같은 시간에 더 많은 코드를 실행할 것이고, 그만큼 게임 시간은 빨라지게 된다.

 

따라서 위의 두 가지 이유 때문에 아주 큰 문제가 발생한다. 그것은 바로 성능이 좋은 컴퓨터와 그렇지 않은 컴퓨터 간에 차이가 발생한다는 것이다. 같은 게임을 더 빠른, 혹은 더 느린 기계에서 실행하면 게임 속도 또한 같이 빨라지거나 느려진다.

 

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

 

 

게임 루프는 전체 게임 코드 중에서도 가장 핵심이다. 10%의 코드가 프로그램 실행 시간 90%를 차지한다고들 한다. 게임 루프 코드는 분명 그 10%에 들어가기 때문에 최적화를 고려해 정교하게 작성해야할 필요가 있다.

 

자 이제 본격적으로 게임 루프에 대해 자세히 알아보자.

 

가장 간단한 게임 루프 형태는 이미 앞에 적어놓았다.

While(true){
    processInput(); // 이전 호출 이후로 들어온 유저 입력 처리
    update(); // 게임 시뮬레이션을 한 단계 시뮬레이션, AI와 물리 처리
    render(); // 플레이어가 어떤 일이 벌어지는지 알 수 있도록 게임 화면을 그림
}

이 방식은 게임 실행 속도를 제어할 수 없다는 문제가 있다. 하드웨어의 차이에 따라 게임의 속도가 달라진다. 또한 콘텐츠, AI, 물리 계산이 많은 지역이나 레벨이 있다면, 그 부분에서만 게임이 느리게 실행될 것이다. 이 방식을 최대한 빨리 달리기라고 표현하겠다.

 

 

위를 보완하기 위해 코드를 살짝 변형해 보겠다. 게임을 60FPS로 돌린다면 한 프레임에 16ms가 주어진다. 그 동안 게임 진행과 랜더링을 다 할 수 있다면 프레임 레이트를 유지할 수 있다. 다음 처럼 프레임을 실행한 뒤에 다음 프레임까지 남은 시간을 기다리면 된다.

코드는 대강 다음과 같다.

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

한 프레임이 빨리 끝나도 sleep() 메서드 덕분에 게임이 너무 빨라지지는 않는다. 하지만 너무 느려지는건 막지 못한다.

한 프레임을 업데이트하고 렌더링 하는데 걸리는 시간이 16ms 보다 길어진다면 소용 없기 때문이다. 대안으로 그래픽과 AI 수준을 낮춰서 업데이트하는 시간을 줄일수야 있겠지만 그렇게 된다면 고품질의 하드웨어에서 게임을 실행하는 유저의 게임 플레이 수준 또한 낮아지기 때문에 좋은 방법이라고는 할 수 없다.

 

 

조금 더 정교하게 만들어 보자. 위의 두 가지에서 문제가 발생한다.

 

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

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

 

2번이 1번보다 오래 걸린다면 게임은 당연히 느려지게 된다.

 

따라서 위의 문제를 타개하기 위해 프레임 이후로 실제 시간이 얼마나 지났는지에 따라 시간 간격을 조절하면 어떨까?

프레임이 오래 걸릴수록 게임 간격을 길게 잡는 것이다. 그렇게 하면 필요에 따라 업데이트 단계를 조절할 수 있기 때문에 실제 시간을 따라갈 수 있다.

이런 걸 가변 시간 간격, 혹은 유동 시간 간격이라고 한다.

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

매 프레임마다 이전 게임 업데이트 이후 실제 시간이 얼마나 지났는지를 elapsed에 저장한다.

게임 상태를 업데이트할 때 elapsed를 같이 넘겨주면 받는 쪽에서는 지난 시간만큼 게임 월드 상태를 진행한다.

 

한 가지 예를 드렁보자. 게임에서 총알이 날아간다. 고정 시간 간격에서는 매 프레임마다 총알 속도에 맞춰서 총알을 움직인다. 가변 시간 간격에서는 속도와 지나간 시간을 곱해서 이동 거리를 구한다.

시간 간격이 커지면 총알을 더 많이 움직인다. 짧고 빠른 간격으로 20번 업데이트를 하든, 크고 느린 간격으로 4번 업데이트를 하든 상관없이 총알은 같은 실제 시간 동안 같은 거리를 이동하게 된다.

이 정도면 전부 해결된 거 같아 보인다.

  • 다양한 하드웨어에서 비슷한 속도로 게임이 돌아간다.
  • 더 빠른 하드웨어를 사용하는 유저는 더 부드러운 게임플레이를 즐길 수 있다.

 

이제 문제가 다 해결된거 같아 보이는가? 안타깝게도 그렇지 않다. 위의 방식은 하드웨어의 품질에 따라 영향을 크게 받지 않는것 같아 보이지만 게임을 비결정적이자 불안정하게 만드는 심각한 문제가 숨어있다.

 

예시를 보자. 위와 같이 총알 위치를 20번 업데이트하는 컴퓨터 A와 4번 업데이트하는 컴퓨터 B로 네트워크 게임을 하고 있다고 치자. 보통 게임에서는 부동 소수점을 쓰기 때문에 반올림 오차가 생기기 쉽다. 따라서 업데이트를 5배 더 많이 하는 A가 B보다 오차가 훨씬 크게 쌓인다. 결국 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 변수에 저장한다. 그 다음으로 안에서 고정 시간 간격 방식으로 루프를 돌며 시제 시간을 따라잡을 때까지 업데이트한다. 후에는 렌더링하고 다시 루프를 실행한다.

 

여기에서 시간 간격(MS_PER_UPDATE)은 더이상 시각적 프레임 레이트가 아니다. 게임을 얼마나 촘촘하게 업데이트할지에 대한 값일 뿐이다. 가장 느린 하드웨어에서도 update()를 실행하는 데 걸리는 시간보다 크게만 설정해주면 된다.

 

렌더링을 update() 루프에서 빼놨기 때문에 CPU에 시간적 여유가 좀 생겼다.

 

 

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

 

위 그림에서 업데이트는 정확하게 고정 간격으로 진행하지만 렌더링은 가능할 대마다 한다.

업데이트보다는 빈번하지 않고 간격도 일정하지 않다. 여기까지는 괜찮지만 문제는 업데이트 후에 항상 렌더링 되는 건 아니라는 점이다. 이게 왜 문제가 되는 걸까?

 

세 번째 렌더링 처럼 업데이트 사이에 렌더링이 있으면 어떻게 되는지 살펴보자.

 

총알이 화면을 지나간다. 첫 번째 업데이트에서는 총알이 화면 왼쪽에 있다. 다음 업데이트에서는 오른쪽에 가있다. 하지만

렌더링은 그 중간에 실행되기 때문에 화면 중앙에 보여야할 총알이 유저에게는 여전히 왼쪽에 있는것처럼 보인다는 것이다.

 

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

render(lag / MS_PER_UPDATE);

 

위의 렌더 값을 업데이트로 넘겨주면 이 시간 만큼의 총알이 움직인 위치에 총알을 렌더링해줄 수 있는 것이다. 물론 이 또한 추측이기 때문에 게임 내의 모든 변수를 추측할 수 없는 노릇이지만, 적어도 보간을 하지 않아 움직임이 튀는 것보다는 덜 거슬린다.

 

 

 

자 마지막으로 게임 루프 디자인 패턴을 결정하기 위한 몇 가지 특징들을 살펴보고 마무리하겠다.

 

게임 루프를 직접 관리하는가, 플랫폼이 관리하는가?

대부분은 직접 게임 루프를 만들필요가 없다.

 

| 플랫폼 이벤트 루프 사용 |

 

  • 간단하다. 게임 핵심 루프 코드를 작성하고 최적화하느라 고민하지 않아도 된다.
  • 플랫폼에 잘 맞는다. 플랫폼이 자기 이벤트를 처리할 수 있도록 따로 시간을 주거나, 이벤트를 캐시한다거나, 플랫폼 입력 모델과 우리 쪽 입력 모델이 맞지 않는 부분을 맞춰주느라고 신경쓰지 않아도 된다.
  • 시간을 제어할 수 없다. 플랫폼은 자기가 적당하다고 생각할 때  코드를 호출하는데 원하는 만큼 자주,부드럽게 호출되지 안흥면 문제가 된다.

 

| 게임 엔진 루프 사용 |

 

  • 코드를 직접 작성하지 않아도 된다. 게임 루프를 만드는 건 쉽지 않다. 루프 코드는 매 프레임마다 실행되기 때문에. 사소한 버그나 약간의 최적화 문제도 크게 영향을 미친다.
  • 코드를 직접 작성할 수 없다. 엔진 루프에서 아쉬운 게 있어도 건드릴 수 없다는 게 단점이다.

 

| 직접 만든 루프 사용 |

 

  • 완전한 제어. 뭐든지 할 수 있다. 개발하는 게임에 딱 맞게 설계할 수 있다.
  • 플랫폼과 상호작용해야 한다. 애플리케이션 프레임워크나 OS 중에는 이벤트를 처리하고 다른 일을 하기 위해 시간을 쪼개주어야한다. 핵심 루프를 직접 만들다보면 프레임 워크가 행되지 않도록 가끔 제어권을 넘겨주어야한다.

 

전력 소모 문제

모바일 게임의 시대가 열리면서 전력 문제를 고민해야 할 가능성이 높아졌다. 아무리 재밌는 게임이라 할지라도 배터리가 30분만에 나가면 아무도 이 게임을 좋아하지 않을 것이기 때문이다.

 

| 최대한 빨리 실행하기 |

 

PC 게임이 주로 이렇게 하고 있다. 게임 루프에서 따로 OS에 sleep을 호출하지 않는다. 대기 시간이 남으면 FPS나 그래픽 품질을 더 높인다.

 

이 방식은 게임의 질을 높이겠지만 그만큼 전력 또한 최대로 사용한다.

 

| 프레임 레이트 제한하기 |

 

모바일 게임에서는 그래픽 품질보다는 게임플레이 품질에 더 집중하는 편이다. 프레임 레이트에 상한(30FPS 또한 60FPS)를 두는 게 보통이다. 게임 루프에서 프레임 시간 안에 할 일이 전부 끝났다면 나머지 시간 동안 sleep을 호출한다.

 

이 방식은 게임의 질도 어느정도 챙기고, 베터리 소모도 줄일 수 있다.

 

 

게임플레이 속도는 어떻게 제어할 것인가?

게임 루프에는 비동기 유저 입력과 시간 따라잡기라는 두가지 핵심 요소가 있다. 입렵을 쉽고 시간 다루기는 어렵다.

게임을 실행할 수 있는 플랫폼은 굉장히 많고, 한 가지 플랫폼에서만 돌아가는 게임은 메리트가 많이 없다.

이런 다양한 플랫폼을 어떻게 지원하느냐가 핵심이다.

 

| 동기화 없는 고정 시간 간격 방식 |

 

맨 처음 본 에제 코드가 이 방식이다. 게임 루프를 최대한 빠르게 실행한다.

  • 간단하다. 이게 유일한 장점이다.
  • 게임 속도는 하드웨어와 게임 복잡도에 바로 영향을 받는다. 조그만 성능 차이에도 게임 속도가 천차만별이다.
  •  

| 동기화하는 고정시간 방식 |

 

좀 더 복잡한 방법이다. 고정 시간 간격으로 게임을 실행하되, 지연이나 동기화 지점을 넣어 게임이 너무 빨리 실행되는 것을 막는다.

  • 간단한 편이다. 게임 루프에서는 어떻게든 동기화를 하기 마련이다. 이중 버퍼에 그린 후, 화면 재생 빈도에 맞춰 버퍼를 바꿔주게 된다.
  • 전력 효율이 높다. 모바일 게임에서는 굉장히 중요하다. 매 틱마다 조금이라도 쉬어줘야 전력을 아낄 수 있다.
  • 게임이 너무 빨라지진 않는다. 동기화 없는 고정 시간 간격 방식 에서 발생하는 문제의 반이 사라진다.
  • 겡미이 너무 느려질 수 있다. 한 프레임에서 업데이트 및 렌더링 하는 시간이 더 길어져 버리면, 재생이 느려진다.

 

 

| 가변 시간 간격 방식 |

 

되도록 쓰지 않는 것을 추천한다. 게임에 변수가 너무 많아진다.

  • 너무 느리거나 너무 빠른 곳에서도 맞춰서 플레이할 수 있다. 게임이 현실 시간을 따라가지 못하면 따라 잡을 수 있도록 시간 간격을 늘린다.
  • 게임 플레이를 불안정하고 비결정적으로 만든다. 이게 진짜 문제다. 특히 물리나 네트워크는 가변 시간 간격에서 훨씬 어렵다.

 

| 업데이트는 고정 시간 간격으로, 렌더링은 가변 시간 간격으로 |

 

예시 중 가장 마지막 방식으로, 복잡하지만 적응력도 가장 높다.

 

  • 너무 느리거나 너무 빨라도 잘 적응한다. 게임을 실시간으로 업데이트할 수만 있다면 뒤처질 일은 없다.
  • 훨씬 복잡하다. 구현이 더 복잡하다는게 주된 단점이다. 업데이트 시간 간격을 정할 대 고사양 유저를 위해 최대한 짧게 해야 하지만, 저사양 유저가 너무 느려지지 않도록 주의해야 한다.