Image de couverture de l'article Optimisation WebGL, partie 1

L'optimisation dans le but d'atteindre une fluidité suffisante, avec WebGL, n'est pas une affaire simple, voilà notre retour d'expérience ainsi que quelques conseils !

WebGL permet de faire des choses incroyables dans un navigateur. Des démos fusent dans tous les coins des Internets - notamment de Shaders magnifiques (cf shadertoy) montrant le potentiel graphique de la technologie.

Cependant, dans le monde de la production, le visage sympathique qu'avait la démo s'efface peu à peu pour laisser place à la grimace de l'optimisation. Votre compteur de FPS, qui doit devrait rester à 60, affiche un faiblard 25, et vous êtes pourtant sur la meilleure machine de tout le voisinage.

Pas de panique ! On est passés par là, et on va partager ce qu'on a pu apprendre sur le sujet...

Fonctionnement de WebGL : regardons sous le capot

Il est avant tout essentiel de bien comprendre comment votre code est exécuté. Nous allons donc parler du pipeline graphique.

Le pipeline graphique, pour un enfant de 6 ans, c'est 2 blocs : le processeur (CPU) et la carte graphique (GPU).

Pour détailler un peu plus, voici un excellent schéma qui provient de NVIDIA :

Pipeline WebGL
Descriptif simplifié du pipeline WebGL

A noter que ce schéma est une simplification fonctionnelle, vous en trouverez d'autres avec le Vertex Shader et le Fragment Shader, plus concrets.

Une explication brève

La géométrie de vos objets 3D est décrite côté Javascript, à base de sommets (vertices) et de triangles disposés dans l'espace. Son aspect visuel est aussi décrit, selon le framework que vous utilisez (ou pas pour les barbus). On appelle souvent cela des materials.

Tout ce beau monde est ensuite découpé par votre framework (ou à la main pour les imberbes), et envoyé sur la carte graphique (GPU). Cette dernière se charge de stocker la géométrie, puis de la discrétiser dans l'espace écran (rasterisation). Cette opération est représentée par le trait rouge sur le schéma, c'est le moment où on passe des objets mathématiques à des pixels réels sur l'écran.

A partir de là, la finalisation de l'image se fait, avec des opérations sur les pixels, ou plus exactement sur les fragmentspuis le résultat final est décrit sur un frame buffer qui est un grand tableau de pixels. Une image, en gros..

La fluidité : le Graal

Le but de l'optimisation est que votre application tourne de façon fluide, dans l'idéal à 60 FPS. Aller plus haut ne sert à rien dans la majorité des cas, car on dépasse la fréquence de rafraîchissement des écrans. Je vous renvoie à la synchronisation verticale si ça vous intéresse.

60 FPS, ça représente 16 ms par image, et c'est très peu étant donné que vous devez faire tenir TOUT le pipeline ci-dessus dans cet intervalle de temps. Et ce pour TOUS vos objets 3D.

Fort heureusement, les blocs principaux du pipeline fonctionnent de façon indépendante. Vous pouvez faire des opérations sur la partie CPU (Javascript) pendant que la partie GPU en réalise d'autres. Cependant vu que l'on est quand même dans un pipeline, il faut que chaque partie soit nourrie suffisamment en données pour avoir du travail à faire, sinon elle ne fait rien. Et pendant ce temps, les parties en amont sont surchargées de travail. Les ressources sont donc mal utilisées, on perd des performances. Quand cette configuration arrive, on dit alors qu'il y a un goulot d'étranglement, ou bottleneck en anglais.

Le schéma ci-dessous illustre la charge allouée à chaque étape du pipeline précédent :

Capture
Encore un excellent schéma de NVIDIA. Décidément, ils sont très forts...

Le schéma a été découpé grossièrement dans les 3 grandes zones où il est possible d'avoir un goulot d'étranglement, soit au niveau CPU donc Javascript, soit au niveau GPU lors du traitement des sommets (vertices), soit au niveau GPU lors du traitement des pixels.

Il a été aussi découpé plus finement, je parle des blocs verts représentant la charge. Si un de ces blocs devient trop important par rapport à ceux qui le suivent, on a un goulot d'étranglement.

Dans ce premier article, nous traiterons uniquement des bottlenecks portant sur le premier bloc, c'est à dire le CPU.

Javascript et optimisation des appels WebGL (Pas de troll, promis)

Il est très fréquent que votre application soit limitée au niveau du CPU, et donc que votre GPU se tourne les pouces en attendant. La raison principale de ce phénomène est que vous avez trop d'objets à dessiner. Comme nous l'avons vu plus haut, votre code JS se charge de tout ce qui est description de vos géométries dans l'espace ainsi que de leur aspect visuel. Votre CPU va envoyer toutes ces informations au GPU qui se chargera de les dessiner.

Il est également très probable que vous utilisiez un framework comme THREE.js ou babylon.js pour gérer les objets de votre scène 3D. Cela facilite grandement votre travail de développeur, car le framework gère automatiquement les appels WebGL, tels que le transfert des géométries et des materiaux vers la carte graphique.

Cependant, ces frameworks, même s'ils possèdent un système de cache pour éviter au maximum la redondance de ces appels WebGL, vont quand même effectuer un trop grand nombre de ces couteux appels.

Grossièrement, pour chaque objet, un nombre fixe d'appels WebGL est effectué pour transférer les informations au GPU. Donc pour objets, vous avez fois ce nombre d'appels à réaliser par le CPU.

Quand WebGL est limité par le CPU

Comment savoir si mon application WebGL est limitée côté CPU ?

On peut utiliser le profiler JS des navigateurs. Sous Chrome, dans la console de développement, onglet "Profiles", le bouton rouge pour enregistrer, on obtient ceci :

Capture

Selon les frameworks, le nom des fonctions va changer. Ce qu'il est intéressant de remarquer, c'est que toute la charge du CPU est utilisée par les traitements javascript, il ne fournit plus assez vite de travail aux étapes suivantes du pipeline, on a une diminution du framerate. 35 FPS sur cet exemple, ce qui veut dire qu'à cause de la lenteur du CPU, une image prend plus de 16 ms à se réaliser.

Voilà ce qui se passe si on lui libère de la charge, le profil d'une application non-CPU-bound ressemble à ceci :

Capture

On remarque le (idle) qui prend 57,64 % du temps processeur. Ce qui signifie qu'il se tourne les transistors plus de la moitié du temps : il n'est pas surchargé. Les étapes suivantes peuvent travailler à plein régime et on arrive donc à réaliser une image dans la limite des 16 ms.

Réduire la charge de travail

C'est bien beau de savoir que notre CPU est engorgé, mais comment réduire sa charge ?

Je vous dirais bien de réduire le nombre d'objets dessinés, mais je l'ai déjà dit. De plus, vous êtes tenaces, vous avez envie de dessiner les 500 morceaux de bois de cette caisse détruite au pied de biche, pas un de moins.

Et bien vous pouvez ! A certaines conditions bien sûr...

Il suffit tout simplement de dessiner les objets en même temps, en les fusionnant en un seul objet, de façon à n'appeler qu'une seule fois les fonctions WebGL. Cette technique s'appelle le draw call batching. 

Melting pot : fusionner pour optimiser

Pour fusionner N objets, il faut prendre toutes les informations de géométrie et les regrouper dans un seul objet, qu'on appelle un batch. Ce batch sera ensuite dessiné en une fois par la boucle de rendu de votre framework préféré. Cette technique présente cependant 2 limitations majeures, et pas des moindres :

  • Tous les objets du batch doivent être statiques entre eux. Une fois fusionnés, on ne peut plus déplacer que le batch globalement, donc plus de déplacement interne au batch, sous peine de le payer très cher.

  • Tous les objets du batch doivent partager le même aspect, ou en d'autre termes, le même material. Un draw call implique un seul vertex shader et un seul fragment shader, pas possible de changer au milieu !

Ces limitations sont lourdes, si bien que tous vos objets ne se prêtent pas au batching.

Appliquer efficacement le batching

Sur des objets statiques

Le batching se prête très bien aux objets statiques. En effet, si vous savez que vos objets ne bougeront pas, il est simple de les fusionner à la construction de la scène. Mais quid de leur aspect ? Un maillage ne peut avoir qu'un seul shader, comme évoqué ci-dessus..

La solution est en 2 parties :

1) Regroupez vos textures dans une texture géante, appelée atlas. Une fois fusionnées, vous avez juste à redéfinir les coordonnées (u,v) de vos vertices pour qu'elles pointent vers la coordonnée voulue dans l'atlas. Voici un excellent article qui explique cette technique, son implémentation, et les embûches à éviter, notamment au niveau des mip-maps.

2) Vous devez faire en sorte que vos objets statiques aient au maximum le même aspect. La terre réfléchit la lumière quasiment de la même façon que l'arbre ou que le crépi de la maison. De même, toutes les vitres sont transparentes de la même façon.

Si vous suivez ces directives, vous allez pouvoir fusionner votre terrain dans une seule mesh, et appliquer l'atlas dessus. Ainsi, vous aurez en général moins de 10 mesh à dessiner pour rendre tout les objets statiques, vous économisez énormément de temps CPU.

 

Sur des objets semi-statiques

J'appelle objet semi-statique un objet qui ne change de position ou d'aspect que très occasionnellement. Vous avez énormément d'objets semi-statiques dans des logiciels d'édition 3D. En effet ces objets changent uniquement quand l'utilisateur le spécifie explicitement. Vous pouvez alors dé-fusionner et re-fusionner ces objets sur action de l'utilisateur. Il y aura forte consommation de CPU à ce moment là, le temps de fusionner la géométrie et les éventuelles textures dans l'atlas. Mais vu qu'on ne le fait qu'une fois, on peut le cacher subtilement derrière une petite roue dentée qui tourne...

 

Sur des petits objets dynamiques partageant le même material

Si les objets sont suffisamment petits, typiquement de l'ordre de la centaine de vertices, il est parfois plus rentable de les fusionner, puis de mettre à jour les informations des vertices à chaque déplacement. Ceci est très adapté à l'exemple des morceaux de bois éclatés. Ces petits morceaux se résument tout au plus à 20 vertices, il n'est donc pas très couteux de changer la position de ces 20 vertices à chaque déplacement

 

En bref : les commandements de la fluidité

Voici les principales directives à suivre pour diminuer significativement la charge de votre processeur :

  • Limitez le nombre de matériaux différents, ce petit écrou caché sous la voiture a-t-il vraiment besoin d'un aspect chromé particulier ?

  • Tous vos objets statiques (murs, terrain, poteaux, arbres...) sont fusionnés dans une seule mesh. Si vous devez appliquer des textures dessus, elles se trouvent toutes dans un atlas construit spécialement pour cette mesh.

  • De la même façon, fusionnez au maximum vos meshes si vous savez qu'elles vont avoir le même mouvement.

  • Pour des objets semi-statiques, vous pouvez prendre le temps de les sortir des batches pour les réintégrer par la suite.

  • Même combat pour des petits objets dynamiques, il est parfois plus rentable d'itérer sur un petit nombre de vertices que d'appeler les fonctions WebGL de rendu.

    D'autres commandements non évoqués dans cet article :

  • Si vous avez beaucoup d'objets dynamiques identiques, vous pouvez utiliser les instances, à condition que l'extension WebGL ANGLE_instanced_arrays soit activée.

  • Ne dessinez pas les objets qui n'ont pas besoin d'être dessinés, ceux qui sont invisibles pour la caméra, ou ceux qui sont cachés par d'autres. En général les frameworks s'occupent automatiquement de ne pas traiter les objets qui ne sont pas dans le champ de vision (frustrum culling), mais pour pousser l'optimisation plus loin, on peut mettre en place un système de portails. Cette technique est particulièrement adaptée pour des scènes d'intérieur.

Commentaires

comment mettre à jour WebGL sur inspiron 1525? merci de votre réponse

cordialement