從顯示一張圖片開始學習OpenGL ES
前言
網上很多介紹OpenGL ES的文章,但由於OpenGL ES內容太多,所以這些文章難免過於臃腫雜亂,很難抓住重點,對於初學者來說最後還是雲裡霧裡。很多人(包括筆者本人)開始深入瞭解OpenGL ES是因為其涉及到實時濾鏡的應用,通常都會參考開源框架GPUImage的實現。如果沒有掌握基本的OpenGL Es的開發知識,很難弄懂其中程式碼緣由。
目前很流行的短視訊特效處理也有涉及到OpenGL的應用,於是已經踩坑無數的筆者下決心讓後來者少走彎路, 以最實用的場景——顯示一張圖片開始學習OpenGL ES.
本文章適合初學Android OpenGL ES 2.0+,以及想要了解OpenGL實時濾鏡實現原理的同學。
準備
在開始實現之前,先要講一些基本的知識,也是OpenGL ES 2D\3D繪圖的一些基本理論, 這裡我們只講繪製一張圖片後面需要用到的知識點 。
座標系
OpenGL擁有獨立的座標系,沒有任何變換前的初始座標系為三維座標系,x y z 取值範圍都是 [-1, 1]:

image
由於我們繪製的是2D圖片,因此可以簡化為二維座標系(只包含xy軸),座標系的原點在視窗中央,x 軸向右,y 軸向上:

image
這時就有疑問了,我們的螢幕或顯示視窗長寬的比例不是1:1(即不是正方形),怎麼跟OpenGL的初始世界座標系對應呢?如果我們沒有指定投影比例,那麼世界座標系則會填充整個顯示視窗,這樣就會導致拉伸變形,比如把上面的三角形投射在視窗時的顯示如下:

image
如果要指定投影比例就得應用到投影和矩陣變換,這裡我們仍使用初始的世界座標系,比如為了上面的三角形顯示正常,根據拉伸比例改變繪製的頂點座標即可。
頂點座標
在OpenGL ES中,支援三種類型的繪製:點、直線以及三角形;由這三種圖形組成其他所有圖形,比如我們看到的圓滑的球體也是由一個個三角形組成的,三角形越多看上去越圓滑:

image
在繪製圖形時我們需要提供相應的頂點位置,然後指定繪製方式去繪製這些頂點,以此呈現出我們想要的圖形。
後面我們顯示一張圖片的時候也需要繪製由兩個三角形組成的矩形,通過GL_TRIANGLE_STRIP
繪製方式(即每相鄰三個頂點組成一個三角形,為一系列相接三角形構成)繪製:

image
紋理貼圖(紋理對映)
我們需要顯示的是一張圖片,而上面一直說繪製圖形。這就好比我們往牆上貼牆紙,首先得搭建好房子,然後決定牆紙的每個地方貼在牆上的哪個位置,這個過程在OpenGL的繪製過程中叫做紋理貼圖,也叫紋理對映。
紋理貼圖時涉及到UV座標,所有的影象檔案都是二維的一個平面,通過這個平面的UV座標我們可以定點陣圖象上的任意一個象素,在android的uv座標的原點在左上角:

image
我們根據頂點的渲染順序,定義每個頂點uv座標,如下圖是我們定義的四個頂點,繪製成一個矩形:

image
那麼根據頂點的渲染順序,定義每個頂點uv座標:

image
指定好特定頂點對應的紋理座標後,頂點與頂點間的其餘部分會進行影象光滑插值處理,最後整張紋理就顯示出來啦。
光柵化
光柵化就是把頂點資料轉換為片元的過程。片元中的每一個元素對應於幀緩衝區中的一個畫素。
把虛擬世界中的三維幾何資訊投影到二維螢幕上,由於目前的顯示裝置螢幕都是離散化的(有一個個的畫素組成),因此需要把投影結果離散化,將其分解為一個個離散化的小單元,這些小單元稱之為片元(片段,Fragment).

image
著色器
OpenGL ES2.0使用可程式設計渲染管線,既然是可程式設計,那就需要我們自己寫著色器程式碼(GLSL),OpenGL中有頂點著色器(Vertex Shader)和片元著色器(Fragment Shader)。
頂點著色器主要用來處理圖形中每個頂點的最終位置。頂點資料由我們傳進著色器,由於繪製圖片不需要變換頂點,所以頂點著色器裡面我們不需要特殊處理每個頂點。而片元著色器主要處理每個片元的最終顏色,這裡我們只要根據傳進來的貼圖資料,進行紋理取樣即可。
開始動手實現!
在Android系統中使用OpenGL需要涉及到兩個最基本的的類,GLSurfaceView/">SurfaceView和GLSurfaceView.Renderer。
- GLSurfaceView繼承了SurfaceView類,它是專門用來顯示OpenGL渲染的圖形。可以這麼理解,GLSurfaceView就是前面我們說的用來顯示OpenGL圖形的視窗。
- GLSurfaceView.Renderer是GLSurfaceview的渲染器,通過GLSurfaceView.setRender()設定。
interface GLSurfaceView.Renderer { //在Surface建立的時候回撥,可以在這裡進行一些初始化操作 public void onSurfaceCreated(GL10 gl, EGLConfig config); //在Surface尺寸改變的的時候回撥,可以在這裡設定視窗的大小 public void onSurfaceChanged(GL10 gl, int width, int height); //繪製每一幀的時候回撥 public void onDrawFrame(GL10 gl); }
這裡需要特別說明, Render渲染器的回撥是在一個單獨的執行緒上執行的,因此我們進行OpenGL的相關操作也需要切換到該GL環境下的執行緒上 ,可以通過GLSurfaceView.queueEvent(Runnable)把操作放入GL環境的佇列中,也可以自己控制佇列,等待Render回撥時再執行佇列的操作。
程式碼如下:
public class GLShowImageActivity extends Activity { // 繪製圖片的原理:定義一組矩形區域的頂點,然後根據紋理座標把圖片作為紋理貼在該矩形區域內。 // 原始的矩形區域的頂點座標,因為後面使用了頂點法繪製頂點,所以不用定義繪製頂點的索引。無論視窗的大小為多少,在OpenGL二維座標系中都是為下面表示的矩形區域 static final float CUBE[] = { // 視窗中心為OpenGL二維座標系的原點(0,0) -1.0f, -1.0f, // v1 1.0f, -1.0f,// v2 -1.0f, 1.0f,// v3 1.0f, 1.0f,// v4 }; // 紋理也有座標系,稱UV座標,或者ST座標。UV座標定義為左上角(0,0),右下角(1,1),一張圖片無論大小為多少,在UV座標系中都是圖片左上角為(0,0),右下角(1,1) // 紋理座標,每個座標的紋理取樣對應上面頂點座標。 public static final float TEXTURE_NO_ROTATION[] = { 0.0f, 1.0f, // v1 1.0f, 1.0f, // v2 0.0f, 0.0f, // v3 1.0f, 0.0f, // v4 }; private GLSurfaceView mGLSurfaceView; private int mGLTextureId = OpenGlUtils.NO_TEXTURE; // 紋理id private GLImageHandler mGLImageHandler = new GLImageHandler(); private FloatBuffer mGLCubeBuffer; private FloatBuffer mGLTextureBuffer; private int mOutputWidth, mOutputHeight; // 視窗大小 private int mImageWidth, mImageHeight; // bitmap圖片實際大小 @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_01); mGLSurfaceView = findViewById(R.id.gl_surfaceview); mGLSurfaceView.setEGLContextClientVersion(2); // 建立OpenGL ES 2.0 的上下文環境 mGLSurfaceView.setRenderer(new MyRender()); mGLSurfaceView.setRenderMode(GLSurfaceView.RENDERMODE_WHEN_DIRTY); // 手動重新整理 } private class MyRender implements GLSurfaceView.Renderer { @Override public void onSurfaceCreated(GL10 gl, EGLConfig config) { GLES20.glClearColor(0, 0, 0, 1); GLES20.glDisable(GLES20.GL_DEPTH_TEST); // 當我們需要繪製透明圖片時,就需要關閉它 mGLImageHandler.init(); // 需要顯示的圖片 Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.thelittleprince); mImageWidth = bitmap.getWidth(); mImageHeight = bitmap.getHeight(); // 把圖片資料載入進GPU,生成對應的紋理id mGLTextureId = OpenGlUtils.loadTexture(bitmap, mGLTextureId, true); // 載入紋理 // 頂點陣列緩衝器 mGLCubeBuffer = ByteBuffer.allocateDirect(CUBE.length * 4) .order(ByteOrder.nativeOrder()) .asFloatBuffer(); mGLCubeBuffer.put(CUBE).position(0); // 紋理陣列緩衝器 mGLTextureBuffer = ByteBuffer.allocateDirect(TEXTURE_NO_ROTATION.length * 4) .order(ByteOrder.nativeOrder()) .asFloatBuffer(); mGLTextureBuffer.put(TEXTURE_NO_ROTATION).position(0); } @Override public void onSurfaceChanged(GL10 gl, int width, int height) { mOutputWidth = width; mOutputHeight = height; GLES20.glViewport(0, 0, width, height); // 設定視窗大小 adjustImageScaling(); // 調整圖片顯示大小。如果不呼叫該方法,則會導致圖片整個拉伸到填充視窗顯示區域 } @Override public void onDrawFrame(GL10 gl) { // 繪製 GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT | GLES20.GL_DEPTH_BUFFER_BIT); // 根據紋理id,頂點和紋理座標資料繪製圖片 mGLImageHandler.onDraw(mGLTextureId, mGLCubeBuffer, mGLTextureBuffer); } // 調整圖片顯示大小為居中顯示 private void adjustImageScaling() { float outputWidth = mOutputWidth; float outputHeight = mOutputHeight; float ratio1 = outputWidth / mImageWidth; float ratio2 = outputHeight / mImageHeight; float ratioMax = Math.min(ratio1, ratio2); // 居中後圖片顯示的大小 int imageWidthNew = Math.round(mImageWidth * ratioMax); int imageHeightNew = Math.round(mImageHeight * ratioMax); // 圖片被拉伸的比例 float ratioWidth = outputWidth / imageWidthNew; float ratioHeight = outputHeight / imageHeightNew; // 根據拉伸比例還原頂點 float[] cube = new float[]{ CUBE[0] / ratioWidth, CUBE[1] / ratioHeight, CUBE[2] / ratioWidth, CUBE[3] / ratioHeight, CUBE[4] / ratioWidth, CUBE[5] / ratioHeight, CUBE[6] / ratioWidth, CUBE[7] / ratioHeight, }; mGLCubeBuffer.clear(); mGLCubeBuffer.put(cube).position(0); } } }
對於著色器的語法和相關使用,這裡我不去贅述,我給的建議是, 先了解頂點著色器和片元著色器的主要作用,然後在把這篇教程理解一遍後,對著色器感興趣的話再去查詢相關的資料。這裡我們只是顯示一張圖片,使用的著色器程式碼很簡單,都加了註釋,不影響大家理解哈。
/** * 負責顯示一張圖片 */ public class GLImageHandler { // 資料中有多少個頂點,管線就呼叫多少次頂點著色器 public static final String NO_FILTER_VERTEX_SHADER = "" + "attribute vec4 position;\n" + // 頂點著色器的頂點座標,由外部程式傳入 "attribute vec4 inputTextureCoordinate;\n" + // 傳入的紋理座標 " \n" + "varying vec2 textureCoordinate;\n" + " \n" + "void main()\n" + "{\n" + "gl_Position = position;\n" + "textureCoordinate = inputTextureCoordinate.xy;\n" + // 最終頂點位置 "}"; // 光柵化後產生了多少個片段,就會插值計算出多少個varying變數,同時渲染管線就會呼叫多少次片段著色器 public static final String NO_FILTER_FRAGMENT_SHADER = "" + "varying highp vec2 textureCoordinate;\n" + // 最終頂點位置,上面頂點著色器的varying變數會傳遞到這裡 " \n" + "uniform sampler2D inputImageTexture;\n" + // 外部傳入的圖片紋理 即代表整張圖片的資料 " \n" + "void main()\n" + "{\n" + "gl_FragColor = texture2D(inputImageTexture, textureCoordinate);\n" +// 呼叫函式 進行紋理貼圖 "}"; private final LinkedList<Runnable> mRunOnDraw; private final String mVertexShader; private final String mFragmentShader; protected int mGLProgId; protected int mGLAttribPosition; protected int mGLUniformTexture; protected int mGLAttribTextureCoordinate; public GLImageHandler() { this(NO_FILTER_VERTEX_SHADER, NO_FILTER_FRAGMENT_SHADER); } public GLImageHandler(final String vertexShader, final String fragmentShader) { mRunOnDraw = new LinkedList<Runnable>(); mVertexShader = vertexShader; mFragmentShader = fragmentShader; } public final void init() { mGLProgId = OpenGlUtils.loadProgram(mVertexShader, mFragmentShader); // 編譯連結著色器,建立著色器程式 mGLAttribPosition = GLES20.glGetAttribLocation(mGLProgId, "position"); // 頂點著色器的頂點座標 mGLUniformTexture = GLES20.glGetUniformLocation(mGLProgId, "inputImageTexture"); // 傳入的圖片紋理 mGLAttribTextureCoordinate = GLES20.glGetAttribLocation(mGLProgId, "inputTextureCoordinate"); // 頂點著色器的紋理座標 } public void onDraw(final int textureId, final FloatBuffer cubeBuffer, final FloatBuffer textureBuffer) { GLES20.glUseProgram(mGLProgId); // 頂點著色器的頂點座標 cubeBuffer.position(0); GLES20.glVertexAttribPointer(mGLAttribPosition, 2, GLES20.GL_FLOAT, false, 0, cubeBuffer); GLES20.glEnableVertexAttribArray(mGLAttribPosition); // 頂點著色器的紋理座標 textureBuffer.position(0); GLES20.glVertexAttribPointer(mGLAttribTextureCoordinate, 2, GLES20.GL_FLOAT, false, 0, textureBuffer); GLES20.glEnableVertexAttribArray(mGLAttribTextureCoordinate); // 傳入的圖片紋理 if (textureId != OpenGlUtils.NO_TEXTURE) { GLES20.glActiveTexture(GLES20.GL_TEXTURE0); GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId); GLES20.glUniform1i(mGLUniformTexture, 0); } // 繪製頂點 ,方式有頂點法和索引法 GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4); // 頂點法,按照傳入渲染管線的頂點順序及採用的繪製方式將頂點組成圖元進行繪製 GLES20.glDisableVertexAttribArray(mGLAttribPosition); GLES20.glDisableVertexAttribArray(mGLAttribTextureCoordinate); GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0); } }
上面的程式碼中使用的OpenGlUtils類是封裝的一個工具類,主要負責載入紋理id,以及載入著色器程式碼,這裡不詳細貼出程式碼細節(都是一些模板程式碼),感興趣的同學待會可以在該文章對應的專案程式碼檢視哦。
最終我們的圖片就顯示出來啦。

image
注意事項
- 需要在GLSurfaceView中設定OpenGL的版本:
setEGLContextClientVersion(2); // 2.0
否則會報類似錯誤glDrawArrays is called with VERTEX_ARRAY client state disabled!
- 操作跟GPU相關的介面時需要在GLSurfaceView渲染的執行緒裡否則會報call to OpenGL ES API with no current context。比如獲取紋理id不能在介面初始化時,需要在onSurfaceCreated之後
完整程式碼地址
ofollow,noindex">https://github.com/1993hzw/OpenGLESIntroduction
後話
OpenGL ES的初步介紹就到此為止了,雖然一直想盡量通俗簡單地講解,但整個寫下來發現還是要涉及到很多東西,因此有不足的地方還望各位讀者指正!其實上面講的就是GPUImage這個開源庫的核心原理,同時目前流行的短視訊特效也是有不少涉及到OpenGL處理的,希望此文對大家學習OpenGL有些許幫助吧。
最後,謝謝大家的的支援!!!後面會根據這篇文章的反響,考慮是否需要繼續寫下一篇關於濾鏡的實現(其實主要是通過編寫著色器實現)。