总有人把图形学光栅化想象成一堆吓人的矩阵和不可名状的数学黑箱但说白了光栅化要回答的问题只有一个一个三角形在屏幕上是哪些像素这个问题听起来像小学奥数做起来却需要完整的公式推导链——从视口变换、边缘函数到重心坐标再到透视修正插值。这篇文章我把整条链路拆开从暴力穷举讲起一路推导到可以直接写在代码里的公式并且附上实际工程里必须注意的坑。适合正在学图形学、准备写软渲染器或者只是想知道深度缓冲背后到底是什么的同学。1. 先撕掉神秘感光栅化就是带规则的暴力穷举1.1 从暴力枚举开始判断一个像素是否被三角形覆盖如果你第一次接触光栅化你可能会说这有什么难的把所有像素遍历一遍判断哪些在三角形内然后写上颜色。其实你说对了。光栅化的起点就是暴力枚举后面每一步优化都来自一个数学构造而不是某种天降神迹。第一步是定义三角形。设屏幕上有三个顶点 $V_0(x_0, y_0)$、$V_1(x_1, y_1)$、$V_2(x_2, y_2)$。我们想知道像素点 $P(x, y)$ 在不在三角形内部。初等几何给我们的第一反应是面积法把 $P$ 和三条边连起来形成三个小三角形如果三个小三角形面积之和等于原三角形面积且每块面积非负那点就在内部。这个方法在数学上是完全正确的但实现起来不太优雅。你得反复算面积海伦公式或者叉积还要处理浮点误差尤其是点落在边上时面积之和的等号会变得非常微妙。而且它返回的只是一个布尔值没法顺便告诉你在插值时需要的权重信息。所以实际光栅化几乎没人这么做。1.2 精简判断工具向量叉积取代角度和距离更聪明的做法是放弃面积这个直观但沉重的工具改用方向和符号。三角形的三条边如果给定一个绕序——通常约定逆时针——那么对于任意点 $P$我们只需要判断 $P$ 是否始终在每条有向边的同一侧。判断点在直线哪一侧用到的是叉积的几何意义。向量叉积的模等于两个向量围成的平行四边形面积更重要的是它的符号携带方向信息。二维里我们常说的叉积其实就是取两个向量 $\mathbf{a}(x_a, y_a)$、$\mathbf{b}(x_b, y_b)$ 的 z 分量$$ \mathbf{a} \times \mathbf{b} x_a y_b - y_a x_b $$这个值如果为正说明 $\mathbf{b}$ 在 $\mathbf{a}$ 的逆时针方向为负则相反等于零说明共线。这一条性质就是整个光栅化内外判断的地基。别小看这个看似简单的符号判断后面所有的边缘函数、重心坐标本质上都是在翻来覆去地使用同一个式子。2. 边缘函数把点在三角形内部变成三次叉积符号判断2.1 边缘函数公式的推导现在我把上面的叉积思想写成光栅化里最常见的边缘函数Edge Function。对三角形的一条边 $V_i \rightarrow V_j$定义一个函数$$ f_{ij}(x, y) (x - x_i)(y_j - y_i) - (y - y_i)(x_j - x_i) $$这个函数从哪里来的它就是向量 $(x - x_i, y - y_i)$ 与边向量 $(x_j - x_i, y_j - y_i)$ 的叉积。拿边 $V_0 \rightarrow V_1$ 举例$$ \mathbf{E} V_1 - V_0 (x_1 - x_0,\ y_1 - y_0) $$ $$ \mathbf{P} - V_0 (x - x_0,\ y - y_0) $$$$ f_{01}(x, y) (\mathbf{P} - V_0) \times \mathbf{E} (x - x_0)(y_1 - y_0) - (y - y_0)(x_1 - x_0) $$如果三角形顶点是逆时针排列那么对于三角形内部的点$f_{01} 0$。因为内部点始终在每条有向边的左侧叉积为正。如果三角形是顺时针排列符号会全部反过来但这并不影响最终判断——只要三个符号一致即可。于是完整的判内条件就是$$ f_{01}(x, y) 0 \quad \text{且} \quad f_{12}(x, y) 0 \quad \text{且} \quad f_{20}(x, y) 0 $$这个形式比面积法好在哪第一它只用减法、乘法没有开方没有除法除法放到后面重心坐标再说非常适合硬件和软件渲染器。第二它天然带有边界的语义$f0$ 表示点恰好在边上后面会引出共享边规则。第三它是线性的意味着我们可以用增量方式高效遍历。2.2 包围盒、像素中心和共享边规则有了边缘函数是否就可以直接全屏扫描了当然可以但没人那么干。你只需要遍历三角形的包围盒Bounding Box而不是整个屏幕。既然边缘函数是线性函数三角形的包围盒计算也非常直接对三个顶点的 x、y 分别取最小值和最大值。包围盒计算公式$$ x_{\min} \left\lfloor \min(x_0, x_1, x_2) \right\rfloor, \quad x_{\max} \left\lceil \max(x_0, x_1, x_2) \right\rceil $$ $$ y_{\min} \left\lfloor \min(y_0, y_1, y_2) \right\rfloor, \quad y_{\max} \left\lceil \max(y_0, y_1, y_2) \right\rceil $$注意这里用了向下取整和向上取整原因很简单像素坐标是整数格点但三角形顶点可能是小数视口变换后的坐标通常带小数。取整的目的是保证一个像素只要有任何可能被三角形覆盖就不会被漏掉。另一个容易被忽略的细节是像素中心采样。屏幕上的像素i, j并不是占据 [i, i1] × [j, j1] 的一个方块而是我们在数学上把它当成像素中心点 (i 0.5, j 0.5) 来采样。所以真正代入边缘函数的点是$$ x i 0.5 \quad y j 0.5 $$采样点设计在像素中心是为了避免像素边界上的模糊归属问题。这一点在做抗锯齿时会反复用到——MSAA 本质上就是在一个像素内放置多个偏移采样点然后对每个采样点独立做边缘测试。共享边规则Top-Left Rule也由此而来。如果两个三角形共享一条边并且我们用的是 $0$ 作为内部判断那么恰好落在边上的像素会被两个三角形同时判定为内部就会出现重复写入。Direct3D 和 OpenGL 的解决方案是规定只让上边和左边的边上的点算作内部。数学上就是把某些边的判断条件从 $ 0$ 改成 $\ge 0$具体哪条边要改取决于边的方向。因为边缘函数是线性的这条规则实现起来非常便宜但却是渲染管线里不可或缺的一环。3. 重心坐标内外判断和属性插值共用的一组底牌3.1 用面积比定义重心坐标边缘函数解决了在不在里面的问题但光栅化还要回答在里面时属性是多少。比如说三角形三个顶点的颜色分别是红、绿、蓝中间某个像素应该是什么颜色我们要插值。插值的数学工具就是重心坐标。重心坐标的定义很直观三角形 $ABC$ 内任意一点 $P$可以表示为$$ P \lambda_0 A \lambda_1 B \lambda_2 C $$其中 $\lambda_0 \lambda_1 \lambda_2 1$且 $\lambda_i \ge 0$。这里 $\lambda$ 的几何意义是面积比$$ \lambda_0 \frac{S_{\triangle PBC}}{S_{\triangle ABC}}, \quad \lambda_1 \frac{S_{\triangle PAC}}{S_{\triangle ABC}}, \quad \lambda_2 \frac{S_{\triangle PAB}}{S_{\triangle ABC}} $$从面积比的视角看重心坐标和第一节面积法其实是一家人但它更进一步不但告诉你点是否在三角形内所有 $\lambda$ 非负还告诉你在各个顶点之间如何分配权重。正因为 $\lambda$ 是线性变化的顶点属性用同一套系数插值颜色、法线、纹理坐标就都能连续过渡。3.2 从方程组解出重心坐标的显式表达式面积比适合理解但真要写代码我们需要显式公式。把上面的向量等式改写一下。令 $A$ 为基准点$$ P A u(B - A) v(C - A) $$这里 $u \lambda_1$$v \lambda_2$$\lambda_0 1 - u - v$。把它写成二维向量方程$$ P - A u(B - A) v(C - A) $$这是一个二元线性方程组。为了解 $u$、$v$利用叉积。设$$ \mathbf{t} P - A, \quad \mathbf{e}_1 B - A, \quad \mathbf{e}_2 C - A $$于是 $\mathbf{t} u \mathbf{e}_1 v \mathbf{e}_2$。两边同时叉乘 $\mathbf{e}_2$$$ \mathbf{t} \times \mathbf{e}_2 u(\mathbf{e}_1 \times \mathbf{e}_2) v(\mathbf{e}_2 \times \mathbf{e}_2) $$由于 $v$ 项叉乘自己为零$$ u \frac{\mathbf{t} \times \mathbf{e}_2}{\mathbf{e}_1 \times \mathbf{e}_2} $$同理两边同时叉乘 $\mathbf{e}_1$$$ v \frac{\mathbf{e}_1 \times \mathbf{t}}{\mathbf{e}_1 \times \mathbf{e}_2} $$其中所有 $\times$ 都是二维叉积即 $x_a y_b - y_a x_b$。分母 $\mathbf{e}_1 \times \mathbf{e}_2$ 正好是三角形面积的 2 倍它是个常量只需要在整个三角形光栅化前算一次。这就是为什么我说边缘函数和重心坐标是同一个底牌——分母叉积本身就有面积的含义分子也在做完全相同的叉积运算。在实际代码里我们可以顺便利用重心坐标做内外判断如果分母采取正数约定三角形为逆时针那么 $u 0$、$v 0$、$u v 1$ 就是点在三角形内的充要条件。你会发现它和边缘函数判断是严格等价的只是中途多算出了两个插值系数。不少软件渲染器干脆直接用重心坐标做完整流程省掉一次重复的边缘计算。4. 透视修正插值为什么不能直接屏幕线性插值4.1 一个直观的错误示范假设我在三维场景里有一条很长的线段两个端点的深度分别是 $z_a 1$ 和 $z_b 4$颜色从纯黑到纯白。按常识线段中点 $t 0.5$ 处颜色应该是灰色0.5。现在把这个线段投影到屏幕。由于近大远小屏幕上的中点并不对应线段的世界中点。如果你天真地在屏幕空间做线性插值即在屏幕上取一个像素按它在两个端点屏幕位置之间的比例插值颜色会出现什么靠近远端的屏幕区域会被拉伸颜色的变化速率在屏幕上不再均匀画面就会呈现一种奇怪的透视错误。最经典的案例是纹理拉伸的地面比如格子在远处越来越密如果直接线性插值纹理坐标格子会扭曲。原因一句话屏幕空间的线性不等于三维空间的线性。4.2 证明1/z 在屏幕空间线性变化要给出正确的插值公式得先弄清楚透视投影对坐标做了什么。考虑裁剪空间里的两个端点 $C_a$、$C_b$其中每个点的坐标为 $(x, y, z, w)$。三维空间中的线性组合点可以写成$$ C(t) (1 - t) C_a t C_b $$透视除法把裁剪坐标变成 NDC 坐标$x_{screen} x_c / w_c$。以 x 方向为例屏幕坐标$$ s(t) \frac{x_c(t)} {w_c(t)} \frac{(1-t)x_a t x_b}{(1-t)w_a t w_b} $$而 NDC 中 $s_a x_a / w_a$$s_b x_b / w_b$。屏幕上看起来夹在中间的比例系数不再是 $t$而是一个扭曲后的系数。那什么量在屏幕空间还是线性的答案是 $1/w$。来看推导。设屏幕参数为 $\alpha$它表示屏幕位置在 $s_a$ 和 $s_b$ 之间的比例。代入上面的 $s(t)$ 反解 $t$可以把 $\alpha$ 表达成 $t$ 的函数。经过整理可以得到$$ 1 - \alpha \frac{(1 - t) w_a}{(1-t)w_a t w_b}, \quad \alpha \frac{t w_b}{(1-t)w_a t w_b} $$于是任意屏幕位置 $\alpha$ 处$$ \frac{1}{w_c(t)} \frac{1}{(1-t)w_a t w_b} \frac{1 - \alpha}{w_a} \frac{\alpha}{w_b} $$这样就证明了 $1/w$ 确实在屏幕上线性变化。在 OpenGL 风格相机中$w -z_{view}$所以工程上大家更常说深度倒数在屏幕空间是线性的。这个结论为什么重要因为顶点属性颜色、UV、法线首先是在三维空间定义的我们要从三维空间的 $t$ 推回屏幕空间的 $\alpha$再反解 $t$而这个反解过程恰好需要用到 $1/w$ 线性这个性质。4.3 属性插值的正确公式与手算示例假设顶点属性 $f$ 在三维空间随 $t$ 线性变化$f(t) (1-t)f_a t f_b$。我们希望得到屏幕参数 $\alpha$ 处的 $f$。前面的反解告诉我们$$ t \frac{\alpha w_a}{(1 - \alpha) w_b \alpha w_a} $$代入 $f(t)$做一点代数整理分子分母同时除以 $w_a w_b$就得到了最终的透视修正插值公式$$ f(\alpha) \frac{(1 - \alpha) \frac{f_a}{w_a} \alpha \frac{f_b}{w_b}} {(1 - \alpha) \frac{1}{w_a} \alpha \frac{1}{w_b}} $$也就是说要插值属性先对 $f/w$ 做屏幕空间线性插值再除以 $1/w$ 的屏幕空间线性插值。用一个极端例子验证。设 $w_a 1$$w_b 4$$f_a 0$$f_b 1$屏幕取中 $\alpha 0.5$。直接线性插值$$ 0.5 \times 0 0.5 \times 1 0.5 $$用透视修正公式$$ \frac{0.5 \times \frac{0}{1} 0.5 \times \frac{1}{4}} {0.5 \times \frac{1}{1} 0.5 \times \frac{1}{4}} \frac{0.125}{0.625} 0.2 $$两个结果差了 0.3在画大三角形时这种偏差是肉眼可见的。这就是为什么现在的 GPU 管线里顶点属性插值默认都是透视正确的。深度缓冲区Z-Buffer也一样。如果深度直接存 NDC 坐标再用屏幕线性方式插值物体表面会出现深度偏差进而导致物体穿插时渲染顺序错误、阴影闪烁。标准做法是保存透视修正后的深度这样即使三角形跨越很大深度范围深度值也足够稳定。5. 写一个可运行的光栅化核心坐标、源码、采坑笔记5.1 最小实现边缘测试 深度 颜色公式推导得差不多了我给一个极简但能跑的核心实现。这个代码只做一件事把一个三角形画到 framebuffer 上考虑深度测试颜色用重心坐标插值。我会把前面的公式直接翻译成代码。struct Vec2 { float x, y; }; struct Vertex { Vec2 ndc; float z_view; Vec3 color; }; struct Framebuffer { int width, height; std::vectorVec4 pixels; // RGBA std::vectorfloat depth; }; // 透视除法从 NDC 到屏幕坐标 Vec2 toScreenSpace(Vec2 ndc, int width, int height) { float sx (ndc.x * 0.5f 0.5f) * width; float sy (ndc.y * 0.5f 0.5f) * height; return { sx, sy }; } // 求重心坐标 u, v bool barycentric(Vec2 a, Vec2 b, Vec2 c, Vec2 p, float* u, float* v) { Vec2 e1 { b.x - a.x, b.y - a.y }; Vec2 e2 { c.x - a.x, c.y - a.y }; Vec2 t { p.x - a.x, p.y - a.y }; float denom e1.x * e2.y - e1.y * e2.x; // e1 x e2 float u_num t.x * e2.y - t.y * e2.x; // t x e2 float v_num e1.x * t.y - e1.y * t.x; // e1 x t *u u_num / denom; *v v_num / denom; return (*u 0.f *v 0.f *u *v 1.f); } void drawTriangle(Framebuffer fb, Vertex v0, Vertex v1, Vertex v2) { Vec2 s0 toScreenSpace(v0.ndc, fb.width, fb.height); Vec2 s1 toScreenSpace(v1.ndc, fb.width, fb.height); Vec2 s2 toScreenSpace(v2.ndc, fb.width, fb.height); int xmin std::max(0, (int)std::floor(std::min({s0.x, s1.x, s2.x}))); int xmax std::min(fb.width - 1, (int)std::ceil (std::max({s0.x, s1.x, s2.x}))); int ymin std::max(0, (int)std::floor(std::min({s0.y, s1.y, s2.y}))); int ymax std::min(fb.height - 1, (int)std::ceil (std::max({s0.y, s1.y, s2.y}))); for (int ty ymin; ty ymax; ty) { for (int tx xmin; tx xmax; tx) { Vec2 p { tx 0.5f, ty 0.5f }; // 像素中心采样 float u, v; if (!barycentric(s0, s1, s2, p, u, v)) continue; // w 是 -z_view这里直接用手头的视图深度代替 float w0 v0.z_view, w1 v1.z_view, w2 v2.z_view; float depth 1.f / (u * (1.f / w0) v * (1.f / w1) (1.f - u - v) * (1.f / w2)); if (depth fb.depth[ty * fb.width tx]) continue; fb.depth[ty * fb.width tx] depth; Vec3 color v0.color * (1 - u - v) v1.color * u v2.color * v; int idx ty * fb.width tx; fb.pixels[idx] Vec4(color.r, color.g, color.b, 1.f); } } }里面有个关键点深度是通过 $1/z$ 的线性插值算出来的所以代码先把三个顶点的 $1/z$ 按重心坐标混合再取倒数。这就是第 4 章推导的直接产物很多人写软渲染器时最常漏掉的就是这一步。5.2 五个必踩的坑第一坐标系的 Y 轴方向。OpenGL 的 NDC 里 Y 轴向上屏幕坐标 Y 轴向下。转换为屏幕坐标时一定记得把 Y 翻转$sy (1 - ndc.y) * 0.5 * height$ 或者等价形式否则三角形上下颠倒绕序也会反转进而影响到背面剔除。很多渲染错误查半天最后都是这里错了。第二浮点 epsilon。边缘函数在像素恰好落在三角形边上的时候浮点误差可能导致符号抖动。处理方法是设置一个非常小的 epsilon比如 $1e-7$把绝对值小于 epsilon 的判定为在边上再结合 top-left 规则决定是否填充。别小看这一点否则渲染 CJK 字形或 CAD 线框时共享边会出现缝或叠影。第三负 w 的规避。内容安全与正确性之外任何真实的透视矩阵都可能在极端 FOV 和物体穿过裁剪平面时产生负 w导致透视除法出现符号反转。正规渲染管线会在裁剪阶段把越过近裁剪面的三角形裁剪掉软渲染器里至少要做一步所有顶点 w 0 才绘制的过滤。第四用增量优化但别牺牲清晰度。很多人为了性能把边缘函数改成增量版本——沿着扫描线每走一步更新 $f$而不是每次重新算。增量优化确实好但前提是你对线性函数的理解足够清楚。否则一旦包围盒边界或者像素中心偏移写错三角形会歪得非常隐蔽。我的建议是先写出朴素版本保证正确再加增量优化并用数学断言对比。第五深度精度。Z-Buffer 深度精度在近处高、远处低这是透视映射本身的特性不是 bug。但如果你的场景里出现远距离物体互相闪烁不要急着调深度位数先检查是不是没有做透视修正插值。很多时候问题就出在把 NDC 深度直接拿来线性插值。这套公式链看着长真正落地下来其实就是一个循环、三个叉积、一套透视修正。但如果每一步都知其所以然后面再看硬件管线文档、写 shader 或者调渲染 bug都会顺很多。