適用於 Android 的 OpenGL 教程:構建 Mandelbrot 集生成器
已發表: 2022-03-11OpenGL 是一個強大的跨平台 API,它允許在各種編程環境中非常接近地訪問系統硬件。
那麼,為什麼要使用它呢?
它為 2D 和 3D 圖形提供了非常低級的處理。 一般來說,這將避免我們因解釋或高級編程語言而遇到的任何麻煩。 不過,更重要的是,它還提供了對關鍵功能的硬件級別訪問:GPU。
GPU 可以顯著加速許多應用程序,但它在計算機中具有非常特殊的作用。 GPU 內核實際上比 CPU 內核慢。 如果我們要運行一個特別是沒有並發活動的串行程序,那麼它在 GPU 內核上幾乎總是比 CPU 內核慢。 主要區別在於GPU支持大規模並行處理。 我們可以創建稱為著色器的小程序,這些程序將同時在數百個內核上有效運行。 這意味著我們可以將原本極其重複的任務同時運行。
在本文中,我們將構建一個簡單的 Android 應用程序,該應用程序使用 OpenGL 在屏幕上呈現其內容。 在開始之前,重要的是您已經熟悉編寫 Android 應用程序的知識和一些類 C 編程語言的語法。 本教程的完整源代碼可在 GitHub 上找到。
OpenGL教程和Android
為了演示 OpenGL 的強大功能,我們將為 Android 設備編寫一個相對基本的應用程序。 現在,Android 上的 OpenGL 分佈在一個名為 OpenGL for Embedded Systems (OpenGL ES) 的子集下。 我們基本上可以將其視為 OpenGL 的精簡版,儘管所需的核心功能仍然可用。
我們將編寫一個看似簡單的應用程序,而不是編寫一個基本的“Hello World”:Mandelbrot 集合生成器。 Mandelbrot 集基於復數領域。 複雜分析是一個非常廣闊的領域,因此我們將更多地關注視覺結果,而不是其背後的實際數學。
版本支持
當我們製作應用程序時,我們希望確保它只分發給那些具有適當 OpenGL 支持的人。 首先在清單文件中聲明使用 OpenGL 2.0,在清單聲明和應用程序之間:
<uses-feature android:glEsVersion="0x00020000" android:required="true" />
此時,對 OpenGL 2.0 的支持無處不在。 OpenGL 3.0 和 3.1 的兼容性正在增強,但為其中任何一個編寫都會遺漏大約 65% 的設備,因此只有在確定需要額外功能時才做出決定。 它們可以通過將版本分別設置為“0x000300000”和“0x000300001”來實現。
應用架構
在 Android 上製作這個 OpenGL 應用程序時,您通常會使用三個主要的類來繪製表面:您的MainActivity
、 GLSurfaceView.Renderer
的擴展和GLSurfaceView
的實現。 從那裡,我們將創建各種封裝圖紙的模型。
MainActivity
,在本例中稱為FractalGenerator
,本質上只是要實例化您的GLSurfaceView
並路由任何全局更改。 這是一個基本上將成為您的樣板代碼的示例:
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(); } }
這也將是您希望在其中放置任何其他活動級別修飾符的類(例如沉浸式全屏)。
更深一層,我們有一個GLSurfaceView
的擴展,它將作為我們的主要視圖。 在這個類中,我們設置了版本,設置了一個渲染器,並控制了觸摸事件。 在我們的構造函數中,我們只需要使用setEGLContextClientVersion(int version)
設置 OpenGL 版本,並創建和設置我們的渲染器:
public FractalSurfaceView(Context context){ super(context); setEGLContextClientVersion(2); mRenderer = new FractalRenderer(); setRenderer(mRenderer); }
此外,我們可以使用setRenderMode(int renderMode)
設置渲染模式等屬性。 因為生成 Mandelbrot 集可能非常昂貴,所以我們將使用RENDERMODE_WHEN_DIRTY
,它只會在初始化時以及對requestRender()
進行顯式調用時渲染場景。 更多設置選項可以在GLSurfaceView
API 中找到。
在我們有了構造函數之後,我們可能想要覆蓋至少一個其他方法: onTouchEvent(MotionEvent event)
,它可以用於一般的基於觸摸的用戶輸入。 在這裡我不打算講太多細節,因為這不是本課的主要重點。
最後,我們進入我們的渲染器,這將是大部分照明工作或場景變化發生的地方。 首先,我們必須稍微了解一下矩陣在圖形世界中是如何工作和操作的。
線性代數快速課程
OpenGL 嚴重依賴矩陣的使用。 矩陣是表示坐標中廣義變化序列的一種非常緊湊的方式。 通常,它們允許我們進行任意旋轉、膨脹/收縮和反射,但只要稍加技巧,我們也可以進行平移。 從本質上講,這一切都意味著您可以輕鬆地執行您想要的任何合理更改,包括移動相機或使對象增長。 通過將矩陣乘以表示坐標的向量,我們可以有效地生成新的坐標系。
OpenGL 提供的 Matrix 類提供了許多我們需要的現成的計算矩陣的方法,但是即使在使用簡單的轉換時,理解它們的工作方式也是一個聰明的想法。
首先,我們可以回顧一下為什麼我們將使用四維向量和矩陣來處理坐標。 這實際上可以追溯到我們使用坐標來進行平移的想法:雖然僅使用三個維度就不可能在 3D 空間中進行平移,但添加第四個維度可以實現這種能力。
為了說明這一點,我們可以使用一個非常基本的通用比例/平移矩陣:
需要注意的是,OpenGL 矩陣是按列排列的,所以這個矩陣可以寫成{a, 0, 0, 0, 0, b, 0, 0, 0, 0, c, 0, v_a, v_b, v_c, 1}
,這與通常的閱讀方式垂直。 這可以通過確保以列形式出現的向量具有與矩陣相同的格式來合理化。
回到代碼
有了這些矩陣知識,我們就可以重新設計我們的渲染器了。 通常,我們將在此類中創建一個矩陣,該矩陣由三個矩陣的乘積形成:模型、視圖和投影。 這將被恰當地稱為 MVPMatrix。 您可以在此處了解有關細節的更多信息,因為我們將使用一組更基本的轉換 - Mandelbrot 集是一個二維全屏模型,它並不需要相機的概念。
首先,讓我們設置類。 我們需要實現 Renderer 接口所需的方法: onSurfaceCreated(GL10 gl, EGLConfig config)
、 onSurfaceChanged(GL10 gl, int width, int height)
和onDrawFrame(GL10 gl)
。 整個班級最終會看起來像這樣:
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 }
提供的代碼中還使用了兩個實用方法, checkGLError
和loadShaders
,以幫助調試和使用著色器。
在所有這些中,我們不斷地向下傳遞命令鏈以封裝程序的不同部分。 我們終於達到了可以編寫程序實際功能的程度,而不是如何對其進行理論上的更改。 這樣做時,我們需要創建一個模型類,其中包含需要為場景中的任何給定對象顯示的信息。 在復雜的 3D 場景中,這可能是動物或茶壺,但我們將做分形作為更簡單的 2D 示例。
在模型類中,我們編寫了整個類——沒有必須使用的超類。 我們只需要一個構造函數和某種帶有任何參數的draw方法。
這就是說,我們仍然需要一些變量,這些變量本質上是樣板文件。 讓我們看一下 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); }
很拗口,不是嗎? 幸運的是,這是程序的一部分,您根本不需要更改,保存模型的名稱。 如果您適當地更改類變量,這應該適用於基本形狀。

為了討論其中的一部分,讓我們看一些變量聲明:
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
在squareCoords
中,我們指定正方形的所有坐標。 請注意,屏幕上的所有坐標都表示為一個網格,左下角為(-1,-1)
,右上角為(1,1)
。
在drawOrder
中,我們根據構成正方形的三角形指定坐標的順序。 特別是為了一致性和速度,OpenGL 使用三角形來表示所有表面。 要製作一個正方形,只需剪下一條對角線(在本例中為0
到2
)即可得到兩個三角形。
為了將這兩個都添加到程序中,您首先必須將它們轉換為原始字節緩衝區,以直接將數組的內容與 OpenGL 接口相連接。 Java 將數組存儲為包含與 OpenGL 實現使用的基於指針的 C 數組不直接兼容的附加信息的對象。 為了解決這個問題,使用了ByteBuffers
,它存儲了對數組原始內存的訪問。
在我們為頂點和繪製順序輸入數據之後,我們必須創建我們的著色器。
著色器
創建模型時,必須製作兩個著色器:頂點著色器和片段(像素)著色器。 所有著色器都是用 GL 著色語言 (GLSL) 編寫的,這是一種基於 C 的語言,添加了許多內置函數、變量修飾符、原語和默認輸入/輸出。 在 Android 上,這些將作為最終字符串通過loadShader(int type, String shaderCode)
傳遞,這是渲染器中的兩個資源方法之一。 讓我們首先回顧一下不同類型的限定符:
-
const
:任何最終變量都可以聲明為常量,因此可以存儲其值以便於訪問。 如果像 π 這樣的數字在整個著色器中經常使用,則可以將它們聲明為常量。 編譯器可能會自動將未修改的值聲明為常量,具體取決於實現。 -
uniform
:Uniform 變量是對於任何單個渲染都被聲明為常量的變量。 它們本質上用作著色器的靜態參數。 -
varying
:如果一個變量被聲明為可變並在頂點著色器中設置,那麼它在片段著色器中被線性插值。 這對於創建任何類型的顏色漸變很有用,並且對於深度變化是隱含的。 -
attribute
:屬性可以被認為是著色器的非靜態參數。 它們表示特定於頂點的輸入集,並且只會出現在頂點著色器中。
此外,我們應該討論已添加的另外兩種類型的原語:
-
vec2
,vec3
,vec4
:給定維度的浮點向量。 -
mat2
,mat3
,mat4
:給定維度的浮點矩陣。
向量可以通過它們的分量x
、 y
、 z
和w
或r
、 g
、 b
和a
來訪問。 它們還可以生成具有多個索引的任何大小的向量:對於vec3 a
, a.xxyz
返回一個vec4
以及對應的a
值。
矩陣和向量也可以索引為數組,矩陣將返回一個只有一個分量的向量。 這意味著對於mat2 matrix
, matrix[0].a
是有效的並且將返回matrix[0][0]
。 使用這些時,請記住它們充當原語,而不是對象。 例如,考慮以下代碼:
vec2 a = vec2(1.0,1.0); vec2 b = a; bx=2.0;
這留下a=vec2(1.0,1.0)
和b=vec2(2.0,1.0)
,這不是人們對對象行為的期望,第二行會給b
一個指向a
的指針。
在 Mandelbrot Set 中,大部分代碼將在片段著色器中,這是在每個像素上運行的著色器。 名義上,頂點著色器適用於每個頂點,包括將基於每個頂點的屬性,例如顏色或深度的更改。 讓我們看一下分形的極其簡單的頂點著色器:
private final String vertexShaderCode = "attribute vec4 vPosition;" + "void main() {" + " gl_Position = vPosition;" + "}";
其中gl_Position
是OpenGL定義的一個輸出變量,用來記錄一個頂點的坐標。 在這種情況下,我們為我們設置gl_Position
的每個頂點傳遞一個位置。 在大多數應用程序中,我們會將vPosition
乘以MVPMatrix
,從而轉換我們的頂點,但我們希望分形始終是全屏的。 所有轉換都將使用局部坐標系完成。
片段著色器將是完成大部分工作以生成集合的地方。 我們將fragmentShaderCode
設置為以下內容:
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; }
大部分代碼只是集合如何工作的數學和算法。 注意幾個內置函數的使用: fract
、 abs
、 mix
、 sin
和clamp
,它們都對向量或標量進行操作並返迴向量或標量。 此外,使用dot
接受向量參數並返回一個標量。
現在我們已經設置好要使用的著色器,我們還有最後一步,即在我們的模型中實現draw
功能:
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"); }
該函數將所有參數傳遞給著色器,包括uniform
變換矩陣和attribute
位置。
在組裝程序的所有部分之後,我們終於可以運行它了。 如果處理了適當的觸摸支持,將繪製絕對令人著迷的場景:
浮點精度
如果我們再放大一點,我們開始注意到圖像中的一個故障:
這與它背後的數學運算完全無關,也與數字在 OpenGL 中存儲和處理的方式有關。 雖然最近對double
精度進行了支持,但 OpenGL 2.0 本身並不支持float
以外的任何東西。 我們專門將它們指定為在我們的著色器中具有precision highp float
的最高精度浮點,但即使這樣還不夠好。
為了解決這個問題,唯一的方法是使用兩個float
模擬double
。 這種方法實際上在本地實現的實際精度的一個數量級範圍內,儘管速度成本相當高。 如果希望獲得更高的準確性,這將作為練習留給讀者。
結論
通過一些支持類,OpenGL 可以快速支持複雜場景的實時渲染。 創建一個由GLSurfaceView
組成的佈局,設置它的Renderer
,並創建一個帶有著色器的模型,所有這些都在一個漂亮的數學結構的可視化中達到了高潮。 我希望您會對開發 OpenGL ES 應用程序產生同樣的興趣!