Tutorial OpenGL untuk Android: Membangun Generator Set Mandelbrot
Diterbitkan: 2022-03-11OpenGL adalah API lintas platform yang kuat yang memungkinkan akses yang sangat dekat ke perangkat keras sistem di berbagai lingkungan pemrograman.
Jadi, mengapa Anda harus menggunakannya?
Ini memberikan pemrosesan tingkat yang sangat rendah untuk grafik dalam 2D dan 3D. Secara umum, ini akan menghindari clunk yang kita miliki karena interpretasi atau bahasa pemrograman tingkat tinggi. Lebih penting lagi, ini juga menyediakan akses tingkat perangkat keras ke fitur utama: GPU.
GPU dapat mempercepat banyak aplikasi secara signifikan, tetapi memiliki peran yang sangat spesifik di komputer. Core GPU sebenarnya lebih lambat dari core CPU. Jika kita menjalankan program yang terutama serial tanpa aktivitas bersamaan, maka hampir selalu lebih lambat pada inti GPU daripada inti CPU. Perbedaan utama adalah bahwa GPU mendukung pemrosesan paralel besar-besaran. Kita dapat membuat program kecil yang disebut shader yang akan berjalan efektif pada ratusan core sekaligus. Ini berarti bahwa kita dapat mengambil tugas yang sangat berulang dan menjalankannya secara bersamaan.
Pada artikel ini, kita akan membangun aplikasi Android sederhana yang menggunakan OpenGL untuk merender kontennya di layar. Sebelum kita mulai, Penting bagi Anda untuk terbiasa dengan pengetahuan menulis aplikasi Android dan sintaks beberapa bahasa pemrograman mirip-C. Seluruh kode sumber dari tutorial ini tersedia di GitHub.
Tutorial OpenGL dan Android
Untuk mendemonstrasikan kekuatan OpenGL, kami akan menulis aplikasi yang relatif mendasar untuk perangkat Android. Sekarang, OpenGL di Android didistribusikan di bawah subset yang disebut OpenGL for Embedded Systems (OpenGL ES). Kami pada dasarnya hanya dapat menganggap ini sebagai versi OpenGL yang dipreteli, meskipun fungsionalitas inti yang diperlukan akan tetap tersedia.
Alih-alih menulis "Hello World" dasar, kami akan menulis aplikasi sederhana yang menipu: generator set Mandelbrot. Himpunan Mandelbrot didasarkan pada bidang bilangan kompleks. Analisis kompleks adalah bidang yang sangat luas, jadi kami akan lebih fokus pada hasil visual daripada matematika sebenarnya di baliknya.
Dukungan Versi
Ketika kami membuat aplikasi, kami ingin memastikan bahwa itu hanya didistribusikan kepada mereka yang memiliki dukungan OpenGL yang tepat. Mulailah dengan mendeklarasikan penggunaan OpenGL 2.0 dalam file manifes, antara deklarasi manifes dan aplikasi:
<uses-feature android:glEsVersion="0x00020000" android:required="true" />
Pada titik ini, dukungan untuk OpenGL 2.0 ada di mana-mana. OpenGL 3.0 dan 3.1 mendapatkan kompatibilitas, tetapi menulis untuk keduanya akan menghilangkan sekitar 65% perangkat, jadi buatlah keputusan hanya jika Anda yakin akan membutuhkan fungsionalitas tambahan. Mereka dapat diimplementasikan dengan mengatur versi masing-masing ke '0x000300000' dan '0x000300001'.
Arsitektur Aplikasi
Saat membuat aplikasi OpenGL ini di Android, biasanya Anda akan memiliki tiga kelas utama yang digunakan untuk menggambar permukaan: MainActivity
, ekstensi GLSurfaceView
, dan implementasi GLSurfaceView.Renderer
. Dari sana, kami akan membuat berbagai Model yang akan merangkum gambar.
MainActivity
, yang disebut FractalGenerator
dalam contoh ini, pada dasarnya hanya akan membuat instance GLSurfaceView
Anda dan mengarahkan perubahan global apa pun ke depan. Berikut adalah contoh yang pada dasarnya akan menjadi kode boilerplate Anda:
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(); } }
Ini juga akan menjadi kelas di mana Anda ingin menempatkan pengubah tingkat aktivitas lainnya, (seperti layar penuh imersif).
Satu kelas lebih dalam, kami memiliki ekstensi GLSurfaceView
, yang akan bertindak sebagai tampilan utama kami. Di kelas ini, kami mengatur versi, menyiapkan Renderer, dan mengontrol acara sentuh. Di konstruktor kami, kami hanya perlu mengatur versi OpenGL dengan setEGLContextClientVersion(int version)
dan juga membuat dan mengatur renderer kami:
public FractalSurfaceView(Context context){ super(context); setEGLContextClientVersion(2); mRenderer = new FractalRenderer(); setRenderer(mRenderer); }
Selain itu, kita dapat mengatur atribut seperti mode render dengan setRenderMode(int renderMode)
. Karena menghasilkan set Mandelbrot bisa sangat mahal, kami akan menggunakan RENDERMODE_WHEN_DIRTY
, yang hanya akan merender adegan saat inisialisasi dan saat panggilan eksplisit dilakukan ke requestRender()
. Opsi lainnya untuk pengaturan dapat ditemukan di GLSurfaceView
API.
Setelah kita memiliki konstruktor, kita mungkin ingin mengganti setidaknya satu metode lain: onTouchEvent(MotionEvent event)
, yang dapat digunakan untuk input pengguna berbasis sentuhan umum. Saya tidak akan membahas terlalu banyak detail di sini, karena itu bukan fokus utama pelajaran.
Akhirnya, kami turun ke Renderer kami, yang akan menjadi tempat sebagian besar pekerjaan untuk pencahayaan atau mungkin perubahan adegan terjadi. Pertama, kita harus melihat sedikit tentang bagaimana matriks bekerja dan beroperasi di dunia grafis.
Pelajaran Singkat Aljabar Linier
OpenGL sangat bergantung pada penggunaan matriks. Matriks adalah cara yang sangat ringkas untuk merepresentasikan urutan perubahan koordinat yang digeneralisasikan. Biasanya, mereka memungkinkan kita untuk melakukan rotasi, dilatasi/kontraksi, dan refleksi sewenang-wenang, tetapi dengan sedikit kemahiran kita juga dapat melakukan terjemahan. Pada dasarnya, ini semua berarti bahwa Anda dapat dengan mudah melakukan perubahan wajar apa pun yang Anda inginkan, termasuk menggerakkan kamera atau membuat objek tumbuh. Dengan mengalikan matriks kami dengan vektor yang mewakili koordinat kami, kami dapat secara efektif menghasilkan sistem koordinat baru.
Kelas Matrix yang disediakan oleh OpenGL memberikan sejumlah cara siap pakai untuk menghitung matriks yang kita perlukan, tetapi memahami cara kerjanya adalah ide yang cerdas bahkan ketika bekerja dengan transformasi sederhana.
Pertama, kita dapat membahas mengapa kita akan menggunakan vektor dan matriks empat dimensi untuk berurusan dengan koordinat. Ini sebenarnya kembali ke gagasan menyempurnakan penggunaan koordinat untuk dapat melakukan terjemahan: sementara terjemahan dalam ruang 3D tidak mungkin hanya menggunakan tiga dimensi, menambahkan dimensi keempat memungkinkan kemampuan.
Untuk mengilustrasikan hal ini, kita dapat menggunakan matriks skala/translasi umum yang sangat mendasar:
Sebagai catatan penting, matriks OpenGL bersifat kolom, jadi matriks ini akan ditulis sebagai {a, 0, 0, 0, 0, b, 0, 0, 0, 0, c, 0, v_a, v_b, v_c, 1}
, yang tegak lurus dengan bagaimana biasanya akan dibaca. Ini dapat dirasionalisasikan dengan memastikan bahwa vektor, yang muncul dalam perkalian sebagai kolom, memiliki format yang sama dengan matriks.
Kembali ke Kode
Berbekal pengetahuan matriks ini, kita dapat kembali mendesain Renderer kita. Biasanya, kita akan membuat matriks di kelas ini yang dibentuk dari hasil kali tiga matriks: Model, View, dan Projection. Ini akan disebut, dengan tepat, sebuah MVPMatrix. Anda dapat mempelajari lebih lanjut tentang spesifikasinya di sini, karena kita akan menggunakan set transformasi yang lebih mendasar—set Mandelbrot adalah model layar penuh 2 dimensi, dan tidak benar-benar membutuhkan ide kamera.
Pertama, mari kita atur kelasnya. Kita perlu mengimplementasikan metode yang diperlukan untuk antarmuka Renderer: onSurfaceCreated(GL10 gl, EGLConfig config)
, onSurfaceChanged(GL10 gl, int width, int height)
, dan onDrawFrame(GL10 gl)
. Kelas penuh akan berakhir seperti ini:
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 }
Ada juga dua metode utilitas yang digunakan dalam kode yang disediakan, checkGLError
dan loadShaders
untuk membantu dalam debugging dan penggunaan shader.
Dalam semua ini, kami terus melewati rantai perintah untuk merangkum bagian-bagian berbeda dari program. Kami akhirnya sampai pada titik di mana kami dapat menulis apa yang sebenarnya dilakukan oleh program kami , alih-alih bagaimana kami dapat membuat perubahan teoretis padanya. Saat melakukan ini, kita perlu membuat kelas model yang berisi informasi yang perlu ditampilkan untuk objek apa pun dalam adegan. Dalam adegan 3D yang kompleks, ini bisa berupa binatang atau teko, tetapi kita akan melakukan fraktal sebagai contoh 2D yang jauh lebih sederhana.
Di kelas Model, kami menulis seluruh kelas—tidak ada superkelas yang harus digunakan. Kita hanya perlu memiliki konstruktor dan semacam metode menggambar yang mengambil parameter apa pun.
Ini mengatakan, masih ada sejumlah variabel yang perlu kita miliki yang pada dasarnya adalah boilerplate. Mari kita lihat konstruktor persis yang digunakan di kelas 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); }
Cukup seteguk, bukan? Untungnya, ini adalah bagian dari program yang tidak perlu Anda ubah sama sekali, simpan nama modelnya. Asalkan Anda mengubah variabel kelas dengan tepat, ini akan bekerja dengan baik untuk bentuk dasar.

Untuk membahas bagian ini, mari kita lihat beberapa deklarasi variabel:
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
Dalam squareCoords
, kami menentukan semua koordinat persegi. Perhatikan bahwa semua koordinat pada layar direpresentasikan sebagai kotak dengan (-1,-1)
di kiri bawah dan (1,1)
di kanan atas.
Di drawOrder
, kami menentukan urutan koordinat berdasarkan segitiga yang akan membentuk persegi. Khususnya untuk konsistensi dan kecepatan, OpenGL menggunakan segitiga untuk mewakili semua permukaan. Untuk membuat persegi, cukup potong diagonal (dalam hal ini, 0
hingga 2
) untuk menghasilkan dua segitiga.
Untuk menambahkan keduanya ke program, pertama-tama Anda harus mengubahnya menjadi buffer byte mentah untuk secara langsung menghubungkan isi array dengan antarmuka OpenGL. Java menyimpan array sebagai objek yang berisi informasi tambahan yang tidak secara langsung kompatibel dengan array C berbasis pointer yang digunakan implementasi OpenGL. Untuk mengatasinya, digunakan ByteBuffers
, yang menyimpan akses ke memori mentah dari array.
Setelah kami memasukkan data untuk simpul dan menggambar urutan, kami harus membuat shader kami.
shader
Saat membuat model, dua shader harus dibuat: Vertex Shader dan Fragment (Pixel) Shader. Semua shader ditulis dalam GL Shading Language (GLSL), yang merupakan bahasa berbasis C dengan tambahan sejumlah fungsi bawaan, pengubah variabel, primitif, dan input/output default. Di Android, ini akan diteruskan sebagai String terakhir melalui loadShader(int type, String shaderCode)
, salah satu dari dua metode sumber daya di Renderer. Pertama-tama mari kita bahas berbagai jenis kualifikasi:
-
const
: Setiap variabel final dapat dideklarasikan sebagai konstanta sehingga nilainya dapat disimpan untuk akses yang mudah. Angka seperti dapat dideklarasikan sebagai konstanta jika sering digunakan di seluruh shader. Kemungkinan kompiler akan secara otomatis mendeklarasikan nilai yang tidak dimodifikasi sebagai konstanta, tergantung pada implementasinya. -
uniform
: Variabel seragam adalah variabel yang dideklarasikan konstan untuk setiap rendering tunggal. Mereka digunakan pada dasarnya sebagai argumen statis untuk shader Anda. -
varying
: Jika sebuah variabel dideklarasikan sebagai variabel dan diset dalam vertex shader, maka variabel tersebut diinterpolasi secara linier di dalam fragment shader. Ini berguna untuk membuat segala jenis gradien dalam warna dan tersirat untuk perubahan kedalaman. -
attribute
: Atribut dapat dianggap sebagai argumen non-statis ke shader. Mereka menunjukkan set input yang spesifik vertex dan hanya akan muncul di Vertex Shaders.
Selain itu, kita harus mendiskusikan dua jenis primitif lain yang telah ditambahkan:
-
vec2
,vec3
,vec4
: Vektor titik mengambang dari dimensi yang diberikan. -
mat2
,mat3
,mat4
: Matriks titik mengambang dari dimensi yang diberikan.
Vektor dapat diakses dengan komponen x
, y
, z
, dan w
atau r
, g
, b
, dan a
. Mereka juga dapat menghasilkan vektor ukuran apa pun dengan beberapa indeks: untuk vec3 a
, a.xxyz
mengembalikan vec4
dengan nilai yang sesuai dari a
.
Matriks dan vektor juga dapat diindeks sebagai array, dan matriks akan mengembalikan vektor hanya dengan satu komponen. Ini berarti bahwa untuk mat2 matrix
, matrix[0].a
valid dan akan mengembalikan matrix[0][0]
. Saat bekerja dengan ini, ingatlah bahwa mereka bertindak sebagai primitif, bukan objek. Misalnya, perhatikan kode berikut:
vec2 a = vec2(1.0,1.0); vec2 b = a; bx=2.0;
Ini meninggalkan a=vec2(1.0,1.0)
dan b=vec2(2.0,1.0)
, yang bukan yang diharapkan dari perilaku objek, di mana baris kedua akan memberikan b
pointer ke a
.
Di Mandelbrot Set, sebagian besar kode akan berada di shader fragmen, yang merupakan shader yang berjalan di setiap piksel. Secara nominal, vertex shader bekerja pada setiap vertex, termasuk atribut yang akan berada pada basis per vertex, seperti perubahan warna atau kedalaman. Mari kita lihat shader vertex yang sangat sederhana untuk fraktal:
private final String vertexShaderCode = "attribute vec4 vPosition;" + "void main() {" + " gl_Position = vPosition;" + "}";
Dalam hal ini, gl_Position
adalah variabel keluaran yang didefinisikan oleh OpenGL untuk merekam koordinat sebuah simpul. Dalam hal ini, kami memberikan posisi untuk setiap simpul yang kami atur gl_Position
. Di sebagian besar aplikasi, kami akan mengalikan vPosition
dengan MVPMatrix
, mengubah simpul kami, tetapi kami ingin fraktal selalu menjadi layar penuh. Semua transformasi akan dilakukan dengan sistem koordinat lokal.
Fragment Shader akan menjadi tempat sebagian besar pekerjaan dilakukan untuk menghasilkan himpunan. Kami akan mengatur fragmentShaderCode
sebagai berikut:
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; }
Sebagian besar kode hanyalah matematika dan algoritme untuk cara kerja himpunan. Perhatikan penggunaan beberapa fungsi bawaan: fract
, abs
, mix
, sin
, dan clamp
, yang semuanya beroperasi pada vektor atau skalar dan mengembalikan vektor atau skalar. Selain itu, dot
digunakan yang mengambil argumen vektor dan mengembalikan skalar.
Sekarang setelah kita menyiapkan shader untuk digunakan, kita memiliki satu langkah terakhir, yaitu mengimplementasikan fungsi draw
dalam model kita:
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"); }
Fungsi meneruskan semua argumen ke shader, termasuk matriks transformasi uniform
dan posisi attribute
.
Setelah merakit semua bagian program, akhirnya kita bisa menjalankannya. Asalkan dukungan sentuhan yang tepat ditangani, adegan yang benar-benar memukau akan dicat:
Akurasi Titik Mengambang
Jika kita memperbesar sedikit lebih banyak, kita mulai melihat kerusakan pada gambar:
Ini sama sekali tidak ada hubungannya dengan matematika dari himpunan di belakangnya dan segala sesuatu yang berkaitan dengan cara angka disimpan dan diproses di OpenGL. Sementara dukungan yang lebih baru untuk presisi double
telah dibuat, OpenGL 2.0 tidak mendukung apa pun selain float
s. Kami secara khusus menetapkannya sebagai pelampung presisi tertinggi yang tersedia dengan pelampung precision highp float
di shader kami, tetapi itu pun tidak cukup baik.
Untuk mengatasi masalah ini, satu-satunya cara adalah dengan meniru double
s menggunakan dua float
s. Metode ini sebenarnya datang dalam urutan besarnya presisi aktual yang diterapkan secara asli, meskipun ada biaya yang agak mahal untuk kecepatan. Ini akan diserahkan sebagai latihan kepada pembaca, jika seseorang ingin memiliki tingkat akurasi yang lebih tinggi.
Kesimpulan
Dengan beberapa kelas dukungan, OpenGL dapat dengan cepat mempertahankan rendering adegan kompleks secara real-time. Membuat tata letak yang terdiri dari GLSurfaceView
, mengatur Renderer
, dan membuat model dengan shader semuanya memuncak dalam visualisasi struktur matematika yang indah. Saya harap Anda akan menemukan banyak minat dalam mengembangkan aplikasi OpenGL ES!