Unity 검색

더욱 원활한 게임플레이를 위한 Unity 2020.2 Time.deltaTime 개선

2020년 10월 1일 테크놀로지 | 18 분 소요
Blog header image
Blog header image
다루는 주제
공유

Unity 2020.2 베타 버전에서는 여러 개발 플랫폼에서 일관되지 않은 Time.deltaTime 값으로 인해 움직임이 끊어지거나 불안정해지는 문제가 수정되었습니다. 이번 포스팅에서는 이에 관한 문제와 곧 출시될 Unity 버전을 통해 조금 더 원활한 게임플레이를 구현하는 방법을 소개합니다.

게임 개발 과정에서는 항상 프레임 속도에 구애받지 않는 움직임을 실현하기 위해 프레임 델타 타임을 염두에 두어야 합니다.

void Update()
{
transform.position += m_Velocity * Time.deltaTime;
}

Time.deltaTime 을 곱해주면 게임이 실행되는 프레임 속도에 관계없이 오브젝트가 일정한 평균 속도로 움직이는 바람직한 효과를 달성할 수 있습니다. 또한 프레임 속도가 고정되어 있다면 이론적으로는 오브젝트가 일정한 속도로 움직여야 합니다. 하지만 실제로 보고된 Time.deltaTime 값을 보면 다음과 같은 현상을 확인할 수 있습니다.

6.854 ms
7.423 ms
6.691 ms
6.707 ms
7.045 ms
7.346 ms
6.513 ms

이 문제는 Unity를 비롯한 여러 게임 엔진에 영향을 미치는 사안으로 사용자 피드백을 통해 수정 작업을 시작하게 되었습니다. Unity 2020.2 베타에서는 이 문제가 안정적으로 해결되었습니다.

그렇다면, 이 현상은 왜 나타나는 것일까요? 프레임 속도가 일정한 144fps로 고정되어도 Time.deltaTime 이 매번 1144초(약 6.94ms)가 아닌 이유는 무엇일까요? 이 블로그 포스팅에서는 이 현상을 조사하고 해결하는 과정을 소개합니다.

델타 타임의 정의와 중요성

쉽게 풀어서 말하자면 델타 타임은 마지막 프레임이 완료되는 데 걸린 시간입니다. 이는 간단하게 들리지만 생각만큼 직관적이지는 않습니다. 대부분의 게임 개발 서적에서는 게임 루프에 대해 다음과 같은 전형적인 정의를 내리고 있습니다.

while (true)
{
ProcessInput();
Update();
Render();
}

이와 같은 게임 루프로 델타 타임을 다음과 같이 간단하게 계산할 수 있습니다.

var time = GetTime();
while (true)
{
var lastTime = time;
time = GetTime();
var deltaTime = time - lastTime;
ProcessInput();
Update(deltaTime);
Render(deltaTime);
}

이 모델은 간단하고 이해하기 쉽지만 최신 게임 엔진에는 매우 부적합합니다. 고성능을 실현하기 위해 요즘 엔진은 "파이프라이닝(pipelining)"이라는 기법을 사용하여 주어진 시간에 2개 이상의 프레임을 처리할 수 있습니다.

아래 그림과

다음 그림을 비교해 보세요.

두 사례 모두 게임 루프의 개별 파트는 동일한 길이의 시간을 차지하지만 두 번째 사례에서는 병렬 실행을 통해 동일한 시간에 두 배 더 많은 프레임을 내보낼 수 있습니다. 엔진을 파이프라이닝하면 총 프레임 시간이 모든 파이프라인 단계의 합이 아닌 가장 긴 단계에 걸린 시간과 같아집니다.

하지만 엔진의 모든 프레임에서 실제로 일어나는 상황은 위 내용보다도 더 복잡합니다.

  • 각 파이프라인 단계에서 소요되는 시간은 프레임마다 다릅니다. 지난 프레임보다 이번 프레임에 화면상의 오브젝트가 더 많을 경우 렌더링에 더 오랜 시간이 걸립니다. 혹은 플레이어가 키보드의 키를 무분별하게 많이 눌러 입력 처리에 더 오랜 시간이 걸릴 수도 있습니다.
  • 이처럼 파이프라인 단계마다 소요되는 시간이 다르므로 빠른 단계가 지나치게 앞서 나아가지 않도록 이 단계를 인위적으로 중단해야 합니다. 이를 구현하는 가장 일반적인 방법은 이전 프레임이 프론트 버퍼(스크린 버퍼라고도 함)로 플립되기까지 기다리는 것입니다. VSync가 활성화되어 있다면 이는 또한 디스플레이의 VBLANK의 시작 부분과 동기화됩니다. 여기에 대해서는 나중에 자세히 다루겠습니다.

위 사실을 염두에 두고, Unity 2020.1의 일반적인 프레임 타임라인을 살펴보겠습니다. 플랫폼과 다양한 설정이 큰 영향을 미치므로 이 문서에서는 Multithreaded Rendering과 VSync가 활성화되어 있고, Graphics Jobs가 비활성화되어 있으며 QualitySettings.maxQueuedFrames가 2로 설정되어 있는 Windows 스탠드얼론 플레이어를 144Hz 모니터에서 프레임 손실 없이 실행하는 경우로 가정합니다. 아래 이미지를 클릭하면 전체 화면으로 볼 수 있습니다.

Unity의 프레임 파이프라인이 처음부터 지금과 같은 형태로 구현된 것은 아닙니다. 지난 10년에 걸쳐 발전을 거듭하며 현재의 모습을 갖추게 되었습니다. Unity 이전 버전을 살펴보면 거의 매 릴리스마다 변화가 있었다는 사실을 확인할 수 있습니다.

몇 가지 대표적인 변경 사항은 다음과 같습니다.

  • 모든 작업이 GPU에 제출되면 Unity는 해당 프레임이 화면에 플립될 때까지 기다리지 않고 이전 프레임을 기다립니다. 이 과정은 QualitySettings.maxQueuedFrames API를 통해 제어됩니다. 이 설정은 현재 표시되고 있는 프레임이 현재 렌더링 중인 프레임 뒤로 얼마나 멀리 위치할 수 있는지를 설명합니다. 프레임 n이 화면에 표시되고 있는 경우 프레임n+1을 렌더링하는 것이 최선이므로 가능한 최소값은 1입니다. 이 경우에는 기본값인 2로 설정되어 있으므로 Unity는 프레임n+2의 렌더링을 시작하기 전에 프레임 n이 화면에 표시되도록 합니다(예를 들어, Unity에서는 프레임5의 렌더링을 시작하기 전에 프레임3이 화면에 나타날 때까지 대기함).
  • 프레임5가 GPU에서 렌더링되는 데 걸리는 시간이 모니터의 새로고침 간격 하나보다 더 길지만(각각 7.22ms, 6.94ms 소요) 프레임 손실은 없었습니다. 이 현상은 실제 프레임이 화면에 나타날 때 값이 2인 QualitySettings.maxQueuedFrames 가 지연되기 때문이며, 이로 인해 “스파이크”가 발생하지 않는 한 프레임 손실을 막아주는 버퍼가 생성됩니다. 값이 1로 설정되었다면 더 이상 작업이 중첩되지 않으므로 Unity에서 분명 프레임이 손실되었을 것입니다.

화면 새로고침이 6.94ms마다 이루어지더라도 Unity의 시간 샘플링에서는 다음과 같이 다른 결과가 표시됩니다.

tdeltaTime(5) = 1.4 + 3.19 + 1.51 + 0.5 + 0.67 = 7.27 ms
tdeltaTime(6) = 1.45 + 2.81 + 1.48 + 0.5 + 0.4 = 6.64 ms
tdeltaTime(7) = 1.43 + 3.13 + 1.61 + 0.51 + 0.35 = 7.03 ms

이 경우 평균 델타 타임((7.27 + 6.64 + 7.03)/3 = 6.98ms)은 실제 모니터 새로고침 속도에 매우 근접하며, 더 긴 기간에 걸쳐 측정하면 결국 정확히 평균 6.94ms가 도출될 것입니다. 하지만 가시적인 오브젝트 움직임을 계산하기 위해 이 델타 타임을 그대로 사용할 경우 아주 미세한 떨림이 발생하게 됩니다. 이를 설명하기 위해 간단한 Unity 프로젝트를 만들었습니다. 여기에는 월드 공간을 가로질러 움직이는 세 개의 녹색 정사각형이 있습니다.

카메라는 상단의 정사각형에 연결되어 있으므로 이 정사각형은 화면에서 완전히 정지되어 있는 것처럼 보입니다. 만약 Time.deltaTime 이 정확하다면 중간과 하단에 있는 정사각형도 멈춰 있는 것처럼 보일 것입니다. 정사각형은 매초 디스플레이 폭의 두 배만큼 움직이며, 속도가 높을수록 떨림 현상도 더 잘 보이게 됩니다. 배경의 고정된 위치에는 움직이지 않는 보라색과 분홍색 정사각형을 배치하여 실제로 녹색 정사각형이 얼마나 빠르게 움직이는지 알 수 있도록 했습니다.

Unity 2020.1에서는 중간과 하단의 정사각형이 상단 정사각형의 움직임과 일치하지 않으므로 약간의 떨림이 발생합니다. 아래는 슬로모션 카메라로 캡처한 동영상입니다(20배 느린 화면).

델타 타임 변화의 원인 찾기

델타 타임은 왜 동일한 값으로 유지되지 않을까요? 디스플레이는 각 프레임을 고정된 시간 동안 표시하여 6.94ms마다 장면을 전환합니다. 이것은 한 프레임이 화면에 나타나는 데 걸리는 시간이자 게임 플레이어가 각 프레임을 보게 되는 시간의 길이이므로 실제 델타 타임에 해당합니다.

각각의 6.94ms 간격은 처리(processing)와 절전(sleeping)이라는 두 부분으로 구성됩니다. 예시 프레임 타임라인에서는 메인 스레드에서 델타 타임을 계산했으니 여기에 초점을 맞추겠습니다. 메인 스레드의 처리 부분은 OS 메시지 펌핑, 입력 처리, Update 호출 및 렌더링 명령 발행으로 이루어집니다. “렌더 스레드 대기”는 절전 부분에 해당합니다. 이 두 간격의 합은 다음과 같이 실제 프레임 시간과 같습니다.

tprocessing + twaiting = 6.94 ms

이 두 가지 상태는 프레임마다 다양한 이유로 변동되지만 합은 일정하게 유지됩니다. 처리 시간이 증가하면 대기 시간은 감소하고 그 반대도 마찬가지이므로 언제나 정확하게 6.94ms가 됩니다. 실제로 대기 상태로 이어지는 모든 부분의 합은 언제나 6.94ms입니다.

tissueGPUCommands(4) + tpumpOSMessages(5) + tprocessInput(5) + tUpdate(5) + twait(5) = 1.51 + 0.5 + 0.67 + 1.45 + 2.81 = 6.94 ms
tissueGPUCommands(5) + tpumpOSMessages(6) + tprocessInput(6) + tUpdate(6) + twait(6) = 1.48 + 0.5 + 0.4 + 1.43 + 3.13 = 6.94 ms
tissueGPUCommands(6) + tpumpOSMessages(7) + tprocessInput(7) + tUpdate(7) + twait(7) = 1.61 + 0.51 + 0.35 + 1.28 + 3.19 = 6.94 ms

그러나 Unity는 Update 시작 시 시간을 쿼리합니다. 따라서 렌더링 커맨드 발행, OS 메시지 펌핑 또는 입력 이벤트 처리에 소요되는 시간에 변화가 있는 경우 잘못된 결과가 도출됩니다.

단순화된 Unity 메인 스레드 루프를 다음과 같이 정의할 수 있습니다.

while (!ShouldQuit())
{
PumpOSMessages();
UpdateInput();
SampleTime(); // We sample time here!
Update();
WaitForRenderThread();
IssueRenderingCommands();
}

이 문제의 해결책은 간단해 보입니다. 시간 샘플링을 대기 이후로 옮기기만 하면 되며, 이때 게임 루프는 다음과 같아집니다.

while (!ShouldQuit())
{
PumpOSMessages();
UpdateInput();
Update();
WaitForRenderThread();
SampleTime();
IssueRenderingCommands();
}

예상과 달리 이렇게 변경하더라도 의도한 대로 작동하지 않습니다. 렌더링의 시간 값이 Update() 와 다르므로 모든 항목에 부정적인 영향을 미칩니다. 이 지점에서 샘플링된 시간을 저장하고 다음 프레임 시작 시에만 엔진 시간을 업데이트하는 것이 한 가지 옵션이 될 수 있습니다. 하지만 이 방법에서는 엔진이 최신 프레임을 렌더링하기 전부터 시간을 사용하게 됩니다.

이처럼 SampleTime()Update() 이후로 옮기는 것은 효과적이지 않으므로, 대기를 프레임 시작 부분으로 옮기면 성공 확률이 더 높을 수 있습니다.

while (!ShouldQuit())
{
PumpOSMessages();
UpdateInput();
WaitForRenderThread();
SampleTime();
Update();
IssueRenderingCommands();
}

그러나 이 경우 또 다른 문제가 발생합니다. 이제는 렌더 스레드가 거의 요청 즉시 렌더링을 마쳐야 하며 이는 작업을 병렬로 실행하는 데에 따른 이점을 렌더링 스레드가 최소한만 누릴 수 있다는 뜻이기도 합니다.

프레임 타임라인을 다시 한 번 살펴 보겠습니다.

2020년 10월 1일 테크놀로지 | 18 분 소요
다루는 주제