Como melhorar o desempenho da tela HTML5

Introdução

O canvas HTML5, que começou como um experimento da Apple, é o padrão mais amplamente aceito para gráficos de modo imediato 2D na Web. Muitos desenvolvedores agora contam com ele para uma ampla variedade de projetos multimídia, visualizações e jogos. No entanto, à medida que a complexidade dos aplicativos que criamos aumenta, os desenvolvedores inadvertentemente atingem o limite de desempenho. Há muitas informações desconexas sobre como otimizar a performance da tela. O objetivo deste artigo é consolidar um pouco desse corpo em um recurso mais fácil de entender para os desenvolvedores. Este artigo inclui otimizações fundamentais que se aplicam a todos os ambientes de gráficos de computador, bem como técnicas específicas da tela que estão sujeitas a mudanças à medida que as implementações da tela são aprimoradas. Mais especificamente, à medida que os fornecedores de navegadores implementam a aceleração de GPU de canvas, algumas das técnicas de desempenho descritas provavelmente se tornarão menos impactantes. Isso será registrado quando apropriado. Este artigo não aborda o uso da tela HTML5. Para isso, confira estes artigos relacionados à tela no HTML5Rocks, este capítulo no site "Dive into HTML5" ou o tutorial do MDN Canvas.

Teste de desempenho

Para lidar com o mundo em constante mudança da tela HTML5, os testes do JSPerf (jsperf.com) verificam se todas as otimizações propostas ainda funcionam. O JSPerf é um aplicativo da Web que permite que os desenvolvedores programem testes de desempenho do JavaScript. Cada teste se concentra em um resultado que você está tentando alcançar (por exemplo, limpar a tela) e inclui várias abordagens que alcançam o mesmo resultado. O JSPerf executa cada abordagem o máximo de vezes possível em um curto período e fornece um número estatisticamente significativo de iterações por segundo. Pontuações mais altas são sempre melhores. Os visitantes de uma página de teste de desempenho do JSPerf podem executar o teste no navegador e permitir que o JSPerf armazene os resultados de teste normalizados no Browserscope (browserscope.org). Como as técnicas de otimização neste artigo são respaldadas por um resultado do JSPerf, você pode voltar para conferir informações atualizadas sobre se a técnica ainda se aplica ou não. Criei um pequeno aplicativo auxiliar que renderiza esses resultados como gráficos, incorporado ao longo deste artigo.

Todos os resultados de desempenho neste artigo são baseados na versão do navegador. Isso acaba sendo uma limitação, já que não sabemos em qual SO o navegador estava sendo executado ou, mais importante, se a tela HTML5 foi acelerada por hardware quando o teste de desempenho foi executado. Para descobrir se a tela HTML5 do Chrome está acelerada por hardware, acesse about:gpu na barra de endereço.

Pré-renderização para uma tela fora da tela

Se você estiver redesenhando primitivas semelhantes na tela em vários frames, como costuma acontecer ao programar um jogo, é possível fazer grandes ganhos de desempenho com a renderização prévia de grandes partes da cena. A pré-renderização significa usar uma tela fora da tela (ou telas) separada para renderizar imagens temporárias e, em seguida, renderizar as telas fora da tela de volta para a visível. Por exemplo, suponha que você esteja redesenhando o Mario correndo a 60 frames por segundo. Você pode redesenhar o chapéu, o bigode e o "M" em cada frame ou renderizar Mario antes de executar a animação. sem pré-renderização:

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

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 em mais detalhes em uma seção posterior.

Essa técnica é especialmente eficaz quando a operação de renderização (drawMario no exemplo acima) é cara. Um bom exemplo disso é a renderização de texto, que é uma operação muito cara.

No entanto, a performance ruim do caso de teste "pre-renderizado solto". Ao fazer a pré-renderização, é importante garantir que a tela temporária se encaixe perfeitamente na imagem que você está desenhando. Caso contrário, o ganho de desempenho da renderização fora da tela será compensado pela perda de desempenho da cópia de uma tela grande para outra (que varia em função do tamanho do destino de origem). Uma tela justa no teste acima é simplesmente menor:

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

Em comparação com a configuração solta, que gera uma performance pior:

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

Chamadas de tela em lote juntas

Como a renderização é uma operação cara, é mais eficiente carregar a máquina de estado de renderização com um longo conjunto de comandos e, em seguida, descarregá-los todos no buffer de vídeo.

Por exemplo, ao desenhar várias linhas, é mais eficiente criar um caminho com todas as linhas e desenhá-lo com uma única chamada de desenho. 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 um desempenho melhor ao desenhar 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 se aplica ao mundo da tela HTML5. Ao desenhar um caminho complexo, por exemplo, é melhor colocar todos os pontos no caminho, em vez de renderizar os segmentos separadamente (jsperf).

No entanto, com a tela, há uma exceção importante a essa regra: se as primitivas envolvidas na exibição do objeto desejado tiverem pequenas caixas de limite (por exemplo, linhas horizontais e verticais), pode ser mais eficiente renderizar essas primitivas separadamente (jsperf).

Evitar mudanças desnecessárias de estado da tela

O elemento de tela HTML5 é implementado em cima de uma máquina de estados que rastreia coisas como estilos de preenchimento e traço, bem como pontos anteriores que compõem o caminho atual. Ao tentar otimizar a performance gráfica, é tentador se concentrar apenas na renderização gráfica. No entanto, manipular a máquina de estados também pode gerar um overhead de desempenho. Se você usar várias cores de preenchimento para renderizar uma cena, por exemplo, será mais barato renderizar por cor do que por posicionamento na tela. Para renderizar um padrão de listras finas, você pode renderizar uma faixa, mudar as cores, renderizar a próxima faixa etc.:

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

Ou renderize 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);
}

Como esperado, a abordagem entrelaçada é mais lenta porque mudar a máquina de estados é caro.

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

Como seria de se esperar, renderizar menos na tela é mais barato do que renderizar mais. Se você tiver apenas diferenças incrementais entre as recriações, poderá ter um aumento significativo de desempenho apenas desenhando a diferença. Em outras palavras, em vez de limpar a tela inteira antes de desenhar:

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

Monitore a caixa delimitadora desenhada e limpe apenas ela.

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

Se você já conhece gráficos de computador, talvez também conheça essa técnica como "regiões de redesenho", em que a caixa de limite renderizada anteriormente é salva e limpa em cada renderização. Essa técnica também se aplica a contextos de renderização baseados em pixels, como ilustrado nesta palestra sobre emuladores Nintendo em JavaScript.

Usar várias telas em camadas para cenas complexas

Como mencionado anteriormente, renderizar imagens grandes é caro e deve ser evitado, se possível. Além de usar outra tela para renderizar fora da tela, conforme ilustrado na seção de pré-renderização, também podemos usar telas sobrepostas umas às outras. Ao usar a transparência na tela em primeiro plano, podemos contar com a GPU para compor os alfas juntos no momento da renderização. Você pode configurar isso da seguinte maneira, com duas telas posicionadas de forma absoluta, uma sobre a outra.

<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 de ter apenas uma tela aqui é que, quando desenhamos ou limpamos a tela em primeiro plano, nunca modificamos o plano de fundo. Se o jogo ou app multimídia puder ser dividido em primeiro e segundo plano, renderize-os em telas separadas para ter um aumento significativo no desempenho.

Muitas vezes, é possível aproveitar a percepção humana imperfeita e renderizar o segundo plano apenas uma vez ou em uma velocidade mais lenta em comparação com o primeiro plano, que provavelmente vai ocupar a maior parte da atenção do usuário. Por exemplo, é possível renderizar o primeiro plano sempre que você renderizar, mas renderizar o segundo plano apenas a cada N frames. Além disso, essa abordagem é generalizada para qualquer número de telas compostas se o aplicativo funcionar melhor com esse tipo de estrutura.

Evite o shadowBlur

Como muitos outros ambientes gráficos, a tela HTML5 permite que os desenvolvedores desfoquem primitivas, mas essa operação pode ser muito cara:

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

Conheça várias maneiras de limpar a tela

Como a tela HTML5 é um paradigma de desenho de modo imediato, a cena precisa ser redesenhada explicitamente em cada frame. Por isso, limpar a tela é uma operação fundamental para apps e jogos de tela HTML5. Como mencionado na seção Evitar mudanças de estado da tela, não é recomendável limpar toda a tela, mas se você precisa fazer isso, há duas opções: chamar context.clearRect(0, 0, width, height) ou usar uma invasão específica de tela: canvas.width = canvas.width. No momento, clearRect geralmente supera a versão de redefinição de largura, mas, em alguns casos, usar a invasão de redefinição de canvas.width é significativamente mais rápida no Chrome 14.

Tenha cuidado com essa dica, já que ela depende muito da implementação da tela e está muito sujeita a mudanças. Para mais informações, consulte o artigo de Simon Sarris sobre como limpar a tela.

Evite coordenadas de ponto flutuante

A tela HTML5 oferece suporte à renderização de subpixels, e não há como desativá-la. Se você desenhar com coordenadas que não são números inteiros, o anti-aliasing será usado automaticamente para tentar suavizar as linhas. Confira o efeito visual, retirado deste artigo sobre desempenho da tela de subpixels por Seb Lee-Delisle:

Subpixel

Se o sprite suavizado não for o efeito que você procura, pode ser muito mais rápido converter suas coordenadas em números inteiros usando Math.floor ou Math.round (jsperf):

Para converter suas coordenadas de ponto flutuante em números inteiros, você pode usar várias técnicas inteligentes. A mais eficiente é adicionar metade ao número de destino e, em seguida, realizar 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;

Confira o detalhamento completo de desempenho (jsperf).

Esse tipo de otimização não importa mais quando as implementações de canvas são aceleradas por GPU, o que pode renderizar rapidamente coordenadas não inteiras.

Otimize suas animações com requestAnimationFrame

A API requestAnimationFrame, relativamente nova, é a maneira recomendada de implementar aplicativos interativos no navegador. Em vez de comandar o navegador para renderizar em uma taxa de tick fixa específica, você pede educadamente ao navegador para chamar sua rotina de renderização e ser chamado quando o navegador estiver disponível. Como efeito colateral, se a página não estiver em primeiro plano, o navegador é inteligente o suficiente para não renderizar. O callback requestAnimationFrame tem como objetivo uma taxa de callback de 60 QPS, mas não a garante. Portanto, você precisa acompanhar quanto tempo passou desde a última renderização. Isso pode ficar mais ou menos assim:

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

Esse uso de requestAnimationFrame se aplica à tela e a outras tecnologias de renderização, como o WebGL. No momento da escrita, essa API está disponível apenas no Chrome, Safari e Firefox. Use este shim.

A maioria das implementações de telas em dispositivos móveis é lenta

Vamos falar sobre dispositivos móveis. Infelizmente, no momento da escrita, apenas o iOS 5.0 Beta com o Safari 5.1 tem implementação de tela móvel com aceleração de GPU. Sem a aceleração da GPU, os navegadores para dispositivos móveis geralmente não têm CPUs potentes o suficiente para aplicativos modernos baseados em tela. Vários dos testes do JSPerf descritos acima têm um resultado muito pior em dispositivos móveis em comparação com computadores, restringindo bastante os tipos de apps entre dispositivos que você pode executar.

Conclusão

Para recapitular, este artigo abordou um conjunto abrangente de técnicas úteis de otimização que vão ajudar você a desenvolver projetos baseados em tela HTML5 com bom desempenho. Agora que você aprendeu algo novo, otimizar suas criações incríveis. Ou, se você não tiver um jogo ou aplicativo para otimizar, confira os Chrome Experiments e o Creative JS para se inspirar.

Referências