GameLoop com taxa constante de pulsos – parte 1 de 2

placeholderantigo

GameLoop

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):

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

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

GameLoop03Agora 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ã.

Baixe o código fonte deste artigo.

Autor: Luiz Nörnberg Ver todos os posts de
Sou Bacharel em Ciência da Computação pela Universidade Católica de Pelotas (UCPel), onde também atuei como professor. Desde a época da faculdade (mais de quinze anos atrás) a paixão por jogos tem sido importante no meu direcionamento profissional. Sou sócio-fundador do Izyplay Game Studio, onde exerço o cargo de Diretor de Tecnologia. Sempre tive grande foco em desenvolvimento em Java, embora tenha migrando para a tecnologia Adobe AIR em função de sua portabilidade. Ah, e é claro, dou meus palpites no game design.

Deixar um Comentário