Android OpenGL ES 開發(二):繪製圖形

揚州慢發表於2020-12-29

OpenGL 繪製圖形步驟

上一篇介紹了 OpenGL 的相關概念,今天來實際操作,使用 OpenGL 繪製出圖形,對其過程有一個初步的瞭解。

OpenGL 繪製圖形主要概括成以下幾個步驟:

  1. 建立程式
  2. 初始化著色器
  3. 將著色器加入程式
  4. 連結並使用程式
  5. 繪製圖形

上述每個步驟還可能會被分解成更細的步驟,對應著多個 api,下面我們來逐個看下。

建立程式

使用 glCreateProgram 建立一個 program 物件並返回一個引用 ID,該物件可以附加著色器物件。注意要在OpenGL渲染執行緒中建立,否則無法渲染。

初始化著色器

著色器的初始化可以細分為三個步驟:

  1. 建立頂點、片元著色器物件
  2. 關聯著色器程式碼與著色器物件
  3. 編譯著色器程式碼

上一篇文章我們提到了頂點著色器和片元著色器都是可程式設計管道,因此著色器的初始化少不了對著色器程式碼的關聯與編譯,上面三個步驟對應的 api 為:

  1. glCreateShader(int type)
    • type:GLES20.GL_VERTEX_SHADER 代表頂點著色器、GLES20.GL_FRAGMENT_SHADER 代表片元著色器
  2. glShaderSource(int shader, String code)
    • shader:著色器物件 ID
    • code:著色器程式碼
  3. glCompileShader(code)
    • code:著色器物件 ID

著色器程式碼使用 GLSL 語言編寫,那程式碼要怎麼儲存並使用呢?我看到過三種方式,列出供大家參考:

  1. 字串變數儲存

這種應該是最直觀的寫法了,直接在對應的類中使用硬編碼儲存著色器程式碼,形如:

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

這種方式不是很建議,可讀性不好。

  1. 存放於 assets 目錄

assets 資料夾下的檔案不會被編譯成二進位制檔案,因此適於存放著色器程式碼,還可以配合 AndroidStudio 外掛 GLSL Support 實現語法高亮:

assets

然後再封裝讀取 assets 檔案的方法:

private fun loadCodeFromAssets(context: Context, fileName: String): String {
    var result = ""
    try {
        val input = context.assets.open(name)
        val reader = BufferedReader(InputStreamReader(input))
        val str = StringBuilder()
        var line: String?
        while ((reader.readLine().also { line = it }) != null) {
            str.append(line)
            str.append("\n") //注意結尾要新增換行符
        }
        input.close()
        reader.close()
        result = str.toString()
    } catch (e: IOException) {
        e.stackTrace
    }
    return result
}

需要注意的是要在結尾新增換行符,否則最後輸出的只是一行字串,不符合 GLSL 語法,自然也就無法正常使用。

  1. 存放於 raw 目錄

存放於 raw 目錄和 assets 目錄其實異曲同工,但有個好處是 raw 檔案會對映到 R 檔案,程式碼中可以通過 R.raw 的方法使用對應的著色器程式碼,但 raw 目錄下不能有目錄結構,這點需要做個取捨。

raw 目錄

同樣的,封裝讀取 raw 檔案的方法:

private fun loadCodeFromRaw(context: Context, fileId: Int): String {
    var result = ""
    try {
        val input = context.resources.openRawResource(fileId)
        val reader = BufferedReader(InputStreamReader(input))
        val str = StringBuilder()
        var line: String?
        while ((reader.readLine().also { line = it }) != null) {
            str.append(line)
            str.append("\n")
        }
        input.close()
        reader.close()
        result = str.toString()
    } catch (e: IOException) {
        e.stackTrace
    }
    return result
}

著色器程式可能編譯失敗,可以使用 glGetShaderiv 方法獲取著色器編譯狀況:

var compileStatus = IntArray(1)
//獲取著色器的編譯情況
GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compileStatus, 0);
if (compileStatus[0] == 0) {//若編譯失敗則顯示錯誤日誌並
    GLES20.glDeleteShader(shader);//刪除此shader
    shader = 0;
}

將著色器加入程式

初始化著色器後拿到著色器物件 ID,再使用 glAttachShader 將著色器物件附加到 program 物件上。

GLES20.glAttachShader(mProgram, shader) //將頂點著色器加入到程式
GLES20.glAttachShader(mProgram, fragmentShader) //將片元著色器加入到程式中

連結並使用程式

使用 glLinkProgram 為附加在 program 物件上的著色器物件建立可執行檔案。連結可能失敗,可以通過 glGetProgramiv 查詢 program 物件狀態:

GLES20.glGetProgramiv(mProgram, GLES20.GL_LINK_STATUS, linkStatus, 0)
// 如果連線失敗,刪除這程式
if (linkStatus[0] == 0) {
    GLES20.glDeleteProgram(mProgram)
    mProgram = 0
}

連結成功後,通過 glUseProgram 使用程式,將 program 物件的可執行檔案作為當前渲染狀態的一部分。

繪製圖形

終於到最核心的繪製圖形了,前面我們初始化了 OpenGL 程式以及著色器,現在需要準備繪製相關的資料,繪製出一個圖形最基礎的兩個資料就是頂點座標和圖形顏色。

定義頂點資料

嘗試畫一個三角定,定義三個頂點,每個頂點包含三個座標 x,y,z。手機螢幕中心座標系(0,0,0),左上角座標(-1, 1, 0)。

private val points = floatArrayOf(
    0.0f, 0.0f, 0.0f, //螢幕中心
    -1.0f, -1.0f, 0.0f, //左下角
    1.0f, -1.0f, 0.0f //右下角
)
private val sizePerPoint = 3 //每個頂點三個座標
private val byteSize = sizePerPoint * 4 //每個頂點之前位元組偏移量,float 四個位元組
private val pointNum = points.size / sizePerPoint //頂點數量
private var vertexBuffer: FloatBuffer? = null //頂點資料浮點緩衝區

OpenGL 修改頂點屬性時接受的資料型別為緩衝區型別 Buffer,因此還需要將陣列型別轉為 Buffer:

fun createFloatBuffer(array: FloatArray): FloatBuffer {
    val bb = ByteBuffer.allocateDirect(array.size * 4);//float 四個位元組
    bb.order(ByteOrder.nativeOrder()) //使用本機硬體裝置的位元組順序
    val buffer = bb.asFloatBuffer() //建立浮點緩衝區
    buffer.put(array) //新增資料
    buffer.position(0);//從第一個座標開始讀取
    return buffer
}

為頂點屬性賦值

頂點著色器程式碼:

attribute vec4 vPosition;

void main() {
    gl_Position = vPosition;
}

頂點著色器的每個輸入變數叫頂點屬性,著色器中定義了 vPosition 用於存放頂點資料,先使用 GLES20.glGetAttribLocation 獲取 vPosition 控制程式碼,再使用 GLES20.glVertexAttribPointer 為 vPosition 新增我們定義好的頂點資料。

public static void glVertexAttribPointer(
        int indx,
        int size,
        int type,
        boolean normalized,
        int stride,
        java.nio.Buffer ptr
    )

該方法接收六個引數,分別代表:

  • indx:要修改的頂點屬性的控制程式碼
  • size:每個頂點的座標數,如果只有 x、y 兩個座標值就傳 2
  • type:座標資料型別
  • normalized:指定在訪問定點資料值時是應將其標準化(true)還是直接轉換為定點值(false)
  • stride:每個頂點之間的位元組偏移量
  • ptr:頂點座標 Buffer
val vPositionHandle = GLES20.glGetAttribLocation(mProgram, "vPosition") //獲取 vPosition 控制程式碼
GLES20.glVertexAttribPointer(vPositionHandle, sizePerPoint, GLES20.GL_FLOAT, false, byteSize, vertexBuffer) //為 vPosition 新增頂點資料

如果 glGetAttribLocation 返回值為 -1 代表獲取失敗,可能 program 物件或著色器物件裡沒有對應的屬性。

還需要注意的是,為頂點屬性賦值時,glVertexAttribPointer 建立了 CPU 和 GPU 之前的邏輯連線,實現了 CPU 資料上傳到 GPU。但 GPU 資料是否可見,也就是頂點著色器能否讀到資料,則由是否啟用了對應的屬性決定。預設情況下頂點屬性都是關閉的,可以通過 glEnableVertexAttribArray 啟用屬性,允許著色器讀取 GPU 資料。

定義片元顏色

OpenGL 定義色值使用 float 陣列,可以使用色值轉換線上工具將十六進位制色值轉換為 float 值

private val colors = floatArrayOf(
    0.93f, 0.34f, 0.16f, 1.00f
)

為顏色屬性賦值

片元著色器程式碼:

precision mediump float;
uniform vec4 zColor;
void main() {
    gl_FragColor = zColor;
}

顏色屬性定義為 uniform 變數,為顏色屬性賦值一樣需要先獲取屬性控制程式碼,再向屬性新增資料:

mColorHandle = GLES20.glGetUniformLocation(mProgram, "zColor"); //獲取 zColor 控制程式碼
GLES20.glUniform4fv(zColorHandle, 1, color, 0); //為 zColor 新增資料

繪製

GLES20.glEnableVertexAttribArray(vPositionHandle) //啟用頂點控制程式碼
GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, pointNum) //渲染圖元
GLES20.glDisableVertexAttribArray(vPositionHandle) //禁用頂點控制程式碼

繪製三角形

噹噹噹當,三角形出現了。上次只是繪製了背景色,今天又向前邁一步繪製出圖形。但是顯而易見這並不是一個等邊三角形,和我們定義的座標有所出入,這是因為 OpenGL 螢幕座標系是一個正方形並且分佈均勻的座標系,因此將圖形繪製到非正方形螢幕上時圖形會被壓縮或者拉伸。下一篇文章我們會使用投影變換來解決這個問題。

Comming soon ?

相關文章