簡述
最近一段時間由于項目上用到了濾鏡功能,所以一直都在學習opengles的相關知識。opengles是opengl的一個子集,是opengl針對移動端的版本。Android手機上現在最多的版本應該是opengles3.0+了,不過通過前段時間的學習,發現網上的教程大多數還是2.0的版本,3.0的資料不僅少,而且大多數只是opengles3.0的基礎功能。出于這個原因,嘗試寫幾篇基于OpenGLES3.0的系列博客。最終目標是從零到可以自己編寫實現一個視頻濾鏡應用。前半部分介紹基本的OpenGLES3.0 的基礎知識,后半部分介紹基于OpenGL的視頻濾鏡制作。本系列博客側重用代碼說話,設計到的原理部分可能寫的比較粗淺,畢竟水平有限??
在這推薦兩本相關書籍:
《OPENGL ES 3.0編程指南》
《OpenGLES應用開發實踐指南Android卷》
第一本以概念講解為主,第二本以實際應用為主,不過第二本使用的2.0版本。
Hello Word
大家都知道每一門編程語言都有一個Hello Word,說白了就是在控制臺輸出一個字符串。那么OpenGL(下面OpenGL 代指OpenGLES3.0)的Hello Word應該是什么呢。其實在OpenGL的世界里邊是沒有控制臺的,可以輸出可觀察信息的只有繪制表面也,并且OpenGL不能輸出字符,只能輸出三種基本圖元:點、線、三角形,OpenGL繪制出來的所有東西都是由這三種基本元素組成,和Android的canvas api有些類似只不過canvas支持的圖元更多一些。對應編程語言的Hello Word,咱們就繪制一個最簡單的點。
想在Android系統里邊使用OpenGL首先需要一個繪制表面,也就是Android里邊的一個View來承載OpenGL繪制出來的界面。Android里邊有一種專門用來處理OpenGL的View ——GLSurfaceView。現在咱們就用GLSurfaceView+OpenGL來繪制一個點。
簡單介紹一下,GLSurfaceView繼承于SurfaceView,不同的是GLSurfaceView會幫你初始化一個OpenGLES的環境,所以GLSurfaceView能辦到的事SurfaceView其實也能辦到的,只不過需要自己額外的初始化一個OpenGl的環境。
OK,直接上代碼
class MainActivity : AppCompatActivity() {
lateinit var rootLayout: RelativeLayout
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
rootLayout = findViewById(R.id.root)
val activityManager =getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val configurationInfo = activityManager.deviceConfigurationInfo
val supportsEs3 = configurationInfo.reqGlEsVersion >= 0x30000
val layoutParams: RelativeLayout.LayoutParams = RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
val glSurfaceView = GLSurfaceView(this)
if (supportsEs3) {
glSurfaceView.setEGLContextClientVersion(3)
}
rootLayout.addView(glSurfaceView, layoutParams)
glSurfaceView.setRenderer(PointRenderer(this))
}
}
class PointRenderer(var context: Context) : GLSurfaceView.Renderer {
var pointProgram = -1
var vertexBuffer: FloatBuffer
var avPosition = -1
private val POSITION_VERTEX = floatArrayOf(
0.0f, 0.0f, 0.0f
)
init {
vertexBuffer = ByteBuffer.allocateDirect(POSITION_VERTEX.size * 4)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
.put(POSITION_VERTEX)
vertexBuffer.position(0)
}
override fun onDrawFrame(gl: GL10?) {
GLES30.glClear(GLES30.GL_COLOR_BUFFER_BIT)
GLES30.glUseProgram(pointProgram)
GLES30.glEnableVertexAttribArray(avPosition)
GLES30.glVertexAttribPointer(avPosition, 3, GLES30.GL_FLOAT, false, 0, vertexBuffer)
GLES30.glDrawArrays(GLES30.GL_POINTS, 0, 1)
GLES30.glDisableVertexAttribArray(avPosition)
}
override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
GLES30.glViewport(0, 0, width, height)
}
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
pointProgram = ShaderUtil.loadProgramFromAssets(
"vertex_point.glsl",
"frag_point.glsl",
context.resources
)
avPosition = GLES30.glGetAttribLocation(pointProgram, "av_Position")
}
}
object ShaderUtil {
fun loadShader(
shaderType: Int,
source: String?
): Int {
var shader = GLES30.glCreateShader(shaderType)
if (shader != 0) {
GLES30.glShaderSource(shader, source)
GLES30.glCompileShader(shader)
checkGLError("glCompileShader")
val compiled = IntArray(1)
GLES30.glGetShaderiv(shader, GLES30.GL_COMPILE_STATUS, compiled, 0)
if (compiled[0] == 0) {
Log.e("ES30_ERROR", "Could not compile shader $shaderType:")
Log.e("ES30_ERROR", "ERROR: " + GLES30.glGetShaderInfoLog(shader))
GLES30.glDeleteShader(shader)
shader = 0
}
} else {
Log.e(
"ES30_ERROR", "Could not Create shader " + shaderType + ":" +
"Error:" + shader
)
}
return shader
}
fun loadProgramFromAssets(
VShaderName: String?,
FShaderName: String?,
resources: Resources
): Int {
val vertexText = loadFromAssetsFile(VShaderName, resources)
val fragmentText = loadFromAssetsFile(FShaderName, resources)
return createProgram(vertexText, fragmentText)
}
fun checkGLError(op: String) {
var error: Int
while (GLES30.glGetError().also { error = it } != GLES30.GL_NO_ERROR) {
Log.e("ES30_ERROR", "$op: glError $error")
throw RuntimeException("$op: glError $error")
}
}
fun createProgram(
vertexSource: String?,
fragmentSource: String?
): Int {
val vertexShader = loadShader(GLES30.GL_VERTEX_SHADER, vertexSource)
if (vertexShader == 0) {
return 0
}
val fragShader = loadShader(GLES30.GL_FRAGMENT_SHADER, fragmentSource)
if (fragShader == 0) {
return 0
}
var program = GLES30.glCreateProgram()
if (program != 0) {
GLES30.glAttachShader(program, vertexShader)
checkGLError("glAttachShader")
GLES30.glAttachShader(program, fragShader)
checkGLError("glAttachShader")
GLES30.glLinkProgram(program)
val linkStatus = IntArray(1)
GLES30.glGetProgramiv(program, GLES30.GL_LINK_STATUS, linkStatus, 0)
if (linkStatus[0] != GLES30.GL_TRUE) {
Log.e("ES30_ERROR", "ERROR: " + GLES30.glGetProgramInfoLog(program))
GLES30.glDeleteProgram(program)
program = 0
}
} else {
Log.e("ES30_ERROR", "glCreateProgram Failed: $program")
}
return program
}
fun loadFromAssetsFile(
fileName: String?,
resources: Resources
): String? {
var result: String? = null
try {
val inputStream = resources.assets.open(fileName!!)
var ch = 0
val baos = ByteArrayOutputStream()
while (inputStream.read().also { ch = it } != -1) {
baos.write(ch)
}
val buffer = baos.toByteArray()
baos.close()
inputStream.close()
result = String(buffer)
result = result.replace("\\r\\n".toRegex(), "\n")
} catch (e: Exception) {
e.printStackTrace()
}
return result
}
}
然后是著色器的代碼,頂點著色器:
#version 300 es
layout (location = 0) in vec4 av_Position;
void main() {
gl_Position = av_Position;
gl_PointSize = 10.0;
}
片段著色器
#version 300 es
precision mediump float;
out vec4 fragColor;
void main() {
fragColor =vec4(1.0,0.0,0.0,1.0);
}
這兩個著色器分別對應著Render里邊加載的 "vertex_point.glsl","frag_point.glsl",這兩個資源文件。好了現在就可以連上手機RUN一下了。效果圖如下:
這就是咱們的第一個opengles3.0(后續簡稱gl)demo,內容則是在gl3的坐標體系的(0,0,0)處繪制一個POINT,顏色為紅色。對于Android開發的同學來說這里邊存在一個和平時開發習慣不太一樣的地方,首先gl的坐標默認都是三維坐標,也就是xyz坐標,并且坐標原點也不太一樣,Android的坐標原點是坐上角,但是gl的坐標體系的坐標原點是屏幕中點,并且gl系統中不止一套坐標系。后面的章節會給大家詳細的講解gl的坐標系統。
hello world 跑完之后,咱們重新看代碼,梳理一遍gl的使用流程:
- MainActivity中開啟了opengles3.0的功能,并且生成了一個glSurfaceView。
- 給glSurfaceView設置一個實現了GLSurfaceView.Renderer接口的Render對象。
- 在Render的onSurfaceCreated方法也就是gl環境創建完成時的回調中編譯gl的著色器程序。
- 在onSurfaceChanged回調中設置gl繪制區域在Android屏幕上的區域大小。
- 在onDrawFrame中繪制每一幀需要閑置的圖像。
下邊著重介紹一下gl著色器的編譯和繪制過程。
編譯著色器
那么著色器是個什么東西呢,大部分資料都會告訴你“著色器是用來實現圖像渲染的用來替代固定渲染管線的可編輯程序”。
WTF! 我第一次讀的時候斷句我都斷不清楚。本質上著色器是個“可編輯程序”,作用是“用來實現圖像渲染”并且“用來替代固定渲染管線”。好了斷句斷清楚了,但是并沒有影響我看不懂這堆鬼玩意。有同感的同學請扣個1。
毫不夸張,以上就是我第一次接觸著色器時候內心想法。經過長時間接觸著色器,現在大致上有了一些自己的理解,其實看不懂上面的話很正常,因為那根本就不是面向Android工程師的解釋,更像是給一些有過計算機圖形學經驗的人看的。經過我自己的摸索,從一個Android工程師的角度來看這個著色器更像是兩個回調函數,頂點著色器是gl在確認繪制圖像的邊緣頂點位置時的一個回調函數,片段著色器則可以看成gl再確認每個像素顏色時的一個回調函數(這么說并不準確,因為gl內部會做優化,并不會保證每個像素都會有回調,更準確的說法是確認每個“片段”顏色時候的回調函數,所以叫片段著色器)。
就像上邊demo代碼,咱們一共繪制了一個點,gl知道咱們只有一個頂點,要確認這個頂點位置的時候就會執行頂點著色器的代碼:
#version 300 es
layout (location = 0) in vec4 av_Position;
void main() {
gl_Position = av_Position;
gl_PointSize = 10.0;
}
gl_Position是頂點著色器的內置變量,av_Position是從Android環境中傳下來的參數,這個頂點著色器其實就是把Android傳下來的位置坐標參數,賦值給了gl_Position變量,從而gl知道了咱們要繪制的點的位置。
確認位置后gl還需要知道點的顏色,這個時候就會執行片段著色器
#version 300 es
precision mediump float;
out vec4 fragColor;
void main() {
fragColor =vec4(1.0,0.0,0.0,1.0);
}
因為咱們繪制的是一個點,可以看成片段著色器只調用了一次。fragColor則是描述該片段的輸出顏色(與定點著色器不同,fragColor并不是內置變量,而是咱們自己聲明的)。片段著色器中并沒有Android環境傳下來的參數,而是直接將一個固定顏色紅色賦值給了fragColor變量,所以咱們會繪制出來一個紅色的點。
以上就是著色器的大致作用,后續章節還會更深入的去講著色器相關知識的,現在只需要對著色器有一個大致的了解就行了。
編譯著色器的流程也不復雜,主要流程就是
- glCreateShader gl生成一個空的著色器程序,需要指定類型(頂點著色器,片段著色器)并返回著色器索引
- glShaderSource gl載入著色器源碼,需要輸入1中生成的著色器索引,以及著色器源碼
- glCompileShader gl編譯著色器,需要輸入1中生成的著色器索引
- glGetShaderiv gl檢查編譯結果(不會影響編譯結果,一般用來輔助查錯)
- 重復上述1—4步驟,生成另外一個著色器
- 現在已經有了兩個著色器程序,需要做的就是連接兩個著色器了
- glCreateProgram 創建gl程序(也就是兩個著色器鏈接成功之后的完整的gl程序),會返回程序索引
- glAttachShader 給7創建的程序添加著色器程序,需要調用兩次 分別添加頂點著色器和片段著色器
- glLinkProgram 鏈接兩個著色器
- glGetProgramiv gl檢查鏈接結果,類似第4步
以上就是編譯著色器的整個流程,編譯完成之后我們會持有一個gl程序(Program)的索引。
OPENGLES3.0的繪制
gl的繪制過程是在Render的onDrawFrame回調里邊處理的,沒回調一次代表著屏幕要刷新一幀畫面。這個直接上代碼
override fun onDrawFrame(gl: GL10?) {
//用預制的值來清空緩沖區
GLES30.glClear(GLES30.GL_COLOR_BUFFER_BIT)
//啟用pointProgram gl程序
GLES30.glUseProgram(pointProgram)
//啟用頂點屬性 avPosition
GLES30.glEnableVertexAttribArray(avPosition)
//用頂點數組給avPosition
GLES30.glVertexAttribPointer(avPosition , 3, GLES30.GL_FLOAT, false, 0, vertexBuffer)
//繪制圖元
GLES30.glDrawArrays(GLES30.GL_POINTS, 0, 1)
//關閉頂點屬性 avPosition
GLES30.glDisableVertexAttribArray(avPosition)
}
最核心的流程其實就是給gl傳遞參數,然后進行繪制。
一個gl環境中是可以有多個gl程序的,然后使用glUseProgram 來確定當前使用的gl程序,當gl環境中只會使用一個gl程序時,可以在onSurfaceCreated中調用一次就可以了。
詳細說一下傳遞參數這一塊。
#version 300 es
layout (location = 0) in vec4 av_Position;
void main() {
gl_Position = av_Position;
gl_PointSize = 10.0;
}
Android環境中的int值avPosition就是頂點著色器av_Position變量在Android中的索引值。
GLES30.glVertexAttribPointer(avPosition , 3, GLES30.GL_FLOAT, false, 0, vertexBuffer)
這行代碼就是給av_Position賦值的代碼。但是現在咱們只有一個頂點,也就是頂點著色器只會調用一次。很明顯av_Position的值只有一個。假如咱們要繪制兩個頂點(0,0,0)(0,1,0)這個時候頂點著色器就會調用兩次。這個時候你可能會考慮一下Android在給gl的頂點著色器傳遞坐標參數的時候怎么在兩次調用著色器的時候把兩個坐標分別傳入呢?glVertexAttribPointer api中并不會設置你的值是傳給第幾次調用的頂點著色器的。連著調用兩次嗎?并不是,如果是這樣那我有100個頂點豈不是要調用100 次api,那就太麻煩了(其實OPENGL完整版的確是有類似的api的OPENGLES作為閹割版舍去了這些低效的api)。這個時候就需要了解gl中頂點數組的概念了,正是使用了頂點數組,才可以讓我們一次性的在多次頂點著色器的調用中,準確的把不同的頂點參數值傳遞到每次調用的頂點著色器(其實頂點著色器就那一個,只不過每次調用都會關聯一個新的頂點,所以更準確的說頂點數組的作用就是把多個頂點的參數正確的傳給對應的多個頂點??????好繞)。
可能看了上邊的入門介紹,還是有點云里霧里,很正常。我第一次也是嘛玩意都沒看懂。主要是因為現在很多教程都是上來就給你講功能,你完全不知道這個功能是是解決什么問題的。就像你只知道glVertexAttribPointer 是賦值api,但是不知道為什么這么設計這個api。所以在這個系列的文章中我會盡量先描述問題,再由問題引申出來gl的相應方案,感覺這樣理解起來會輕松很多的。
好了這就是第一篇的所有內容,又看不明白的地方不用怕,后續的文章會把坑慢慢填回來的。后續會介紹著色器的基本語法,以及應用層給著色器傳值的各種方式。并且會嘗試繪制更多的圖形。