서비스
교육 철학

게임 루프 (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++
복사
보시다시피, 업데이트는 깔끔하고 고정된 간격으로 실행됩니다. 반면 렌더링은 가능할 때마다 수행됩니다. 업데이트보다 덜 빈번하고 일정하지도 않지만, 이는 문제가 되지 않습니다. 진짜 문제는 렌더링이 항상 업데이트 시점에 정확히 일어나는 것은 아니라는 점입니다. 세 번째 렌더링을 보세요. 두 업데이트 사이에 위치합니다:
Close-up of the timeline showing Renders falling between Update steps.
총알이 화면을 가로질러 날아간다고 상상해보자. 첫 번째 업데이트에서 총알은 왼쪽에 있다. 두 번째 업데이트에서는 오른쪽으로 이동한다. 게임이 이 두 업데이트 사이 시점에 렌더링되므로, 사용자는 총알이 화면 중앙에 있을 것으로 기대한다. 하지만 현재 구현에서는 여전히 왼쪽에 있다. 이는 움직임이 들쭉날쭉하거나 끊겨 보인다는 의미다.
다행히 렌더링할 때 업데이트 프레임 사이에서 정확히 얼마나 떨어져 있는지 알고 있다. 그 값은 lag에 저장되어 있다. 업데이트 루프는 업데이트 시간 단계보다 작을 때 빠져나오지, 0일 때가 아니다. 그 남은 양은 다음 프레임까지 얼마나 진행되었는지를 나타낸다.
렌더링할 때 이 값을 전달한다.
render(lag / MS_PER_UPDATE);
여기서 MS_PER_UPDATE로 나누는 것은 값을 정규화하기 위해서다. render()에 전달되는 값은 업데이트 시간 단계와 관계없이 0(이전 프레임 바로 직후)에서 1.0 바로 아래(다음 프레임 바로 직전)까지 변한다. 이렇게 하면 렌더러는 프레임 속도에 대해 걱정할 필요 없이 0에서 1 사이의 값만 다루면 된다.
렌더러는 각 게임 객체 와 그 현재 속도를 알고 있다. 예를 들어 총알이 화면 왼쪽에서 20픽셀 떨어져 있고 프레임당 오른쪽으로 400픽셀씩 이동한다고 가정해보자. 프레임 중간에 있다면 render()에 0.5를 전달하게 된다. 그래서 총알을 반 프레임 앞인 220픽셀에 그린다. 짜잔, 부드러운 움직임이다.
물론 외삽이 틀릴 수도 있다. 다음 프레임을 계산할 때 총알이 장애물에 부딪치거나 속도가 줄어드는 것을 발견할 수 있다. 우리는 지난 프레임에서의 위치와 다음 프레임에서 있을 것으로 생각되는 위치 사이를 보간하여 렌더링했다. 하지만 물리와 AI로 전체 업데이트를 실제로 완료하기 전까지는 그것을 알 수 없다.
따라서 외삽은 약간의 추측이며 때때로 틀리게 된다. 다행히 이런 종류의 보정은 보통 눈에 띄지 않는다. 적어도 전혀 외삽하지 않을 때 발생하는 끊김보다는 덜 눈에 띈다.

설계 결정

이 챕터의 길이에도 불구하고 포함한 것보다 빠뜨린 것이 더 많다. 디스플레이의 재생률과 동기화, 멀티스레딩, GPU 같은 것들을 고려하면 실제 게임 루프는 꽤 복잡해질 수 있다. 하지만 높은 수준에서 답해야 할 몇 가지 질문이 있다.
이것은 여러분이 선택하는 것이라기보다는 여러분을 위해 결정되는 것이다. 웹 브라우저에서 실행되는 게임을 만든다면 사실상 고전적인 게임 루프를 직접 작성할 수 없다. 브라우저의 이벤트 기반 특성이 그것을 배제한다. 마찬가지로 기존 게임 엔진을 사용한다면 직접 만드는 대신 그것의 게임 루프에 의존하게 될 것이다.
•
플랫폼의 이벤트 루프 사용:
◦
간단하다. 게임의 핵심 루프를 작성하고 최적화하는 것에 대해 걱정할 필요가 없다.
◦
플랫폼과 잘 맞는다. 호스트가 자체 이벤트를 처리할 시간을 명시적으로 제공하거나, 이벤트를 캐싱하거나, 플랫폼의 입력 모델과 여러분의 모델 간의 임피던스 불일치를 관리하는 것에 대해 걱정할 필요가 없다.
◦
타이밍에 대한 제어권을 잃는다. 플랫폼은 자신이 적절하다고 생각하는 대로 여러분의 코드를 호출한다. 그것이 여러분이 원하는 만큼 자주 또는 부드럽게 이루어지지 않는다면 어쩔 수 없다. 더 나쁜 것은 대부분의 애플리케이션 이벤트 루프는 게임을 염두에 두고 설계되지 않았으며 보통 느리고 끊긴다.
•
게임 엔진의 루프 사용:
◦
직접 작성할 필요가 없다. 게임 루프를 작성하는 것은 꽤 까다로울 수 있다. 그 핵심 코드는 매 프레임마다 실행되기 때문에 사소한 버그나 성능 문제가 게임에 큰 영향을 미칠 수 있다. 견고한 게임 루프는 기존 엔진을 사용하는 것을 고려하는 이유 중 하나다.
◦
직접 작성할 수 없다. 물론 그 동전의 반대편은 엔진에 완벽하게 맞지 않는 필요사항이 있다면 제어권을 잃는다는 것이다.
•
직접 작성:
◦
완전한 제어권. 원하는 대로 무엇이든 할 수 있다. 게임의 필요에 맞게 특별히 설계할 수 있다.
◦
플랫폼과 인터페이스해야 한다. 애플리케이션 프레임워크와 운영 체제는 보통 이벤트를 처리하고 다른 작업을 수행할 시간을 기대한다. 앱의 핵심 루프를 소유한다면 그것은 시간을 얻지 못할 것이다. 프레임워크가 멈추거나 혼란스러워지지 않도록 주기적으로 명시적으로 제어권을 넘겨야 한다.

전력 소비를 어떻게 관리할 것인가?

이것은 5년 전에는 문제가 되지 않았다. 게임은 벽에 꽂힌 것이나 전용 휴대용 기기에서 실행되었다. 하지만 스마트폰, 노트북, 모바일 게임의 출현으로 이제 이것에 신경 쓸 가능성이 높다. 아름답게 실행되지만 플레이어의 휴대폰을 우주 히터로 만들고 30분 후에 배터리가 소진되는 게임은 사람들을 행복하게 만드는 게임이 아니다.
이제 게임을 멋지게 보이게 만드는 것뿐만 아니라 가능한 한 적은 CPU를 사용하는 것에 대해서도 생각해야 할 수 있다. 프레임에서 필요한 모든 작업을 완료했다면 CPU를 휴면 상태로 만드는 성능의 상한선이 있을 것이다.
•
최대한 빠르게 실행: 이것은 PC 게임에서 할 가능성이 높은 것이다(물론 그것들도 점점 노트북에서 플레이되고 있다). 게임 루프는 명시적으로 OS에 휴면을 지시하지 않는다. 대신 여분의 사이클은 FPS나 그래픽 품질을 높이는 데 사용된다. 이것은 가능한 최고의 게임플레이 경험을 제공하지만 가능한 한 많은 전력을 사용한다. 플레이어가 노트북을 사용한다면 무릎을 따뜻하게 해줄 것이다.
•
프레임 속도 제한: 모바일 게임은 종종 그래픽 디테일을 극대화하는 것보다 게임플레이의 품질에 더 집중한다. 이러한 게임 중 많은 것들이 프레임 속도에 상한선을 설정한다(보통 30 또는 60 FPS). 게임 루프가 그 시간을 소비하기 전에 처리를 완료하면 나머지 시간 동안 휴면한다. 이것은 플레이어에게 "충분히 좋은" 경험을 제공하고 그 이상으로는 배터리를 아낀다.

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

게임 루프에는 두 가지 핵심 요소가 있다. 논블로킹 사용자 입력과 시간의 흐름에 적응하는 것. 입력은 간단하다. 마법은 시간을 어떻게 다루느냐에 있다. 게임이 실행될 수 있는 플랫폼은 거의 무한대이며, 단일 게임이 꽤 많은 플랫폼에서 실행될 수 있다. 그 변화를 어떻게 수용하느냐가 핵심이다.
게임 제작은 인간 본성의 일부인 것 같다. 우리가 컴퓨팅을 할 수 있는 기계를 만들 때마다 우리가 한 첫 번째 일 중 하나가 그 위에서 게임을 만드는 것이었기 때문이다. PDP-1은 2kHz 머신으로 단지 4,096 워드의 메모리만 있었지만, Steve Russell과 친구들은 그 위에서 Spacewar!를 만들어냈다.
•
동기화 없는 고정 시간 단계: 이것은 우리의 첫 번째 샘플 코드였다. 가능한 한 빠르게 게임 루프를 실행하면 된다.
◦
간단하다. 이것이 주요한(음, 유일한) 장점이다.
◦
게임 속도가 하드웨어와 게임 복잡도에 직접적으로 영향을 받는다. 주요 단점은 변화가 있다면 그것이 게임 속도에 직접적으로 영향을 미친다는 것이다. 게임 루프의 픽시 자전거다.
•
동기화가 있는 고정 시간 단계: 복잡성 사다리의 다음 단계는 고정 시간 단계로 게임을 실행하되, 게임이 너무 빠르게 실행되지 않도록 루프 끝에 지연이나 동기화 지점을 추가하는 것이다.
◦
여전히 꽤 간단하다. 아마도 실제로 작동하기에는 너무 간단한 예제보다 코드 한 줄만 더 많다. 대부분의 게임 루프에서는 어쨌든 동기화를 할 것이다. 아마도 그래픽을 더블 버퍼링하고 디스플레이의 재생률에 버퍼 플립을 동기화할 것이다.
◦
전력 친화적이다. 이것은 모바일 게임에서 놀랍도록 중요한 고려사항이다. 사용자의 배터리를 불필요하게 소진시키고 싶지 않다. 각 틱에 더 많은 처리를 집어넣으려고 하는 대신 단순히 몇 밀리초 동안 휴면하면 전력을 절약할 수 있다.
◦
게임이 너무 빠르게 실행되지 않는다. 이것은 고정 루프의 속도 문제의 절반을 해결한다.
◦
게임이 너무 느리게 실행될 수 있다. 게임 프레임을 업데이트하고 렌더링하는 데 너무 오래 걸리면 재생이 느려진다. 이 스타일은 업데이트와 렌더링을 분리하지 않기 때문에 더 고급 옵션보다 더 빨리 이 문제에 부딪힐 가능성이 있다. 따라잡기 위해 렌더링 프레임만 건너뛰는 대신 게임플레이가 느려진다.
•
가변 시간 단계: 이것을 솔루션 공간의 옵션으로 여기에 포함시키겠지만, 제가 아는 대부분의 게임 개발자들은 이것을 반대한다. 그것이 왜 나쁜 아이디어인지 기억하는 것이 좋다.
◦
너무 느리게 그리고 너무 빠르게 실행되는 것 모두에 적응한다. 게임이 실시간을 따라잡지 못한다면 따라잡을 때까지 점점 더 큰 시간 단계를 취한다.
◦
게임플레이를 비결정적이고 불안정하게 만든다. 이것이 물론 진짜 문제다. 특히 물리와 네트워킹은 가변 시간 단계로 훨씬 더 어려워진다.
•
고정 업데이트 시간 단계, 가변 렌더링: 샘플 코드에서 다룬 마지막 옵션은 가장 복잡하지만 또한 가장 적응력이 높다. 고정 시간 단계로 업데이트하지만, 플레이어의 시계를 따라잡기 위해 필요하다면 렌더링 프레임을 건너뛸 수 있다.
◦
너무 느리게 그리고 너무 빠르게 실행되는 것 모두에 적응한다. 게임이 실시간으로 업데이트할 수 있는 한 게임은 뒤처지지 않는다. 플레이어의 머신이 최고급이라면 더 부드러운 게임플레이 경험으로 응답한다.
◦
더 복잡하다. 주요 단점은 구현에 조금 더 많은 노력이 필요하다는 것이다.

원문 전체 해설·코드·도해

💡
위의 수업용 정리와 실습을 먼저 읽은 뒤, 아래에서 책의 원문 흐름·모든 코드·원본 도해·각주를 빠짐없이 확인할 수 있습니다.

게임 루프 (Game Loop)

9.1 의도

게임 시간의 진행을 사용자 입력과 프로세서 속도로부터 분리합니다.

9.2 동기

이 책에서 패턴 하나만 남겨야 한다면 게임 루프 패턴을 고르겠습니다. 거의 모든 게임에서 사용하지만 어느 두 구현도 똑같지 않고, 게임 이외의 분야에서는 그다지 쓰이지 않는다는 점에서 전형적인 ‘게임 프로그래밍 패턴’입니다.
얼마나 유용한지 알아보기 위해 잠시 과거로 돌아가 보겠습니다. 모든 프로그래머가 턱수염을 기르던 시절에는 프로그램이 식기세척기처럼 동작했습니다. 코드를 들이붓고 버튼을 누른 뒤 한참 기다려야 결과를 볼 수 있었습니다. 이런 프로그램을 배치 프로그램(batch program)이라고 합니다. 모든 작업이 끝나면 프로그램도 멈췄습니다.
요즘에도 배치 모드 프로그램은 있습니다. 다만 천공카드에 코드를 입력하지 않아도 됩니다. 셸 스크립트, 명령줄 프로그램, 마크다운으로 쓴 글을 책으로 바꾸는 짧은 파이썬 스크립트도 여기에 속합니다.
원주 9-1: 여성 프로그래머인 에이다 러브레이스와 그레이스 호퍼에게는 명예 턱수염이 있었다고 치겠습니다.
원주 9-2 · 옮긴이: 저자는 파이썬 스크립트로 마크다운 문서를 웹 버전의 HTML과 PDF로 변환했습니다. 변환 코드를 확인할 수 있습니다.
CPU와의 인터뷰
컴퓨터실에서 코드를 밀어 넣고 자기 자리로 돌아와 결과가 나올 때까지 몇 시간씩 기다리는 방식으로는 디버깅이 너무 오래 걸렸습니다. 즉각적인 피드백을 원했던 프로그래머들은 대화형 프로그램(interactive program)을 만들었습니다. 초기 대화형 프로그램 중에는 게임도 있었습니다.
당신은 작은 벽돌 건물 앞 막다른 길에 서 있다. 주변에는 숲이 있다. 작은 물줄기가 건물에서 흘러나와 개울로 향하고 있다.
〉 들어간다
당신은 건물 안에 들어왔다. 깊은 샘 위에 지은 우물집이었다.
이제 프로그램과 실시간으로 대화할 수 있습니다. 프로그램이 입력을 기다렸다가 응답하면, 우리가 다시 대답합니다. 유치원에서 배웠던 것처럼 번갈아 이야기하는 것입니다. 우리 차례가 되면 프로그램은 가만히 기다립니다. 코드는 다음과 같습니다.
while (true) { char* command = readCommand(); handleCommand(command); }
C++
복사
원주 9-3: 위의 화면은 최초의 어드벤처 게임인 《콜로설 케이브 어드벤처(Colossal Cave Adventure)》의 화면입니다.
원주 9-4: 이 코드는 무한 루프라 게임을 끝낼 방법이 없습니다. 실제 게임이라면 while (!done)처럼 빠져나갈 수 있게 만들겠지만, 여기서는 코드를 간단하게 보여주려고 이렇게 작성했습니다.
이벤트 루프
최신 GUI 애플리케이션의 내부도 옛날 어드벤처 게임과 놀라울 정도로 비슷합니다. 워드 프로세서는 사용자가 키를 누르거나 클릭하기 전에는 가만히 기다립니다.
while (true) { Event* event = waitForEvent(); dispatchEvent(event); }
C++
복사
문자 입력 대신 마우스나 키보드의 입력 이벤트를 기다릴 뿐, 기본적으로는 사용자 입력이 들어올 때까지 멈춰 있는 옛날 텍스트 어드벤처와 동작 방식이 크게 다르지 않습니다.
하지만 게임은 대부분의 다른 소프트웨어와 달리 사용자 입력이 없어도 계속 돌아갑니다. 아무것도 하지 않고 화면만 보고 있어도 애니메이션과 시각적 연출은 이어집니다. 운이 나쁘면 몬스터가 우리 영웅을 계속 때리는 모습을 보게 될 수도 있습니다.
사용자 입력을 처리하되 입력을 마냥 기다리지 않는다는 점이 게임 루프의 첫 번째 핵심입니다. 루프는 끊임없이 돌아갑니다.
while (true) { processInput(); update(); render(); }
C++
복사
뒤에서 다듬겠지만 기본적인 모습은 크게 달라지지 않습니다. processInput()은 이전 호출 이후 들어온 사용자 입력을 처리합니다. update()는 게임 시뮬레이션을 한 단계 진행하며, 보통 AI와 물리를 이 순서대로 처리합니다. 마지막으로 render()는 플레이어가 무슨 일이 벌어지는지 알 수 있도록 화면을 그립니다.
원주 9-5: 거의 모든 이벤트 루프 시스템은 입력이 없을 때 필요한 작업을 할 수 있도록 대기(idle) 이벤트를 제공합니다. 커서를 깜빡이거나 진행 바를 보여주기에는 충분하지만 게임에서 쓰기에는 부족합니다.
원주 9-6: 이름에서 짐작할 수 있듯이 update()에는 10장의 업데이트 메서드 패턴을 적용하기 좋습니다.
💡
게임에서 활용하기: 메뉴를 조작하지 않아도 배경의 눈은 내리고, 적은 순찰하고, 음악은 흐릅니다. 입력 처리·시뮬레이션·그리기를 한 흐름에서 계속 진행시키는 이유입니다. 다만 화면을 그린 횟수와 시뮬레이션을 진행한 시간은 반드시 같을 필요가 없습니다.
게임 월드에서의 시간
루프가 입력을 기다리지 않는다면, 한 바퀴 도는 데 얼마나 걸리는지 궁금할 것입니다. 게임 루프가 돌 때마다 게임 상태가 조금씩 진행됩니다. 게임 월드의 주민 입장에서는 시간이 째깍째깍 흐르는 셈입니다.
그동안 플레이어의 실제 시간도 흐릅니다. 실제 시간 동안 루프가 얼마나 많이 돌았는지 측정하면 초당 프레임 수(frames per second, FPS)를 얻습니다. 루프가 빠르게 돌면 FPS가 올라가면서 부드럽고 빠른 화면이 보입니다. 느리게 돌면 스톱모션 영화처럼 뚝뚝 끊겨 보입니다.
앞의 단순한 루프는 무조건 최대한 빠르게 실행되므로 두 가지 요인이 프레임 레이트를 결정합니다. 첫째는 한 프레임에 하는 작업의 양입니다. 물리 계산이 복잡하고 객체가 많으며 그래픽이 정교해서 CPU와 GPU가 계속 바쁘다면 한 프레임에 걸리는 시간이 늘어납니다.
둘째는 코드가 실행되는 플랫폼의 속도입니다. 하드웨어가 빠르면 같은 시간에 더 많은 코드를 실행할 수 있습니다. 멀티코어, GPU, 전용 오디오 하드웨어, OS 스케줄러 등도 한 틱에 걸리는 시간에 영향을 줍니다.
원주 9-7: 게임 루프 한 바퀴를 보통 ‘틱(tick)’ 또는 ‘프레임(frame)’이라고 부릅니다.
게임 시간과 실제 시간
게임 개발 초창기에는 두 번째 요인이 정해져 있었습니다. 패미컴(NES)이나 애플 II용 게임은 어느 CPU에서 실행될지 정확히 알 수 있었으므로 특정 CPU 전용 코드를 사용할 수 있었고, 실제로 그렇게 했습니다. 개발자는 한 틱에 얼마나 많은 작업을 할지만 정하면 되었습니다.
이때는 원하는 속도를 얻기 위해 매 프레임에 작업을 얼마나 넣을지 고민해야 했습니다. 같은 게임을 더 빠르거나 더 느린 기계에서 실행하면 게임 자체의 속도도 함께 빨라지거나 느려졌습니다.
요즘은 개발 중인 게임이 정확히 어느 하드웨어에서 실행될지 알 수 있는 행복한 경우가 드뭅니다. 대신 여러 기계에서 잘 돌아가게 만들어야 합니다. 어떤 하드웨어에서도 일정한 속도로 실행되도록 만드는 것이 게임 루프의 또 다른 핵심 업무입니다.
원주 9-8 · 옮긴이: 두 기계는 모두 MOS 6502 계열의 프로세서를 사용했습니다.
원주 9-9: 옛날 PC에 ‘터보’ 버튼이 있었던 이유입니다. 새로 나온 빠른 PC에서 옛날 게임을 실행하면 너무 빨라 제대로 플레이할 수 없었습니다. 그럴 때 터보 버튼을 꺼서 PC를 느리게 만들었습니다.

9.3 패턴

게임 루프는 게임을 하는 내내 실행됩니다. 한 바퀴마다 멈추지 않고 사용자 입력을 처리한 뒤, 게임 상태를 업데이트하고 화면을 렌더링합니다. 그리고 시간의 흐름에 맞춰 게임플레이 속도를 조절합니다.

9.4 언제 쓸 것인가?

잘못 적용한 패턴은 사용하지 않는 것보다 못하기 때문에, 이 책의 ‘언제 쓸 것인가?’ 절에서는 지나친 의욕을 경계하는 경우가 많았습니다. 디자인 패턴의 목표는 패턴을 최대한 많이 적용하는 것이 아닙니다.
하지만 게임 루프는 다릅니다. 이 패턴만큼은 반드시 사용될 것이라고 자신 있게 말할 수 있습니다. 게임 엔진을 사용하면 직접 루프를 작성하지 않을 수 있지만, 그래도 어딘가에는 게임 루프가 있습니다.
턴제 게임에는 필요하지 않다고 생각할 수도 있습니다. 사용자가 턴을 끝내기 전까지 게임 상태가 진행되지는 않더라도, 시각적·청각적 상태는 계속 바뀌어야 합니다. 사용자의 턴이 끝나기를 ‘기다리는’ 동안에도 애니메이션과 음악은 계속 돌아가야 합니다.
원주 9-10: 저는 루프 코드에 따라 ‘엔진’과 ‘라이브러리’를 나눕니다. 라이브러리를 쓰면 우리가 메인 루프를 가지고 라이브러리 함수를 호출합니다. 엔진을 쓰면 엔진이 루프를 가지고 우리 코드를 호출합니다.

9.5 주의사항

게임 루프는 전체 게임의 핵심입니다. 코드의 10%가 실행 시간의 90%를 차지한다는 말을 하곤 합니다. 게임 루프는 분명 그 10%에 들어가므로 최적화를 고려해 꼼꼼하게 만들어야 합니다.
원주 9-11: 이런 식으로 통계를 내니 기계공학자나 전기공학자 같은 ‘진짜’ 공학자들이 프로그래머를 공학자로 쳐주지 않는 것입니다.
플랫폼의 이벤트 루프에 맞춰야 할 수도 있습니다
그래픽 UI와 이벤트 루프가 있는 OS나 플랫폼에서 게임을 만든다면 애플리케이션 루프가 두 개 있는 셈이므로 서로 맞춰야 합니다.
제어권을 가져와 우리 루프만 남기는 방법도 있습니다. 오래된 윈도우 API로 게임을 만든다면 main()에 게임 루프를 두고, 루프 안에서 PeekMessage()를 호출해 OS의 이벤트를 가져와 전달할 수 있습니다. GetMessage()와 달리 PeekMessage()는 사용자 입력을 기다리지 않으므로 게임 루프를 멈추지 않고 계속 실행할 수 있습니다.
반대로 플랫폼의 내부 이벤트 루프를 무시하기 어려운 경우도 있습니다. 웹 브라우저에서는 이벤트 루프가 실행 모델 깊숙이 자리 잡고 모든 것을 제어합니다. 따라서 브라우저의 루프를 게임 루프로 써야 합니다. requestAnimationFrame() 등을 호출하고 브라우저가 우리 코드를 콜백으로 실행해주기를 기다리는 방식입니다.

9.6 예제 코드

소개는 길었지만 게임 루프 코드 자체는 매우 짧습니다. 여러 구현 방식을 장단점과 함께 살펴보겠습니다.
게임 루프는 AI와 렌더링 같은 게임 시스템을 진행하지만, 여기서는 패턴에 집중하려고 가상의 함수를 호출합니다. render(), update() 같은 함수를 실제로 구현하는 일은 여러분의 몫입니다.
최대한 빨리 달리기
가장 단순한 형태는 앞에서 보았습니다.
while (true) { processInput(); update(); render(); }
C++
복사
문제는 실행 속도를 제어할 수 없다는 것입니다. 빠른 하드웨어에서는 무슨 일이 일어나는지 알 수 없을 만큼 빨리 돌아가고, 느린 하드웨어에서는 느릿느릿 기어갑니다. 콘텐츠와 AI, 물리 계산이 많은 지역이나 레벨이 있다면 그 부분에서만 게임이 느려집니다.
한숨 돌리기
첫 번째 변형은 이를 간단하게 해결합니다. 60FPS로 실행하려면 한 프레임에 약 16ms가 주어집니다. 이 시간 안에 게임 진행과 렌더링을 끝낼 수 있다면 프레임 레이트를 유지할 수 있습니다. 한 프레임을 실행한 뒤 다음 프레임까지 남은 시간 동안 기다리면 됩니다.
그림 9-1. 프레임의 남은 시간 동안 대기하는 간단한 게임 루프
코드는 대략 다음과 같습니다.
while (true) { double start = getCurrentTime(); processInput(); update(); render(); sleep(start + MS_PER_FRAME - getCurrentTime()); }
C++
복사
프레임을 빨리 끝내더라도 sleep()이 있으므로 게임이 지나치게 빨라지지는 않습니다. 하지만 너무 느려지는 것은 막지 못합니다. 업데이트와 렌더링에 16ms 이상 걸리면 sleep()의 시간 인수가 음수가 되어야 합니다. 컴퓨터가 시간을 되돌릴 수는 없으므로 게임이 느려집니다.
그래픽과 AI 수준을 낮춰 프레임당 작업을 줄일 수는 있습니다. 그러나 그렇게 하면 빠른 하드웨어를 쓰는 사람까지 포함해 모든 플레이어의 게임플레이 품질이 낮아집니다.
원주 9-12: FPS는 초당 프레임 수이므로 1초를 FPS로 나누면 프레임당 시간을 구할 수 있습니다.
💡
구현할 때: 정확한 60Hz의 간격은 약 16.667ms입니다. 책의 16ms는 설명을 간단히 한 값입니다. 실제 코드에서는 남은 시간이 양수일 때만 대기해야 하며, 타이머 해상도와 실제로 깨어나는 시간도 고려해야 합니다.
한 번은 짧게, 한 번은 길게
좀 더 정교하게 만들어 보겠습니다. 문제는 두 가지로 정리됩니다.
1.
업데이트할 때마다 게임 시간이 정해진 만큼 진행됩니다.
2.
업데이트를 계산하는 데에도 현실의 시간이 걸립니다.
두 번째 시간이 첫 번째 시간보다 길면 게임이 느려집니다. 게임 시간을 16ms 진행하는 데 실제로 16ms 이상 걸린다면 따라갈 수 없습니다. 하지만 한 번에 게임 시간을 16ms보다 더 많이 진행할 수 있다면 업데이트 횟수가 적어도 따라잡을 수 있습니다.
이전 프레임 이후 실제로 얼마나 시간이 흘렀는지에 따라 시간 간격을 조절하면 됩니다. 프레임이 오래 걸렸으면 게임 시간의 간격도 길게 잡습니다. 필요한 만큼 업데이트 단계를 조절하므로 실제 시간을 따라갈 수 있습니다. 이를 가변 시간 간격(variable time step) 또는 유동 시간 간격(fluid time step)이라고 합니다.
double lastTime = getCurrentTime(); while (true) { double current = getCurrentTime(); double elapsed = current - lastTime; processInput(); update(elapsed); render(); lastTime = current; }
C++
복사
매 프레임마다 이전 업데이트 이후 실제로 흐른 시간을 elapsed에 저장합니다. 게임 상태를 업데이트할 때 이 값을 함께 넘기면, 받는 쪽에서는 그만큼 게임 월드의 상태를 진행합니다.
총알이 날아다닌다고 생각해 보겠습니다. 고정 시간 간격에서는 매 프레임 총알의 속도에 맞춰 일정한 거리를 움직입니다. 가변 시간 간격에서는 속도에 지난 시간을 곱해 이동 거리를 구합니다. 간격이 길면 더 멀리 움직입니다. 짧고 빠른 간격으로 20번 업데이트하든, 길고 느린 간격으로 4번 업데이트하든 같은 실제 시간 동안 같은 거리를 이동합니다.
이제 문제가 모두 해결된 듯합니다.
•
여러 하드웨어에서 비슷한 속도로 게임이 돌아갑니다.
•
빠른 하드웨어를 쓰면 더 부드러운 게임플레이를 즐길 수 있습니다.
하지만 심각한 문제가 숨어 있습니다. 게임이 비결정적이고 불안정해집니다. 어떤 함정인지 예를 들어 보겠습니다.
2인용 네트워크 게임에서 프레드는 훌륭한 게임용 PC로, 조지는 할머니의 오래된 PC로 접속했습니다. 앞서 말한 총알이 두 사람의 화면을 가로지릅니다. 프레드의 PC는 게임이 빠르므로 시간 간격이 짧습니다. 총알이 화면을 지나가는 1초 동안 50프레임이 실행된다고 하겠습니다. 반면 조지의 PC는 1초에 5프레임밖에 보여주지 못합니다.
프레드의 물리 엔진은 총알 위치를 50번 업데이트하지만 조지의 물리 엔진은 5번만 업데이트합니다. 게임에서는 보통 부동소수점(floating point)을 쓰므로 반올림 오차가 생기기 쉽습니다. 부동소수점을 더할 때마다 결과에 오차가 생길 수 있습니다. 원문에서는 프레드의 PC가 덧셈을 10배 많이 하므로 오차가 더 크게 쌓이고, 결국 PC마다 같은 총알의 위치가 달라진다고 설명합니다.
가변 시간 간격의 문제는 이것만이 아닙니다. 게임 물리 엔진은 실시간 실행을 위해 실제 물리 법칙을 근사합니다. 근삿값이 튀는 것을 막으려고 감쇠(damping)를 적용하는데, 이 감쇠는 시간 간격에 맞춰 세심하게 조정해야 합니다. 시간 간격이 달라져 감쇠의 효과가 바뀌면 물리 계산이 불안정해집니다.
이런 불안정성만으로도 충분히 나쁩니다. 이번 예제는 경계해야 한다는 뜻에서 여기까지 살펴보고, 더 나은 방법으로 넘어가겠습니다.
원주 9-13: ‘결정적’이라는 말은 실행할 때마다 입력이 같으면 결과도 정확히 같다는 뜻입니다. 결정적인 프로그램은 버그를 일으키는 입력만 찾으면 반복 재현할 수 있어 디버깅이 훨씬 쉽습니다. 컴퓨터는 기계적으로 코드를 실행하므로 본래 결정적입니다. 비결정성은 까다로운 현실 세계가 개입할 때 생깁니다. 네트워크, 시스템 클럭, 스레드 스케줄링 등은 프로그램이 완전히 통제할 수 없는 외부 세계에 어느 정도 의존합니다.
원주 9-14: 정말로 물체가 ‘튀어 오를’ 수 있습니다. 물리 엔진의 상태가 망가지면 속도가 이상해져 객체가 하늘 높이 튀어 오르기도 합니다.
💡
오차 설명 보충: 덧셈 횟수가 많다고 오차가 반드시 더 큰 것은 아닙니다. 여기서 중요한 점은 두 기계가 서로 다른 시간 간격과 연산 순서로 계산하면 같은 결과를 보장할 수 없다는 것입니다. 고정 시간 간격도 그 자체만으로 플랫폼 간 결정성을 보장하지는 않지만, 계산 과정을 맞추는 데 필요한 조건을 단순하게 합니다.
따라잡기
가변 시간 간격에 영향을 받지 않는 부분 중 하나는 렌더링입니다. 렌더링은 실행 순간의 상태를 포착할 뿐, 이전 렌더링에서 얼마나 시간이 지났는지는 고려하지 않습니다. 때가 되면 현재 상태를 그리면 됩니다.
이 특성을 활용하겠습니다. 물리와 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(); }
C++
복사
프레임을 시작할 때마다 새로 흐른 실제 시간을 lag에 더합니다. 이 값은 게임 시간이 실제 시간에 얼마나 뒤처졌는지를 뜻합니다. 안쪽 루프는 실제 시간을 따라잡을 때까지 고정 시간 간격으로 게임을 업데이트합니다. 따라잡고 나면 렌더링한 뒤 바깥 루프를 다시 실행합니다.
원주 9-15: 모션 블러처럼 시간 간격의 영향을 받는 작업도 있습니다. 하지만 이때도 약간의 시간 차이는 플레이어가 잘 알아차리지 못합니다.
그림 9-2. 핵심 업데이트 루프로부터 렌더링을 분리합니다
여기서 MS_PER_UPDATE는 더 이상 시각적인 프레임 레이트가 아닙니다. 게임을 얼마나 촘촘히 업데이트할지를 정하는 값입니다. 시간 간격이 짧으면 실제 시간을 따라잡는 데 더 많은 계산이 필요하고, 길면 게임플레이가 끊겨 보입니다. 이상적으로는 60FPS보다 빠르게 업데이트되도록 간격을 충분히 짧게 잡아, 좋은 PC에서 시뮬레이션이 더 그럴듯하게 보이게 합니다.
하지만 너무 짧으면 안 됩니다. 가장 느린 하드웨어에서도 한 번의 update()를 실행하는 데 걸리는 실제 시간보다는 시뮬레이션 시간 간격이 커야 합니다. 그렇지 않으면 게임 시간이 계속 뒤처집니다.
다행히 렌더링을 내부 업데이트 루프에서 빼서 CPU 시간에 여유가 생겼습니다. 느린 PC에서는 화면이 조금 끊길 수 있지만, 안전한 고정 시간 간격으로 여러 하드웨어에서 게임을 일정한 속도로 시뮬레이션할 수 있습니다.
원주 9-16: 내부 업데이트 루프에 최대 반복 횟수를 두는 보호 장치를 넣을 수 있습니다. 그러면 게임이 느려질 수는 있어도 영원히 멈춰 있는 것보다는 낫습니다. 옮긴이는 다음과 같은 조건을 예로 들었습니다.
for (int i = 0; lag >= MS_PER_UPDATE && i < kMaxLoopTime; ++i) { // 고정 시간 간격으로 업데이트합니다. }
C++
복사
원주 9-17 · 옮긴이: 유니티의 MonoBehaviour::FixedUpdate가 이런 방식입니다.
💡
밀린 업데이트가 계속 쌓인다면: 예를 들어 10ms의 시뮬레이션을 계산하는 데 매번 15ms가 걸리면, 따라잡으려고 실행한 업데이트가 오히려 지연을 더 키웁니다. 이를 흔히 ‘죽음의 나선(spiral of death)’이라고 부릅니다. 최대 반복 횟수와 함께 일시정지 복귀 후 누적 시간을 어떻게 처리할지, 남은 지연을 유지할지 버릴지도 게임 규칙에 맞게 정해야 합니다.
중간에 끼는 경우
아직 자투리 시간(residual lag) 문제가 남아 있습니다. 업데이트는 고정 간격이지만 렌더링은 가능한 시점에 실행하므로, 두 업데이트 사이에서 화면을 그리는 일이 자주 생깁니다.
그림 9-3. 시간에 따른 업데이트와 렌더링 시점
업데이트는 정확히 고정된 간격으로 진행하지만 렌더링은 가능할 때마다 합니다. 그림에서는 업데이트보다 빈도가 낮고 간격도 일정하지 않습니다. 여기까지는 괜찮습니다. 문제는 항상 업데이트 직후에 렌더링하지는 않는다는 점입니다. 예를 들어 세 번째 렌더링은 두 업데이트 사이에 있습니다.
그림 9-4. 두 업데이트 사이에 렌더링이 실행됩니다
총알이 화면을 가로지르는 상황을 다시 떠올려 보겠습니다. 첫 업데이트에서는 왼쪽에 있고 다음 업데이트에서는 오른쪽에 있습니다. 두 시점의 중간에서 렌더링한다면 플레이어는 가운데에 있는 총알을 보아야 합니다. 하지만 현재 구현은 여전히 왼쪽에 그립니다. 그래서 움직임이 튀어 보입니다.
다행히 lag를 보면 렌더링 시점이 업데이트 프레임에서 얼마나 떨어져 있는지 알 수 있습니다. lag가 업데이트 간격보다 작아지면 내부 루프를 빠져나옵니다. 원문은 이때의 lag를 ‘다음 프레임까지 남은 시간’이라고 설명합니다. 렌더링에는 다음 값을 넘깁니다.
render(lag / MS_PER_UPDATE);
C++
복사
원주 9-18: lag를 MS_PER_UPDATE로 나누어 정규화했습니다. 따라서 render()의 인수는 업데이트 간격과 관계없이, 이전 프레임 직후의 0부터 다음 프레임 직전의 1.0 사이가 됩니다. 렌더러는 프레임 레이트 대신 0~1 사이 값만 신경 쓰면 됩니다.
💡
lag의 정확한 의미: 위 코드에서 lag는 마지막으로 완료한 업데이트 시점 이후 남아 있는 누적 시간입니다. 즉 그 시점으로부터 이미 흐른 자투리 시간이며, 다음 업데이트까지 남은 시간은 MS_PER_UPDATE - lag입니다. 원문의 ‘남은 시간’이라는 표현을 이렇게 구분하면 코드와 그림이 일치합니다. 내부 루프를 정상적으로 빠져나온 뒤에는 0 ≤ lag < MS_PER_UPDATE입니다.
렌더러는 게임 객체와 각 객체의 현재 속도를 알고 있습니다. 총알이 왼쪽에서 20픽셀 떨어져 있고 프레임마다 오른쪽으로 400픽셀 이동한다고 하겠습니다. 프레임 중간이라면 render()는 0.5를 받아 총알을 220픽셀에 그립니다. 이제 움직임이 부드럽습니다.
물론 이런 보간은 틀릴 수 있습니다. 다음 프레임을 실제로 계산해 보니 총알이 장애물에 부딪혔거나 느려질 수도 있습니다. 렌더링에서는 현재 위치와 다음 프레임의 예상 위치 사이를 보간하지만, 물리와 AI를 실제로 업데이트하기 전까지 정확한 위치는 알 수 없습니다.
추측이 들어가므로 틀릴 수 있지만, 이런 위치의 오차는 크게 눈에 띄지 않습니다. 적어도 보간하지 않아 움직임이 튀는 것보다는 덜 거슬립니다.
💡
보간과 예측을 구분하기: 책의 현재 위치 + 속도 × 자투리 시간 예제는 엄밀히는 현재 상태에서 미래를 예측하는 외삽입니다. 다른 흔한 구현은 이전·현재의 확정된 두 상태를 저장하고 alpha = lag / dt로 그 사이를 보간합니다. 후자는 한 업데이트 간격만큼 늦게 보이는 대신 아직 계산하지 않은 충돌을 예측하지 않아도 됩니다. 두 방식 모두 시뮬레이션 상태를 바꾸지 않고 그릴 위치만 계산해야 합니다.

9.7 디자인 결정

분량이 길어졌지만 다루지 못한 내용이 더 많습니다. 화면 재생 빈도(refresh rate)와의 동기화, 멀티스레딩, GPU까지 고려하면 실제 루프는 훨씬 복잡해집니다. 여기서는 고수준에서 확인할 질문만 살펴보겠습니다.
게임 루프를 직접 관리하는가, 플랫폼이 관리하는가?
대부분은 선택의 여지가 없습니다. 웹 브라우저는 이벤트 기반이라 별도의 전형적인 루프를 만들기 어렵습니다. 기존 게임 엔진을 쓴다면 직접 만들기보다 엔진의 루프를 사용할 가능성이 높습니다.
플랫폼의 이벤트 루프를 사용한다면:
•
간단합니다. 게임의 핵심 루프를 직접 작성하고 최적화하느라 고민하지 않아도 됩니다.
•
플랫폼에 잘 맞습니다. 플랫폼이 이벤트를 처리하도록 시간을 따로 주거나 이벤트를 캐시하거나, 플랫폼과 게임의 입력 모델을 맞추는 작업을 신경 쓰지 않아도 됩니다.
•
시간을 제어할 수 없습니다. 플랫폼이 적당하다고 판단할 때 우리 코드를 호출합니다. 원하는 만큼 자주, 부드럽게 호출하지 않으면 문제가 됩니다. 대부분의 애플리케이션 이벤트 루프는 게임을 고려해 설계하지 않아 느리고 끊기는 경우가 많습니다.
게임 엔진의 루프를 사용한다면:
•
직접 작성하지 않아도 됩니다. 루프는 만들기 쉽지 않습니다. 매 프레임 실행하므로 사소한 버그나 작은 최적화 문제도 크게 영향을 줍니다. 이렇게 요구가 까다로운 만큼 엔진의 루프를 그대로 쓰는 것도 좋은 선택입니다.
•
직접 작성할 수 없습니다. 엔진 루프에 아쉬운 부분이 있어도 손댈 수 없다는 점은 단점입니다.
직접 만든 루프를 사용한다면:
•
완전히 제어할 수 있습니다. 무엇이든 할 수 있고 개발 중인 게임에 꼭 맞게 설계할 수 있습니다.
•
플랫폼과 상호작용해야 합니다. 일부 애플리케이션 프레임워크나 OS는 이벤트 처리와 다른 작업을 위해 시간을 나눠주기를 기대합니다. 우리 루프만 계속 돌리면 프레임워크에 시간이 주어지지 않을 수 있으므로, 응답 없이 멈추는 행(hang)을 막으려면 때때로 제어권을 넘겨야 합니다.
전력 소모 문제
이 책이 쓰이기 5년 전만 해도 전력은 큰 문제가 아니었습니다. 전원이 연결된 기계나 게임 전용 휴대 기기로 플레이했기 때문입니다. 스마트폰, 노트북, 모바일 게임의 시대에는 전력을 고민할 가능성이 높아졌습니다. 게임이 아무리 멋져도 30분 만에 배터리가 나가고 휴대전화가 손난로가 되면 반가워할 사람은 없습니다.
이제는 멋진 화면뿐 아니라 CPU를 가능한 한 적게 쓰는 방법도 생각해야 합니다. 한 프레임의 작업을 끝내면 CPU를 쉬게 해야 하는 성능의 상한이 있는 셈입니다.
최대한 빨리 실행하기:
PC 게임이 주로 사용하는 방식입니다. 다만 PC 게임도 점점 노트북에서 많이 플레이됩니다. 루프에서 OS의 sleep()을 따로 호출하지 않고, 시간이 남으면 FPS나 그래픽 품질을 높입니다.
최고의 게임플레이를 제공할 수 있지만 전력도 최대한 사용합니다. 노트북으로 플레이하면 성능 좋은 무릎 난로까지 얻게 됩니다.
프레임 레이트 제한하기:
모바일 게임은 그래픽 품질보다 게임플레이 품질에 집중하는 편입니다. 보통 30FPS나 60FPS처럼 상한을 둡니다. 프레임 시간 안에 할 일을 끝내면 나머지 시간에는 sleep()을 호출합니다.
이렇게 하면 ‘충분히 괜찮은’ 경험을 제공하면서 배터리 소모를 줄일 수 있습니다.
게임플레이 속도를 어떻게 제어할 것인가?
게임 루프의 두 핵심은 비동기 사용자 입력과 실제 시간 따라잡기입니다. 입력은 쉽지만 시간은 어렵습니다. 게임을 실행할 수 있는 플랫폼은 매우 많고, 한 플랫폼에서만 돌아가는 게임은 드뭅니다. 다양한 플랫폼을 어떻게 지원할지가 핵심입니다.
원주 9-19: 게임을 만드는 일은 인간의 본성일지도 모릅니다. 계산할 수 있는 하드웨어가 새로 나오면 가장 먼저 하는 일 중 하나가 게임 만들기이기 때문입니다. 원문은 PDP-1을 ‘2kHz 클럭과 4,096워드의 메모리’라고 소개하며, 스티브 러셀과 동료들이 이 기계에서 《스페이스워!(Spacewar!)》를 만들었다고 설명합니다. 옮긴이는 PDP-1이 DEC에서 1959년에 개발한 컴퓨터라고 덧붙였습니다.
동기화 없는 고정 시간 간격 방식:
맨 처음 예제처럼 루프를 최대한 빠르게 실행합니다.
•
간단합니다. 주된 장점이자 유일한 장점입니다.
•
하드웨어와 게임 복잡도가 게임 속도에 곧바로 영향을 줍니다. 작은 차이에도 속도가 달라집니다. 게임 루프계의 픽시 자전거인 셈입니다.
원주 9-20 · 옮긴이: 픽시 자전거에는 기어가 없습니다.
동기화하는 고정 시간 간격 방식:
고정 간격으로 게임을 실행하되 루프 마지막에 지연이나 동기화 지점을 넣어 너무 빨리 실행되지 않게 합니다.
•
여전히 간단한 편입니다. 앞의 ‘너무 간단해서 쓰기 어려운’ 방식에 코드 한 줄을 더한 정도입니다. 게임 루프는 어차피 어느 정도 동기화합니다. 이중 버퍼에 그린 뒤 화면 재생 빈도에 맞춰 버퍼를 교환하는 것이 그 예입니다.
•
전력 효율이 높습니다. 특히 모바일에서는 배터리를 낭비하면 안 됩니다. 매 틱에 일을 더 하기보다 몇 밀리초라도 쉬어야 전력을 아낍니다.
•
너무 빨라지지 않습니다. 동기화 없는 방식에서 생기는 속도 문제의 절반을 해결합니다.
•
너무 느려질 수 있습니다. 업데이트와 렌더링에 오래 걸리면 게임 재생이 느려집니다. 둘을 분리하지 않았기 때문에 이런 상황이 생기기 더 쉽습니다. 시간을 따라잡기 위해 렌더링 프레임을 줄이지 못하므로 게임플레이가 느려집니다.
가변 시간 간격 방식:
주변 개발자들이 말렸지만, 왜 나쁜지 아는 것도 의미가 있으므로 경고와 함께 포함했습니다.
•
아주 느리거나 빠른 환경에도 맞춰 플레이할 수 있습니다. 실제 시간을 따라잡지 못하면 시간 간격을 늘립니다.
•
게임플레이가 불안정하고 비결정적이 됩니다. 이것이 진짜 문제입니다. 특히 물리와 네트워크 작업이 훨씬 어려워집니다.
업데이트는 고정 시간 간격, 렌더링은 가변 시간 간격:
‘따라잡기’에서 본 마지막 방식입니다. 가장 복잡하지만 적응력도 가장 높습니다. 업데이트는 고정 간격으로 하되, 실제 시간을 따라잡아야 하면 렌더링 프레임을 낮출 수 있습니다.
•
느리거나 빠른 환경 모두에 잘 적응합니다. 실시간으로 업데이트할 수만 있다면 뒤처지지 않습니다. 최고 사양에서는 게임을 더 부드럽게 보여줄 수 있습니다.
•
훨씬 복잡합니다. 구현의 복잡성이 주된 단점입니다. 고사양 사용자를 위해 업데이트 간격을 짧게 잡으면서도, 저사양 사용자의 게임이 너무 느려지지 않게 해야 합니다.
원주 9-21 · 옮긴이: 실시간으로 업데이트한다는 것은, 여기의 16ms 간격 예에서는 그 안에 모든 업데이트 작업을 끝낼 수 있다는 뜻입니다.
💡
확인해 보기: 144Hz 모니터에서 60Hz 모니터보다 캐릭터가 빨리 달린다면, 이동량을 실제 시간 대신 렌더링 횟수에 연결하지 않았는지 살펴보세요. 고정 업데이트는 같은 시간에 같은 횟수로 진행하고, 렌더링은 그 사이를 부드럽게 보여주는 역할을 맡게 할 수 있습니다.

9.8 관련 자료

•
글렌 피들러(Glenn Fiedler)의 Fix Your Timestep은 게임 루프에 관한 고전입니다. 이 글이 없었다면 이 장도 전혀 달라졌을 것입니다.
•
쿤 비터르스(Koen Witters)의 deWITTERS Game Loop도 좋은 글입니다.
•
‘MonoBehaviour Lifecycle’은 복잡한 유니티 프레임워크의 게임 루프를 그림으로 잘 설명합니다.
원주 9-22 · 옮긴이: 지금은 ‘Execution Order of Event Functions’로 검색하면 됩니다. 원문에 제시된 링크.