首页 / 资讯中心 / 文章详情

OpenGL画正方体:从三角形到3D的必经之路

OpenGL画正方体:从三角形到3D的必经之路 ★ FEATURED ARTICLE
学OpenGL很多人卡住的第一道坎不是数学也不是图形学理论而是“不知道从哪开始”。三角形画出来了点线也画出来了下一步该干嘛我的建议永远是同一个画个正方体。别小看这个看起来有点“幼稚”的玩具它其实是2D到3D的分水岭——一旦你靠自己把它转起来说明OpenGL里最核心的管线流程、顶点缓冲、矩阵变换、深度测试这些概念你已经从“听过名字”进化到了“知道怎么用”的阶段。这篇内容我按自己的实操顺序来写先讲整体思路再拆核心概念然后给一套可以直接抄作业的代码最后把我踩过的坑全部列出来。适合刚入门OpenGL、已经能画出一个三角形但还没跨进3D门槛的读者也适合被各种概念绕晕了、想通过一个小项目把知识点串起来的人。1. 画一个正方体为什么是OpenGL入门的“必修课”正方体看起来简单但你别只把它当成一个几何题。放在OpenGL的语境里它几乎逼着你把渲染管线的所有关键环节摸一遍。1.1 正方体这个“玩具”的含金量从几何角度看正方体没什么神秘的8个顶点、6个面、12个三角形。比三角形多出来的部分是“数量”和“空间关系”多个面之间有前后遮挡要正确地显示出来就必须处理深度要能从不同角度观察它就得引入模型、视图、投影三种矩阵6个面想用不同颜色区分或者后面上纹理、打光照顶点属性就不再只是位置和颜色这两样了。也就是说画三角形时你只需要“把数据送进GPU”但画正方体时你得学会“组织数据、管理状态、控制变换”。这三件事恰恰是实际图形开发里天天要做的日常。1.2 从三角形到正方体一步跨过三个门槛第一个门槛是坐标维度变了。三角形的顶点坐标是三维的但本质上“贴”在一个平面上你感受不到深度。正方体则要求你真正理解三维空间顶点从-1到1分布在X、Y、Z三个轴上然后通过矩阵把它们投影到屏幕上。第二个门槛是深度。没有深度测试的时候GPU只按提交顺序画图元后画的会盖住先画的而正方体有多个面互相遮挡必须开启深度缓冲让每个像素保留“离相机最近”的记录。第三个门槛是旋转。你总不会只想看一个静止的正面吧让正方体转起来就要每帧改矩阵把时间变量和高性能渲染循环串起来。这三个门槛每一个背后对应着一个明确的API或数学概念但它们不是孤立的而是被“正方体”这个项目串成了完整链条。这也是为什么我从来不建议一上来就去啃一大堆理论、然后照着教程抄一堆高大上的代码改两个参数就自称学会了——你得像拼图一样把程序跑起来再回头去理解每一片拼图为什么在这里。2. 动手前必须搞懂的几个核心概念如果你已经在网上找过OpenGL教程大概率会看到一堆缩写VBO、VAO、EBO、GLSL、MVP……每个概念单独看还能理解放在一起就容易乱。这一节我用自己理解的方式全盘讲一遍不追求教科书式的严谨但保证能串起来。2.1 OpenGL的“状态机”思维OpenGL本质上是一个巨大的状态机。它不是“你给我一个物体我把物体画出来”的接口而更像是“你告诉我当前处于什么状态然后按照当前状态去解释你给的数据”。比如我现在绑定了哪个VAO、启用了哪个着色器程序、开没开深度测试、用哪个纹理单元——这些都算状态。你每次画东西前只要保证关键状态是对的OpenGL就会按这些状态去处理后面提交的绘制命令。这个思维很反直觉因为日常编程里我们习惯“传参、调用”。但在OpenGL里很多配置是一次设置、全局生效的。比如开启深度测试只需要在初始化时调一次glEnable(GL_DEPTH_TEST)后面所有绘制都受影响。理解状态机之后很多“为什么我的画面变了”的问题都能解释八成是某个状态在别处被改掉了。2.2 VAO、VBO、EBO三件套到底各管什么这三个缓冲对象是最容易混淆的用个生活化的类比来记VBO是“仓库里的货”里面存的是一整块顶点数据每个顶点有哪些属性、连续排列都由VBO负责搬运。EBO是“索引目录”告诉GPU“按照什么顺序去取仓库里的货”从而复用顶点避免重复存储。VAO是“取货清单”它不存数据本身只记录“VBO里第几个属性偏移多少、步长多少、传给着色器的哪个location”相当于把数据布局的说明归档。画三角形时你可以只用VAOVBO画正方体时必须引入EBO因为正方体8个顶点、但需要36个索引6个面 × 2个三角形 × 3个顶点。如果不用索引你得把顶点复制36份内存浪费、改动也笨拙。EBO减掉的不是绘制次数是顶点数据的重复存储。2.3 着色器顶点着色器和片段着色器的分工着色器是跑在GPU上的小程序用GLSL编写。你要至少准备两个顶点着色器处理每个顶点的位置还可能做坐标变换片段着色器处理每个像素片元的颜色。它们的关系有点像“先画框架、再填色”顶点着色器决定三角形落在屏幕哪里片段着色器决定三角形内部每一点的颜色。一个最容易理解错的地方片段着色器不是“每个顶点执行一次”而是“每个像素执行一次”。正方体有6个面每个面至少由两个三角形组成光栅化后每个像素都会跑一遍片段着色器。这也是为什么在片段着色器里写“uniform颜色”能影响整个物体而“逐顶点颜色”需要在顶点着色器里传递varying也就是如今的in/out。2.4 MVP矩阵摆好物体、架好相机、按下快门MVP是三个矩阵的缩写MModel模型矩阵把物体从自己的局部坐标挪到世界坐标。旋转、平移、缩放都在这一步。VView视图矩阵相当于把相机放在某个位置、朝某个方向看所有物体都会被变换到“以相机为原点”的观察空间。PProjection投影矩阵把三维观察空间压成一个裁剪盒再做透视除法最终映射到屏幕坐标。总的来说最终顶点位置 P × V × M × localPos。注意顺序是从右往左读不是从左往右。我在初学阶段反复在这里翻车——矩阵乘法不满足交换律写反了画面会乱成一团。你可以把MVP想象成拍照M是模特摆姿势V是摄影师找机位P是镜头焦距三者叠在一起才是最终成像。3. 实操一步步把那个正方体弄出来概念说再多不如代码跑一遍。下面这套流程是我自己从零搭的基于C和OpenGL核心模式Core Profile窗口和上下文管理用的GLFW函数指针加载用的GLAD数学库用的GLM。这些库都是OpenGL生态里的常见选择网上找安装教程很容易这里不赘述重点讲画正方体的每一步代码和意图。3.1 初始化和创建窗口窗口创建看似是“环境问题”但有个魔鬼细节核心模式下必须通过GLAD加载OpenGL函数指针。#include glad/glad.h #include GLFW/glfw3.h #include glm/glm.hpp #include glm/gtc/matrix_transform.hpp #include glm/gtc/type_ptr.hpp #include iostream void framebuffer_size_callback(GLFWwindow* window, int width, int height) { glViewport(0, 0, width, height); } int main() { glfwInit(); glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); GLFWwindow* window glfwCreateWindow(800, 600, Cube, NULL, NULL); glfwMakeContextCurrent(window); gladLoadGLLoader((GLADloadproc)glfwGetProcAddress); glViewport(0, 0, 800, 600); glfwSetFramebufferSizeCallback(window, framebuffer_size_callback); return 0; }这里有个很容易踩的坑在创建上下文之前必须先用glfwWindowHint设置OpenGL版本和模式。如果你不指定很可能得到一个旧版兼容上下文某些核心模式API会失效。另外gladLoadGLLoader要在glfwMakeContextCurrent之后调用否则函数指针都是空的一调用就崩溃。3.2 准备正方体的顶点数据和索引正方体我习惯用中心在原点、边长2的坐标从-1到1。这样后面做矩阵旋转、缩放都是对称的我试过用边长1的版本旋转时总感觉“重心偏移”代码还得多乘一个0.5不划算。8个顶点坐标如下float vertices[] { // positions // colors -0.5f, -0.5f, -0.5f, 1.0f, 0.0f, 0.0f, 0.5f, -0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.5f, 0.5f, -0.5f, 0.0f, 0.0f, 1.0f, -0.5f, 0.5f, -0.5f, 1.0f, 1.0f, 0.0f, -0.5f, -0.5f, 0.5f, 0.0f, 1.0f, 1.0f, 0.5f, -0.5f, 0.5f, 1.0f, 0.0f, 1.0f, 0.5f, 0.5f, 0.5f, 1.0f, 1.0f, 1.0f, -0.5f, 0.5f, 0.5f, 0.0f, 0.0f, 0.0f }; unsigned int indices[] { 0, 1, 2, 2, 3, 0, // 背面 4, 5, 6, 6, 7, 4, // 前面 1, 5, 6, 6, 2, 1, // 右面 0, 4, 7, 7, 3, 0, // 左面 3, 7, 6, 6, 2, 3, // 顶面 0, 1, 5, 5, 4, 0 // 底面 };每个顶点用6个float前3个是位置后3个是颜色。我给每个顶点安排了颜色这样每个面会有渐变效果能直观看到空间朝向比纯白布线更容易判断对错。索引顺序我给出的是一组能正常工作的逆时针卷绕如果你要开启背面剔除这个顺序很重要不开的话顺序错了也能画出来但对后续学习不友好建议从一开始就保持逆时针习惯。3.3 绑定VAO、VBO、EBO这步是很多教程直接复制粘贴、但很少解释清楚的部分。我的操作顺序是先创建一个VAO绑定它然后创建VBO并绑定分配数据再创建EBO并绑定最后设置顶点属性指针。unsigned int VBO, VAO, EBO; glGenVertexArrays(1, VAO); glGenBuffers(1, VBO); glGenBuffers(1, EBO); glBindVertexArray(VAO); glBindBuffer(GL_ARRAY_BUFFER, VBO); glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO); glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW); // 位置属性 glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0); glEnableVertexAttribArray(0); // 颜色属性 glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3 * sizeof(float))); glEnableVertexAttribArray(1); glBindBuffer(GL_ARRAY_BUFFER, 0); glBindVertexArray(0);注意两个细节。第一glVertexAttribPointer里最后一个参数是“偏移量”必须写成(void*)0和(void*)(3 * sizeof(float))不能直接传0和3。很多人会忘了类型转换导致编译报错或者内存错乱。第二EBO的绑定必须在VAO绑定期间完成这样VAO会“记住”这个EBO如果你在解绑全部顶点数组之后再单独绑定EBOVAO是不知道EBO存在的。我当时调了一下午画面什么都没有最后查了才明白是这个顺序问题。3.4 编译着色器程序着色器源码我用两个const char*字符串保存实际项目里建议读文件但示例代码里直接内嵌更省事。const char* vertexShaderSource R( #version 330 core layout (location 0) in vec3 aPos; layout (location 1) in vec3 aColor; out vec3 vColor; uniform mat4 uMVP; void main() { gl_Position uMVP * vec4(aPos, 1.0); vColor aColor; } ); const char* fragmentShaderSource R( #version 330 core in vec3 vColor; out vec4 FragColor; void main() { FragColor vec4(vColor, 1.0); } );顶点着色器里我特意加了一个uMVP的uniform矩阵把所有变换都用矩阵乘法完成。这样代码里不需要重复写旋转公式逻辑也更清晰。片段着色器只做一件事把接收到的顶点颜色原样输出。如果后面想上纹理只需要在这里加一个sample结构是一样的。编译过程就是常规的创建着色器对象、附源码、编译、检查错误、删除对象我建议每次都要检查编译结果比如如果GLSL版本号写错不会直接报错而是导致着色器program链接失败屏幕全黑排查起来得花不少时间。3.5 开启深度测试深度测试是3D的一个核心机制直接决定正方体各面能不能正确遮挡。在初始化部分glViewport之后加两行glEnable(GL_DEPTH_TEST);为什么只加这一行就够了因为深度测试需要的深度缓冲是在窗口系统创建时自动分配的。但你在绘制循环里每帧必须清除深度缓冲否则上一帧的深度信息会残留导致画面显示混乱。清除深度缓冲的时机放在绘制之前glClearColor(0.1f, 0.1f, 0.1f, 1.0f); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);3.6 计算MVP矩阵并传给着色器矩阵部分我直接用GLM库。每帧要做的事是给一个模型矩阵做旋转视图矩阵做相机位姿投影矩阵用透视投影。具体代码如下glm::mat4 model glm::mat4(1.0f); model glm::rotate(model, (float)glfwGetTime() * glm::radians(50.0f), glm::vec3(0.5f, 1.0f, 0.0f)); glm::mat4 view glm::mat4(1.0f); view glm::translate(view, glm::vec3(0.0f, 0.0f, -3.0f)); glm::mat4 proj glm::perspective(glm::radians(45.0f), 800.0f / 600.0f, 0.1f, 100.0f); glm::mat4 mvp proj * view * model; glUseProgram(shaderProgram); int mvpLoc glGetUniformLocation(shaderProgram, uMVP); glUniformMatrix4fv(mvpLoc, 1, GL_FALSE, glm::value_ptr(mvp));相机放在(0, 0, -3)意味着它离原点3个单位的距离。其实方向是朝原点看的。如果你相机设成(0, 0, 0)也就是和正方体重合那从正方体内部看出去你只能看到各面内部画面会非常混乱——这是我第一次画正方体时遇到的情况还以为是数据错了其实是相机被包在物体里面了。3.7 渲染循环前面所有准备都是为了在渲染循环里做得干净高效。循环内部的标准姿势是清屏→使用着色器→绑定VAO→设置uniform矩阵→绘制→交换缓冲→处理事件。核心代码while (!glfwWindowShouldClose(window)) { glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); glUseProgram(shaderProgram); glBindVertexArray(VAO); // 更新模型矩阵旋转和MVP model glm::mat4(1.0f); model glm::rotate(model, (float)glfwGetTime() * glm::radians(50.0f), glm::vec3(0.5f, 1.0f, 0.0f)); mvp proj * view * model; glUniformMatrix4fv(mvpLoc, 1, GL_FALSE, glm::value_ptr(mvp)); glDrawElements(GL_TRIANGLES, 36, GL_UNSIGNED_INT, 0); glfwSwapBuffers(window); glfwPollEvents(); }这里有几个可能让你卡壳的点第一glDrawElements的第二个参数是36不是6因为36个索引对应12个三角形。第二最后一个参数传0因为EBO已经绑定在VAO上GPU知道去哪找索引数据。第三每帧都要重新计算MVP因为model矩阵里的旋转角度在变。如果你忘了更新uniform就算模型矩阵每帧都在变画面也是不动的这个“数据没到GPU”的问题最容易忽略。3.8 让画面真正“活”起来如果一切正常运行起来你应该看到一个多色立方体在旋转能看到不同面的颜色渐变而且旋转到后面时后面的面会被前面挡住。这个“遮挡”效果对初学者来说很有成就感它是深度测试和正确矩阵配合的结果。如果你想让旋转更可控一点可以绑定键盘事件比如按A往左转、按D往右转。我的建议是先用时间驱动旋转因为它不需要处理输入能更快看到效果。等正方体稳了再改成输入驱动。4. 常见问题与排查技巧实录写这部分之前我想说一句图形学项目调试最忌讳“瞎改”。改了没效果就加打印打印不出来就先断点。下面这些问题全是我自己遇到过、或者在帮别人排查时看到过的按出现频率从高到低整理。4.1 屏幕上什么都看不见或者只有一个乱色块90%的情况是顶点数据布局和着色器属性设置不匹配。比如你在着色器里写了layout(location 0)的是位置但glVertexAttribPointer里步长写成了3个float而顶点数据其实是6个float一组GPU就会把颜色数据当成位置来解析画面乱成一团。另一个高频原因是矩阵没传过去比如忘了glUseProgram或者uniform名称写错GLSL里uniform变量如果未被优化掉名字必须和c代码里的字符串完全一致多一个空格都不行。还有一种隐蔽情况着色器编译成功、链接失败会直接导致绘制命令无效。检查链接状态用的是glGetProgramiv这个步骤真不能省。我见过有人改着色器时把#version 330 core弄丢了结果无论怎么调C代码画面永远是黑的。4.2 画面闪烁或者面与面之间串着乱变这是典型的深度测试问题。不需要深度时OpenGL按提交顺序绘制后画的覆盖先画的你需要深度测试时片元不仅要通过颜色缓冲还要通过深度测试。如果没开启glEnable(GL_DEPTH_TEST)或者每帧没清深度缓冲上一帧的深度信息会和当前帧混合造成“闪烁”和“乱了”的视觉效果。小技巧调试时可以用glDisable(GL_DEPTH_TEST)对比看看画面区别理解什么是深度什么不是深度。实际开发时一定开启而且一定要清缓冲glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)是标准组合别漏了其中一个。4.3 旋转时正方体“穿插”或“被切成两半”如果只是绕某个轴转出现“穿插”感很可能是矩阵乘法顺序错了。记住MVP是从右到左乘proj * view * model不是model * view * proj。矩阵乘法不满足交换律顺序错了等于先做透视再做模型变换画面就会很奇怪。我第一次用GLM时写成了model * view * proj看到的是整个坐标系被压扁立方体像被夹在两块玻璃中间排查了很久。4.4 启用了背面剔除后一半面消失背面剔除是个优化功能默认不开启。如果你开了glEnable(GL_CULL_FACE)GPU会把“背面”的三角形丢弃而“背面”的定义取决于索引顶点的卷绕方向逆时针为正面还是反向取决于你的设定。我给的索引是按逆时针正面来写的但如果你用别的顶点顺序需要把索引顺序统一调成逆时针。如果你想要双面显示可以glDisable(GL_CULL_FACE)或者设置glCullFace(GL_BACK)配合glFrontFace(GL_CCW)。建议第一个正方体先别开背面剔除等你能看到完整六面后再开它观察哪几个面消失了这能帮你理解图元装配阶段到底发生了什么。4.5 画出来的正方体是“扁”的或者被拉伸这通常是投影矩阵里的宽高比没对上。我用的窗口是800x600透视投影第二个参数是宽高比800.0f / 600.0f。如果你写成了1.0f而窗口不是正方形画面就会纵向或横向拉伸。同理如果你改变窗口大小但没更新投影矩阵画面也会变形。所以我把framebuffer_size_callback里重新计算投影矩阵作为标准动作或者至少把投影矩阵放在循环里计算。4.6 旋转不流畅帧率极低可能是没开垂直同步或者绘制循环里做了太多CPU工作。一个容易忽略的点glfwPollEvents只处理事件不会垂直同步你需要glfwSwapInterval(1)开启垂直同步否则画面会被撕裂或帧率飙高。如果你的代码里每帧重新创建着色器、重新Compile那帧率肯定爆炸——着着色器应该只编译一次渲染循环里只改矩阵和绘制。4.7 用GL_UNSIGNED_INT却传了0或者用GL_UNSIGNED_BYTE却传了12索引数组的数据类型要和glDrawElements的第四个参数对应。如果你声明indices是unsigned int数组那么必须用GL_UNSIGNED_INT第四参数传(void*)0表示从起始处开始。如果你用GL_UNSIGNED_BYTE节省内存那么索引最大只能到255正方体8个顶点够用但复杂模型就不够。这个类型不匹配的问题不会编译报错只会画出乱线或者错面。我自己的习惯是数组类型和枚举类型保持一致除非确实有必要做内存优化。否则调试成本往往大于那一点点内存收益。5. 一些能提升幸福感的小扩展画出一个转动的正方体任务达成。但既然你已经搭建好了整个管线完全可以顺手再做几个小扩展巩固理解也让自己发博文时更有话说。给正方体加上线框模式用glPolygonMode(GL_FRONT_AND_BACK, GL_LINE)画线框可以同时看到背面的隐藏线配合深度测试你能非常直观地理解“哪些面被挡住了”。但这会暴露绘制顺序的真相线框模式下的透明感其实是深度的结果可以多观察。画完线框记得改回GL_FILL否则下次绘制全部是线框。改用键盘控制旋转角度把glfwGetTime()换成全局变量angle监听键盘旋转、缩放这会迫使你处理输入和状态更新做一个小交互。给正方体加上两三个“同伴”把同一个VAO用不同的模型矩阵绘制多次画一个“正方体阵列”。这个扩展会刷新你对“绘制调用”的认知同一个VBO可以多次复用不同位置通过不同MVP实现完全不浪费内存。这也为后面学习实例化渲染打底。加光照如果你已经理解了MVP哪怕只加最简单的“漫反射光”也需要法线数据、光源位置、视角方向这会让你的物体从“花哨的色块”升级成“有立体感的物体”。这一步做完基本就是入门级的真实感渲染了。关于这个项目最后还想说几句我的经验是学OpenGL不要贪多、贪快。当初我画完三角形急着去学纹理、光照、模型加载结果每个知识点都似懂非懂最后回过头来重新把正方体做了一遍才把所有概念串起来。改变我的是这样一个心态每个小项目都要“手动实现”核心部分——矩阵自己算一遍哪怕用GLM也得知道乘的顺序着色器自己写一遍哪怕抄也要逐行弄懂。这个正方体项目里最值钱的部分不是“画出来”而是你在调试中踩过的每一个坑。那些坑会让你在以后面对更复杂场景时知道问题可能出在哪一层。如果这篇文章能让你少走一点弯路那它就有价值了。现在照着代码把正方体转起来吧。
阅读完成 · 觉得有帮助?
咨询建站