O que você está procurando?
Engine & platform

Corrigindo Time.deltaTime no Unity 2020.2 para uma jogabilidade mais suave: O que foi preciso?

TAUTVYDAS ŽILYS / UNITY TECHNOLOGIESContributor
Oct 1, 2020|18 Min
Corrigindo Time.deltaTime no Unity 2020.2 para uma jogabilidade mais suave: O que foi preciso?
Esta página da Web foi automaticamente traduzida para sua conveniência. Não podemos garantir a precisão ou a confiabilidade do conteúdo traduzido. Se tiver dúvidas sobre a precisão do conteúdo traduzido, consulte a versão oficial em inglês da página da Web.

O Unity 2020.2 beta apresenta uma correção para um problema que afeta muitas plataformas de desenvolvimento: valores Time.deltaTime inconsistentes, que levam a movimentos bruscos e intermitentes. Leia esta postagem do blog para entender o que estava acontecendo e como a próxima versão do Unity ajuda você a criar uma jogabilidade um pouco mais suave.

Desde o início dos jogos, alcançar movimento independente da taxa de quadros em videogames significava levar em consideração o tempo delta do quadro:

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

Isso atinge o efeito desejado de um objeto se movendo a uma velocidade média constante, independentemente da taxa de quadros em que o jogo está sendo executado. Em teoria, ele também deve mover o objeto em um ritmo constante se sua taxa de quadros for estável. Na prática, o quadro é bem diferente. Se você olhasse os valores reais de Time.deltaTime relatados, você poderia ter visto isto:

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

Este é um problema que afeta muitos mecanismos de jogo, incluindo o Unity – e somos gratos aos nossos usuários por nos alertarem sobre isso. Felizmente, o Unity 2020.2 beta começa a resolver esse problema.

Então por que isso acontece? Por que, quando a taxa de quadros é bloqueada em 144 fps constantes, Time.deltaTime não é igual a 1⁄144 segundos (~6,94 ms) todas as vezes? Nesta postagem do blog, levarei você em uma jornada de investigação e, finalmente, correção desse fenômeno.

O que é tempo delta e por que ele é importante?

Em termos leigos, o tempo delta é a quantidade de tempo que seu último quadro levou para ser concluído. Parece simples, mas não é tão intuitivo quanto você imagina. Na maioria dos livros de desenvolvimento de jogos, você encontrará esta definição canônica de um loop de jogo:

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

Com um loop de jogo como este, é fácil calcular o tempo delta:

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

Embora esse modelo seja simples e fácil de entender, ele é altamente inadequado para mecanismos de jogos modernos. Para atingir alto desempenho, os motores hoje em dia usam uma técnica chamada “pipelining”, que permite que um motor trabalhe em mais de um quadro ao mesmo tempo.

Compare isto:

Quadro

Para isso:

Quadro

Em ambos os casos, partes individuais do loop do jogo levam o mesmo tempo, mas o segundo caso as executa em paralelo, o que permite enviar mais que o dobro de quadros no mesmo período de tempo. O pipeline do mecanismo altera o tempo do quadro de igual à soma de todos os estágios do pipeline para igual ao mais longo.

Entretanto, mesmo isso é uma simplificação do que realmente acontece em cada quadro no mecanismo:

  • Cada estágio do pipeline leva um tempo diferente em cada quadro. Talvez este quadro tenha mais objetos na tela do que o anterior, o que tornaria a renderização mais demorada. Ou talvez o jogador tenha rolado o rosto no teclado, o que fez com que o processamento de entrada demorasse mais.
  • Como diferentes estágios do pipeline levam diferentes períodos de tempo, precisamos interromper artificialmente os mais rápidos para que eles não avancem muito. Mais comumente, isso é implementado esperando até que algum quadro anterior seja invertido para o buffer frontal (também conhecido como buffer de tela). Se o VSync estiver habilitado, isso também será sincronizado com o início do período VBLANK do monitor. Falarei mais sobre isso mais tarde.

Com esse conhecimento em mente, vamos dar uma olhada em um cronograma de quadros típico no Unity 2020.1. Como a seleção da plataforma e várias configurações afetam significativamente isso, este artigo assumirá um player autônomo do Windows com renderização multithread habilitada, trabalhos gráficos desabilitados, vsync habilitado e QualitySettings.maxQueuedFrames definido como 2 em execução em um monitor de 144 Hz sem perda de quadros. Clique na imagem para vê-la em tamanho real:

Quadro

O pipeline de quadros do Unity não foi implementado do zero. Em vez disso, ele evoluiu na última década para se tornar o que é hoje. Se você voltar para versões anteriores do Unity, verá que ele muda a cada poucos lançamentos.

Você pode notar imediatamente algumas coisas sobre isso:

  • Depois que todo o trabalho é enviado para a GPU, o Unity não espera que o quadro seja virado para a tela: em vez disso, ele espera o anterior. Isso é controlado pela API QualitySettings.maxQueuedFrames. Esta configuração descreve o quão longe o quadro que está sendo exibido pode ficar do quadro que está sendo renderizado no momento. O valor mínimo possível é 1, já que o melhor que você pode fazer é renderizar framen+1 quando framen estiver sendo exibido na tela. Como está definido como 2 neste caso (que é o padrão), o Unity garante que o framen seja exibido na tela antes de começar a renderizar o framen+2 (por exemplo, antes do Unity começar a renderizar o frame5, ele espera que o frame3 apareça na tela).
  • O Frame5 demora mais para ser renderizado na GPU do que um único intervalo de atualização do monitor (7,22 ms vs 6,94 ms); no entanto, nenhum dos quadros é descartado. Isso acontece porque QualitySettings.maxQueuedFrames com o valor 2 atrasa o aparecimento do quadro real na tela, o que produz um buffer no tempo que protege contra a queda de quadros, desde que o "pico" não se torne a norma. Se fosse definido como 1, o Unity certamente teria descartado o quadro, pois ele não sobreporia mais o trabalho.

Embora a atualização da tela ocorra a cada 6,94 ms, a amostragem de tempo do Unity apresenta uma imagem diferente:

Matemática

A média do tempo delta neste caso ((7,27 + 6,64 + 7,03)/3 = 6,98 ms) é muito próxima da taxa de atualização real do monitor (6,94 ms) e, se você medisse isso por um período de tempo mais longo, a média acabaria sendo exatamente 6,94 ms. Infelizmente, se você usar esse tempo delta como ele é para calcular o movimento do objeto visível, você introduzirá uma trepidação muito sutil. Para ilustrar isso, criei um projeto simples no Unity. Ele contém três quadrados verdes que se movem pelo espaço do mundo:

A câmera é acoplada ao cubo superior, então ela aparece perfeitamente estática na tela. Se Time.deltaTime for preciso, os cubos do meio e de baixo também parecerão estar parados. Os cubos se movem duas vezes na largura da tela a cada segundo: quanto maior a velocidade, mais visível a trepidação se torna. Para ilustrar o movimento, coloquei cubos roxos e rosas imóveis em posições fixas no fundo para que você possa saber o quão rápido os cubos estão realmente se movendo.

No Unity 2020.1, os cubos do meio e de baixo não correspondem exatamente ao movimento do cubo superior – eles tremem um pouco. Abaixo está um vídeo capturado com uma câmera em câmera lenta (reduzida em 20x):

Identificando a fonte da variação do tempo delta

Então de onde vêm essas inconsistências de tempo delta? O visor mostra cada quadro por um período fixo de tempo, mudando a imagem a cada 6,94 ms. Este é o tempo delta real porque é o tempo que leva para um quadro aparecer na tela e é a quantidade de tempo que o jogador do seu jogo observará cada quadro.

Cada intervalo de 6,94 ms consiste em duas partes: processamento e suspensão. O cronograma do quadro de exemplo mostra que o tempo delta é calculado no thread principal, então ele será nosso foco principal. A parte de processamento do thread principal consiste em enviar mensagens do sistema operacional, processar entradas, chamar Update e emitir comandos de renderização. “Aguarde o thread de renderização” é a parte adormecida . A soma desses dois intervalos é igual ao tempo real do quadro:

Matemática

Ambos os tempos flutuam por vários motivos a cada quadro, mas sua soma permanece constante. Se o tempo de processamento aumentar, o tempo de espera diminuirá e vice-versa, então eles sempre serão exatamente iguais a 6,94 ms. Na verdade, a soma de todas as partes que levam à espera sempre é igual a 6,94 ms:

Matemática

Entretanto, o Unity consulta o tempo no início da atualização. Por isso, qualquer variação no tempo necessário para emitir comandos de renderização, enviar mensagens do sistema operacional ou processar eventos de entrada prejudicará o resultado.

Um loop de thread principal simplificado do Unity pode ser definido assim:

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

A solução para esse problema parece ser simples: basta mover a amostragem de tempo para depois da espera, para que o loop do jogo se torne isto:

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

No entanto, essa alteração não funciona corretamente: a renderização tem leituras de tempo diferentes de Update(), o que tem efeitos adversos em todos os tipos de coisas. Uma opção é salvar o tempo amostrado neste ponto e atualizar o tempo do mecanismo somente no início do próximo quadro. No entanto, isso significaria que o mecanismo estaria usando o tempo anterior à renderização do quadro mais recente.

Como mover SampleTime() para depois de Update() não é eficaz, talvez mover a espera para o início do quadro seja mais bem-sucedido:

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

Infelizmente, isso causa outro problema: agora o thread de renderização deve terminar a renderização quase tão logo seja solicitado, o que significa que o thread de renderização se beneficiará apenas minimamente de trabalhar em paralelo.

Vamos dar uma olhada novamente na linha do tempo do quadro:

Quadro

O Unity aplica a sincronização do pipeline aguardando o thread de renderização a cada quadro. Isso é necessário para que o thread principal não fique muito à frente do que está sendo exibido na tela. O thread de renderização é considerado “concluído” quando termina a renderização e aguarda o aparecimento de um quadro na tela. Em outras palavras, ele espera que o buffer traseiro seja invertido e se torne o buffer frontal. No entanto, o thread de renderização não se importa realmente com quando o quadro anterior foi exibido na tela – apenas o thread principal se preocupa com isso, porque ele precisa se autolimitar. Então, em vez de fazer com que o thread de renderização espere o quadro aparecer na tela, essa espera pode ser movida para o thread principal. Vamos chamá-lo de WaitForLastPresentation(). O loop do thread principal se torna:

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

O tempo agora é amostrado logo após a parte de espera do loop, então o tempo será alinhado com a taxa de atualização do monitor. O tempo também é amostrado no início do quadro, então Update() e Render() veem os mesmos tempos.

É muito importante notar que WaitForLastPresention() não espera que o quadro - 1 apareça na tela. Se fosse esse o caso, nenhum pipeline seria feito. Em vez disso, ele espera que framen - QualitySettings.maxQueuedFrames apareça na tela, o que permite que o thread principal continue sem esperar a conclusão do último quadro (a menos que maxQueuedFrames esteja definido como 1, caso em que cada quadro deve ser concluído antes que um novo comece).

Alcançando estabilidade: Precisamos ir mais fundo!

Após implementar essa solução, o tempo delta se tornou muito mais estável do que antes, mas ainda ocorreram algumas oscilações e variações ocasionais. Dependemos do sistema operacional para despertar o mecanismo do modo de espera no horário certo. Isso pode levar vários microssegundos e, portanto, introduzir instabilidade no tempo delta, especialmente em plataformas de desktop onde vários programas estão sendo executados ao mesmo tempo.

Para melhorar o tempo, você pode usar o registro de data e hora exato de um quadro apresentado na tela (ou um buffer fora da tela), que a maioria das APIs/plataformas gráficas permite que você extraia. Por exemplo, o Direct3D 11 e 12 têm IDXGISwapChain::GetFrameStatistics, enquanto o macOS fornece CVDisplayLink. No entanto, há algumas desvantagens nessa abordagem:

  • Você precisa escrever um código de extração separado para cada API gráfica suportada, o que significa que o código de medição de tempo agora é específico da plataforma e cada plataforma tem sua própria implementação separada. Como cada plataforma se comporta de forma diferente, uma mudança como essa corre o risco de consequências catastróficas.
  • Com algumas APIs gráficas, para obter esse registro de data e hora, o VSync deve estar habilitado. Isso significa que se o VSync estiver desabilitado, o tempo ainda deverá ser calculado manualmente.

No entanto, acredito que essa abordagem vale o risco e o esforço. O resultado obtido usando esse método é muito confiável e produz temporizações que correspondem diretamente ao que é visto no visor.

Como não precisamos mais amostrar o tempo nós mesmos, as etapas WaitForLastPresention() e SampleTime() são combinadas em uma nova etapa:

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

Com isso, o problema do movimento instável é resolvido.

Considerações sobre latência de entrada

Latência de entrada é um assunto complicado. Não é muito fácil medir com precisão e pode ser introduzido por vários fatores diferentes: hardware de entrada, sistema operacional, drivers, mecanismo de jogo, lógica do jogo e a tela. Aqui, concentro-me no fator do mecanismo de jogo da latência de entrada, já que o Unity não pode afetar os outros fatores.

A latência de entrada do mecanismo é o tempo entre a mensagem de entrada do sistema operacional ficar disponível e a imagem ser enviada para o monitor. Dado o loop do thread principal, você pode visualizar a latência de entrada como parte do código (supondo que QualitySettings.maxQueuedFrames esteja definido como 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.

Ufa, é isso! Muitas coisas acontecem entre a entrada estar disponível como uma mensagem do sistema operacional e seus resultados serem visíveis na tela. Se o Unity não estiver perdendo quadros e o tempo gasto pelo loop do jogo for principalmente de espera em comparação ao processamento, o pior cenário de latência de entrada do mecanismo para uma taxa de atualização de 144 Hz é 4 * 6,94 = 27,76 ms, porque estamos esperando que os quadros anteriores apareçam na tela quatro vezes (o que significa quatro intervalos de taxa de atualização).

Você pode melhorar a latência bombeando eventos do sistema operacional e atualizando a entrada depois de esperar que o quadro anterior seja exibido:

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

Isso elimina uma espera da equação, e agora a pior latência de entrada é 3 * 6,94 = 20,82 ms.

É possível reduzir ainda mais a latência de entrada reduzindo QualitySettings.maxQueuedFrames para 1 em plataformas que o suportam. Então, a cadeia de processamento de entrada se parece com isto:

--------------------- // 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.

Agora, a pior latência de entrada é 2 * 6,94 = 13,88 ms. Este é o nível mais baixo que podemos atingir ao usar o VSync.

Aviso: Definir QualitySettings.maxQueuedFrames como 1 essencialmente desabilitará o pipelining no mecanismo, o que tornará muito mais difícil atingir sua taxa de quadros desejada. Tenha em mente que se você acabar executando em uma taxa de quadros menor, sua latência de entrada provavelmente será pior do que se você mantivesse QualitySettings.maxQueuedFrames em 2. Por exemplo, se isso fizer com que você caia para 72 quadros por segundo, sua latência de entrada será 2 * 1⁄72 = 27,8 ms, o que é pior do que a latência anterior de 20,82 ms. Se você quiser usar essa configuração, sugerimos adicioná-la como uma opção ao menu de configurações do jogo para que jogadores com hardware rápido possam reduzir QualitySettings.maxQueuedFrames, enquanto jogadores com hardware mais lento podem manter a configuração padrão.

Efeitos do VSync na latência de entrada

Desabilitar o VSync também pode ajudar a reduzir a latência de entrada em determinadas situações. Lembre-se de que a latência de entrada é a quantidade de tempo que passa entre uma entrada se tornar disponível no sistema operacional e o quadro que processou a entrada ser exibido na tela ou, como uma equação matemática:

latência = tdisplay - tinput

Dada essa equação, há duas maneiras de reduzir a latência de entrada: tornar tdisplay mais baixo (exibir a imagem mais cedo) ou tornar tinput mais alto (consultar eventos de entrada mais tarde).

O envio de dados de imagem da GPU para o monitor consome muitos dados. Basta fazer as contas: para enviar uma imagem não HDR de 2560x1440 para o monitor 144 vezes por segundo, é necessário transmitir 12,7 gigabits por segundo (24 bits por pixel * 2560 * 1440 * 144). Esses dados não podem ser transmitidos em um instante: a GPU está constantemente transmitindo pixels para a tela. Após cada quadro ser transmitido, há uma breve pausa e a transmissão do próximo quadro começa. Este período de interrupção é chamado de VBLANK. Quando o VSync está habilitado, você está basicamente dizendo ao sistema operacional para inverter o buffer de quadros somente durante o VBLANK:

Quadro

Quando você desativa o VSync, o buffer traseiro é invertido para o buffer frontal no momento em que a renderização é concluída, o que significa que o monitor começará a receber dados da nova imagem no meio do seu ciclo de atualização, fazendo com que a parte superior do quadro seja do quadro mais antigo e a parte inferior do quadro seja do quadro mais novo:

Quadro

Esse fenômeno é conhecido como “rasgo”. O tearing nos permite reduzir a exibição na parte inferior do quadro, sacrificando a qualidade visual e a suavidade da animação pela latência de entrada. Isso é especialmente eficaz quando a taxa de quadros do jogo é menor que o intervalo VSync, o que permite uma recuperação parcial da latência causada por um VSync perdido. Também é mais eficaz em jogos onde a parte superior da tela é ocupada pela interface do usuário ou por um skybox, o que torna mais difícil perceber o tearing.

Outra maneira de desabilitar o VSync pode ajudar a reduzir a latência de entrada é aumentando o tinput. Se o jogo for capaz de renderizar a uma taxa de quadros muito maior do que a taxa de atualização (por exemplo, a 150 fps em uma tela de 60 Hz), desabilitar o VSync fará com que o jogo execute eventos do sistema operacional várias vezes durante cada intervalo de atualização, o que reduzirá o tempo médio em que eles ficam na fila de entrada do sistema operacional aguardando que o mecanismo os processe.

Tenha em mente que desabilitar o VSync deve ficar a critério do jogador, pois isso afeta a qualidade visual e pode causar náuseas se o tearing for perceptível. É uma prática recomendada fornecer uma opção de configuração no seu jogo para habilitar/desabilitar, caso seja compatível com a plataforma.

Conclusão

Com essa correção implementada, a linha do tempo do quadro do Unity fica assim:

Quadro

Mas isso realmente melhora a suavidade do movimento dos objetos? Pode apostar que sim!

Executamos a demonstração do Unity 2020.1 que mostramos no início deste post no Unity 2020.2.0b1. Aqui está o vídeo em câmera lenta resultante:

Esta correção está disponível na versão beta 2020.2 para estas plataformas e APIs gráficas:

  • Windows, Xbox One, Plataforma Universal do Windows (D3D11 e D3D12)
  • macOS, iOS, tvOS (Metal)
  • Playstation 4
  • Switch

Planejamos implementar isso para o restante das plataformas suportadas em um futuro próximo.

Acompanhe este tópico do fórum para atualizações e diga-nos o que você acha do nosso trabalho até agora.

Leitura adicional sobre tempo de quadro
Unity 2020.2 beta e além
Visão geral do beta

Se você estiver interessado em saber mais sobre o que está disponível na versão 2020.2, confira a postagem do blog sobre a versão beta e registre-se no webinar da versão beta do Unity 2020.2. Também compartilhamos recentemente nossos planos de roteiro para 2021.