O coração de qualquer jogo é o game loop, um laço que fica sendo repetido durante toda execução do jogo. A cada volta desse laço temos um novo frame de jogo. Mas você sabe que a taxa de frames pode variar muito de um hardware para outro. Como manter a lógica do jogo rodando a uma velocidade fixa, independente da variação nos frames?
Quando jogamos, o que vemos na tela é uma sucessão de quadros que nos dá a impressão de movimento. Isso é chamado animação e tenho certeza que você já conhece seus princípios. Em geral somos levados a crer que, a cada quadro novo que vemos, algo mudou no estado do jogo. Se uma nave está se movendo, a cada quadro ela estará, digamos, um pixel para a direita.
É o game loop, o laço principal do jogo, que emite o que podemos chamar de pulsos para atualizar o estado do jogo (update) e redesenhar a imagem na tela (render). Em sua forma mais simples, a cada volta do game loop temos um pulso para update e um para render. Mas estas duas tarefas não precisam ser sempre sincronizadas. Na verdade, é melhor que não sejam.
Vamos contruir um programa de exemplo para estudar mais a fundo o game loop. Neste post vamos ver o loop mais simples e entender sua limitação. No próximo veremos como desacoplar o update do render, de forma que o update rode sempre a uma determinada velocidade e o render rode o mais rápido que puder.
Arquivo Main.java
001package abrindoojogo.exemplos.gameloop;
002
003public class Main
004{
005 public static void main(String[] args)
006 {
007 JogoLoopSimples jogo = new JogoLoopSimples();
008 jogo.gameloop();
009 }
010}
Esta classe simplesmente cria um objeto do tipo JogoLoopSimples e chama seu método gameloop() que vai rodar o jogo. A seguir a classe do jogo em sí, que possui o game loop.
Arquivo JogoLoopSimples
001package abrindoojogo.exemplos.gameloop;
002
003import java.awt.Color;
004import java.awt.Dimension;
005import java.awt.Graphics2D;
006import java.awt.Toolkit;
007import java.awt.event.KeyEvent;
008import java.awt.event.KeyListener;
009import java.awt.image.BufferStrategy;
010import javax.swing.JFrame;
011import javax.swing.WindowConstants;
012
013public class JogoLoopSimples extends JFrame implements KeyListener
014{
015 BufferStrategy bs;
016 Contador contador;
017 int nave_x, nave_y, nave_qtd;
018
019 public JogoLoopSimples()
020 {
021 setUndecorated(true);
022 setSize(800, 600);
023 Dimension d = Toolkit.getDefaultToolkit().getScreenSize();
024 setLocation(d.width / 2 - 400, d.height / 2 - 300);
025 setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
026 setIgnoreRepaint(true);
027 addKeyListener(this);
028 contador = new Contador();
029 }
030
031 public void gameloop()
032 {
033 initialize();
034 while (true)
035 {
036 Thread.yield();
037 update();
038 render();
039 }
040 }
041
042 public void initialize()
043 {
044 setVisible(true);
045 createBufferStrategy(2);
046 bs = getBufferStrategy();
047
048 nave_x = 400;
049 nave_y = 50;
050 nave_qtd = 1;
051
052 contador.inicia();
053 }
054
055 public void update()
056 {
057 contador.contaPulso();
058 nave_x += 1;
059 if (nave_x > getWidth() + 30)
060 {
061 nave_x = 0;
062 }
063 }
064
065 public void render()
066 {
067 contador.contaFrame();
068 Graphics2D g = (Graphics2D) bs.getDrawGraphics();
069 g.setColor(Color.black);
070 g.fillRect(0, 0, getWidth(), getHeight());
071 int x = nave_x;
072 int y = nave_y;
073 for (int i = 0; i < nave_qtd; i++)
074 {
075 g.setColor(Color.yellow);
076 g.drawLine(x, y, x - 20, y - 5);
077 g.drawLine(x, y, x - 20, y + 5);
078 g.drawLine(x - 20, y - 5, x - 20, y + 5);
079 y += 15;
080 if (y > 550)
081 {
082 y = nave_y;
083 x += 15;
084 }
085 }
086 g.setColor(Color.white);
087 g.drawString("Pulsos: " + contador.getPulsosPorSegundo() + " Frames: " + contador.getFramesPorSegundo() + " naves: " + nave_qtd, 10, 20);
088 g.dispose();
089 bs.show();
090 }
091
092 public void keyPressed(KeyEvent e)
093 {
094 if (e.getKeyCode() == KeyEvent.VK_ESCAPE)
095 {
096 System.exit(0);
097 }
098
099 if (e.getKeyCode() == KeyEvent.VK_UP)
100 {
101 nave_y -= 1;
102 }
103 if (e.getKeyCode() == KeyEvent.VK_DOWN)
104 {
105 nave_y += 1;
106 }
107 if (e.getKeyCode() == KeyEvent.VK_RIGHT)
108 {
109 if (nave_qtd < 1054)
110 {
111 nave_qtd += 1;
112 }
113 }
114 if (e.getKeyCode() == KeyEvent.VK_LEFT)
115 {
116 if (nave_qtd > 1)
117 {
118 nave_qtd -= 1;
119 }
120 }
121 }
122
123 public void keyReleased(KeyEvent e)
124 {
125 }
126
127 public void keyTyped(KeyEvent e)
128 {
129 }
130}
Vejamos os métodos mais importantes. O contructor faz bastante coisa de configuração, mas tudo com relação à janela do jogo. É tirada a decoração (título, borda, etc) ficando apenas um quadro centralizado no vídeo. É centralizado baseado no tamanho da tela, obtida por meio de Toolkit.getDefaultToolkit().getScreenSize(). Boa parte desse código é parecida com o que foi mostrado no post Passive VS Active Rendering. Note, no entando na criação do objeto “contador”. Ele será utilizado para contar quantos pulsos e quantos frames obteremos. Os pontos onde ele é utilizado estão destacados em vermelho.
O jogo é muito simples – na verdade nem é um jogo. O método update() incrementa a variável nave_x, fazendo ela ir até a direita da tela e então recomeçar da esquerda. Quando uma tecla é pressionada, o método keyPressed modifica a variável nave_y, fazendo a posição subir ou descer, ou aumenta/diminui a variável nave_qtd, que informa quantos sprites teremos na tela.
O método render() limpa a tela, desenha a “nave” (um triângulo feito com linhas) dentro de um loop que vai repetir o desenho tantas vezes quanto for o valor de nave_qtd. Depois mostra na tela os valores de pulsos por segundo e frames por segundos do contador.
Note que toda vez que o método update() é executado, dentro dele é chamado o contador.contaPulso(). E no método render() é chamado contador.contaFrame(). Assim estamos contando quantas vezes estes dois métodos são chamados.
Nosso game loop é a versão mais simples possível, reproduzida abaixo.
031 public void gameloop()
032 {
033 initialize();
034 while (true)
035 {
036 Thread.yield();
037 update();
038 render();
039 }
040 }
É chamado update() e depois render(), um após o outro sem nenhum controle mais elaborado. O método yield() faz o programa interromper rapidamente seu processamento para que o resto do sistema tenha tempo de CPU. Usar ele faz o programa parar um pouco para escutar o teclado. Sem ele, um pressionar de tecla pode ser perdido.
Finalmente vejamos o código do contador.
Arquivo Contador.java
001package abrindoojogo.exemplos.gameloop;
002
003public class Contador
004{
005 static public double NANOS_EM_UM_SEGUNDO = 1e9;
006 protected long pulsosPorSegundo;
007 protected long framesPorSegundo;
008 protected long nanoTimeAnterior;
009 protected long pulsosContados;
010 protected long framesContados;
011
012 public void inicia()
013 {
014 nanoTimeAnterior = System.nanoTime();
015 pulsosContados = 0;
016 framesContados = 0;
017 pulsosPorSegundo = 0;
018 framesPorSegundo = 0;
019 }
020
021 public void contaPulso()
022 {
023 pulsosContados++;
024 verifica();
025 }
026
027 public void contaFrame()
028 {
029 framesContados++;
030 verifica();
031 }
032
033 protected void verifica()
034 {
035 if (System.nanoTime() - nanoTimeAnterior > NANOS_EM_UM_SEGUNDO)
036 {
037 pulsosPorSegundo = pulsosContados;
038 framesPorSegundo = framesContados;
039 pulsosContados = 0;
040 framesContados = 0;
041 nanoTimeAnterior = System.nanoTime();
042 }
043 }
044
045 public void sleep(long miliSecondsToSleep)
046 {
047 try
048 {
049 Thread.sleep(miliSecondsToSleep);
050 } catch (Exception e)
051 {
052 }
053 }
054
055 public long getPulsosPorSegundo()
056 {
057 return pulsosPorSegundo;
058 }
059
060 public long getFramesPorSegundo()
061 {
062 return framesPorSegundo;
063 }
064}
O trabalho desta classe é interessante: cada vez que chamamos o método contaPulso(), ela incrementa o contador de pulsos e chama o método verifica() que testa se já passou um segundo desde a última verificação. Se sim, ele atribui a quantidade de pulsos contados para a variável pulsosPorSegundo. Assim, esta variável é atualizada a cada segundo com a quantidade de pulsos que ocorreram. Nesse momento o contador de pulsos é zerado para contar quantos pulsos ocorrerão no próximo segundo. A mesma coisa para a contagem de frames.
O tempo é medido em nanosegundos, que é bem menor que os milisegundos geralmente utilizados nos programas. Um segundo contém 1.000 milisegundos, ou seja, 1e3 (mil). E possui 1.000.000.000 nano segundos, ou seja, 1e9 (um milhão). Esse valor está registrado na constante NANOS_EM_UM_SEGUNDO para ser utilizado nos cálculos.
Essa classe oferece ainda um método utilitário chamado sleep(), que servirá para realizarmos alguns testes. Ele simplifica o uso do método sleep() de Thread, que precisa de um try-catch para ser usado. Ele faz o processamento parar durante os milisegundos informados.
Executando esse programa, obtenho o seguinte resultado (só mostro o canto da tela):
Conforme os dados do contador mostrados, tenho 47 pulsos por segundo e também 47 frames. Naturalmente estes números serão sempre iguais, porque dentro do loop chamo uma vez update() e uma vez o render().
Se eu desejasse 60 fps (frames por segundo), não seria possível. 47 é tudo que eu consigo no meu computador e ainda fica variando. Se eu pressiono a seta para direita, aumentando a quantidade de sprites até 1054, a taxa de frames cai mais um pouco.
Talvez seu computador seja bem mais rápid e mesmo aumentando os sprites a taxa de frames não apresente muita diferença. Vamos fazer um teste mais exagerado. Chamaremos o método contador.sleep() dentro do método render(), de forma a fazer este método demorar alguns milisegundos a mais.
001
002
003 public void render()
004 {
005 contador.contaFrame();
006 Graphics2D g = (Graphics2D) bs.getDrawGraphics();
007 g.setColor(Color.black);
008 g.fillRect(0, 0, getWidth(), getHeight());
009 int x = nave_x;
010 int y = nave_y;
011 for (int i = 0; i < nave_qtd; i++)
012 {
013 g.setColor(Color.yellow);
014 g.drawLine(x, y, x - 20, y - 5);
015 g.drawLine(x, y, x - 20, y + 5);
016 g.drawLine(x - 20, y - 5, x - 20, y + 5);
017 y += 15;
018 if (y > 550)
019 {
020 y = nave_y;
021 x += 15;
022 }
023 }
024 g.setColor(Color.white);
025 g.drawString("Pulsos: " + contador.getPulsosPorSegundo() + " Frames: " + contador.getFramesPorSegundo() + " naves: " + nave_qtd, 10, 20);
026 g.dispose();
027 bs.show();
028 contador.sleep(50);
029 }
Agora sim. O tempo de atualizar a tela (render) leva absurdos 50 ms (milisegundos). Absolutamente lento demais. A taxa de frames cai para 14 na minha máquina.
Agora preste atenção na parte mais importante: a taxa de pulsos por segundo cai junto com os frames, embora apenas a rotina render() tenha recebido o tempo a mais. Isso ocorre porque como uma rotina é chamada depois da outra, em sequencia, update() tem que esperar render() executar para ser executada novamente.
Rodando o jogo você percebe que não é apenas a taxa de atualização da tela que fica lenta, mas o jogo todo. A nave agora leva uma eternidade para chegar até a extremidade da tela, porque temos apenas 14 pulsos de atualização da sua posição por segundo.
Está comprovado o problema. No próximo post a solução. Até amanhã.
Arquivado em: Java, Programação, Técnico
