Melhoria do desempenho do canvas em HTML5

HTML5 Rocks

Introdução

O canvas em HTML5, que começou como um experimento da Apple, é o padrão gráfico de modo imediato 2D (link em inglês) com a maior compatibilidade na web. Ultimamente, muitos desenvolvedores empregam esse padrão para uma grande variedade de projetos multimídia, visualizações e jogos. Contudo, à medida que os aplicativos construídos ficam mais complexos, os desenvolvedores sem perceber esbarram em uma barreira de desempenho.

O conhecimento sobre a otimização do canvas é muito disperso. Este artigo tenta reunir um pouco desse conhecimento de forma mais "mastigada" para os desenvolvedores. Este artigo aborda as otimizações fundamentais que se aplicam a todos os ambientes de computação gráfica e também técnicas específicas do canvas que podem mudar conforme as implementações do canvas evoluem. Em especial, a crescente implementação da aceleração GPU do canvas pelos fornecedores de navegador pode acabar dispensando algumas das técnicas de desempenho discutidas. Isso será indicado quando for o caso.

Este artigo não trata do uso do canvas em HTML 5. Para isso, veja estesartigos sobre canvas (link em inglês) no HTML5Rocks, este capítulo do Dive into HTML5 (link em inglês) e o tutorial MDN Canvas (link em inglês).

Teste de desempenho

Para lidar com as constantes mudanças do canvas em HTML5, JSPerf (jsperf.com [link em inglês]) podem ser feitos testes que confirmam se cada otimização proposta ainda funciona. JSPerf é um aplicativo da web com o qual os desenvolvedores podem criar testes de desempenho do JavaScript. Cada teste se concentra em um resultado que se tenta alcançar (por exemplo, limpeza do canvas) e emprega vários métodos para chegar ao mesmo resultado. O JSPerf executa cada um desses métodos o máximo de vezes possível por um pequeno período de tempo e fornece um número estatisticamente significativo de iterações por segundo. Quanto mais altas as pontuações, melhor.

Os visitantes de uma página de teste de desempenho JSPerf podem executar o teste em um navegador e o JSPerf pode armazenar os resultados normalizados no Browserscope (browserscope.org [link em inglês]). Como as técnicas de otimização neste artigo se baseiam em um resultado do JSPerf, você pode retornar para ver informações atualizadas e confirmar se a técnica ainda é válida. Desenvolvemos um pequeno aplicativo de ajuda (link em inglês) que transforma esses resultados em gráficos, incorporados ao longo deste artigo.

Todos os resultados de desempenho neste artigo estão atrelados à versão do navegador. Isso acabou por se tornar uma limitação, já que não sabíamos em qual SO o navegador seria executado e, sobretudo, se o canvas em HTML5 seria acelerado por hardware quando o teste de desempenho ocorresse. Você pode descobrir se o canvas em HTML5 do Google Chrome é acelerado por hardware. Para isso, digite about:gpu na barra de endereço.

Pré-renderização para um canvas fora da tela

Ao redesenhar primitivas semelhantes na tela em vários quadros, como ocorre no desenvolvimento de jogos, é possível obter grandes ganhos de desempenho com a pré-renderização de grandes partes da cena. Pré-renderizar significa usar um canvas (ou mais de um) separado fora da tela em que imagens temporárias são renderizadas, para depois renderizar os canvas fora de tela em um canvas visível novamente. Para quem conhece computação gráfica, esta técnica também é conhecida como lista de exibição (link em inglês).

Por exemplo, vamos supor que você esteja redesenhando o Mario correndo a 60 quadros por segundo. Você pode redesenhar o chapéu, o bigode e o "M" a cada quadro ou pré-renderizar o Mario antes de executar a animação.

sem pré-renderização:

// canvas, context are defined
function render() {
  drawMario(context);
  requestAnimationFrame(render);
}

com pré-renderização:

var m_canvas = document.createElement('canvas');
m_canvas.width = 64;
m_canvas.height = 64;
var m_context = m_canvas.getContext(‘2d’);
drawMario(m_context);

function render() {
  context.drawImage(m_canvas, 0, 0);
  requestAnimationFrame(render);
}

Observe o uso de requestAnimationFrame, que será discutido mais a fundo em uma seção posterior. O gráfico a seguir ilustra os ganhos de desempenho do uso da pré-renderização (a partir deste jsperf [link em inglês]):

Essa técnica é especialmente eficaz quando a operação de renderização (drawMario no exemplo anterior) é dispendiosa. Um bom exemplo disso é a renderização de texto, uma operação dispendiosa. Este é o tipo de salto de desempenho que se pode esperar com a pré-renderização desta operação (a partir deste jsperf [link em inglês]):

Contudo, observe, no exemplo acima, o baixo desempenho do teste de "pré-renderização com folga". Durante a pré-renderização, é importante garantir que o canvas temporário tenha um tamanho mais próximo da imagem que está sendo desenhada, do contrário, o ganho de desempenho da renderização fora de tela é contrabalançado pela perda de desempenho da cópia de um canvas grande em outro (o que varia em função do tamanho do destino e origem). Um canvas que fique mais justo no teste acima é simplesmente menor:

can2.width = 100;
can2.height = 40;

Diferentemente do canvas que cabe com folga, cujo desempenho é menor:

can3.width = 300;
can3.height = 100;

Chamadas de canvas em lote

Como desenhar é uma operação dispendiosa, é mais eficiente carregar a máquina de estado de desenho com um conjunto de comandos e, então, fazer com que ela coloque todos no buffer de vídeo.

Por exemplo, quando estiver desenhando várias linhas, é mais eficiente criar um caminho contendo todas as linhas e desenhá-lo com uma única chamada. Em outras palavras, em vez de desenhar linhas separadas:

for (var i = 0; i < points.length - 1; i++) {
  var p1 = points[i];
  var p2 = points[i+1];
  context.beginPath();
  context.moveTo(p1.x, p1.y);
  context.lineTo(p2.x, p2.y);
  context.stroke();
}

Obtemos melhor desempenho se desenharmos uma única polilinha:

context.beginPath();
for (var i = 0; i < points.length - 1; i++) {
  var p1 = points[i];
  var p2 = points[i+1];
  context.moveTo(p1.x, p1.y);
  context.lineTo(p2.x, p2.y);
}
context.stroke();

Isso também vale para o mundo do canvas em HTML5. Quando estiver desenhando um caminho complexo, por exemplo, é melhor colocar todos os pontos no caminho do que renderizar os segmentos separadamente (jsperf [link em inglês]).

Mas devemos lembrar que, quando se trata de Canvas, existe uma exceção importante a esta regra: se as primitivas envolvidas no desenho do objeto desejado tiverem pequenas caixas delimitadoras (por exemplo, linhas horizontais e verticais), pode ser mais eficiente renderizá-las separadamente (jsperf [link em inglês]):

Como evitar mudanças desnecessárias de estado de canvas

O elemento do canvas em HTML5 é implementado sobre uma máquina de estado que rastreia itens como estilos de pincelada e preenchimento, assim como pontos anteriores que compõem o caminho atual. Quando se trata de otimizar o desempenho gráfico, a tentação é se concentrar unicamente na renderização gráfica. Contudo, a manipulação da máquina de estado também contribui para um resultado elevado de desempenho.

Ao usar várias cores de preenchimento para renderizar uma cena, por exemplo, é menos custoso renderizar por cor do que por posicionamento no canvas. Para renderizar uma estampa de listra fina, pode-se renderizar uma listra, mudar a cor, renderizar a próxima lista, etc.:

for (var i = 0; i < STRIPES; i++) {
  context.fillStyle = (i % 2 ? COLOR1 : COLOR2);
  context.fillRect(i * GAP, 0, GAP, 480);
}

Ou então renderizar todas as listras ímpares e depois todas as pares:

context.fillStyle = COLOR1;
for (var i = 0; i < STRIPES/2; i++) {
  context.fillRect((i*2) * GAP, 0, GAP, 480);
}
context.fillStyle = COLOR2;
for (var i = 0; i < STRIPES/2; i++) {
  context.fillRect((i*2+1) * GAP, 0, GAP, 480);
}

No teste de desempenho a seguir desenhamos uma estampa listrada intercalada usando os dois métodos (jsperf [link em inglês]):

Como seria de se esperar, a abordagem intercalada é mais lenta porque a alteração da máquina de estado é dispendiosa.

Renderizar apenas as diferenças da tela, e não todo o novo estado

Como seria de se esperar, renderizar menos na tela é mais fácil do que renderizar mais. Se houver apenas diferenças graduais entre os redesenhos, é possível obter um aumento considerável do desempenho redesenhando apenas a diferença. Em outras palavras, em vez de limpar toda a tela antes de desenhar:

context.fillRect(0, 0, canvas.width, canvas.height);

Controle e limpe apenas a caixa delimitadora.

context.fillRect(last.x, last.y, last.width, last.height);

Isso está ilustrado no teste de desempenho a seguir, que apresenta um ponto branco atravessando a tela (jsperf [link em inglês]):

Quem tem mais familiaridade com computação gráfica normalmente conhece essa técnica como "regiões de redesenho", que consiste em salvar a caixa delimitadora anterior e depois limpá-la a cada renderização.

Esta técnica também serve para contextos de renderização baseados em pixel, como mostra esta palestra sobre o emulador da Nintendo em JavaScript (link em inglês).

Usar canvas de várias camadas para cenas complexas

Como já dissemos, o desenho de imagens grandes é dispendioso e deve ser evitado se possível. Além de usar outro canvas para renderizar fora da tela, como mostra a seção sobre pré-renderização, podemos também usar camadas de canvas sobre outro canvas. Usando transparência no canvas do primeiro plano, podemos recorrer ao GPU para compor os alfas juntos na hora de renderizar. Isso pode ser configurado da seguinte maneira: com dois canvas absolutamente posicionados um sobre o outro.

<canvas id="bg" width="640" height="480" style="position: absolute; z-index: 0">
</canvas>
<canvas id="fg" width="640" height="480" style="position: absolute; z-index: 1">
</canvas>

A vantagem, se compararmos com o uso de um único canvas nesse caso, é que quando desenhamos ou limpamos o primeiro plano, não modificamos o plano de fundo. Se for possível dividir o aplicativo multimídia ou jogo em primeiro plano e plano de fundo, considere renderizar esses planos em canvas separados para obter um salto de desempenho. O gráfico a seguir compara o caso do canvas único e simples com aquele em que você simplesmente redesenha e limpa o primeiro plano (jsperf [link em inglês]):

Muitas vezes é possível valer-se das limitações da percepção humana e renderizar o plano de fundo apenas uma vez ou em uma velocidade menor que a do primeiro plano (que costuma ocupar mais a atenção do usuário). Por exemplo, você pode renderizar o primeiro plano sempre, e o plano de fundo somente de N em N quadros.

Esse método costuma dar certo com qualquer número de canvas compostos se seu aplicativo funcionar melhor com esse tipo de estrutura.

Evitar o shadowBlur

Como muitos outros ambientes gráficos, o canvas em HTML5 permite aos desenvolvedores desfocar primitivas, mas essa operação pode ser bem dispendiosa:

context.shadowOffsetX = 5;
context.shadowOffsetY = 5;
context.shadowBlur = 4;
context.shadowColor = 'rgba(255, 0, 0, 0.5)';
context.fillRect(20, 20, 150, 100);

O teste de desempenho a seguir mostra a mesma cena renderizada com e sem sombra e a diferença drástica no desempenho (jsperf [link em inglês]):

Saiba várias maneiras de limpar o canvas

Como o canvas em HTML5 é um paradigma de desenho de modo imediato (link em inglês), a cena precisa ser redesenhada explicitamente a cada quadro. Por isso, limpar o canvas é uma operação fundamentalmente importante para os aplicativos e jogos que usam canvas em HTML5.

Conforme mencionado na seção Como evitar mudanças desnecessárias de estado de canvas, limpar todo o canvas normalmente não é o mais recomendável, mas se você precisar fazer isso, existem duas opções: chamar context.clearRect(0, 0, width, height) ou usar um hack específico para canvas para isso: canvas.width = canvas.width;.

Até a data deste artigo, o clearRect, no geral, funciona melhor que a versão de redefinição de largura, mas em alguns casos usar o hack de redefinição canvas.width é significativamente mais rápido no Google Chrome 14 (jsperf [link em inglês]):

Esta dica requer cautela, já que depende muito da implementação do canvas subjacente e está muito sujeita a mudanças. Para obter mais informações, consulte o artigo de Simon Sarris sobre a limpeza do canvas (link em inglês).

Evitar coordenadas de ponto de flutuação

O canvas em HTML5 suporta renderização de subpixel e não há como desativá-la. Se você desenhar com coordenadas e não houver inteiros, o canvas automaticamente tenta suavizar as linhas. Este é o efeito visual, tirado deste artigo sobre desempenho do canvas de subpixel escrito por Seb Lee-Delisle (link em inglês):

subpixel

Se o sprite suavizado não for o efeito pretendido, pode ser bem mais rápido converter as coordenadas em inteiros usando Math.floor ou Math.round (jsperf [link em inglês]):

Para converter as coordenadas de ponto flutuante em inteiros, é possível usar várias técnicas inteligentes. A que mais funciona é a que envolve somar meio ao número desejado e, em seguida, executar operações bit a bit no resultado para eliminar a parte fracionária.

// With a bitwise or.
rounded = (0.5 + somenum) | 0;
// A double bitwise not.
rounded = ~~ (0.5 + somenum);
// Finally, a left bitwise shift.
rounded = (0.5 + somenum) << 0;

O detalhamento completo do desempenho está aqui (jsperf [link em inglês]):

Este tipo de otimização não será mais necessário quando as implementações de canvas forem aceleradas por GPU, o que acelerará a renderização das coordenadas não inteiras.

Otimizar as animações com "requestAnimationFrame"

A API relativamente nova requestAnimationFrame é a forma recomendada de implementar aplicativos interativos no navegador. Em vez de dar o comando para que o navegador renderize a uma taxa de atualização fixa, solicite sutilmente ao navegador que chame a sua rotina de renderização e seja chamado quando o navegador estiver disponível. Um efeito colateral útil é que, se a página não estiver no primeiro plano, o navegador é inteligente o suficiente para não renderizar.

O retorno de chamada de requestAnimationFrame tem como meta uma taxa de retorno de 60 FPS, mas não há como garanti-la. Por isso, você deve controlar o tempo decorrido desde a última renderização. Isso deve ser algo do tipo:

var x = 100;
var y = 100;
var lastRender = new Date();
function render() {
  var delta = new Date() - lastRender;
  x += delta;
  y += delta;
  context.fillRect(x, y, W, H);
  requestAnimationFrame(render);
}
render();

Esse uso do requestAnimationFrame serve para o canvas e também para outras tecnologias de renderização, como WebGL.

Até a data deste artigo, essa API está disponível somente no Google Chrome, Safari e Firefox, por isso você deve usar esta correção (link em inglês).

A maioria das implementações de canvas móveis é lenta

Passemos agora aos dispositivos móveis. Infelizmente, até a data deste artigo, apenas o iOS 5.0 beta executando Safari 5.1 tinha a implementação de canvas acelerado para dispositivos móveis. Sem a aceleração GPU, os navegadores móveis não têm, normalmente, CPUs com capacidade o suficiente para aplicativos modernos baseados em canvas. Vários testes de JSPerf descritos acima tiveram uma ordem de grandeza pior nos dispositivos móveis do que nos computadores desktop, o que restringe bastante os tipos de aplicativos para vários tipos de dispositivo que é possível executar corretamente.

Conclusão

Recapitulando, este artigo apresentou um conjunto amplo de técnicas úteis de otimização para desenvolver projetos baseados em canvas em HTML5 com bom desempenho. Agora ponha em prática o que aprendeu aqui, otimizando suas criações magníficas. Ou então, se ainda não tiver um jogo ou aplicativo para otimizar, acesse Chrome Experiments (link em inglês) e Creative JS (link em inglês) para se inspirar.

Referências

  • Modo imediato x modo retido (link em inglês).
  • Outros artigos sobre canvas (link em inglês) do HTML5Rocks.
  • Seção Canvas (link em inglês) do Dive into HTML5.
  • O JSPerf (link em inglês) permite que os desenvolvedores criem testes de desempenho em JS.
  • O Browserscope (link em inglês) armazena os dados de desempenho do navegador.
  • O JSPerfView (link em inglês) renderiza os testes do JSPerf como gráficos.
  • Postagem do blog (link em inglês) do Simon sobre limpeza do canvas.
  • Postagem do blog do Sebastian (link em inglês) sobre o desempenho da renderização de subpixel.
  • Postagem do blog do Paul (link em inglês) sobre o uso do requestAnimationFrame.
  • Palestra do Ben (link em inglês) sobre a otimização de um emulador JS NES.

Comments

0