【OPENGL ES 3.0 学习笔记】第十二天:实现立方体(glDrawArrays)

用glDrawArrays绘制立方体
在OpenGL ES中,立方体是3D渲染的基础案例,而glDrawArrays作为直接渲染函数,虽不支持顶点复用,却能直观展示图元装配与3D变换的核心逻辑。
本文将详细解析基于Kotlin实现的彩色旋转立方体代码,重点拆解顶点数据设计、矩阵变换原理及渲染流程,帮助读者理解3D渲染的底层逻辑。
实现概述
glDrawArrays是OpenGL ES中最直接的渲染函数,其核心特性是按顺序读取顶点数据,无需索引缓冲区(IBO)。
但这也意味着:对于立方体这种存在大量顶点复用的模型,必须显式定义所有顶点——一个立方体有6个面,每个面由2个三角形组成(共12个三角形),每个三角形需3个顶点,因此总共需要36个顶点(即使实际独特顶点仅8个)。
本文实现的核心功能包括:
- 用
glDrawArrays(GL_TRIANGLES, ...)渲染36个顶点组成的立方体; - 为6个面分配不同颜色(红、绿、蓝、黄、青、紫),通过顶点颜色插值实现平滑过渡;
- 基于MVP矩阵(模型×视图×投影)实现3D视角与旋转动画;
- 启用深度测试解决3D场景中的面遮挡问题。
从Activity到渲染器
整个实现分为两个核心类:CubeActivity(负责初始化渲染容器)和CubeRenderer(负责具体渲染逻辑),二者分工明确,符合OpenGL ES的最佳实践。
1. CubeActivity:渲染容器的初始化
CubeActivity的作用是创建并配置GLSurfaceView——OpenGL ES的渲染容器,关键步骤包括:
class CubeActivity : Activity() {private lateinit var glSurfaceView: GLSurfaceViewoverride fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)glSurfaceView = GLSurfaceView(this).apply {setEGLContextClientVersion(3) // 指定OpenGL ES 3.0setRenderer(CubeRenderer()) // 设置渲染器renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY // 连续渲染(动画需要)}setContentView(glSurfaceView)}// 生命周期管理:暂停/恢复渲染override fun onPause() { super.onPause(); glSurfaceView.onPause() }override fun onResume() { super.onResume(); glSurfaceView.onResume() }
}
GLSurfaceView:Android中专门用于OpenGL渲染的视图,内部维护一个后台渲染线程,避免阻塞UI线程。RENDERMODE_CONTINUOUSLY:设置为连续渲染模式(默认是按需渲染),确保动画帧能实时更新。
2. CubeRenderer:核心渲染逻辑的实现
CubeRenderer实现了GLSurfaceView.Renderer接口,包含三个核心回调方法,分别对应渲染的三个阶段:初始化(onSurfaceCreated)、尺寸变化(onSurfaceChanged)、帧渲染(onDrawFrame)。
顶点数据设计
由于glDrawArrays无法复用顶点,必须为12个三角形(6个面×2个三角形)定义36个顶点。每个顶点包含位置信息(3个float:x/y/z)和颜色信息(4个float:r/g/b/a),总结构为[x, y, z, r, g, b, a]。
1. 位置数据:立方体的空间坐标
立方体的中心位于原点(0,0,0),边长为1,因此每个顶点的x/y/z坐标范围为[-0.5, 0.5]。6个面的顶点定义规则如下:
private val vertices = floatArrayOf(// 前面(z=0.5):2个三角形,6个顶点-0.5f, -0.5f, 0.5f, // 左下0.5f, -0.5f, 0.5f, // 右下0.5f, 0.5f, 0.5f, // 右上-0.5f, -0.5f, 0.5f, // 左下(重复)0.5f, 0.5f, 0.5f, // 右上(重复)-0.5f, 0.5f, 0.5f, // 左上// 后面(z=-0.5):2个三角形,6个顶点-0.5f, -0.5f, -0.5f, // 左下-0.5f, 0.5f, -0.5f, // 左上0.5f, 0.5f, -0.5f, // 右上-0.5f, -0.5f, -0.5f, // 左下(重复)0.5f, 0.5f, -0.5f, // 右上(重复)0.5f, -0.5f, -0.5f, // 右下// 左面(x=-0.5)、右面(x=0.5)、上面(y=0.5)、下面(y=-0.5)各6个顶点...
)
- 顶点顺序:每个三角形的顶点按逆时针方向排列(如前面的第一个三角形顶点顺序为“左下→右下→右上”),符合OpenGL默认的“正面判定”规则,确保背面剔除(若启用)时不会误删可见面。
- 重复顶点:例如“前面左下”顶点在两个三角形中重复出现,这是
glDrawArrays的必然结果,也是其相比glDrawElements的劣势(内存占用更高)。
2. 颜色数据:6个面的区分标识
为了直观区分立方体的6个面,为每个面的顶点分配独特颜色(rgba值范围[0,1]):
// 前面顶点颜色:红色(1,0,0,1)
// 后面顶点颜色:绿色(0,1,0,1)
// 左面顶点颜色:蓝色(0,0,1,1)
// 右面顶点颜色:黄色(1,1,0,1)
// 上面顶点颜色:青色(0,1,1,1)
// 下面顶点颜色:紫色(1,0,1,1)
- 颜色插值:三角形内部的像素颜色会由GPU自动进行线性插值(例如红色顶点与绿色顶点之间的像素会呈现渐变色),这也是片段着色器接收的
vColor变量的由来。
着色器程序
着色器是OpenGL ES的“渲染大脑”,负责将顶点数据转换为屏幕像素。
本实现包含顶点着色器和片段着色器,二者通过变量传递数据。
1. 顶点着色器:处理3D坐标变换
顶点着色器的核心任务是将3D顶点坐标通过MVP矩阵转换为屏幕2D坐标,并将顶点颜色传递给片段着色器:
#version 300 es
in vec3 aPosition; // 顶点位置(输入)
in vec4 aColor; // 顶点颜色(输入)
uniform mat4 uMVPMatrix; // MVP矩阵(统一变量)
out vec4 vColor; // 传递给片段着色器的颜色(输出)void main() {// MVP矩阵变换:3D坐标 → 屏幕坐标gl_Position = uMVPMatrix * vec4(aPosition, 1.0);vColor = aColor; // 传递颜色(后续会被插值)
}
in变量:aPosition和aColor分别接收顶点的位置和颜色数据,由CPU通过glVertexAttribPointer绑定。uniform变量:uMVPMatrix是所有顶点共享的矩阵,用于3D坐标变换,由CPU通过glUniformMatrix4fv传入。out变量:vColor将顶点颜色传递给片段着色器,GPU会在三角形内部对该变量进行插值。
2. 片段着色器:输出最终像素颜色
片段着色器负责为每个像素(片段)计算最终颜色,这里直接使用顶点着色器传递的插值后颜色:
#version 300 es
precision mediump float; // 精度声明(必须)
in vec4 vColor; // 从顶点着色器接收的插值颜色
out vec4 fragColor; // 输出像素颜色void main() {fragColor = vColor; // 直接使用插值后的颜色
}
precision mediump float:声明浮点数精度为中等(平衡性能与精度),OpenGL ES 3.0要求必须显式声明。fragColor:替代OpenGL ES 2.0中的gl_FragColor,作为片段着色器的输出颜色。
矩阵变换详解:MVP矩阵的3D魔法
3D渲染的核心是将三维空间中的顶点坐标转换为二维屏幕上的像素位置,这一过程由MVP矩阵(Model-View-Projection)完成。
MVP矩阵是三个矩阵的乘积:模型矩阵(Model)、视图矩阵(View)、投影矩阵(Projection)。
1. 模型矩阵(Model Matrix):控制物体自身变换
模型矩阵用于描述物体在世界坐标系中的位置、旋转、缩放。在本实现中,我们通过旋转让立方体动起来:
// 初始化模型矩阵为单位矩阵(无变换)
Matrix.setIdentityM(modelMatrix, 0)
// 绕X轴旋转rotateX度
Matrix.rotateM(modelMatrix, 0, rotateX, 1f, 0f, 0f)
// 绕Y轴旋转rotateY度
Matrix.rotateM(modelMatrix, 0, rotateY, 0f, 1f, 0f)
- 单位矩阵:
Matrix.setIdentityM初始化矩阵为单位矩阵(类似数字“1”,与任何矩阵相乘都不改变其值)。 - 旋转API:
Matrix.rotateM(矩阵, 偏移, 角度, x, y, z)表示绕向量(x,y,z)旋转指定角度,这里分别绕X轴(1,0,0)和Y轴(0,1,0)旋转。 - 动画实现:
rotateX和rotateY在每帧渲染时递增(rotateX += 0.5f,rotateY += 0.8f),形成连续旋转效果。
2. 视图矩阵(View Matrix):模拟相机视角
视图矩阵用于描述“相机”的位置和朝向,相当于将世界坐标系转换为相机坐标系(以相机为原点):
Matrix.setLookAtM(viewMatrix, 0,0f, 0f, 3f, // 相机位置(eyeX, eyeY, eyeZ):在Z轴上距离原点3个单位0f, 0f, 0f, // 相机目标点(centerX, centerY, centerZ):看向原点0f, 1f, 0f // 相机上方向(upX, upY, upZ):Y轴为上方向
)
- 参数含义:想象你站在(0,0,3)的位置,看向原点(0,0,0),头顶朝向Y轴正方向,这就是视图矩阵定义的“观察视角”。
- 效果:视图矩阵会将所有顶点坐标“平移”到以相机为原点的坐标系中,模拟人眼观察世界的效果。
3. 投影矩阵(Projection Matrix):3D到2D的映射
投影矩阵将3D相机坐标系中的顶点投影到2D屏幕上,本实现使用透视投影(模拟人眼“近大远小”的效果):
val aspect = width.toFloat() / height // 屏幕宽高比
Matrix.perspectiveM(projectionMatrix, 0,45f, // 视野角度(FOV):垂直方向可视角度45度aspect, // 宽高比:确保图形不变形0.1f, // 近裁剪面:距离相机小于0.1的物体不显示100f // 远裁剪面:距离相机大于100的物体不显示
)
- 透视投影 vs 正交投影:透视投影会让远处的物体看起来更小(符合人眼视觉),正交投影则保持物体大小不变(适合工程绘图)。
- 裁剪面:近裁剪面和远裁剪面定义了相机的可视范围,超出范围的物体将被裁剪(不渲染)。
4. MVP矩阵的乘法顺序
矩阵乘法不满足交换律,因此MVP矩阵的计算必须遵循投影矩阵 × 视图矩阵 × 模型矩阵的顺序:
// 计算视图-模型矩阵(视图矩阵 × 模型矩阵)
val viewModel = FloatArray(16)
Matrix.multiplyMM(viewModel, 0, viewMatrix, 0, modelMatrix, 0)// 计算MVP矩阵(投影矩阵 × 视图-模型矩阵)
Matrix.multiplyMM(mvpMatrix, 0, projectionMatrix, 0, viewModel, 0)
- 变换流程:顶点坐标先被模型矩阵变换(物体自身运动),再被视图矩阵变换(相机视角),最后被投影矩阵变换(2D屏幕映射)。
multiplyMM的参数:Matrix.multiplyMM(结果矩阵, 结果偏移, 左矩阵, 左偏移, 右矩阵, 右偏移),即“左矩阵 × 右矩阵”。
渲染流程:从数据到屏幕的完整链路
CubeRenderer的渲染流程可分为初始化、尺寸适配、帧渲染三个阶段,每个阶段都有明确的职责。
1. 初始化阶段(onSurfaceCreated)
在渲染表面创建时调用,负责初始化OpenGL状态、编译着色器、配置顶点缓冲区:
override fun onSurfaceCreated(unused: GL10, config: EGLConfig) {// 1. 初始化背景色和深度测试GLES30.glClearColor(0.2f, 0.2f, 0.2f, 1f) // 深灰色背景GLES30.glEnable(GLES30.GL_DEPTH_TEST) // 启用深度测试(解决3D遮挡)// 2. 编译着色器程序programId = createProgram(vertexShaderCode, fragmentShaderCode)// 3. 初始化VAO和VBOinitBuffers()
}
- 深度测试:通过
glEnable(GL_DEPTH_TEST)启用,确保距离相机近的面会遮挡远的面(通过比较像素的z值实现)。 - VAO(顶点数组对象):用于保存顶点属性配置(位置和颜色的
glVertexAttribPointer参数),避免每次渲染重复配置。
2. 尺寸适配阶段(onSurfaceChanged)
当渲染表面尺寸变化时(如屏幕旋转)调用,主要配置视口和投影矩阵:
override fun onSurfaceChanged(unused: GL10, width: Int, height: Int) {GLES30.glViewport(0, 0, width, height) // 设置视口(渲染区域为整个屏幕)val aspect = width.toFloat() / heightMatrix.perspectiveM(projectionMatrix, 0, 45f, aspect, 0.1f, 100f) // 重新计算投影矩阵
}
- 视口:
glViewport(x, y, width, height)定义OpenGL渲染结果在屏幕上的显示区域,这里设置为全屏。
3. 帧渲染阶段(onDrawFrame)
每帧渲染时调用,是实际绘制立方体的逻辑:
override fun onDrawFrame(unused: GL10) {// 1. 清除颜色和深度缓冲区GLES30.glClear(GLES30.GL_COLOR_BUFFER_BIT or GLES30.GL_DEPTH_BUFFER_BIT)// 2. 更新旋转角度(动画)rotateX += 0.5frotateY += 0.8f// 3. 计算MVP矩阵calculateMVPMatrix()// 4. 绑定着色器程序和VAOGLES30.glUseProgram(programId)GLES30.glBindVertexArray(vaoId)// 5. 传入MVP矩阵val mvpLoc = GLES30.glGetUniformLocation(programId, "uMVPMatrix")GLES30.glUniformMatrix4fv(mvpLoc, 1, false, mvpMatrix, 0)// 6. 绘制立方体(36个顶点,三角形图元)GLES30.glDrawArrays(GLES30.GL_TRIANGLES, 0, 36)// 7. 清理状态GLES30.glBindVertexArray(0)GLES30.glUseProgram(0)
}
- 缓冲区清除:
glClear必须在每帧开始时调用,清除上一帧的颜色和深度数据,否则会出现画面残留。 glDrawArrays调用:GL_TRIANGLES表示按三角形图元渲染,0是起始顶点索引,36是总顶点数(12个三角形)。
运行效果

总结
本文通过glDrawArrays实现了一个彩色旋转立方体,详细解析了顶点数据设计、着色器逻辑、MVP矩阵变换及完整渲染流程。
虽然glDrawArrays存在顶点冗余的问题,但其直观的渲染方式能帮助初学者理解3D渲染的核心原理——从顶点数据到屏幕像素,本质是通过矩阵变换将3D空间映射到2D平面,并通过图元装配和光栅化生成最终图像。

