Search Unity

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

, 10월 1, 2020

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();
}

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

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

Unity는 프레임마다 렌더 스레드를 기다리는 방식으로 파이프라인을 동기화합니다. 이는 메인 스레드가 화면에 표시되는 내용보다 너무 앞서 실행되지 않도록 하기 위해 필요합니다. 렌더 스레드는 렌더링을 마치고 프레임이 화면에 나타나길 기다릴 때 비로소 “작업 완료”인 것으로 간주됩니다. 다시 말해, 백 버퍼가 플립되어 프론트 버퍼가 될 때까지 대기한다는 의미입니다. 그러나 이전 프레임이 화면에 표시되어도 렌더 스레드에는 중요하지 않으며 이를 중요하게 여기는 것은 스스로 속도를 조절해야 하는 메인 스레드뿐입니다. 따라서, 렌더 스레드가 화면에 프레임이 나타날 때까지 대기하게 만들지 않고 이 대기 단계를 메인 스레드로 옮기면 됩니다. 이것을 WaitForLastPresentation()이라고 하겠습니다. 그러면 메인 스레드 루프는 다음과 같아집니다.

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

이제 시간이 루프의 대기 부분 직후에 샘플링되므로 타이밍이 모니터의 새로고침 속도와 맞춰집니다. 시간은 프레임 시작 시에도 샘플링되기 때문에 Update() 및 Render()의 타이밍이 같아집니다.

여기에서 WaitForLastPresention()은 프레임n – 1이 화면에 나타나기를 기다리지 않는다는 점이 매우 중요합니다. 만약 그렇게 되면 파이프라이닝은 전혀 이루어지지 않게 됩니다. 대신 프레임n – QualitySettings.maxQueuedFrames가 화면에 나타나길 기다리므로 마지막 프레임이 완료될 때까지 기다리지 않고도 메인 스레드는 계속 진행할 수 있습니다(단, maxQueuedFrames가 1로 설정되어 있는 경우에는 각 프레임이 새로운 프레임 시작 전에 완료되어야 함).

안정성을 위한 추가 작업

이 방법을 구현한 후 델타 타임은 전보다 훨씬 안정되었지만 여전히 약간의 떨림 현상과 간헐적인 변동이 발생했습니다. 개선된 방법은 운영체제가 대기 상태이던 엔진을 제때 가동할 것이라 예상합니다. 하지만 여기에는 수 마이크로초가 소요될 수 있으므로 델타 타임에 떨림 현상이 추가되며, 특히 여러 프로그램을 동시에 실행 중인 데스크톱 플랫폼에서는 더욱 그렇습니다.

그렇다면 이제 어떻게 해야 할까요? 대부분의 그래픽 API/플랫폼은 화면에 표시되는 프레임의 정확한 타임스탬프(또는 오프스크린 버퍼)를 추출할 수 있도록 해줍니다. 예를 들어, Direct3D 11 및 12에는 IDXGISwapChain::GetFrameStatistics가 있고, macOS는 CVDisplayLink를 제공합니다. 하지만 이러한 접근 방식에도 몇 가지 단점이 있습니다.

  • 지원되는 그래픽스 API마다 별도의 추출 코드를 작성해야 하며, 이로 인해 시간 측정 코드가 플랫폼에 종속되어 플랫폼마다 구현 방식이 달라집니다. 각 플랫폼은 서로 다른 방식으로 동작하므로 이와 같은 변경은 끔찍한 결과를 초래할 수도 있습니다.
  • 일부 그래픽스 API에서는 이러한 타임스탬프를 얻기 위해 VSync가 활성화되어 있어야 합니다. 즉, VSync가 비활성화되어 있으면 시간을 여전히 수동으로 계산해야 합니다.

하지만 저는 이 방법이 위험과 수고를 감수할 만한 가치가 있다고 생각합니다. 이렇게 얻어진 결과는 매우 안정적이며 디스플레이에 표시되는 내용과 직접 상응하는 타이밍을 도출합니다.

이제 샘플링 시간을 그래픽스 API에서 추출하므로 WaitForLastPresention() 및 SampleTime() 단계가 하나의 새로운 단계로 결합됩니다.

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

이를 통해 떨림 현상이 해결됩니다.

입력 지연 개선

입력 지연은 까다로운 주제입니다. 정확하게 측정하기도 쉽지 않고, 유발 요인도 입력 하드웨어, 운영체제, 드라이버, 게임 엔진, 게임 로직, 디스플레이 등으로 다양합니다. Unity가 다른 요인에는 영향을 미칠 수 없으므로 여기에서는 입력 지연을 유발하는 게임 엔진 요인에 초점을 맞추겠습니다.

엔진 입력 지연은 입력 OS 메시지를 사용할 수 있게 되는 시점과 이미지를 디스플레이로 내보낼 때까지 걸리는 시간을 의미합니다. 메인 스레드 루프를 바탕으로 다음과 같이 코드의 일부로 입력 지연을 시각화할 수 있습니다(QualitySettings.maxQueuedFrames가 2로 설정되어 있다고 가정).

PumpOSMessages(); // Pump input OS messages for frame 0
UpdateInput(); // Process input for frame 0
——————— // Earliest input event from the OS that didn’t become part of frame 0 arrives here!
WaitForLastPresentationAndGetTimestamp(); // Wait for frame -2 to appear on the screen
Update(); // Update game state for frame 0
WaitForRenderThread(); // Wait until all commands from frame -1 are submitted to the GPU
IssueRenderingCommands(); // Send rendering commands for frame 0 to the rendering thread
PumpOSMessages(); // Pump input OS messages for frame 1
UpdateInput(); // Process input for frame 1
WaitForLastPresentationAndGetTimestamp(); // Wait for frame -1 to appear on the screen
Update(); // Update game state for frame 1, finally seeing the input event that arrived
WaitForRenderThread(); // Wait until all commands from frame 0 are submitted to the GPU
IssueRenderingCommands(); // Send rendering commands for frame 1 to the rendering thread
PumpOSMessages(); // Pump input OS messages for frame 2
UpdateInput(); // Process input for frame 2
WaitForLastPresentationAndGetTimestamp(); // Wait for frame 0 to appear on the screen
Update(); // Update game state for frame 2
WaitForRenderThread(); // Wait until all commands from frame 1 are submitted to the GPU
IssueRenderingCommands(); // Send rendering commands for frame 2 to the rendering thread
PumpOSMessages(); // Pump input OS messages for frame 3
UpdateInput(); // Process input for frame 3
WaitForLastPresentationAndGetTimestamp(); // Wait for frame 1 to appear on the screen. This is where the changes from our input event appear.

OS 메시지로 입력을 사용 가능하게 되는 시점부터 그 결과가 화면에 나타나기까지 매우 많은 과정을 거치게 됩니다. Unity가 프레임을 손실하지 않고 게임 루프에서 소요되는 시간이 대부분 처리가 아닌 대기인 경우, 새로고침 속도가 144hz일 때 엔진에서 유발되는 입력 지연은 최악의 상황에 4 * 6.94 = 27.76ms에 달하며, 이는 이전 프레임이 화면에 나타나기까지 네 번(즉, 4번의 새로고침 속도 간격) 대기하기 때문입니다.

이전 프레임이 표시되기를 기다린 후에 OS 이벤트를 펌핑하고 입력을 업데이트하여 지연을 개선할 수 있습니다.

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

이렇게 하면 한 번의 대기가 제거되기 때문에 이제 최악의 경우 입력 지연은 3 * 6.94 = 20.82ms가 됩니다.

지원하는 플랫폼에서 QualitySettings.maxQueuedFrames를 1로 줄이면 입력 지연을 더 줄이는 것도 가능합니다. 그러면 입력 처리 과정은 다음과 같아집니다.

——————— // Input event arrives from the OS!
WaitForLastPresentationAndGetTimestamp(); // Wait for frame -2 to appear on the screen
PumpOSMessages(); // Pump input OS messages for frame 0
UpdateInput(); // Process input for frame 0
Update(); // Update game state for frame 0 with the input event that we are measuring
WaitForRenderThread(); // Wait until all commands from frame -1 are submitted to the GPU
IssueRenderingCommands(); // Send rendering commands for frame 0 to the rendering thread
WaitForLastPresentationAndGetTimestamp(); // Wait for frame 0 to appear on the screen. This is where the changes from our input event appear.

이제, 최악의 경우 입력 지연은 2 * 6.94 = 13.88ms가 되며, VSync를 사용할 경우에 달성할 수 있는 가장 낮은 수치입니다.

주의: 만약 QualitySettings.maxQueuedFrames를 1로 설정하면 기본적으로 엔진의 파이프라이닝을 비활성화하므로 목표로 하는 프레임 속도에 도달하기가 더 어려워집니다. 더 낮은 프레임 속도로 실행할 경우 QualitySettings.maxQueuedFrames를 2로 유지할 때보다 입력 지연이 악화될 가능성이 높다는 사실을 염두에 두시기 바랍니다. 예를 들어, 초당 72프레임으로 낮출 경우 입력 지연은 2 * 172 = 27.8ms가 되며, 이는 이전의 지연 시간인 20.82ms보다 나빠진 수치입니다. 이 설정을 사용하고 싶다면 게임 설정 메뉴에 별도 옵션을 추가하여 하드웨어의 성능이 좋은 경우 플레이어가 직접 QualitySettings.maxQueuedFrames를 낮추고, 반대의 경우 기본 설정을 유지할 수 있도록 합니다.

입력 지연에 VSync가 미치는 효과

VSync를 비활성화하면 특정 상황에서 입력 지연이 짧아질 수도 있습니다. 입력 지연이 OS에서 입력을 사용할 수 있게 되는 때부터 해당 입력을 처리한 프레임이 화면에 나타나기까지 경과되는 시간의 양이라는 사실을 고려하여, 이를 다음과 같이 수식으로 나타낼 수 있습니다.

latency = tdisplaytinput

이 방정식을 보면, 입력 지연을 줄일 수 있는 방법에는 두 가지가 있습니다. 하나는 tdisplay를 낮추는 것(이미지를 디스플레이로 더 빨리 보냄)이고, 또 다른 하나는 tinput을 높이는 것(입력 이벤트를 나중에 쿼리)입니다.

GPU에서 디스플레이로 이미지 데이터를 보내는 일은 매우 데이터 집약적인 작업입니다. 계산해보면 2560×1440의 비HDR 이미지를 초당 144번 디스플레이로 보내기 위해서는 매초 12.7기가비트를 전송해야 합니다(픽셀당 24비트 * 2560 * 1440 * 144). 이러한 데이터는 순식간에 전송될 수 없으며, GPU는 지속적으로 픽셀을 디스플레이로 전송합니다. 각 프레임이 전송된 후에는 짧은 공백이 있고 나서 다음 프레임 전송이 시작됩니다. 이 공백 기간을 VBLANK라고 부릅니다. VSync가 활성화되어 있으면 기본적으로 VBLANK 중에만 프레임 버퍼를 플립하도록 OS에 알릴 수 있습니다.

하지만 VSync가 비활성화될 경우, 렌더링이 끝나는 순간 백 버퍼가 프론트 버퍼로 플립되므로 디스플레이가 새로고침 주기 중에 갑자기 새 이미지의 데이터를 받기 시작하며 이로 인해 이전 프레임이 프레임 상단에 오고, 새 프레임이 프레임 하단에 오는 일이 발생합니다.

이러한 현상을 “티어링(tearing)”이라고 합니다. 티어링이 발생하면 프레임 하단을 위해 tdisplay를 줄여서 화질과 애니메이션의 자연스러움을 포기하는 대신 입력 지연을 개선할 수 있습니다. 이 방법은 게임의 프레임 속도가 VSync 간격보다 느릴 때 특히 효과적이며, 누락된 VSync로 인해 발생하는 지연 문제를 일부 해결할 수 있습니다. 또한, 화면 상단에 UI 또는 스카이박스가 배치된 게임에서는 티어링을 눈치채기가 더 어려워지므로 효과는 더욱 높아집니다.

VSync를 비활성화하여 입력 지연을 단축시킬 수 있는 또 다른 방법으로는 tinput을 늘리는 방법이 있습니다. 게임이 새로고침 속도보다 훨씬 빠른 프레임 속도로 렌더링을 할 수 있는 경우(예: 60Hz 디스플레이에서 150fps) VSync를 비활성화하면 게임에서는 각 새로고침 간격 중에 OS 이벤트를 여러 차례 펌프할 수 있게 되며 그 결과, OS 입력 대기열에 머무르는 평균 시간이 줄어듭니다.

VSync 비활성화는 화질에 영향을 미치고 티어링이 눈에 띄게 되면 멀미를 유발할 수 있기 때문에 결국 게임 플레이어가 결정할 수 있도록 해야 합니다. 플랫폼에서 지원한다면 게임 내에서 활성화/비활성화 설정 옵션을 제공하는 것이 베스트 프랙티스입니다.

결론

수정 사항을 모두 구현하면 Unity의 프레임 타임라인은 다음과 같아집니다.

이렇게 하면 실제로 오브젝트 움직임이 더 자연스러워집니다.

이 포스팅의 시작부에 보여드린 Unity 2020.1 데모를 Unity 2020.2.0b1에서 실행했습니다. 아래에서 결과를 슬로모션 비디오로 볼 수 있습니다.

 

이번 수정 사항은 다음 플랫폼 및 그래픽스 API용 2020.2 베타에서 제공됩니다.

  • Windows, Xbox One, 유니버설 Windows 플랫폼(D3D11 및 D3D12)
  • macOS, iOS, tvOS (Metal)
  • Playstation 4
  • Switch

가까운 시일 내에 다른 지원 플랫폼에도 구현할 예정입니다.

새로운 소식을 받으려면 이 포럼 스레드를 팔로우하시고 새로운 개선사항에 관한 의견을 공유해주세요.

프레임 타이밍과 관련된 추가 읽을거리

Unity 2020.2 베타 및 향후 계획

2020.2에서 제공하는 기능에 대해 자세히 알아보려면 Unity 2020.2 베타 버전 출시에 관한 블로그 포스팅을 참조하고 Unity 2020.2 베타 웨비나에 등록하시기 바랍니다. 최근에 소개한  2021년 Unity 로드맵도 참조해 보세요.

34 replies on “더욱 원활한 게임플레이를 위한 Unity 2020.2 Time.deltaTime 개선”

That was a really good write up of the problem. I got here because I was trying to write a timer script that simulated a time bomb beeping using Time.deltaTime and the results were inconsistent. I found recommendations to use Time.time to compare Time differences but the documentation says to avoid doing that. It might just be something wrong with my implementation but the article is still very informative.

The Timer beeps also increase in frequency from 1 sec > 0.5 sec > 0.25 sec so that might have something to do with it.

Why did you start with this code:

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

While **everywhere** – including Unity official documentation, you can read: you should **NOT** change / calculate physics in Update, but in FixedUpdate, using Time.fixedDeltaTime.

Why is that? How can we trust an article beginning with that? What opinion/best practice should we trust in Unity?

Thank you for addressing this issue! It’s remarkable how intricate deltaTime calculation can be. Great work by the team!

Speaking of deltaTime, when you have a game with VSync on, Application.targetFramerate is 60 and playing on a 144 Hz monitor, the game will run as if it was in fast-forward. Is there any chance there might be a “cappedDeltaTime” of sorts exposed to Animator, Timeline, ParticleSystem, Shaders etc. or a more global setting to set the FPS limit to Application.targetFramerate with VSync on?

Thanks for all the hard work. Thanks for taking the time to detail the issue and solution. Looking forward to the fix.

Good that it’s fixed, but with Unity 2020b5 I’ve run into bug – Time.deltaTime cannot be larger than 0.1f for some reason. So if my framerate lower than 10 fps (and I’m trying to create benchmark were such low framerates a common thing on low-end hardware) it’s not working (.

This improvement is really welcoming.
Unfortunately, I am one of people who spent good amount of time trying to figure it out why my time delta is so not consistent.
I am not angry that it took so long, what is weird is the fact that the issue was just recently acknowledged as problem with the engine.
I have seen many topis talking about it and the most common outcome was: “Your code is wrong, it’s not Unity’s fault”.
The main issue I see it took 10 years to acknowledge there is an issue in the first place.
How many things like that cost developers time because the only answer on internet they can find is: “It’s developers problem, not Unity issue”.
I hope we won’t see anything else like this going that path.

Nice work, sometimes it’s amazing how you can still find & fix issues like these that have been around for 10 years (and using ‘standard’ methods that have been around for ages). In my engine I found the same issue and tried fixed timesteps (assuming you’d have no dropframes, which is ofcourse not really realistic). Unfortunately my whole timing structure was based on integer milliseconds and doing 60Hz meant alternatively jumping 16 and 17 ms at times. :-) Similar issues will come up anyway by the 1ms difference, although subtle.

The depth of this article!! Thanks so much for breaking this down and for doing the work to make delta time smoother. Please more blog posts like this :)

This is very very nice!

The following 2 questions come to my mind:

1) At the beginning of the post its mentioned that several assumptions are made (Graphics jobs disabled, etc.),
does this means the fix only works under those settings or was it mentioned just to keep the same game loop structure across the examples?

2) Does the fix works regardless if we are using URP or Built-in Renderer?

What a nice, in-depth article. I for one really appreciate the time that went into both implementing these improvements and writing such a detailed report.

Nice work, now if you could just figure out how to break the rendering buffer up into layers so user input could jump to the next rendering frame set we could almost eradicate input latency or at least get it down to nearly next frame timings. e.g. Player shoots and a muzzle flash is displayed in the next frame set to render.

This is actually what the VR headset drivers do. They implement a technique called late latch. The VR headset orientation is updated to GPU memory after the GPU command buffer has been recorded. Just before the GPU renders that queued frame. This is similar to the technique used by the hardware mouse cursor.

This oculus blog post describes the technique in more detail:
https://developer.oculus.com/blog/optimizing-vr-graphics-with-late-latching/

This technique works well with hardware devices that you can poll at precise time intervals (modern mouse can be polled 1000 times per second). However the GPU can’t poll the CPU to give it an exact game state at this exact moment. It takes significant time for the CPU to calculate one frame. Thus we must start calculating it in advance. But there’s no guarantee that a full CPU frame is ready when the GPU would need to late update the data. It might be ready every other time and miss the deadline every other frame. This would cause massive juddering to the animation. The CPU and the GPU are running asynchronously for a reason (both can run at the same time without waiting). If we add wait for late latch data, we lose this asynchronous execution. It’s not an easy problem to solve in generic case. High frequency hardware sensors such as mouse and VR headset can be made to work acceptably well, but even in these cases, if you do sudden large movements, you might turn the camera too much and see missing objects on screen edges. Visibility culling algorithm generated draw calls 1-2 frames ago. Just updating the camera matrix isn’t enough.

Sad that it took you 10+ years to finally look into it. Imagine now many users suffered due to this over those years.
But hey! At least you have something to write about in the blog!

“What is delta time and why is it important?”
A great headline for a blog post from an engine team.
Quite well reflects how unity perceives it’s users.

Is this response really necessary?
Have you addressed every possible issue you are aware of in your own projects?
Did Unity work on any other features other than this issue?
Is it possible there were other priorities that superseded this work?
Have any successful games been shipped with Unity with the delta time issue?
Do you evaluate your own work with the same level of negativity?

When commenting ask yourself what your intended outcome is. If the answer is to ultimately to hurt someone, just don’t. Please.

Yes, this new approach hasn’t been in the engine for past 10 years till now. But fun fact: it hasn’t been in most competing game engines out there either, at least not in the ones you can get access to. What Unity has done in past has been pretty standard way of doing it. So you are essentially blaming Unity here for doing something in past that most engines have done and still do for the delta time measurement.

Now that they made it better, Unity actually has edge over competing engines in this regard, yet you manage only to leave a negative comment? I guess some people are never happy. I know I’m thrilled about this change :)

I’d be thrilled about this change if I hadn’t been using hacks to get around the deltatime issues for seven years now. The simple reality is that not addressing known issues in the engine for years, up to a decade in time, is the standard for Unity and it’s what’s caused a lot of people, myself included, to give up on the engine entirely.

Comments are closed.