计算机图形学实验课:渲染管线、光栅化与光照纹理调试

发布时间:2026/10/25 9:29:31
计算机图形学实验课:渲染管线、光栅化与光照纹理调试

应用技术大学的计算机图形学实验课,大概是整个课程体系里最"看着简单、做着上头"的一门。屏幕上一根线没画直、一个三角形颜色不对、纹理贴上去糊成一团,你盯着代码看半小时也找不出问题——因为图形学的错误往往不在语法层,而在数学层和管线层。我带过几届实验课,也自己从固定管线一路写到着色器,最深的体会是:图形学实验的价值不在于"把图渲染出来",而在于你能不能说清楚每一个像素是怎么被算出来的。这篇内容面向正在做或者准备做计算机图形学实验的同学,也适合已经能跑通Demo但讲不清原理的开发者。我会按实验课的真实推进顺序,把光栅化、几何变换、光照着色、曲线曲面、纹理映射这几块拆开讲,每一块都给出可复现的代码骨架、参数推导过程,以及我自己踩过的坑。

1. 实验课的第一道坎:为什么图形学代码"跑得动"却"讲不清"

图形学实验有个很典型的现象:照着实验指导书敲完,窗口弹出来了,三角形也画出来了,但老师一问"这个顶点最后落在屏幕的哪个像素上",人就卡住了。这不是学习态度问题,而是图形学的知识结构和普通编程课完全不同——普通编程课是"输入→处理→输出"的线性思维,图形学是"建模坐标→变换到裁剪空间→光栅化成片元→着色→写帧缓冲"的多级流水线思维,中间任何一级理解断层,代码照样能跑,但你就失去了调试能力。

1.1 把"渲染管线"当成一条传送带来理解

我一般建议学生先别急着写代码,拿一张纸把管线画成传送带:传送带的起点是一堆三维顶点坐标,终点是屏幕上的颜色像素。中间经过的每一站都要问三个问题——这一站输入什么、输出什么、做了什么运算。

具体来说,顶点数据先经过模型矩阵搬到世界空间,再经过视图矩阵搬到相机空间,再经过投影矩阵搬到裁剪空间,然后做透视除法得到标准化设备坐标(NDC),最后经过视口变换映射到屏幕像素。这一串里,前三个变换是矩阵乘法,第四个是除以 w 分量,第五个是线性缩放加平移。

提示:实验报告里如果只写"使用了MVP矩阵",基本拿不到过程分。要写清楚每个矩阵的维度、作用空间、以及为什么乘的顺序是 P×V×M 而不是 M×V×P。

为什么顺序这么重要?因为矩阵乘法不满足交换律。顶点是列向量时,变换从右往左作用:先乘模型矩阵,再乘视图矩阵,最后乘投影矩阵。你可以用一个小例子验证:先平移(1,0,0)再放大2倍,和先放大2倍再平移(1,0,0),结果差了一倍的距离。这个验证我让学生每个人都手算一遍,比看十遍教材管用。

1.2 建立"像素级验证"的调试习惯

普通程序调试靠打印变量,图形学调试要靠"打印像素"。我推荐的做法是:写完一个绘制函数,先别画复杂模型,画一个已知坐标的三角形,然后用代码把帧缓冲里某几个坐标的颜色值读出来打印。

以常见的OpenGL为例,可以用glReadPixels读取指定像素:

unsigned char pixel[4]; glReadPixels(320, 240, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); printf("center pixel: R=%d G=%d B=%d A=%d\n", pixel[0], pixel[1], pixel[2], pixel[3]);

这里有个坑必须提前说:glReadPixels的坐标原点在左下角,而大多数人习惯的屏幕坐标原点在左上角。我第一次做实验时读中心点颜色一直是黑的,排查了四十分钟才发现是坐标系搞反了,把 y 坐标用height - y换算一下就好了。这种坑在实验指导书里通常不会写,但它能吃掉你半个下午。

另一个习惯是给每个绘制阶段加"可视化中间结果"。比如把法线当成颜色输出(法线 xyz 映射到 rgb),你立刻能看出法线方向对不对;把纹理坐标当成颜色输出,能看出 UV 有没有超出 0 到 1 的范围。这两个技巧在后面的光照和纹理实验里会反复用到,比任何断点调试都直观。

2. 工具链选型:固定管线和可编程管线,实验环境怎么搭

实验课第一件事是选技术栈,而这个选择会直接影响你后面能学到什么。应用技术大学的教学场景通常有三种主流方案,我按"学习收益"和"上手成本"做了个对比,你可以对照自己的实验要求选。

方案代表技术上手成本能接触到的底层细节适合的实验类型
固定管线老式 OpenGL(glBegin/glEnd)低少,大部分被封装入门绘制、变换演示
可编程管线现代 OpenGL / WebGL + GLSL中多,着色器全流程可控光照、纹理、阴影
高层封装Three.js / 图形库封装层很低很少,偏应用快速出效果、课程展示

2.1 为什么我建议至少用一次可编程管线

固定管线的写法很诱人,几行glBegin(GL_TRIANGLES)就能画图,但它把顶点处理、光照计算全藏在驱动里,你根本看不到矩阵怎么作用、法线怎么参与计算。应用到具体实验里,学生做完固定管线实验,往往只知道"调了glLightfv就有光了",但说不清光照公式。

可编程管线的学习曲线陡一些,要先写顶点着色器和片元着色器,还要管理顶点缓冲对象(VBO)、顶点数组对象(VAO),但一旦跑通,整个计算过程对你就是透明的。我通常建议:入门实验用固定管线快速建立"图形是画出来的"这个直觉,从光照实验开始必须切到可编程管线,否则后面的内容全是黑盒。

一个最小的顶点着色器长这样:

#version 330 core layout(location = 0) in vec3 aPos; layout(location = 1) in vec3 aNormal; uniform mat4 model; uniform mat4 view; uniform mat4 projection; uniform mat3 normalMatrix; out vec3 fragNormal; out vec3 fragPos; void main() { fragPos = vec3(model * vec4(aPos, 1.0)); fragNormal = normalMatrix * aNormal; gl_Position = projection * view * vec4(fragPos, 1.0); }

注意这里的normalMatrix,它是模型矩阵左上角 3×3 部分的逆转置矩阵。为什么不能直接用模型矩阵变换法线?因为法线是垂直于表面的方向向量,当模型发生非均匀缩放(比如 x 方向拉长 2 倍),法线如果按同样方式缩放,就不再垂直于变换后的表面了。这个细节是光照实验里最容易翻车的地方,我在第 5 节会展开讲推导。

2.2 环境搭建里最容易被忽略的两件事

第一件是深度测试默认没开。很多同学画完两个三角形发现后面的盖住了前面的,以为是绘制顺序问题,其实是忘了glEnable(GL_DEPTH_TEST)。深度测试的原理是每个片元带一个深度值,写入帧缓冲时和已有深度比较,更近的才覆盖。开了之后还要记得每帧清空深度缓冲,glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT),少清一个就会看到上一帧的残影。

第二件是视口和窗口尺寸不一致。窗口被拖拽改变大小时,如果不重新调用glViewport,画面会被拉伸变形或者只渲染在左下角一块。标准的处理是注册窗口尺寸回调,在里面更新视口和投影矩阵的宽高比:

void framebuffer_size_callback(GLFWwindow* window, int width, int height) { glViewport(0, 0, width, height); float aspect = (float)width / (float)height; projection = glm::perspective(glm::radians(45.0f), aspect, 0.1f, 100.0f); }

宽高比不更新带来的后果是画面"横向拉胖",学生在做相机实验时经常误判成投影矩阵参数写错了,其实只是漏了这个回调。这类环境层的坑不涉及算法,但会浪费大量排查时间,我一般会在实验课开头统一演示一遍。

3. 光栅化实验:直线、圆与多边形的像素级拆解

光栅化是图形学实验里最有"手工感"的一块,也是最能体现数学到像素转换的环节。屏幕上没有真正的直线,只有一排排发光的像素点,你要做的事情是用整数运算决定"这一列像素里,哪一行该点亮"。这个判断逻辑,就是光栅化算法。

3.1 从 DDA 到 Bresenham:误差累积的两种处理思路

DDA(数字微分分析)的思路很直观:把直线参数化,x 每次加 1,y 就加一个斜率 k,然后对 y 四舍五入取整。代码短,但有个硬伤——每步都要做浮点加法和取整,而且误差会随着步数累积。

Bresenham 算法的精妙之处在于把浮点运算换成了整数误差项的符号判断。核心思想是维护一个"决策变量",表示当前点到理想直线的偏差,根据偏差的正负决定下一个像素是取正右方还是右上方。以斜率在 0 到 1 之间的直线为例:

void bresenhamLine(int x0, int y0, int x1, int y1) { int dx = x1 - x0; int dy = y1 - y0; int d = 2 * dy - dx; // 初始决策变量 int y = y0; for (int x = x0; x <= x1; ++x) { setPixel(x, y); if (d > 0) { y++; d += 2 * (dy - dx); } else { d += 2 * dy; } } }

这个d的推导值得在实验报告里写清楚:它本质上是"真实直线在 x+1 处的 y 值"与"当前像素行中心"之差的 2 倍再乘 dx,之所以乘 2 是为了把分数比较转成整数比较。我让学生自己推一遍这个式子,推完之后对"整数光栅化"这件事的理解会完全不一样。

注意:Bresenham 的完整实现要分八象限讨论。斜率绝对值大于 1 时要交换 x 和 y 的角色,否则循环步进方向会出错,画出来的线会断开。这是实验里最常见的扣分点。

3.2 圆和椭圆:八分对称性的利用

画圆实验里,很多人第一反应是用参数方程逐角度采样,结果圆看起来毛毛糙糙,因为角度采样在圆周上分布不均匀。正确做法是利用圆的八分对称性:只计算 0 到 45 度这一段(第一象限中靠近 y 轴的部分),其余七个点通过对称变换得到。

中点画圆算法同样是整数决策变量的思路,决策变量来自圆方程 F(x,y) = x² + y² - r² 在中点处的取值符号。当决策变量小于 0,说明中点在圆内,选正右方的像素;否则选右下方的像素。

void midpointCircle(int r) { int x = 0, y = r; int d = 1 - r; // 初始决策变量 while (x <= y) { plot8(x, y); // 八分对称绘制 if (d < 0) { d += 2 * x + 3; } else { d += 2 * (x - y) + 5; y--; } x++; } }

plot8会一次性画出 (x,y)、(y,x)、(-x,y)、(-y,x) 等八个对称点。这里我在实验课上见过一个反复出现的错误:对称点画了,但圆心没有加上去,导致八个点分散在原点周围而不是围绕指定圆心。修法是把plot8里的每个点都加上圆心偏移(cx, cy)。这个错误看起来很低级,但它恰好说明"算法逻辑"和"坐标系统"是两件事,混在一起就会出错。

多边形填充实验通常会引入扫描线算法或种子填充。扫描线填充的核心是求交点、排序、配对填充,这里有个经典边界问题:顶点恰好落在扫描线上时,交点会被重复计算,导致填充区域错位。标准解法是"左闭右开"规则,即判断交点时,把共享顶点只计入一侧的边。这个规则如果不讲清楚,学生填出来的多边形总会在某些行多一个或少一个像素。

4. 几何变换实验:矩阵不是背出来的,是验出来的

变换实验是图形学里数学味最重的一块,也是很多人第一次真正感受到"线性代数有用"的地方。但实验课的问题在于,学生往往把矩阵当公式背,背完能通过检查,一换场景就不会用。我的做法是:所有变换都要求学生先用两个具体点手算,再用代码验证,最后对比结果。

4.1 齐次坐标到底解决了什么问题

二维变换用 2×2 矩阵就够表示旋转和缩放,但平移不行,因为平移是加法不是乘法。齐次坐标的做法是给点补一个维度,二维点 (x, y) 写成 (x, y, 1),这样平移就能写成矩阵乘法:

$$ \begin{bmatrix} 1 & 0 & t_x \ 0 & 1 & t_y \ 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} x \ y \ 1 \end{bmatrix}

\begin{bmatrix} x + t_x \ y + t_y \ 1 \end{bmatrix} $$

这样一来,旋转、缩放、平移全部统一成矩阵乘法,多个变换可以预先乘成一个矩阵,顶点只需要乘一次。这个"合并矩阵"优化在渲染大量顶点时意义重大,也是实验里应该让学生实测的:分别用三次矩阵乘法变换 10 万个点,和用一个合并矩阵变换 10 万个点,用系统计时器量一下耗时差异。我实测下来,合并之后通常能快一到两倍,具体倍数和硬件有关。

提示:齐次坐标中的 w 分量不是"补位的 1",在投影变换后它有意义。透视除法就是拿 xyz 除以 w,这是实现"近大远小"的关键,实验报告里要单独说明。

4.2 旋转方向与矩阵顺序:两个高频翻车点

旋转方向的问题几乎每届都有人问:为什么我按逆时针公式写的矩阵,画出来是顺时针?原因是屏幕坐标系里 y 轴方向和你数学课本上不一样。数学坐标系 y 轴向上,屏幕坐标系 y 轴向下,两者差一个镜像,旋转的视觉方向就反了。解决办法要么在投影矩阵里翻转 y,要么接受这个约定,在实验报告里写明使用的坐标系。

矩阵顺序的问题更隐蔽。假设你要把一个物体先缩放到 2 倍,再绕原点旋转 90 度,最后一个顶点 v 的变换是:

glm::mat4 S = glm::scale(glm::mat4(1.0f), glm::vec3(2.0f)); glm::mat4 R = glm::rotate(glm::mat4(1.0f), glm::radians(90.0f), glm::vec3(0,0,1)); glm::mat4 M = R * S; // 先缩放,再旋转 glm::vec3 result = M * v;

关键点:列向量约定下,右侧的矩阵先作用。所以"先缩放再旋转"写成R * S,而不是S * R。这个顺序如果写反,非均匀缩放的物体会被剪切变形,看起来像"斜了",很多同学误以为是模型数据坏了,其实是矩阵乘反了。

我做实验时有个笨但有效的检查方法:挑一个不在原点的点,比如 (1, 0, 0),按你写的矩阵顺序手算出结果,再和代码输出对比。手算和代码一致,顺序就对了。这个方法虽然土,但它把"矩阵顺序"这个抽象问题变成了可以验证的具体数字。

4.3 三维相机的构造:视图矩阵其实是手动搭坐标系

视图矩阵的实验我一般放在变换实验的最后,因为它综合了前面所有知识。相机的本质是定义一套新的坐标系:相机位置是原点,观察方向是 -z 轴,上方向是 +y 轴,右方向是 +x 轴。

构造过程是:观察方向 = 目标点 - 相机位置,归一化得到 z 轴(注意取反才是相机朝向);右方向 = 上方向 × z 轴,归一化;再用 z 轴 × 右方向得到真正的上方向。这三个正交基向量组成旋转部分,再和相机位置组合成平移部分:

glm::mat4 lookAt(glm::vec3 eye, glm::vec3 center, glm::vec3 up) { glm::vec3 f = glm::normalize(center - eye); // 前方向 glm::vec3 s = glm::normalize(glm::cross(f, up)); // 右方向 glm::vec3 u = glm::cross(s, f); // 正交化后的上方向 glm::mat4 view(1.0f); view[0][0] = s.x; view[1][0] = s.y; view[2][0] = s.z; view[0][1] = u.x; view[1][1] = u.y; view[2][1] = u.z; view[0][2] = -f.x; view[1][2] = -f.y; view[2][2] = -f.z; view[3][0] = -glm::dot(s, eye); view[3][1] = -glm::dot(u, eye); view[3][2] = glm::dot(f, eye); return view; }

注意u = cross(s, f)这一步是在做正交化。如果输入的 up 向量和 f 不垂直(很常见,比如随手写个 (0,1,0)),直接用它会让画面倾斜。用叉乘重新算出垂直的上方向,这个问题就消除了。这个细节在实验指导书里往往一笔带过,但它是"画面看着有点歪"的常见原因。

5. 光照与着色实验:从 Lambert 到 Phong 的逐步逼近

光照实验是整个图形学实验里最有成就感的一块,也是最能区分"会调包"和"懂原理"的一块。同一个球体,用不同的光照模型渲染出来,质感差异非常明显。我的建议是不要一上来就用完整的光照公式,而是按 Lambert 漫反射、Phong 镜面反射、Blinn-Phong 优化的顺序逐步加,每加一步都观察画面变化。

5.1 法线变换为什么必须用逆转置矩阵

在讲光照模型之前,必须先解决法线变换的问题。顶点位置用模型矩阵变换,法线却不能用同一个矩阵,原因在于法线是"垂直关系"的载体,而垂直关系在非均匀缩放下不保持。

举个具体例子:一条斜面法线是 (1, 1, 0),模型在 x 方向拉伸 2 倍,如果法线也按同样方式变成 (2, 1, 0),它和变换后表面的夹角就不再是 90 度了。数学上可以证明,保持垂直关系的正确变换是模型矩阵左上角 3×3 部分的逆转置。

glm::mat3 normalMatrix = glm::transpose(glm::inverse(glm::mat3(model)));

实测下的现象很直观:用错矩阵时,拉伸变形区域的明暗分布会明显不对,高光位置偏移,看起来像"光从错误的方向照过来"。而在均匀缩放和纯旋转下,逆转置刚好等于原矩阵,所以有些同学在简单模型上没发现问题,一换到非均匀缩放就翻车。

5.2 Lambert 漫反射:点积背后的物理直觉

漫反射项的公式是max(dot(N, L), 0)乘以光照颜色和物体颜色,其中 N 是归一化法线,L 是从表面指向光源的归一化向量。这个点积的几何意义是:法线越正对光源,表面接收到的光越多;法线与光线垂直时点积为 0,表面不受光。

vec3 N = normalize(fragNormal); vec3 L = normalize(lightPos - fragPos); float diff = max(dot(N, L), 0.0); vec3 diffuse = diff * lightColor * objectColor;

这里必须强调归一化。如果 N 或 L 没有归一化,点积的结果会超过 1 或者偏小,画面不是过曝就是偏暗。我见过有同学把normalize写在顶点着色器里但片元里忘了,插值之后法线长度不再为 1,明暗过渡就变得很怪。正确做法是在片元着色器里重新归一化。

max(..., 0.0)这一层也不能省,点积为负说明光在表面背面,物理上不应该有贡献,不加 max 会出现背面发亮、明暗翻转的现象。

5.3 Phong 与 Blinn-Phong:半程向量的那次优化

镜面反射项描述的是高光。Phong 模型用反射向量 R 和视线向量 V 的点积,Blinn-Phong 则用半程向量 H(光线方向和视线方向的平均)和法线的点积:

vec3 V = normalize(viewPos - fragPos); vec3 H = normalize(L + V); // Blinn-Phong 的半程向量 float spec = pow(max(dot(N, H), 0.0), shininess); vec3 specular = spec * lightColor * specularStrength;

两者视觉上很接近,但 Blinn-Phong 计算量更小,而且在高光边缘更平滑。shininess参数控制高光范围,值越大高光越集中、物体看起来越光滑。实验里建议让学生把 shininess 从 2 调到 256,观察高光从"一大片"收缩成"一个小点"的过程,这个连续变化的观察比看公式更能建立参数直觉。

注意:Phong 光照是逐片元计算的(在片元着色器里),不是逐顶点。逐顶点光照在低面数模型上会出现明显的马赫带效应,高光断成一块一块。实验报告里如果写了"逐顶点光照",要说明它在什么情况下不够用。

5.4 光照实验的调试顺序:先孤立,再组合

光照实验最容易出的问题是"画面不对但不知道哪一项不对"。我的排查顺序是固定的:

  1. 只开环境光,确认物体整体亮度正常,没有全黑或全白;
  2. 只开漫反射,把光源放在物体正前方,确认最亮区域在正对光源的位置;
  3. 只开镜面反射,把 shininess 设小一点,确认高光能跟着相机移动;
  4. 三项一起开,调权重直到视觉合理。

这个顺序的价值在于每次只引入一个变量。我见过学生一上来就写完整公式,结果画面一片黑,然后开始乱改参数,越改越乱。按孤立变量法排查,十分钟就能定位到是法线没归一化还是光源位置传错。

6. 曲线曲面与纹理映射:那些"看起来对"但细节翻车的地方

曲线曲面和纹理映射是实验课里"见效快、翻车也快"的两块。曲线实验画出来的曲线往往一眼看着没问题,但控制点一动就发现曲线不经过某些点、或者曲率突变;纹理实验贴上去能看,但一转视角就开始拉伸、接缝、摩尔纹。这些问题的根源都在参数化细节上。

6.1 Bézier 曲线:de Casteljau 比多项式展开更好懂

Bézier 曲线的标准公式是伯恩斯坦多项式求和,直接展开写代码也能跑,但学生往往不理解为什么曲线会被控制点"拉"过去。de Casteljau 算法用递归插值的几何方式构造曲线,理解成本低得多:在相邻控制点之间按参数 t 做线性插值,得到新的一层点,再对新点插值,直到只剩一个点。

glm::vec3 deCasteljau(const std::vector<glm::vec3>& pts, float t) { std::vector<glm::vec3> tmp = pts; while (tmp.size() > 1) { for (size_t i = 0; i + 1 < tmp.size(); ++i) tmp[i] = glm::mix(tmp[i], tmp[i+1], t); tmp.pop_back(); } return tmp[0]; }

这个算法有三个性质值得在实验里验证:曲线经过首尾控制点、曲线位于控制点凸包内、控制点整体平移时曲线同向平移。我让学生把控制点拖到共线,观察曲线退化成直线;再把中间控制点拉到很远,观察曲线被"拽"的幅度。这些交互验证比静态截图更能说明问题。

B 样条实验的难点在节点向量。均匀 B 样条好写,但一旦要求曲线经过端点,就要用准均匀或 clamped 节点向量,即在首尾重复节点。实验里常见的错误是节点向量长度算错:n+1 个控制点、k 阶(次数为 k-1)的 B 样条,节点向量长度应该是 n+k+1。这个公式记错了,程序要么越界要么曲线形状完全不对。

6.2 纹理坐标与采样:拉伸、接缝、摩尔纹的三类成因

纹理实验里最典型的现象是"贴图看起来糊糊的"或者"边缘有缝"。这三类问题对应三个不同的原因,排查时可以分开看:

现象常见原因处理方式
纹理拉伸变形UV 坐标范围或映射方式不对检查 UV 是否覆盖 0 到 1,模型 UV 是否按比例分配
模型接缝处有裂缝采样模式导致边缘颜色不连续设置 wrap 模式为 repeat 或 clamp to edge
远处出现摩尔纹采样频率高于纹理分辨率开启 mipmap 并设置合适的过滤模式

wrap 模式这块值得展开说。默认的GL_REPEAT在 UV 超出 0 到 1 时会循环平铺,适合地面、墙面这种连续表面;GL_CLAMP_TO_EDGE会把边缘像素向外延伸,适合单张海报这种不希望重复的情况。选错了就会出现"接缝处突然重复半张图"的诡异效果。

mipmap 的原理是预先生成一系列逐级缩小的纹理,采样时根据屏幕上的像素密度选择合适的层级。远处物体一个屏幕像素可能覆盖纹理上几十个像素,如果不做 mipmap,采样点随机落在纹理的不同位置,就会产生高频闪烁,也就是摩尔纹。开启很简单:

glGenerateMipmap(GL_TEXTURE_2D); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

注意MIN_FILTER一定要设成带 mipmap 的选项,只开GL_LINEAR是没用的,这是个高频错误。另外纹理坐标的 y 轴方向在图像加载时经常和图形 API 约定相反,加载出来的图上下颠倒,解决办法是在加载时翻转,或者在着色器里用1.0 - uv.y。这个坑我在不同项目里反复遇到,建议加载纹理的封装函数里直接处理掉。

6.3 多纹理混合时的透明度排序问题

做完基础纹理之后,实验通常会加一个混合(blending)环节,画半透明的物体。这里有个原理性的坑:透明渲染必须从远到近绘制,否则会出现"近处透明物体被远处物体遮挡"的错误效果。

原因是混合是逐片元按绘制顺序叠加的,先画的已经在帧缓冲里,后画的按 alpha 混合上去。如果近的先画,远的后画,远物体的片元会覆盖掉近物体已经混合好的颜色。正确做法是把透明物体单独收集,按相机距离从远到近排序后再绘制。这个排序在实验用的少量物体场景里很好实现,但在真实场景里开销不小,这也是为什么很多引擎把透明物体和实体物体分开渲染。

7. 实验报告与答辩:把"我做出来了"变成"我知道为什么"

最后说一个不那么技术、但直接决定实验得分的事:报告怎么写。应用技术大学的实验评分通常分过程分和报告分,过程分看代码能不能跑,报告分看你能不能讲清楚。我见过太多代码写得不错但报告只有截图和"运行成功"四个字的作业,得分很吃亏。

7.1 报告里必须出现的四类内容

第一类是参数推导。任何一个算法用了什么公式,这个公式怎么来的,关键参数取值的依据是什么,都要写。比如 Bresenham 的决策变量推导、Phong 的 shininess 取值理由、mipmap 层级怎么算。

第二类是对照实验。单纯的"结果截图"说明不了什么,有对照组才有信息量。开了深度测试和没开深度测试的对比、用逆转置矩阵和用原矩阵的对比、有 mipmap 和没 mipmap 的对比,这类图放上去,老师一眼就知道你真的理解了。

第三类是异常现象与排查过程。报告里写"遇到了边缘接缝问题,排查后定位为 wrap 模式设置不当",比写"程序运行成功"有价值得多。排查过程本身就是能力证明。

第四类是性能或精度数据。比如前面提到的合并矩阵优化前后耗时对比、不同面数模型下逐顶点和逐片元光照的视觉差异、纹理尺寸对加载时间的影响。有一两个具体数字,报告的专业度立刻不一样。

7.2 答辩时被追问的三个问题,提前准备好

根据我带实验课的经验,老师最常追问的三个问题是:"你这个矩阵为什么这么乘"、"这个算法的时间复杂度是多少"、"如果模型更复杂,你的方案还成立吗"。

第一个问题考的是变换顺序理解,准备好手算过程就能答。第二个问题考的是算法意识,Bresenham 是 O(n),n 是较长轴上的像素数;扫描线填充是 O(边数 + 扫描线数 × 交点排序开销),能说出大致量级就行。第三个问题考的是边界意识,比如逐顶点光照在低面数下失真、固定管线在复杂材质下不够用、透明排序在大量物体时开销大,能说出局限比强行说"没问题"更让人信服。

提示:答辩时如果被问到不会的,直接说"这块我还没验证到,我的推测是……",比硬编一个答案好。图形学的细节太多,没人指望你全懂,但态度和思路是能看出来的。

7.3 实验之外的延伸方向

如果做完基础实验还有余力,我建议往两个方向延伸。一个是把渲染结果和数学公式对起来,比如写一个小工具,输入任意点和光源位置,实时显示法线点积的数值和对应像素颜色,这样公式和画面就彻底打通了。另一个是做一次完整的场景,把变换、光照、纹理、透明混合全部用上,因为单独的实验都是玩具,组合起来才会暴露排序、状态管理、资源释放这些工程问题。

我自己在这些实验里折腾下来,最大的收获其实不是学会了某个 API,而是建立了一种"从像素反推计算过程"的思维方式。屏幕上任何一个颜色不对的像素,背后一定有一个具体的矩阵、一次具体的归一化、一个具体的采样决策出了问题。顺着这条链路往回找,几乎总能找到原因。这个习惯后来在处理任何图形相关的问题时都用得上,比记住多少个函数签名有用得多。