Search
Duplicate

게임 로직(Game Loop)

게임에서 제일 중요한 디자인패턴을 하나 뽑자면 당연하게도 게임 루프 패턴을 고를 것이다. 게임 루프는 거의 모든 게임에서 사용되고 있으며, 어느 것도 서로 똑같지 않고, 게임이 아닌 분야에서 그다지 쓰이지 않는다는 점에서 전형적인 게임프로그래밍을 위한 디자인패턴이라고 보면된다.
게임루프가 얼마나 유용한지 과거의 프로젝트들을 살펴보자. 초창기에 프로그래머들이 만든 프로그램들은 대부분 임베디드(세탁기, 식기세척기, 냉장고, 자동차)같은 프로그램처럼 동작했습니다. 버튼을 누르고 그에 해당하는 동작이 실행되는 방식 이었다. 이런 걸 배치모드프로그램이라고 한다. 더이상 이러한 프로그램은 점점 사라지고 있다. 하드웨어가 급격하게 발전함으로써…

CPU와의 관계

컴퓨터실에 가서 코드를 밀어 넣은 뒤 자기 자리로 돌아와 결과가 나올 떄까지 꽤 오랜시간 기다렸었다. 즉각적인 피드백을 원했던 프로그래머들은 대화형 프로그램을 만들 었다. 초기 대화형 프로그램중에는 게임도 있었다.
요렇게 실시간으로 프로그램과 유저가 대화를 나누는 방식으로 개발하였다. 나는 입력을 하고 프로그램은 그 입력을 받아 계산을 하고 이 같은 상호작용을 번갈아가면서 진행하는 방식의 게임이다.
while (true) { char* command = readCommand(); handleCommand(command); }
C++
복사

이벤트 루프

최신 GUI 어플리케이션도 내부를 들여다 보면 옜날 어드벤처 게임과 놀랄정도로 비슷하다. 워드 프로세서만 해도 사용자가 키를 누르거나 클릭하기 전에는 가만히 기다린다.
while (true) { Event* event = waitForEvent(); dispatchEvent(event); }
C++
복사
GUI 에플리케이션 역시 문자 입력 대신 마우스나 키보드 입력 이벤트를 기다린다는 점 외에는 기본적으로 사용자 입력을 받을 때까지 멈춰 있는 옛날 텍스트 기반 게임과 비슷한 동작방식이다.
하지만 대부분 소프트웨어와 달리, 게임은 유저 입력이 없어도 계속 돌아간다. 아무것도 하지 않은 채로 화면만 보고 있어도 게임은 멈추지않고 계속 애니메이션을 재생시키든지, 무언가 연산을 해주고 있다던지 하는 모습을 볼수 있다.
게임루프에서 사용자 입력을 처리하지만 마냥 기다리고 있지 않다는 점, 이게 게임루프의 핵심 이론이다.
while (true) { processInput(); update(); render(); }
C++
복사
뒤에서 우리가 조금 더 세부적으로 위 로직을 다듬을수는 있지만, 기본 게임루프 로직은 크게 달라지지 않는다. processInput()에서는 이전 호출 이후로 들어온 유저 입력을 처리하고, update()에서는 게임 시뮬레이션 한 단계에 필요한 로직을 실행하고, render()에서는 update()에서 처리된 로직을 기반으로 화면에 게임 오브젝트를 그려준다.

게임월드에서 시간

루프가 입력을 기다리지 않는다면 루프가 도는 데 시간이 얼마나 걸리는지 궁금할수 있다. 게임 루프가 돌 때마다 게임 상태는 조금씩 진행된다. 그 동안 플레이어의 실제 시간도 흘러간다. 실제 시간 동안 게임 루프가 얼마나 많이 돌았는지 측정하면 ‘초당 프레임 수(FPS)’를 얻을 수 있다. 게임루프가 빠르게 돌면 FPS가 올라가면서 부드럽고 빠른 화면을 볼 수 있다. 게임 루프가 느리게 돌아가면 스톱모션처럼 툭툭 끊키면서 화면을 보여줄수 있다.
두가지 요인이 프레임 레이트를 결정한다. 하나는 한 프레임안에서 얼마나 많은 작업을 하는가? 물리 계산이 복잡하고 게임객체가 많고 그래픽이 정교해 CPU,GPU가 계속 바쁘다면 한프레임에 걸리는 시간이 늘어난다.
다른요인으로는 코드가 실행되는 플렛폼의 속도다. 하드웨어속도가 빠르다면 같은 시간에 더 많은 코드를 실행할수 있다. 멀티코어, GPU, 전용 오디오 하드웨어, OS 스케줄러 등도 한 턱에 걸리는 시간에 영향을 미친다.

게임 시간 vs 실제 시간

루프가 입력을 기다리지 않는다면 루프가 도는 데 시간이 얼마나 걸리는지 궁금할 것이다. 게임 루프가 돌때마다 게임 상태는 조금씩 진행된다. 그동안 플레이어의 실제 시간도 흘러간다. 실제 시간동안 게임 루프가 얼마나 돌았는지 측정하면 ‘초당 프레임수’를 계산할수 있다. 게임 루프가 빠르게 돌면 FPS 가 올라가면서 부드럽게 빠른 화면을 볼수 있다. 시간을 측정하는 방법은 하드웨어 고유의 진동수를 기반으로 측정해줘야한다.
LV07 Time, Double Buffering 여기를 참고하자.

패턴

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

언제 쓸것인가?

부적합한 패턴은 안쓰는 것만 못하다. ‘ 언제 쓸것인가?’ 패턴을 최대한 많이 적용하는게 디자인패턴의 목적이 아니다. 하지만 게임 루프 패턴은 다르다. 해당 패턴을 사용하지 않으면 게임을 만들기 어려운 시대에 들어왔다. 이패턴 만큼은 항상 사용한다고 주장할수 있다.
게임이 진행되지 않더라도 실행되어 있다면 애니메이션, 사운드 이런 것들은 끊임없이 계속 돌아가야 한다.

주의사항

게임루프패턴은 게임 코드중에서도 핵심이 되는 코이다. 10%코드가 프로그램 실행 시간의 90%를 차지한다고 이야기한다. 게임루프느 그 10%들어가기 떄문에 항상 최적화도 고려해 줘야한다.

플랫폼에 따라서 이벤트 루프에 맞춰야 할수도 있다.

그래픽 UI와 이벤트 루프가 들어있는 OS나 플랫폼 게임을 만드는 경우에는, 엪플리케이션 루프가 2개 있을수도 있다. 대표적으로 유니티, 언리얼 게임 엔진이 될수 있다. 실제 엔진으로 빌드되서 서비스되는 게임은 루프가 1개지만 게임엔진에서는 툴, 게임 2개의 루프를 실행해야 한다.
필요에 따라서 원하는 루프만 남겨서 실행할 수도 있다. 오래된 윈도우 API로 게임을 만든다면, main()에 게임루프를 두고 르프 안에서 PeekMessage()를 호출 해 OS로부터 이벤트를 전달받는 식으로 2가지 유형을 사용할 수 있다. GetMessage() 와는 달리 PeekMessage() 는 유저 입력이 올때까지 기다리지 않기 떄문에, 게임루프를 돌릴수 있다.
플랫폼에 따라서는 내부 이벤트 루프를 무시하기 어려울 수 도 있다. 웹브라우저에서는 이벤트 루프가 브라우저 실행 모델 깊숙한 곳에서 모든것을 좌우하기 떄문에 이걸 게임루프에 활용 해야 한다. 게임이 돌아가게 하려면 requestAnimationFrame()같이 함수를 이용하여 게임루프를 개조하여 사용해야 한다.
밑의 코드는 간단한 게임 루프 형태이다.
while (true) { processInput(); update(); render(); }
C++
복사
이방식의 문제점은 속도를 제어하기가 어렵다. 컴퓨터 하드웨어 따라서 저 루프문이 실행되는 속도를 제어할수가 없다.

한숨 돌리기

60FPS 기준으로 게임을 실행하면 한프레임이 16ms가 걸린다. 그동안에 게임에 진행과 렌더링을 다 할 수 있다면 프레임 레이트를 유지할 수 있다. 프레임을 실행한뒤 다음 프레임까지 남은시간을 기다릴수 있다.
코드는 다음과 같다.
while (true) { double start = getCurrentTime(); processInput(); update(); render(); sleep(start + MS_PER_FRAME - getCurrentTime()); }
C++
복사
한 프레임이 빨리 끝나도 Sleep() 덕분에 게임이 너무 빨리지지는 않는다. 다만 너무 느려지는건 막을 수 없다.

한번은 짧게, 한 번은 길게

1.
업데이트를 할 떄마다 정해진 만큼 게임시간이 진행된다.
2.
업데이트를 하는 데에는 현실 세계의 시간이 어느정도 걸린다.
결국 우리의 문제는 위 두가지이다.
2번이 1번보다 오래 걸리면 게임은 느려진다. 반대로 시간이 짧게 걸리면 게임안의 시간은 빨라진다.
즉 결국 모든 컴퓨터에서 동일한 결과를 만들게 하려면 루프를 실행할 시간을 동일하게 맞춰줘야한다.
프레임 이후로 실제 시간이 얼마나 지났는지에 따라서 시간 간격을 조절하면된다. 프레임이 오래 걸릴 수록 게임 간격을 길게 잡고, 필요에 따라 업데이트 단계를 조절 할 수 있기 떄문에 실제 시간을 따라갈 수 있다. 이런걸 가변, 유동 시간이라고 한다.
double lastTime = getCurrentTime(); while (true) { double current = getCurrentTime(); double elapsed = current - lastTime; processInput(); update(elapsed); render(); lastTime = current; }
C++
복사
매 프레임 마다 이전 게임 업데이트 이후 실제 시간이 얼마나 지났는지를 elapsed에 저장한다.
게임 상태를 업데이트 할때마다 elapsed 를 같이 넘겨주면 받는 쪽에서는 지난 시간만큼 게임 월드 상태를 진행한다.
이값을 내가 이동할 속도에 곱해주 게되면 사양과 관계없이 로직을 동일하게 실행 할 수 있다.
이게 자주 사용되는 Time.deltatime이라는 용어로 유니티 엔진에서는 사용되고 있다.

참고 사항

하지만 렌더링 부분은..? 시간을 영향을 받지 않는다.그냥 연산이 끝나서 때가 되면 화면에 그려줄 뿐이다. 사양이 좋으면 빠르게 그리고 나쁘면 느리게 그릴 뿐이다. 게임로직에 직접적인 영향을 안받는 경우가 대다수입니다.
이 점을 활용 해보자. 일반로직들은 고정 시간 간격으로 업데이트를 하고, 렌더링은 유연하게 만들어서 프로세서의 낭비를 줄 일 것이다.
원리는 다음과 같다. 이런 루프타임 시간을 얼마나 지났는지 확인 후 게임의 현재 실제시간과 현재를 따라잡을때까지 고정 시간 간격만큼 여러번 게임시간을 시뮬레이션 해준다.
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(); }
C++
복사
update()루프에서 렌더링과 인풋을 제외시켜뒀기 때문에 cpu에게는 시간적 여유가 조금 더 생긴다. 느린 pc에서는 화면이 조금 끊키겠지만, 결과저그올 안정한 고정 시간 간격을 이용해 여러 하드웨어에서 일정한 속도로 게임을 시뮬레이션 할수 있고, 렌더링은 컴퓨터 사양이 좋은 하드웨어에서는 자신의 사양만큼 퍼포먼스를 뽑아 낼 수 있다.

렌더링이 느린 경우

업데이트는 고정 간격으로 진행되지만, 렌더링은 가능 할떄마다 진행한다. 만약에 그래픽카드 성능이 많이 부족하다면 위와같은 상황처럼 루프가 돌수 있다.
예를 들어 총알이 화면을 지나간다. 첫번쨰 업데이틑 중에는 총알이 왼쪽 에 있고, 두번 쨰 업데이트 중에는 총알이 로른쪽에 있다. 그 두 업데이트 중간에 렌더링하기 떄문에 여전히 총알은 왼쪽에 만 있다.
다행이 렌더링 할때 업데이트 프레임이 시간적으로 얼마나 떨어져 있는 지를 값(lag)을 보고 정확하게 알아낼 수도 있다.
render(lag / MS_PER_UPDATE);
C++
복사