Accélérer les graphiques en utilisant OffscreenCanvas

Le rendu des graphiques peut sérieusement affecter le navigateur. Surtout quand il s'agit de sortir une multitude d'éléments représentant des diagrammes dans l'interface d'une application complexe. Vous pouvez essayer d'améliorer la situation en utilisant l'interface OffscreenCanvas, le niveau de support dont les navigateurs augmentent progressivement. Il permet, en utilisant un web-travailleur, de lui confier les tâches de formation d'une image affichée dans un élément visible <canvas>. L'article, dont nous publions aujourd'hui la traduction, est consacré à l'utilisation de l'interface . Ici, nous expliquerons pourquoi cette interface peut être nécessaire, ce que l'on peut vraiment attendre de son application et quelles difficultés peuvent survenir lors de son utilisation.



OffscreenCanvas

Pourquoi faire attention à OffscreenCanvas?


Le code impliqué dans le rendu du diagramme peut lui-même avoir une complexité de calcul suffisamment élevée. Dans de nombreux endroits, vous pouvez trouver des histoires détaillées sur la façon de produire une animation fluide et pour assurer une interaction pratique de l'utilisateur avec la page, il est nécessaire que le rendu d'une image tienne dans environ 10 ms, ce qui vous permet d'atteindre 60 images par seconde. Si les calculs requis pour produire une trame prennent plus de 10 ms, cela entraînera des "ralentissements" notables de la page.

Cependant, lors de l'affichage des diagrammes, la situation est aggravée par les éléments suivants:

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

Ces problèmes sont des candidats idéaux pour une approche d'optimisation classique appelée diviser pour mieux régner. Dans ce cas, nous parlons de la distribution de la charge de calcul sur plusieurs threads. Cependant, avant l'apparition de l'interface, OffscreenCanvastout le code de rendu devait être exécuté dans le thread principal. La seule façon dont ce code pouvait utiliser les API dont il avait besoin.

D'un point de vue technique, des calculs «lourds» pourraient être envoyés à un thread de travail Web plus tôt. Mais, comme les appels responsables du rendu devaient être effectués à partir du flux principal, cela nécessitait l'utilisation de schémas d'échange de messages complexes entre le flux de travail et le flux principal dans le code de sortie du graphique. De plus, cette approche n'apportait souvent qu'un léger gain de performances.

spécificationOffscreenCanvasnous donne un mécanisme pour transférer le contrôle de surface pour afficher les graphiques des éléments <canvas>dans un travailleur Web. Cette spécification est actuellement prise en charge par les navigateurs Chrome et Edge (après la migration d'Edge vers Chromium). Il est prévu que le support de Firefox OffscreenCanvasapparaisse dans les six mois. Si nous parlons de Safari, on ne sait toujours pas si la prise en charge de cette technologie est prévue dans ce navigateur.

Sortie de graphique en utilisant OffscreenCanvas



Un exemple de graphique qui affiche 100 000 points multicolores

Afin de mieux évaluer les avantages et les inconvénients potentielsOffscreenCanvas, regardons un exemple de sortie du diagramme "lourd" montré dans la figure précédente.

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

Tout d'abord, nous interrogeons OffscreenCanvasl'élément canvasà l'aide de la nouvelle méthode transferControlToOffscreen(). Ensuite, nous appelons la méthode worker.postMessage({ canvas: offscreenCanvas }, [offscreenCanvas]), en faisant cela pour envoyer un message au travailleur contenant un lien vers offscreenCanvas. Il est très important qu'ici, comme deuxième argument, vous deviez utiliser [offscreenCanvas]. Cela vous permet de rendre le propriétaire de cet objet travailleur, ce qui lui permettra de gérer seul OffscreenCanvas.

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

Étant donné que les tailles ont OffscreenCanvasété héritées à l'origine des attributs widthet de l' heightélément canvas, il est de notre responsabilité de maintenir ces valeurs à jour lorsque l'élément est redimensionné canvas. Nous utilisons ici l'événement measurede d3fc-canvas, qui nous donnera l'occasion, en accord avec requestAnimationFrame, de transmettre au travailleur des informations sur les nouvelles dimensions de l'élément canvas.

Afin de simplifier l'exemple, nous utiliserons des composants de la bibliothèque d3fc . Il s'agit d'un ensemble de composants auxiliaires qui agrègent les composants d3 ou complètent leurs fonctionnalités. Vous OffscreenCanvaspouvez travailler avec sans composants d3fc. Tout ce qui sera discuté peut être fait en utilisant exclusivement des fonctionnalités JavaScript standard.

Maintenant, allez dans le code du fichier worker.js. Dans cet exemple, pour une réelle augmentation des performances de rendu, nous allons utiliser 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);
    }
});

Lorsque nous recevons un message contenant une propriété canvas, nous supposons que le message provient du thread principal. Ensuite, nous obtenons le canvascontexte de l'élément webglet le transmettons au composant series. Ensuite, nous appelons le composant series, en lui passant les données ( data), que nous voulons rendre en l'utilisant (ci-dessous, nous parlerons de l'origine des variables correspondantes).

De plus, nous vérifions les propriétés widthet height, fournies dans le message, et les utilisons pour définir les dimensions offscreenCanvaset la zone d'affichage de WebGL. Le lien offscreenCanvasn'est pas utilisé directement. Le fait est que le message contient une propriété offscreenCanvasou des propriétés widthet height.

Le code de travail restant est responsable de la configuration du rendu de ce que nous voulons afficher. Il n'y a rien de spécial ici, donc si tout cela vous est familier, vous pouvez immédiatement passer à la section suivante dans laquelle nous discutons des performances.

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]);

Tout d'abord, nous créons un ensemble de données contenant les coordonnées des points répartis aléatoirement autour de l'origine x / y, et ajustons l'échelle. Nous ne définissons pas la plage de valeurs x et y à l'aide de la méthode range, car les séries WebGL sont affichées en taille réelle de l'élément canvas (-1 -> +1dans les coordonnées normalisées de l'appareil).

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);

Ensuite, nous avons configuré une série de points en utilisant xScaleet yScale. Ensuite, nous configurons les moyens d'accès appropriés qui vous permettent de lire les données.

De plus, nous définissons notre propre fonction de vérification d'égalité, qui est conçue pour garantir que le composant ne transmet pas de dataGPU à chaque rendu. Nous devons l'exprimer explicitement, car même si nous savons que nous ne modifierons pas ces données, le composant ne peut pas le savoir sans effectuer une vérification «sale» gourmande en ressources.

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

Ce code vous permet de coloriser le graphique. Nous utilisons l'index du point dans l'ensemble de données pour sélectionner la couleur appropriée colorScale, puis la convertissons au format requis et l'utilisons pour décorer le point de sortie.

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

Maintenant que les points sont colorés, il ne reste plus qu'à animer le graphique. Pour cette raison, nous aurons besoin d'un appel de méthode constant render(). De plus, cela créera une certaine charge sur le système. Nous simulons l'augmentation et la diminution de l'échelle du graphique en utilisant requestAnimationFramepour modifier les propriétés xScale.domainet yScale.domainchaque cadre. Ici, les valeurs dépendantes du temps sont appliquées et calculées de sorte que l'échelle du graphique change en douceur. De plus, nous modifions l'en-tête du message pour qu'une méthode soit appelée pour démarrer le cycle de rendu render(), et pour que nous n'ayons pas à appeler directement 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'
);

Pour que cet exemple fonctionne, il ne reste plus qu'à importer les bibliothèques nécessaires dans l'ouvrier. Nous utilisons pour cela importScripts, et aussi, pour ne pas utiliser les outils de construction de projets, nous utilisons une liste de dépendances compilée manuellement. Malheureusement, nous ne pouvons pas simplement télécharger les versions complètes de d3 / d3fc, car elles dépendent du DOM et ce dont elles ont besoin dans le programme de travail n'est pas disponible.

→ Le code complet de cet exemple se trouve sur GitHub

Toiles et performances


L'animation dans notre projet fonctionne grâce à l'utilisation requestAnimationFramed'un travailleur. Cela permet au travailleur de rendre les pages même lorsque le thread principal est occupé par d'autres choses. Si vous regardez la page du projet décrite dans la section précédente et cliquez sur le bouton Stop main thread, vous pouvez faire attention au fait que lorsque la boîte de message s'affiche, la mise à jour des informations d'horodatage s'arrête. Le thread principal est bloqué, mais l'animation du graphique ne s'arrête pas.


Fenêtre du projet

Nous pourrions organiser un rendu initié en recevant des messages du flux principal. Par exemple, de tels événements peuvent être envoyés lorsqu'un utilisateur interagit avec un graphique ou lorsqu'il reçoit de nouvelles données sur le réseau. Mais avec cette approche, si le thread principal est occupé par quelque chose et qu'aucun message n'est reçu dans l'ouvrier, le rendu s'arrêtera.

Le rendu continu du graphique dans des conditions où rien ne se passe est un excellent moyen d'agacer l'utilisateur avec un ventilateur qui bourdonne et une batterie faible. En conséquence, il s'avère que dans le monde réel, la décision sur la façon de répartir les responsabilités entre le thread principal et le thread de travail dépend de si le travailleur peut rendre quelque chose d'utile lorsqu'il ne reçoit pas de messages sur un changement de situation du fil principal.

Si nous parlons du transfert de messages entre les threads, il convient de noter qu'ici nous avons appliqué une approche très simple. Honnêtement, nous devons transmettre très peu de messages entre les fils, donc je préfère appeler notre approche une conséquence du pragmatisme, pas de la paresse. Mais si nous parlons d'applications réelles, on peut noter que le schéma de transfert des messages entre les flux deviendra plus compliqué si l'interaction de l'utilisateur avec le diagramme et la mise à jour des données visualisées en dépendra.

Vous pouvez utiliser l'interface MessageChannel standard pour créer des canaux de messages séparés entre le thread principal et le thread de travail .. Chacun de ces canaux peut être utilisé pour une catégorie spécifique de messages, ce qui facilite le traitement des messages. Comme alternative aux mécanismes standard, les bibliothèques tierces comme Comlink peuvent agir . Cette bibliothèque cache des détails de bas niveau derrière une interface de haut niveau utilisant des objets proxy .

Une autre caractéristique intéressante de notre exemple, qui devient clairement visible rétrospectivement, est le fait qu'il prend en compte le fait que les ressources GPU, comme les autres ressources système, sont loin d'être infinies. La traduction du rendu en travailleur permet au thread principal de résoudre d'autres problèmes. Mais le navigateur, de toute façon, doit utiliser le GPU pour rendre le DOM et ce qui a été généré par les moyens OffscreenCanvas.

Si le travailleur consomme toutes les ressources GPU, la fenêtre principale de l'application rencontrera des problèmes de performances, quel que soit l'endroit où le rendu est effectué. Faites attention à la façon dont la vitesse de mise à jour de l'horodatage dans l'exemple diminue à mesure que le nombre de points affichés augmente. Si vous avez une carte graphique puissante, vous devrez peut-être augmenter le nombre de points transmis à la page dans la chaîne de requête. Pour ce faire, vous pouvez utiliser une valeur qui dépasse la valeur maximale de 100 000, spécifiée à l'aide de l'un des liens de la page d'exemple.

Il convient de noter qu'ici nous n'avons pas exploré le monde secret des attributs de contexte. Une telle étude pourrait nous aider à augmenter la productivité de la solution. Cependant, je ne l'ai pas fait en le faisant en raison du faible niveau de prise en charge de ces attributs.

Si nous parlons de rendu utilisant OffscreenCanvas, l'attribut semble le plus intéressant ici desynchronized. Il, où il est pris en charge, et en tenant compte de certaines restrictions, vous permet de vous débarrasser de la synchronisation entre la boucle d'événements et la boucle de rendu qui est exécutée dans le travailleur. Cela minimise le délai de mise à jour de l'image. Des détails à ce sujet peuvent être trouvés ici .

Sommaire


L'interface OffscreenCanvasdonne aux développeurs la possibilité d'améliorer les performances de rendu des graphiques, mais son utilisation nécessite une approche réfléchie. En l'utilisant, vous devez tenir compte des éléments suivants:

  • (, , ) - .

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

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

    • OffscreenCanvas, GPU-, , . , , .

Voici un exemple de code, et voici sa version de travail.

Chers lecteurs! Avez-vous utilisé OffscreenCanvas pour accélérer l'affichage des graphiques sur les pages Web?


All Articles