☰
图形学光栅化核心:边缘函数、重心坐标与透视修正插值
2026/9/30 5:19:43 网站建设 项目流程

总有人把"图形学光栅化"想象成一堆吓人的矩阵和不可名状的数学黑箱,但说白了,光栅化要回答的问题只有一个:一个三角形在屏幕上是哪些像素?这个问题听起来像小学奥数,做起来却需要完整的公式推导链——从视口变换、边缘函数到重心坐标,再到透视修正插值。这篇文章我把整条链路拆开,从暴力穷举讲起,一路推导到可以直接写在代码里的公式,并且附上实际工程里必须注意的坑。适合正在学图形学、准备写软渲染器,或者只是想知道深度缓冲背后到底是什么的同学。

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 $$

这个形式比面积法好在哪?第一,它只用减法、乘法,没有开方,没有除法(除法放到后面重心坐标再说),非常适合硬件和软件渲染器。第二,它天然带有"边界"的语义:$f=0$ 表示点恰好在边上,后面会引出共享边规则。第三,它是线性的,意味着我们可以用增量方式高效遍历。

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, i+1] × [j, j+1] 的一个方块,而是我们在数学上把它当成像素中心点 (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::vector<Vec4> pixels; // RGBA std::vector<float> 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,都会顺很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询