☰
OpenGL ES 2.0实战:从立方体到球体的几何体绘制与光照
2026/10/16 4:11:42 网站建设 项目流程

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,也没有内置的球体或立方体生成函数。所有顶点数据都得你自己算好,塞进 VBO(Vertex 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份,那顶点总数就是 (32+1) * (32+1) = 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球体,帧率稳定在60fps,GPU占用率不到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 是不是太小,再检查法线是不是正确归一化了。如果立方体渲染出来有渐变效果,看起来像被磨圆了,那一定是法线没有按面独立设置,检查顶点数据里每个面的法线是否一致。这两个问题我见过太多次了,基本上都是这两个原因。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询