图形学自学路上很多人前面几讲还能跟住真正开始掉队通常就是从光栅化这一步开始的。前面把三维物体一路变换到 [-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, i1] x [j, j1]中心点在 (i0.5, j0.5)。所以后面做覆盖测试时我们要去判断的不是像素索引 (i,j)而是像素中心 (i0.5, j0.5) 有没有落在三角形内部。这个问题在课程作业里反复坑人有人直接用 (i,j) 去测最后三角形边缘出现一条条细缝或者异常缺口看起来像浮点误差其实是采样点选错了。0.5 这个偏移看起来小但它是栅格化最底层的一层约定值得多花十分钟想明白。另外还有一个细节变换之后的三角形如果恰好落在 xwidth 或者 yheight 这种边界上会存在到底算哪个像素的歧义。图形硬件里通常会有一套完整的 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.01far 设为 10000远处的墙壁疯狂闪烁。后来把 near 调整到 1.0far 缩小到 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 一份最小光栅化器的自测清单光看课件和文章很容易有一种我都懂了的错觉。我的经验是学完这一讲后花一个晚上写一个最小光栅化器远远比多看两遍课程有效。自测的题目我都列在下面能不看笔记回答出来才算真掌握能够不用查资料写出视口变换矩阵并说出为什么覆盖测试要用像素中心 (x0.5, y0.5)而不是像素索引。知道同侧法判断三角形内外时叉积结果的三种情况分别代表什么为什么允许等于 0。能推导重心坐标公式并解释 α、β、γ 的几何意义。能说清 Z-Buffer 为什么要用小于而不是小于等于以及深度精度为什么不是远近线性分配的。知道 MSAA 和把分辨率提高 4 倍的本质区别在哪里。这些问题的答案其实都在前面几节里但你合上书能顺畅讲出来和看着书点头是两码事。我自己当时写完最小光栅化器后做了几组对比实验逆时针和顺时针的顶点顺序分别渲染看三角形内外判断结果是否一致把深度初始值设成 0看画面会不会一片空白把覆盖测试从 (x0.5, y0.5) 换成 (x, y)看边缘缺口长什么样。这些操作把课程里每个公式都变成了肌肉记忆。6.2 常见误区与排错经验这一讲代码量不大但排错往往会花掉很多时间。我总结几个最常见的坑按出现频率排了一下第一个坑是深度初始值。把 Z-Buffer 初始化成 0而不是一个很大的正数结果所有像素都比缓存值大任何三角形都不会被写入屏幕上什么也不显示。排查这个问题时可以打印深度缓存的最大值和最小值马上就能发现。第二个坑是覆盖测试采样点选错。用 (i,j) 而不是 (i0.5, j0.5)画面会出现细缝尤其是在三角形边缘。这个坑的特点是看起来像浮点误差实际上根本不是所以排查难度不小。第三个坑是忘记处理 NDC 坐标。有些人直接把 [-1,1] 里的坐标当成屏幕坐标用画面会缩在左上角或者直接穿模。解决办法是明确每一步坐标在哪个空间不要混着算。第四个坑是重心坐标的分母符号问题。三角形顶点顺序一变分母可能变负插值结果全部乱套。处理办法是判断符号后统一符号或者干脆按面积绝对值做法虽然浪费一点性能但不容易错。第五个坑是窗口 resize 之后深度缓存和颜色缓存没有同步清理。旧缓存里的值会让新画面出现奇怪的残留和闪烁。这在作业里不常见但做小型交互应用时我踩过。还有一条经验不要在不知道结论正确性的时候急着优化。我刚开始写光栅化器时总想着用增量算法、查表法减少运算结果代码越来越难读出 bug 根本查不出来。后来先把朴素版本跑通再对着性能分析工具决定要不要优化效率高得多。我个人学完这一讲最大的体会是光栅化看起来是把几何变成像素的笨办法但所有后续话题——纹理映射、着色、抗锯齿、透明排序——都是在这个底层上叠起来的。第六讲像是一个地基看不出来有多炫但后面的每一讲都会回头用到它。如果哪天你发现后面某个概念怎么也理解不了大概率可以回到光栅化这一章找一个被忽略的细节补一下。