图形学自学路上,很多人前面几讲还能跟住,真正开始掉队通常就是从光栅化这一步开始的。前面把三维物体一路变换到 [-1,1] 的立方体里,看着还挺优雅;到了把三角形变成像素这一步,画风突然就变成了一堆边界函数、包围盒、循环遍历和深度比较。这篇 GAMES101-现代计算机图形学学习笔记(06),就把第六讲涉及的光栅化、重心坐标插值和 Z-Buffer 整个串一遍,把我自己重新推导过的笔记和踩过的坑都写进去。适合正在跟这门课、或者刚写完前两个小作业的读者拿来对照,也适合想搞明白"三角形到底怎么画到屏幕上"的人。
1. 从相机变换到屏幕:光栅化之前的"最后一公里"
1.1 为什么非要有一个独立的屏幕坐标变换
很多人学完前面的 MVP 变换后会有一个疑问:点都已经到 [-1,1] 的立方体里了,下一步不就是"画出来"吗?但问题在于,[-1,1] 是一个抽象的规范空间,它本身没有宽度、没有像素块、没有坐标系原点。屏幕是一块由离散像素组成的矩形,每个像素是一个面积为 1 的小方块,它们有自己的坐标约定。你必须在两者之间做一次映射,否则"画在哪"这件事根本没法谈。
更具体地说,视口变换做的事情是:把规范立方体的 x、y 分量从 [-1,1] 映射到 [0, width] 和 [0, height],z 分量暂时保持不变,留给后面的深度缓冲去用。课程里默认的是左下角原点的坐标系,x 向右,y 向上,z 朝屏幕外。真实 API 里通常还有一堆额外约定,比如左上角原点、y 向下、DPI 缩放,但学习阶段抓住核心映射就够了。
这段内容看着是纯数学,但我建议你亲手推导一遍,而不是直接背公式。因为真正写光栅化代码时,坐标系一换、原点一翻,很多人就乱了。先搞清楚"屏幕坐标系里一个连续坐标落在哪个像素",比记住任何公式都重要。
1.2 视口变换的公式拆解与像素中心偏移
视口变换的核心公式很简单:
x_screen = (x_ndc + 1) / 2 * width y_screen = (y_ndc + 1) / 2 * height这个公式把 NDC 里的 -1 变成屏幕上的 0,把 1 变成 width 或 height。举个例子,假设窗口是 800x600,一个三角形三个顶点的 NDC 坐标分别是 (-1,-1)、(0,0)、(1,1),经过变换后落在屏幕的 (0,0)、(400,300)、(800,600)。看起来平平无奇,但实际上这里埋了一个非常容易踩的坑:变换出来的坐标是连续实数,而像素是整数格子。像素 (i,j) 覆盖的区域是 [i, i+1] x [j, j+1],中心点在 (i+0.5, j+0.5)。
所以后面做覆盖测试时,我们要去判断的不是像素索引 (i,j),而是像素中心 (i+0.5, j+0.5) 有没有落在三角形内部。这个问题在课程作业里反复坑人:有人直接用 (i,j) 去测,最后三角形边缘出现一条条细缝或者异常缺口,看起来像浮点误差,其实是采样点选错了。0.5 这个偏移看起来小,但它是栅格化最底层的一层约定,值得多花十分钟想明白。
另外还有一个细节:变换之后的三角形如果恰好落在 x=width 或者 y=height 这种边界上,会存在"到底算哪个像素"的歧义。图形硬件里通常会有一套完整的 top-left 规则来解决,学习阶段我们只要知道:为了避免两个相邻三角形在共享边上产生重叠或者裂缝,实际绘制算法会约定某一条边算、某一条边不算。这也是为什么做半开区间判断能解决大部分边界问题。
2. 三角形光栅化的硬核细节:覆盖测试与遍历策略
2.1 为什么主角是三角形,以及"三角形内"的几何意义
图形学之所以把三角形当成基本图元,不是因为别的形状画不出来,而是三角形有太多好性质:三个点决定一个平面,没有任何弯曲;任意多边形都能拆成三角形;线性插值简单;现代 GPU 的光栅化硬件就是围绕三角形设计的。所以光栅化要做的事情本质上只有两件——判断哪些像素属于这个三角形,以及这些像素的颜色应该是什么。
第二个问题需要回到几何原理层面去看。一个点如果在三角形内部,它就可以被三个顶点以非负的系数线性组合出来,也就是凸组合。换句话说,三角形内部的点都能写成 P = α·A + β·B + γ·C,其中 α、β、γ 都大于等于 0,而且加起来等于 1。这三个系数就是重心坐标。理解了这件事,三角形内外判断就不再是"背一个算法",而是有几何直觉在里面。
2.2 同侧法判断:叉积的逐边检验
最常用的三角形内外判断方法是同侧法。思路是这样:如果点 P 在三角形内部,那么 P 一定位于每条边的同一侧。具体做法是,对三角形的三条边分别计算叉积的 z 分量,如果三个叉积符号一致(全正或全负),点就在三角形内;如果某个叉积为 0,表示点在边上,可以算作在三角形内。
假设三角形的三个顶点是 v0、v1、v2,按逆时针顺序排列,点 q 用屏幕坐标表示,那么判断代码的核心部分长这样:
bool insideTriangle(float x, float y, const Vec2f& v0, const Vec2f& v1, const Vec2f& v2) { // 返回向量 (b - a) 与 (c - a) 叉积的 z 分量 auto cross = [](const Vec2f& a, const Vec2f& b, const Vec2f& c) { return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); }; float c0 = cross(v0, v1, {x, y}); float c1 = cross(v1, v2, {x, y}); float c2 = cross(v2, v0, {x, y}); // 允许边界点算在三角形内,同时规避浮点符号抖动 return (c0 >= 0 && c1 >= 0 && c2 >= 0) || (c0 <= 0 && c1 <= 0 && c2 <= 0); }很多人写代码时只判断"全大于 0",忽略了三边全部小于 0 的情况。当顶点按顺时针排列时,叉积结果符号是反的,但你仍然能判断点是否在内,只是符号从正变成了负。这里我建议你自己写几个顶点,分别按逆时针和顺时针两种顺序测一遍,感受一下符号的变化。这比背结论有用得多。
还有一个容易忽略的点:浮点数的符号抖动。当一个点非常靠近三角形某条边时,叉积结果可能是一个极小的正数或负数,受浮点精度影响会在 0 附近跳动。所以工程上通常允许一个小的容差,或者像上面代码那样,允许等于 0 的情况同时出现在符号判断中。这属于典型的"文档不会写,但实际渲染时必须要处理"的问题。
2.3 包围盒遍历与增量优化,还有那些"边界像素"
能判断一个点是否在三角形内,接下来就是遍历。最粗暴的做法是枚举整个屏幕的每一个像素,但这显然太浪费了。一个三角形通常只覆盖屏幕很小一部分,所以我们会先算出三个顶点在 x、y 方向上的最小值minX/minY和最大值maxX/maxY,构成一个轴对齐包围盒,然后只遍历包围盒内部的像素,再做内外判断。
包围盒大小的计算方式和你在二维几何里学过的完全一样:
minX = floor(min(v0.x, v1.x, v2.x)) maxX = ceil(max(v0.x, v1.x, v2.x)) minY = floor(min(v0.y, v1.y, v2.y)) maxY = ceil(max(v0.y, v1.y, v2.y))我习惯在边界外面再扩一格,避免因为浮点误差漏掉边缘像素。很多教科书不会特意提这一步,但实际写代码时,三角形顶点刚好落在整数坐标附近的情况太常见了,这一步能让很多视觉瑕疵消失。
更进一步的优化是用增量式扫描线算法:因为相邻两个像素的 x 坐标只差 1,重新代入边的叉积公式时,会发现叉积结果的变化量是个固定常数,可以提前算好增量,每行只做加法,不用每次重新计算乘法。这种优化在学习阶段不是必须的,但我建议你了解一下它的存在,这样以后读到 GPU 光栅化硬件怎么做像素遍历时,会更有画面感。
遍历策略本身没什么难点,真正容易出错的是穿过多边形共享边时的裂缝和重叠。三角形 A 和三角形 B 共享一条边,如果边界判断两个都不收,画面会出现一条黑色细线;如果两个都收,又会出现颜色叠加。准确的做法是前面提到的 top-left 规则:把上边和左边算作内部,下边和右边算作外部,这样每个边界像素恰好只属于一个三角形。这个规则在真正的硬件光栅器里是强制约定,学习阶段记住原理就够了。
3. 重心坐标与属性插值:从"画三角形"到"画内容"
3.1 重心坐标怎么算
光栅化把三角形覆盖的像素找出来之后,下一步需要给每个像素分配颜色。三角形只有三个顶点有颜色,中间像素的颜色靠插值得到,插值的工具就是重心坐标。
重心坐标的几何定义很简单:把点 P 和三角形的三个顶点连起来,会得到三个小三角形,P 的某个重心坐标分量等于对应小三角形面积与大三角形面积的比值。比如 α = S(PBC) / S(ABC),β = S(PCA) / S(ABC),γ = S(PAB) / S(ABC),而且 α+β+γ = 1。
实际编程中通常直接用以下公式计算:
d = (v1.y - v2.y) * (v0.x - v2.x) + (v2.x - v1.x) * (v0.y - v2.y) α = ((v1.y - v2.y) * (p.x - v2.x) + (v2.x - v1.x) * (p.y - v2.y)) / d β = ((v2.y - v0.y) * (p.x - v2.x) + (v0.x - v2.x) * (p.y - v2.y)) / d γ = 1 - α - β这里的 d 是两倍的大三角形面积(带符号),如果 d 为 0,说明三角形退化成了一条线或者一个点,可以直接跳过,不参与绘制。点 P 在三角形内,当且仅当 α、β、γ 都在 [0,1] 范围内,这对最终的颜色插值非常重要。
我第一次写这段代码时犯过一个错:把 v1、v2 的坐标顺序搞混,导致 computed 出来的 α、β 全错,插值颜色看起来像被揉碎的花布。后来发现只要用面积法推导一遍,公式里的每一项是什么一目了然,根本不用死记。
3.2 插值到底插什么:颜色、深度、后续的 UV 和法线
有了重心坐标,三角形内的任意属性都可以做线性插值。颜色是其中最简单的一种:
color = α * color0 + β * color1 + γ * color2如果颜色是 RGB 三个分量,那么每个分量单独插值,最后组合成一个新的颜色。插值之后,三角形内部看起来就有了渐变效果,而不是像玩具一样靠纯色拼出来的。
比颜色更重要的是深度插值。三角形三个顶点各自有一个变换后的深度值,像素的深度同样可以用重心坐标插出来,然后拿这个插值深度去做 Z-Buffer 比较。这个点经常被初学者忽略,他们以为深度计算是另一个独立系统,实际上它就是最普通的线性插值应用。
再往后,纹理坐标 UV、法线、切线这些属性也都靠重心坐标插值。有一个需要格外注意的地方:顶点属性在屏幕上做线性插值,得到的是屏幕空间属性,不等于透视变换前的属性。简单来说,如果直接把三维空间里均匀分布的纹理信息在投影后的屏幕坐标里做线性插值,会出现扭曲,解决方法是做透视校正插值。这是后面纹理映射那一讲的核心内容,这里我先埋个伏笔,方便你以后回头看。
4. Z-Buffer 深度缓冲:画家算法的失败与逐像素的博弈
4.1 画家算法为什么在真实场景里会翻车
在 Z-Buffer 出现之前,人们先想到的是画家算法。思路很像画油画:先把远处的三角形画上去,再用近处的三角形覆盖它。只要所有物体能排出一个严格的深度顺序,这个方法就能工作。
但真实场景里"严格深度顺序"根本不存在。想象两个三角形互相穿插:整个三角形 A 都在三角形 B 前面,还是 B 在 A 前面?答案是"视情况而定",因为它们在屏幕上的前后关系会随着位置变化。更麻烦的是循环遮挡:A 挡着 B 的一块,B 挡着 C 的一块,C 又挡着 A 的一块。这种场景在模型穿插、复杂地形、半透明物体交错时非常常见,画家算法彻底失效。
所以第六讲引入 Z-Buffer 的时候,我印象很深。它没有尝试去解决"谁在前面"这种全局排序问题,而是退了一步:反正一个像素最终只能显示一个颜色,那我就让每个像素各自记住当前最近的深度,新的三角形来了就跟旧的比一比,深度小的留下。全局排序复杂的要命,局部逐像素比较却简单可靠。
4.2 Z-Buffer 原理、伪代码、精度与深度冲突
Z-Buffer 的实现非常直白:每个像素准备一个深度缓存,初始值设成离相机无穷远;每画一个像素,先插值出深度,跟缓存里存的深度比较,小于缓存值就更新颜色和深度。
// depthBuffer 初始化为正无穷大 // frameBuffer 里每像素存的是颜色 float depth = interpolateDepth(alpha, beta, gamma); // 用重心坐标插值 if (depth < depthBuffer[pixelIndex]) { depthBuffer[pixelIndex] = depth; frameBuffer[pixelIndex] = interpolatedColor; }注意比较符号是"小于"而不是"小于等于"。如果两个三角形恰好深度完全一样,用小于等于会导致后画的三角形反复覆盖先画的,边缘容易出现闪烁。所以工程实现里通常约定深度完全相等时,谁先来谁留下。
Z-Buffer 的实现不难,但它的精度模型需要理解。实际图形 API 里的深度并不是从 near 到 far 线性分配的。因为透视除法,屏幕上的深度值跟 1/z 近似成正比,也就是说,离相机近的区域分到了大量深度精度,远处的深度精度被压缩得很少。结果就是:场景纵深跨度很大的时候,远处物体表面容易出现深度冲突,也就是俗称的"Z-fighting",表现为远处几何表面剧烈闪烁。
我在自己项目里遇到过:把 near 设为 0.01,far 设为 10000,远处的墙壁疯狂闪烁。后来把 near 调整到 1.0,far 缩小到 3000,闪烁立刻消失了。这个问题的原因是深度缓冲只有有限位数(通常是 24 位),没有给远处的"整数抖动"留足够余量。所以遇到 Z-fighting,第一反应不是换更大的 far,而是应该把 near 和 far 的比值控制在一个合理范围内,让有效深度范围更紧凑。
5. 锯齿:采样带来的"冒名顶替"伪影
5.1 混淆与锯齿:同一个信号的两种面孔
光栅化的本质,是从连续几何信息里取出离散像素点的值。这个"取"的动作在信号处理里叫采样。学过一点信号处理的都知道,采样频率低于信号本身的变化频率时,高频信息会被伪造成一个低频信号,这就是混叠。
锯齿只是混叠最明显的一种:三角形的斜边在屏幕上变成了一格一格的楼梯。更隐蔽的问题是小物体闪烁:一个很远的三角形栅栏,每根柱子比一个像素还窄,采样时可能这一帧采到柱子,下一帧采到空隙,渲染结果就会一闪一闪。还有摩尔纹:密集的重复纹理图案在远处扭曲成奇怪的波纹。这些都不是渲染 bug,而是采样欠了信息,数学上的必然结果。
理解这一点很重要,因为它决定了抗锯齿的思路。抗锯齿不是在"修 bug",而是设法恢复或补偿那些被采样丢掉的信息。不同的方案做的都是这件事,只是手段和成本不同。
5.2 MSAA / FXAA / TAA:三种不同的妥协
第六讲里最常被提到的抗锯齿方案是 MSAA,它也是大家写作业时最容易用上的一个。简单来说,MSAA 把一个像素分成若干个采样点,对每个采样点单独做覆盖测试和深度测试,得出这个像素里有多少个子采样点被三角形覆盖,再用覆盖率来决定颜色。比如 4x MSAA 就是每个像素分成 2x2 的采样点,覆盖率是 25%、50% 还是 100%,都会影响最终颜色。
很多人把 MSAA 理解成"把分辨率提高四倍再渲染",这是不对的。真正的 MSAA 在高分辨率下只多做了覆盖测试和深度测试,片元着色器仍然只在每个像素中心执行一次。这也是 MSAA 比 SSAA 便宜很多的原因。一个直观的类比是:MSAA 花了很多钱去"数清楚"边缘区域到底被盖住多少,但填充大面积颜色时仍然按整像素来的。
表格里我对比了几个主流的抗锯齿思路:
| 方案 | 核心思路 | 主要代价 | 典型问题 |
|---|---|---|---|
| MSAA | 像素内多个采样点,覆盖与深度测试细分,颜色按覆盖率混合 | 采样点多了,深度/覆盖测试成本上升 | 对着色器生成的高频细节无效 |
| FXAA | 后期在边缘像素上做模糊,属于图像后处理 | 很便宜,不改变渲染流程 | 细节纹理会被轻微柔化 |
| TAA | 每帧采样位置略微偏移,把过往帧的信息累积复原 | 需要运动向量,处理新露出的区域 | 移动物体可能产生拖影 |
我在学习第六讲时对 MSAA 的预期是"解决一切锯齿",实际测试后发现:它解决的是几何边缘的锯齿,纹理本身引起的摩尔纹它没法解决。遇到纹理闪烁,要么提高纹理过滤质量,要么在上层改用 FXAA 或 TAA。每种方案都在性能和画质之间找平衡点,没有完全免费的抗锯齿。
6. 第六讲学完,动手验证什么才算真正掌握
6.1 一份最小光栅化器的自测清单
光看课件和文章,很容易有一种"我都懂了"的错觉。我的经验是,学完这一讲后花一个晚上写一个最小光栅化器,远远比多看两遍课程有效。自测的题目我都列在下面,能不看笔记回答出来,才算真掌握:
- 能够不用查资料写出视口变换矩阵,并说出为什么覆盖测试要用像素中心 (x+0.5, y+0.5),而不是像素索引。
- 知道同侧法判断三角形内外时,叉积结果的三种情况分别代表什么,为什么允许等于 0。
- 能推导重心坐标公式,并解释 α、β、γ 的几何意义。
- 能说清 Z-Buffer 为什么要用"小于"而不是"小于等于",以及深度精度为什么不是远近线性分配的。
- 知道 MSAA 和"把分辨率提高 4 倍"的本质区别在哪里。
这些问题的答案其实都在前面几节里,但你合上书能顺畅讲出来,和看着书点头,是两码事。
我自己当时写完最小光栅化器后,做了几组对比实验:逆时针和顺时针的顶点顺序分别渲染,看三角形内外判断结果是否一致;把深度初始值设成 0,看画面会不会一片空白;把覆盖测试从 (x+0.5, y+0.5) 换成 (x, y),看边缘缺口长什么样。这些操作把课程里每个公式都变成了肌肉记忆。
6.2 常见误区与排错经验
这一讲代码量不大,但排错往往会花掉很多时间。我总结几个最常见的坑,按出现频率排了一下:
第一个坑是深度初始值。把 Z-Buffer 初始化成 0,而不是一个很大的正数,结果所有像素都比缓存值大,任何三角形都不会被写入,屏幕上什么也不显示。排查这个问题时可以打印深度缓存的最大值和最小值,马上就能发现。
第二个坑是覆盖测试采样点选错。用 (i,j) 而不是 (i+0.5, j+0.5),画面会出现细缝,尤其是在三角形边缘。这个坑的特点是"看起来像浮点误差,实际上根本不是",所以排查难度不小。
第三个坑是忘记处理 NDC 坐标。有些人直接把 [-1,1] 里的坐标当成屏幕坐标用,画面会缩在左上角或者直接穿模。解决办法是明确每一步坐标在哪个空间,不要混着算。
第四个坑是重心坐标的分母符号问题。三角形顶点顺序一变,分母可能变负,插值结果全部乱套。处理办法是判断符号后统一符号,或者干脆按面积绝对值做法,虽然浪费一点性能,但不容易错。
第五个坑是窗口 resize 之后,深度缓存和颜色缓存没有同步清理。旧缓存里的值会让新画面出现奇怪的残留和闪烁。这在作业里不常见,但做小型交互应用时我踩过。
还有一条经验:不要在不知道结论正确性的时候急着优化。我刚开始写光栅化器时,总想着用增量算法、查表法减少运算,结果代码越来越难读,出 bug 根本查不出来。后来先把朴素版本跑通,再对着性能分析工具决定要不要优化,效率高得多。
我个人学完这一讲最大的体会是:光栅化看起来是把几何变成像素的"笨办法",但所有后续话题——纹理映射、着色、抗锯齿、透明排序——都是在这个底层上叠起来的。第六讲像是一个地基,看不出来有多炫,但后面的每一讲都会回头用到它。如果哪天你发现后面某个概念怎么也理解不了,大概率可以回到光栅化这一章,找一个被忽略的细节补一下。