如果你是第一次接触 OpenGL,或者刚从某本旧教材开始入门,大概率会在目录里看到一大片以gl开头的函数:glBegin、glVertex3f、glColor3f、glMatrixMode、glLoadIdentity、glLightfv……这些接口曾经是 OpenGL 学习的标准起点,但在现在以核心模式(Core Profile)为主的开发环境里,基本已经不被支持,甚至很多新版本驱动里一调用就直接报错。这篇笔记就是想把这批“过时”的基本用法讲清楚。重点不是让你继续照着写老代码,而是帮你看懂它们到底在做什么,为什么被淘汰,以及现在应该用什么思路去替代。
我会先还原一个典型的旧版 OpenGL“立即模式”开发流程,再从实际编码角度拆解每一步的底层逻辑,最后给出迁移到现代 OpenGL 的路线。这篇文章适合两类人:一类是刚接触图形编程、正在看老教程的新手,另一类是维护过老项目、经常被兼容模式坑到的开发者。如果你已经能熟练写 shader 和 VBO,那可以直接跳到第 4 节看对比总结。
1. 为什么还要聊这些“过时”的用法
很多年以前,我入图形学这行时,老师给的第一段代码就是glBegin(GL_TRIANGLES)加三个glVertex3f。当时的体感就是觉得 OpenGL 挺好学,画个三角形几行代码搞定,不用理解太多底层细节。后来换了环境才知道,这套写法早已不是主流的“正确姿势”。但如果你因此就把旧接口全部丢掉,也会失去一部分理解图形管线的好素材。
1.1 固定管线与可编程管线的区别
所谓固定管线,指的是 GPU 内部有一套设计好的处理流程,包括顶点变换、光照计算、纹理混合、雾化、裁剪等。早期 OpenGL(2.1 及以前)把每个阶段都做成了“固定功能单元”。开发者要做的事情,就是通过一堆状态函数往这些单元里配置参数。就像一台傻瓜相机,你只需要按快门,曝光、对焦、白平衡都由相机自动完成。
可编程管线则不一样。OpenGL 3.2 开始引入核心模式,把固定管线的很多功能从 API 里删掉了,取而代之的是让你自己写顶点着色器(Vertex Shader)和片元着色器(Fragment Shader)。着色器实际上就是跑在 GPU 上的小段程序,你完全掌控每个顶点的位置怎么算、每片元的颜色怎么来。这像从傻瓜相机换成了单反手动模式,上限更高,但入门门槛也上去了。
为了说清楚差异,我整理了一个对比表:
| 对比项 | 早期固定管线 | 现代核心模式 |
|---|---|---|
| 顶点传入方式 | glBegin/glVertex逐个传,CPU 到 GPU 拷贝频繁 | 顶点数据放到 VBO(顶点缓冲对象)里,批量上传一次 |
| 坐标变换 | glMatrixMode + glTranslate/glRotate维护矩阵栈 | 自己创建MVP矩阵,在 shader 中用 uniform 传参 |
| 光照 | glLightfv/glMaterialfv配置光源和材质属性 | 在 shader 中实现光照模型,数据任意定义 |
| 纹理 | glEnable(GL_TEXTURE_2D) + glTexCoord | 使用纹理单元和 sampler 采样器 |
| 状态管理 | 全局状态机,大量枚举参数 | 管线状态显式管理,多用对象(VAO、VBO等) |
从这个表就能看出来,固定管线的设计思路是“开箱即用”,它把很多数学运算藏在 API 里;而现代 OpenGL 把“怎么算”交给了开发者。从 3.2 开始,凡是和固定功能相关的 API 在核心模式下都被标记为废弃,甚至直接不可用。所以你在新工程里看不到glTranslatef这类函数。
1.2 学习旧接口的三个价值
既然废弃了,为什么还要花时间了解?第一个价值是读老代码。很多开源项目、老版本引擎、竞赛用模板、大学实验代码,用的还是兼容模式。你要是完全不懂glBegin/glEnd,拿到这些代码时连入口都找不到。
第二个价值是理解概念原型。现代 shader 里经常提到的模型矩阵、视图矩阵、投影矩阵、法线变换、纹理坐标,其实都是从固定管线那套逻辑继承过来的。你知道了glMatrixMode(GL_PROJECTION)在做什么,再看现代代码里用glm::perspective生成矩阵时,思路完全能对得上。旧 API 只不过把矩阵细节藏起来了,而现代写法是让你自己算,数学内核没变。
第三个价值是面试和课程应对。某些图形学课程仍然保留着固定管线教学阶段,面试也偶尔会问“OpenGL 的固定管线和可编程管线有什么区别”。你不需要多精通旧接口,但至少要知道它们存在的年代和解决的问题。
2. OpenGL 窗口与上下文初始化的基本套路
任何 OpenGL 程序的第一步不是画图,而是创建一个 OpenGL 上下文。旧教程通常用 GLUT 来做这件事,因为 GLUT 把创建窗口和处理事件封装得很简单。现在更多人用 GLFW,功能更完整,也持续在维护。理解旧套路有助于你明白上下文初始化的本质。
2.1 用 GLUT 还是 GLFW:历史包袱与新选择
GLUT(OpenGL Utility Toolkit)是很老的工具库,提供了glutInit、glutCreateWindow、glutDisplayFunc这些跨平台函数,让我们能快速搭起一个窗口。但它已经停止维护多年,很多新系统上编译会遇到问题,而且它对高 DPI、键盘输入、窗口管理的支持都很弱。出于学习目的,了解 GLUT 的初始化顺序仍然有意义;如果写新代码,我更推荐 GLFW,它同时支持 OpenGL、OpenGL ES、Vulkan,api 设计也比较现代。
用 GLUT 创建一个窗口,经典代码大概是这样的:
#include <GL/glut.h> void display() { glClear(GL_COLOR_BUFFER_BIT); glFlush(); } int main(int argc, char** argv) { glutInit(&argc, argv); glutInitDisplayMode(GLUT_SINGLE | GLUT_RGB); glutInitWindowSize(800, 600); glutCreateWindow("Legacy OpenGL Demo"); glutDisplayFunc(display); // 注册绘制回调 glutMainLoop(); // 进入事件循环 return 0; }glutInitDisplayMode的作用是告诉系统你希望使用哪种帧缓冲模式,比如单缓冲还是双缓冲,RGB 颜色模式还是带索引的,以及要不要深度缓冲。这里用GLUT_SINGLE意味着绘制完成后直接显示,但现代程序基本都用GLUT_DOUBLE,避免闪烁。glutMainLoop则是让程序进入一个无限事件循环,一直等待窗口刷新或输入事件。
很多老的教程还会在创建窗口后调用glewInit()。GLEW 是 OpenGL 扩展加载库,用来获取新函数的指针。在早期,很多 OpenGL 新功能不是标准 API,而是由驱动提供的扩展,GLEW 帮你把这些扩展函数地址加载进当前上下文。即使你不写现代代码,只要想调用某些核心模式函数,也需要先初始化 GLEW。如果忘了glewInit,经常会出现莫名其妙的空指针调用,窗口画不出东西。
2.2 初始化步骤与最简单的绘制循环
假设我们用兼容模式,并且想画一个最简单的彩色三角形,完整流程包括:创建窗口、设置清屏颜色、注册回调、在主循环里清屏和绘制。旧式写法如下:
#include <GL/glut.h> void display() { glClearColor(0.2f, 0.3f, 0.3f, 1.0f); glClear(GL_COLOR_BUFFER_BIT); glBegin(GL_TRIANGLES); glColor3f(1.0f, 0.0f, 0.0f); glVertex2f(-0.5f, -0.5f); glColor3f(0.0f, 1.0f, 0.0f); glVertex2f( 0.5f, -0.5f); glColor3f(0.0f, 0.0f, 1.0f); glVertex2f( 0.0f, 0.5f); glEnd(); glFlush(); } int main(int argc, char** argv) { glutInit(&argc, argv); glutInitDisplayMode(GLUT_SINGLE | GLUT_RGB); glutInitWindowSize(640, 480); glutCreateWindow("tri"); glutDisplayFunc(display); glutMainLoop(); return 0; }这里有几个关键点。glClearColor设置的颜色只在调用glClear(GL_COLOR_BUFFER_BIT)时生效;它设置的是“当前清屏颜色”。glBegin(GL_TRIANGLES)到glEnd()之间的代码描述了一个图元。每调用一次glVertex2f,就传一个顶点坐标;在顶点之前调用glColor3f,会给这个顶点单独设置颜色。三个顶点分别被赋予红、绿、蓝,于是 GPU 在片元阶段会自动做颜色插值,最后屏幕上看到一个渐变平滑的三角形。
这就是早期 OpenGL 最典型的“立即模式”:每次绘制时,CPU 把顶点数据一个一个送进管线,GPU 再按照当时的固定功能状态去处理。这个教学模式很适合理解图元装配和颜色插值,但它把很多运算法则硬编码了,比如它默认没有深度测试、没有光照,顶点位置直接就是裁剪坐标。你现在看到的是最原始的样子,为了让它更接近“真实三维渲染”,还需要补充矩阵变换、光照、纹理等步骤。
3. 经典固定管线绘制流程拆解
固定管线的核心理念是“状态机”。它维护一大堆状态,比如当前颜色、当前矩阵模式、当前纹理绑定对象、当前光照开关。你需要按照特定顺序设置这些状态,然后再提交顶点数据。下面把这些状态逐一分组看。
3.1 从 glBegin 到 glEnd:顶点数据的传递方式
glBegin和glEnd是固定管线中最经典的一对函数。glBegin(GLenum mode)接受一个枚举值,表示接下来要绘制什么图元:GL_POINTS、GL_LINES、GL_TRIANGLES、GL_TRIANGLE_STRIP、GL_QUADS、GL_POLYGON等。在glBegin和glEnd之间,你可以调用一组允许的顶点属性函数:
glVertex3f(x, y, z):设置顶点坐标。glColor3f(r, g, b):设置当前颜色。glNormal3f(nx, ny, nz):设置当前法线。glTexCoord2f(u, v):设置当前纹理坐标。glMultiTexCoord2f(GL_TEXTUREi, u, v):设置多纹理坐标(一般用不到)。
这些“当前属性”会像粘贴在顶点上一样,被记录到图元里。比如先设置颜色为红色,然后传两个顶点,这两个顶点都会被记录为红色;再改成蓝色传两个顶点,那后面两个就是蓝色。这种“状态值随顶点提交”的行为,在当时是一个非常直观的 API 设计。
但问题也很明显。每个顶点都通过函数调用从 CPU 传到 GPU,数据是流式的;GPU 收到一个顶点就得处理一个顶点。如果场景有几万三角形,就需要几十万次函数调用,CPU 会累死,带宽也扛不住。后来的现代做法是先把所有顶点数据放进一个数组,上传到 GPU 显存里的 VBO,然后在绘制指令里直接用偏移量引用。这就是为什么glBegin/glEnd被无情淘汰。
3.2 坐标变换与 glMatrix 家族
固定管线里的矩阵操作是一个“栈”结构。你可以想象有一摞盘子,最上面的盘子表示当前使用的矩阵。glMatrixMode用来选择操作哪个栈:GL_MODELVIEW(模型视图矩阵栈)、GL_PROJECTION(投影矩阵栈)、GL_TEXTURE(纹理矩阵栈)。这其实是把多个变换矩阵分开管理,避免乱套。
典型的三维绘制前,你会做两件事:设置投影矩阵和设置模型视图矩阵。投影矩阵决定“视景体”长什么样,常见的有正交投影和透视投影。老代码里通常这样写:
glMatrixMode(GL_PROJECTION); glLoadIdentity(); gluPerspective(45.0f, (float)width / height, 0.1f, 100.0f); glMatrixMode(GL_MODELVIEW); glLoadIdentity(); gluLookAt(0, 0, 5, 0, 0, 0, 0, 1, 0);glLoadIdentity()把当前矩阵设为单位矩阵,意思是“先重置”。gluPerspective生成一个透视投影矩阵,参数分别是视场角、宽高比、近裁剪面、远裁剪面。gluLookAt则是构造一个观察矩阵,用“眼睛位置、目标点位置、上向量”三个参数确定相机姿态。
设置完这两个矩阵后,你在绘制物体时可以通过glTranslatef、glRotatef、glScalef来进一步修改模型视图矩阵。比如画一个绕原点旋转的立方体:
glMatrixMode(GL_MODELVIEW); glLoadIdentity(); glTranslatef(0.0f, 0.0f, -3.0f); glRotatef(angle, 0.0f, 1.0f, 0.0f); // 然后绘制立方体的 8 个顶点这里最容易踩的坑是矩阵相乘的顺序。OpenGL 用的是列向量约定,调用glTranslatef之后调glRotatef,实际效果是先旋转再平移。很多新手把代码顺序理解成“先平移后旋转”,结果物体绕着世界原点而不是自身中心转。现在用 GLM 库时你还会遇到这个坑:model = glm::translate * glm::rotate * scale,顺序恰好和视觉直觉相反。学习旧接口的矩阵栈,其实能帮你养成对“变换顺序”的敏感度。
3.3 光照材质与 glLight/glMaterial 的魔法
固定管线提供了内置光照模型,一般称为逐顶点光照(per-vertex lighting)。它会在顶点着色阶段,根据给定的光源信息和顶点法线计算出每个顶点的颜色,再在片元阶段插值。开启光照的代码很典型:
glEnable(GL_LIGHTING); glEnable(GL_LIGHT0); GLfloat lightPos[] = { 1.0f, 1.0f, 2.0f, 1.0f }; GLfloat lightColor[] = { 1.0f, 1.0f, 1.0f, 1.0f }; glLightfv(GL_LIGHT0, GL_POSITION, lightPos); glLightfv(GL_LIGHT0, GL_DIFFUSE, lightColor); GLfloat matDiffuse[] = { 0.8f, 0.6f, 0.4f, 1.0f }; glMaterialfv(GL_FRONT, GL_DIFFUSE, matDiffuse);glLightfv中的fv表示参数是浮点数向量。GL_POSITION设置光源位置,GL_DIFFUSE设置漫反射光的颜色。这里有个关键细节:光源位置是四维向量,第四位是 1.0 时表示点光源,是 0.0 时表示方向光。这个技巧现在看有些别扭,但在当时,OpenGL 用一个第四分量区分两种光源很节省状态设置。材质颜色通过glMaterialfv设置,它会与光源颜色做分量乘加运算,最后生成顶点颜色。
要让光照结果正确,必须在每个顶点前调用glNormal3f设置法线。如果法线缺失或错误,光的计算结果就会混乱。我记得当时画一个立方体,法线给错了,结果每个面都出现奇怪的明暗过渡,像是几何错位。这也说明一个本质问题:固定管线把光照模型固定了,你只能往里塞参数;而现代 shader 里,你可以自己控制“漫反射+高光+环境光”的计算细节,甚至实现 PBR 光照。
3.4 纹理映射的基本用法
纹理映射在固定管线里同样很有“咒语味”。经典步骤包括:
- 生成纹理对象:
glGenTextures(1, &texId)。 - 绑定纹理对象:
glBindTexture(GL_TEXTURE_2D, texId)。 - 设置纹理采样参数:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)。 - 上传图像数据:
glTexImage2D(...)。 - 启用纹理:
glEnable(GL_TEXTURE_2D)。 - 绘制时指定纹理坐标:
glTexCoord2f。
一个最简单的平面贴纹理代码:
glEnable(GL_TEXTURE_2D); glBindTexture(GL_TEXTURE_2D, texId); glBegin(GL_QUADS); glTexCoord2f(0, 0); glVertex2f(-1, -1); glTexCoord2f(1, 0); glVertex2f( 1, -1); glTexCoord2f(1, 1); glVertex2f( 1, 1); glTexCoord2f(0, 1); glVertex2f(-1, 1); glEnd();glTexCoord2f的第一个参数是横向 u,第二个是纵向 v,范围通常是 0 到 1。纹理坐标和顶点坐标绑定在一起。最容易被忽略的是glTexParameteri中GL_TEXTURE_MIN_FILTER和GL_TEXTURE_MAG_FILTER的设置。不设置或者用默认值,放大或缩小时可能产生严重的锯齿或方块,甚至会因为 mipmap 不完整导致纹理呈黑色。
另一个坑是图像数据在内存里的排列顺序。OpenGL 默认认为图像的第一行是纹理坐标的底部(v=0),而很多图片格式的第一行是顶部,直接上传后纹理是上下颠倒的。现代图像加载库通常会帮你翻转 y 轴,但老代码里经常要手动把图片翻转一次。固定管线还有一个全局开关GL_TEXTURE_2D,你必须在绘制前glEnable,否则glBindTexture绑定得再好也不会出现在画面里。这个全局开关的设计,也是“状态机”哲学的一个鲜明例证。
4. 这些方法为什么被弃用,现代 OpenGL 替代方案是什么
如果你已经试着跑过上面的代码,可能会说:这也没那么难啊?但是一旦场景复杂度上来,固定管线的弊端就会暴露得淋漓尽致。这也是为什么 GPU 厂商和标准制定者最终决定掀桌子,引入可编程管线。
4.1 弃用的原因:状态机陷阱与性能瓶颈
先说状态机陷阱。固定管线里,大量状态是全局的,它们相互影响:当前矩阵、当前材质、当前纹理、当前是否开启深度测试、当前 blend 函数。你很难判断某一段代码在另一个环境下是否还能正常工作,因为状态可以被其他模块修改。比如你在绘制阴影时改了深度测试方式,但忘了恢复,后面所有物体都表现异常。这种“全局变量”式的 API 设计,越到后来越难维护。
性能瓶颈更致命。glBegin/glEnd的逐顶点调用模式,会让 CPU 和 GPU 之间产生极高频率的往返通信。每传一个顶点,就做一次函数调用和数据拷贝;GPU 很难做批量优化。同时,固定管线把很多计算固定在了远古硬件设计上,现代 GPU 的并行架构更适合做大量顶点/片元的统一处理,而不是处理“一串随机的即时模式命令”。
还有一个隐藏问题是驱动无法优化。由于状态切换非常灵活,驱动只能为每种状态组合生成对应的代码路径,一旦多个状态交叠,优化极难。与其这样,不如把“状态机”的职责交给应用层,提供更小的功能原语。这就是现代 OpenGL 核心模式的设计立场:能用对象(VBO、VAO、Framebuffer)表示就绝不藏状态;能让你写 shader 就绝不硬编码光照。
4.2 现代 OpenGL 核心模式下的做法
现代 OpenGL 的绘制流程不再是“设置状态 + 逐个传顶点”,而是“准备数据 + 写 shader + 一次提交”。核心概念有三个:
第一,顶点数据存储。你把顶点坐标、颜色、法线、纹理坐标统统写进一个数组,上传到 VBO 中。再用一个 VAO(顶点数组对象)记录“每个属性的格式”,比如第 0 个属性是坐标,偏移多少,步长多少。之后绘制时绑定这个 VAO,GPU 就能从显存里批量读取数据。
第二,着色器阶段。你至少需要一个顶点着色器和一个片元着色器。顶点着色器接收layout(location = 0) in vec3 position;这样的输入,并用 uniform 接收 CPU 端传进来的 MVP 矩阵,计算gl_Position。片元着色器则接收从顶点着色器插值过来的变量(比如颜色、纹理坐标),再输出一个vec4的颜色。自由度非常高。
第三,批次提交。绘制时不再glBegin/glEnd,而是glDrawArrays(GL_TRIANGLES, 0, vertexCount)。GPU 直接根据 VAO 描述去显存中读取数据,一次调用绘制成百上千个三角形,效率高很多。
举例来说,同样画一个三角形,现代 OpenGL 的伪代码流程是:
// 1. 创建 VBO 并上传顶点数据 glGenBuffers(1, &vbo); glBindBuffer(GL_ARRAY_BUFFER, vbo); glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); // 2. 创建 VAO 并描述属性 glGenVertexArrays(1, &vao); glBindVertexArray(vao); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0); glEnableVertexAttribArray(0); // 3. 编译 shader 并链接 GLuint program = createShaderProgram(); // 4. 绘制 glUseProgram(program); glBindVertexArray(vao); glDrawArrays(GL_TRIANGLES, 0, 3);仔细看,现代 API 没有glTranslate,没有glRotate,没有glLight。矩阵变换完全靠你在 shader 里写mat4 model和gl_Position = projection * view * model * vec4(position, 1.0)完成。光照更是在片元着色器里写循环、分支、BRDF 公式。这种自由度意味着高手能做非常复杂的渲染算法,但也意味着“从零开始画一个三角形”的入门门槛比旧 API 高不少。
4.3 从旧代码迁移的思路
如果你手里有一份老代码,想迁移到现代 OpenGL,建议按下面几步走:
- 先搭一个现代窗口:推荐 GLFW,加上 GLAD 或 GLEW 来加载函数。创建上下文时请求核心模式,并设置 OpenGL 版本为 3.3 以上。
- 把顶点属性整理成结构体数组:确定有坐标、颜色、法线、纹理坐标;然后生成 VBO 和 VAO,把数据一次性上传。
- 写好基础 shader:顶点着色器处理矩阵变换,片元着色器处理颜色输出。一开始不需要太复杂,只把旧代码中的固定颜色输出即可,验证流程通顺。
- 用 GLM 写矩阵数学:用
glm::perspective、glm::lookAt、glm::translate把旧代码里的矩阵操作替换成 CPU 端矩阵,再通过 uniform 传到 shader。 - 把光照要求转换成 shader 代码:旧代码的
glLight相当于给出了光源位置和颜色,你在 shader 里用 uniform 接过来,手动计算漫反射和高光。
迁移过程中最容易出错的是“变换顺序”和“坐标空间”。旧 API 会自动处理裁剪坐标输出,新 API 中你必须自己在顶点着色器里把 object 坐标变换到 clip 坐标。另外,旧代码里很多隐含状态比如“当前纹理是否启用”都没有了,你必须显式地在 shader 里判断是否采样纹理。这是一个痛苦但必要的重构过程。
5. 实操中的常见问题与排查记录
在这套旧用法上,我踩过不少坑,也见过很多人被同样的问题卡住。如果你在跑老代码,下面这些排查经验大概率用得上。
5.1 黑屏、空白窗口的常见原因
费了半天劲,窗口是出来了,但里面黑乎乎一片,什么都看不到。黑屏通常不是“画错了”,而是“根本没画上去”。先检查这几点:
- 是否在创建上下文时启用了兼容模式?现在很多系统默认给你一个兼容性最好的上下文,但有些环境只支持核心模式,你调
glBegin时直接报GL_INVALID_OPERATION。 - 是否忘了调用
glutDisplayFunc?如果你没有注册绘制回调,窗口只会执行一次初始化和事件循环,display 内容永远不会被绘制。这是最常见的“黑屏”原因。 - 是否设置了
glClearColor但没调用glClear?那只是设置颜色,不会清除帧缓冲,屏幕上显示的还是之前的东西。 - 是否调用了
glFlush或glutSwapBuffers?如果你用了双缓冲,绘制后必须交换缓冲区才能显示,否则画面一直停留在“未显示”的背面缓冲上。
一个高效的黑屏排查思路是:先只画一个纯色三角形,去掉所有矩阵、光照、纹理操作。如果连纯色都显示不出来,问题一定在初始化和绘制循环;如果纯色可以,那再逐层加光照、纹理。
5.2 矩阵变换“诡异”的坑
矩阵变换的坑大多来自“顺序”和“矩阵栈”的理解偏差。老代码里,glLoadIdentity是重置,glTranslatef是累加乘到当前矩阵上。举个例子:
glTranslatef(1, 0, 0); glRotatef(90, 0, 0, 1);实际效果是先绕原点旋转 90 度,再平移到 (1,0)。如果你以为先平移再旋转,物体位置会完全不对。排查变换问题时,最直观的办法是临时把旋转角度设成 0,看看平移是否按预期工作;再把平移设成 0,看旋转是否围绕原点工作。这样能把“变换本身写错”和“顺序理解错”区分开。
另一个常见问题是投影矩阵设置不正确。如果你忘了设置glMatrixMode(GL_PROJECTION)和glLoadIdentity,默认投影矩阵有可能是单位矩阵,视锥体的近远范围非常窄([-1,1])。物体在 z=0.5 时能看到,在 z=-5 时就被裁掉了。这时候可以试着把物体沿着 z 轴慢慢移近相机,或者用glOrtho设置大的视景体,先让画面出现再说。
5.3 纹理显示不正确的排查思路
纹理相关的老代码问题集中在四个地方:
- 图片上下颠倒。解决办法是翻转图片的 y 坐标,或者翻转纹理坐标。在
glTexImage2D之前将图像内存按行反转,是最常见的处理方式。 - 没有启用纹理。老代码中忘记
glEnable(GL_TEXTURE_2D)会导致物体颜色变成纯色,纹理想显示也显示不出来。 GL_UNPACK_ALIGNMENT不对。如果图片宽度不是 4 的倍数,默认按 4 字节对齐读取时会错位,导致纹理出现斜条纹或花屏。调用glPixelStorei(GL_UNPACK_ALIGNMENT, 1)可以解决。- 过滤参数没有设置完整。特别是
GL_TEXTURE_MIN_FILTER,如果你设置了GL_LINEAR_MIPMAP_LINEAR却没有生成 mipmap,纹理就会是黑色或空白。要么改成GL_LINEAR,要么调用glGenerateMipmap(新版本支持)。
排查纹理问题,我建议画一个很小的纹理(比如 2x2 像素),填充四个分明颜色,然后看输出。这样很容易判断是纹理坐标错位、对齐错位,还是过滤参数的问题。
5.4 兼容模式在 macOS 上的限制
如果你在 macOS 上练习旧代码,会遇到一个比较特殊的问题。macOS 对 OpenGL 的支持最高只到 4.1,并且系统默认提供的是核心模式的上下文,并不提供真正的固定管线和立即模式。你需要通过NSOpenGLProfileVersionLegacy或 GLFW 的GLFW_OPENGL_COMPAT_PROFILE来请求兼容模式,但即便如此,很多旧函数在驱动层也仅部分支持,glBegin仍然会报错或者使用急救包。
这里我不展开具体配置,因为你只要记住结论:在 macOS 上,与其折腾兼容模式,不如直接用现代 OpenGL。所有经典固定管线代码,最好在 Windows 或 Linux 虚拟机里跑。如果你只是“想了解”这些旧用法,完全可以在 macOS 上跑现代 OpenGL,配合阅读老代码,脑内把概念映射过去就行。
6. 整理旧接口时的一些实际体会
真正动手整理这些旧接口时,我有一个很明显的感受:它们并不难懂,难点反而在“能不能忘掉它们”。就像学开车时先学手动挡,虽然手动挡在城里开起来很累,但它会让你搞明白离合器、换挡和发动机转速之间的关系。学完再开自动挡,你会知道自己脚下的操作本质上是在控制什么。旧版 OpenGL 的glMatrixMode、glLightfv、glTexEnv就是那根离合器,现代 shader 里的矩阵乘法和光照计算就是自动挡的电子控制单元。
我的建议是:新手可以从这段历史学起,但不要在上面花费超过两周时间。换到现代 OpenGL 之后,你会发现很多 API 名称都变了,但底层的渲染流水线概念没变。你过去调glVertex3f时写的每个顶点,现在变成了vertices数组里的一个个浮点数;以前调glLightfv时给的光源位置,现在变成了 shader 里的uniform vec3 lightPos。知识并没有消失,只是换了一种更暴露本质的形态。
最后分享一个小技巧:如果你想快速验证一段老式 OpenGL 代码的逻辑,又不想折腾兼容模式,可以先用一个现代的 OpenGL 窗口,把顶点数据处理成 VBO,然后在顶点着色器里用简单的gl_Position = projection * view * vec4(position, 1.0)来复现。那些旧接口里的“魔法矩阵”,你就把它当成一组预先算好的 uniform 来理解。这样一来,你看老教程时会觉得豁然开朗,写新代码时也不会重蹈状态机的覆辙。
旧接口是一段值得了解的历史,但绝不是现代项目的最佳起点。看懂它们,然后放下它们,才是这类“基本用法介绍”真正想传达的东西。