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 😛