Acelerando gráficos usando OffscreenCanvas

A renderização de gráficos pode afetar seriamente o navegador. Especialmente quando se trata de produzir uma infinidade de elementos representando diagramas na interface de um aplicativo complexo. Você pode tentar melhorar a situação usando a interface OffscreenCanvas, cujo nível de suporte está aumentando gradualmente. Ele permite, usando um trabalhador da Web, mudar para ele as tarefas de formar uma imagem exibida em um elemento visível <canvas>. O artigo, cuja tradução estamos publicando hoje, é dedicado ao uso da interface . Aqui, falaremos sobre por que essa interface pode ser necessária, sobre o que realmente pode ser esperado de seu uso e sobre quais dificuldades podem surgir ao trabalhar com ela.



OffscreenCanvas

Por que prestar atenção ao OffscreenCanvas?


O código envolvido na renderização do diagrama pode ter uma complexidade computacional suficientemente alta. Em muitos lugares, você pode encontrar histórias detalhadas sobre como produzir animações suaves e para garantir uma interação conveniente do usuário com a página, é necessário que a renderização de um quadro caiba em cerca de 10 ms, o que permite atingir 60 quadros por segundo. Se os cálculos necessários para a saída de um quadro demorar mais de 10 ms, isso resultará em "lentidão" perceptível na página.

Ao exibir diagramas, no entanto, a situação é agravada pelo seguinte:

  • — - (SVG, canvas, WebGL) , , .
  • , , , , 10 . ( — ) .

Esses problemas são candidatos ideais para uma abordagem de otimização clássica chamada dividir e conquistar. Nesse caso, estamos falando sobre a distribuição da carga de computação em vários segmentos. No entanto, antes do aparecimento da interface, OffscreenCanvastodo o código de renderização precisava ser executado no encadeamento principal. A única maneira de esse código usar as APIs necessárias.

Do ponto de vista técnico, cálculos "pesados" poderiam ser enviados para um encadeamento de trabalhadores da Web anteriormente. Porém, como as chamadas responsáveis ​​pela renderização precisavam ser feitas no fluxo principal, isso exigia o uso de esquemas complexos de troca de mensagens entre o fluxo de trabalho e o fluxo principal no código de saída do gráfico. Além disso, essa abordagem geralmente oferece apenas um pequeno ganho de desempenho.

EspecificaçãoOffscreenCanvasnos fornece um mecanismo para transferir o controle de superfície para exibir elementos gráficos <canvas>em um trabalhador da Web. Atualmente, esta especificação é suportada pelos navegadores Chrome e Edge (após a migração do Edge para o Chromium). Espera-se que no Firefox o suporte OffscreenCanvasapareça dentro de seis meses. Se falamos sobre o Safari, ainda não se sabe se o suporte para esta tecnologia está planejado neste navegador.

Saída do gráfico usando OffscreenCanvas



Um exemplo de gráfico que exibe 100.000 pontos multicoloridos

Para avaliar melhor as vantagens e desvantagens em potencialOffscreenCanvas, vejamos um exemplo da saída do diagrama "pesado" mostrado na figura anterior.

const offscreenCanvas = canvasContainer
  .querySelector('canvas')
  .transferControlToOffscreen();
const worker = new Worker('worker.js');
worker.postMessage({ offscreenCanvas }, [offscreenCanvas]);

Primeiro, consultamos OffscreenCanvaso elemento canvasusando o novo método transferControlToOffscreen(). Em seguida, chamamos o método worker.postMessage({ canvas: offscreenCanvas }, [offscreenCanvas]), fazendo isso para enviar uma mensagem ao trabalhador que contém um link para offscreenCanvas. É muito importante que aqui, como segundo argumento, você precise usar [offscreenCanvas]. Isso permite que você faça o proprietário desse objeto trabalhador, o que permitirá que ele gerencie sozinho OffscreenCanvas.

canvasContainer.addEventListener('measure', ({ detail }) => {
  const { width, height } = detail;
  worker.postMessage({ width, height });
});
canvasContainer.requestRedraw();

Considerando que os tamanhos foram OffscreenCanvasoriginalmente herdados dos atributos widthe do heightelemento canvas, é nossa responsabilidade manter esses valores atualizados quando o elemento for redimensionado canvas. Aqui usamos o evento measurefrom d3fc-canvas, que nos dará a oportunidade, de acordo com requestAnimationFrame, de transmitir ao trabalhador informações sobre as novas dimensões do elemento canvas.

Para simplificar o exemplo, usaremos componentes da biblioteca d3fc . Este é um conjunto de componentes auxiliares que agregam componentes d3 ou complementam sua funcionalidade. Você OffscreenCanvaspode trabalhar sem os componentes d3fc. Tudo o que será discutido pode ser feito usando recursos JavaScript exclusivamente padrão.

Agora vá para o código do arquivo worker.js. Neste exemplo, para obter um aumento real no desempenho da renderização, usaremos o WebGL.

addEventListener('message', ({ data: { offscreenCanvas, width, height } }) => {
    if (offscreenCanvas != null) {
        const gl = offscreenCanvas.getContext('webgl');
        series.context(gl);
        series(data);
    }

    if (width != null && height != null) {
        const gl = series.context();
        gl.canvas.width = width;
        gl.canvas.height = height;
        gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
    }
});

Quando recebemos uma mensagem contendo uma propriedade canvas, assumimos que a mensagem veio do thread principal. Em seguida, obtemos o canvascontexto do elemento webgle passamos para o componente series. Em seguida, chamamos o componente series, passando os dados ( data), que queremos renderizar usando (abaixo, falaremos sobre de onde as variáveis ​​correspondentes vieram ).

Além disso, verificamos as propriedades widthe height, que vieram na mensagem, e as usamos para definir as dimensões offscreenCanvase a área de visualização do WebGL. O link offscreenCanvasnão é usado diretamente. O fato é que a mensagem contém uma propriedade offscreenCanvasou propriedades widthe height.

O código de trabalho restante é responsável por configurar a renderização do que queremos exibir. Não há nada de especial aqui; portanto, se tudo isso lhe é familiar, você pode prosseguir imediatamente para a próxima seção na qual discutiremos o desempenho.

const randomNormal = d3.randomNormal(0, 1);
const randomLogNormal = d3.randomLogNormal();

const data = Array.from({ length: 1e5 }, () => ({
    x: randomNormal(),
    y: randomNormal(),
    size: randomLogNormal() * 10
}));

const xScale = d3.scaleLinear().domain([-5, 5]);

const yScale = d3.scaleLinear().domain([-5, 5]);

Primeiro, criamos um conjunto de dados contendo as coordenadas dos pontos distribuídos aleatoriamente em torno da origem x / y e ajustamos a escala. Não definimos o intervalo dos valores x e y usando o método range, pois a série WebGL é exibida em tamanho real do elemento canvas (-1 -> +1nas coordenadas normalizadas do dispositivo).

const series = fc
    .seriesWebglPoint()
    .xScale(xScale)
    .yScale(yScale)
    .crossValue(d => d.x)
    .mainValue(d => d.y)
    .size(d => d.size)
    .equals((previousData, data) => previousData.length > 0);

Em seguida, configuramos uma série de pontos usando xScalee yScale. Em seguida, configuramos os meios de acesso apropriados que permitem a leitura de dados.

Além disso, definimos nossa própria função de verificação de igualdade, projetada para garantir que o componente não transmita dataGPUs a cada renderização. Devemos expressar isso explicitamente, porque mesmo que saibamos que não modificaremos esses dados, o componente não poderá saber sobre eles sem executar uma verificação "suja" que consome muitos recursos.

const colorScale = d3.scaleOrdinal(d3.schemeAccent);

const webglColor = color => {
    const { r, g, b, opacity } = d3.color(color).rgb();
    return [r / 255, g / 255, b / 255, opacity];
};

const fillColor = fc
    .webglFillColor()
    .value((d, i) => webglColor(colorScale(i)))
    .data(data);

series.decorate(program => {
    fillColor(program);
});

Este código permite colorir o gráfico. Usamos o índice do ponto no conjunto de dados para selecionar uma cor adequada colorScale, depois convertemos para o formato necessário e o usamos para decorar o ponto de saída.

function render() {
    const ease = 5 * (0.51 + 0.49 * Math.sin(Date.now() / 1e3));
    xScale.domain([-ease, ease]);
    yScale.domain([-ease, ease]);
    series(data);
    requestAnimationFrame(render);
}

Agora que os pontos estão coloridos, tudo o que resta é animar o gráfico. Devido a isso, precisaremos de uma chamada de método constante render(). Além disso, isso criará alguma carga no sistema. Simulamos o aumento e a diminuição da escala do gráfico usando requestAnimationFramepara modificar as propriedades xScale.domaine yScale.domaintodos os quadros. Aqui, os valores dependentes do tempo são aplicados e calculados para que a escala do gráfico mude sem problemas. Além disso, modificamos o cabeçalho da mensagem para que um método seja chamado para iniciar o ciclo de renderização render()e para que não seja necessário chamar diretamente series(data).

importScripts(
    './node_modules/d3-array/dist/d3-array.js',
    './node_modules/d3-collection/dist/d3-collection.js',
    './node_modules/d3-color/dist/d3-color.js',
    './node_modules/d3-interpolate/dist/d3-interpolate.js',
    './node_modules/d3-scale-chromatic/dist/d3-scale-chromatic.js',
    './node_modules/d3-random/dist/d3-random.js',
    './node_modules/d3-scale/dist/d3-scale.js',
    './node_modules/d3-shape/dist/d3-shape.js',
    './node_modules/d3fc-extent/build/d3fc-extent.js',
    './node_modules/d3fc-random-data/build/d3fc-random-data.js',
    './node_modules/d3fc-rebind/build/d3fc-rebind.js',
    './node_modules/d3fc-series/build/d3fc-series.js',
    './node_modules/d3fc-webgl/build/d3fc-webgl.js'
);

Para que este exemplo funcione, resta apenas importar as bibliotecas necessárias para o trabalhador. Usamos para isso importScriptse também, para não usar as ferramentas para criar projetos, usamos uma lista de dependências compiladas manualmente. Infelizmente, não podemos apenas fazer o download das compilações completas do d3 / d3fc, pois elas dependem do DOM e o que elas precisam não está disponível no trabalhador.

→ O código completo deste exemplo pode ser encontrado no GitHub

Telas e desempenho


A animação em nosso projeto funciona graças ao uso requestAnimationFramede um trabalhador. Isso permite que o trabalhador processe páginas mesmo quando o segmento principal está ocupado com outras coisas. Se você olhar a página do projeto descrita na seção anterior e clicar no botão Stop main thread, poderá prestar atenção ao fato de que, quando a caixa de mensagem for exibida, a atualização das informações do carimbo de data / hora será interrompida. O segmento principal está bloqueado, mas a animação do gráfico não para.


Janela do projeto

Podemos organizar uma renderização iniciada pelo recebimento de mensagens do fluxo principal. Por exemplo, esses eventos podem ser enviados quando um usuário interage com um gráfico ou ao receber dados atualizados pela rede. Mas com essa abordagem, se o segmento principal estiver ocupado com algo e nenhuma mensagem for recebida no trabalhador, a renderização será interrompida.

A renderização contínua do gráfico em condições em que nada acontece é uma ótima maneira de incomodar o usuário com um ventilador vibrante e bateria fraca. Como resultado, verifica-se que, no mundo real, a decisão sobre como dividir responsabilidades entre o thread principal e o thread de trabalho depende se o trabalhador pode render algo útil quando não recebe mensagens sobre uma mudança na situação do segmento principal.

Se falamos sobre a transferência de mensagens entre threads, deve-se notar que aqui aplicamos uma abordagem muito simples. Honestamente, precisamos transmitir muito poucas mensagens entre os threads, então prefiro chamar nossa abordagem de consequência do pragmatismo, não de preguiça. Porém, se falarmos sobre aplicativos reais, pode-se observar que o esquema de transferência de mensagens entre os fluxos se tornará mais complicado se a interação do usuário com o diagrama e a atualização dos dados visualizados depender deles.

Você pode usar a interface MessageChannel padrão para criar canais de mensagens separados entre o thread principal e o thread de trabalho .. Cada um desses canais pode ser usado para uma categoria específica de mensagens, o que facilita o processamento de mensagens. Como alternativa aos mecanismos padrão, bibliotecas de terceiros como o Comlink podem agir . Esta biblioteca oculta detalhes de baixo nível atrás de uma interface de alto nível usando objetos proxy .

Outra característica interessante do nosso exemplo, que se torna claramente visível em retrospecto, é o fato de levar em consideração o fato de que os recursos da GPU, como outros recursos do sistema, estão longe de serem infinitos. A conversão da renderização em um trabalhador permite que o thread principal resolva outros problemas. Mas o navegador, de qualquer maneira, precisa usar a GPU para renderizar o DOM e o que foi gerado pelos meios OffscreenCanvas.

Se o trabalhador consumir todos os recursos da GPU, a janela principal do aplicativo terá problemas de desempenho, independentemente de onde a renderização é realizada. Preste atenção em como a velocidade de atualização do registro de data e hora no exemplo diminui à medida que o número de pontos exibidos aumenta. Se você possui uma placa gráfica poderosa, pode ser necessário aumentar o número de pontos transmitidos para a página na cadeia de consulta. Para fazer isso, você pode usar um valor que exceda o valor máximo de 100000, especificado usando um dos links na página de exemplo.

Note-se que aqui não exploramos o mundo secreto dos atributos de contexto. Esse estudo poderia nos ajudar a aumentar a produtividade da solução. No entanto, não fiz isso por causa do baixo nível de suporte a esses atributos.

Se falarmos sobre renderização usando OffscreenCanvas, o atributo parecerá mais interessante aqui desynchronized. Ele, onde é suportado, e levando em consideração algumas restrições, permite que você se livre da sincronização entre o loop de eventos e o loop de renderização que é executado no trabalhador. Isso minimiza o atraso na atualização da imagem. Detalhes sobre isso podem ser encontrados aqui .

Sumário


A interface OffscreenCanvasoferece aos desenvolvedores a oportunidade de melhorar o desempenho da renderização do gráfico, mas sua utilização requer uma abordagem cuidadosa. Para usá-lo, é necessário considerar o seguinte:

  • (, , ) - .

    • , . , , , postMessage.
  • , (, SVG/HTML- <canvas>), , , .

    • , , , , , , , , .
  • , GPU, OffscreenCanvas .

    • OffscreenCanvas, GPU-, , . , , .

Aqui está um código de exemplo e aqui está sua versão de trabalho.

Queridos leitores! Você usou o OffscreenCanvas para acelerar a exibição de gráficos nas páginas da web?


All Articles