Imagine o problema: você tem um jogo e precisa trabalhar a 60 qps em um monitor de 60 Hz. Seu computador é rápido o suficiente para renderizar e atualizar para ocupar uma quantidade insignificante de tempo, então você ativa o vsync e escreve este loop do jogo:
while(running) { update(); render(); display(); }
Muito fácil! Agora o jogo funciona a 60fps e tudo corre como um relógio. Feito. Obrigado por ler este post.
Bem, obviamente, nem tudo é tão bom. E se alguém tiver um computador fraco que não consiga processar o jogo a uma velocidade suficiente para fornecer 60fps? E se alguém comprou um desses novos e legais monitores de 144 hertz? E se ele desativasse o vsync nas configurações do driver?
Você pode pensar: preciso medir o tempo em algum lugar e fornecer uma atualização com a frequência correta. Isso é bastante simples - apenas acumule tempo em cada ciclo e atualize sempre que exceder o limite em 1/60 de segundo.
while(running) { deltaTime = CurrentTime()-OldTime; oldTime = CurrentTime(); accumulator += deltaTime; while(accumulator > 1.0/60.0){ update(); accumulator -= 1.0/60.0; } render(); display(); }
Feito, em nenhum lugar mais fácil. De fato, existem vários jogos nos quais o código parece essencialmente dessa maneira. Mas isso está errado. Isso é adequado para ajustar horários, mas leva a problemas com movimentos bruscos (gagueira) e outras incompatibilidades. Esse problema é muito comum: os quadros não são exibidos exatamente 1/60 de segundo; mesmo quando o vsync está ativado, sempre há um pouco de ruído no momento em que são exibidos (e na precisão do temporizador do SO). Portanto, haverá situações em que você renderiza um quadro, e o jogo acredita que o tempo para a atualização ainda não chegou (porque a bateria está ficando uma fração minúscula), por isso apenas repete o mesmo quadro novamente, mas agora o jogo está atrasado para o quadro, dobrando atualização. Aqui está a contração!
No Google, você pode encontrar várias soluções prontas para eliminar essa contração. Por exemplo, um jogo pode usar uma variável em vez de uma etapa constante e simplesmente abandonar completamente as baterias no código de temporização. Ou você pode implementar uma etapa de tempo constante com um renderizador interpolador, descrito em um artigo bastante famoso "
Fix Your Timestep ", de Glenn Fielder. Ou você pode refazer o código do timer para que ele fique um pouco mais flexível, conforme descrito na publicação
Frame Timing Issues do Slick Entertainment (infelizmente este blog não está mais lá).
Tempos difusos
O método Slick Entertainment com "tempos nebulosos" no meu mecanismo foi o mais fácil de implementar, porque não exigia alterações na lógica e na renderização do jogo. Então, em
The End is Nigh, eu o usei. Bastava inseri-lo no motor. De fato, ele simplesmente permite que o jogo seja atualizado "um pouco mais cedo" para evitar problemas com diferenças de tempo. Se o jogo incluir vsync, ele permitirá que você use o vsync como o cronômetro principal do jogo e forneça uma imagem suave.
É assim que o código de atualização fica agora (o jogo "pode funcionar" a 62 qps, mas ainda é processado a cada passo como se funcionasse a 60 qps. Não entendo por que limitá-lo para que os valores da bateria não caiam abaixo de 0, mas sem este código não funciona). Você pode interpretar da seguinte maneira: "o jogo é atualizado com uma etapa fixa, se renderizado no intervalo de 60fps a 62fps":
while(accumulator > 1.0/62.0){ update(); accumulator -= 1.0/60.0; if(accumulator < 0) accumulator = 0; }
Se o vsync estiver ativado, ele essencialmente permitirá que o jogo funcione com um tom fixo, que corresponde à taxa de atualização do monitor e fornece uma imagem suave. O principal problema aqui é que, quando o vsync está desativado, o jogo funciona um
pouco mais rápido, mas a diferença é tão insignificante que ninguém notará.
Corredores de velocidade. Os Speedrunners perceberão. Logo após o lançamento do jogo, eles notaram que algumas pessoas nas listas de recordes de speedran tinham tempos de viagem mais baixos, mas acabou sendo melhor que outros. E a razão imediata para isso foi o tempo pouco claro e a desconexão do vsync no jogo (ou monitores de 144 Hz). Portanto, ficou óbvio que você precisa desativar essa imprecisão ao desconectar o vsync.
Ah, mas ainda não podemos verificar se o vsync está desativado. Não há chamadas para isso no sistema operacional e, embora possamos solicitar ao aplicativo para ativar ou desativar o vsync, na verdade, ele é completamente dependente do sistema operacional e do driver gráfico. A única coisa que pode ser feita é renderizar vários quadros, tentar medir o tempo de execução dessa tarefa e comparar se eles levam o mesmo tempo. Foi exatamente o que fiz para
The End is Nigh . Se o jogo não incluir vsync com uma frequência de 60 Hz, ele voltará ao temporizador de quadros original com "60 fps estritos". Além disso, adicionei um parâmetro ao arquivo de configuração que força o jogo a não usar imprecisão (principalmente para corredores de velocidade que precisam de tempo preciso) e adicionei um manipulador de timer exato no jogo para eles, o que permite o uso do auto-splitter (este é um script que funciona com um timer de tempo atômico).
Alguns usuários ainda se queixavam dos movimentos ocasionais de quadros individuais, mas pareciam tão raros que podiam ser explicados por eventos do SO ou por outros motivos externos. Não é grande coisa. Certo?
Observando meu código de timer recentemente, notei algo estranho. A bateria foi deslocada, cada quadro demorou um pouco mais de 1/60 segundo, então, de tempos em tempos, o jogo pensava que era tarde para o quadro e realizava uma atualização dupla. Aconteceu que meu monitor funciona com uma frequência de 59,94 Hz, e não 60 Hz. Isso significava que a cada 1000 quadros, ele precisava realizar uma atualização dupla para "recuperar o atraso". No entanto, isso é muito simples de corrigir - basta alterar o intervalo de frequências de quadros permitidas (não de 60 para 62, mas de 59 para 61).
while(accumulator > 1.0/61.0){ update(); accumulator -= 1.0/59.0; if(accumulator < 0) accumulator = 0; }
O problema descrito acima com o vsync desconectado e os monitores de alta frequência ainda persiste, e a mesma solução se aplica a ele (reversão para timer estrito se o monitor
não estiver sincronizado com o vsync em 60).
Mas como você sabe se esta é a solução certa? Como garantir que funcione corretamente em todas as combinações de computadores com diferentes tipos de monitores, com e sem o vsync ativado e assim por diante? É muito difícil acompanhar todos esses problemas de timer na cabeça e entender o que causa a dessincronização, loops estranhos e similares.
Simulador de monitor
Tentando encontrar uma solução confiável para o "problema do monitor de 59,94 hertz", percebi que não podia apenas executar verificações de tentativa e erro, na esperança de encontrar uma solução confiável. Eu precisava de uma maneira conveniente de testar diferentes tentativas de escrever um cronômetro de alta qualidade e de uma maneira fácil de verificar se isso causa uma sacudida ou uma mudança de horário em diferentes configurações de monitores.
O Monitor Simulator aparece em cena. Este é o código "sujo e rápido" que escrevi, simulando a "operação do monitor" e essencialmente mostrando-me vários números que dão uma idéia da estabilidade de cada timer testado.
Por exemplo, para o cronômetro mais simples, os seguintes valores são exibidos desde o início do artigo:
20211012021011202111020211102012012102012[...]
TOTAL UPDATES: 10001
TOTAL VSYNCS: 10002
TOTAL DOUBLE UPDATES: 2535
TOTAL SKIPPED RENDERS: 0
GAME TIME: 166.683
SYSTEM TIME: 166.7
Primeiro, o código exibe para cada vsync emulado o número do número de "atualizações" no ciclo do jogo após o vsync anterior. Quaisquer valores diferentes de 1 sólido levam a uma imagem tremida. No final, o código exibe as estatísticas acumuladas.
Ao usar o "temporizador difuso" (com um intervalo de 60 a 62 fps) em um monitor 59,94-Hertz, o código exibe o seguinte:
111111111111111111111111111111111111111111111[...]
TOTAL UPDATES: 10000
TOTAL VSYNCS: 9991
TOTAL DOUBLE UPDATES: 10
TOTAL SKIPPED RENDERS: 0
GAME TIME: 166.667
SYSTEM TIME: 166.683
O movimento do quadro é muito raro, portanto pode ser difícil perceber com um número igual a 1. Mas as estatísticas exibidas mostram claramente que o jogo realizou várias atualizações duplas aqui, o que leva ao movimento. Na versão fixa (com um intervalo de 59–61 fps), há 0 atualizações ignoradas ou duplas.
Você também pode desativar o vsync. O restante dos dados estatísticos se torna sem importância, mas isso me mostra claramente a magnitude da “mudança de horário” (a mudança de horário do sistema em relação a onde deveria estar o horário do jogo).
GAME TIME: 166.667
SYSTEM TIME: 169.102
É por isso que, quando o vsync está desativado, você precisa mudar para um cronômetro rígido, caso contrário, essas discrepâncias se acumulam com o tempo.
Se eu definir o tempo de renderização para .02 (ou seja, "é necessário mais do que um quadro" para renderizar), então terei uma contração. Idealmente, o padrão do jogo deve parecer 202020202020, mas é um pouco desigual.
Nessa situação, esse cronômetro se comporta um pouco melhor que o anterior, mas fica mais confuso e mais difícil descobrir como e por que ele funciona. Mas eu posso colocar os testes neste simulador e verificar como eles se comportam, e você pode descobrir os motivos mais tarde. Tentativa e erro, bebê!
while(accumulator >= 1.0/61.0){ simulate_update(); accumulator -= 1.0/60.0; if(accumulator < 1.0/59.0–1.0/60.0) accumulator = 0; }
Você pode baixar
um simulador de monitor e verificar independentemente diferentes métodos de cálculo de tempo.
Envie-me um e-mail se encontrar algo melhor.
Não estou 100% satisfeito com minha decisão (ainda é necessário um hack com "reconhecimento vsync" e podem ocorrer ocasional espasmos durante a dessincronização), mas acredito que seja quase tão bom quanto uma tentativa de implementar um ciclo de jogo com uma etapa fixa. Parte desse problema surge porque é muito difícil determinar os parâmetros do que é considerado "aceitável" aqui. A principal dificuldade está na troca entre mudança de horário e quadros duplos / ignorados. Se você executar um jogo de 60 Hz em um monitor PAL de 50 Hz ... qual será a decisão certa? Você quer empurrões selvagens ou jogabilidade visivelmente mais lenta? Ambas as opções parecem ruins.
Renderização separada
Nos métodos anteriores, descrevi o que eu chamo de "renderização de lockstep". O jogo atualiza seu estado, depois é renderizado e, ao renderizar, sempre exibe o estado mais recente do jogo. Renderização e atualização são conectadas juntas.
Mas você pode separá-los. É exatamente isso que o método descrito na postagem "
Fix Your Timestep "
faz . Não vou me repetir, você definitivamente deveria ler este post. Este (como eu o entendo) é o "padrão da indústria" usado em jogos e mecanismos AAA como Unity e Unreal (no entanto, em jogos ativos 2D intensos, eles geralmente preferem usar uma etapa fixa (passo em falso), porque às vezes a precisão que oferece a você este método).
Mas se descrevermos brevemente o post de Glenn, ele simplesmente descreverá o método de atualização com uma taxa de quadros fixa, mas ao renderizar, a interpolação é realizada entre o estado "atual" e o "anterior" do jogo, e o valor atual da bateria é usado como o valor de interpolação. Com esse método, você pode renderizar a qualquer taxa de quadros e atualizar o jogo a qualquer frequência, e a imagem será sempre suave. Sem empurrões, funciona universalmente.
while(running){ computeDeltaTimeSomehow(); accumulator += deltaTime; while(accumulator >= 1.0/60.0){ previous_state = current_state; current_state = update(); accumulator -= 1.0/60.0; } render_interpolated_somehow(previous_state, current_state, accumulator/(1.0/60.0)); display(); }
Então, elementar. O problema está resolvido.
Agora você só precisa ter certeza de que o jogo pode renderizar os estados interpolados ... mas espere um minuto, na verdade não é nada fácil. No post de Glenn, supõe-se simplesmente que isso pode ser feito. É fácil o suficiente para armazenar em cache a posição anterior do objeto do jogo e interpolar seus movimentos, mas o estado do jogo é muito mais que isso. É necessário levar em conta o estado da animação, a criação e destruição de objetos e um monte de coisas.
Além disso, na lógica do jogo, você precisa considerar se o objeto é teleportado ou se precisa ser movido sem problemas para que o interpolador não faça suposições falsas sobre o caminho feito pelo objeto de jogo para sua posição atual. O caos real pode ocorrer com as curvas, especialmente se em um quadro a curva de um objeto puder mudar em mais de 180 graus. E como processar corretamente objetos criados e destruídos?
No momento, estou apenas trabalhando nessa tarefa no meu mecanismo. Na verdade, eu apenas interpolo os movimentos e deixo tudo o que é. Você não notará espasmos se o objeto não se mover suavemente, portanto, pular os quadros de animação e sincronizar a criação / destruição do objeto em um quadro não se tornará um problema se todo o resto for executado sem problemas.
No entanto, é estranho que, de fato, esse método processe o jogo em um estado atrasado em 1 estado do jogo, de onde a simulação está agora localizada. Isso é discreto, mas pode ser conectado a outras fontes de atrasos, por exemplo, atrasos de entrada e taxas de atualização de monitores, para aqueles que precisam de uma jogabilidade mais responsiva (estou falando de você, corredores de velocidade) provavelmente preferem usar o locktep no jogo.
No meu motor, eu apenas dou uma escolha. Se você possui um monitor de 60 hertz e um computador rápido, é melhor usar o lockstep com o vsync ativado. Se o monitor tiver uma taxa de atualização não padrão ou o computador fraco não conseguir renderizar constantemente 60 quadros por segundo, ative a interpolação de quadros. Quero chamar essa opção de "desbloquear taxa de quadros", mas as pessoas podem pensar que isso significa simplesmente "ativar esta opção se você tiver um bom computador". No entanto, esse problema pode ser resolvido mais tarde.
Na verdade, existe
um método para contornar esse problema.
Atualizações de etapa de tempo variável
Muitas pessoas me perguntaram por que não apenas atualizar o jogo com um intervalo de tempo variável, e os programadores teóricos costumam dizer: "se o jogo foi escrito CORRETAMENTE, você pode simplesmente atualizá-lo com um intervalo de tempo arbitrário".
while(running) { deltaTime = CurrentTime()-OldTime; oldTime = CurrentTime(); update(deltaTime); render(); display(); }
Sem esquisitices com horários. Nenhuma renderização de interpolação estranha. Tudo é simples, tudo funciona.
Então, elementar. O problema está resolvido. E agora para sempre! É impossível alcançar um resultado melhor!
Agora, é simples o suficiente para fazer a lógica do jogo funcionar com uma etapa de tempo arbitrária. É simples, basta substituir todo esse código:
position += speed;
sobre isso:
position += speed * deltaTime;
e substitua o seguinte código:
speed += acceleration; position += speed;
sobre isso:
speed += acceleration * deltaTime; position += speed * deltaTime;
e substitua o seguinte código:
speed += acceleration; speed *= friction; position += speed;
sobre isso:
Vec3D p0 = position; Vec3D v0 = velocity; Vec3D a = acceleration*(1.0/60.0); double f = friction; double n = dt*60; double fN = pow(friction, n); position = p0 + ((f*(a*(f*fN-f*(n+1)+n)+(f-1)*v0*(fN-1)))/((f-1)*(f-1)))*(1.0/60.0); velocity = v0*fN+a*(f*(fN-1)/(f-1));
... então espere
De onde veio tudo isso?
A última parte é literalmente copiada do código auxiliar do meu motor, que executa "um movimento realmente correto, independente da taxa de quadros com velocidade que limita o atrito". Há um pouco de lixo nele (essas multiplicações e divisões por 60). Mas esta é a versão “correta” do código com uma etapa de tempo variável para o fragmento anterior. Eu descobri isso por mais de uma hora com o
Wolfram Alpha .
Agora eles podem me perguntar por que não fazer assim:
speed += acceleration * deltaTime; speed *= pow(friction, deltaTime); position += speed * deltaTime;
E, embora pareça funcionar, é realmente errado fazê-lo. Você pode verificar você mesmo. Execute duas atualizações com deltaTime = 1 e, em seguida, execute uma atualização com deltaTime = 2, e os resultados serão diferentes. Normalmente, nos esforçamos para que o jogo funcione em conjunto, para que tais discrepâncias não sejam bem-vindas. Provavelmente, essa é uma solução boa o suficiente, se você tiver certeza de que deltaTime é sempre aproximadamente igual a um valor, mas precisará escrever um código para garantir que as atualizações sejam executadas com uma frequência constante e ... sim. Está certo, agora estamos tentando fazer tudo "CORRETAMENTE".
Se um pedaço tão pequeno de código se desdobra em cálculos matemáticos monstruosos, imagine padrões de movimento mais complexos nos quais muitos objetos em interação participam, e assim por diante. Agora você pode ver claramente que a solução "certa" é irrealizável. O máximo que podemos alcançar é uma "aproximação aproximada". Vamos esquecer isso por enquanto e supor que realmente tenhamos uma versão "realmente correta" das funções de movimento. Ótimo, certo?
Na verdade não. Aqui está um exemplo real do problema que tive com isso no
Bombernauts . Um jogador pode pular cerca de 1 peça e o jogo ocorre em uma grade de blocos em 1 peça. Para pousar em um bloco, as pernas do personagem devem subir acima da superfície superior do bloco.
Mas como o reconhecimento de colisões aqui é realizado com uma etapa discreta, se o jogo funcionar com uma taxa de quadros baixa, às vezes as pernas não alcançarão a superfície do ladrilho, embora tenham seguido a mesma curva de movimento e, em vez de levantar, o jogador escorregará da parede.
Obviamente, esse problema é solucionável. Mas ilustra os tipos de problemas que encontramos ao tentar implementar corretamente o trabalho do ciclo do jogo com uma etapa de tempo variável. Perdemos coerência e determinismo, por isso teremos de nos livrar das funções de reprodução do jogo gravando as informações do jogador, o multiplayer determinístico e similares. Para jogos 2D rápidos baseados em reflexos, a consistência é extremamente importante (e olá novamente para acelerar os corredores).
Se você tentar ajustar as etapas de tempo para que não sejam muito grandes nem muito pequenas, você perderá a principal vantagem obtida com a etapa de tempo variável e poderá usar com segurança os outros dois métodos descritos aqui. O jogo não vale a pena. Muito esforço extra será colocado na lógica do jogo (implementando a matemática correta do movimento), e muitas vítimas serão necessárias na área de determinismo e consistência. Eu usaria esse método apenas para um jogo de ritmo musical (no qual as equações de movimento são simples e exigem máxima capacidade de resposta e suavidade). Em todos os outros casos, escolherei uma atualização fixa.
Conclusão
Agora você sabe como fazer o jogo funcionar a uma frequência constante de 60fps. Isso é trivialmente simples e ninguém mais deve ter problemas com isso. Não
há outros problemas que complicam esta tarefa.