写几篇博客介绍一下在 Android 中如何使用 OpenGL,包括:
- 在 Android 中使用 OpenGL(图形绘制)
- 在 Android 中使用 OpenGL(VAO、VBO、EBO)
- 在 Android 中使用 OpenGL(视角与投影)
- 在 Android 中使用 OpenGL(纹理)
- 在 Android 中使用 OpenGL(并行运算)
这是第一篇,介绍怎么在 Android 中用 OpenGL 绘制图形。看完我们将知道怎么绘制静态的多边形。
效果:
1. 环境搭建
Android 中使用 OpenGL 需要在 AndroidManifest.xml 中添加依赖,Android 4.3 以上即可支持 OpenGL 3.0,所以我们依赖 3.0 的 OpenGL,这样接口新一些:
AndroidManifest.xml
<manifest ... >
<!-- 使用 OpenGL3.0 -->
<uses-feature android:glEsVersion="0x00030000" android:required="true" />
</manifest>
先整体看一下接下来会实现的几个类:
我们使用 GLSurfaceView 作为显示 OpenGL 绘制结果的视图,定义一个 DisplayShapeView 继承自 GLSurfaceView :
DisplayShapeView.kt
class DisplayShapeView(context: Context?) : GLSurfaceView(context) {
private val renderer: DisplayShapeRenderer
init {
// 设置版本号
setEGLContextClientVersion(3)
// 创建 Renderer
renderer = DisplayShapeRenderer()
setRenderer(renderer)
// 渲染模式设置为 仅在调用 requestRender() 时才渲染,减少不必要的绘制
renderMode = RENDERMODE_WHEN_DIRTY
}
}
再在 Activity 显示这个 DisplayShapeView:
MainActivity.kt
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 使用自定义的 View
val glView = DisplayShapeView(this)
setContentView(glView)
}
}
再把 Renderer 的实现了。
在这篇文章中,Renderer 类比较简单,只在需要绘制时调用 triangle 对象绘制,在界面发生变化时更新视口大小即可。
在下一篇文章中,我们会在这个类中添加投影矩阵等逻辑。
DisplayShapeRenderer.kt
class DisplayShapeRenderer: GLSurfaceView.Renderer {
/* ======================================================= */
/* Fields */
/* ======================================================= */
/**
* 三角形
*/
private var triangle: Triangle? = null
/* ======================================================= */
/* Override/Implements Methods */
/* ======================================================= */
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
// rgba,清空背景
GLES30.glClearColor(0F, 0F, 0F, 0F)
// 实例化一个三角形
this.triangle = Triangle()
}
override fun onDrawFrame(gl: GL10?) {
// 清空画面
GLES30.glClear(GLES30.GL_COLOR_BUFFER_BIT)
// 绘制三角形
this.triangle?.draw()
}
override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
// 更新 可视窗口 的大小
GLES30.glViewport(0, 0, width, height)
}
}
到这一步,我们的大致环境就搭好啦,下面我们开始实现具体的绘制逻辑。
2. 形状的顶点
我们定义一个 AbsShape 类,用于实现和规范形状的绘制。
这个类比较大,我们拆开了一步步看。
先来定义出两个方法,用于 提供形状的顶点坐标,以及这些坐标是怎么组合为三角形的。
abstract class AbsShape {
/* ======================================================= */
/* Protected Methods */
/* ======================================================= */
/**
* 顶点坐标。
* 定义了一个顶点的坐标集合。
*/
protected open fun getVertices(): FloatArray {
return floatArrayOf(
-0.6F, -0.4F, 0F,
-0.8F, 0.4F, 0F,
-0.4F, 0.4F, 0F
)
}
/**
* 构成三角形的索引。
*
* 「顶点坐标」只是提供了有哪些顶点,但这些顶点如何构成多
* 个三角形,则是由这个方法定义。由于只有三个顶点,所以
* 这里按照 0->1->2 的顺序即可。
*
* 例如对于一个矩形,它有4个顶点,由两个三角形构成,这时就
* 有多种方式去组合了,后面在绘制矩形时我们会看到。
*/
protected open getVertexIndices(): IntArray {
return intArrayOf(0, 1, 2)
}
}
3. 着色器的定义与编译
我们需要定义两种着色器,告诉 OpenGL 「顶点在哪儿」(顶点着色器)和「顶点围成的区域的颜色是什么」(像素着色器)。
但这两种着色器都需要我们用 GLSL 去实现,幸运的是它很简单,至少目前还是(写出了翻译腔的感觉~)。
关于 GLSL (OpenGL Shading Language)的更多语法,可以 Google 一下。
abstract class AbsShape {
/* ======================================================= */
/* Protected Methods */
/* ======================================================= */
// 省略上面定义好的方法....
/**
* 顶点着色器的 GLSL 代码。
*/
protected open fun vertexShaderCode(): String {
return "attribute vec4 vPosition;" +
"void main() {" +
" gl_Position = vPosition;" +
"}"
}
/**
* 片段着色器。
*/
protected open fun fragmentShaderCode(): String {
return "precision mediump float;" +
"uniform vec4 vColor;" +
"void main() {" +
" gl_FragColor = vColor;" +
"}"
}
}
比较简单,其中顶点着色器定义了一个全局变量 vPosition
,类型是 vec4
, 这是一个三维坐标的数组。
而像素着色器定义了形状的颜色,我们后面会在绘制时,将顶点坐标和颜色传递到 vPosition
和 vColor
这两个变量中供 OpenGL 使用。
关于像素着色器为什么称为 FragmentShader:
物体是连续的,显示屏是离散的,离散的显示屏上的每一个像素点都对应物体的一片区域。这个区域就是 Fragment。
接下来我们来编译上面这两段 Shader 代码:
abstract class AbsShape {
/* ======================================================= */
/* Fields */
/* ======================================================= */
/**
* 编译生成的 OpenGL 程序
*/
private val program: Int
/* ======================================================= */
/* Constructors */
/* ======================================================= */
init {
// 创建 OpenGL 的 Program
program = load()
}
/* ======================================================= */
/* Protected Methods */
/* ======================================================= */
// 省略上面有的部分...
protected fun load(): Int {
// 顶点着色器
val vertexShader = loadShader(GLES30.GL_VERTEX_SHADER, vertexShaderCode())
// 像素着色器
val fragmentShader = loadShader(GLES30.GL_FRAGMENT_SHADER, fragmentShaderCode())
// 创建 GL Program
val program = GLES30.glCreateProgram()
// 绑定 Shader
GLES30.glAttachShader(program, vertexShader)
GLES30.glAttachShader(program, fragmentShader)
// 链接程序
GLES30.glLinkProgram(program)
return program
}
protected fun loadShader(type: Int, code: String): Int {
// 创建 shader
val shader = GLES30.glCreateShader(type)
// 设置代码
GLES30.glShaderSource(shader, code)
// 编译代码
GLES30.glCompileShader(shader)
return shader
}
}
经过上面的步骤,我们的准备工作就告一段落了,接下来让我们开始为 program 传递坐标和颜色,并让它绘制出来。
4. 绘制
在绘制之前,我们先理解一下什么是 VAO、VBO、EBO (其实上面在定义方法 getVertexIndices()
时就已经透露啦~)。
VBO(Vertex Buffer Object) 是用于缓存顶点数组的。对应的数据是上面 getVertices()
返回的顶点坐标。
EBO(Element Buffer Object) 是用于缓存顶点的索引。对应的数据是上面 getVertexIndices()
返回的数组。每三个 index 对应一个三角形。
VAO(Vertex Array Object) 可以理解为 VBO、EBO 所在的一个上下文,它本身没有数据。
我们不希望在刚接触 OpenGL 时 就被这些名词绕晕了头,所以关注 vbo 和 ebo 对应的数据是什么就行。
abstract class AbsShape {
// 省略 Fields、Constructor ...
/* ======================================================= */
/* Public Methods */
/* ======================================================= */
fun draw() {
// 使用编译好的 program
GLES30.glUseProgram(program)
// 设置顶点信息
handleVertices()
// 准备纹理信息
handleColor()
// 绘制顶点
GLES30.glDrawElements(
/*mode =*/ GLES30.GL_TRIANGLES,
/*count =*/ triangleCount * 3,
/*type =*/ GLES30.GL_UNSIGNED_INT,
/*offset =*/ 0
)
// 关闭其可修改
GLES30.glDisableVertexAttribArray(positionLocation)
// 释放资源
GLES30.glBindVertexArray(0)
GLES30.glBindBuffer(GLES30.GL_ELEMENT_ARRAY_BUFFER, 0)
GLES30.glBindBuffer(GLES30.GL_ARRAY_BUFFER, 0)
GLES30.glDeleteVertexArrays(1, intArrayOf(vaoID), 0)
GLES30.glDeleteBuffers(1, intArrayOf(vboID), 0)
GLES30.glDeleteBuffers(1, intArrayOf(eboID), 0)
}
// 省略 Protected Methods ...
}
这几个步骤没什么好解释的,聪明的你一看就明白了。
让我们分别看一下 handleVertices()
和 handleColor()
怎么实现的:
处理顶点:
abstract class AbsShape {
/* ======================================================= */
/* Fields */
/* ======================================================= */
/**
* 通过「反射」获取到的 OpenGL 代码中定义的字段
*/
private var positionLocation: Int = 0
/**
* 每个坐标有几维
*/
private val VERTEX_COORDINATE_DIMS = 3
/**
* 用于保存申请到的 GPU 上的内存的句柄。
*/
private var vaoID = 0
private var vboID = 0
private var eboID = 0
/**
* 当前图形中,三角形的个数。
*/
private var triangleCount = 0
// 省略 其他字段、Constructor、Public Methods ...
/* ======================================================= */
/* Protected Methods */
/* ======================================================= */
// 省略其他 protected methods ...
protected open fun handleVertices() {
// 生成 VAO,顶点数组对象,Vertex Array Object
handleVAO()
// 生成 VBO,顶点缓冲对象,Vertex Buffer Object
handleVBO()
// 生成 EBO,索引缓冲对象,Element Buffer Object
handleEBO()
// 找到在 Shader 中定义的 vPosition 变量的位置(类似于 Java 中的反射)
positionLocation = GLES30.glGetAttribLocation(program, "vPosition")
// 使能 positionFieldID
GLES30.glEnableVertexAttribArray(positionLocation)
GLES30.glVertexAttribPointer(
/*index = */ positionLocation,
/*size = */ VERTEX_COORDINATE_DIMS,
/*type = */ GLES30.GL_FLOAT,
/*normalized = */ false,
/*stride = */ 0,
/*offset = */ 0
)
}
protected fun handleVAO() {
val vaoIDs = IntArray(1)
GLES30.glGenVertexArrays(1, vaoIDs, 0)
vaoID = vaoIDs[0]
// 绑定 VAO
GLES30.glBindVertexArray(vaoID)
}
protected fun handleVBO() {
val vboIDs = IntArray(1)
GLES30.glGenBuffers(1, vboIDs, 0)
vboID = vboIDs[0]
// 绑定 VBO 为 vboID 对应的内存
GLES30.glBindBuffer(GLES30.GL_ARRAY_BUFFER, vboID)
// 填充 VBO
val vertices = getVertices()
val verticesBuffer = numberArray2Buffer(vertices)
GLES30.glBufferData(
/*target =*/ GLES30.GL_ARRAY_BUFFER,
/*size =*/ vertices.size * 4,
/*data =*/ verticesBuffer,
/*usage =*/ GLES30.GL_STATIC_DRAW
)
}
protected fun handleEBO() {
val eboIDs = IntArray(1)
GLES30.glGenBuffers(1, eboIDs, 0)
eboID = eboIDs[0]
// 绑定 EBO 为 eboID 对应的内存
GLES30.glBindBuffer(GLES30.GL_ELEMENT_ARRAY_BUFFER, eboID)
// 填充 EBO
val indices = getVertexIndices()
val indicesBuffer = numberArray2Buffer(indices)
GLES30.glBufferData(
/*target =*/ GLES30.GL_ELEMENT_ARRAY_BUFFER,
/*size =*/ indices.size * 4,
/*data =*/ indicesBuffer,
/*usage =*/ GLES30.GL_STATIC_DRAW
)
triangleCount = indices.size / 3
}
/**
* 将 number array 转为 number buffer。
* Kotlin 的 IntArray、FloatArray 没有共同的父类,只好用泛型啦。
*/
protected fun<ArrayType> numberArray2Buffer(values: ArrayType): Buffer {
// 计算 bytes 大小, short int 占据 2 个字节
val size = when (values) {
is ShortArray -> values.size * Short.SIZE_BYTES
is IntArray -> values.size * Int.SIZE_BYTES
is FloatArray -> values.size * Int.SIZE_BYTES
is DoubleArray -> values.size * Int.SIZE_BYTES * 2
is LongArray -> values.size * Long.SIZE_BYTES
else -> throw IllegalArgumentException("不支持的数组类型")
}
// 申请空间
val buffer = ByteBuffer.allocateDirect(size)
// 获取当前设备的 byte order
val order = ByteOrder.nativeOrder()
// 设置 buffer 的字节序
buffer.order(order)
// 把当前 bytes buffer 强转为指定类型的 Buffer,并 put 数据
val typedBuffer: Buffer = when (values) {
is ShortArray -> { buffer.asShortBuffer().apply { put(values) } }
is IntArray -> { buffer.asIntBuffer().apply { put(values); } }
is FloatArray -> { buffer.asFloatBuffer().apply { put(values) } }
is DoubleArray -> { buffer.asDoubleBuffer().apply { put(values) } }
is LongArray -> { buffer.asLongBuffer().apply { put(values) } }
else -> throw IllegalArgumentException("不支持的数组类型")
}
typedBuffer.position(0)
return typedBuffer
}
}
处理颜色:
abstract class AbsShape {
/* ======================================================= */
/* Fields */
/* ======================================================= */
private var colorLocation: Int = 0
// 省略 其他字段、Constructor、Public Methods ...
/* ======================================================= */
/* Protected Methods */
/* ======================================================= */
// 省略其他 protected methods ...
protected fun handleColor() {
// 获取 Shader 程序中的 vColor 变量的字段位置
colorLocation = GLES30.glGetUniformLocation(program, "vColor")
// RGBA
val color = floatArrayOf(0.63671875f, 0.76953125f, 0.22265625f, 1.0f)
// 填充数据
GLES30.glUniform4fv(colorLocation, /*count =*/ 1, color, /*offset =*/ 0)
}
}
我们目前只显示纯色的形状,没有纹理,所以处理颜色很简单啦。
运行起来,我们就能在手机上看到这样的效果:
5. 四边形与五边形的绘制
上面我们绘制了最简单的三角形,如果要绘制其他形状,可以继承自 AbsShape,重写顶点的返回值就好啦:
Rectangle.kt
class Rectangle: AbsShape() {
/* ======================================================= */
/* Override/Implements Methods */
/* ======================================================= */
override fun getVertices(): FloatArray {
val minX = -0.20F;
val maxX = 0.15F;
val minY = -0.3F;
val maxY = 0.3F;
// 逆时针方向的4个坐标
// 在OpenGL中所有组合的图形的绘制方向必须一致。
// 要么都是顺时针,要么都是逆时针。
return floatArrayOf(
minX, maxY, 0F,
minX, minY, 0F,
maxX, minY, 0F,
maxX, maxY, 0F
)
}
override fun getVertexIndices(): IntArray {
return intArrayOf(
// 第1个三角形
0, 1, 2,
// 第2个三角形
0, 2, 3
)
}
}
Pentagon.kt
class Pentagon: AbsShape() {
/* ======================================================= */
/* Override/Implements Methods */
/* ======================================================= */
override fun getVertices(): FloatArray {
val centerX = 0.5F
val centerY = 0F
val radius = 0.3F
// 五边形每一扇的弧度
val sector = Math.PI * 2 / 5
val temp = Math.PI / 2
// 5个顶点,每个顶点3个维度
val res = FloatArray(5 * 3)
for (i in 4 downTo 0) {
// 逆时针,用极坐标生成直角坐标
val (x, y) = polar2XY(radius, (sector * i + temp).toFloat())
// 将直角坐标填充到 res
res[i * 3 + 0] = centerX + x
res[i * 3 + 1] = centerY + y
res[i * 3 + 2] = 0F
}
return res
}
override fun getVertexIndices(): IntArray {
return intArrayOf(
// 用3个三角形拼成一个五边形
0, 1, 2,
2, 3, 4,
0, 2, 4
)
}
/* ======================================================= */
/* Private Methods */
/* ======================================================= */
/**
* 极坐标 -> 直角坐标
*
* @param theta 弧度
*/
@Suppress("SameParameterValue")
private fun polar2XY(r: Float, theta: Float): Pair<Float, Float> {
val x = r * cos(theta)
val y = r * sin(theta)
return Pair(x, y)
}
}
让我们再看一下效果:
如果你比较细心,你会发现这个三角形的长宽比例是有问题的。 那是因为我们还没有根据屏幕的宽高比去适配 OpenGL 的坐标系。这个留到下一篇文章我们一起来实现。