Game Development, 게임개발/디자인패턴

Game Loop Pattern, 게임 루프 패턴 ** [디자인패턴]

게임이 더 좋아 2021. 10. 7. 18:27
반응형
728x170

 

요약하자면 

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

더 쉽게 말하자면 입력이 없어도 프로세서가 잘 돌아간다는 말이다.

 

**저자는 이 패턴이 가장 중요하고 많이 쓰인다고 말했다.

이 패턴은 특수한데 이름만 봐도 게임 분야에서만 쓸 것 같고 실제로 게임 분야 외에서는 찾아보기 힘들다.

게임 루프가 얼마나 유용한지 알아보면서 배워보자

 


 

 

예전의 프로그래머들은 코드를 모두 짜고 나서야 프로그램을 돌릴 수 있었다.

중간에 확인하는 것은 거의 불가능에 가까웠다.

이런 것을 배치모드, batch mode 프로그램이라고 하는데 모든 작업이 끝나고 나면 프로그램이 멈췄다.

요즘도 배치 모드 프로그램이 있지만

예전과 같이 카드에 구멍을 뚫어서 기계에 넣는 과정은 하지 않아도 되었다.

 

즉, 여기서 나오는 문제점은

결과도 오래 걸리면 디버깅하는 데도 오래 걸린다는 말이다.

즉각적인 피드백을 받는다면 바로바로 프로그램의 문제점을 고치면서 생산성이 올라갔을텐데..

예전에는

그러지 못했다.

 

그래서 이를 해결하는 게임인 대화형 프로그램을 만들었다.

"두 갈래 길이 나왔다. 어디로 가겠는가?"

1. 왼쪽

2. 오른쪽

 

등과 같이 플레이어의 입력을 기다렸다가 응답하는 형식이다.

프로그램은 플레이어의 입력을 기다린다.

while(true)... 하면서 무한 대기를 하다가 특정 조건을 만족하면 break로 탈출하는 느낌이다.

 


 

그러나 이러한 방식은 여전히 쓰인다.

최신 GUI 어플리케이션 내부를 들여다 봐도 비슷하다.

단적으로 워드 프로세서만 봐도 사용자 입력을 무한 대기한다.

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

 

 

GUI 어플리케이션 역시 문자 입력 대신 마우스나 키보드 입력 이벤트를 기다린다는 점 외에는

앞서 말한 옛날의 구현 방식과 똑같이 작동했다.

 

하지만 대부분의 다른 소프트웨어와는 달리,

게임은 유저 입력이 없어도 계속 돌아간다.

 

예를 들면 

몬스터가 나오는 필드에 가만히 있으면 몬스터가 계속 때려서 언젠가 죽는다.

 

 

루프에서 사용자 입력을 처리하지만 마냥 기다리고 있지 않는다 점

이게 게임 루프의 첫 번째 핵심이다.

 

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

 

즉, 항상 돌아간다. Input을 수시로 받으면서도 Update를 수시로 받으면서 프레임마다 렌더링을 한다.

위에 코드는 간략하게 설명했지만 정말 크게 다를 것이 없다.

 


 

그렇다면 게임 월드에서 시간과 분명

우리 현실 시간과 다를 것인데 어떻게 처리할 것인가?

가 궁금할 수 있다.

 

루프가 입력을 기다리지 않는다면 루프가 도는데 얼마나 걸리는지가 중요한 것이다.

만약 루프가 길다면 사용자 입력을 해도 입력을 받지 않는 경우가 생길 것이기 때문이다.

 

실제로는 FPS라고 frame per second라고 일초당 프레임으로 계산한다.

FPS가 높으면 높을수록 부드럽고 빠른 화면을 보여준다. 

 

그래 그렇다면 프레임 레이트, frame rate는 어떻게 결정되는가?

2가지 요인이 있다.

 

1. 한 프레임 안에서 얼마나 많은 작업을 수행하는가?

물리 계산이 복잡해지고 오브젝트가 많으며 그래픽이 정교해질수록 CPU,GPU가 작업할 것들이 많아진다.

그렇게 되면 프레임이 떨어지는 결과가 나온다.(그 반대에 비해)

 

2. 코드가 실행되는 플랫폼의 속도는 얼마나 빠른가?

하드웨어 속도가 빠르다면 같은 시간에 더 많은 작업이 수행이 가능할 것이다.

OS가 더 최적화된 플랫폼에서 더 빠르게 수행할 것이다.

GPU와 맞는 시스템에서 더 빠르게... 등등

 

요인이 너무 많다. 때문에 우리는 프레임을 고정으로 사용하는 경우가 많다.

아니 보장하는 경우가 많다.

60 프레임을 넘을 수 있는 하드웨어를 가져도 60으로 고정하고

프레임이 낮아질 경우 적절한 조치를 해서 다시 60으로 올리는 것과 같은 일이다.

 

** 또한 모든 하드웨어가 똑같은 속도를 보장하지 않으므로 게임 루프에서는 어느 하드웨어에서라도 일정한 속도에서 실행될 수 있도록 보장해야 한다.

 

 


 

게임 루프 패턴은 게임 실행 내내 실행된다.

즉, 시간에 흐름에 따라 게임 플레이 속도를 조절한다.

그렇기 때문에 게임 루프는 최적화를 고려하여 가장 까다로운 검정과정을 거쳐야 한다.

이러한 게임 루프를 예제를 통해서 알아보자

 


 

아까 봤던 것들이다.

 

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

 

앞서 본 예제를 다시 데려왔다. 여전히 짧다.

게임 루프에서는 AI와 렌더링 같은 게임 시스템을 진행하지만

게임 루프 패턴에만 집중하기 위해 예제에서는 가상의 함수를 호출한다.

**render나 update는 사용자가 정의하기 나름이다.

 

위에 써있는 방식은 게임 실행 속도를 제어할 수 없다는 문제점이 있다.

아까도 말했듯이 플레이어에게 게임의 일정한 속도를 보장해주어야 하는데 위 코드는 빠른 하드웨어에서는

모든 것이 빨라질 것이다.

나는 분명 "w" 키를 잠깐 누른 것 같지만 저기 멀리 캐릭터가 이동해있다거나 그런 것이다.

 

즉, 연산이 많은 부분만 게임 속도가 느려지고

간단한 부분에선 과도하게 빨라지는 등

말도 안되는 그러한 현상이 나타날 것이다.

 

 


 

만약 60FPS로 게임을 돌린다면 한 프레임당 약 16.66ms 가 걸린다.

그 동안 게임 진행과 렌더링을 다 하게 된다면 프레임을 유지하는 것이 가능하게 된다.

나머지 시간은 기다리는 것으로 프레임을 구성하면 된다.

https://gameprogrammingpatterns.com/game-loop.html

 

코드는 조금 더 자세해졌다.

while (true)
{
  double start = getCurrentTime();
  processInput();
  update();
  render();

	//남은 프레임은 그냥 대기한다.
  sleep(start + MS_PER_FRAME - getCurrentTime());
}

 

한 프레임이 빨리 끝나더라도 sleep() 함수 덕분에 빨라지는 것은 막을 수 있다.

**다만 느려지는 것은 막지 못한다...?

맞다. 

게임하다 보면 "님 잠만 나 프레임 드랍옴" 이러는 것이다.

한 프레임을 업데이트하고 렌더링하는 데 걸리는 시간이 16.66ms 이상이 된다면

sleep()함수가 수행하는 시간이 음수가 된다.

이는 말이 안되는 것이고.. 결국 게임이 느려진다.

 

이는 그래픽과 AI 수준을 낮춰서 한 프레임에 연산되는 횟수를 낮춤으로 해결할 수도 있다.

하지만 이렇게 구현한다면 고품질로 게임을 즐기려는 사람이 피해보게 된다.

 


다시 말하자면

문제는 2가지다.

 

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를 같이 넘겨주어

받는 쪽에서 지난 시간만큼 게임 월드 상태를 진행하게 된다.

 

게임에서 총알이 날아다닌다고 해보자

고정 시간 간격에서는 매 프레임마다 총알 속도에 맞춰서 총알을 움직인다.

가변 시간 간격에서는 속도와 지나간 시간을 곱해서 이동거리를 구현해야 한다.

시간 간격이 커지면 총알을 더 많이 움직이면 된다.

 

짧고 빠르게 20번 업데이트 하거나 길고 느리게 4번 업데이트하나 

총알은 같은 실제 시간 동안 같은 거리를 날아가게 된다.

??? 뭐야 간단히 해결됐네??

가 아니다.

 

이 방식을 쓰면 게임이 indeterministic, 비결정적이게 된다.

??

무슨 문제인지 예제를 통해 알아보자.

 

2인용 네트워크 게임이 있다고 하자.

나는 최신형 PC로 들어왔고

친구는 할머니 집에 왜 있는지도 모르는 컴퓨터로 겨우겨우 랜선 CAT.4를 연결해서 들어왔다.

 

앞서 말했듯이 내 컴퓨터에서 총알은 업데이트가 무진장 많이 될 것이고

친구 컴퓨터에서는 될까 말까한 정도이다.

(업데이트하는 연산을 컴퓨터가 안좋아서 하기 힘들기 때문)

 

내 PC에서는 1초에 50번 업데이트가 가능한 반면

친구는 1초에 5번 될까말까 한다.

 

보통 게임에서는 부동 소수점, floating point를 쓰기 때문에 "반올림 오차"가 생기는데 

부동 소수점의 숫자를 더할 때마다 결과 값에 오차가 생길 수 있다.

 

내 PC에서는 오차를 포함한 연산이 친구보다 10배 더 많이 하기 때문에

같은 총알의 위치가 서로 다르게 보인다.

 

가변 시간 간격에서는 결국 이러한 문제가 생기게 된다. 또 있다.

실시간으로 실행하기 위해 게임 물리 엔진은 실제 물리 법칙의 근사치를 취한다.

근사치가 튀는 것을 막기 위해서 감쇠, damping을 적용하는데

감쇠는 시간 간격에 맞춰 세심하게 조정해야 한다.

감쇠 값이 바뀌다보면 물리가 불안정해지는 결과가 나온다. 

**배틀그라운드에서 갑자기 공중으로 날라가는 것을 본 적이 있는가?

 

 

이런 불안정성은 충분히 나쁜데.. 더 알아보진 말자.. 충분하다

 


 

가변 시간 간격에 영향을 받지 않는 부분 중 하나가 렌더링이다.

렌더링은 실행되는 순간을 포착할 뿐, 이전 렌더링 이후로 시간이 어느 정도 지났는지는 고려하지 않는다.

그냥 때가 되면 렌더링할 뿐이다.

 

이 점을 활용해보자.

모든 것을 더 간단하게 만들고, 물리, AI도 조금 더 안정적으로 만들기 위해

고정 시간 간격으로 업데이트해보자.

하지만 렌더링 간격은 유연하게 만들어서 프로세서 낭비를 줄여보자.

 

원리는 

이전 루프 이후로 실제 시간이 얼마나 지났는지를 확인한 후

게임의 "현재"실제 시간의 "현재"를 따라잡을 때까지 고정 시간 간격만큼 게임 시간을 여러 번 시뮬레이션 하는 것이다.

 

즉, 실제 시간을 따라잡기 위한 코드다.

double previous = getCurrentTime();
double lag = 0.0;
while (true)
{
  double current = getCurrentTime();
  double elapsed = current - previous;
  previous = current;
  lag += elapsed;

  processInput();
	
  // lag이 더 크면.. 게임 시간이 더 느리면 업데이트를 해주어 lag을 줄이면서 현재시간과 같게 해줌
  while (lag >= MS_PER_UPDATE)
  {
    update();
    lag -= MS_PER_UPDATE;
    //해당만큼 따라잡겠다.
    //만약 update 하는 시간이 MS_PER_UPDATE 보다 길어지면 따라잡을 수 없음.
    //update 시간 - MS_PER_UPDATE 만큼 따라잡기
  }

  render();
}

 

프레임을 시작할 때마다 실제 시간이 얼마나 지났는지를 lag 변수에 저정한다.

이 값은 실제 시간에 비해 게임 시간이 얼마나 뒤쳐졌는지를 의미한다.

그 다음은 안에서 고정 시간 간격 방식으로 루프를 돌면서

실제 시간을 따라잡을 때까지 게임을 업데이트하는 것이다.

다 따라잡았다면 그 후 렌더링을 하고 다시 루프를 실행한다.

 

 

여기서 시간 간격, MS_PER_UPDATE는 더이상 시각적 프레임 레이트가 아니다.

게임을 얼마나 촘촘하게 업데이트 할 지에 대한 값일 뿐이다.

 

시간 간격이 짧을수록 실제 시간을 따라잡기가 오래 걸리지만 부드럽고

시간 간격이 길수록 실제 시간을 따라잡기는 쉽지만 게임이 끊겨 보인다.

 

이상적인 것은 보통의 게임에서 60FPS 이상을 유지하도록 시간 간격을 짧게 잡아서 부드럽게 구현하는 것이다.

 

하지만 시간 간격이 너무 짧아지는 것도 안된다.

가장 느린 하드웨어에서 돌린다고 가정했을 때에도

update를 실행하는 데 걸리는 시간보다(업데이트 하는 데 걸리는 시간)는

시간 간격(MS_PER_UPDATE)이 커야 한다.

그렇지 않으면 게임 시간은 따라잡을 수가 없는 상태가 되어버린다.

 

다행히 렌더링을 update 때 루프에서 돌지 않기 때문에 CPU 시간에 여유가 생겼고

느린 PC에서는 화면이 좀 끊기더라도 안전한 고정 시간 간격을 이용해서 

여러 하드웨어에서 일정한 속도를 보장하려고 했다.

 


 

하지만 여전히 residual lag, 나머지 시간 문제가 남아있다.

업데이트는 고정 시간 간격으로 하더라도, 렌더링은 그냥 하기 때문이다.

플레이어는 두 업데이트 사이에 렌더링이 되는 경우가 있기 때문이다.

 

위 그림에서 업데이트는 고정간격으로 진행하지만 렌더링을 가능할 때마다 하므로 일정하지 않다.

뭐 잘 되는 것 같은데 뭐가 문제냐?

여기가 문제다.

 

항상 업데이트 후에 렌더링 되는 것이 아니라는 것이다.

 

총알이 화면에 지나가는 것을 다시 상기시켜보자

첫 번째 업데이트에서는 총알이 화면 왼쪽에 있다.

다음 업데이트에서는 오른쪽에 가있다.

두 업데이트 중간에 렌더링을 하게 되면 플레이어는 화면 가운데 있는 총알을 볼 수 있어야 하는데

여전히 연산에서는 왼쪽에 있기 때문에 왼쪽에 있는 것으로 나온다.

그렇게 되면 부자연스러운 움직임이 보이고 그것을 움직임이 튄다고 한다. 

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

이때 lag에 있는 값은 다음 프레임까지 남은 시간이다.

 

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

render(lag / MS_PER_UPDATE);

 

렌더러는 게임 객체들과 각각의 현재 속도를 안다. 총알이 화면 왼쪽으로부터 20픽셀에 있고,

오른쪽으로 프레임당 400픽셀로 이동한다고 해보자

프레임 중간이라면 render() 는 0.5를 인수로 받아서 총알을 한 프레임의 중간인 220 픽셀에 그린다.

 

물론 이렇게 보간하는 방법은 틀릴 수 있다.

다음 프레임에 계산해보니 총알이 장애물에 부딪혔거나 느려졌을 수도 있다.

렌더링에서는 현재 프레임에서의 위치와 다음 프레임의 "예상"위치를 이용해 보간하는 것이다.

실제로 물리와  AI를 업데이트 하기 전에는 정확한 위치를 알 수는 없다.

 

보간은 어느 정도 추측이 들어가기 때문에 틀릴 가능성이 항상 존재한다.

다만 이런 보간이 없는 것이 더 이상하기 때문에 보간을 할 뿐이다.

 


 

즉, 화면에 비추는 렌더링 빈도까지와 동기화를 해야하고

멀티 스레딩.. GPU 등등과 게임 루프가 앞서 간단히 말했지만

그 속내는 엄청난 최적화를 거듭해서 작성해야 한다.

 

여기서 몇가지 질문을 던질 수 있는데

1. 게임루프를 직접 관리하는가? 아니면 플랫폼이 관리하는가?

대부분은 직접이 아닌 플랫폼이 관리하는 경우가 대다수이다.

직접 만들기보다는 이미 구현된, 엔진에 내장된 것들을 쓸 가능성이 높다.

 

**플랫폼 이벤트 루프를 사용시 몇 가지 장점이 생긴다.

1. 간단하다. 

게임 루프 코드를 작성하는데 최적화도 필요없고 그냥 쓰면 된다.

2. 플랫폼에 최적화되었다. 

플랫폼이 자기 이벤트를 처리할 수 있도록 따로 시간을 준다거나, 이벤트를 캐시한다거나, 많은 고려할 사항을 알아서 해준다.

다만 단점이 있으니

1. 시간 제어가 불가능하다

플랫폼이 관리하기 때문에 플랫폼이 적당하다고 생각하는 만큼 호출하게 되는데 

특히 어플리케이션 이벤트 루프에서는 게임을 대부분 고려하지 않기 때문에 끊기는 것이 대다수다.

 

 


 

게임 엔진 루프를 사용하는 것도 있다.

 

1. 코드를 직접 작성하지 않아도 된다.

미리 만들어져 있는 루프를 쓰면 알아서 다 된다.

 

2. 코드를 직접 작성할 수 없다.

엔진의 루프에서 아쉬운 점이 있어도 그냥 써야 한다.

 


 

직접 만든 루프를 사용하는 것도 있다.

완전한 제어가 가능해서 개발하는 게임에 딱 맞게 설계가 가능하다.

다만 내가 만든 것이 플랫폼과 상호작용할 지는 미지수라서

어플리케이션 프레임워크나 OS 중에는 이벤트를 처리하고 다른 일을 하기 위해 시간을 쪼개주기를 기대하지만

핵심 루프를 만들어 돌리면 프레임워크에 시간이 주어지지 않는 등.. 문제가 생길 수도 있다.

 


 

모바일로 시장이 옮겨가면서 

전력공급에 대한 문제도 생겼는데 이는 발열과도 큰 관련이 있다.

즉, CPU를 가능한 적게 쓰면서 보여주고 싶은 것들을 다 보여줘야 하는 상황이 왔다.

**요즘 뭐 게임은.. 모바일 게임이라면서 PC런쳐로 가능하게..만들었고 정작

모바일에서는 최적화가 되지 않은.. 게임이 등장하고는 한다.

 

조삼모사다. PC를 모바일에 연결한 건지...  모바일을 PC에 연결한 것인지 불분명하다.

 

다시 본론으로 돌아와서 전력소모는 열과 관련이 있다고 했다.

모바일이 아니라 PC더라도 이러한 발열을 잡아야 하는데

발열을 잡지 않으면 쓰로틀링이라는 CPU 멘붕상태가 와서.. 제대로 작업이 진행되지 않을 수 있다.

 

그래서 2가지로 나뉘는데

적당히 구현하고 적당히 즐기자.

또는

최대한 고품질로 난로는 덤

 

이것은 사용자가 어떨지를 판단해서 개발자가 알아서 한다.

 


 

그렇게 된다면 게임 플레이 속도에 관계가 있다.

게임이 엄청나게 빠른 속도로 흘러간다면 연산도 빨리 진행되어야 하고.. 등등 그렇게 될 것인데

게임 플레이 속도는 어떻게 제어할 것인지도 게임 루프와 큰 관계가 있다.

 

게임 루프에는 비동기 유저 입력과 시간 따라잡기라는 핵심 요소 2개로 이뤄져있는데

입력은 쉽지만 시간을 다루는 것이 어렵고

플랫폼이 많다보니 멀티 플랫폼을 겨냥한다면 각각 지원해야하는 것이 어려운 것도 단점이다.

 

 

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

간단하게 구현가능하다.

게임 속도는 하드웨어와 게임 복잡도에 바로바로 영향을 받는다.

 

-동기화하는 고정 시간 간격 방식

고정 시간 간격으로 실행하되, 루프 마지막에 지연이나 동기화 지점을 넣어서 게임이 너무 빨리 진행되는 것을 막는다.

그래도 간단한 편으로 이중 버퍼를 이용하여 화면 재생 빈도에 맞춰 버퍼를 바꾸는 등으로 가능하다.

또한 전력 효율이 높아서 모바일 게임에서 쓰기도 한다.

또한 게임이 너무 빨라지는 경우를 지연과 같은 것으로 막기 때문에 동기화 없는 방식의 문제를 여기서 해결한다.

다만 게임이 느려질 수도 있어서 업데이트와 렌더링을 분리하지 않은 것에 대한 문제도 해결해야 한다.

 

-가변 시간 간격 방식

사람들이 쓰지 않는 데에는 이유가 있다.

너무 느리거나 너무 빠른 곳에서도 플레이가 가능하다는 점이 장점이라고 했으나

게임 플레이를 불안정하고 비결정적으로 만든다. 특히 물리나 네트워크는 가변 시간 간격에서 훨씬 다루기가 어렵다.

 

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

이 방법이 가장 적응력이 높아서 많이 쓰인다. 

너무 느리거나 너무 빨라도 적응할 수 있다. 게임을 실시간으로 업데이트만 가능하다면 뒤쳐질 일은 없다.

최고 사양 하드웨어에서도 더욱 부드럽게 구현이 가능하다.

하지만 훨씬 복잡하기 때문에.. 업데이트 시간 간격과 고사양,저사양 유저를 잘 고려해야 한다.

 

 

반응형
그리드형