Tutorial HTML 5 – Parte 3/3

Esta é a terceira e última parte deste tutorial básico sobre jogos em HTML5. Nela veremos como melhorar a aparência das animações utilizando interpolação das posições.

Veremos como separar a lógica do jogo da renderização, fazendo a lógica rodar a uma taxa fixa (e baixa) e atualizando a tela o mais rápido possível. Embora esta seja uma técnica genérica, útil em qualquer tecnologia, é especialmente aplicável em jogos HTML onde o ideal é atualizar a lógica do jogo mais lentamente.

Continuaremos com o exemplo da parte anterior, sendo que os arquivos aojcanvas.js e aojinput.js permanecem inalterados. Nossa maior modificação não será no motor em sí, mas na própria página do jogo, onde temos a divisão do pulso em update() e render().

No entanto o motor recebe uma pequena modificação. Iniciaremos por ela.

Substituindo setInterval por setTimeout

Na conclusão da parte anterior, comentei sobre um problema que pode afetar o jogo caso a lógica demore muito para executar. No caso, o problema ocorre se a lógica levar mais tempo para executar do que o esperado para cada pulso.

Imagine que o pulso está agendado (com setInterval) para ser chamado a cada 50 milisegundos, mas por algum motivo nossa lógica leva 60 milisegundos. Ela ainda vai estar executando quando for disparado um novo pulso, iniciando novo processamento da lógica do jogo. Isso gera duas execuções em paralelo, diminuindo ainda mais a performance, piorando o prolema.

Para resolver isso, trocamos o uso de setInterval por setTimeout. A diferença entre elas é a seguinte:

  • setInterval agenda chamadas contínuas para a rotina informada. Ou seja, fica chamando a rotina automaticamente a cada X milisegundos.
  • setTimeout agenda apenas um execução da rotina. Ou seja, chama a rotina daqui a x milisegundos e só. Para executar a rotina novamente, precisamos agendar com um novo setTimeout.

A mudança que faremos, então, consiste em trocar a chamada de setInterval presente em aojStartGameLoop(), como também adicionar uma chama semelhante dentro de aojPulse. Veja abaixo.

function aojStartGameLoop() {
       ... // mesmo código de antes
       setTimeout('aojPulse()', 0); // trocamos setInterval por setTimeout
       // isso vai chamar aojPulse apenas uma vez, então dentro dela precisamos de outros setTimeout
}
 
function aojPulse() {
        ... // mesmo código de antes
        setTimeout('aojPulse()', 0); // adicionamos uma chamada a setTimeout - a cada pulso agendamos outro
    }
}

Bem, se você tiver prestado atenção, percebeu que o código acima não é exatamente equivalente ao que tínhamos antes. Estamos passando zero milisegundos para o setTimeout, o que faz com que a próxima execução seja agenda para imediatamente – ou, na prática, para tão logo seja possível. Ou seja, não respeitamos mais o limite de FPS informado, rodando, ao invés disso, na velocidade máxima possível.

Se colocarmos esse game loop nos exemplos anteriores, sem modificá-los, teremos a ação deles acelerada, porque neles a lógica roda a cada pulso, sem nenhum filtro. Mas a modificação que faremos nos exemplos dará conta disso.

Separando update() e render()

Vamos lembrar como era a relação entre nosso pulso e as rotinas update() e render() no exemplo anterior.

function pulse() {
    update();
    render();
}
 
function update() {
    ... // lógica do jogo
}
 
function render() {
    ... // atualiza a imagem na tela
}

Não poderia ser mais simples. A cada pulso executamos um update e um render. Vejamos agora o que muda. O código está abaixo e logo após a explicação.

var _lastMilisecond = 0;
var _updatesCount = 0;
var _updatesInLastSecond = 0;
var _lastUpdateMilisecond = 0;
var _frameCount = 0;
 
function pulse() {
    var d = new Date();
    if (d.getTime() - _lastMilisecond > 50) {
        _lastMilisecond = d.getTime();
        update();
        _frameCount = 0;
    }
    _frameCount ++;
    render(_frameCount / (_aojPulsesInLastSecond / _updatesInLastSecond));
}
 
function update() {
    _updatesCount ++;
    var d = new Date();
    if (d.getTime() - _lastUpdateMilisecond > 1000) {
        _lastUpdateMilisecond = d.getTime();
        _updatesInLastSecond = _updatesCount;
        _updatesCount = 0;
    }
    ... // lógica do jogo
}

Vamos analisar a rotina pulse() primeiro. Em linhas gerais, agora ela executa update() a cada x milisegundos e executa render() sempre, ou seja, a cada pulso. O filtro para update é feito com o seguinte algoritmo:

  1. A cada pulso é obtido o tempo atual;
  2. Se a diferença entre o tempo atual e o tempo armazenado anteriormente (_lastMilisecond) for maior do que 50 milisegundos:
    1. Armazenamos o tempo atual em _lastMilisecond;
    2. Zeramos _frameCount (explicada abaixo);
    3. Chamamos update();

O efeito disto é que update() é chamado a cada 50 milisegundos. Ignorei a rotina de informar o FPS desejado e utilizei o valor fixo 50, mas poderia ter feito algo configurável. Executando um update a cada 50 milisegundos terei 20 updates por segundo. Na realidade, teremos um pouco menos, porque a estes 50 milisegundos somamos o tempo de execução da própria rotina update().

O código colocado dentro da rotina update é comum e serve para contar quantas vezes por segundo ela é chamada. É o mesmo código que tínhamos dentro de aojPulse() no exemplo anterior.

A variável _frameCount serve para contar quantos frames (que nesse caso são iguais a pulsos) ocorrem entre duas chamadas de update. Por isso é zerada quando ocorre um update e a partir daí vai sendo incrementada a cada pulso.

Veja no gráfico abaixo como se comporta essa variável. Vamos assumir que a duração aqui seja 1 segundo, ou seja, temos 20 pulsos por segundo. As linhas vermelhas mostram os pulsos onde update é chamado (aqui temos apenas 4 updates por segundo, para simplificar).

Já a chamada de render é feita sem nenhum controle, ou seja, é chamada em todos pulsos. E veja que ela recebe um valor como parâmetro. Vejamos o que ele significa.

Esse valor é calculado da seguinte forma: primeiro a quantidade de pulsos ocorridos no último segundo é dividida pela quantidade de updates no mesmo período. Na figura acima, que representa 1 segundo, ocorreram 20 pulsos e 4 updates. Então temos 20/4 = 5.

Esse número (5) é a quantidade de pulsos que ocorre entre dois updates – e vem a ser a quantidade de vezes que render() é chamada entre dois updates.

Para completar o valor do parâmetro de render, dividimos o valor de _frameCount pela quantidade de pulsos entre dois updates, de forma a obter um valor normalizado entre 0 e 1. Por exemplo, quando _frameCount é 1, teremos 1/5 = 0.2; quando _frameCount é 2, teremos 2/5 = 0.4; quando _frameCount for 5, teremos 5/5 = 1. Veja na figura abaixo.

Interpolação

A interpolação consiste em obter valores intermediários entre dois valores conhecidos. Por exemplo, dados os valores 10 e 20, se fizermos a interpolação entre eles com três passos, teremos os valores (12.5, 15.0, 17.5). Se fizermos com 7 passos teremos (11.25, 12.5, 13.75, 15.0, 16.25, 17.5, 18.75).

Nosso objetivo aqui é interpolar as posições dos nossos sprites de forma a gerar posições intermediárias que não são calculadas pelo update. Por exemplo, digamos que no último update ocorrido nosso sprite tenha ficado na posição x=150 e no próximo update, ele vá para a posição x=180. Se nada for mudado em render, veremos nosso sprite saltar diretamente da posição 150 para a 180.

Lembre-se que entre estes dois updates tivemos (conforme a figura acima) 5 chamadas a render. Se não tivermos interpolação implementada, o que ocorre é que as cinco chamadas vão mostrar o sprite na posição 150, até que ocorre o próximo update e a posição muda para 180 – e os próximos cinco render mostrarão ele na posição 180, e assim por diante.

Com a interpolação, no entanto, calcularemos dentro de render posições intermediárias. Sabemos que são 5 render entre os updates. Assim, o primeiro render deverá mostrar o sprite a 1/5 (0.2) do caminho entre 150 e 180; o segundo update mostrará a 2/5 (0.4) do caminho; e o quinto mostrará a 5/5 (1.0) do caminho, ou seja, bem no 180.

Os valores 0.2, 0.4, etc, como vimos, são aqueles passados para render no parâmetro, que chamaremos de delta. Como usamos esse delta para afetar o x do sprite? Basta multiplicar o delta pela distância entre as duas posições (180 – 150 = 30) e somar na primeira posição (150).

Ou seja, a fórmula é essa: x = x1 + (x2 – x1) * delta, onde, no exemplo, x1=150, x2=180 e delta = 0.2, 0.4, 0.6, etc.

Os valores de x serão assim:

  1. Primeiro update
    x = 150 + (180-150) * 0.2;
    x = 150 + 30 * 0.2;
    x = 150 + 6;
    x = 156;
  2. Segundo update
    x = 150 + 30 * 0.4;
    x = 150 + 12;
    x = 162;
  3. Terceiro update
    x = 150 + 30 * 0,6;
    x = 150 + 18;
    x = 168;
  4. Quarto update
    x = 150 + 30 * 0,8;
    x = 150 + 24;
    x = 174;
  5. Quinto update
    x = 150 + 30 * 1,0;
    x = 150 + 30;
    x = 180;

Veja como este recurso é poderoso: ele nos permitiu mostrar o sprite em 6 posições diferentes (150, 156, 162, 168, 174 e 180), embora a lógica do jogo tenha calculado apenas duas (150 e 180). Isso nos permite rodar o jogo a uma baixa taxa de updates, mas mostrar uma animação suave, tanto mais suave quantos forem os frames entre os updates.

Quanto melhor o desempenho do computador do jogador, mais vezes render vai ser chamada entre os updates e assim mais passos teremos em nossa interpolação, melhorando a animação.

Vejamos, então, como ficou o código da rotina render:

function render(delta) {
     aojFillRect(0, 0, aojCanvas().width, aojCanvas().height, 'gray');
     var x = carro.lastX + ((carro.x - carro.lastX) * delta); // x = x1 + (x2-x1) * delta
     var y = carro.lastY + ((carro.y - carro.lastY) * delta);
     aojDrawImage(carro.sprite, x, y);
     aojFillRect(57,5,50,20, 'blue');
     aojSetFont('');
     aojDrawText(_updatesInLastSecond + ' tps', 65, 20, 'yellow');
}

Mas que atributos são esses: “lastX” e “lastY”? Bem, estes são atributos adicionados ao sprite para armazenar a posição anterior dele – afinal, agora precisamos de duas posições para fazer a interpolação. Sempre que update muda o valor de x, armazena o anterior em lastX. Veja abaixo o código completo de update:

function update() {
      _updatesCount ++;
      var d = new Date();
      if (d.getTime() - _lastUpdateMilisecond > 1000) {
           _lastUpdateMilisecond = d.getTime();
           _updatesInLastSecond = _updatesCount;
           _updatesCount = 0;
      }
      carro.lastX = carro.x;
      carro.lastY = carro.y;
      carro.x += carro.dx;
      carro.y += carro.dy;
      if (carro.x < 0 || carro.x > aojCanvas().width) {
           carro.dx *= -1;
      }
      if (carro.y < 0 || carro.y > aojCanvas().height) {
           carro.dy *= -1;
      }
}

Está implementada assim a interpolação da posição do sprite entre updates, criando um movimento mais suave e contínuo do que tínhamos no exemplo anterior.

Conclusão

Como dito no início deste post, a interpolação é uma técnica genérica, que pode ser utiliza em qualquer motor para melhorar a aparência do movimento dos sprites. Mas é especialmente bem vinda em jogos para navegadores, já que o desempenho do javascript ainda não é tão alto. Assim, é bom podermos rodar o jogo a uma taxa de atualização mais baixa, dando mais tempo para a lógica dentro de update ser executada, sem, no entanto, ter a animação prejudicada.

Uma coisa interessante de notar nessa técnica é que o que vemos na tela está sempre um passo atrás da lógica do jogo. Veja vem: quando o update coloca o sprite na posição 180, recém inicia a apresentação dele entre 150 e 180. Quando ele chegar no 180, já teremos outro update que colocará o sprite mais para frente, digamos x=210. Agora o render vai interpolar entre 180 e 210.

Isso não causa nenhum problema visível na maioria dos jogos, mas naqueles cuja ação é muito rápida (geralmente shooters), poderão ser percebidas discrepâncias com relação aos controles – afinal, o input está sendo processado pelo update, levando em conta a posição atual, enquanto o render vem atrás, interpolando a partir da posição anterior.

Nesses casos extremos não podemos usar a posição atual com relação à anterior. É preciso, de alguma forma, usar a posição atual com relação à próxima, que ainda não foi calculada! Para isso usamos a extrapolação ao invés da interpolação – basicamente é seguir o calculo da interpolação passando do valor de destino. Mas essa já é outra história. Fica como lição de casa…

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.

4 Comentários em "Tutorial HTML 5 – Parte 3/3"

  1. adailton 02/02/2013 at 20:23 - Reply

    este exemplo usa 100% de cpu lol html5 esta fail ainda…

  2. Helio 30/05/2013 at 15:58 - Reply

    Showww!!!!

  3. Philipe Requena 13/07/2013 at 17:01 - Reply

    Fera, muito show… Sou Desenvolvedor Java e tava afim de brincar um pouco em HTML5, seu tutorial esclareceu meu caminho ;) .
    Continue assim!

  4. kurono 26/04/2016 at 14:46 - Reply

    tem como fazer esse exemplo da aceleração e desaceleração do carro sem o uso do pluguin que você construiu?

Deixar um Comentário