Animações em JavaScript a 60 fps

Publicado em 6 jul 2020. Uns 5 minutos de leitura.

Na era do CSS moderno, eu falar em animações feitas com JavaScript pode fazer você achar que estou desenterrando cadáveres. Com o CSS atual, a vida de quem quer fazer animações bonitas e rápidas melhorou bastante. Infelizmente, nem tudo dá para animar com CSS ainda, e algumas coisas precisam ser feitas na mão, via JavaScript.

Esse post é uma versão atualizada de um tutorial que escrevi em 2016.

Recentemente, eu precisei animar a mudança de cor de fundo em um elemento, que era um radial-gradient. Isso não pode ser feito apenas com CSS da maneira que eu queria, então parti para o JavaScript. O problema? Ficou feio, meio lerdo, com umas travadas. Eu queria uma animação que rodasse a 60 ~first person shooters~ frames por segundo, e consegui esse feito usando o requestAnimationFrame. Vou te contar como fiz!

Objetivo: animar um gradiente

Meu objetivo é fazer com que a cor central de um gradiente radial se expandisse aos poucos até tomar o elemento inteiro (para fins de exemplo, estou usando apenas 2 cores, então vai ser só um círculo vermelho crescendo e ficando mais nítido). Se eu pudesse fazer o que quero diretamente com CSS, seria algo assim:

<div id="example-fake"></div>
#example-fake {
  animate: change-bg 1s;
  width: 30rem;
  height: 30rem;
}

/* Isso NÃO funciona */
@keyframes change-bg {
  from {
    background-image: radial-gradient(circle, tomato 25%, white 50%);
  }
  to {
    background-image: radial-gradient(circle, tomato 50%, white 50%);
  }
}

Como eu falei, isso não funciona. Você não pode animar o color-stop de um gradiente via CSS. Vamos fazer isso com JavaScript?

O jeito tradicional: setInterval / setTimeout

A primeira coisa que vem na sua cabeça (ou não) quando você pensa em animar com JavaScript é o uso de timeouts. A ideia é fazer uma função que atualiza o valor do percentual no gradiente, baseada em quanto tempo se passou.

Para garantir que o delay de execução com timeout não interfira na duração da animação, ao invés de contar o tempo restante, vamos calcular o tempo restante baseado no momento que iniciou.

const element = document.querySelector('#example-1');
const initialTime = +new Date();
const duration = 1000;

const interval = setInterval(() => {
  const currentTime = +new Date();
  const elapsedTime = currentTime - initialTime;
  const percent = 25 + Math.min(elapsedTime / duration, 1) * 25;
  
  element.style.backgroundImage = `radial-gradient(circle, tomato ${percent}%, white 50%)`;
  if (elapsedTime > duration) {
    clearInterval(interval);
  }
}, 100);
<div id="example-1"></div>
#example-1 {
  width: 200px;
  min-height: 200px;
}

Funciona? Sim, mas tem alguns problemas. O principal deles é aquele número 100 ali embaixo. De onde ele veio??? Resposta: foi um chute. Ao colocar 100, essa animação vai rodar 10 vezes por segundo. Por isso a animação roda meio estranha. Para rodar a 60 FPS, eu teria que colocar o valor de 1000 / 60. Se você o fizer, dá uma melhorada, provavelmente.

Porém, existem alguns outros problemas associados. Nem todo dispositivo vai rodar a 60 FPS, e você está desperdiçando recursos. A depender dos recursos (ex.: tem placa de vídeo? está na bateria? está em primeiro plano ou minimizado?), o navegador vai tentar desenhar sua página numa quantidade de frames por segundo diferente.

A mensagem importante aqui é: não adianta você atualizar seu elemento numa velocidade diferente da que o navegador pode/quer desenhar na tela. Ou seja, você deveria mudar o valor animado apenas quando o navegador estiver disposto a desenhar novamente.

A solução: requestAnimationFrame

Os navegadores modernos contam com uma função extremamente útil para animações: requestAnimationFrame. Essa função diz ao navegador que você gostaria de atualizar algo na tela. Então, quando ele estiver pronto para redesenhar, ele deve antes chamar sua função.

Se o navegador for atualizar a tela 60 vezes por segundo, ele chamará sua função 60 vezes apenas. Se for menos, ele chama menos. Se for mais, ele chama mais. Ou seja, sua animação não tem mais um frame rate definido. O frame rate será o do navegador, que é o único que importa de verdade, no fim das contas.

Vamos re-escrever o código acima para chamar o requestAnimationFrame "recursivamente" até a animação acabar (não é uma recursão literal pois é assíncrono).

const element = document.querySelector('#example-2');
const initialTime = +new Date();
const duration = 1000;

function updateGradient() {
  const currentTime = +new Date();
  const elapsedTime = currentTime - initialTime;
  const percent = 25 + Math.min(elapsedTime / duration, 1) * 25;

  element.style.backgroundImage = `radial-gradient(circle, tomato ${percent}%, white 50%)`;

  if (elapsedTime <= duration) {
    requestAnimationFrame(updateGradient);
  }
}

requestAnimationFrame(updateGradient);
<div id="example-2"></div>
#example-2 {
  width: 200px;
  min-height: 200px;
}

Note que a mudança no código é quase imperceptível. Apenas criei uma função nomeada para atualizar o gradiente (updateGradient). No final, ao invés de parar a animação quando termina como fiz com o setInterval, ela verifica se ainda precisa animar e chama requestAnimationFrame novamente. Note que para a animação começar, precisei chamar o requestAnimationFrame pela primeira vez, também.

Pronto! A animação agora vai executar em sincronia com o frame rate do navegador. Isso garante animações não só suaves e visualmente agradáveis, como um ganho em performance (leia-se: economizar bateria do seu usuário), pois se o navegador precisar diminuir o frame rate, seu código não vai ficar atualizando a animação inutilmente.

Toques finais

Eu usei um exemplo de animação simples para ficar fácil de visualizar o que estava acontecendo. Mas você pode usar a mesma ideia para fazer qualquer animação de maneira suave e performática!

Para que tudo funcione corretamente, é importante que você chame a próxima iteração do requestAnimationFrame dentro da função que você passa para ele.

Note que esse é um recurso recente (ou era quando escrevi esse artigo pela primeira vez, em 2016). Os navegadores modernos, em geral, já implementam. Mas, se você quer aumentar o suporte, você pode usar um Polyfill.

Links úteis