简介:吉林大学计算机图形学实验源代码包,面向图形学课程、MFC 程序设计及软件工程课程设计学习者,覆盖矩形、圆形绘制,颜色对话框设置 RGB,以及通过鼠标输入顶点并采用边标志算法填充多边形等核心实验;要求用学号后四位填充多边形内部,是一份强调交互与算法落地的课程设计参考。压缩包共 30 个文件,以 9 个头文件与 8 个 cpp 源文件为主体,另有 rc/rc2/ico/bmp 等界面资源文件、dsw/dsp 工程描述文件和 ReadMe 说明,整体仅 65KB,结构简洁。资源已吸引 1461 人浏览学习。通过学习这份代码,可掌握 MFC 绘图消息处理、图形学基础类封装、颜色选择与多边形填充等关键实现;工程按文档/视图结构组织,可从菜单交互、鼠标取点、对话框输入一直追踪到光栅化填充算法,尤其边标志算法示例对理解扫描转换具有直接帮助。
1. 为什么我要把吉林大学计算机图形学实验代码全部开源
说句实话,最早决定把这份代码整理出来,动机非常简单:期末复习的时候,我自己翻遍各种平台,找不到一份结构清晰、注释完整、能直接跑通的图形学实验代码。很多资料要么是十几年前的旧接口写法,要么只贴了核心函数没有完整工程,拿回来根本编译不过。反复踩了几次坑以后,我萌生了一个念头——干脆把自己在吉林大学计算机图形学课程里写的全部实验源代码整理好放出来,按实验编号分好目录,每条关键逻辑都写上注释,让后来的人少走点弯路。
这份资源适合的人很明确:正在修计算机图形学课程、需要应付实验报告和验收的同学;准备考研复试、想快速回顾图形学基础算法实现的人;以及纯粹对图形渲染底层感兴趣、想看看OpenGL固定管线和着色器写法差异的入门开发者。它不是一个图形学教程,而是一套“照着跑就能过、跑完再读注释就能懂”的完整实验代码集合。
我的实验环境是在Windows 10下用Visual Studio 2019开发,OpenGL通过freeglut库实现窗口管理和基本交互,数学运算部分用了GLM库辅助矩阵计算。整套代码从OpenGL 2.1的固定功能管线写到可编程着色器,覆盖了课程要求的核心知识点:直线生成算法、多边形填充、几何变换、三维观察、光照模型、纹理映射、曲线曲面,以及一个综合性的期末大作业。下面我会按照实验顺序,把每份代码的设计思路、关键实现和我在测试过程中踩过的坑逐一拆开讲清楚。
2. 实验环境的选型逻辑:为什么坚持VS2019+freeglut+GLM
2.1 环境搭建的版本选择与理由
很多同学一上来就纠结用哪个OpenGL版本、哪套库,其实对于课程实验来说,核心标准只有一个:稳定,够用,能快速出结果。我在选型时对比了几种方案,最终确定VS2019 + freeglut 3.0.0 + GLM 0.9.9.8的组合,理由很实际。
第一条理由是课程本身的教学内容以经典扫描转换算法和固定管线为主,传统的OpenGL立即模式(glBegin/glEnd)虽然老旧,但和课本上的伪代码对应关系最强。你在《计算机图形学》教材里看到DDA算法步骤,马上就能在代码里找到对应的语句。如果一上来就用现代OpenGL的可编程管线,单是VAO/VBO的初始化就要几十行模板代码,反而把算法核心给淹没了。
第二条理由是freeglut足够轻量,不需要额外编译依赖,下载预编译的二进制包就能直接用。GLM是纯头文件库,复制到include目录即可。整个配置过程不超过十五分钟,比配置其他框架省事得多。
2.2 配置过程里最容易出问题的三个环节
虽然环境配置整体简单,但有几个细节处理不好,后面编译会报一堆莫名其妙的错。
第一个坑是glut.h和gl.h的包含顺序。按Windows平台的惯例,必须写成:
#include <GL/freeglut.h>不要自己分别包含gl.h和glu.h。freeglut的include目录里已经有完整的依赖管理,但如果你在代码里同时写了:
#include <GL/glut.h>而又把freeglut的头文件放在include路径前面,编译器很可能优先找到老的glut头文件,导致函数签名不一致。我的做法是只保留freeglut目录,并且确保项目属性里的“附加包含目录”只指向freeglut的include文件夹。
第二个坑是链接库的选择。freeglut在x64平台下有freeglut.lib和freeglutd.lib两个版本,前者是Release版,后者是Debug版。如果链接器设置里固定填了freeglut.lib,在Debug模式下运行可能会出现内存分配检查报错。我的做法是在“附加依赖项”里同时写上:
freeglut.lib freeglutd.lib opengl32.lib glu32.libDebug模式下编译器会自动匹配带d后缀的库。opengl32.lib和glu32.lib是系统自带的,务必保证在链接列表里,缺了它们会出现无法解析的外部符号。
第三个坑是GLM版本。早期0.9.8版本的头文件组织方式和后来的版本不同,有些教程写的#include <glm/gtc/matrix_transform.hpp>在个别版本里路径对不上。我固定使用0.9.9.8,这个版本的接口稳定,网上的学习资料也多。如果你的项目用了更高版本,建议留意glm::perspective函数第三个和第四个参数排序,新旧版本在这个地方是一致的,但有个别教程为了视觉效果故意把参数写反,复制代码时容易中招。
3. 实验一:直线生成算法——DDA、中点画线、Bresenham
3.1 三种算法的代码组织方式
直线生成是图形学课程的入门实验,也是最容易写出“跑得通但不知道对错”的代码。我的实现思路是把三种算法封装在一个类里,每个算法对应一个独立函数,然后在display回调函数中根据键盘事件切换显示模式。
DDA算法的核心就是利用直线斜率把增量分解到x方向或y方向:
void LineDDA(int x0, int y0, int x1, int y1) { int dx = x1 - x0; int dy = y1 - y0; int steps = abs(dx) > abs(dy) ? abs(dx) : abs(dy); float xIncrement = dx / (float)steps; float yIncrement = dy / (float)steps; float x = x0; float y = y0; glBegin(GL_POINTS); for (int i = 0; i <= steps; i++) { glVertex2i(round(x), round(y)); x += xIncrement; y += yIncrement; } glEnd(); }这段代码的优点是逻辑一目了然,缺点也很明显:每一步都用浮点数加法和取整运算,当线段长、点数多时性能消耗比纯整数算法大。但课程实验通常只画几百个点,性能差异看不出来,所以DDA用来理解思想是最合适的。
中点画线法和Bresenham算法的代码核心都在于决策参数的更新,而不是实实在在去求直线方程。中点画线的思路是判断中点位置与直线的相对关系,从而决定下一个点往哪个方向走。我在这里把关键代码写成:
void MidPointLine(int x0, int y0, int x1, int y1) { int a = y0 - y1; int b = x1 - x0; int d = 2 * a + b; int delta1 = 2 * a; int delta2 = 2 * (a + b); int x = x0, y = y0; glBegin(GL_POINTS); glVertex2i(x, y); while (x < x1) { if (d < 0) { x++; y++; d += delta2; } else { x++; d += delta1; } glVertex2i(x, y); } glEnd(); }Bresenham的改进在于完全消除了浮点数,通过维护误差项来近似直线。需要特别注意的是,上述代码默认处理的是斜率在0到1之间的情况,如果直线斜率大于1或者方向是负的,参数更新公式就要相应调整。很多同学的代码在画正斜率时一切正常,一换方向就画成锯齿状,问题就出在这里。
3.2 绘图坐标系的坑与验证方法
OpenGL的默认窗口坐标系原点在左下角,而实验指导书里用到的窗口坐标通常是像素坐标,原点在左上角。如果你的实验要求用鼠标点击取点来画线,就必须在鼠标回调函数里做坐标转换。
我的做法是:
void mouse(int button, int state, int x, int y) { if (button == GLUT_LEFT_BUTTON && state == GLUT_DOWN) { int convertedY = height - y; points.push_back({x, convertedY}); } }别小看这一步,我见过有同学的画线实验结果整体上下颠倒,查了半小时发现是鼠标坐标没翻转。还有验证的问题:你可以把同一条直线的端点分别用三种算法画一遍,然后把三个结果叠在一起显示,用肉眼检查像素点是否完全重合。如果某一种算法在某些斜率下和其他两种不太一样,多半是决策参数更新写错了。
4. 实验二:多边形填充与裁剪——扫描线算法和Cohen-Sutherland
4.1 多边形扫描线填充的实现要点
多边形填充实验是很多人第一次接触“数据结构驱动算法”的地方。扫描线算法需要维护边表(ET)和活动边表(AET),处理边与扫描线的交点,再按交点配对其填充区间。整个流程听起来简单,真正写起来很容易出错的地方是边表构建和交点排序。
我的实现把边表设计成以扫描线y值为索引的桶结构,桶里存放一条边的所有信息:当前x坐标、dy增量、x增量以及ymax。构建过程大致是:
- 遍历多边形所有边,忽略水平边(因为水平边本身就落在扫描线上,填充区间不受它影响)。
- 对非水平边,计算y_min对应的x值,把边挂到y_min那条扫描线的桶中。
- 边表里每条边记录的信息包括x_min、y_max、x增量等。
在扫描线逐行移动时,先从当前y对应的边表桶中把新边加入AET,然后按x值对AET排序,配对交点,最后删除ymax小于等于当前y的边。最关键的一步是交点配对逻辑——要按照x坐标递增的顺序两两配对,组成填充区间。
4.2 Cohen-Sutherland裁剪的编码实现
线段裁剪算法的核心是区域编码。我用四位二进制码表示端点的位置:上、下、左、右。任何一条线段,只要两个端点的编码按位与结果不为零,说明线段完全在窗口外,可以直接剔除;如果两个端点编码都为0000,说明完全在窗口内,直接保留;否则需要求交点分段处理。
这个算法逻辑本身不复杂,但我的体会是它特别适合锻炼位运算能力。我实现的完整代码如下:
// 编码 // bit 3: top, bit 2: bottom, bit 1: right, bit 0: left unsigned char encode(float x, float y) { unsigned char code = 0; if (x < xmin) code |= 1; if (x > xmax) code |= 2; if (y < ymin) code |= 4; if (y > ymax) code |= 8; return code; }然后通过while循环反复判断端点的编码状态,每次裁剪掉窗口外的一部分,直到端点编码满足完全可见或完全不可见。需要注意交点的计算要分两种情况:若线段与窗口左边界相交,则交点坐标为x = xmin,通过直线方程算出y的值;若与上边界相交,则设置y = ymax,反解出x。为了不重复代码,我定义了四个边界判断函数,每个返回交点坐标,裁剪循环不断调用即可。
这类裁剪算法验证的重点是边界情况:线段刚好穿过窗口顶点、线段与窗口边界重叠、线段完全在窗口内部。我的测试用例里专门构造了这些场景,发现很多同学的算法在处理“刚好穿过顶点”时容易漏判或多算一个交点,原因多半是在求交时用了浮点数相等判断。解决方法是判断端点与边界距离时,设置一个极小的阈值,比如fabs(x - xmin) < 1e-6就认为在边界上。
5. 实验三:几何变换与三维观察——平移、旋转、缩放、透视投影
5.1 从数学矩阵到OpenGL代码的映射
几何变换实验的核心内容是矩阵乘法顺序。课本上反复强调先缩放、再旋转、最后平移的顺序,但很多人写代码时直接调glTranslatef、glRotatef、glScalef,以为顺序无所谓。这里我踩过最大的坑就是变换顺序反了。
OpenGL的变换矩阵是列主序存储,作用于顶点时实际上是把顶点坐标右乘矩阵。因此代码里先写的变换会先生效。举个例子:
glTranslatef(tx, ty, 0); glRotatef(angle, 0, 0, 1); glScalef(sx, sy, 1);这表示先缩放,再旋转,最后平移。如果你想要的效果是先指定位移再围绕某个中心旋转,则必须自己先构造复合变换矩阵,或者撤销平移操作。我在实验代码里提供了一个函数,通过键盘方向键实时修改平移量和旋转角度,方便观察矩阵顺序对最终渲染结果的影响:
void display() { glClear(GL_COLOR_BUFFER_BIT); glMatrixMode(GL_MODELVIEW); glLoadIdentity(); glTranslatef(tx, ty, 0); glRotatef(angle, 0, 0, 1); glScalef(sx, sy, 1); drawTriangle(); // 绘制一个三角形 glutSwapBuffers(); }我强烈建议你在做完基本平移旋转练习后,把这段代码改成矩阵线性代数版本,也就是自己在CPU端计算变换矩阵,然后通过glMultMatrixf传入OpenGL,这样更能理解顶点变换的过程。我在工程里也额外写了一个对照版本,用GLM构造模型矩阵:
glm::mat4 model = glm::mat4(1.0f); model = glm::translate(model, glm::vec3(tx, ty, 0.0f)); model = glm::rotate(model, glm::radians(angle), glm::vec3(0.0f, 0.0f, 1.0f)); model = glm::scale(model, glm::vec3(sx, sy, 1.0f)); glLoadMatrixf(glm::value_ptr(model));5.2 透视投影与正交投影的对照实验
三维观察部分的实验要求理解从世界坐标系到屏幕坐标系的完整流程。我在代码里实现了透视投影和正交投影的切换,通过键盘按键F切换模式。
对透视投影来说,glFrustum或gluPerspective的参数很容易搞混。我使用gluPerspective(fov, aspect, zNear, zFar),注意fov是垂直视场角,aspect是窗口宽高比,zNear和zFar分别是近裁剪面和远裁剪面。我特意把近裁剪面设为0.1,远裁剪面设为100,如果近裁剪面太小,深度精度会变差;太大会切掉近处的物体。这套参数是很多图形学课程实验的默认值,但如果你的场景深度范围特别大,需要相应调整。
正交投影的对照可以直观看到透视投影带来的近大远小效果。我写了一个简单的立方体场景,用键盘方向键控制相机绕物体旋转,这样可以看到两种投影模式下立方体边线的视觉差异。这个实验能帮助你理解OpenGL的视锥体概念,对后面学习深度缓冲也有很大帮助。
6. 实验四:光照模型与纹理映射——从固定管线到着色器
6.1 固定管线的Phone光照模型实现
光照实验是理解渲染原理的重要分水岭。在OpenGL 2.1固定管线里,光照计算是内置的,你只需要设置光源属性、材质属性,启用GL_LIGHTING即可。但这样做容易让人陷入“调参”陷阱,没有真正理解Phone光照模型的三个分量:环境光、漫反射、镜面反射。
我的实现里手动写了一套Phone光照计算逻辑,虽然不会用于最终渲染,但能通过调试输出看到每个像素的光照分量。关键步骤是:
- 计算表面法向量。
- 计算入射光方向(从顶点指向光源的向量)。
- 计算视角方向(从顶点指向相机位置)。
- 环境光分量直接使用光源环境项与材质环境项的乘积。
- 漫反射分量用
max(dot(N, L), 0)乘以光的漫反射强度和材质漫反射系数。 - 镜面反射分量使用
pow(max(dot(R, V), 0), shininess),其中R是反射光方向。
在固定管线里,法向量的设置非常重要。如果是球体,法向量可以直接用顶点坐标减去球心并归一化。如果是任意网格模型,则需要通过相邻三角形的面法向量平均得到顶点法向量。我在实验代码里提供了一个茶壶模型,可以直接调用glutSolidTeapot生成,省去了自己建模的麻烦。
6.2 纹理映射的坐标与采样参数陷阱
纹理映射实验我实现了一个立方体六个面贴上棋盘格纹理,通过按T键切换是否启用纹理,观察映射效果。这里有两个非常隐蔽的坑。
第一个坑是纹理坐标方向。OpenGL约定纹理坐标系原点在左下角,但很多图片格式加载到内存后,第一行像素是图片最上面一行。如果直接用图片的第一行作为纹理坐标t = 0对应的像素,最终贴图会上下颠倒。解决办法是在加载图片时做y轴翻转,或者在构建纹理坐标时把t取反。
第二个坑是纹理环绕方式。默认情况下,纹理坐标超出[0,1]范围时,OpenGL会将纹理重复贴到表面上,但这取决于GL_TEXTURE_WRAP_S和GL_TEXTURE_WRAP_T参数。如果设置为GL_CLAMP_TO_EDGE,在边界处会拉伸边缘像素,产生模糊。我的立方体每个面都使用完整纹理,坐标刚好在0到1之间,但由于立方体顶点在所有面上共享,纹理坐标的设置需要很小心。我采取了每个面单独指定顶点数据的方式,避免共享顶点导致纹理坐标冲突。
从固定管线到着色器的进阶思路是,我额外写了一个简单的GLSL版本,用顶点着色器和片元着色器实现棋盘格着色。它的核心shader代码片段为:
// 片元着色器片段 void main() { vec3 color; if (mod(floor(vPosition.x * 10.0) + floor(vPosition.y * 10.0), 2.0) < 1.0) { color = vec3(1.0); } else { color = vec3(0.0); } gl_FragColor = vec4(color, 1.0); }把光照和纹理相结合是后续大作业的基础,理解固定管线的状态机方式,再对比着色器的灵活编程方式,你会发现两者的思维模型完全不同。
7. 实验五:曲线曲面——Bezier曲线与B样条曲线绘制
7.1 Bezier曲线的de Casteljau递推实现
曲线曲面实验可以说是图形学“计算味”最强的一部分,也是很多人第一次知道“拉格朗日插值”在图形学里的实际应用。Bezier曲线用控制点来定义形状,曲线本身不经过中间控制点,而是受它们“牵引”。
我实现了de Casteljau递推算法,这种算法的好处是计算过程简单直观,而且可以递推求曲线上的点。核心逻辑是:对每一层线性插值,得到一组新的控制点,反复操作直到只剩一个点,就是曲线上参数t对应的位置。代码实现如下:
glm::vec2 bezierPoint(const std::vector<glm::vec2>& controlPoints, float t) { std::vector<glm::vec2> points = controlPoints; int n = points.size(); for (int r = 1; r < n; r++) { for (int i = 0; i < n - r; i++) { points[i] = (1 - t) * points[i] + t * points[i + 1]; } } return points[0]; }绘制时,只需要把t以固定步长从0循环到1,把得到的点用glVertex2f连接成线段即可。步长我取0.001,这样曲线比较光滑,同时性能也能接受。如果你想看到动态绘制过程,可以把步长加大一点,每秒绘制几个t对应的点,效果更像“动画”。
7.2 B样条与Bezier的比较实验
B样条曲线相比Bezier曲线具有局部支撑性:移动一个控制点,只影响附近一段曲线,而不会改变整条曲线形状。这是它在工业设计中被广泛使用的重要原因。我在实验里实现了三次均匀B样条曲线的绘制,核心是计算基函数,然后按不同控制点区间生成的曲线段拼接。
B样条的基函数递归定义理解起来有一定难度,但实现上其实可以用矩阵形式简化。三次B样条的矩阵表示如下:
// P(t) = (1/6) * [t^3 t^2 t 1] * B * [P0 P1 P2 P3]^T // B是三次均匀B样条基矩阵其中基矩阵B为固定常数,这个矩阵可以预先算好,避免每帧递归计算基函数。我在代码里直接展开计算:
float b0 = (1 - t) * (1 - t) * (1 - t) / 6.0f; float b1 = (3 * t * t * t - 6 * t * t + 4) / 6.0f; float b2 = (-3 * t * t * t + 3 * t * t + 3 * t + 1) / 6.0f; float b3 = t * t * t / 6.0f; glm::vec2 point = b0 * P0 + b1 * P1 + b2 * P2 + b3 * P3;为了直观对比,我在一个窗口里并排绘制一条三次Bezier曲线和一条三次B样条曲线,控制点完全相同。可以看到Bezier曲线穿过第一个和最后一个控制点,而B样条曲线通常不经过任何一个控制点,整体更平滑。这是我个人非常喜欢的一个对照实验,能帮助你把课本上“逼近”和“插值”的概念落实到视觉层面。
8. 期末大作业:一个可交互的三维场景搭建
8.1 大作业的整体架构与功能划分
期末大作业我选择做一个可自由漫游的室内场景,包含地面、墙壁、家具和光照系统。这个任务把之前所有实验的知识点串联起来:几何变换用来摆放不同物体,光照模型让物体产生立体感,纹理映射给地面和墙壁贴上材质,三维观察实现相机漫游,键盘和鼠标交互用到了前面学的事件处理机制。
工程文件按功能划分了几个模块:
main.cpp:窗口初始化和主循环。Camera.h/cpp:相机类,封装了位置、朝向、俯仰角和偏航角,实现前后左右移动和视角旋转。Object.h/cpp:简单物体类,用参数化方程生成立方体、球体、圆柱体等几何体。Texture.h/cpp:纹理加载和管理类。Shader.h/cpp:如果使用着色器版本,这里负责编译和链接shader程序。
相机类的核心是view矩阵的更新。我采用第一人称视角,俯仰角和偏航角控制摄像机观察方向,位置用方向键和鼠标控制。每帧根据角度计算forward向量、right向量和up向量,然后调用gluLookAt:
glm::vec3 forward; forward.x = cos(pitch) * cos(yaw); forward.y = sin(pitch); forward.z = cos(pitch) * sin(yaw); forward = glm::normalize(forward); glm::vec3 right = glm::normalize(glm::cross(forward, glm::vec3(0.0f, 1.0f, 0.0f))); glm::vec3 up = glm::cross(right, forward); glm::mat4 view = glm::lookAt(cameraPos, cameraPos + forward, up);8.2 大作业里隐藏的难点与优化经验
大作业最容易出问题的地方是帧率和交互手感。如果你在display回调里每次都重新生成几何体顶点数据并调用glVertex3f,当场景物体数量增多时帧率会明显下降。优化的思路是把静态物体的顶点数据放到显示列表里,OpenGL会把它编译到服务器端,重复绘制时开销大大降低。代码大致是:
GLuint roomList = glGenLists(1); glNewList(roomList, GL_COMPILE); drawRoom(); glEndList();然后在display函数里直接调用glCallList(roomList),性能提升非常明显。
另一个容易忽视的问题是深度缓冲。启用深度测试之后记得在窗口初始化时设置深度缓冲位数,我一般用:
glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGBA | GLUT_DEPTH); glEnable(GL_DEPTH_TEST);否则会出现物体前后遮挡关系混乱的情况。还有一个小技巧是键盘交互时每按一次键就调用glutPostRedisplay,保证画面及时刷新,避免画面卡死的感觉。
期末验收时老师经常会问的一个问题是:光照计算是在哪个坐标系下完成的?如果你的实现是固定管线,那么回答“在视觉空间(eye space)中完成”是对的。如果用了GLSL着色器,你要清楚法向量矩阵的变换。我在代码里专门写了一段注释,说明使用gl_NormalMatrix转换法向量的原因,防止物体经过非等比缩放后法向量不垂直表面,导致光照结果错误。
9. 每一份代码都该有的数据结构设计思路
很多人写图形学实验习惯把所有逻辑堆在main.cpp里,一个文件几百行甚至上千行,后期调试非常痛苦。我在这套代码里坚持了三个设计原则:
第一,每个实验对应独立命名空间或类,例如namespace exp1_line、namespace exp2_polygon。这样不同实验之间互不干扰,每个命名空间内只暴露一个init()和display()接口,主函数通过宏切换编译目标。
第二,算法核心尽量与OpenGL绘制分离。直线生成算法只负责计算像素点坐标,放入一个std::vector<std::pair<int, int>>,再由单独的绘制函数统一渲染。这样做的好处是方便单元测试,也便于用文本方式输出点坐标验证算法正确性。
第三,常量全部用constexpr或#define统一管理。窗口宽度、高度、网格大小、步长等参数集中放在一个GlobalConfig.h头文件里。调参时只改一处,不用满文件查找。
这种代码组织方式不仅对图形学实验有效,对任何图形相关的课程设计都值得借鉴。老师在验收时随便点一个函数问“这个变量为什么这么命名”“这个参数为什么是0.001”,你都能答得上来,因为代码结构本身就体现了你的思考过程。
10. 测试与调试的方法论:怎么确认你的图形学代码是对的
图形学代码的视觉效果具有很强的主观性,很容易出现“看起来差不多”但实际算法不严谨的情况。我在完成每个实验后,都会用几组标准数据做验证。
对直线算法,我用起点(0,0)终点(10,4)、起点(0,0)终点(4,10)、起点(5,5)终点(-2,8)等多组数据,在画布上输出像素点坐标,对比三种算法的结果差异。如果哪种算法在某些斜率下与其他不一致,基本可以定位问题出在决策参数的更新条件。为了直观,我还会在代码里用红色画DDA点、绿色画Bresenham点、蓝色画中点画线点,重叠部分会变色,一眼看出差异。
对多边形填充,我的验证方法是填充一个已知面积的矩形和梯形,用屏幕像素计数算填充面积。比如画一个宽100高50的矩形,如果填充正确,统计到的像素点数应该接近5000。如果在边缘区域出现多于或少于理论值的点,说明扫描线算法的交点配对可能漏了边。这个方法听起来粗糙,但足够验证大多数基础算法的正确性。
对光照和纹理,最好的验证方式是调整光源位置和观察角度,观察高光和阴影变化的规律。如果材质反光过强,把shininess调低;如果纹理出现拉伸变形,优先检查纹理坐标是否均匀。建议不要一次性调整多个参数,否则出了问题很难定位是光源问题还是材质问题。
调试工具方面,我只用了最简单的printf输出关键状态量:直线的每个像素点坐标、填充交点的排序结果、每帧相机的position和forward向量。这些输出放在独立的log文件里,比在命令行窗口直接打印更清晰,也不会影响渲染窗口的焦点。
11. 对后来者的一点实操建议
整套代码从最初的开头到最终版本,前后改了三四轮,每一轮都是被实际运行中的问题逼出来的。第一轮能跑通所有基础算法,但代码混乱到我自己都不想看;第二轮把数据结构抽出来,才慢慢有了“工程感”;第三轮加注释加测试,才敢放出来分享。
如果你现在也在做计算机图形学实验,我的第一条建议是:不要直接复制任何人的代码到实验报告里交差,包括我这套。图形学实验的验收重点不是“能跑”,而是“你懂不懂”。建议拿到代码后,先删掉所有函数体,只看函数名和注释,尝试自己把核心算法补全,实在写不出来再参考。这个过程看起来费时间,但比考前突击复习效率高得多。
第二条建议是:这门课的核心不是OpenGL的API,而是背后的数学模型。代码写不出来,很多时候不是你不会OpenGL,而是线性代数里矩阵变换、向量点乘叉乘的几何意义没吃透。我在实验过程中重新翻了一遍线性代数教材,把“矩阵乘法为什么从右往左作用”彻底搞懂,之后写几何变换实验顺畅了很多。
第三条建议是:保留你调试过程中修改过的每一个错误版本。我在实验报告里附上了几个典型bug的修正记录,比如直线算法负斜率处理错误、纹理图像倒置错误、透视投影参数错位问题。这些错误分析和解决办法不仅让报告显得充实,也为期末复习提供了具体案例。
如果你愿意把这套代码作为起点,也可以往几个方向继续扩展:把固定管线的光照代码改写成现代OpenGL着色器版本,加入阴影映射功能;在B样条实验基础上实现NURBS曲线;把鼠标拾取功能加到三维场景里,实现简单的物体选择交互。这些都是很好的课程设计题目,比单纯“跑通实验”更有挑战性。
最后说一句这片代码里最值得你保存的,其实不是任何一段算法实现,而是我写在头文件最上方的那行总结——“所有代码先写思路,再写实现,最后调参。”按照这个顺序来,你的图形学实验不会差到哪里去。
本文还有配套的精品资源,点击获取