资讯详情 OpenGL图形学实验全解析:从DDA到着色器的可运行工程
📅 2026/10/23 9:50:56
简介西北工业大学软件学院计算机图形学实验代码包覆盖二维/三维图形绘制、几何变换、投影视图、光照模型、物体建模、碰撞检测及交互设计等知识点全部程序基于C语言在VC6.0环境编写调试适合课程同步练习、期末复习与项目复盘参考。压缩包共224个文件、5.37MB含28个cpp源文件、18个h头文件、12套dsp工程文件以及dsw、ncb、exe可执行程序、obj目标文件、pch预编译头工程结构完整可独立编译运行。已有581人学习。各实验子项目代码附有详细注释可对照Dijkstra最短路径、Flood Fill区域填充、Phong光照模型等经典算法实现理解图形学底层原理亦能直接运行exe查看效果适合需要动手强化编程能力的学生与自学者。1. 期末突击计算机图形学一份能直接跑的实验包意味着什么图形学实验最折磨人的不是算法公式而是你把DDA和Bresenham背得滚瓜烂熟屏幕依然一片黑。我整理这份某高校软件学院《计算机图形学》课程实验包的时候核心目标只有一个让每个实验都能单独编译打开就有图形反馈。它覆盖画线、画圆、二维变换、三维观察、光照材质与纹理映射从CPU端的光栅化算法到GPU端着色器都有可运行工程。适合两类人一类是期末想快速补齐实验、照着可运行代码理解原理的同学另一类是熟悉API但没从头写过图形学算法的开发者。压缩包里没有绕开算法的魔改画笔路径、光源位置、顶点索引全部摊开在源码里你可以从框架里拎出一个实验直接改也能把整套工程当模板逐步替换成自己的渲染器实现。2. 技术栈与工程拓扑Visual Studio OpenGL从固定管线到着色器2.1 依赖选择freeglut、GLEW、GLM 各司其职OpenGL 不只是链接一个 opengl32.lib 就能舒服开发的它缺三样东西窗口上下文、现代扩展函数指针、数学库。这套实验包用的是 C/Visual Studio 栈依赖集中在 freeglut、GLEW、GLM 三件套上。freeglut 负责创建窗口、处理键盘鼠标事件和主循环GLUT 那套回调模型和教材里“绘图-事件-重绘”的描述几乎一一对应课堂实验用它比直接写 Win32 消息循环省太多事。GLEW 负责把 OpenGL 3.0 之后的扩展函数加载进来没有它 glGenVertexArrays 这种函数在 Windows 上根本拿不到地址。GLM 是纯头文件的矩阵库写观察和投影矩阵时不需要自己手推透视矩阵公式。组件职责使用要点freeglut窗口、输入事件、主循环回调函数注册后由 glutMainLoop 驱动GLEW加载现代 OpenGL 扩展函数必须定义 GLEW_STATIC 再链 glew32s.libGLM矩阵、向量、透视投影列主序直接对接 GLSL 的 mat4opengl32.libWindows 系统 OpenGL 基础库附加依赖项里固定存在为什么不用 DirectX不是不能用而是课程实验讲的是通用图形学概念OpenGL 在各平台行为一致固定管线的 glBegin/glEnd 写法对早期光栅化实验特别友好。你会在 01、02 号实验里看到传统绘制 API到光照纹理实验再切到着色器正好覆盖图形学教学的两个阶段。2.2 工程目录结构一个解决方案挂六个实验打开压缩包第一眼看到的是MyProjects.sln根目录按实验编号拆分工程。大多数实验是一个main.cpp加少量封装头文件个别实验带资源目录整体结构大致如下MyProjects/ ├─ 01_DDA_Bresenham/ │ ├─ src/main.cpp │ └─ README.md ├─ 02_Circle_Ellipse/ ├─ 03_Transform2D/ ├─ 04_ViewProjection/ ├─ 05_Lighting_Material/ ├─ 06_TextureMapping/ ├─ common/ │ ├─ shader.h │ └─ win32_path.h ├─ libs/ │ ├─ freeglut/ # include 与 lib 分平台存放 │ ├─ glew/ │ └─ glm/ └─ MyProjects.slncommon/不是课程强制的是我在整理代码时习惯性的收口凡是两个以上工程都用得到的封装比如着色器编译、文件路径转换都放进公共头文件。你把每个工程完全独立也可以但那样遇到着色器编译报错就得复制粘贴六遍检查代码公共目录能少改很多地方。各实验与课程知识点的对应关系如下工程目录核心知识点演示重点01_DDA_BresenhamDDA 与 Bresenham 直线生成两种算法在相同坐标下的造点轨迹02_Circle_Ellipse中点画圆、椭圆的参数方程圆对称性与决策参数的更新03_Transform2D平移、旋转、缩放的复合矩阵绕任意点旋转的实现04_ViewProjection透视投影、观察矩阵视锥体与深度测试效果05_Lighting_MaterialBlinn-Phong 光照模型光源位置、材质高光06_TextureMapping纹理坐标、采样、边界处理四边形表面的贴图效果2.3 快速编译VS 属性页里那四个配置项拿到源码先别急着点运行OpenGL 实验工程最常见的翻车就是依赖没配齐。以 03_Transform2D 为例先在 VS 里把包含目录和库目录指到libs/下附加包含目录 $(SolutionDir)libs\freeglut\include;$(SolutionDir)libs\glew\include;$(SolutionDir)libs\glm 附加库目录 $(SolutionDir)libs\freeglut\lib\x64;$(SolutionDir)libs\glew\lib\x64 预处理器定义 GLEW_STATIC;_CRT_SECURE_NO_WARNINGS 附加依赖项 opengl32.lib;freeglut.lib;glew32s.lib用$(SolutionDir)而不是写死路径是为了让你把整个MyProjects目录换个位置也能编译。_CRT_SECURE_NO_WARNINGS是让fopen这类函数不再报 C4996 警告做纹理加载实验时能少刷很多屏。如果你只跑 01、02 固定管线实验GLEW 不配也能运行但想一次把六个实验全部编译过建议每个工程统一加这条预处理器定义避免同一套代码在 Debug 和 Release 下行为不一致那是后续最常见的一个坑。3. 核心模块拆解一个三角形从顶点到像素的完整链路3.1 窗口创建与 GLUT 回调GLUT 死循环想退出来怎么办三角形能显示到屏幕上起点是窗口初始化和回调注册。这套代码在实验包里每个工程都可以单独抽出来看int main(int argc, char** argv) { glutInit(argc, argv); glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGB | GLUT_DEPTH); glutInitWindowSize(800, 600); glutCreateWindow(CG Lab); // 注意GLEW 必须在窗口创建之后才能初始化 glewExperimental GL_TRUE; glewInit(); glutReshapeFunc(reshape); glutDisplayFunc(display); glutKeyboardFunc(keyboard); setupScene(); // 创建 VAO、VBO、着色器 glutMainLoop(); return 0; // 这行实际执行不到 }GLUT_DOUBLE申请双缓冲避免画面闪烁GLUT_DEPTH给深度缓冲区腾地方三维实验里必须加否则深度测试等于没开。glutMainLoop一旦进入就阻塞在这里不断派发事件后面的return 0不会执行。想让程序退出正确做法是调用glutLeaveMainLoop()而不是硬exit。3.2 着色器编译为什么 glShaderSource 的第四个参数要传 nullptr三角形的 GPU 端渲染离不开着色器。下面是精简后的着色器编译封装注意看glShaderSource的参数GLuint CompileShader(GLenum type, const char* src) { GLuint shader glCreateShader(type); glShaderSource(shader, 1, src, nullptr); // 按\0截断省去手动数长度 glCompileShader(shader); GLint ok 0; glGetShaderiv(shader, GL_COMPILE_STATUS, ok); if (!ok) { char log[512] {0}; glGetShaderInfoLog(shader, 511, nullptr, log); printf(shader compile error:\n%s\n, log); glDeleteShader(shader); return 0; } return shader; }glShaderSource的第四个参数表示每个字符串的长度。传nullptr时驱动按 C 字符串的\0结尾自动截断日常写常量着色器源码最省事。但如果从文件里读代码用std::ifstream读取后字符串长度不等于文件长度把content.c_str()传进去其实也没问题真正容易踩的是用read把内容读进一个未补\0的char数组这时第四个参数必须显式传实际长度。编译后用glGetShaderiv查GL_COMPILE_STATUS不是查返回指针返回值只代表函数调用是否成功我看到太多人忽略状态直接链到程序里最后画面黑得毫无头绪。3.3 VAO/VBO把顶点属性和缓冲对象绑定在一起从数据到像素中间得有顶点缓冲。下面这段是实验里最常见的三角形初始化写法float vertices[] { // x y z r g b -0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f }; GLuint vao, vbo; glGenVertexArrays(1, vao); glGenBuffers(1, vbo); glBindVertexArray(vao); glBindBuffer(GL_ARRAY_BUFFER, vbo); glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); // 第 0 个顶点属性位置取前 3 个 float glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0); glEnableVertexAttribArray(0); // 第 1 个顶点属性颜色跨过前面 3 个 float glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3 * sizeof(float))); glEnableVertexAttribArray(1);这段代码的核心是步长和偏移量。每个顶点包含 6 个float位置在前 3 个颜色在后 3 个所以属性步长是6 * sizeof(float)。位置偏移是 0颜色偏移要从第 3 个float开始写成(void*)(3 * sizeof(float))。GL_STATIC_DRAW表示数据几乎不变驱动可以把它放到更合适的内存位置如果每帧都在改顶点坐标就改成GL_DYNAMIC_DRAW。3.4 绘制与 MVP 矩阵两个矩阵相乘的顺序别搞反顶点数据准备好后display回调里做的事情是从 CPU 侧把模型、观察、投影三个矩阵合起来传给着色器的 uniform再发起绘制void display() { glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); glUseProgram(shaderProgram); glm::mat4 proj glm::perspective(45.0f, (float)WIDTH / (float)HEIGHT, 0.1f, 100.0f); glm::mat4 view glm::lookAt( glm::vec3(0.0f, 0.0f, 5.0f), glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, 1.0f, 0.0f)); glm::mat4 model glm::rotate(glm::mat4(1.0f), rotAngle, glm::vec3(0.0f, 1.0f, 0.0f)); glm::mat4 mvp proj * view * model; glUniformMatrix4fv(uMVP, 1, GL_FALSE, mvp[0][0]); glBindVertexArray(vao); glDrawArrays(GL_TRIANGLES, 0, 3); glutSwapBuffers(); }OpenGL 使用列主序矩阵GLM 的mat4布局和 GLSL 一致传给 uniform 时不需要转置所以第三个参数传GL_FALSE。矩阵相乘的顺序是proj * view * model顶点点v实际经历的是proj * (view * (model * v))先模型变换到世界再观察变换到相机最后投影到裁剪空间。把顺序写成model * view * proj是旋转平移混乱的重灾区后面避坑章会专门讲。glDrawArrays的第一个参数决定图元类型GL_TRIANGLES每三个顶点组成一个三角形顶点数是 3所以第三个参数传 3。如果三角形画不出来或者位置不对先检查顶点数组里的坐标范围再查矩阵不要一上来就怀疑显卡驱动。4. 实验代码逐层分析从画线、变换到光照的算法落地4.1 DDA 与 Bresenham浮点递增和整数纠偏的两条路线前两个实验是光栅化的地基。DDA 思路直接把线段两端点的dx、dy拆成若干小步每步加一次增量void dda(int x0, int y0, int x1, int y1) { float dx x1 - x0; float dy y1 - y0; int steps max(abs(dx), abs(dy)); float xInc dx / steps; float yInc dy / steps; float x x0, y y0; for (int i 0; i steps; i) { setPixel(round(x), round(y)); x xInc; y yInc; } }步数取dx、dy绝对值较大的一方是为了保证步长不超过一个像素。DDA 的问题在于浮点运算和round循环次数多了会有积累误差。Bresenham 则完全抛弃浮点用误差项err判断下一帧该走哪个方向void bresenham(int x0, int y0, int x1, int y1) { int dx abs(x1 - x0), sx x0 x1 ? 1 : -1; int dy -abs(y1 - y0), sy y0 y1 ? 1 : -1; int err dx dy; while (true) { setPixel(x0, y0); if (x0 x1 y0 y1) break; int e2 2 * err; if (e2 dy) { err dy; x0 sx; } if (e2 dx) { err dx; y0 sy; } } }这里的sy写成负的绝对值是刻意让err可以同时判断 x 和 y 两个方向。两个算法在 01 号实验里都保留了切换绘制函数就能直观看到同一个端点对产生的点阵差异。实验报告里最值得写的不是复杂度而是四边形区域里哪个算法对边缘更平滑。4.2 中点画圆一个决策值如何取代平方根运算画圆如果直接用参数方程x cx r * cosθ每一帧都要算三角函数。中点画圆算法用决策值d的符号判断下一个像素选哪个完全绕开三角函数void midpointCircle(int cx, int cy, int r) { int x 0, y r; int d 1 - r; while (x y) { circlePoints(cx, cy, x, y); x; if (d 0) d 2 * x 1; else { y--; d 2 * (x - y) 1; } } }d的初始值是1 - r对应圆与网格线的首个中点位置每次 x 递增后如果判定中点落在圆内y 不变否则 y 减一。circlePoints利用圆的八对称性把(x, y)同时映射为另外 7 个点所以只需要扫描八分之一圆弧。动手改实验时可以把d的更新公式打印出来对比 y 的递减趋势比直接背公式印象深刻。4.3 复合变换的顺序先缩放还是先平移结果完全不同03 号实验的二维变换最容易出错的不是单个矩阵而是复合顺序。用 GLM 写变换看着是按代码顺序执行的glm::mat4 m(1.0f); m glm::translate(m, glm::vec3(tx, ty, 0)); m glm::rotate(m, glm::radians(angle), glm::vec3(0, 0, 1)); m glm::scale(m, glm::vec3(sx, sy, 1)); // 等价效果: v T * R * S * v因为顶点是列向量矩阵组合后实际先对顶点做缩放再做旋转最后平移。如果希望先平移到某个位置再缩放就要把代码里translate和scale的调用顺序反过来而不是想当然地认为“写在前面的先执行”。绕任意点旋转是另一个常见需求。正确的复合方式是“先平移到原点旋转再平移回去”glm::mat4 m glm::mat4(1.0f); m glm::translate(m, glm::vec3(px, py, 0)); m glm::rotate(m, glm::radians(angle), glm::vec3(0, 0, 1)); m glm::translate(m, glm::vec3(-px, -py, 0));这段代码的含义是T(p) * R * T(-p)对点来说先回了原点转完再回原位。实验报告里一般会要求画一个矩形绕自身中心旋转你在画矩形时如果顶点是相对中心点定义的那第一个translate(-px, -py)就可以省略这个细节值得写进说明。4.4 观察、投影与 Blinn-Phong 光照固定管线到着色器的一小步04 号实验里还有部分固定管线写法方便和着色器版本做对比glMatrixMode(GL_PROJECTION); glLoadIdentity(); gluPerspective(60.0, aspect, 0.1, 100.0); glMatrixMode(GL_MODELVIEW); glLoadIdentity(); gluLookAt(0, 0, 6, 0, 0, 0, 0, 1, 0);gluPerspective的第一个参数是垂直视场角单位是度不是弧度第二个参数aspect是窗口宽高比后两个参数分别是近裁剪面和远裁剪面到相机的距离。这两个距离的比值会影响深度缓冲的精度近远差得太大时远处物体会出现深度冲突表现为三角形表面闪烁。gluLookAt的三组参数分别是眼睛位置、观察目标点、视口向上的方向向量。到 05 号光照实验就进入可编程管线Blinn-Phong 是课程里最通用的模型它的片元着色器核心计算如下vec3 N normalize(normal); vec3 L normalize(lightPos - fragPos); vec3 V normalize(viewPos - fragPos); vec3 H normalize(L V); float diff max(dot(N, L), 0.0); float spec pow(max(dot(N, H), 0.0), shininess); vec3 color ambient diff * lightColor spec * specColor;H是半程向量把反射方向的计算换成了L V的归一化性能更好且效果接近。写这个着色器时最常见的低级错误是忘记normalize导致dot结果偏大或偏小高光区域突然闪烁另一个是把法线放在顶点着色器里变换完直接插值没有在片元里重新归一化模型转动时高光会跟着飘。这些现象在 05 号实验的窗口里都能复现是调光照的必经之路。5. 常见问题黑屏、白板和乱码的排查路径图形学实验排查不能靠瞪先确认“窗口有没有创建出来、OpenGL 有没有初始化、数据有没有进管线、着色器有没有链接成功”这四层再动手改代码。下面这五条是这套实验包里出现频率最高的翻车现场。5.1 窗口能开画面黑屏glewInit 放错了时机现象窗口正常弹出但三角形不显示glGetError()偶尔返回GL_INVALID_ENUM。原因glewInit()在有合法 GL 上下文前调用扩展函数指针加载失败后续所有glGen*调用都拿到无效对象。解决把glewInit放到glutCreateWindow之后并检查返回值glutCreateWindow(CG Lab); glewExperimental GL_TRUE; GLenum err glewInit(); if (err ! GLEW_OK) { printf(glew init failed: %s\n, glewGetErrorString(err)); return; }glewExperimental置GL_TRUE是为了让它加载一些驱动没有主动暴露的实验性扩展。如果不想被黑屏折磨这段代码应该作为所有着色器工程的固定前缀。5.2 纹理贴上去是灰色中文路径和纹理翻转各占一半现象06 号实验的纹理加载函数返回成功但屏幕上模型是一整块灰白色或者纹理上下颠倒。原因图像读取库在 Windows 下遇到中文目录或中文文件名时路径解析失败另一类问题是图片的行对齐规则和 OpenGL 默认的GL_UNPACK_ALIGNMENT4不一致宽高不是 4 的倍数时读出来的像素就是错位的。解决把工程和资源放到纯英文路径下这个“玄学”问题通常立刻消失如果只是上下颠倒用下面这行调整像素上传对齐方式glPixelStorei(GL_UNPACK_ALIGNMENT, 1);GL_UNPACK_ALIGNMENT默认是 4表示每行像素在内存里按 4 字节对齐。BMP 这类无压缩格式每行往往正好 4 字节对齐但遇到奇数宽度就会错位。把它设成 1 是按单字节紧凑排列兼容性最好代价是上传速度略降课程实验里完全够用。5.3 Debug 跑得好好的Release 链接报几十个未解析符号现象Debug 编译正常切到 Release 后链接器报一堆__imp_glGenVertexArrays或__imp_wglChoosePixelFormat开头的未解析外部符号。原因只改了 Debug 配置的依赖路径Release 配置里 GLEW 和 freeglut 的库路径还是空的或者是链接了glew32.lib却忘记定义GLEW_STATIC导致静态库和动态库的符号约定对不上。解决打开 VS 属性页左上角配置选“所有配置”平台选“所有平台”把第二章里那四项依赖统一填一遍。还有一个容易忽略的点freeglut.dll和glew32.dll这些动态库必须和生成的 exe 在同一目录否则双击运行时直接弹“找不到 dll”这种现象更像是工程配置坏了但其实只是文件没复制到位。5.4 物体旋转后总是平漂一段复合矩阵顺序反了现象用键盘旋转物体时它不绕自身中心转而是边转边朝某个方向平移旋转中心明显不对。原因模型矩阵里平移和旋转的顺序写反了。比如写成model rotate * translate顶点先被平移、再旋转等于绕着世界原点旋转一个已经偏移的物体看起来就像额外平移了一段。解决先确认你的顶点坐标是不是相对物体中心定义的。如果不是要先把物体移回原点、旋转、再移回去glm::mat4 model glm::translate(glm::mat4(1.0f), glm::vec3(centerX, centerY, 0)) * glm::rotate(glm::mat4(1.0f), angle, glm::vec3(0, 0, 1)) * glm::translate(glm::mat4(1.0f), glm::vec3(-centerX, -centerY, 0));这个式子从右往左读先移到原点旋转再平移回原位置。矩阵乘法是图形学实验里调试时间最长的地方遇到位置不对先把旋转角度固定成 0观察平移是否单独正常再逐步叠加旋转。5.5 着色器编译通过一调用就黑屏或崩溃忘了检查 Link 状态现象CompileShader返回的 shader 对象不是 0但链接完程序后画面还是黑的控制台也没有任何报错。原因glLinkProgram之后没有检查GL_LINK_STATUS很多着色器编译通过但变量名拼写错误链接时 uniform 被优化掉glGetUniformLocation返回 -1后续glUniformMatrix4fv把一个非法地址传进去。解决链接完成后强制加一段日志检查同时打印 uniform 地址glLinkProgram(prog); GLint linked 0; glGetProgramiv(prog, GL_LINK_STATUS, linked); if (!linked) { char log[512]; glGetProgramInfoLog(prog, 511, nullptr, log); printf(link error:\n%s\n, log); } GLint u glGetUniformLocation(prog, mvp); if (u 0) printf(uniform mvp not found\n);不要靠肉眼对比着色器字符串里的名字直接让程序把 uniform 地址打出来。从这套实验包开始我给自己定了一条规则任何 gl 相关的返回值都要检查错误状态再小的工程也一样这条习惯能省掉一半的调试时间。6. 二次开发把分散实验改造成一个可交互的图形学 Demo六个实验各自独立运行没问题但它们之间没有统一入口。二次开发的第一步是把不同实验的绘制逻辑抽象成同一种接口这样后续想加一个“实验切换菜单”就很容易。class IRenderable { public: virtual void Initialize() 0; virtual void Render(float dt) 0; virtual void OnKey(unsigned char key, int x, int y) 0; virtual ~IRenderable() {} };每个实验工程只要继承这个接口把原来的init、display、keyboard搬进三个虚函数即可。第二步是做一个简单的场景管理器用数字键 1 到 6 切换不同的实验class SceneManager { public: static SceneManager Instance() { static SceneManager inst; return inst; } void Register(int id, std::unique_ptrIRenderable scene) { scenes_[id] std::move(scene); } void SwitchTo(int id) { current_ scenes_[id].get(); } IRenderable* Current() { return current_; } private: std::mapint, std::unique_ptrIRenderable scenes_; IRenderable* current_ nullptr; };注册顺序放在main里和工程目录编号保持一致。这样做的好处是每个实验原来的main.cpp不再拥有独立的glutMainLoop而是统一交给框架调度主循环只保留一次glutDisplayFunc(display)内部转发给当前场景。改造完成后验证方式很简单依次按 1 到 6确认每个场景都能渲染且不会串状态再切回场景 1看看之前建的 VAO 是否还在。如果还想更进一步就把固定管线的 01 到 03 号实验迁移到着色器版本用glDrawArrays(GL_POINTS, ...)替代glBegin(GL_POINTS)。这个迁移过程能帮你真正搞清楚固定管线的矩阵栈和现代管线的 uniform 之间是什么关系而不是只会复制粘贴。我当年把实验合并成 Demo 后以为在一台机器上跑通就万事大吉结果换到另一台机器上演示时DLL 没复制、纹理路径写的是绝对路径现场直接翻车。从那以后我每次改完都强制走一遍流程清空输出目录、Debug 和 Release 分别编译、把 DLL 和资源改成相对路径、再换一个目录重新跑一次。这套血泪流程虽然看着笨但保住了很多次演示。希望帮到你。本文还有配套的精品资源点击获取