
1. 从两个几何体看透 OpenGL ES 2.0 的渲染管线很多人第一次接触 OpenGL ES 2.0都是从画一个三角形开始的。三角形跑通之后下一步几乎所有人都会去尝试画立方体和球体。这两个几何体看起来简单实际上它们分别代表了两种截然不同的建模思路和渲染策略。立方体是硬边、平面、低顶点数的代表球体是曲面、高顶点数、需要参数化生成的典型。把这两个都吃透基本上 OpenGL ES 2.0 的顶点缓冲、索引绘制、矩阵变换、光照计算这几块核心内容就都串起来了。这篇文章面向的是已经能在 Android 或 iOS 上跑通一个 OpenGL ES 2.0 三角形、但对三维几何体绘制还没有系统实践的开发者。我会从最底层的顶点数据组织讲起把立方体和球体各自的生成逻辑、索引缓冲的构建方式、法线向量的计算、以及实际渲染中容易踩的坑全部拆开讲一遍。代码层面以 OpenGL ES 2.0 的可编程管线为准着色器用 GLSL ES 1.00 语法平台无关Android 的 GLSurfaceView 和 iOS 的 EAGLContext 都能直接套用。先说一个容易被忽略的前提OpenGL ES 2.0 没有固定管线没有glBegin/glEnd也没有内置的球体或立方体生成函数。所有顶点数据都得你自己算好塞进 VBOVertex Buffer Object然后通过glDrawArrays或glDrawElements提交给 GPU。这意味着你对顶点数据的理解程度直接决定了你能不能画出正确的东西。下面我分几个层面来展开。2. 立方体绘制24个顶点还是8个顶点2.1 顶点数量的选择背后是法线问题一个立方体有8个角直觉上应该只需要8个顶点。但实际在 OpenGL ES 2.0 里做光照渲染时你会发现8个顶点根本不够用。原因在于法线向量。立方体的每个面有独立的朝向一个角上的顶点同时属于三个面如果只用8个顶点每个顶点只能有一个法线那光照计算就会出错——三个面共享同一个法线看起来就像被磨圆了一样。所以正确的做法是用24个顶点每个面4个顶点每个顶点携带该面朝外的法线。这样每个面的光照都是独立计算的硬边效果才能出来。代价是顶点数从8变成24但对于立方体这种低面数模型来说完全无所谓。顶点数据的组织方式我习惯按面来排列每个顶点包含位置3个float和法线3个float交错存储在一个float数组里。交错存储的好处是GPU缓存命中率高因为位置和法线在内存里是连续的顶点着色器读取时不需要跳来跳去。// 立方体顶点数据位置(x,y,z) 法线(nx,ny,nz) float cubeVertices[] { // 前面 (z 1) -1.0f, -1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, -1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f, -1.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f, // 后面 (z -1) 1.0f, -1.0f, -1.0f, 0.0f, 0.0f, -1.0f, -1.0f, -1.0f, -1.0f, 0.0f, 0.0f, -1.0f, -1.0f, 1.0f, -1.0f, 0.0f, 0.0f, -1.0f, 1.0f, 1.0f, -1.0f, 0.0f, 0.0f, -1.0f, // 上面 (y 1) -1.0f, 1.0f, 1.0f, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, 1.0f, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, -1.0f, 0.0f, 1.0f, 0.0f, -1.0f, 1.0f, -1.0f, 0.0f, 1.0f, 0.0f, // 下面 (y -1) -1.0f, -1.0f, -1.0f, 0.0f, -1.0f, 0.0f, 1.0f, -1.0f, -1.0f, 0.0f, -1.0f, 0.0f, 1.0f, -1.0f, 1.0f, 0.0f, -1.0f, 0.0f, -1.0f, -1.0f, 1.0f, 0.0f, -1.0f, 0.0f, // 右面 (x 1) 1.0f, -1.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f, -1.0f, -1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, -1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f, 0.0f, 0.0f, // 左面 (x -1) -1.0f, -1.0f, -1.0f, -1.0f, 0.0f, 0.0f, -1.0f, -1.0f, 1.0f, -1.0f, 0.0f, 0.0f, -1.0f, 1.0f, 1.0f, -1.0f, 0.0f, 0.0f, -1.0f, 1.0f, -1.0f, -1.0f, 0.0f, 0.0f, };这段数据里每个面的顶点顺序都是逆时针的从外部看这一点非常重要因为后面要开启背面剔除。如果顺序搞反了面会被剔除掉你会看到一个空心的立方体。2.2 索引缓冲让24个顶点画出12个三角形24个顶点要组成6个面每个面2个三角形总共12个三角形。如果直接用glDrawArrays(GL_TRIANGLES, 0, 24)那是把24个顶点当成24个独立三角形顶点来用显然不对。正确的方式是用索引缓冲告诉GPU哪三个顶点组成一个三角形。unsigned short cubeIndices[] { 0, 1, 2, 0, 2, 3, // 前面 4, 5, 6, 4, 6, 7, // 后面 8, 9, 10, 8, 10, 11, // 上面 12, 13, 14, 12, 14, 15, // 下面 16, 17, 18, 16, 18, 19, // 右面 20, 21, 22, 20, 22, 23 // 左面 };索引缓冲用GL_ELEMENT_ARRAY_BUFFER绑定绘制时调用glDrawElements(GL_TRIANGLES, 36, GL_UNSIGNED_SHORT, 0)。36就是12个三角形乘以3个索引。用索引缓冲的好处是顶点数据只需要上传一次GPU通过索引复用顶点减少顶点着色器的执行次数。虽然立方体只有24个顶点效果不明显但换成球体之后索引缓冲的价值就体现出来了。2.3 立方体的MVP矩阵与深度测试立方体画出来之后如果不做任何变换你看到的是一个正面朝向屏幕的正方形因为默认的投影是正交的而且没有旋转。要让它看起来像个立方体需要构造三个矩阵模型矩阵Model、视图矩阵View、投影矩阵Projection合称MVP矩阵。模型矩阵负责旋转和缩放视图矩阵负责把相机往后拉投影矩阵负责透视效果。透视投影矩阵的构造在 OpenGL ES 2.0 里没有内置函数需要自己写。核心参数是视场角fov、宽高比aspect、近裁剪面near和远裁剪面far。void perspectiveMatrix(float* m, float fov, float aspect, float near, float far) { float f 1.0f / tanf(fov * 0.5f * M_PI / 180.0f); memset(m, 0, 16 * sizeof(float)); m[0] f / aspect; m[5] f; m[10] (far near) / (near - far); m[11] -1.0f; m[14] (2.0f * far * near) / (near - far); }这里有个细节OpenGL ES 2.0 的矩阵是列主序的所以m[11] -1.0f而不是m[14]。如果你从网上抄了一段行主序的代码直接用会发现画面完全不对。我当初就在这里卡了半天后来把矩阵打印出来逐项对比才发现问题。深度测试必须开启否则立方体的背面会盖住正面看起来像一团乱麻。在onSurfaceCreated里调用glEnable(GL_DEPTH_TEST)并且在每帧渲染前用glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)清除深度缓冲。背面剔除也建议开启glEnable(GL_CULL_FACE)配合glCullFace(GL_BACK)能省掉一半的片元着色器计算。3. 球体生成经纬度参数化的数学推导3.1 从球面参数方程到顶点坐标球体的顶点生成和立方体完全不同立方体是手工列出来的球体必须用参数方程算出来。球面的参数方程是x r * sin(theta) * cos(phi)y r * cos(theta)z r * sin(theta) * sin(phi)其中 theta 是极角从0到πphi 是方位角从0到2π。把 theta 和 phi 分别分成若干份每一份的交叉点就是一个顶点。假设经度方向分32份纬度方向分32份那顶点总数就是 (321) * (321) 1089个。为什么加1因为经度从0到2π需要33条线才能闭合纬度从0到π需要33条线才能覆盖两个极点。#define SPHERE_SLICES 32 #define SPHERE_STACKS 32 void generateSphere(float radius, float** vertices, int* vertexCount, unsigned short** indices, int* indexCount) { int vCount (SPHERE_SLICES 1) * (SPHERE_STACKS 1); int iCount SPHERE_SLICES * SPHERE_STACKS * 6; float* verts (float*)malloc(vCount * 6 * sizeof(float)); unsigned short* idx (unsigned short*)malloc(iCount * sizeof(unsigned short)); int v 0; for (int stack 0; stack SPHERE_STACKS; stack) { float theta stack * M_PI / SPHERE_STACKS; float sinTheta sinf(theta); float cosTheta cosf(theta); for (int slice 0; slice SPHERE_SLICES; slice) { float phi slice * 2.0f * M_PI / SPHERE_SLICES; float x radius * sinTheta * cosf(phi); float y radius * cosTheta; float z radius * sinTheta * sinf(phi); // 位置 verts[v] x; verts[v] y; verts[v] z; // 法线球面上法线就是归一化的位置向量 verts[v] x / radius; verts[v] y / radius; verts[v] z / radius; } } // 索引生成... }球体的法线计算比立方体简单得多因为球面上任意一点的法线方向就是该点位置向量的归一化方向。这也是球体适合做光照测试的原因——法线连续变化光照过渡自然能很直观地看出漫反射和高光的效果。3.2 索引缓冲的三角形拼接逻辑球体的索引生成需要把相邻的四个顶点组成两个三角形。对于第 stack 行、第 slice 列的顶点它的索引是stack * (SPHERE_SLICES 1) slice。四个顶点分别是左上stack * (SPHERE_SLICES 1) slice右上stack * (SPHERE_SLICES 1) slice 1左下(stack 1) * (SPHERE_SLICES 1) slice右下(stack 1) * (SPHERE_SLICES 1) slice 1两个三角形的索引顺序是左上、左下、右上以及右上、左下、右下。注意顺序要保证逆时针否则背面剔除会把球体内部翻出来。int i 0; for (int stack 0; stack SPHERE_STACKS; stack) { for (int slice 0; slice SPHERE_SLICES; slice) { int first stack * (SPHERE_SLICES 1) slice; int second first SPHERE_SLICES 1; idx[i] first; idx[i] second; idx[i] first 1; idx[i] first 1; idx[i] second; idx[i] second 1; } }这里有个容易踩的坑在极点位置多个顶点会重合在同一个位置但索引仍然会生成三角形。这些退化三角形面积为零GPU会直接丢弃不影响渲染结果但会浪费一点索引数据。如果追求极致性能可以在极点处特殊处理但实际项目中32x32的球体索引数据也就几KB没必要优化。3.3 球体顶点数的性能权衡32x32的球体有1089个顶点6144个索引总共2048个三角形。这个数量在移动端GPU上完全无压力但如果把 slices 和 stacks 提高到64顶点数就变成4225三角形数变成8192顶点着色器的执行次数翻四倍。对于简单的光照演示32x32足够了如果需要更光滑的边缘可以提高到48x48再高就有点浪费了。我实测过在几款中低端移动设备上渲染32x32球体帧率稳定在60fpsGPU占用率不到10%。但如果同时渲染多个球体比如100个那就需要考虑实例化渲染或者合并顶点缓冲了。OpenGL ES 2.0 没有内置的实例化API只能通过glDrawElements多次调用或者用扩展GL_ANGLE_instanced_arrays这是另一个话题了。4. 着色器编写光照模型怎么落地4.1 顶点着色器的矩阵传递与法线变换顶点着色器的核心任务是把顶点从模型空间变换到裁剪空间同时把法线变换到世界空间供片元着色器使用。OpenGL ES 2.0 的 GLSL ES 1.00 没有in/out关键字用的是attribute和varying。// 顶点着色器 attribute vec3 aPosition; attribute vec3 aNormal; uniform mat4 uMVPMatrix; uniform mat4 uModelMatrix; uniform mat3 uNormalMatrix; varying vec3 vNormal; varying vec3 vWorldPos; void main() { vec4 worldPos uModelMatrix * vec4(aPosition, 1.0); vWorldPos worldPos.xyz; vNormal normalize(uNormalMatrix * aNormal); gl_Position uMVPMatrix * vec4(aPosition, 1.0); }这里有个关键点法线变换不能用模型矩阵直接乘因为模型矩阵可能包含非均匀缩放会导致法线方向错误。正确做法是用模型矩阵的逆转置矩阵也就是uNormalMatrix。在CPU端计算这个矩阵需要先求逆再转置OpenGL ES 2.0 没有内置函数得自己写。如果模型矩阵只有旋转和平移没有缩放那法线矩阵就是模型矩阵的左上角3x3部分可以直接用。4.2 片元着色器的漫反射与高光计算片元着色器负责最终的颜色计算。我用的是经典的 Blinn-Phong 光照模型包含环境光、漫反射和镜面高光三个分量。precision mediump float; varying vec3 vNormal; varying vec3 vWorldPos; uniform vec3 uLightPos; uniform vec3 uCameraPos; uniform vec3 uLightColor; uniform vec3 uObjectColor; void main() { vec3 N normalize(vNormal); vec3 L normalize(uLightPos - vWorldPos); vec3 V normalize(uCameraPos - vWorldPos); vec3 H normalize(L V); // 环境光 vec3 ambient 0.1 * uObjectColor; // 漫反射 float diff max(dot(N, L), 0.0); vec3 diffuse diff * uLightColor * uObjectColor; // 镜面高光 float spec pow(max(dot(N, H), 0.0), 32.0); vec3 specular spec * uLightColor; gl_FragColor vec4(ambient diffuse specular, 1.0); }precision mediump float这一行必须写在片元着色器开头否则编译会报错。mediump 的精度对于颜色计算足够了用 highp 会稍微增加GPU负担但在现代移动GPU上差异可以忽略。如果你的设备支持GL_FRAGMENT_PRECISION_HIGH也可以用 highp。高光指数32是我调试出来的经验值。指数越大高光越集中看起来越像金属指数越小高光越分散看起来越像塑料。立方体用16比较合适球体用32到64之间效果最好。这个参数没有绝对标准根据你的材质需求调整就行。4.3 着色器编译与链接的常见错误着色器编译失败是新手最容易卡住的地方。OpenGL ES 2.0 的着色器编译错误信息通过glGetShaderInfoLog获取但很多开发者忘了调用这个函数只看到glCreateProgram返回0就懵了。GLuint compileShader(GLenum type, const char* source) { GLuint shader glCreateShader(type); glShaderSource(shader, 1, source, NULL); glCompileShader(shader); GLint compiled 0; glGetShaderiv(shader, GL_COMPILE_STATUS, compiled); if (!compiled) { GLint infoLen 0; glGetShaderiv(shader, GL_INFO_LOG_LENGTH, infoLen); if (infoLen 0) { char* infoLog (char*)malloc(infoLen); glGetShaderInfoLog(shader, infoLen, NULL, infoLog); // 打印 infoLog这里能看到具体的行号和错误原因 free(infoLog); } glDeleteShader(shader); return 0; } return shader; }常见的编译错误包括变量未声明、类型不匹配、缺少精度限定符、使用了GLSL ES 1.00不支持的语法比如switch语句在部分设备上不支持。链接错误通常是attribute或uniform名字拼写不一致或者顶点着色器和片元着色器的varying变量类型不匹配。5. 渲染循环中的状态管理与性能陷阱5.1 VBO的创建时机与更新策略VBO的创建应该放在onSurfaceCreated里而不是每帧都创建。每帧创建VBO会导致GPU内存频繁分配和释放性能极差。正确的做法是在初始化时创建VBO把顶点数据和索引数据上传一次之后每帧只绑定和绘制。// 初始化时 GLuint vbo, ibo; glGenBuffers(1, vbo); glBindBuffer(GL_ARRAY_BUFFER, vbo); glBufferData(GL_ARRAY_BUFFER, sizeof(cubeVertices), cubeVertices, GL_STATIC_DRAW); glGenBuffers(1, ibo); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo); glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(cubeIndices), cubeIndices, GL_STATIC_DRAW);GL_STATIC_DRAW表示数据不会频繁更新。如果顶点数据每帧都变比如粒子系统那就用GL_DYNAMIC_DRAW。对于立方体和球体这种静态几何体GL_STATIC_DRAW是唯一正确的选择。5.2 属性指针的设置与解绑每次绘制前需要设置顶点属性指针告诉GPU顶点数据的内存布局。位置属性占3个float法线属性占3个float交错存储所以步长是6个float位置偏移是0法线偏移是3个float。glBindBuffer(GL_ARRAY_BUFFER, vbo); glEnableVertexAttribArray(aPositionLoc); glVertexAttribPointer(aPositionLoc, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0); glEnableVertexAttribArray(aNormalLoc); glVertexAttribPointer(aNormalLoc, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3 * sizeof(float))); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo); glDrawElements(GL_TRIANGLES, 36, GL_UNSIGNED_SHORT, 0); glDisableVertexAttribArray(aPositionLoc); glDisableVertexAttribArray(aNormalLoc); glBindBuffer(GL_ARRAY_BUFFER, 0); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);注意glVertexAttribPointer的最后一个参数是偏移量不是字节数。很多人写成3 * sizeof(float)是对的但写成3就错了。这个偏移量会被转换成字节偏移所以必须乘以sizeof(float)。5.3 深度测试与透明度的冲突处理如果立方体和球体同时渲染而且球体有透明效果深度测试就会出问题。透明物体需要在不透明物体之后渲染并且要关闭深度写入glDepthMask(GL_FALSE)否则透明物体后面的东西会被错误地遮挡。我的做法是先渲染所有不透明物体开启深度测试和深度写入然后按距离相机的远近排序透明物体从远到近渲染关闭深度写入但保持深度测试开启。这样透明物体之间能正确混合同时不会遮挡后面的不透明物体。对于立方体和球体的简单场景如果两者都不透明那就直接开启深度测试渲染顺序无所谓。但如果球体用了半透明材质就必须按上面的流程处理。6. 从立方体到球体我踩过的那些坑6.1 球体接缝处的纹理坐标错乱虽然这篇文章主要讲几何体绘制但很多人画完球体之后会想贴纹理。球体的纹理坐标在经度0度和360度处会重合如果纹理坐标没有正确设置接缝处会出现一条明显的错位线。解决办法是在经度方向生成顶点时把最后一个顶点的纹理坐标设为1.0而不是0.0这样纹理就能正确环绕。float u (float)slice / SPHERE_SLICES; float v (float)stack / SPHERE_STACKS; // 纹理坐标 verts[v] u; verts[v] v;这个细节在纯色渲染时看不出来一旦贴图就会暴露。我当初调试了一个下午才发现是纹理坐标的问题一开始还以为是索引顺序错了。6.2 立方体背面剔除后的消失问题开启背面剔除后如果立方体的顶点顺序搞反了整个面会消失。判断顺序是否正确的方法是从立方体外部看每个面的顶点应该是逆时针排列的。如果你不确定可以先关闭背面剔除用双面渲染确认几何体形状正确然后再逐个面检查顶点顺序。另一个容易忽略的点是背面剔除依赖于顶点的屏幕空间投影顺序如果模型矩阵包含负缩放比如镜像顶点顺序会翻转导致正面被剔除。这种情况下需要调用glFrontFace(GL_CW)来反转正面定义。6.3 法线矩阵在非均匀缩放下的错误表现如果对球体做非均匀缩放比如scale(1.0, 2.0, 1.0)球体会变成椭球。这时候如果法线矩阵还是用模型矩阵的左上角3x3光照就会出错——法线不再垂直于表面高光位置会偏移。正确的做法是计算模型矩阵的逆转置矩阵作为法线矩阵。// 计算法线矩阵模型矩阵左上角3x3的逆转置 void normalMatrix(float* out, const float* model) { // 提取3x3部分 float m[9] { model[0], model[1], model[2], model[4], model[5], model[6], model[8], model[9], model[10] }; // 求逆 float det m[0]*(m[4]*m[8]-m[5]*m[7]) - m[1]*(m[3]*m[8]-m[5]*m[6]) m[2]*(m[3]*m[7]-m[4]*m[6]); float invDet 1.0f / det; float inv[9]; inv[0] (m[4]*m[8]-m[5]*m[7]) * invDet; inv[1] (m[2]*m[7]-m[1]*m[8]) * invDet; inv[2] (m[1]*m[5]-m[2]*m[4]) * invDet; inv[3] (m[5]*m[6]-m[3]*m[8]) * invDet; inv[4] (m[0]*m[8]-m[2]*m[6]) * invDet; inv[5] (m[2]*m[3]-m[0]*m[5]) * invDet; inv[6] (m[3]*m[7]-m[4]*m[6]) * invDet; inv[7] (m[1]*m[6]-m[0]*m[7]) * invDet; inv[8] (m[0]*m[4]-m[1]*m[3]) * invDet; // 转置 out[0] inv[0]; out[1] inv[3]; out[2] inv[6]; out[3] inv[1]; out[4] inv[4]; out[5] inv[7]; out[6] inv[2]; out[7] inv[5]; out[8] inv[8]; }这段代码看起来繁琐但如果你要做非均匀缩放的动画这是必须的。如果只是均匀缩放法线矩阵可以直接用模型矩阵的3x3部分归一化省掉求逆的麻烦。6.4 索引缓冲的字节对齐问题glDrawElements的索引类型可以是GL_UNSIGNED_BYTE、GL_UNSIGNED_SHORT或GL_UNSIGNED_INT。用GL_UNSIGNED_SHORT时索引数据在内存中的起始地址必须是2字节对齐的。如果你把索引数据放在一个结构体里或者从文件里读取时没有对齐可能会导致GPU读取错误的数据画面出现乱三角形。我的习惯是用malloc分配索引数组malloc返回的地址是最大对齐的不会有问题。但如果用new unsigned char[]然后强制转换成unsigned short*就要小心对齐问题。最稳妥的方式是直接用unsigned short数组让编译器处理对齐。7. 进阶方向从静态几何体到动态场景立方体和球体跑通之后下一步可以尝试把两者放在同一个场景里加一个轨道相机让球体绕立方体旋转。这涉及到视图矩阵的实时更新以及多个物体的MVP矩阵分别计算。再进一步可以给球体加上环境贴图反射或者用立方体做天空盒。这些都是在掌握了基本几何体绘制之后的自然延伸。我个人在实际项目中的体会是OpenGL ES 2.0 的难点不在于API本身而在于对三维数学的理解。矩阵变换、法线计算、光照模型这些概念在纸面上看很简单但真正写到代码里一个符号错误就会导致画面全黑或者几何体扭曲。我的建议是每写一段代码就打印一下中间结果比如把MVP矩阵的值输出到日志里和手算的结果对比。这种笨办法看起来低效但能帮你快速定位问题比盲目调试快得多。最后再分享一个小技巧如果球体渲染出来看起来有棱角不光滑先检查 slices 和 stacks 是不是太小再检查法线是不是正确归一化了。如果立方体渲染出来有渐变效果看起来像被磨圆了那一定是法线没有按面独立设置检查顶点数据里每个面的法线是否一致。这两个问题我见过太多次了基本上都是这两个原因。