게임에서 제일 중요한 디자인패턴을 하나 뽑자면 당연하게도 게임 루프 패턴을 고를 것이다. 게임 루프는 거의 모든 게임에서 사용되고 있으며, 어느 것도 서로 똑같지 않고, 게임이 아닌 분야에서 그다지 쓰이지 않는다는 점에서 전형적인 게임프로그래밍을 위한 디자인패턴이라고 보면된다.
게임루프가 얼마나 유용한지 과거의 프로젝트들을 살펴보자. 초창기에 프로그래머들이 만든 프로그램들은 대부분 임베디드(세탁기, 식기세척기, 냉장고, 자동차)같은 프로그램처럼 동작했습니다. 버튼을 누르고 그에 해당하는 동작이 실행되는 방식 이었다. 이런 걸 배치모드프로그램이라고 한다. 더이상 이러한 프로그램은 점점 사라지고 있다. 하드웨어가 급격하게 발전함으로써…
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++
복사
보시다시피, 업데이트는 깔끔하고 고정된 간격으로 실행됩니다. 반면 렌더링은 가능할 때마다 수행됩니다. 업데이트보다 덜 빈번하고 일정하지도 않지만, 이는 문제가 되지 않습니다. 진짜 문제는 렌더링이 항상 업데이트 시점에 정확히 일어나는 것은 아니라는 점입니다. 세 번째 렌더링을 보세요. 두 업데이트 사이에 위치합니다:
총알이 화면을 가로질러 날아간다고 상상해보자. 첫 번째 업데이트에서 총알은 왼쪽에 있다. 두 번째 업데이트에서는 오른쪽으로 이동한다. 게임이 이 두 업데이트 사이 시점에 렌더링되므로, 사용자는 총알이 화면 중앙에 있을 것으로 기대한다. 하지만 현재 구현에서는 여전히 왼쪽에 있다. 이는 움직임이 들쭉날쭉하거나 끊겨 보인다는 의미다.
다행히 렌더링할 때 업데이트 프레임 사이에서 정확히 얼마나 떨어져 있는지 알고 있다. 그 값은 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!를 만들어냈다.
•
동기화 없는 고정 시간 단계: 이것은 우리의 첫 번째 샘플 코드였다. 가능한 한 빠르게 게임 루프를 실행하면 된다.
◦
간단하다. 이것이 주요한(음, 유일한) 장점이다.
◦
게임 속도가 하드웨어와 게임 복잡도에 직접적으로 영향을 받는다. 주요 단점은 변화가 있다면 그것이 게임 속도에 직접적으로 영향을 미친다는 것이다. 게임 루프의 픽시 자전거다.
•
동기화가 있는 고정 시간 단계: 복잡성 사다리의 다음 단계는 고정 시간 단계로 게임을 실행하되, 게임이 너무 빠르게 실행되지 않도록 루프 끝에 지연이나 동기화 지점을 추가하는 것이다.
◦
여전히 꽤 간단하다. 아마도 실제로 작동하기에는 너무 간단한 예제보다 코드 한 줄만 더 많다. 대부분의 게임 루프에서는 어쨌든 동기화를 할 것이다. 아마도 그래픽을 더블 버퍼링하고 디스플레이의 재생률에 버퍼 플립을 동기화할 것이다.
◦
전력 친화적이다. 이것은 모바일 게임에서 놀랍도록 중요한 고려사항이다. 사용자의 배터리를 불필요하게 소진시키고 싶지 않다. 각 틱에 더 많은 처리를 집어넣으려고 하는 대신 단순히 몇 밀리초 동안 휴면하면 전력을 절약할 수 있다.
◦
게임이 너무 빠르게 실행되지 않는다. 이것은 고정 루프의 속도 문제의 절반을 해결한다.
◦
게임이 너무 느리게 실행될 수 있다. 게임 프레임을 업데이트하고 렌더링하는 데 너무 오래 걸리면 재생이 느려진다. 이 스타일은 업데이트와 렌더링을 분리하지 않기 때문에 더 고급 옵션보다 더 빨리 이 문제에 부딪힐 가능성이 있다. 따라잡기 위해 렌더링 프레임만 건너뛰는 대신 게임플레이가 느려진다.
•
가변 시간 단계: 이것을 솔루션 공간의 옵션으로 여기에 포함시키겠지만, 제가 아는 대부분의 게임 개발자들은 이것을 반대한다. 그것이 왜 나쁜 아이디어인지 기억하는 것이 좋다.
◦
너무 느리게 그리고 너무 빠르게 실행되는 것 모두에 적응한다. 게임이 실시간을 따라잡지 못한다면 따라잡을 때까지 점점 더 큰 시간 단계를 취한다.
◦
게임플레이를 비결정적이고 불안정하게 만든다. 이것이 물론 진짜 문제다. 특히 물리와 네트워킹은 가변 시간 단계로 훨씬 더 어려워진다.
•
고정 업데이트 시간 단계, 가변 렌더링: 샘플 코드에서 다룬 마지막 옵션은 가장 복잡하지만 또한 가장 적응력이 높다. 고정 시간 단계로 업데이트하지만, 플레이어의 시계를 따라잡기 위해 필요하다면 렌더링 프레임을 건너뛸 수 있다.
◦
너무 느리게 그리고 너무 빠르게 실행되는 것 모두에 적응한다. 게임이 실시간으로 업데이트할 수 있는 한 게임은 뒤처지지 않는다. 플레이어의 머신이 최고급이라면 더 부드러운 게임플레이 경험으로 응답한다.
◦
더 복잡하다. 주요 단점은 구현에 조금 더 많은 노력이 필요하다는 것이다.








