Tutoriel OpenGL pour Android : Construire un générateur d'ensembles de Mandelbrot
Publié: 2022-03-11OpenGL est une puissante API multiplateforme qui permet un accès très proche au matériel du système dans une variété d'environnements de programmation.
Alors, pourquoi devriez-vous l'utiliser?
Il fournit un traitement de très bas niveau pour les graphiques en 2D et en 3D. En général, cela évitera tout blocage dû aux langages de programmation interprétés ou de haut niveau. Plus important encore, cependant, il fournit également un accès au niveau matériel à une fonctionnalité clé : le GPU.
Le GPU peut considérablement accélérer de nombreuses applications, mais il a un rôle très spécifique dans un ordinateur. Les cœurs GPU sont en fait plus lents que les cœurs CPU. Si nous devions exécuter un programme qui est notamment en série sans activité concurrente, il sera presque toujours plus lent sur un cœur GPU que sur un cœur CPU. La principale différence est que le GPU prend en charge le traitement parallèle massif. Nous pouvons créer de petits programmes appelés shaders qui fonctionneront efficacement sur des centaines de cœurs à la fois. Cela signifie que nous pouvons prendre des tâches qui sont autrement incroyablement répétitives et les exécuter simultanément.
Dans cet article, nous allons créer une application Android simple qui utilise OpenGL pour afficher son contenu à l'écran. Avant de commencer, il est important que vous connaissiez déjà la connaissance de l'écriture d'applications Android et la syntaxe de certains langages de programmation de type C. L'intégralité du code source de ce tutoriel est disponible sur GitHub.
Tutoriel OpenGL et Android
Pour démontrer la puissance d'OpenGL, nous allons écrire une application relativement basique pour un appareil Android. Maintenant, OpenGL sur Android est distribué sous un sous-ensemble appelé OpenGL pour les systèmes embarqués (OpenGL ES). Nous pouvons essentiellement considérer cela comme une version simplifiée d'OpenGL, bien que la fonctionnalité de base nécessaire soit toujours disponible.
Au lieu d'écrire un "Hello World" de base, nous allons écrire une application d'une simplicité trompeuse : un générateur d'ensembles de Mandelbrot. L'ensemble de Mandelbrot est basé sur le domaine des nombres complexes. L'analyse complexe est un domaine magnifiquement vaste, nous nous concentrerons donc davantage sur le résultat visuel que sur les mathématiques réelles qui le sous-tendent.
Prise en charge des versions
Lorsque nous créons l'application, nous voulons nous assurer qu'elle n'est distribuée qu'à ceux qui disposent d'un support OpenGL approprié. Commencez par déclarer l'utilisation d'OpenGL 2.0 dans le fichier manifeste, entre la déclaration du manifeste et l'application :
<uses-feature android:glEsVersion="0x00020000" android:required="true" />
À ce stade, la prise en charge d'OpenGL 2.0 est omniprésente. OpenGL 3.0 et 3.1 gagnent en compatibilité, mais écrire pour l'un ou l'autre laissera de côté environ 65% des appareils, alors ne prenez la décision que si vous êtes certain d'avoir besoin de fonctionnalités supplémentaires. Ils peuvent être implémentés en définissant la version sur '0x000300000' et '0x000300001' respectivement.
Architecture des applications
Lors de la création de cette application OpenGL sur Android, vous aurez généralement trois classes principales utilisées pour dessiner la surface : votre MainActivity
, une extension de GLSurfaceView
et une implémentation de GLSurfaceView.Renderer
. À partir de là, nous créerons divers modèles qui encapsuleront des dessins.
MainActivity
, appelé FractalGenerator
dans cet exemple, va essentiellement instancier votre GLSurfaceView
et acheminer toutes les modifications globales sur toute la ligne. Voici un exemple qui sera essentiellement votre code passe-partout :
public class FractalGenerator extends Activity { private GLSurfaceView mGLView; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); //Create and set GLSurfaceView mGLView = new FractalSurfaceView(this); setContentView(mGLView); } //[...] @Override protected void onPause() { super.onPause(); mGLView.onPause(); } @Override protected void onResume() { super.onResume(); mGLView.onResume(); } }
Ce sera également la classe dans laquelle vous voudrez mettre d'autres modificateurs de niveau d'activité (comme le plein écran immersif).
Une classe plus loin, nous avons une extension de GLSurfaceView
, qui agira comme notre vue principale. Dans cette classe, nous définissons la version, configurons un moteur de rendu et contrôlons les événements tactiles. Dans notre constructeur, il nous suffit de définir la version OpenGL avec setEGLContextClientVersion(int version)
et également de créer et de définir notre moteur de rendu :
public FractalSurfaceView(Context context){ super(context); setEGLContextClientVersion(2); mRenderer = new FractalRenderer(); setRenderer(mRenderer); }
De plus, nous pouvons définir des attributs comme le mode de rendu avec setRenderMode(int renderMode)
. Étant donné que la génération d'un ensemble de Mandelbrot peut être très coûteuse, nous utiliserons RENDERMODE_WHEN_DIRTY
, qui ne rendra la scène qu'à l'initialisation et lorsque des appels explicites sont effectués à requestRender()
. Plus d'options pour les paramètres peuvent être trouvées dans l'API GLSurfaceView
.
Une fois que nous aurons le constructeur, nous voudrons probablement remplacer au moins une autre méthode : onTouchEvent(MotionEvent event)
, qui peut être utilisée pour une entrée utilisateur tactile générale. Je ne vais pas entrer dans trop de détails ici, car ce n'est pas l'objet principal de la leçon.
Enfin, nous arrivons à notre Renderer, qui sera l'endroit où la plupart des travaux d'éclairage ou peut-être des changements de scène se produisent. Tout d'abord, nous devrons examiner un peu comment les matrices fonctionnent et fonctionnent dans le monde graphique.
Leçon rapide d'algèbre linéaire
OpenGL s'appuie fortement sur l'utilisation de matrices. Les matrices sont un moyen merveilleusement compact de représenter des séquences de changements généralisés de coordonnées. Normalement, ils nous permettent de faire des rotations, des dilatations/contractions et des réflexions arbitraires, mais avec un peu de finesse, nous pouvons aussi faire des translations. Essentiellement, tout cela signifie que vous pouvez facilement effectuer tout changement raisonnable que vous souhaitez, y compris déplacer une caméra ou faire grandir un objet. En multipliant nos matrices par un vecteur représentant notre coordonnée, nous pouvons effectivement produire le nouveau système de coordonnées.
La classe Matrix fournie par OpenGL donne un certain nombre de façons prêtes à l'emploi de calculer les matrices dont nous aurons besoin, mais comprendre comment elles fonctionnent est une bonne idée même lorsque vous travaillez avec des transformations simples.
Tout d'abord, nous pouvons expliquer pourquoi nous utiliserons des vecteurs et des matrices à quatre dimensions pour traiter les coordonnées. Cela revient en fait à l'idée de peaufiner notre utilisation des coordonnées pour pouvoir faire des translations : alors qu'une translation dans l'espace 3D est impossible en utilisant seulement trois dimensions, l'ajout d'une quatrième dimension permet la capacité.
Pour illustrer cela, nous pouvons utiliser une matrice échelle/translation générale très basique :
Comme remarque importante, les matrices OpenGL sont par colonne, donc cette matrice serait écrite comme {a, 0, 0, 0, 0, b, 0, 0, 0, 0, c, 0, v_a, v_b, v_c, 1}
, qui est perpendiculaire à la façon dont il sera habituellement lu. Cela peut être rationalisé en s'assurant que les vecteurs, qui apparaissent dans la multiplication sous forme de colonne, ont le même format que les matrices.
Retour au code
Forts de cette connaissance des matrices, nous pouvons revenir à la conception de notre Renderer. Habituellement, nous allons créer une matrice dans cette classe qui est formée du produit de trois matrices : Model, View et Projection. Cela s'appellerait, à juste titre, une MVPMatrix. Vous pouvez en savoir plus sur les spécificités ici, car nous allons utiliser un ensemble de transformations plus basique - l'ensemble Mandelbrot est un modèle plein écran en 2 dimensions, et il ne nécessite pas vraiment l'idée d'un appareil photo.
Tout d'abord, installons la classe. Nous devrons implémenter les méthodes requises pour l'interface Renderer : onSurfaceCreated(GL10 gl, EGLConfig config)
, onSurfaceChanged(GL10 gl, int width, int height)
et onDrawFrame(GL10 gl)
. La classe complète finira par ressembler à ceci :
public class FractalRenderer implements GLSurfaceView.Renderer { //Provide a tag for logging errors private static final String TAG = "FractalRenderer"; //Create all models private Fractal mFractal; //Transformation matrices private final float[] mMVPMatrix = new float[16]; //Any other private variables needed for transformations @Override public void onSurfaceCreated(GL10 unused, EGLConfig config) { // Set the background frame color GLES20.glClearColor(0.0f, 0.0f, 0.0f, 1.0f); //Instantiate all models mFractal = new Fractal(); } @Override public void onDrawFrame(GL10 unused) { //Clear the frame of any color information or depth information GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT | GLES20.GL_DEPTH_BUFFER_BIT); //Create a basic scale/translate matrix float[] mMVPMatrix = new float[]{ -1.0f/mZoom, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f/(mZoom*mRatio), 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, -mX, -mY, 0.0f, 1.0f}; //Pass the draw command down the line to all models, giving access to the transformation matrix mFractal.draw(mMVPMatrix); } @Override public void onSurfaceChanged(GL10 unused, int width, int height) { //Create the viewport as being fullscreen GLES20.glViewport(0, 0, width, height); //Change any projection matrices to reflect changes in screen orientation } //Other public access methods for transformations }
Il existe également deux méthodes utilitaires utilisées dans le code fourni, checkGLError
et loadShaders
pour aider au débogage et à l'utilisation des shaders.
Dans tout cela, nous continuons à transmettre la chaîne de commandement le long de la ligne pour encapsuler les différentes parties du programme. Nous en sommes finalement arrivés au point où nous pouvons écrire ce que notre programme fait réellement, au lieu de comment nous pouvons y apporter des modifications théoriques. Ce faisant, nous devons créer une classe de modèle contenant les informations à afficher pour un objet donné de la scène. Dans les scènes 3D complexes, cela pourrait être un animal ou une bouilloire, mais nous allons faire une fractale comme exemple 2D beaucoup plus simple.
Dans les classes Model, nous écrivons la classe entière - il n'y a pas de superclasses qui doivent être utilisées. Nous n'avons besoin que d'un constructeur et d'une sorte de méthode de dessin qui accepte tous les paramètres.
Cela dit, il y a encore un certain nombre de variables dont nous aurons besoin et qui sont essentiellement passe-partout. Examinons le constructeur exact utilisé dans la classe Fractal :
public Fractal() { // initialize vertex byte buffer for shape coordinates ByteBuffer bb = ByteBuffer.allocateDirect( // (# of coordinate values * 4 bytes per float) squareCoords.length * 4); bb.order(ByteOrder.nativeOrder()); vertexBuffer = bb.asFloatBuffer(); vertexBuffer.put(squareCoords); vertexBuffer.position(0); // initialize byte buffer for the draw list ByteBuffer dlb = ByteBuffer.allocateDirect( // (# of coordinate values * 2 bytes per short) drawOrder.length * 2); dlb.order(ByteOrder.nativeOrder()); drawListBuffer = dlb.asShortBuffer(); drawListBuffer.put(drawOrder); drawListBuffer.position(0); // Prepare shaders int vertexShader = FractalRenderer.loadShader( GLES20.GL_VERTEX_SHADER, vertexShaderCode); int fragmentShader = FractalRenderer.loadShader( GLES20.GL_FRAGMENT_SHADER, fragmentShaderCode); // create empty OpenGL Program mProgram = GLES20.glCreateProgram(); // add the vertex shader to program GLES20.glAttachShader(mProgram, vertexShader); // add the fragment shader to program GLES20.glAttachShader(mProgram, fragmentShader); // create OpenGL program executables GLES20.glLinkProgram(mProgram); }
Toute une bouchée, n'est-ce pas ? Heureusement, c'est une partie du programme que vous n'aurez pas du tout à modifier, enregistrez le nom du modèle. Si vous modifiez les variables de classe de manière appropriée, cela devrait bien fonctionner pour les formes de base.

Pour discuter de certaines parties de cela, regardons quelques déclarations de variables :
static float squareCoords[] = { -1.0f, 1.0f, 0.0f, // top left -1.0f, -1.0f, 0.0f, // bottom left 1.0f, -1.0f, 0.0f, // bottom right 1.0f, 1.0f, 0.0f }; // top right private final short drawOrder[] = { 0, 1, 2, 0, 2, 3 }; // order to draw vertices
Dans squareCoords
, nous spécifions toutes les coordonnées du carré. Notez que toutes les coordonnées à l'écran sont représentées sous forme de grille avec (-1,-1)
en bas à gauche et (1,1)
en haut à droite.
Dans drawOrder
, nous spécifions l'ordre des coordonnées en fonction des triangles qui constitueraient le carré. Particulièrement pour la cohérence et la vitesse, OpenGL utilise des triangles pour représenter toutes les surfaces. Pour faire un carré, il suffit de couper une diagonale (dans ce cas, 0
à 2
) pour donner deux triangles.
Afin d'ajouter ces deux éléments au programme, vous devez d'abord les convertir en un tampon d'octets bruts pour interfacer directement le contenu du tableau avec l'interface OpenGL. Java stocke les tableaux sous forme d'objets contenant des informations supplémentaires qui ne sont pas directement compatibles avec les tableaux C basés sur des pointeurs utilisés par l'implémentation d'OpenGL. Pour y remédier, ByteBuffers
sont utilisés, qui stockent l'accès à la mémoire brute du tableau.
Après avoir entré les données pour les sommets et l'ordre de dessin, nous devons créer nos shaders.
Shaders
Lors de la création d'un modèle, deux shaders doivent être créés : un Vertex Shader et un Fragment (Pixel) Shader. Tous les shaders sont écrits en GL Shading Language (GLSL), qui est un langage basé sur C avec l'ajout d'un certain nombre de fonctions intégrées, de modificateurs de variables, de primitives et d'entrées/sorties par défaut. Sur Android, ceux-ci seront transmis en tant que chaînes finales via loadShader(int type, String shaderCode)
, l'une des deux méthodes de ressource dans le Renderer. Passons d'abord en revue les différents types de qualificatifs :
-
const
: Toute variable finale peut être déclarée comme une constante afin que sa valeur puisse être stockée pour un accès facile. Des nombres comme π peuvent être déclarés comme des constantes s'ils sont fréquemment utilisés dans le shader. Il est probable que le compilateur déclarera automatiquement les valeurs non modifiées comme des constantes, selon l'implémentation. -
uniform
: Les variables uniformes sont celles qui sont déclarées constantes pour tout rendu unique. Ils sont essentiellement utilisés comme arguments statiques pour vos shaders. -
varying
: Si une variable est déclarée comme variable et est définie dans un vertex shader, alors elle est interpolée linéairement dans le fragment shader. Ceci est utile pour créer n'importe quel type de dégradé de couleur et est implicite pour les changements de profondeur. -
attribute
: les attributs peuvent être considérés comme des arguments non statiques d'un shader. Ils désignent l'ensemble des entrées qui sont spécifiques au sommet et n'apparaîtront que dans Vertex Shaders.
De plus, nous devrions discuter de deux autres types de primitives qui ont été ajoutées :
-
vec2
,vec3
,vec4
: vecteurs à virgule flottante de dimension donnée. -
mat2
,mat3
,mat4
: Matrices à virgule flottante de dimension donnée.
Les vecteurs sont accessibles par leurs composants x
, y
, z
et w
ou r
, g
, b
et a
. Ils peuvent également générer n'importe quel vecteur de taille avec plusieurs indices : pour vec3 a
, a.xxyz
renvoie un vec4
avec les valeurs correspondantes de a
.
Les matrices et les vecteurs peuvent également être indexés sous forme de tableaux, et les matrices renverront un vecteur avec un seul composant. Cela signifie que pour mat2 matrix
, matrix[0].a
est valide et renverra matrix[0][0]
. Lorsque vous travaillez avec ceux-ci, rappelez-vous qu'ils agissent comme des primitives et non comme des objets. Par exemple, considérez le code suivant :
vec2 a = vec2(1.0,1.0); vec2 b = a; bx=2.0;
Cela laisse a=vec2(1.0,1.0)
et b=vec2(2.0,1.0)
, ce qui n'est pas ce à quoi on pourrait s'attendre d'un comportement d'objet, où la deuxième ligne donnerait à b
un pointeur vers a
.
Dans l'ensemble de Mandelbrot, la majorité du code sera dans le fragment shader, qui est le shader qui s'exécute sur chaque pixel. Nominalement, les shaders de vertex fonctionnent sur chaque vertex, y compris les attributs qui seront sur une base par vertex, comme les changements de couleur ou de profondeur. Jetons un coup d'œil au vertex shader extrêmement simple pour une fractale :
private final String vertexShaderCode = "attribute vec4 vPosition;" + "void main() {" + " gl_Position = vPosition;" + "}";
En cela, gl_Position
est une variable de sortie définie par OpenGL pour enregistrer les coordonnées d'un sommet. Dans ce cas, nous passons dans une position pour chaque sommet auquel nous gl_Position
. Dans la plupart des applications, nous multiplierions vPosition
par un MVPMatrix
, transformant nos sommets, mais nous voulons que la fractale soit toujours en plein écran. Toutes les transformations seront effectuées avec un système de coordonnées local.
Le Fragment Shader sera l'endroit où la majeure partie du travail est effectuée pour générer l'ensemble. Nous allons définir fragmentShaderCode
sur ce qui suit :
precision highp float; uniform mat4 uMVPMatrix; void main() { //Scale point by input transformation matrix vec2 p = (uMVPMatrix * vec4(gl_PointCoord,0,1)).xy; vec2 c = p; //Set default color to HSV value for black vec3 color=vec3(0.0,0.0,0.0); //Max number of iterations will arbitrarily be defined as 100. Finer detail with more computation will be found for larger values. for(int i=0;i<100;i++){ //Perform complex number arithmetic p= vec2(px*px-py*py,2.0*px*py)+c; if (dot(p,p)>4.0){ //The point, c, is not part of the set, so smoothly color it. colorRegulator increases linearly by 1 for every extra step it takes to break free. float colorRegulator = float(i-1)-log(((log(dot(p,p)))/log(2.0)))/log(2.0); //This is a coloring algorithm I found to be appealing. Written in HSV, many functions will work. color = vec3(0.95 + .012*colorRegulator , 1.0, .2+.4*(1.0+sin(.3*colorRegulator))); break; } } //Change color from HSV to RGB. Algorithm from https://gist.github.com/patriciogonzalezvivo/114c1653de9e3da6e1e3 vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0); vec3 m = abs(fract(color.xxx + K.xyz) * 6.0 - K.www); gl_FragColor.rgb = color.z * mix(K.xxx, clamp(m - K.xxx, 0.0, 1.0), color.y); gl_FragColor.a=1.0; }
Une grande partie du code est simplement le calcul et l'algorithme du fonctionnement de l'ensemble. Notez l'utilisation de plusieurs fonctions intégrées : fract
, abs
, mix
, sin
et clamp
, qui opèrent toutes sur des vecteurs ou des scalaires et renvoient des vecteurs ou des scalaires. De plus, un dot
est utilisé qui prend des arguments vectoriels et renvoie un scalaire.
Maintenant que nos shaders sont configurés pour être utilisés, nous avons une dernière étape, qui consiste à implémenter la fonction draw
dans notre modèle :
public void draw(float[] mvpMatrix) { // Add program to OpenGL environment GLES20.glUseProgram(mProgram); // get handle to vertex shader's vPosition member mPositionHandle = GLES20.glGetAttribLocation(mProgram, "vPosition"); mMVPMatrixHandle = GLES20.glGetUniformLocation(mProgram, "uMVPMatrix"); //Pass uniform transformation matrix to shader GLES20.glUniformMatrix4fv(mMVPMatrixHandle, 1, false, mvpMatrix, 0); //Add attribute array of vertices GLES20.glEnableVertexAttribArray(mPositionHandle); GLES20.glVertexAttribPointer( mPositionHandle, COORDS_PER_VERTEX, GLES20.GL_FLOAT, false, vertexStride, vertexBuffer); // Draw the square GLES20.glDrawElements( GLES20.GL_TRIANGLES, drawOrder.length, GLES20.GL_UNSIGNED_SHORT, drawListBuffer); // Disable vertex array GLES20.glDisableVertexAttribArray(mPositionHandle); FractalRenderer.checkGlError("Test"); }
La fonction transmet tous les arguments aux shaders, y compris la matrice de transformation uniform
et la position de l' attribute
.
Après avoir assemblé toutes les parties du programme, nous pouvons enfin l'essayer. À condition que le support tactile approprié soit géré, des scènes absolument fascinantes seront peintes :
Précision en virgule flottante
Si on zoome un peu plus, on commence à remarquer une cassure dans l'image :
Cela n'a absolument rien à voir avec les mathématiques de l'ensemble derrière et tout à voir avec la façon dont les nombres sont stockés et traités dans OpenGL. Bien qu'une prise en charge plus récente de la double
précision ait été faite, OpenGL 2.0 ne prend nativement en charge rien de plus que les float
s. Nous les avons spécifiquement désignés comme étant les flotteurs de précision les plus élevés disponibles avec precision highp float
dans notre shader, mais même cela n'est pas suffisant.
Afin de contourner ce problème, le seul moyen serait d'émuler double
s en utilisant deux float
s. Cette méthode se situe en fait dans un ordre de grandeur de la précision réelle d'une méthode implémentée nativement, bien qu'il y ait un coût assez élevé pour accélérer. Ceci sera laissé en exercice au lecteur, si l'on souhaite avoir un niveau de précision supérieur.
Conclusion
Avec quelques classes de support, OpenGL peut rapidement maintenir le rendu en temps réel de scènes complexes. La création d'une mise en page composée d'un GLSurfaceView
, la définition de son Renderer
et la création d'un modèle avec des shaders ont abouti à la visualisation d'une belle structure mathématique. J'espère que vous trouverez autant d'intérêt à développer une application OpenGL ES !