Tutorial OpenGL pentru Android: Construirea unui generator de set Mandelbrot

Publicat: 2022-03-11

OpenGL este un API multiplatform puternic care permite accesul foarte apropiat la hardware-ul sistemului într-o varietate de medii de programare.

Deci, de ce ar trebui să-l folosești?

Oferă un nivel foarte scăzut de procesare pentru grafică atât în ​​2D, cât și în 3D. În general, acest lucru va evita orice zgomot pe care îl avem din cauza limbajelor de programare interpretate sau de nivel înalt. Mai important, totuși, oferă și acces la nivel hardware la o caracteristică cheie: GPU.

GPU poate accelera semnificativ multe aplicații, dar are un rol foarte specific într-un computer. Miezurile GPU sunt de fapt mai lente decât nucleele CPU. Dacă ar fi să rulăm un program care este în special serial fără activitate concomitentă, atunci va fi aproape întotdeauna mai lent pe un nucleu GPU decât pe un nucleu CPU. Principala diferență este că GPU-ul acceptă procesarea masivă în paralel. Putem crea programe mici numite shaders care vor rula eficient pe sute de nuclee simultan. Aceasta înseamnă că putem prelua sarcini care altfel sunt incredibil de repetitive și le putem rula simultan.

OpenGL și generator de set Mandelbrot

În acest articol, vom construi o aplicație Android simplă care utilizează OpenGL pentru a-și reda conținutul pe ecran. Înainte de a începe, este important să fiți deja familiarizat cu cunoștințele de scriere a aplicațiilor Android și sintaxa unui limbaj de programare asemănător C. Întregul cod sursă al acestui tutorial este disponibil pe GitHub.

Tutorial OpenGL și Android

Pentru a demonstra puterea OpenGL, vom scrie o aplicație relativ simplă pentru un dispozitiv Android. Acum, OpenGL pe Android este distribuit sub un subset numit OpenGL pentru sisteme încorporate (OpenGL ES). În esență, ne putem gândi la aceasta ca la o versiune redusă a OpenGL, deși funcționalitatea de bază necesară va fi în continuare disponibilă.

În loc să scriem un „Hello World” de bază, vom scrie o aplicație înșelător de simplă: un generator de set Mandelbrot. Mulțimea Mandelbrot se bazează pe domeniul numerelor complexe. Analiza complexă este un domeniu minunat de vast, așa că ne vom concentra mai mult pe rezultatul vizual decât pe matematica reală din spatele acestuia.

Cu OpenGL, construirea unui generator de set Mandelbrot este mai ușoară decât credeți!
Tweet

Suport pentru versiuni

suport pentru versiunea opengl

Când facem aplicația, vrem să ne asigurăm că este distribuită numai celor cu suport adecvat pentru OpenGL. Începeți prin a declara utilizarea OpenGL 2.0 în fișierul manifest, între declarația manifest și aplicație:

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

În acest moment, suportul pentru OpenGL 2.0 este omniprezent. OpenGL 3.0 și 3.1 câștigă în compatibilitate, dar scrierea pentru oricare va lăsa deoparte aproximativ 65% dintre dispozitive, așa că luați decizia doar dacă sunteți sigur că veți avea nevoie de funcționalități suplimentare. Acestea pot fi implementate setând versiunea la „0x000300000” și, respectiv, „0x000300001”.

Arhitectura aplicației

arhitectura aplicației opengl

Când realizați această aplicație OpenGL pe Android, veți avea, în general, trei clase principale care sunt utilizate pentru a desena suprafața: MainActivity , o extensie a GLSurfaceView și o implementare a unui GLSurfaceView.Renderer . De acolo, vom crea diverse Modele care vor încapsula desene.

MainActivity , numită FractalGenerator în acest exemplu, este în esență doar să instanțieze GLSurfaceView și să direcționeze orice modificări globale pe linie. Iată un exemplu care, în esență, va fi codul dvs. general:

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

Aceasta va fi, de asemenea, clasa în care veți dori să puneți orice alt modificator al nivelului de activitate (cum ar fi ecranul complet imersiv).

Cu o clasă mai profundă, avem o extensie a GLSurfaceView , care va acționa ca vizualizare principală. În această clasă, setăm versiunea, setăm un Renderer și controlăm evenimentele tactile. În constructorul nostru, trebuie doar să setăm versiunea OpenGL cu setEGLContextClientVersion(int version) și, de asemenea, să creăm și să setăm redarea noastră:

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

În plus, putem seta atribute precum modul de randare cu setRenderMode(int renderMode) . Deoarece generarea unui set Mandelbrot poate fi foarte costisitoare, vom folosi RENDERMODE_WHEN_DIRTY , care va reda scena doar la inițializare și atunci când se fac apeluri explicite la requestRender() . Mai multe opțiuni pentru setări pot fi găsite în GLSurfaceView API.

După ce avem constructorul, probabil că vom dori să suprascriem cel puțin o altă metodă: onTouchEvent(MotionEvent event) , care poate fi folosită pentru introducerea generală a utilizatorului bazată pe atingere. Nu voi intra în prea multe detalii aici, deoarece acesta nu este punctul central al lecției.

În cele din urmă, ajungem la Renderer-ul nostru, care va fi locul în care se va întâmpla cea mai mare parte a lucrărilor pentru iluminare sau, poate, schimbări de scenă. În primul rând, va trebui să ne uităm puțin la modul în care funcționează și funcționează matricele în lumea grafică.

Lecție rapidă de algebră liniară

OpenGL se bazează în mare măsură pe utilizarea matricelor. Matricele sunt un mod minunat de compact de a reprezenta secvențe de modificări generalizate în coordonate. În mod normal, ne permit să facem rotații, dilatări/contracții și reflexii arbitrare, dar cu puțină finețe putem face și traduceri. În esență, toate acestea înseamnă că puteți efectua cu ușurință orice modificare rezonabilă pe care o doriți, inclusiv mutarea unei camere sau creșterea unui obiect. Înmulțind matricele noastre cu un vector care reprezintă coordonatele noastre, putem produce eficient noul sistem de coordonate.

Clasa Matrix oferită de OpenGL oferă o serie de moduri gata făcute de calculare a matricelor de care vom avea nevoie, dar înțelegerea modului în care funcționează este o idee inteligentă chiar și atunci când lucrați cu transformări simple.

În primul rând, putem analiza de ce vom folosi vectori și matrici cu patru dimensiuni pentru a ne ocupa de coordonate. Acest lucru se întoarce de fapt la ideea de a îmbunătăți utilizarea coordonatelor pentru a putea face traduceri: în timp ce o traducere în spațiul 3D este imposibilă folosind doar trei dimensiuni, adăugarea unei a patra dimensiuni permite această capacitate.

Pentru a ilustra acest lucru, putem folosi o scară generală de bază/matrice de traducere:

scara opengl și matricea de translație

Ca o notă importantă, matricele OpenGL sunt pe coloană, astfel încât această matrice ar fi scrisă ca {a, 0, 0, 0, 0, b, 0, 0, 0, 0, c, 0, v_a, v_b, v_c, 1} , care este perpendicular pe modul în care va fi citit de obicei. Acest lucru poate fi raționalizat asigurându-vă că vectorii, care apar în multiplicare ca o coloană, au același format ca matricele.

Înapoi la Cod

Înarmați cu această cunoaștere a matricelor, ne putem întoarce la proiectarea Renderer-ului nostru. De obicei, vom crea o matrice în această clasă care este formată din produsul a trei matrice: Model, View și Projection. Acesta ar fi numit, în mod corespunzător, un MVPMatrix. Puteți afla mai multe despre detalii aici, deoarece vom folosi un set mai simplu de transformări - setul Mandelbrot este un model bidimensional, pe ecran complet și nu necesită cu adevărat ideea unei camere.

În primul rând, să organizăm clasa. Va trebui să implementăm metodele necesare pentru interfața Renderer: onSurfaceCreated(GL10 gl, EGLConfig config) , onSurfaceChanged(GL10 gl, int width, int height) și onDrawFrame(GL10 gl) . Clasa completă va arăta cam așa:

 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 }

Există, de asemenea, două metode utilitare utilizate în codul furnizat, checkGLError și loadShaders pentru a ajuta la depanare și la utilizarea shader-urilor.

În toate acestea, continuăm să trecem lanțul de comandă pe linie pentru a încapsula diferitele părți ale programului. Am ajuns în cele din urmă la punctul în care putem scrie ce face programul nostru de fapt, în loc de modul în care îi putem face modificări teoretice. Când facem acest lucru, trebuie să facem o clasă de model care să conțină informațiile care trebuie afișate pentru orice obiect dat din scenă. În scenele 3D complexe, acesta ar putea fi un animal sau un ceainic, dar vom face un fractal ca exemplu 2D mult mai simplu.

În clasele Model, scriem întreaga clasă — nu există superclase care trebuie folosite. Trebuie doar să avem un constructor și un fel de metodă de desen care să preia orice parametri.

Acestea fiind spuse, există încă o serie de variabile pe care va trebui să le avem și care sunt în esență standarde. Să aruncăm o privire la constructorul exact folosit în clasa 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); }

Destul de gură, nu-i așa? Din fericire, aceasta este o parte a programului pe care nu va trebui să o modificați deloc, salvați numele modelului. Cu condiția să modificați în mod corespunzător variabilele de clasă, acest lucru ar trebui să funcționeze bine pentru formele de bază.

Pentru a discuta părți ale acestui lucru, să ne uităm la câteva declarații de variabile:

 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

În squareCoords , specificăm toate coordonatele pătratului. Rețineți că toate coordonatele de pe ecran sunt reprezentate ca o grilă cu (-1,-1) în stânga jos și (1,1) în dreapta sus.

În drawOrder , specificăm ordinea coordonatelor pe baza triunghiurilor care ar alcătui pătratul. În special pentru consistență și viteză, OpenGL folosește triunghiuri pentru a reprezenta toate suprafețele. Pentru a face un pătrat, pur și simplu tăiați o diagonală (în acest caz, de la 0 la 2 ) pentru a da două triunghiuri.

Pentru a le adăuga pe ambele la program, mai întâi trebuie să le convertiți într-un buffer de octeți brut pentru a interfața direct conținutul matricei cu interfața OpenGL. Java stochează matrice ca obiecte care conțin informații suplimentare care nu sunt direct compatibile cu matricele C bazate pe pointer pe care le folosește implementarea OpenGL. Pentru a remedia acest lucru, sunt folosite ByteBuffers , care stochează accesul la memoria brută a matricei.

După ce am introdus datele pentru vârfuri și ordinea de desen, trebuie să ne creăm shaders.

Shaders

La crearea unui model, trebuie realizate două shadere: un Vertex Shader și un Fragment (Pixel) Shader. Toate shaderele sunt scrise în GL Shading Language (GLSL), care este un limbaj bazat pe C, cu adăugarea unui număr de funcții încorporate, modificatori de variabile, primitive și intrare/ieșire implicită. Pe Android, acestea vor fi transmise ca șiruri finale prin loadShader(int type, String shaderCode) , una dintre cele două metode de resurse din Renderer. Să trecem mai întâi peste diferitele tipuri de calificative:

  • const : Orice variabilă finală poate fi declarată ca o constantă, astfel încât valoarea sa poate fi stocată pentru acces ușor. Numerele precum π pot fi declarate ca constante dacă sunt utilizate frecvent în întregul shader. Este probabil ca compilatorul să declare automat valorile nemodificate ca constante, în funcție de implementare.
  • uniform : variabilele uniforme sunt cele care sunt declarate constante pentru orice randare. Ele sunt folosite în esență ca argumente statice pentru shaders.
  • varying : Dacă o variabilă este declarată variabilă și este setată într-un vertex shader, atunci este interpolată liniar în fragment shader. Acest lucru este util pentru a crea orice fel de gradient de culoare și este implicit pentru modificările de adâncime.
  • attribute : Atributele pot fi considerate ca argumente non-statice pentru un shader. Ele denotă setul de intrări care sunt specifice vârfurilor și vor apărea numai în Vertex Shaders.

În plus, ar trebui să discutăm alte două tipuri de primitive care au fost adăugate:

  • vec2 , vec3 , vec4 : vectori în virgulă mobilă de dimensiune dată.
  • mat2 , mat3 , mat4 : matrici cu virgulă mobilă de dimensiune dată.

Vectorii pot fi accesați prin componentele lor x , y , z și w sau r , g , b și a . De asemenea, pot genera orice vector de dimensiune cu mai mulți indici: pentru vec3 a , a.xxyz returnează un vec4 cu valorile corespunzătoare ale a .

Matricele și vectorii pot fi, de asemenea, indexați ca și tablouri, iar matricele vor returna un vector cu o singură componentă. Aceasta înseamnă că pentru mat2 matrix , matrix[0].a este validă și va returna matrix[0][0] . Când lucrați cu acestea, amintiți-vă că acţionează ca primitive, nu ca obiecte. De exemplu, luați în considerare următorul cod:

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

Aceasta lasă a=vec2(1.0,1.0) și b=vec2(2.0,1.0) , ceea ce nu este ceea ce ne-am aștepta de la comportamentul obiectului, unde a doua linie ar da lui b un pointer către a .

În setul Mandelbrot, cea mai mare parte a codului va fi în fragment shader, care este shader-ul care rulează pe fiecare pixel. În mod nominal, umbritoarele de vârf funcționează pe fiecare vârf, inclusiv atribute care vor fi pe bază de vârf, cum ar fi modificările de culoare sau adâncime. Să aruncăm o privire la shaderul extrem de simplu de vârf pentru un fractal:

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

În aceasta, gl_Position este o variabilă de ieșire definită de OpenGL pentru a înregistra coordonatele unui vârf. În acest caz, trecem într-o poziție pentru fiecare vârf la care setăm gl_Position . În majoritatea aplicațiilor, am înmulți vPosition cu o MVPMatrix , transformându-ne vârfurile, dar dorim ca fractalul să fie întotdeauna pe ecran complet. Toate transformările se vor face cu un sistem de coordonate local.

Fragment Shader va fi locul în care se face cea mai mare parte a muncii pentru a genera setul. Vom seta fragmentShaderCode la următoarele:

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

O mare parte din cod este doar matematica și algoritmul pentru modul în care funcționează setul. Rețineți utilizarea mai multor funcții încorporate: fract , abs , mix , sin și clamp , care operează toate pe vectori sau scalari și returnează vectori sau scalari. În plus, se folosește dot care preia argumente vectoriale și returnează un scalar.

Acum că avem shaderele configurate pentru utilizare, avem un ultim pas, care este să implementăm funcția de draw în modelul nostru:

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

Funcția transmite toate argumentele la shaders, inclusiv matricea de transformare uniform și poziția attribute .

După ce am asamblat toate părțile programului, îl putem da în sfârșit o rulare. Cu condiția ca suportul tactil adecvat să fie gestionat, vor fi pictate scene absolut fascinante:

tutorial opengl

tutorial opengl mandelbrot

tutorial generator mandelbrot

Precizie în virgulă mobilă

Dacă mărim puțin mai mult, începem să observăm o defecțiune în imagine:

Acest lucru nu are absolut nimic de-a face cu matematica setului din spatele lui și totul de-a face cu modul în care numerele sunt stocate și procesate în OpenGL. În timp ce s-a făcut un suport mai recent pentru precizie double , OpenGL 2.0 nu acceptă în mod nativ nimic mai mult decât float -uri. Le-am desemnat în mod special ca fiind floatoarele de cea mai înaltă precizie disponibile cu precision highp float în shaderul nostru, dar nici măcar asta nu este suficient de bun.

Pentru a ocoli această problemă, singura modalitate ar fi să emulați double folosind doi float . Această metodă se încadrează de fapt într-un ordin de mărime al preciziei reale a uneia implementate nativ, deși există un cost destul de mare pentru viteză. Acest lucru va fi lăsat ca un exercițiu pentru cititor, dacă cineva dorește să aibă un nivel mai ridicat de precizie.

Concluzie

Cu câteva clase de asistență, OpenGL poate susține rapid redarea în timp real a scenelor complexe. Crearea unui aspect compus dintr-un GLSurfaceView , setarea lui Renderer și crearea unui model cu shadere au culminat cu vizualizarea unei structuri matematice frumoase. Sper că veți găsi la fel de mult interes în dezvoltarea unei aplicații OpenGL ES!