Tutorial OpenGL per Android: creazione di un generatore di set di Mandelbrot

Pubblicato: 2022-03-11

OpenGL è una potente API multipiattaforma che consente un accesso molto ravvicinato all'hardware del sistema in una varietà di ambienti di programmazione.

Allora, perché dovresti usarlo?

Fornisce un'elaborazione di livello molto basso per la grafica sia in 2D che in 3D. In generale, questo eviterà qualsiasi clunk che abbiamo a causa di linguaggi di programmazione interpretati o di alto livello. Ancora più importante, tuttavia, fornisce anche l'accesso a livello hardware a una caratteristica chiave: la GPU.

La GPU può accelerare notevolmente molte applicazioni, ma ha un ruolo molto specifico in un computer. I core della GPU sono infatti più lenti dei core della CPU. Se dovessimo eseguire un programma che è in particolare seriale senza attività simultanea, sarà quasi sempre più lento su un core GPU rispetto a un core CPU. La differenza principale è che la GPU supporta l'elaborazione parallela massiccia. Possiamo creare piccoli programmi chiamati shader che funzioneranno efficacemente su centinaia di core contemporaneamente. Ciò significa che possiamo svolgere attività altrimenti incredibilmente ripetitive ed eseguirle contemporaneamente.

Generatore di set OpenGL e Mandelbrot

In questo articolo, creeremo una semplice applicazione Android che utilizza OpenGL per visualizzare il suo contenuto sullo schermo. Prima di iniziare, è importante che tu abbia già familiarità con la conoscenza della scrittura di applicazioni Android e la sintassi di alcuni linguaggi di programmazione simili al C. L'intero codice sorgente di questo tutorial è disponibile su GitHub.

Tutorial OpenGL e Android

Per dimostrare la potenza di OpenGL, scriveremo un'applicazione relativamente semplice per un dispositivo Android. Ora, OpenGL su Android è distribuito in un sottoinsieme chiamato OpenGL for Embedded Systems (OpenGL ES). Possiamo essenzialmente pensare a questa come a una versione ridotta di OpenGL, anche se le funzionalità di base necessarie saranno ancora disponibili.

Invece di scrivere un semplice "Hello World", scriveremo un'applicazione ingannevolmente semplice: un generatore di set Mandelbrot. L'insieme di Mandelbrot si basa sul campo dei numeri complessi. L'analisi complessa è un campo meravigliosamente vasto, quindi ci concentreremo sul risultato visivo più che sulla matematica reale dietro di esso.

Con OpenGL, costruire un generatore di set Mandelbrot è più facile di quanto pensi!
Twitta

Supporto alla versione

supporto per la versione opengl

Quando creiamo l'applicazione, vogliamo assicurarci che sia distribuita solo a coloro che dispongono di un adeguato supporto OpenGL. Inizia dichiarando l'uso di OpenGL 2.0 nel file manifest, tra la dichiarazione manifest e l'applicazione:

 <uses-feature android:glEsVersion="0x00020000" android:required="true" />

A questo punto, il supporto per OpenGL 2.0 è onnipresente. OpenGL 3.0 e 3.1 stanno guadagnando in compatibilità, ma scrivere per entrambi lascerà fuori circa il 65% dei dispositivi, quindi prendi la decisione solo se sei certo di aver bisogno di funzionalità aggiuntive. Possono essere implementati impostando la versione rispettivamente su '0x000300000' e '0x000300001'.

Architettura dell'applicazione

architettura dell'applicazione opengl

Quando crei questa applicazione OpenGL su Android, avrai generalmente tre classi principali utilizzate per disegnare la superficie: your MainActivity , un'estensione di GLSurfaceView e un'implementazione di GLSurfaceView.Renderer . Da lì, creeremo vari modelli che incapsulano i disegni.

MainActivity , chiamato FractalGenerator in questo esempio, essenzialmente creerà un'istanza di GLSurfaceView e indirizzerà eventuali modifiche globali lungo la linea. Ecco un esempio che sarà essenzialmente il tuo codice boilerplate:

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

Questa sarà anche la classe in cui vorrai inserire qualsiasi altro modificatore del livello di attività (come lo schermo intero immersivo).

Una classe più profonda, abbiamo un'estensione di GLSurfaceView , che fungerà da nostra vista principale. In questa classe, impostiamo la versione, impostiamo un Renderer e controlliamo gli eventi touch. Nel nostro costruttore, dobbiamo solo impostare la versione OpenGL con setEGLContextClientVersion(int version) e anche creare e impostare il nostro renderer:

 public FractalSurfaceView(Context context){ super(context); setEGLContextClientVersion(2); mRenderer = new FractalRenderer(); setRenderer(mRenderer); }

Inoltre, possiamo impostare attributi come la modalità di rendering con setRenderMode(int renderMode) . Poiché la generazione di un set di Mandelbrot può essere molto costosa, utilizzeremo RENDERMODE_WHEN_DIRTY , che eseguirà il rendering della scena solo all'inizializzazione e quando vengono effettuate chiamate esplicite a requestRender() . È possibile trovare altre opzioni per le impostazioni nell'API GLSurfaceView .

Dopo aver ottenuto il costruttore, probabilmente vorremo sovrascrivere almeno un altro metodo: onTouchEvent(MotionEvent event) , che può essere utilizzato per l'input utente generale basato sul tocco. Non entrerò troppo nei dettagli qui, poiché questo non è l'obiettivo principale della lezione.

Infine, scendiamo al nostro Renderer, che sarà il luogo in cui avverrà la maggior parte del lavoro per l'illuminazione o forse i cambiamenti nella scena. Innanzitutto, dovremo esaminare un po' come funzionano e funzionano le matrici nel mondo della grafica.

Lezione veloce di algebra lineare

OpenGL si basa molto sull'uso di matrici. Le matrici sono un modo meravigliosamente compatto di rappresentare sequenze di cambiamenti generalizzati nelle coordinate. Normalmente ci permettono di fare rotazioni, dilatazioni/contrazioni e riflessioni arbitrarie, ma con un po' di finezza possiamo anche fare traslazioni. In sostanza, tutto ciò significa che puoi facilmente eseguire qualsiasi modifica ragionevole desideri, incluso lo spostamento di una telecamera o la crescita di un oggetto. Moltiplicando le nostre matrici per un vettore che rappresenta la nostra coordinata, possiamo produrre efficacemente il nuovo sistema di coordinate.

La classe Matrix fornita da OpenGL offre una serie di modi pronti per calcolare le matrici di cui avremo bisogno, ma capire come funzionano è un'idea intelligente anche quando si lavora con semplici trasformazioni.

Innanzitutto, possiamo esaminare il motivo per cui utilizzeremo vettori e matrici quadridimensionali per gestire le coordinate. Questo in realtà risale all'idea di affinare il nostro uso delle coordinate per essere in grado di eseguire traduzioni: mentre una traduzione nello spazio 3D è impossibile utilizzando solo tre dimensioni, l'aggiunta di una quarta dimensione consente l'abilità.

Per illustrare questo, possiamo usare una scala generale/matrice di traduzione molto semplice:

scala opengl e matrice di traslazione

Come nota importante, le matrici OpenGL sono a colonne, quindi questa matrice sarebbe scritta come {a, 0, 0, 0, 0, b, 0, 0, 0, 0, c, 0, v_a, v_b, v_c, 1} , che è perpendicolare a come verrà normalmente letto. Questo può essere razionalizzato assicurando che i vettori, che appaiono nella moltiplicazione come una colonna, abbiano lo stesso formato delle matrici.

Torna al codice

Forti di questa conoscenza delle matrici, possiamo tornare a progettare il nostro Renderer. Di solito, creeremo una matrice in questa classe che è formata dal prodotto di tre matrici: Modello, Vista e Proiezione. Questo sarebbe chiamato, in modo appropriato, MVPMtrix. Puoi saperne di più sulle specifiche qui, poiché utilizzeremo un insieme più semplice di trasformazioni: il set di Mandelbrot è un modello bidimensionale a schermo intero e non richiede davvero l'idea di una fotocamera.

Per prima cosa, impostiamo la classe. Dovremo implementare i metodi richiesti per l'interfaccia Renderer: onSurfaceCreated(GL10 gl, EGLConfig config) , onSurfaceChanged(GL10 gl, int width, int height) e onDrawFrame(GL10 gl) . L'intera classe finirà per assomigliare a questo:

 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 }

Esistono anche due metodi di utilità utilizzati nel codice fornito, checkGLError e loadShaders per facilitare il debug e l'uso degli shader.

In tutto questo, continuiamo a passare la catena di comando lungo la linea per incapsulare le diverse parti del programma. Siamo finalmente arrivati ​​al punto in cui possiamo scrivere cosa fa effettivamente il nostro programma, invece di come possiamo apportare modifiche teoriche ad esso. Quando si esegue questa operazione, è necessario creare una classe modello che contenga le informazioni che devono essere visualizzate per un determinato oggetto nella scena. In complesse scene 3D, questo potrebbe essere un animale o un bollitore, ma faremo un frattale come esempio 2D molto più semplice.

Nelle classi Model, scriviamo l'intera classe: non ci sono superclassi che devono essere utilizzate. Abbiamo solo bisogno di avere un costruttore e una sorta di metodo di disegno che accetta tutti i parametri.

Detto questo, ci sono ancora un certo numero di variabili che dovremo avere che sono essenzialmente standard. Diamo un'occhiata al costruttore esatto utilizzato nella 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); }

Un bel boccone, vero? Fortunatamente, questa è una parte del programma che non dovrai modificare affatto, salva il nome del modello. A condizione che tu modifichi le variabili di classe in modo appropriato, questo dovrebbe funzionare bene per le forme di base.

Per discutere parti di questo, diamo un'occhiata ad alcune dichiarazioni di variabili:

 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

In squareCoords specifichiamo tutte le coordinate del quadrato. Nota che tutte le coordinate sullo schermo sono rappresentate come una griglia con (-1,-1) in basso a sinistra e (1,1) in alto a destra.

In drawOrder , specifichiamo l'ordine delle coordinate in base ai triangoli che formerebbero il quadrato. In particolare per coerenza e velocità, OpenGL utilizza triangoli per rappresentare tutte le superfici. Per fare un quadrato, taglia semplicemente una diagonale (in questo caso, da 0 a 2 ) per dare due triangoli.

Per aggiungere entrambi al programma, devi prima convertirli in un buffer di byte grezzo per interfacciare direttamente il contenuto dell'array con l'interfaccia OpenGL. Java memorizza gli array come oggetti che contengono informazioni aggiuntive che non sono direttamente compatibili con gli array C basati su puntatori utilizzati dall'implementazione di OpenGL. Per rimediare a questo, vengono utilizzati ByteBuffers , che memorizzano l'accesso alla memoria grezza dell'array.

Dopo aver inserito i dati per i vertici e disegnato l'ordine, dobbiamo creare i nostri shader.

Shader

Quando si crea un modello, è necessario creare due shader: un Vertex Shader e un Fragment (Pixel) Shader. Tutti gli shader sono scritti in GL Shading Language (GLSL), che è un linguaggio basato su C con l'aggiunta di una serie di funzioni integrate, modificatori di variabili, primitive e input/output predefiniti. In Android, questi verranno passati come stringhe finali tramite loadShader(int type, String shaderCode) , uno dei due metodi di risorse nel Renderer. Esaminiamo prima i diversi tipi di qualificazioni:

  • const : qualsiasi variabile finale può essere dichiarata come costante in modo che il suo valore possa essere memorizzato per un facile accesso. Numeri come π possono essere dichiarati come costanti se vengono usati frequentemente nello shader. È probabile che il compilatore dichiari automaticamente i valori non modificati come costanti, a seconda dell'implementazione.
  • uniform : le variabili uniformi sono quelle dichiarate costanti per ogni singolo rendering. Sono usati essenzialmente come argomenti statici per i tuoi shader.
  • varying : se una variabile è dichiarata variabile ed è impostata in un vertex shader, viene interpolata linearmente nello shader del frammento. Questo è utile per creare qualsiasi tipo di sfumatura di colore ed è implicito per i cambiamenti di profondità.
  • attribute : gli attributi possono essere considerati argomenti non statici per uno shader. Indicano l'insieme di input che sono specifici del vertice e appariranno solo in Vertex Shaders.

Inoltre, dovremmo discutere di altri due tipi di primitive che sono stati aggiunti:

  • vec2 , vec3 , vec4 : vettori in virgola mobile di data dimensione.
  • mat2 , mat3 , mat4 : matrici in virgola mobile di data dimensione.

I vettori sono accessibili dalle loro componenti x , y , z e w o r , g , b e a . Possono anche generare vettori di qualsiasi dimensione con indici multipli: per vec3 a , a.xxyz restituisce un vec4 con i valori corrispondenti di a .

Matrici e vettori possono anche essere indicizzati come array e le matrici restituiranno un vettore con un solo componente. Ciò significa che per mat2 matrix , matrix[0].a è valido e restituirà matrix[0][0] . Quando lavori con questi, ricorda che agiscono come primitivi, non come oggetti. Ad esempio, considera il seguente codice:

 vec2 a = vec2(1.0,1.0); vec2 b = a; bx=2.0;

Questo lascia a=vec2(1.0,1.0) e b=vec2(2.0,1.0) , che non è quello che ci si aspetterebbe dal comportamento dell'oggetto, dove la seconda riga darebbe a b un puntatore a .

Nel Mandelbrot Set, la maggior parte del codice sarà nello shader del frammento, che è lo shader che viene eseguito su ogni pixel. Nominalmente, i vertex shader funzionano su ogni vertice, inclusi gli attributi che saranno per vertice, come le modifiche al colore o alla profondità. Diamo un'occhiata al vertex shader estremamente semplice per un frattale:

 private final String vertexShaderCode = "attribute vec4 vPosition;" + "void main() {" + " gl_Position = vPosition;" + "}";

In questo, gl_Position è una variabile di output definita da OpenGL per registrare le coordinate di un vertice. In questo caso, passiamo in una posizione per ogni vertice a cui impostiamo gl_Position . Nella maggior parte delle applicazioni, moltiplichiamo vPosition per un MVPMatrix , trasformando i nostri vertici, ma vogliamo che il frattale sia sempre a schermo intero. Tutte le trasformazioni verranno eseguite con un sistema di coordinate locale.

Il Fragment Shader sarà il luogo in cui viene eseguita la maggior parte del lavoro per generare il set. Imposteremo fragmentShaderCode su quanto segue:

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

Gran parte del codice è semplicemente la matematica e l'algoritmo per il funzionamento del set. Notare l'uso di diverse funzioni integrate: fract , abs , mix , sin e clamp , che operano tutte su vettori o scalari e restituiscono vettori o scalari. Inoltre, viene utilizzato il dot che accetta argomenti vettoriali e restituisce uno scalare.

Ora che abbiamo i nostri shader impostati per l'uso, abbiamo un ultimo passaggio, che consiste nell'implementare la funzione di draw nel nostro modello:

 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 funzione passa tutti gli argomenti agli shader, inclusa la matrice di trasformazione uniform e la posizione attribute .

Dopo aver assemblato tutte le parti del programma, possiamo finalmente dargli un'occhiata. A condizione che venga gestito il corretto supporto tattile, verranno dipinte scene assolutamente affascinanti:

tutorial opengl

tutorial di mandelbrot opengl

tutorial generatore di mandelbrot

Precisione in virgola mobile

Se ingrandiamo un po' di più, iniziamo a notare un'interruzione nell'immagine:

Questo non ha assolutamente nulla a che fare con la matematica del set dietro di esso e tutto ciò che ha a che fare con il modo in cui i numeri vengono archiviati ed elaborati in OpenGL. Sebbene sia stato creato un supporto più recente per la double precisione, OpenGL 2.0 non supporta nativamente nient'altro che float s. Li abbiamo specificatamente designati per essere i float di massima precisione disponibili con precision highp float nel nostro shader, ma anche questo non è abbastanza buono.

Per aggirare questo problema, l'unico modo sarebbe emulare double s usando due float s. Questo metodo rientra effettivamente in un ordine di grandezza della precisione effettiva di uno implementato in modo nativo, sebbene vi sia un costo piuttosto elevato per la velocità. Questo sarà lasciato come esercizio al lettore, se si desidera avere un livello di accuratezza più elevato.

Conclusione

Con poche classi di supporto, OpenGL può sostenere rapidamente il rendering in tempo reale di scene complesse. La creazione di un layout composto da un GLSurfaceView , l'impostazione del suo Renderer e la creazione di un modello con shader sono culminati nella visualizzazione di una bellissima struttura matematica. Spero che troverai altrettanto interesse nello sviluppo di un'applicazione OpenGL ES!