简述
最近一段时间由于项目上用到了滤镜功能,所以一直都在学习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的相应方案,感觉这样理解起来会轻松很多的。
好了这就是第一篇的所有内容,又看不明白的地方不用怕,后续的文章会把坑慢慢填回来的。后续会介绍着色器的基本语法,以及应用层给着色器传值的各种方式。并且会尝试绘制更多的图形。