OpenGL, le guide du noob pour développeur Android

Alors comme ça, vous avez décidé de faire de l’OpenGL sous Android ? Avant toute chose, il est important de savoir dans quoi vous mettez les pieds. Vous allez pleurer, supplier, implorer et remettre en question ce que vous pensiez acquis depuis l’école primaire. Rassurez-vous, c’est normal !

‘guardedRun’ est la méthode qui contient tout le code effectué par le thread qui appelle votre Renderer. Oups !

Qu’est-ce qu’OpenGL ?

Pour être sûrs que l’on parle de la même chose, mettons les choses au clair tout de suite. OpenGL est une interface de programmation qui permet de discuter avec le driver graphique de votre appareil. Celui-ci peut-être un téléphone comme il peut être un ordinateur ou une télévision ou tout autre appareil électronique qui supporte OpenGL. Parce que oui, l’appareil doit le supporter. Dans le cadre d’Android, celui-ci supporte :
* OpenGL ES 1.0 et 1.1 depuis Android 1.0 (API 4)
* OpenGL ES 2.0 depuis Android 2.2 (API 8)
* OpenGL ES 3.0 depuis Android 4.3 (API 18) (enfin presque)
* OpenGL ES 3.1 depuis Android 5.0 (API 21)

ES quoi maintenant ? …

Vous vous en doutiez, c’était trop beau pour être vrai, il y a un piège. Android ne supporte pas OpenGL mais OpenGL ES. OpenGL ES est une variante de la spécification OpenGL pour les périphériques intégrés. 
OK, ok ! C’est pas si terrible, il y a des différences mais elles ne sont pas majeures. Ce qui veut dire que du code qui fonctionne sur votre ordinateur ne fonctionnera peut-être pas à l’identique sur un téléphone mais presque.
Ouf !

Le driver graphique ?

“Je croyais qu’Android c’était fait en Java et qu’on n’avait pas besoin de se soucier du matériel sauf si on fait du code natif (C ou C++)”

Vous aviez presque raison. Quand on fait de l’OpenGL, on parle directement avec le driver graphique et donc il est possible que le même code Java ne fonctionne pas de la même manière sur tous les téléphones. Mais il sera encore temps de vous en soucier si et quand ça vous arrivera !

Entrons dans le vif du sujet !

Je ne vais parler ici que d’OpenGL ES 2.0 car c’est celui qui est supporté sur la majeure partie des téléphones (Android 2.2+). Cependant, cela devrait être suffisant pour commencer et vous mettre le pied à l’étrier pour OpenGL ES 3.0 ou 3.1.

Dans les sections suivantes, je vais présenter les points importants et les “gotchas” sans entrer dans le détails d’implémentation. Pour savoir comment mettre tout ça en musique, veuillez vous rendre sur le projet exemple qui accompagne cet article à l’adresse suivante : https://bitbucket.org/Xzan/opengl-example . 
Veuillez noter que tout a été mis dans un seul fichier volontairement pour tenter de faciliter la lecture.
Ou, mieux encore, l’excellent tutoriel du site android : https://developer.android.com/training/graphics/opengl/index.html

GLSurfaceView et le Renderer

Il faut bien commencer quelque part et, en général, il est bien de commencer par le commencement. Pour ne pas vous embrouiller dès le début, on va commencer avec ce qu’il faut savoir pour Android en Java.

Dans notre cas, cela va consister par ajouter une vue dans laquelle on va afficher le résultat de nos commandes OpenGL. Cette vue s’appelle GLSurfaceView et s’occupe de la création d’un thread pour nos commandes OpenGL. 
Vient alors son interface : GLSurfaceView.Renderer qui sera appelée à 3 moments clés du thread OpenGL de GLSurfaceView:

  • onSurfaceCreated(GL10 gl, EGLConfig config)
  • onSurfaceChanged(GL10 gl, int width, int height)
  • onDrawFrame(GL10 gl)

Bien que les 3 méthodes sont assez explicites, il est important de savoir ce que vous allez faire dans chacune d’entre elles.

* Dans onSurfaceCreated, vous allez initialiser vos programmes et vos configurations initiales. Vous pouvez voir cette méthode comme le constructeur d’une View. Cette méthode n’est appelée qu’une seule fois pour le cycle de vue de votre Surface. Mais la Surface peut être détruite et cette méthode sera rappelée quand la prochaine sera créée.

* onSurfaceChanged est un bon endroit pour créer vos textures (on va y revenir aussi) et recréer tout ce qui est dépendant de la taille de votre affichage. Vous pouvez voir cette méthode comme View.onSizeChanged(int w, int h, int oldw, int oldh). Cette méthode est aussi appelée peu souvent.

* Enfin, onDrawFrame est appelée à chaque fois que votre vue doit s’afficher à l’écran, c’est-à-dire très souvent. Vous pouvez la voir comme la méthode View.onDraw(Canvas canvas) et du coup, les bonnes règles de performance s’appliquent aussi (E.g.: n’instanciez pas d’Objets dans cette méthode, etc.).

Bonus : Vous pouvez définir que vous ne souhaitez pas que votre vue se redessine à chaque fois mais uniquement quand elle est “sale”. Pour cela, vous devez appelez GLSurfaceView.setRenderMode(int) avec le paramètre RENDERMODE_WHEN_DIRTY et vous devrez spécifier que votre vue est sale en appelant GLSurfaceView.requestRender().
Attention, rappelez vous que SurfaceView n’est pas une vue comme les autres et qu’elle s’affiche en dessous de votre activité dans laquelle un “trou” permet de voir la Surface. Si vous souhaitez obtenir la même chose dans une vue “plus classique”, utilisez une TextureView. Il n’existe pas de GLTextureView mais vous pouvez trouver une implémentation par Roman Nurik dans Muzei : https://github.com/romannurik/muzei/blob/master/main/src/main/java/com/google/android/apps/muzei/render/GLTextureView.java . En soi, il n’est pas impossible de faire de l’OpenGL sans GLSurfaceView ou GLTextureView mais il est plus simple de les utiliser plutôt que de vous soucier vous même du cycle de vie, au début.

Le fonctionnement d’OpenGL

Maintenant, il est temps de parler d’OpenGL mais avant d’aller plus loin, pour bien comprendre ce qu’il se passe, il est important d’avoir un aperçu du pipeline OpenGL. En d’autres termes, les étapes par lesquelles OpenGL passe pour construire une image à l’écran à partir de valeurs qu’on lui donne.

Dans cette section, je vais utiliser des mots qui vous seront peut-être inconnus ou pas totalement compris tel que shader, fragment ou texture. Ces concepts importants sont expliqués plus loin. N’hésitez pas à relire cette partie une fois que vous aurez fini de lire l’article pour bien comprendre ce qui y est expliqué.
Très belle représentation faite par https://www.ntu.edu.sg/home/ehchua/programming/opengl/CG_BasicsTheory.html
  1. Nous passons au vertex shader des coordonnées de sommets qui les transforme et les passe au “rastériseur”. Ces sommets vont ainsi former des triangles qui sont les briques de base d’une scène 3D.
  2. Celui-ci va remplir le(s) triangle(s) avec des fragments pour qu’il(s) puisse(nt) être affiché(s) à l’écran. Les fragments sont des ensembles d’états qui permettent de calculer les pixels finaux.
  3. Pour chaque fragment, on va appeler le fragment shader afin de lui donner une couleur à afficher à l’écran.
  4. Les données sont ensuite fusionnées pour être affichées à l’écran ou envoyées dans une texture sous forme de pixels.

Les programmes en GLSL (ou shaders)

GLSL est un raccourci pour OpenGL Shader Language et est le nom du language dans lequel on va programmer OpenGL. Le mot clé dans la phrase précédente est “Shader”. Ce mot bizarre que vous avez déjà peut-être entendu, sans jamais avoir réellement compris, est en réalité très simple. C’est une partie du programme OpenGL qui sera exécutée sur le GPU.

De plus, il existe plusieurs types de shaders. Notamment, il en existe deux qui nous intéressent :

  • Le vertex shader : en charge de calculer la position d’affichage. Vertex signifie sommet, en anglais, comme ceux d’un triangle. On passe un tableau d’attributs associés à un point dans l’espace 3D et le vertex shader va en calculer la position à l’écran. Ce tableau d’attributs est le plus souvent composé de coordonnées et d’une couleur (ou de coordonnées d’une texture). Le vertex shader est exécuté une fois par vertex.
  • Le fragment shader : en charge de calculer la couleur de chaque pixel. Il reçoit en entrée la sortie du vertex shader. Ce code est exécuté pour chaque pixel de votre image. En clair, le GPU optimise la plupart de ses appels pour être le plus performant possible pour l’affichage et est capable de calculer la valeur de plusieurs pixels en parallèle. On se représente souvent que le GPU calcule chacun des pixels en même temps, par facilité, mais ce qu’il est important d’en retenir c’est qu’on ne commence pas en haut à gauche de l’écran pour finir en bas à droite. Si vous décidez une information pour le pixel [0,0], au moment de calculer le pixel [0,1] vous n’aurez pas cette information.

Un exemple très simple de ces deux types de shaders peut être trouvé dans le projet exemple :

Vertex shader :
precision mediump float;
uniform mat4 uMVPMatrix;
attribute vec4 vPosition;
attribute vec4 vTextureCoordinate;
varying vec2 position;
void main() {
gl_Position = uMVPMatrix * vPosition;
position = vTextureCoordinate.xy;
}
Fragment shader :
precision mediump float;
uniform sampler2D uTexture;
varying vec2 position;
void main() {
gl_FragColor = texture2D(uTexture, position);
}

Dans le vertex shader, on reçoit 3 paramètres :

  • uMVPMatrix : une matrice qui nous permet de changer l’angle de vue, la rotation et l’échelle.
  • vPosition : les coordonnées des sommets qui vont former notre “strip”.
  • vTextureCoordinate : les coordonnées de texture correspondantes à chacun des vertices (vertex au pluriel).

Dans le fragment shader, on reçoit 2 paramètres :

  • uTexture : texture qui contient l’image que l’on va afficher.
  • position : paramètre reçu du vertex shader qui contient la position du pixel à aller chercher pour l’affichage.

Vous vous en doutez, il existe des shaders beaucoup plus complexes que ceux-là.

Entrez dans la matrice (Systèmes de coordonnées)

Le système de coordonnées d’OpenGL ne tient pas compte de la taille de l’écran, comme le montre l’image ci-dessous :

Image pas du tout honteusement volée à https://developer.android.com/guide/topics/graphics/opengl.html#coordinate-mapping

Il est donc important de passer le ratio et d’autres informations au vertex shader pour palier à cela. C’est notamment une des utilités de cette ligne dans le projet exemple.

Dans le cas de notre projet exemple et dans beaucoup de cas, on va passer les coordonnées en “strip”. Ce qui veut dire qu’on va lui passer les sommets de triangles adjacents formant l’image que l’on souhaite afficher :

Schéma de 4 triangles formés avec les sommets A,B,C,D,E,F provenant de Wikipedia : https://en.wikipedia.org/wiki/Triangle_strip

Dans le projet exemple, on peut voir que le tableau passé est composé de 4 sommets qui partent du coin inférieur gauche, au coin inférieur droit, au coin supérieur gauche et finissent au coin supérieur droit. Les coordonnées de la texture sont légèrement différentes pour respecter l’orientation dans laquelle l’image est chargée.

Aussi, si vous faites un petit peu attention, vous noterez que les coordonnées passées pour la position vont de -1 à 1 tandis que les coordonnées pour les textures vont de 0 à 1. Par ailleurs, les coordonnées de position contiennent la profondeur sous la forme de la coordonnée Z.

Vous noterez aussi que, dans le vertex shader donné en exemple, il y a une multiplication entre uMVPMatrix et vPosition. vPosition est une matrice contenant les coordonnées citées plus haut et uMVPMatrix est une matrice formée grâce aux méthodes utilitaires fournies par la classe Matrix. GLSL est capable de faire des multiplications de matrices et des traitements sur celles-ci de manière très simple et efficace. 
Une autre nuance, j’ai utilisé “vTextureCoordinate.xy”. Cela sert à former un vecteur de taille 2 contenant la première et la deuxième valeur du vecteur de taille 4 qu’est vTextureCoordinate. J’aurais aussi pu faire : “vTextureCoordinate.xx” pour former un vecteur de taille 2 avec chaque fois la première valeur de vTextureCoordinate.

Attention, petite subtilité que vous allez rencontrer en lisant des shaders: pour être plus correct et respecter les conventions de nommage, j’aurais dû utiliser “vTextureCoordinate.st”. STPQ remplace XYZW quand on parle de coordonnées de texture et RGBA quand on parle de couleur. Quoi qu’il en soit, utiliser l’un ou l’autre n’a pas d’influence sur l’exécution du code, juste sur sa lisibilité.

Les textures

Les textures sont des espaces mémoires dans lesquels votre processeur graphique (GPU) va stocker des images. Soit pour les afficher soit pour y écrire des nouvelles images.

Le code pour créer une texture peut être trouvé ici.

Les tampons (FBO)

Un FrameBuffer Object (ou FBO) est un tampon qui va venir se greffer au dessus d’une texture pour pouvoir écrire dedans. Sans FBO, toutes les commandes OpenGL que vous exécuteriez le seraient automatiquement à l’écran. Ce qui est plutôt ennuyant quand vous essayez d’appliquer plusieurs effets à la suite, par exemple.

Le code pour créer un FBO peut être trouvé ici.

Conseils

Dans le projet exemple, j’ai volontairement mis tout dans un seul et même fichier. Cela a été fait pour faciliter la lecture du code car, quand j’apprenais, j’ai remarqué que le fait de devoir chercher dans quelle classe se trouvait le morceau de code qui était lié à ce que je voulais, m’interrompait dans mon raisonnement et n’aidait donc pas à la compréhension. 
Cependant, une fois que vous aurez assimilé les concepts de bases, je ne peux que vous conseiller d’abstraire au maximum votre code derrière du code Java. Par exemple, n’hésitez pas à faire une classe qui va s’occuper de la création du FBO pour vous. N’hésitez pas non plus à extraire toute une partie de votre setup dans une classe qui étendra GLSurfaceView ou encore à sortir la logique des shaders dans leur propre classe (comme le fait si bien GPUImage). Etc. Cela rendra le code plus propre et facile à lire quand vous aurez acquis les concepts de base.

Crashez souvent mais crashez bien. Débugger du code relatif à OpenGL est particulièrement difficile. N’hésitez pas à mettre des vérifications d’erreurs ou d’état (pour les FBO ou la compilation de shader, par exemple) et à lancer une RuntimeException pour vous aider à voir ce qui ne va pas. Ca vous aidera lors du développement mais aussi dans vos tests sur différents téléphones.

Enfin, testez manuellement sur plusieurs téléphones et plusieurs versions d’Android. Vous ne pourrez pas tout attraper mais vous allez déjà rencontrer pas mal de petits détails. Pensez à tester sur des flagships comme la gamme Galaxy S ou Galaxy Note de Samsung mais aussi sur des téléphones plus modestes. N’oubliez pas non plus les marques chinoises assez répandues comme Wiko. 
Si vous n’avez pas beaucoup de téléphones et/ou voulez la jouer plus sûr, pensez à publier votre application en alpha et beta à travers le Play Store pour obtenir du feedback de vos utilisateurs.

Conclusion

Vous l’aurez compris, faire de l’OpenGL, c’est beaucoup de boilerplate, de mathématiques et d’arrachage de cheveux. Cependant, rassurez-vous, beaucoup de gens sont passés avant vous et ont déjà rencontré les problèmes que vous rencontrerez. 
J’espère à travers cet article avoir pu mettre en lumière quelques concepts clé qui vous aideront à comprendre les postes StackOverflow que vous lirez quand vous chercherez des réponses à vos questions.

Comme dit précédemment, le tutoriel fourni par le site android est vraiment bien fait, bien que survolant peut-être des points qui, je l’espère, auront déjà été éclaircis ici. Il fournit aussi un projet exemple qui est une bonne source d’inspiration pour le code et fournit des méthodes utilitaires qui vous seront certainement d’une grande aide.

Bonus

Enfin, si vous souhaitez entendre un peu de ma frustration sur le sujet mais surtout écouter Romain Guy nous parler d’OpenGL, vous pouvez écouter cet épisode du podcast Android Leaks.