GameLoop com taxa constante de pulsos – parte 2 de 2

GameLoopNo artigo anterior vimos o game loop em sua forma mais simples, que tem o problema de ter a taxa de pulsos e frames presas uma a outra. Isso faz o jogo correr mais lentamente em CPUs lentas e acelerar em CPU mais poderosas, impedindo uma velocidade estável em todas plataformas. Neste artigo veremos como criar um game loop que garanta uma velocidade fixa para o jogo.

Aprendemos, no post anterior, a diferença entre o pulso e o frame, sendo o primeiro relacionado à lógica do jogo e o segundo à atualização da tela. Tinhamos uma classe Main que criava um objeto do tipo JogoLoopSimples. Vamos modificar essa classe para usar agora um objeto do tipo JogoPulsoFixo.

Arquivo Main.java

001package abrindoojogo.exemplos.gameloop;
002
003public class Main
004{
005    public static void main(String[] args)
006    {
007        JogoPulsoFixo jogo = new JogoPulsoFixo();
008        jogo.gameloop();
009    }
010}

O game loop da classe JogoLoopSimples era assim (para relembrar):

001public void gameloop()
002    {
003        initialize();
004        while (true)
005        {
006            Thread.yield();
007            update();
008            render();
009        }
010    }

Uma chamada a update() e uma a render(), acopladas. Se render demora, update também demora e o jogo fica lento. Bem, vamos partir do seguinte pressuposto: é possível “aturar” uma taxa de frames baixa, mas uma taxa de pulsos baixa modifica a jogabilidade.

Imagine um jogo onde uma nave se move um pixel por pulso. Se temos 60 pulsos por segundo, ao final de dois segundos a nave terá se movido 120 pixels. Mas se a taxa cair para 10 pulsos por segundo, em dois segundos a nave vai se mover apenas 20 pixels. E mais: se o computador for muito rápido, a taxa pode subir a 100 pulsos por segundo, e a nave vai andar 200 pixels em dois segundos. A jogabilidade é totalmente afetada.

A solução que apresento a seguir tenta manter a taxa de pulsos fixa, em detrimento da taxa de frames. Ou seja, se a máquina é lenta, priorizamos os pulsos e geramos os frames conforme der. Sempre teremos 60 pulsos por segundo, embora a taxa de frames possa cair para 10.

Isso causa o seguinte efeito: a nave vai se mover sempre 120 pixels em dois segundos, independente da velocidade da máquina. Mas se a máquina for lenta, a tela estará sendo atualizada apenas a 10 frames por segundo – o jogador vai ver a imagem quadro a quadro, mas a jogabilidade não fica tão comprometida, porque não há mudança na velocidade da ação.

E se a máquina for muito rápida, ainda assim ficaremos apenas com 60 pulsos por segundo, embora possamos estar obtendo 100 frames. A atualização da tela será bem rápida, o que é bom, e a nave continua se movendo na velocidade esperada.

Eu mostro abaixo a nova classe, chamada JogoPulsoFixo. Ela é derivada da classe do artigo anterior, de forma que tem tudo igual a ela. A lógica do jogo é exatamente a mesma. Só sobreescrevi o método gameloop, para modificá-lo.

Arquivo JogoPulsoFixo.java

001package abrindoojogo.exemplos.gameloop;
002
003public class JogoPulsoFixo extends JogoLoopSimples
004{
005    long PULSOS_DESEJADOS_POR_SEGUNDO = 60;
006    double NANOS_ESPERADOS_POR_PULSO = Contador.NANOS_EM_UM_SEGUNDO / PULSOS_DESEJADOS_POR_SEGUNDO;
007
008    @Override
009    public void gameloop()
010    {
011        initialize();
012        long nanoTimeDoProximoPulso = System.nanoTime();
013        while (true)
014        {
015            Thread.yield();
016            while (System.nanoTime() > nanoTimeDoProximoPulso)
017            {
018                update();
019                nanoTimeDoProximoPulso += NANOS_ESPERADOS_POR_PULSO;
020            }
021            render();
022        }
023    }
024}

A modificação (marcada em vermelho) é simples em termos de código, mas pode ser difícil de entender como funciona. Por isso vou explicar bem detalhadamente.

Nas linhas 005, 006 são declaradas constantes. PULSOS_DESEJADOS_POR_SEGUNDO armazena a quantidade de pulsos que desejamos que o game rode. Nesse caso é 60. Não é necessário mais do que isso para a lógica de qualquer jogo. Alguns rodam a 30 ou 25.

NANOS_ESPERADOS_POR_PULSO armazena a quantidade de nanosegundos que cada pulso deve durar. Calcular isso é fácil: se queremos 60 pulsos por segundo, basta pegar a quantidade total de nanosegundos que cabem em um segundo (1e9 ou 1.000.000.000) e dividir por sessenta pulsos. Cada pulso vai durar, nesse caso, 16.666.666 nanosegundos. Em geral vai durar menos, mas queremos ter um pulso a cada 16.666.666 nanosegundos, de forma a constituir 60 por segundo.

Agora veja o game loop. A grande mudança é que temos um outro laço dentro dele, para chamar repetidamente update(). Funciona assim: inicializamos uma variável chamada nanoTimeDoProximoPulso com o valor no nanosegundo atual. Em seguida entramos no game loop e já caimos no loop do update. É feita a verificação para ver se o nanosegundo atual é maior do que o nanoTimeDoProximoPulso, o que certamente não será, já que acabamos de atribuir o tempo atual a esta variável.

Pois bem. Entramos no loop do update(), chamamos esta rotina e depois adicionamos ao nanoTimeDoProximoPulso a quantidade de nanos esperados em um pulso. Isso nos dá o momento no tempo quando deverá ser executado o próximo pulso. Não executaremos ele antes desse tempo. Por outro lado, passar muito desse tempo, executaremos ele mais de uma vez até recuperar o atraso.

Vamos analisar um exemplo volta a volta do game loop. Respire fundo antes de continuar lendo.

  1. Vamos assumir que tudo iniciou com o nanoTime = 0 (zero). Executamos update(), que demora 5.000.000 nanosegundos e somamos 16.666.666 na variável nanoTimeDoProximoPulso. Isso quer dizer que o próximo pulso é esperado para ocorrer nesse tempo.
  2. Como update() demorou apenas 5.000.000 nanosegundos, ainda não chegamos ao 16.666.666 e saimos do loop do update. É executado em seguida o render(), que demora mais 5.000.000 nanosegundos e voltamos ao início do game loop.
  3. Nova verificação do loop do update. O tempo atual é maior do que 16.666.666? Já que update() levou 5.000.000 e render() mais 5.000.000, estamos em 10.000.000 nanosegundos, que é menor. Não entramos no loop do update, ou seja, desta vez o update() não será executado, porque ainda não está na hora esperada. Executamos o render(), que leva mais 5.000.000 nanosegundos e voltamos ao início do game loop.
  4. Agora já estamos em 15.000.000, mas ainda é menor do que 16.666.666, então pulamos novamente o loop do update e vamos direto para o render. Mais 5.000.000 nanosegundos e voltamos ao início.
  5. Agora sim, estamos em 20.000.000 nanosegundos e isso é maior do que o esperado para o próximo pulso, que deveria ter ocorrido em 16.666.666. Estamos atrasados, mas tudo bem – agora entramos no loop do update, executamos ele (mais 5.000.000) e somamos mais 16.666.666 na variável nanoTimeDoProximoPulso. Isso quer dizer que agora vamos esperar até o tempo chegar em 33.333.332 para executar update() novamente.

Já deu para notar que update está sendo limitado. Praticamente temos três render() para cada update(). Essa é uma máquina rápida. Vamos refazer a análise em uma máquina lenta.

  1. Tudo inicia no nanoTime = 0 (zero). Executamos update(), que agora demora 10.000.000 nanosegundos e somamos 16.666.666 na variável nanoTimeDoProximoPulso.
  2. Como update() demorou apenas 9.000.000 nanosegundos, ainda não chegamos ao 16.666.666 e saimos do loop do update. É executado em seguida o render(), que demora 15.000.000 nanosegundos (placa de vídeo lenta) e voltamos ao início do game loop.
  3. Nova verificação do loop do update e o tempo atual é 25.000.000, ou seja, maior que o 16.666.666 esperado para o próximo pulso. Assim, entramos no loop do update e executamos ele. Mais 10.000.000 se passam e estamos então há 35.000.000 nanosegundos de jogo. Acrescentamos novamente 16.666.666 à variável, ficando com 33.333.332, que seria a hora do próximo pulso.
  4. Mas veja: já estamos em 35.000.000, ou seja, já passou a hora do segundo pulso. Então ficamos dentro do loop do update (porque 35.000.000 é maior que 33.333.332) e vamos executar update mais uma vez. Se passam mais 10.000.000 e estamos em 45.000.000. Somamos novamente 16.666.666 na variável e o próximo pulso fica então esperado para 49.999.998.
  5. Ok, desta vez o tempo atual (45.000.000) é menor do que o esperado para o próximo pulso (49.999.998). Então pulamos o loop do update e vamos para o render(). Executamos ele e, devido aos seus 15.000.000 ficamos em 60.000.000 e voltamos ao início.

Já deu para ver que vamos entrar novamente no loop do update porque estamos bem atrasados. Vamos acabar executando o update duas vezes novamente, ou seja, nessa máquina o update executa o dobro de vezes que o render.

Caramba, que explicação cheia de volta… Entendeu? Espero que sim. Caso contrário, leia novamente o exemplo. Experimente colocar o comando contador.sleep(X), onde X é um número de milisegundos, dentro das rotinas update() e render() no arquivo JogoLoopSimples. Isso vai simular rotinas demoradas. Use valores como 50 ou 100 milisegundos.

Esse game loop mantém a taxa de pulsos fixa (na verdade pode oscilar entre 60 e 62) e pode ser utilizado para a criação de games reais. Mas ele apresenta um problema com máquina muitissimo lentas: pode ser que o update() precise rodar 500 vezes para cada render(). Ou mais! Ou ainda, ficar rodando sem nunca chegar na taxa desejada, e assim nunca executar o update().

Alguma dica de como resolver isso? Se você achar uma solução envie para mim (nornberg no gmail). Depois publico um post citando as soluções que aparecerem.

Baixe o código fonte deste artigo.

<pre><span style=”font-family:Monospaced,monospace;color:#000000″><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>001</span><span style=”color:#0000e6;”>package</span> abrindoojogo.exemplos.gameloop;<span style=”color:#000000;”><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>002</span><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>003</span></span><span style=”color:#0000e6;”>public</span> <span style=”color:#0000e6;”>class</span> JogoPulsoFixo <span style=”color:#0000e6;”>extends</span> JogoLoopSimples<span style=”color:#000000;”><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>004</span></span>{<span style=”color:#000000;”><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>005</span>&nbsp;&nbsp;&nbsp;&nbsp;</span><span style=”color:#0000e6;”>long</span> PULSOS_DESEJADOS_POR_SEGUNDO = <span style=”color:#000000;”>60</span>;<span style=”color:#000000;”><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>006</span>&nbsp;&nbsp;&nbsp;&nbsp;</span><span style=”color:#0000e6;”>double</span> NANOS_ESPERADOS_POR_PULSO = Contador.NANOS_EM_UM_SEGUNDO / PULSOS_DESEJADOS_POR_SEGUNDO;<span style=”color:#000000;”><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>007</span>&nbsp;&nbsp;&nbsp;&nbsp;</span><span style=”color:#0000e6;”>long</span> MAX_FRAMES_PARA_PULAR = <span style=”color:#000000;”>2</span>;<span style=”color:#000000;”><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>008</span><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>009</span>&nbsp;&nbsp;&nbsp;&nbsp;</span><span style=”color:#0000e6;”>public</span> <span style=”color:#0000e6;”>void</span> gameloop()<span style=”color:#000000;”><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>010</span>&nbsp;&nbsp;&nbsp;&nbsp;</span>{<span style=”color:#000000;”><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>011</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>initialize();<span style=”color:#000000;”><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>012</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span><span style=”color:#0000e6;”>long</span> nanoTimeDoProximoPulso = System.nanoTime();<span style=”color:#000000;”><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>013</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span><span style=”color:#0000e6;”>while</span> (<span style=”color:#0000e6;”>true</span>)<span style=”color:#000000;”><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>014</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>{<span style=”color:#000000;”><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>015</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>Thread.yield();<span style=”color:#000000;”><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>016</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span><span style=”color:#0000e6;”>long</span> pulsos = <span style=”color:#000000;”>0</span>;<span style=”color:#000000;”><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>017</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span><span style=”color:#0000e6;”>while</span> (System.nanoTime() &gt; nanoTimeDoProximoPulso &amp;&amp; pulsos &lt; MAX_FRAMES_PARA_PULAR)<span style=”color:#000000;”><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>018</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>{<span style=”color:#000000;”><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>019</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>update();<span style=”color:#000000;”><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>020</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>nanoTimeDoProximoPulso += NANOS_ESPERADOS_POR_PULSO;<span style=”color:#000000;”><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>021</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>pulsos++;<span style=”color:#000000;”><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>022</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>}<span style=”color:#000000;”><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>023</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>render();<span style=”color:#000000;”><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>024</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>}<span style=”color:#000000;”><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>025</span>&nbsp;&nbsp;&nbsp;&nbsp;</span>}<span style=”color:#000000;”><br/><span style=’color:#666;background-color:#DDD;border-right:1px #999 solid;margin-right:5px;padding:2px’>026</span></span>}</span></pre></div>

5 Respostas

  1. Nornberg, muito bons seus tutoriais, estou aprendendo muito, dá uma base muito boa, pois não é como os que encontramos por aí, “faça assim” e pronto.

    Método muito didático, valeu.

    PS, estou te mandando uma tentativa de responder o desafio.

    • Olá Alex. Recebi seu e-mail. Obrigado pelo interesse! Vou olhar seu código e lhe responder amanhã a noite.

    • Alex, boa a sua idéia e ela realmente funciona para o propósito que tu descreves – vou adicionar ela como exemplo no post. Mas… não resolve ainda o problema do desafio. Experimente colocar um valor muito alto para o tempo do update (não do render) e veja que o jogo fica trancado nele, sem chance de atualização da tela.

  2. Hmmm, até que enfim o fatídico loop sem fim dos jogos, e aqui com direito a temporizador! Gostei muito! Será que tem como implementar algum VSync aqui com o Java? Se possível mostre em alguma próxima postagem, quando tiver oportunidade.

    • O VSync é implementado no Java pela classe BufferStrategy quando em tela-cheia (e já notei que não é em todas máquinas). Fora isso, ainda não há suporte para sincronização com o refresh do monitor. Mas, em geral, utilizando double buffer (createBufferStrategy(2)) a coisa fica bem fluida, sem piscar, não sendo necessário o vsync. Lembro também que o vsync faz seu game rodar de acordo com a taxa do monitor, que pode variar muito (60, 75, 80, 100Hz), o que pode dificultar o controle da velocidade.

Deixe uma resposta