OpenGL 學習系列--基礎的繪製流程

glumes發表於2018-05-08

開始探索奇妙的 3D 世界了,OpenGL 搞起。

OpenGL 簡介

OpenGL 是一種應用程式程式設計介面,它是一種可以對圖形硬體裝置特性進行訪問的軟體庫。

重點:OpenGL 是一種介面,既然是介面,那麼就必然要有實現。

事實上,它的實現是由顯示裝置廠商提供的,而且依賴於廠商提供的硬體裝置。

OpenGL 常用於 CAD、虛擬實境、科學視覺化程式和電子遊戲開發。

在 Android 上使用的是 OpenGL ES,它是 OpenGL 的子集,在 OpenGL 的基礎之上裁剪掉了一些非必要的部分,主要是針對手機、PAD 和遊戲主機等嵌入式裝置設計的。

在 Android 上開發 OpenGL 既可以使用 Java 也可以使用 C ,話不多說,擼起袖子就是幹!

OpenGL 的繪製流程

學習 OpenGL 的繪製,最好還是先從 2D 繪製開始,逐漸過渡到 3D 繪製。

Android 為 OpenGL 的繪製提供了一個特定的檢視GLSurfaceView,就像 SurfaceView 一樣,它渲染繪製也可以在一個單獨的執行緒中,而非主執行緒,畢竟 GLSurfaceView 就是繼承自 SurfaceView 的。

在使用 GLSurfaceView 時,需要通過setRenderer方法給它設定一個渲染器,而主要的渲染工作就是由渲染器Renderer完成了。

通過繼承GLSurfaceView.Renderer類來實現我們自己的渲染器程式,主要有如下三個方法:

  • onSurfaceCreated
    • 當 GLSurfaceView 建立時呼叫,主要做一些準備工作。
  • onSurfaceChanged
    • 當 GLSurfaceView 檢視改變時呼叫,第一次建立時也會被呼叫。
  • onDrawFrame
    • 每一幀繪製時被呼叫。

實現渲染器程式時,首先要考慮三個問題:

  • 在什麼地方進行繪製?
  • 繪製成什麼形狀?
  • 用什麼顏色來繪製?

而我們的程式也主要以解決上述三個問題為主,下面以 OpenGL 繪製一個點來講解。

OpenGL 座標

手機螢幕的座標是以左上角為原點(0,0),向右為 X 軸正方形,向下為 Y 軸正方向,而 OpenGL 也有著它自己的一套座標定義。

假設我們定義了一個點的座標(4.3,2.1),也就是它的 X 軸座標和 Y 軸座標,而 OpenGL 最後會把我們定義的座標對映手機螢幕的實際物理座標上。

無論是 X 座標還是 Y 座標,OpenGL 都會把手機螢幕對映到 [-1,1] 的範圍內。也就是說:螢幕的左邊對應 X 軸的 -1 ,螢幕的右邊對應 +1,螢幕的底邊會對應 Y 軸的 -1,而螢幕的頂邊就對應 +1。

不管螢幕是什麼形狀和大小,這個座標範圍都是一樣的,例如下圖所示:

https://user-gold-cdn.xitu.io/2018/5/8/1633d19c44e08a4f?w=258&h=240&f=png&s=16011

所以,上面定義的座標(4.3,2.1),最後是會被對映到手機螢幕之外的,處於不可見的狀態。

這裡,假定繪製一個位於原點的點(0,0),那麼對映之後的位置就手機螢幕的中心了。

基本圖元

解決了位置的問題,接下來就是形狀和顏色的問題。

如同 Android 的 Canvas 物件提供了一些方法來完成基本的繪製:drawPoint、drawRect、drawLine 等,OpenGL 程式也提供且僅提供了三種基本的圖元來完成繪製。

  • 三角形

其他的所有形狀都是基於這三種圖元來完成的,比如矩形就可以看成是兩個三角形拼成的。

由於我們要繪製的是一個點,在座標系中,一個座標就可以代替一個點了。假設要繪製一個三角形,那麼在座標系中就需要三個點才行了。

接下來就涉及到 OpenGL 如何把定義的點的資料繪製出來了。

渲染管線

首先要明白一個概念渲染管線

根據百度百科的定義,渲染管線也稱為渲染流水線畫素流水線畫素管線,是顯示晶片內部(GPU)處理圖形訊號相互獨立的並行處理單元。

顯示卡的渲染管線就是顯示核心的重要組成部分,是負責給圖形配上顏色的一組專門通道。渲染管線的數量是決定顯示晶片效能和檔次的最重要的引數之一。

現階段的顯示卡都是分為頂點渲染畫素渲染的。在顯示卡,內部分為兩大區域,一個區域是頂點渲染單元(也叫頂點著色),主要負責描繪圖形,也就是建立模型。一個區域是畫素渲染管線,主要負責把頂點繪出的圖形填上顏色。

https://user-gold-cdn.xitu.io/2018/5/8/1633d19c4a073089?w=1614&h=410&f=png&s=272841

上圖就是 OpenGL 中渲染管線的一個處理流程。

可以看到,流程圖從讀取頂點資料開始,然後後執行兩個著色器:

  • 頂點著色器
    • 主要負責描繪圖形,也就是根據頂點座標,建立圖形模型。
  • 片段著色器
    • 主要負責把頂點繪出的圖形填上顏色。

由於這兩個著色器對於最後圖形顯示效果至關重要,並且它們還是可以通過程式設計來控制的,這也是為什麼可程式設計渲染管線要優於固定程式設計管線了。

事實上,隨著顯示技術的發展,渲染管線將不復存在了,頂點著色器和渲染管線統一被流處理器(Stream Processors)所取代。

但是目前手機上 OpenGL 還是使用渲染管線中,有了渲染管線,我們就可以完成點的形狀繪製和著色兩大問題了,接下來的工作也是圍繞這條渲染管線開始的。

記憶體拷貝

當定義完了頂點座標,並且明確了下一步:頂點座標將要通過渲染管線進行一系列處理,那麼接下來就是如何把頂點座標傳遞給渲染管線了。

OpenGL 的實現是由顯示裝置廠商提供的,它作為本地系統庫直接執行在硬體上。而我們定義的頂點 Java 程式碼是執行在虛擬機器上的,這就涉及到了如何把 Java 層的記憶體複製到 Native 層了。

一種方法是直接使用JNI開發,直接呼叫本地系統庫,也就是用 C++ 來開發 OpenGL,這種實現肯定要學會的。

另一種方法就是在 Java 層把記憶體塊複製到 Native 層。

使用ByteBuffer.allocateDirect()方法就可以分配一塊 Native 記憶體,這塊記憶體不會被 Java 的垃圾回收器管理。

它的使用方法大致都一樣,抽出公共的模板:

   // 宣告一個位元組緩衝區 FloatBuffer
   private FloatBuffer floatBuffer;
   // 定義頂點資料
   float[] vertexData = new float[16];
   // FloatBuffer 初始化工作並放入頂點資料
   floatBuffer = ByteBuffer
       .allocateDirect(vertexData.length * Constant.BYTES_PRE_FLOAT)
       .order(ByteOrder.nativeOrder())
       .asFloatBuffer()
       .put(vertexData);
複製程式碼

allocateDirect方法分配了記憶體並指定了大小之後,下一步就是告訴 ByteBuffer 按照本地位元組序組織它的內容。本地位元組序是指,當一個值佔用多個位元組時,比如 32 位整型數,位元組按照從最重要位到最不重要位或者相反順序排列。

接下來asFloatBuffer方法可以得到一個反映底層位元組的 FloatBuffer 類例項,避免直接操作單獨的位元組,而是使用浮點數。

最後,通過put方法就可以把資料從 Java 層記憶體複製到 Native 層了,當程式結束時,這塊記憶體就會被釋放掉。

頂點著色器

接下來可程式設計的部分了,定義著色器(Shader)程式。

使用不同的著色器對輸入的圖後設資料執行計算操作,判斷它們的位置、顏色,以及其他渲染屬性。

首先是頂點著色器。

在渲染管線中傳輸的每個頂點座標位置,OpenGL 都會呼叫一個頂點著色器來處理頂點相關的資料,這個處理過程可以很複雜,也可以很簡單。

想要定義一個著色器程式,還要通過一種特殊的語言去編寫:OpenGL Shading Language,簡稱GLSL.

GLSL語言類似於 C 語言或者 Java 語言,它的程式入口也是一個名為main的函式。關於 GLSL 的部分,完全可以單獨寫一篇部落格了,暫時先不詳細闡述。

下面就是一個簡單的頂點著色器程式:

attribute vec4 a_Position;
void main()
{
    gl_Position = a_Position;
    gl_PointSize = 30.0;
}
複製程式碼

著色器類似於一個函式呼叫的方式——資料傳輸進來,經過處理,然後再傳輸出去。

其中,gl_Positiongl_PointSize就是著色器中的特殊全域性變數,它接收輸入。

a_Position就是我們定義的一個變數,它是vec4型別的。而attribute只能存在於頂點著色器中,一般用於儲存頂點資料,它可以在資料緩衝區中讀取資料。

資料快取區中的頂點座標會賦值給 a_Position ,a_Position 會傳遞給 gl_Position。

而 gl_PointSize 則固定了點的大小為 30。

有了頂點著色器,就能夠為每個頂點生成最終的位置,接下來就是定義片段著色器。

根據上圖的渲染管線,頂點著色器到片段著色器之間,還要經過組裝圖元光柵化圖元

光柵化技術

移動裝置的螢幕由成百上千個小的、獨立的部件組成,他們稱為畫素。每個畫素通常由三個單獨的子元件構成,它們發出紅色、綠色和藍色的光,因為每個畫素都非常小,人的眼睛會把紅色、綠色和藍色的光混合在一起,從而創造出巨量的顏色範圍。

OpenGL 就是通過 光柵化 技術的過程把每個點、直線及三角形分解成大量的小片段,它們可以對映到移動裝置螢幕的畫素上,從而生成一幅影像。這些片段類似於螢幕上的畫素,每一個都包含單一的純色。

如下圖所示:

https://user-gold-cdn.xitu.io/2018/5/8/1633d19c4a364568?w=285&h=240&f=png&s=39244

OpenGL 通過光柵化技術把一條直線對映為一個片段集合,顯示系統通常會把這些片段直接對映到螢幕上的畫素,結果一個片段就對應一個畫素。

明白了這樣的顯示原理,就可以在其中做一些操作了,這就是片段著色器的功能了。

片段著色器

片段著色器的主要目的就是告訴 GPU 每個片段的最終顏色應該是什麼。

對於基本圖元的每個片段,片段著色器都會被呼叫一次,因此,如果一個三角形被對映到 10000 個片段,那麼片段著色器就會被呼叫 10000 次。

下面就是一個簡單的片段著色器程式:

precision mediump float;
uniform vec4 u_Color;
void main()
{
    gl_FragColor = u_Color;
}
複製程式碼

其中,gl_FragColor變數就是 OpenGL 最終渲染出來的顏色的全域性變數,而u_Color就是我們定義的變數,通過在 Java 層繫結到 u_Color變數並給它賦值,就會傳遞到 Native 層的gl_FragColor中。

而第一行的mediump指的就是片段著色器的精度了,有三種可選,這裡用中等精度就行了。uniform則表示該變數是不可變的了,也就是固定顏色了,目前顯示固定顏色就好了。

編譯 OpenGL 程式

明白了著色器的功能和光柵化技術之後,對渲染管線的流程也就更加清楚了,接下來就是編譯 OpenGL 的程式了。

編譯 OpenGL 程式基本流程如下:

  • 編譯著色器
  • 建立 OpenGL 程式和著色器連結
  • 驗證 OpenGL 程式
  • 確定使用 OpenGL 程式

編譯著色器

建立新的檔案編寫著色器程式,然後再從檔案以字串的形式中讀取檔案內容。這樣會比把著色器程式寫成字串的形式更加清晰。

當讀取了著色器程式內容之後,就可以編譯了。

   // 編譯頂點著色器
    public static int compileVertexShader(String shaderCode) {
        return compileShader(GL_VERTEX_SHADER, shaderCode);
    }
    
    // 編譯片段著色器
    public static int compleFragmentShader(String shaderCode) {
        return compileShader(GL_FRAGMENT_SHADER, shaderCode);
    }

    // 根據型別編譯著色器
    private static int compileShader(int type, String shaderCode) {
	    // 根據不同的型別建立著色器 ID
        final int shaderObjectId = glCreateShader(type);
        if (shaderObjectId == 0) {
            return 0;
        }
        // 將著色器 ID 和著色器程式內容連線
        glShaderSource(shaderObjectId, shaderCode);
        // 編譯著色器
        glCompileShader(shaderObjectId);
		// 以下為驗證編譯結果是否失敗
        final int[] compileStatsu = new int[1];
        glGetShaderiv(shaderObjectId, GL_COMPILE_STATUS, compileStatsu, 0);
        if ((compileStatsu[0] == 0)) {
	        // 失敗則刪除
            glDeleteShader(shaderObjectId);
            return 0;
        }
        return shaderObjectId;
    }
複製程式碼

以上程式主要就是通過glCreateShader方法建立了著色器 ID,然後通過glShaderSource連線上著色器程式內容,接下來通過glCompileShader編譯著色器,最後通過glGetShaderiv驗證是否失敗。

glGetShaderiv函式比較通用,在著色器階段和 OpenGL 程式階段都會通過它來驗證結果。

建立 OpenGL 程式和著色器連結

接下來就是建立 OpenGL 程式並加著色器加進來。

  public static int linkProgram(int vertexShaderId, int fragmentShaderId) {
		// 建立 OpenGL 程式 ID
        final int programObjectId = glCreateProgram();
        if (programObjectId == 0) {
            return 0;
        }
        // 連結上 頂點著色器
        glAttachShader(programObjectId, vertexShaderId);
        // 連結上 片段著色器
        glAttachShader(programObjectId, fragmentShaderId);
        // 連結著色器之後,連結 OpenGL 程式
        glLinkProgram(programObjectId);
        final int[] linkStatus = new int[1];
        // 驗證連結結果是否失敗
        glGetProgramiv(programObjectId, GL_LINK_STATUS, linkStatus, 0);
        if (linkStatus[0] == 0) {
	        // 失敗則刪除 OpenGL 程式
            glDeleteProgram(programObjectId);
            return 0;
        }
        return programObjectId;
    }
複製程式碼

首先通過glCreateProgram程式建立 OpenGL 程式,然後通過glAttachShader將著色器程式 ID 新增上 OpenGL 程式,接下來通過glLinkProgram連結 OpenGL 程式,最後通過glGetProgramiv來驗證連結是否失敗。

驗證 OpenGL 程式

連結了 OpenGL 程式後,就是驗證 OpenGL 是否可用。

 public static boolean validateProgram(int programObjectId) {
        glValidateProgram(programObjectId);
        final int[] validateStatus = new int[1];
        glGetProgramiv(programObjectId, GL_VALIDATE_STATUS, validateStatus, 0);
        return validateStatus[0] != 0;

    }
複製程式碼

通過glValidateProgram函式驗證,並再次通過glGetProgramiv函式驗證是否失敗。

確定使用 OpenGL 程式

當一切完成後,就是確定使用該 OpenGL 程式了。

// 建立 OpenGL 程式過程
 public static int buildProgram(Context context, int vertexShaderSource, int fragmentShaderSource) {
        int program;

        int vertexShader = compileVertexShader(
                TextResourceReader.readTextFileFromResource(context, vertexShaderSource));

        int fragmentShader = compleFragmentShader(
                TextResourceReader.readTextFileFromResource(context, fragmentShaderSource));

        program = linkProgram(vertexShader, fragmentShader);

        validateProgram(program);

        return program;
    }

// 建立完畢後,確定使用
    mProgram = ShaderHelper.buildProgram(context, R.raw.point_vertex_shader
                , R.raw.point_fragment_shader);

        glUseProgram(mProgram);
複製程式碼

將上述的過程合併起來,buildProgram函式返回的 OpenGL 程式 ID 即可,通過glUseProgram函式表示使用該 OpenGL 程式。

繪製

完成了 OpenGL 程式的編譯,就是最後的繪製了,再回到渲染器 Renderer裡面。

public class PointRenderer extends BaseRenderer {

    private Point mPoint;
    public PointRenderer(Context mContext) {
        super(mContext);
    }

    @Override
    public void onSurfaceCreated(GL10 gl, EGLConfig config) {
        super.onSurfaceCreated(gl, config);
        glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
        // 在 onSurfaceCreated 裡面初始化,否則會報執行緒錯誤
        mPoint = new Point(mContext);
        // 繫結相應的頂點資料
        mPoint.bindData();
    }
    
    @Override
    public void onSurfaceChanged(GL10 gl, int width, int height) {
        // 確定視口大小
        glViewport(0, 0, width, height);
    }

    @Override
    public void onDrawFrame(GL10 gl) {
        // 清屏
        glClear(GL_COLOR_BUFFER_BIT);
        // 繪製
        mPoint.draw();
    }
}
複製程式碼

onSurfaceCreated函式裡面做初始化工作,繫結資料等;在onSurfaceChanged方法裡面確定檢視大小,在onDrawFrame裡面執行繪製。

為了簡化渲染流程,把所有的操作都放在放在要渲染的物件裡面去了,宣告一個 Point 物件,代表要繪製的點。

public class Point extends BaseShape {

    // 著色器中定義的變數,在 Java 層繫結並賦值
    private static final String U_COLOR = "u_Color";
    private static final String A_POSITION = "a_Position";
    private int aColorLocation;
    private int aPositionLocation;
    
    float[] pointVertex = {
            0f, 0f
    };

    public Point(Context context) {
        super(context);
        mProgram = ShaderHelper.buildProgram(context, R.raw.point_vertex_shader
                , R.raw.point_fragment_shader);
        glUseProgram(mProgram);
        vertexArray = new VertexArray(pointVertex);
        POSITION_COMPONENT_COUNT = 2;
    }

    @Override
    public void bindData() {
        //繫結值
        aColorLocation = glGetUniformLocation(mProgram, U_COLOR);
        aPositionLocation = glGetAttribLocation(mProgram, A_POSITION);
        // 給繫結的值賦值,也就是從頂點資料那裡開始讀取,每次讀取間隔是多少
        vertexArray.setVertexAttribPointer(0, aPositionLocation, POSITION_COMPONENT_COUNT,
                0);
    }

    @Override
    public void draw() {
        // 給繫結的值賦值
        glUniform4f(aColorLocation, 0.0f, 0.0f, 1.0f, 1.0f);
        glDrawArrays(GL_POINTS, 0, 1);
    }
}
複製程式碼

在 Point 的建構函式中,編譯並使用 OpenGL 程式,而在 bindData函式中,通過glGetUniformLocationglGetAttribLocation函式繫結了我們在 OpenGL 中宣告的變數u_Colora_Position,要注意的是,attribute型別和uniform型別所對應的方法是不同的,最後通過給POSITION_COMPONENT_COUNT變數賦值,指定每個頂點資料的個數為 2 。

繫結了變數之後,接下來就是給他們賦值了,對於uniform型別變數,由於是固定值,所以直接呼叫glUniform4f方法給其賦值就好了,而attribute型別變數,則需要對應頂點資料中的值了,vertexArray.setVertexAttribPointer方法就是完成這個任務的。

	// 給某個頂點資料繫結值,並 Enable 使能
    public void setVertexAttribPointer(int dataOffset, int attributeLocation, int componentCount, int stride) {
        floatBuffer.position(dataOffset);
        glVertexAttribPointer(attributeLocation, componentCount, GL_FLOAT, false, stride, floatBuffer);
        glEnableVertexAttribArray(attributeLocation);
        floatBuffer.position(0);
    }
複製程式碼

setVertexAttribPointer方法中,使用了glVertexAttribPointer方法來繫結值,它的引數釋義如下所示:

https://user-gold-cdn.xitu.io/2018/5/8/1633d19c49dcd183?w=640&h=343&f=png&s=194254

通過glEnableVertexAttribArray方法來開啟使用即可。

最後通過glDrawArrays方法來執行最後的繪製,GL_POINTS代表繪製的型別,而引數0,1則代表繪製的點的範圍,它是一個左閉右開的區間。

以上步驟就完成了一個點的繪製,如圖所示:

https://user-gold-cdn.xitu.io/2018/5/8/1633d19c46dbcb8a?w=151&h=240&f=png&s=8495

具體程式碼詳情,可以參考我的 Github 專案:

github.com/glumes/Andr…

小結

使用 OpenGL 進行繪製的原理,也就是按照 GPU 的渲染管線流程,提供了頂點資料之後,執行頂點著色器,然後執行片段著色器,最後對映到手機螢幕上。

而作為可程式設計的階段,我們就是在頂點著色器和片段著色器中做我們想要的處理,編寫了著色器程式碼之後,通過編譯連結成 OpenGL 程式。然後給 OpenGL 中設定的變數繫結對應的值,從頂點資料何處開始讀取值。到這裡,一切準備工作就做完了。

最後就在在渲染器 Renderer 中開始繪製了。

參考

1、《OpenGL ES 應用開發實踐指南》 2、《OpenGL 程式設計指南》(原書第八版) 3、https://github.com/glumes/AndroidOpenGLTutorial

問題

1、https://stackoverflow.com/questions/11286819/opengl-es-api-with-no-current-context

提示報錯:

call to OpenGL ES API with no current context (logged once per thread)

是因為,在 Render 的建構函式中初始化變數,並不是 OpenGL 的執行緒中。而是在主執行緒中。

最後,如果覺得文章不錯,歡迎關注微信公眾號:【紙上淺談】

紙上淺談

相關文章