Samouczek OpenGL dla Androida: Budowanie generatora zestawów Mandelbrota

Opublikowany: 2022-03-11

OpenGL to potężny wieloplatformowy interfejs API, który pozwala na bardzo bliski dostęp do sprzętu systemowego w różnych środowiskach programistycznych.

Dlaczego więc miałbyś go używać?

Zapewnia bardzo niski poziom przetwarzania grafiki zarówno w 2D, jak i 3D. Ogólnie rzecz biorąc, pozwoli to uniknąć wszelkich zgrzytów, które mamy z powodu interpretowanych lub języków programowania wysokiego poziomu. Co ważniejsze, zapewnia również dostęp na poziomie sprzętowym do kluczowej funkcji: GPU.

GPU może znacznie przyspieszyć wiele aplikacji, ale w komputerze pełni bardzo specyficzną rolę. Rdzenie GPU są w rzeczywistości wolniejsze niż rdzenie CPU. Gdybyśmy mieli uruchomić program, który jest w szczególności szeregowy bez jednoczesnej aktywności, to prawie zawsze będzie wolniejszy na rdzeniu GPU niż na rdzeniu procesora. Główna różnica polega na tym, że GPU obsługuje masowe przetwarzanie równoległe. Możemy tworzyć małe programy zwane shaderami, które będą działać efektywnie na setkach rdzeni jednocześnie. Oznacza to, że możemy wykonywać zadania, które poza tym są niezwykle powtarzalne, i uruchamiać je jednocześnie.

Generator zestawów OpenGL i Mandelbrota

W tym artykule będziemy budować prostą aplikację na Androida, która używa OpenGL do renderowania swojej zawartości na ekranie. Zanim zaczniemy, ważne jest, abyś był już zaznajomiony z wiedzą na temat pisania aplikacji na Androida i składnią niektórych języków programowania podobnych do C. Cały kod źródłowy tego samouczka jest dostępny w serwisie GitHub.

Samouczek OpenGL i Android

Aby zademonstrować moc OpenGL, napiszemy stosunkowo podstawową aplikację na urządzenie z Androidem. Teraz OpenGL na Androida jest dystrybuowany w ramach podzbioru o nazwie OpenGL dla systemów wbudowanych (OpenGL ES). Zasadniczo możemy myśleć o tym jako o okrojonej wersji OpenGL, chociaż podstawowa funkcjonalność, która jest potrzebna, będzie nadal dostępna.

Zamiast pisać podstawowe „Hello World”, napiszemy zwodniczo prostą aplikację: generator zestawów Mandelbrota. Zbiór Mandelbrota opiera się na dziedzinie liczb zespolonych. Analiza złożona to niezwykle rozległa dziedzina, więc bardziej skupimy się na wynikach wizualnych niż na rzeczywistej matematyce.

Dzięki OpenGL budowanie generatora zestawów Mandelbrota jest łatwiejsze niż myślisz!
Ćwierkać

Obsługa wersji

Obsługa wersji opengl

Kiedy tworzymy aplikację, chcemy mieć pewność, że jest ona dystrybuowana tylko do osób z odpowiednią obsługą OpenGL. Zacznij od zadeklarowania użycia OpenGL 2.0 w pliku manifestu, pomiędzy deklaracją manifestu a aplikacją:

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

W tym momencie wsparcie dla OpenGL 2.0 jest wszechobecne. OpenGL 3.0 i 3.1 zyskują na kompatybilności, ale pisanie dla każdego z nich pominie około 65% urządzeń, więc podejmij decyzję tylko wtedy, gdy masz pewność, że potrzebujesz dodatkowej funkcjonalności. Można je zaimplementować, ustawiając wersję odpowiednio na „0x000300000” i „0x000300001”.

Architektura aplikacji

architektura aplikacji opengl

Tworząc tę ​​aplikację OpenGL w systemie Android, zazwyczaj będziesz mieć trzy główne klasy, które są używane do rysowania powierzchni: twoja MainActivity , rozszerzenie GLSurfaceView i implementacja GLSurfaceView.Renderer . Stamtąd stworzymy różne modele, które będą hermetyzowały rysunki.

MainActivity , zwany w tym przykładzie FractalGenerator , w zasadzie po prostu tworzy instancję GLSurfaceView i kieruje wszelkie globalne zmiany wzdłuż linii. Oto przykład, który zasadniczo będzie twoim standardowym kodem:

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

Będzie to również klasa, w której będziesz chciał umieścić wszelkie inne modyfikatory poziomu aktywności (takie jak immersyjny tryb pełnoekranowy).

O jedną klasę głębiej mamy rozszerzenie GLSurfaceView , które będzie działać jako nasz główny widok. W tej klasie ustawiamy wersję, konfigurujemy Renderer i kontrolujemy zdarzenia dotykowe. W naszym konstruktorze musimy tylko ustawić wersję OpenGL za pomocą setEGLContextClientVersion(int version) oraz stworzyć i ustawić nasz renderer:

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

Dodatkowo możemy ustawić atrybuty, takie jak tryb renderowania, za pomocą setRenderMode(int renderMode) . Ponieważ generowanie zestawu Mandelbrota może być bardzo kosztowne, użyjemy RENDERMODE_WHEN_DIRTY , który wyrenderuje scenę tylko podczas inicjalizacji i gdy zostaną wykonane wyraźne wywołania requestRender() . Więcej opcji ustawień można znaleźć w GLSurfaceView API.

Po utworzeniu konstruktora prawdopodobnie będziemy chcieli przesłonić co najmniej jedną inną metodę: onTouchEvent(MotionEvent event) , która może być używana do ogólnych danych wejściowych użytkownika opartych na dotyku. Nie będę tu wdawał się zbytnio w szczegóły, ponieważ nie jest to główny temat lekcji.

Wreszcie przechodzimy do naszego Renderera, w którym będzie odbywała się większość pracy nad oświetleniem lub być może zmianami w scenie. Najpierw przyjrzymy się trochę, jak matryce działają i działają w świecie grafiki.

Szybka lekcja algebry liniowej

OpenGL w dużej mierze opiera się na wykorzystaniu macierzy. Macierze to cudownie zwarty sposób przedstawiania sekwencji uogólnionych zmian współrzędnych. Zwykle pozwalają nam na dowolne rotacje, dylatacje/skurcze i odbicia, ale przy odrobinie finezji możemy również wykonać tłumaczenia. Zasadniczo oznacza to, że możesz z łatwością dokonać dowolnej rozsądnej zmiany, jaką chcesz, w tym przesuwać kamerę lub powiększać obiekt. Mnożąc nasze macierze przez wektor reprezentujący naszą współrzędną, możemy efektywnie wytworzyć nowy układ współrzędnych.

Klasa Matrix dostarczana przez OpenGL daje wiele gotowych sposobów obliczania macierzy, których będziemy potrzebować, ale zrozumienie, jak one działają, jest mądrym pomysłem nawet podczas pracy z prostymi przekształceniami.

Po pierwsze, możemy omówić, dlaczego będziemy używać czterowymiarowych wektorów i macierzy do radzenia sobie ze współrzędnymi. Wraca to do idei doprecyzowania naszego wykorzystania współrzędnych, aby móc wykonywać translacje: podczas gdy tłumaczenie w przestrzeni 3D jest niemożliwe przy użyciu tylko trzech wymiarów, dodanie czwartego wymiaru umożliwia taką możliwość.

Aby to zilustrować, możemy użyć bardzo podstawowej ogólnej macierzy skali/tłumaczenia:

skala opengl i macierz translacji

Ważna uwaga, macierze OpenGL są kolumnowe, więc ta macierz zostałaby zapisana jako {a, 0, 0, 0, 0, b, 0, 0, 0, 0, c, 0, v_a, v_b, v_c, 1} , który jest prostopadły do ​​tego, jak zwykle będzie odczytywany. Można to zracjonalizować, zapewniając, że wektory, które pojawiają się w mnożeniu jako kolumna, mają taki sam format jak macierze.

Powrót do Kodeksu

Uzbrojeni w tę wiedzę o macierzach możemy wrócić do projektowania naszego Renderera. Zwykle tworzymy w tej klasie macierz, która jest utworzona z iloczynu trzech macierzy: Model, Widok i Projekcja. Byłoby to odpowiednio nazwane MVMatrix. Możesz dowiedzieć się więcej o szczegółach tutaj, ponieważ będziemy używać bardziej podstawowego zestawu przekształceń — zestaw Mandelbrota jest dwuwymiarowym modelem pełnoekranowym i tak naprawdę nie wymaga pomysłu z aparatem.

Najpierw skonfigurujmy klasę. Musimy zaimplementować wymagane metody dla interfejsu Renderera: onSurfaceCreated(GL10 gl, EGLConfig config) , onSurfaceChanged(GL10 gl, int width, int height) i onDrawFrame(GL10 gl) . Cała klasa będzie wyglądać mniej więcej tak:

 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 }

W dostarczonym kodzie znajdują się również dwie metody narzędziowe, checkGLError i loadShaders , które pomagają w debugowaniu i korzystaniu z shaderów.

W tym wszystkim przekazujemy łańcuch poleceń w dół wiersza, aby zawrzeć różne części programu. W końcu doszliśmy do punktu, w którym możemy napisać, co faktycznie robi nasz program, zamiast tego, jak możemy wprowadzać w nim teoretyczne zmiany. Robiąc to, musimy stworzyć klasę modelu, która zawiera informacje, które należy wyświetlić dla dowolnego obiektu w scenie. W złożonych scenach 3D może to być zwierzę lub czajnik, ale zrobimy fraktal jako znacznie prostszy przykład 2D.

W klasach Model piszemy całą klasę — nie ma żadnych nadklas, których trzeba użyć. Potrzebujemy tylko konstruktora i jakiejś metody rysowania, która pobiera dowolne parametry.

To powiedziawszy, nadal istnieje wiele zmiennych, które będziemy musieli mieć, które są zasadniczo szablonowe. Przyjrzyjmy się dokładnemu konstruktorowi użytemu w klasie 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); }

Niezły kęs, prawda? Na szczęście jest to część programu, której w ogóle nie będziesz musiał zmieniać, zapisz nazwę modelu. Pod warunkiem, że odpowiednio zmienisz zmienne klasy, powinno to działać dobrze w przypadku podstawowych kształtów.

Aby omówić część tego, spójrzmy na niektóre deklaracje zmiennych:

 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

W squareCoords podajemy wszystkie współrzędne kwadratu. Zauważ, że wszystkie współrzędne na ekranie są reprezentowane jako siatka z (-1,-1) w lewym dolnym rogu i (1,1) w prawym górnym rogu.

W drawOrder określamy kolejność współrzędnych na podstawie trójkątów, które tworzą kwadrat. Szczególnie dla spójności i szybkości OpenGL używa trójkątów do reprezentowania wszystkich powierzchni. Aby zrobić kwadrat, po prostu skróć przekątną (w tym przypadku od 0 do 2 ), aby uzyskać dwa trójkąty.

Aby dodać oba te elementy do programu, najpierw musisz przekonwertować je na surowy bufor bajtów, aby bezpośrednio połączyć zawartość tablicy z interfejsem OpenGL. Java przechowuje tablice jako obiekty zawierające dodatkowe informacje, które nie są bezpośrednio kompatybilne z tablicami C opartymi na wskaźnikach, których używa implementacja OpenGL. Aby temu zaradzić, używane są ByteBuffers , które przechowują dostęp do surowej pamięci tablicy.

Po wprowadzeniu danych dla wierzchołków i kolejności rysowania musimy stworzyć nasze shadery.

Shadery

Podczas tworzenia modelu należy stworzyć dwa shadery: Vertex Shader i Fragment (Pixel) Shader. Wszystkie moduły cieniujące są napisane w języku GL Shading Language (GLSL), który jest językiem opartym na języku C z dodatkiem wielu wbudowanych funkcji, modyfikatorów zmiennych, prymitywów i domyślnego wejścia/wyjścia. W systemie Android zostaną one przekazane jako końcowe ciągi przez loadShader(int type, String shaderCode) , jedną z dwóch metod zasobów w module renderującym. Przyjrzyjmy się najpierw różnym typom kwalifikacji:

  • const : Dowolna zmienna końcowa może być zadeklarowana jako stała, dzięki czemu jej wartość może być przechowywana w celu łatwego dostępu. Liczby takie jak π mogą być deklarowane jako stałe, jeśli są często używane w całym shaderze. Jest prawdopodobne, że kompilator automatycznie zadeklaruje niezmodyfikowane wartości jako stałe, w zależności od implementacji.
  • uniform : Zmienne uniformów to takie, które są deklarowane jako stałe dla dowolnego pojedynczego renderowania. Są one używane zasadniczo jako statyczne argumenty dla twoich shaderów.
  • varying : Jeśli zmienna jest zadeklarowana jako zmienna i jest ustawiona w programie Vertex Shader, jest ona liniowo interpolowana w module Fragment Shader. Jest to przydatne do tworzenia dowolnego gradientu w kolorze i jest niejawne przy zmianach głębi.
  • attribute : atrybuty można traktować jako niestatyczne argumenty modułu cieniującego. Oznaczają one zestaw danych wejściowych, które są specyficzne dla wierzchołków i pojawią się tylko w Vertex Shaders.

Ponadto powinniśmy omówić dwa inne typy prymitywów, które zostały dodane:

  • vec2 , vec3 , vec4 : Wektory zmiennoprzecinkowe o podanym wymiarze.
  • mat2 , mat3 , mat4 : Macierze zmiennoprzecinkowe danego wymiaru.

Dostęp do wektorów można uzyskać za pomocą ich komponentów x , y , z oraz w lub r , g , b i a . Mogą również generować wektor o dowolnym rozmiarze z wieloma indeksami: dla vec3 a , a.xxyz zwraca vec4 z odpowiednimi wartościami a .

Macierze i wektory mogą być również indeksowane jako tablice, a macierze zwrócą wektor z tylko jednym składnikiem. Oznacza to, że dla mat2 matrix , matrix[0].a jest poprawna i zwróci matrix[0][0] . Podczas pracy z nimi pamiętaj, że działają one jak prymitywne, a nie przedmioty. Rozważmy na przykład następujący kod:

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

Pozostawia a=vec2(1.0,1.0) i b=vec2(2.0,1.0) , co nie jest tym, czego można by oczekiwać od zachowania obiektu, gdzie druga linia dałaby b wskaźnik do a .

W zestawie Mandelbrota większość kodu będzie znajdowała się w Fragment Shader, czyli shader działający na każdym pikselu. Nominalnie, Vertex Shadery działają na każdym wierzchołku, w tym na atrybutach, które będą dotyczyć każdego wierzchołka, takich jak zmiany koloru lub głębokości. Rzućmy okiem na niezwykle prosty Vertex Shader dla fraktala:

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

W tym przypadku gl_Position jest zmienną wyjściową zdefiniowaną przez OpenGL do rejestrowania współrzędnych wierzchołka. W tym przypadku przekazujemy pozycję dla każdego wierzchołka, na który ustawiliśmy gl_Position . W większości aplikacji pomnożylibyśmy vPosition przez MVPMatrix , przekształcając nasze wierzchołki, ale chcemy, aby fraktal zawsze był pełnoekranowy. Wszystkie przekształcenia zostaną wykonane w lokalnym układzie współrzędnych.

Fragment Shader będzie miejscem, w którym wykonuje się większość pracy, aby wygenerować zestaw. Ustawimy fragmentShaderCode na następujący:

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

Znaczna część kodu to tylko matematyka i algorytm działania zestawu. Zwróć uwagę na użycie kilku wbudowanych funkcji: fract , abs , mix , sin i clamp , które działają na wektorach lub skalarach i wektorach powrotu lub skalarach. Dodatkowo używana jest dot , która pobiera argumenty wektorowe i zwraca skalar.

Teraz, gdy mamy już skonfigurowane nasze shadery, mamy ostatni krok, którym jest zaimplementowanie funkcji draw w naszym modelu:

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

Funkcja przekazuje wszystkie argumenty do shaderów, w tym uniform macierz transformacji i pozycję attribute .

Po złożeniu wszystkich części programu, możemy go wreszcie uruchomić. Pod warunkiem odpowiedniego wsparcia dotykowego namalowane zostaną absolutnie hipnotyzujące sceny:

samouczek opengl

samouczek opengl mandelbrota

samouczek generatora mandelbrota

Dokładność zmiennoprzecinkowa

Jeśli przybliżymy nieco bardziej, zaczniemy dostrzegać załamanie obrazu:

Nie ma to absolutnie nic wspólnego z matematyką stojącą za tym zestawem, a wszystko ze sposobem przechowywania i przetwarzania liczb w OpenGL. Chociaż wprowadzono nowsze wsparcie dla double precyzji, OpenGL 2.0 nie obsługuje natywnie niczego poza float s. Specjalnie wyznaczyliśmy je, aby były najbardziej precyzyjnymi pływakami dostępnymi z precision highp float w naszym shaderze, ale nawet to nie jest wystarczająco dobre.

Aby obejść ten problem, jedynym sposobem byłoby emulowanie double s przy użyciu dwóch float . Ta metoda faktycznie mieści się w zakresie o rząd wielkości rzeczywistej precyzji natywnie zaimplementowanej, chociaż wiąże się z dość poważnymi kosztami szybkości. Pozostanie to jako ćwiczenie dla czytelnika, jeśli ktoś chce mieć wyższy poziom dokładności.

Wniosek

Dzięki kilku klasom wsparcia OpenGL może szybko utrzymać renderowanie złożonych scen w czasie rzeczywistym. Stworzenie układu składającego się z GLSurfaceView , ustawienie jego Renderer i stworzenie modelu z shaderami, wszystko to zakończyło się wizualizacją pięknej struktury matematycznej. Mam nadzieję, że zainteresuje Cię tworzenie aplikacji OpenGL ES!