Games101作业3全解析:从光栅化到着色器,透视校正与TBN矩阵排坑指南

发布时间:2026/10/24 1:30:25
Games101作业3全解析:从光栅化到着色器,透视校正与TBN矩阵排坑指南

图形学入门课的第三次作业(也就是经常被提到的 Games101 作业3),是我个人认为整门课里最能拉开差距的一次。前两次作业还在跟光栅化和深度缓冲搏斗,到作业3突然变成了“写着色器”:法线着色、Blinn-Phong、纹理映射、凹凸贴图、位移贴图……光是这些名词就能劝退一批人。我见过不少同学在最后一天对着全黑的渲染结果发呆,其实大部分问题不是代码能力不够,而是没搞清几个关键概念之间的连接。这篇文章按我提交时理解的顺序来写,从光栅化怎么把数据送进着色器开始,一路讲到双线性插值和 TBN 矩阵,最后附一份我提交前必查的清单。如果你现在正卡在某张图一直不对,可以直接跳到对应章节对症状。

1. 整体渲染链路:从重心坐标到片段着色器,作业3到底多了一环

1.1 一次 draw 调用在作业3框架里发生了什么

作业2里我们写的是rasterize_triangle本身:遍历包围盒、算重心坐标、做深度测试、往 frame buffer 里写颜色。到作业3,框架把“往 frame buffer 里写颜色”这件事抽象成了一个fragment_shader函数,你在里面决定这个像素最终是什么颜色。整体流程变成:

  1. vertex_shader把顶点属性(位置、法线、纹理坐标)从模型数据里读出来,包装成若干个顶点。
  2. 光栅化阶段对每个三角形做投影变换、包围盒遍历、重心坐标计算、深度测试。
  3. 命中像素后,把当前像素的插值属性打包成payload,交给fragment_shader。
  4. fragment_shader返回一个颜色,写入 frame buffer。

很多同学第一次打开main.cpp会懵,因为里面有七个fragment_shader函数:normal_fragment_shader、phong_fragment_shader、texture_fragment_shader、bump_fragment_shader、displacement_fragment_shader,可能还有带“bonus”字的额外函数。其实不必慌,它们的输入输出完全一样,都是接收一个FragmentShaderPayload,返回Vector3f颜色。区别只在于中间怎么算颜色。

1.2 你必须搞清楚的几个变量所在空间

作业3最容易出问题的地方,不是公式写错,而是把不同坐标空间里的向量混在一起算。涉及的关键变量有这么几个:

  • pos:通常是齐次裁剪坐标,带有w分量,主要用于深度测试和透视校正插值。
  • view_pos:经过模型变换、观察变换后的坐标。不同版本框架里这个变量的空间不完全一样,有的更接近“观察空间”,有的只是“模型空间 + 模型矩阵”。
  • normal:顶点法线。同样存在空间问题,是模型空间还是世界空间,取决于框架怎么传。
  • tex_coords:纹理坐标,范围一般在 0 到 1。
  • light.position:场景光源位置,一般是世界空间定义。

判断方法很简单:在fragment_shader里临时把颜色设成某个向量的可视化值,比如return normal * 0.5 + Vector3f(0.5, 0.5, 0.5),如果物体呈现彩色渐变,说明法线方向基本合理;如果某个面的颜色明显不符合直觉,再检查是不是空间没统一。实际项目里,模型矩阵是单位矩阵时,模型空间、世界空间几乎等价,很多作业代码直接混用也能跑出正确结果。但严谨的做法是先把光源方向、视线方向、法线方向全部统一到同一个空间,再代入 Blinn-Phong 公式。

1.3 插值是这一切的基石

作业3和作业2的本质区别,就是引入了属性插值。顶点只有三个,但三角形覆盖的像素可能有几千个,每个像素拿到的法线、纹理坐标、颜色都是顶点属性按重心坐标插值出来的。插值本身不复杂,复杂的是“用什么坐标去插值”。下一节就是这个作业的第一个大坑:透视校正插值。

2. 透视校正插值:贴图扭曲的罪魁祸首

2.1 为什么屏幕空间线性插值会出错

想象你站在一条笔直的马路中间拍照,路面上铺着等宽的砖块。近处的砖在画面里占几十个像素,远处的砖可能只占一两个像素,甚至不到一个像素。如果你直接在屏幕空间里用重心坐标做线性插值,相当于默认“屏幕上的距离变化和真实空间的距离变化成正比”,这显然是错的。透视投影下,真实空间里等间距的物体,在屏幕上呈现的是“近大远小”,而且这种关系不是线性的。

对应到属性插值:用一个三角形的三个顶点属性做插值时,不能直接用屏幕空间的alpha, beta, gamma。正确的做法是先把属性除以各自的w,插值完再除以“1/w 的插值”,也就是透视校正插值。

2.2 透视校正公式与代码习惯

对于某个顶点属性A,在屏幕空间重心坐标为α, β, γ的情况下,正确的插值结果是:

A_interp = (α * A0 / w0 + β * A1 / w1 + γ * A2 / w2) / (α / w0 + β / w1 + γ / w2)

这个公式可以理解为:先在透视空间对属性做线性插值,再用1/w把结果拉回到屏幕空间。w是齐次裁剪坐标的第四个分量,不是深度缓冲里的z。

作业3的框架里通常已经写好了interpolate函数,你只需要保证传给它的重心坐标是屏幕空间的重心坐标。如果你是自己实现的版本,检查点就一句话:有没有除以每个顶点的w。如果你发现物体在画面中间时贴图正常,一到画面边缘就明显拉伸,那几乎可以断定是少了透视校正。

我也见过有人把透视校正做两遍,结果纹理反而歪掉。怎么检查?最直接的办法就是打开贴图有明显横竖纹理的模型(作业默认的模型就是很好的测试目标),盯着画面中斜着的三角形看:如果纹理线条在三角形内部呈直线渐变、没有奇怪的弧线,说明插值是对的。

2.3 一个容易忽略的细节:属性插值后再归一化

法线、纹理坐标这类属性经过插值之后,长度可能不再是 1。尤其法线,三个顶点的法线方向各不相同,插值出来的中间法线长度通常小于 1,方向也有一点偏差。所以在片段着色器里拿到插值后的法线,第一件事就是normalize()。这一点说一百遍都不为过,因为全黑、光照发暗、高光位置不对,很多都是没归一化导致的。

3. Blinn-Phong 光照模型:把公式变成像素颜色

3.1 公式拆分:环境光、漫反射、高光

Blinn-Phong 是作业3第一个要写的完整光照模型,公式拆开看并不复杂:

  • 环境光:ambient = Ka * ambient_light,用来模拟物体间散射光线,让暗部不至于全黑。它不依赖法线、光源方向,也不需要距离衰减。
  • 漫反射:diffuse = Kd * (light.intensity / r^2) * max(0, dot(n, l))。l是从着色点指向光源的单位方向,r是着色点到光源的距离。dot(n, l)为负说明光从背面照过来,直接取 0。
  • 高光:specular = Ks * (light.intensity / r^2) * pow(max(0, dot(n, h)), p)。其中h = normalize(l + v),是入射光方向和视线方向的半程向量。指数p控制高光集中程度,值越大高光越锐利。

最后把三个分量加起来,就是这一像素收到的直接光照颜色。

3.2 代码里的向量来源:l、v、h 谁是谁

写代码时最容易乱的是这几个向量。我的实现习惯是:

Vector3f point = view_pos; // 当前着色点坐标 Vector3f light_dir = normalize(light.position - point); Vector3f view_dir = normalize(camera_pos - point); // 或 -normalize(view_pos),取决于空间 Vector3f half_vec = normalize(light_dir + view_dir);

这里有两个不一致的坑。

第一个坑是view_pos到底是观察空间还是世界空间。如果view_pos是观察空间坐标,那么相机在原点,视线方向应该取-normalize(view_pos);如果view_pos是世界空间坐标,那么要拿相机世界坐标减去point。混用这两种方式会导致高光在物体旋转时跑到错误的位置。

第二个坑是光源方向。有的框架给的是light_dir,有的给的是light.position。如果只给light.position,就要按照“光源位置减去着色点位置”来计算方向,不要下意识直接用light.position当方向。

归一化之后,dot值最好也随手clamp一下,防止浮点误差造成负的平方根。

3.3 实测中的常见症状对照

我在这个作业上见过的典型症状和原因差不多是这样:

现象可能原因
整个物体全黑法线没归一化、法线空间不对、光源方向反了,或者dot(n, l)全为负数
只有镜面高光、没有漫反射漫反射系数Kd设置错误,或者忘了加环境光
高光面积特别大、像塑料半程向量的指数p太小,试着把p调到 32 以上
颜色过曝、一片白最终颜色没有 clamp 到 0~1,RGB 值超过显示范围
光照跟着相机移动视线方向v用了世界空间的方向,却把它当成观察空间来算

一个很实用的方法:写phong_fragment_shader时先只算环境光加漫反射,确认物体有立体感之后,再叠加高光。这样出问题时能快速定位是哪个分量出了问题。

4. 纹理映射:UV 坐标、通道顺序和双线性插值

4.1 一张贴图为什么上下颠倒

纹理映射的第一步,就是根据tex_coords从贴图中取出颜色。作业3的Texture::getColor是现成的,但很多人第一次跑出来,贴图是上下颠倒的。原因很简单:图片文件的像素行序跟图形学里的纹理坐标v方向不一定一致。图像第一行通常对应顶部,而纹理坐标v=0可能被定义为底部。

解决办法是在getColor里做一次翻转:

v = 1.0f - v;

这个不是所有模型都一定需要,具体要看模型 UV 是怎么展开的。但作业默认的模型和纹理组合下,加上这一行通常能让图案方向恢复正常。

4.2 通道顺序:颜色偏蓝偏红不是玄学

另一个高频坑是颜色整体发蓝或发红。这是因为作业框架常用 OpenCV 读图,而 OpenCV 读进来的图像通道顺序是 BGR,不是 RGB。如果你直接拿image.at<cv::Vec3b>(y, x)的三个通道当 RGB 用,红蓝就会互换。

检查办法很简单:在getColor里对同一个位置输出一个纯文字提示,或者直接看第一次纹理渲染图里“红色物体变蓝、蓝色物体变红”。如果确认是 BGR 顺序,交换一下返回颜色的x()和z()分量即可。

4.3 双线性插值的实现细节

作业3带星号的延伸要求里,通常会让你实现getColorBilinear,用来解决纹理像素过少时出现的马赛克和边缘抖动。双线性插值的思路是先找到当前 UV 点处于哪四个纹理像素之间,然后做两次水平方向插值,再做一次垂直方向插值。

我的实现大致是这样的:

Vector3f Texture::getColorBilinear(float u, float v) { if (u < 0.0f || v < 0.0f || u > 1.0f || v > 1.0f) return Vector3f(0.0f, 0.0f, 0.0f); v = 1.0f - v; float u_tex = u * width - 0.5f; float v_tex = v * height - 0.5f; int x0 = floor(u_tex); int y0 = floor(v_tex); float fu = u_tex - x0; float fv = v_tex - y0; int x1 = std::min(x0 + 1, width - 1); int y1 = std::min(y0 + 1, height - 1); x0 = std::max(0, std::min(x0, width - 1)); y0 = std::max(0, std::min(y0, height - 1)); Vector3f c00 = getColorNoFlip( (float)x0 / width, (float)y0 / height ); Vector3f c01 = getColorNoFlip( (float)x0 / width, (float)y1 / height ); Vector3f c10 = getColorNoFlip( (float)x1 / width, (float)y0 / height ); Vector3f c11 = getColorNoFlip( (float)x1 / width, (float)y1 / height ); Vector3f top = c00 + (c10 - c00) * fu; Vector3f bottom = c01 + (c11 - c01) * fu; return top + (bottom - top) * fv; }

有几个细节值得注意。第一,减 0.5 是为了让采样点落在纹理像素中心附近,否则插值结果会有半像素偏移。第二,边界处理一定要 clamp,不然x0+1会越界。第三,如果你在函数内部做了一次v = 1 - v,那内部取四个样本时不要再次翻转,不然方向会乱。这个细节写清楚之后,双线性插值基本一次过。

5. Bump 和 Displacement:法线欺骗与顶点移动

5.1 法线贴图为什么看起来偏蓝紫色

Bump Mapping 的核心思想是:不改变三角形几何,只修改片段的法线方向,让光照计算时表面看起来凹凸不平。它依靠的是法线贴图:一张纹理里存储的是每个片段的切线空间法线。

切线空间里,法线的默认方向是(0, 0, 1),也就是垂直于表面向上。把这个向量从[-1,1]范围编码到[0,1]的 RGB 值,得到(0.5, 0.5, 1.0),对应的颜色就是带蓝紫色的。所以你看到一个法线贴图整体蓝紫色是非常正常的,不用怀疑贴图有问题。只有当你采样之后没做color * 2 - 1的逆映射,才会得到完全错误的光照。

5.2 从切线空间到世界空间:TBN 矩阵

法线贴图样本给出的是切线空间法线,但光照计算用的是世界空间或模型空间的法线,所以需要一个 TBN 矩阵做变换。T 是切线方向,B 是副切线方向,N 是原法线方向。作业3的正经做法是:三角形顶点如果带切线属性,就从顶点切线构造 TBN;如果没有,就用纹理坐标差分或者任意正交基近似。

我提交时用的是任意正交基近似法,因为作业模型本身比较简单,这种方法已经够用:

Vector3f n = normal.normalized(); Vector3f t; if (std::abs(n.x()) < 0.999f) t = n.cross(Vector3f(1.0f, 0.0f, 0.0f)).normalized(); else t = n.cross(Vector3f(0.0f, 1.0f, 0.0f)).normalized(); Vector3f b = n.cross(t).normalized(); Matrix3f TBN; TBN << t.x(), b.x(), n.x(), t.y(), b.y(), n.y(), t.z(), b.z(), n.z();

拿到 TBN 之后,把采样到的法线贴图颜色映射为切线空间法线,再乘 TBN 转到世界空间:

Vector3f tex_normal = payload.texture->getColor(u, v); tex_normal = tex_normal * 2.0f - Vector3f(1.0f, 1.0f, 1.0f); tex_normal.normalize(); Vector3f bump_normal = (TBN * tex_normal).normalized();

之后的光照计算就用bump_normal代替原来的normal。

5.3 Displacement Mapping:真的动一下顶点

Displacement 和 Bump 的区别,是它会把高度值真正应用到几何位置上。作业3的框架不会真的去改三角形的顶点,但会在片段着色器里把view_pos沿法线方向偏移一段距离,再拿偏移后的点去算光照。这样在视觉效果上,物体的轮廓会发生变化,而 Bump Mapping 的轮廓完全不会变。

我的实现顺序:

float height = payload.texture->getColor(u, v).x(); // 取 R 通道作为高度 view_pos += normal * height * scale; // scale 是控制强弱的外挂参数

偏移完之后,别忘了重新计算光线方向和视线方向。因为着色点变了,光源相对它的方向也变了。如果还用偏移前的点去算光照,位移贴图的效果会非常奇怪。

这里一个明显的坑是scale不能拍脑袋乱调。我试过把 scale 调成十几,结果是模型表面像被暴力拉扯,出现大量撕裂和自穿插。正确做法是让高度值归一化到 0~1 后,再乘一个比较小的缩放系数,然后一步步调。

5.4 常见现象:法线发紫、位移剧烈破碎

Bump 和位移部分最常遇到的现象,我整理成一张表:

现象原因
bump 之后光照完全变花、像噪声法线贴图颜色没有正确映射到[-1,1],或没有做 TBN 变换
法线贴图呈现纯蓝紫色但物体没有凹凸感TBN 构造错误,导致扰动后的法线方向被抵消
位移模型边缘出现刺眼的锯齿状片段着色器位移改变了视觉位置,但光栅化深度仍用原三角形插值,属于正常现象;也可以改进为在几何阶段真正位移顶点
位移效果太夸张、表面断裂scale过大,或者高度值没有做归一化

如果 Bump 不生效,最直接的调试方法是在 fragment shader 中把bump_normal可视化出来:return bump_normal * 0.5 + Vector3f(0.5, 0.5, 0.5)。如果物体表面出现平滑的彩色渐变,说明法线变换成功;如果表面只有噪点一样的颜色,一定是 TBN 环节出了错。

6. 提交前最后的自检清单:五张输出图应该是什么样

6.1 每张图的正常特征

作业3最后会产出多张对比图。我提交前会逐张确认:

  • 法线着色图:物体表面应该是平滑的颜色渐变,每个面因朝向不同呈现不同颜色,颜色过渡处没有诡异的黑线。
  • Blinn-Phong 图:有明显的明暗过渡、环境光让暗部不死黑、高光位置固定且随视线移动变化。
  • 纹理贴图:图案方向正确,斜边没有明显扭曲,颜色正常(不偏蓝偏红)。
  • Bump 图:表面出现凹凸感,但物体整体轮廓和普通纹理贴图一致。
  • Displacement 图:凹凸感更强,并且轮廓发生可见偏移,尤其边缘位置。

我见过最坑的一种情况是 Bump 图和 Phong 图长得一模一样,这通常是因为 Bump 函数里返回的是原始color,而不是扰动后的shader_color。写完整框架时,注意区分“当前着色器的颜色变量”和“最终返回的颜色变量”。

6.2 症状与排查点速查表

症状排查顺序
所有图都全黑1. fragment_shader 是否真的被调用 2. 深度测试/buffer 清屏 3. 法线是否被正确传递 4. 输出颜色是否被 clamp 成 0
只有有贴图的 shader 错1. v 轴翻转 2. BGR/RGB 通道 3. getColor 坐标是否从 0 开始
光照随相机乱动1. view_dir 计算空间 2. 是否该用-normalize(view_pos)却用了世界坐标相机位置
Bump 无效果1. 是否忘了映射颜色 2. 是否忘了乘 TBN 矩阵 3. 是否在最后重新 normalize
Displacement 有位移但光照断裂严重1. 位移后是否重新计算光源方向 2. scale 是否过大 3. 高度值是否超出 0~1 范围

6.3 我的调试习惯

最后分享一个我自己一直保留的习惯:在 fragment shader 里准备一个调试开关,比如用一个全局bool debug,当它为真时,直接把中间变量作为颜色输出。这样不用一次次改代码、重编译、截图,就能快速确认是哪个环节出了问题。

举个例子,排查光照问题时我会依次输出:

// 调试步骤1:只看法线 return normal * 0.5f + Vector3f(0.5f, 0.5f, 0.5f); // 调试步骤2:只看光源方向 return light_dir * 0.5f + Vector3f(0.5f, 0.5f, 0.5f); // 调试步骤3:只看视线方向 return view_dir * 0.5f + Vector3f(0.5f, 0.5f, 0.5f);

几乎每次都能立刻定位是法线反了、方向计算错了,还是空间不一致。比在好几千行里硬找变量快得多。

我最后再说一个小细节:提交前把调试代码、多余输出、临时的超大scale都清理干净,保留一份干净可跑的结果图。作业3的价值不在于跑通几张图,而在于亲手体会“属性插值之后会怎么失真”“坐标系一变向量就完全不是一回事”。这些感受,后面做实时渲染、做路径追踪时都会反复用到。