03. 光栅化:三角形怎么变成像素
03. 光栅化:三角形怎么变成像素
本篇位置:模块二 · 第 3 篇 | 前置:顶点 | 预计阅读:30 分钟
你已经在用它了
SurfaceFlinger 里有一个类,专门回答"哪些像素被盖住了"这个问题(libs/ui/include/ui/Region.h):
class Region : public LightFlattenable<Region> {
public:
bool contains(int x, int y) const;
Region& orSelf(const Region& rhs);
Region& andSelf(const Region& rhs);
Region& subtractSelf(const Region& rhs);
// ...
};合成时它是这么用的(CompositionEngine/src/Output.cpp):
Region visibleRegion;
visibleRegion.set(Rect(layerFEState->outsetRectForShadow(geomRect.toFloatRect())));
shadowRegion = visibleRegion.subtract(geomRect);
if (visibleRegion.isEmpty()) {
// 完全被挡住了,这个图层不用画
return;
}Region 内部就是一组矩形。图层是矩形,屏幕是矩形,遮挡关系用矩形的交、并、差算出来——结果是精确的,没有任何近似。
三个问题:
Region用一组矩形就精确回答了"盖住哪些像素"。GPU 面对一个任意角度的三角形,同一个问题怎么答? 还能用这套集合运算吗?contains(x, y)是遍历矩形列表判断。一帧几百万个像素,GPU 也这么一个个判?- 三角形只有三个角上有 UV,中间几千个像素的 UV 是从哪来的?
学完你会什么
- 说清为什么矩形的覆盖能用集合运算算,三角形不能。
- 手算一次边函数,说清它的符号和绝对值各代表什么。
- 说清光栅化怎么用三次边函数判定覆盖,以及为什么这套算法特别适合做成硬件。
- 说清顶点上的数据是怎么变成每个像素一份的,以及为什么必须做透视校正。
- 说清 GPU 为什么以 2×2 为单位着色,这带来了什么代价和什么好处。
1. 同一个问题,两种答案
Region 之所以能精确,前提是它处理的形状全是轴对齐矩形。
两个轴对齐矩形求交,结果还是一个轴对齐矩形;求差,结果是最多 4 个轴对齐矩形。这个集合在这些运算下是封闭的,所以永远能用有限个矩形精确表示,永远不会退化成"一个像素被盖住了 37%"这种情况。
三角形一进来,这套就崩了:
| 轴对齐矩形(你的世界) | 任意三角形(GPU 的世界) | |
|---|---|---|
| 边的方向 | 只有水平和垂直 | 任意角度 |
| 两个形状求交 | 还是矩形,集合封闭 | 可能是任意多边形 |
| 能不能精确表示覆盖 | 能,一组矩形 | 不能,边界像素是部分覆盖 |
| 判定一个像素 | 查它在不在某个矩形里 | 得算 |
所以问题 1 的答案是:换了个完全不同的解法。GPU 不再试图"描述"覆盖区域,而是逐个像素去问"你在里面吗"。
这听起来蠢得多——Region 一次集合运算搞定的事,GPU 要问几万次。但它有个决定性的好处:每次问都是独立的,可以几千个像素同时问。把一道需要全局推理的题,换成了几万道彼此无关的简单判断题——这正是硬件擅长的形状。
2. 边函数:一次叉积,判一条边
怎么问"这个像素在三角形里吗"?
先化简:一个三角形是三条边围出来的。如果一个点在三条边的"同一侧",它就在三角形内部。于是问题变成三次"点在这条边的哪一侧"。
这个判断有个极其便宜的算法。对一条从 指向 的有向边,定义边函数(edge function):
这就是 M1 第 1 篇讲的二维叉积——向量 和 的叉积。
图:一条有向边把整个平面分成两半。符号告诉你点在哪一侧。图里标了两个像素中心:一个是 −0.9(刚好在左侧,离边非常近),一个是 +61.6(在右侧,离得远)。
这个函数有两个都有用的性质:
- 符号——告诉你点在边的哪一侧。这是覆盖判定要的。
- 绝对值——正比于 的面积(准确说是面积的两倍)。这是第 4 节要用的。
一次计算,两个用途。上一篇讲背面剔除时说过同样的话——那里用的也是这个叉积,只不过输入是三角形自己的三个顶点。
为什么说它便宜?展开看这个式子:两次减法、两次乘法、一次减法。没有除法,没有开方,没有分支。这正是能做成流水线电路的运算形状——除法和开方在硬件上要贵一到两个数量级。
还有个更妙的性质: 对 是线性的。从一个像素挪到右边相邻像素, 加 1, 的变化量是固定的 ——和当前位置无关。所以硬件扫描一行像素时,根本不用每次重算,只要不断做加法。这叫增量计算,是光栅化硬件真正的加速点。
3. 三条边同号 = 在里面
有了边函数,覆盖判定就是三次求值加一次符号比较:
(这里假设三个顶点按"内部为正"的绕序排列。绕序反过来的话三个都是负——见上一篇。)
还有一步不能少:不能真的扫全屏。
图左:先算三角形的包围盒——三个顶点的 x/y 极值。图里只需要检查 90 个像素,而不是全屏 144 个(62%)。图右:包围盒内每个像素中心算三次边函数,三个都 ≥ 0 的有 34 个。
包围盒这一步在真实场景里的收益远大于图里:一个三角形通常只占屏幕的极小一块,包围盒能把候选像素从几百万砍到几百。
这就回答了问题 2:GPU 确实是"一个个判",但它做了两件事让这件事可行——先用包围盒把范围砍到极小,然后成千上万个像素并行判。而且每次判定只是几次乘加,没有除法。
真实硬件还会再分一层:先以 8×8 或 16×16 的块为单位,整块判断"完全在内 / 完全在外 / 部分相交"。完全在内的块跳过逐像素测试,完全在外的直接丢,只有部分相交的块才逐像素算。这叫分层光栅化(hierarchical rasterization),思路和包围盒是一样的——尽量用一次判断否掉一大片。
4. 顶点上的数据,怎么变成每个像素一份
现在回答问题 3。
三角形的三个顶点各带一份 UV,而它内部有几十上百个像素,每个都需要自己的 UV。光栅化顺手把这件事也办了,用的还是刚才那三个边函数。
上一节说过, 的绝对值正比于一个小三角形的面积。把点 和三个顶点连起来,三角形被切成三块,每块的面积占比就是重心坐标 (恒有 )。而这三块面积,正是刚才为了判覆盖已经算出来的那三个边函数值。
又是"一次计算,多个用途":三次边函数求值,同时给出了"在不在里面"和"怎么加权"。这就是为什么真实的光栅化实现里,覆盖测试和属性插值总是写在一起。
重心坐标的定义和推导 M1 第 6 篇已经讲透了,这里只用结论:
UV、法线、顶点色,全都用同一组 加权。
但有个坑必须记住:透视投影下,直接用屏幕空间的 插值是错的。
原因是屏幕上等距的两点,在三维空间里并不等距——近处被拉开、远处被压缩。正确做法是透视校正插值:先把属性除以 ,插值完再除以插值后的 。现代 GPU 默认就这么做,你在 GLSL 里写 noperspective 修饰符时关掉的正是它。
M1 第 6 篇末尾那个提示框讲的就是这件事,这里只补一句它错起来什么样:一面贴着棋盘格纹理、斜着朝向你的地板,如果关掉透视校正,远处的格子会明显变形、格线会弯——因为纹理坐标在屏幕上被均匀铺开了,而它本该在远处挤在一起。
5. GPU 其实以 2×2 为单位着色
最后一件事,它解释了很多性能现象。
片段着色器从来不是一个像素一个像素跑的,而是以 2×2 的小方块(quad)为单位。
原因是 dFdx / dFdy 这类偏导数函数——它们要回答"相邻像素之间这个值变了多少"。要算这个,就必须同时有相邻像素的值。所以硬件干脆规定:着色以 2×2 为一组,组内四个像素同步执行同一条指令,需要偏导数时直接从邻居那里取。
这个机制是纹理 mipmap 级别选择的基础(第 4 篇会用到)——GPU 正是靠"相邻像素的 UV 差了多少"来判断该读哪一级。
代价是:三角形边缘上会产生被浪费的像素。
图:深色是真正在三角形内的 34 个像素,浅色是为了凑齐 2×2 被迫一起着色、但结果会被丢弃的 18 个。实际着色 52 个,浪费 35%。
那些浅色像素叫 helper 像素——它们跑了完整的片段着色器,算完的结果直接扔掉,只为了让同组的真实像素能算出偏导数。
这条给出了一个重要的性能结论:三角形越小,浪费比例越高。一个只覆盖 2 个像素的三角形,仍然要着色一整个 2×2 quad,浪费 50%。所以过度细分的模型在移动端是实打实的性能杀手——不是因为顶点变多了(顶点很便宜,上一篇讲过),而是因为大量小三角形把着色开销放大了好几倍。
⚠️ 别搞混
Region 的"覆盖"和光栅化的"覆盖",问的是同一个问题,但不在一个层级:
Region | 光栅化 | |
|---|---|---|
| 处理的形状 | 只有轴对齐矩形 | 任意三角形 |
| 怎么得到答案 | 集合运算(交、并、差) | 逐像素算边函数 |
| 结果 | 精确,一组矩形 | 逐像素的是/否;边界像素只能取整(第 9 篇) |
| 粒度 | 整块区域 | 单个像素 |
| 谁在算 | CPU,在合成决策时 | GPU 固定功能硬件,在绘制时 |
| 用途 | 决定要不要画这个图层 | 决定画哪些像素 |
一个是决策,一个是执行。visibleRegion.isEmpty() 为真时整个图层都不提交给 GPU——这是在光栅化之前就把活儿省掉了,和上一篇"越早丢弃越省"是同一个思路。
回到源码
问题 1:Region 用矩形集合,GPU 怎么办?
换了个解法。Region 能精确,是因为轴对齐矩形在交并差运算下是封闭的——结果永远还是矩形。任意三角形没有这个性质,所以 GPU 放弃"描述覆盖区域",改成逐像素问"你在里面吗"。
代价是问的次数多了几万倍,收益是每次问都彼此独立,可以大规模并行。
问题 2:几百万像素真的一个个判?
是,但有两层加速:
- 包围盒——先把候选范围从全屏砍到三角形所占的一小块(图里 144 → 90,真实场景收益大得多);真实硬件还会再按 8×8 / 16×16 分块,整块判定。
- 每次判定极便宜——三次边函数,每次只有乘加,没有除法开方;而且边函数对位置是线性的,扫描相邻像素时只要做加法,不用重算。
问题 3:中间像素的 UV 从哪来?
从三个顶点插值来,权重是重心坐标——而重心坐标正是那三个边函数值归一化的结果。判覆盖和算权重用的是同一次计算。
必须做透视校正(先除 再插值),否则斜面上的纹理会明显变形。
看到"这个三角形太小"这类性能建议时,现在你知道它的机制在哪了:不是顶点贵,是 2×2 quad 让小三角形的着色浪费高得离谱。
自测
1. 为什么 Region 能精确表示覆盖,光栅化不能?
因为 Region 处理的全是轴对齐矩形,而矩形集合在交、并、差运算下是封闭的——结果永远还能用有限个矩形精确表示。
任意三角形的边是任意角度的,两个形状求交可能得到任意多边形,而且边界像素会被部分覆盖(盖住 37% 这种情况)。有限个矩形表示不了它。
所以光栅化只能逐像素判定,边界像素还得取整——这个取整就是第 9 篇讲的锯齿。
2. 边函数的符号和绝对值各有什么用?
符号:点在这条边的哪一侧。三条边符号一致 = 点在三角形内,这是覆盖判定。
绝对值:正比于该点与这条边两端组成的小三角形的面积。三个值归一化就是重心坐标,用来把顶点属性插值到这个像素。
一次计算,两个用途——这就是覆盖测试和属性插值在实现里总写在一起的原因。(上一篇的背面剔除用的还是同一个叉积。)
3. 为什么说边函数"特别适合硬件"?
三条理由:
- 运算形状简单——只有乘法和减法,没有除法、没有开方、没有分支。除法和开方在硬件上贵一到两个数量级。
- 完全独立——每个像素的判定不依赖别的像素,几千个可以同时算。
- 线性—— 对位置是线性的,从一个像素挪到相邻像素,增量是常数。硬件扫描一行时只要不断加法,不用重算。
4. 关掉透视校正插值,画面上看起来是什么样?
斜面上的纹理会明显变形。
典型例子:一面贴棋盘格、斜着朝向你的地板。关掉之后,远处的格子会变形、格线会弯——因为纹理坐标在屏幕上被均匀铺开了,而它本该在远处挤在一起(近大远小)。
正确做法是先把属性除以 再插值,最后除以插值后的 。GPU 默认开启,GLSL 里的 noperspective 修饰符会关掉它。
5. 为什么"模型别细分过头"在移动端是条硬建议?
因为片段着色以 2×2 quad 为单位。
一个三角形哪怕只覆盖 1 个像素,也要着色一整个 2×2 块,其余 3 个是算完就扔的 helper 像素——浪费 75%。图里那个不算小的三角形已经浪费了 35%。
三角形越小,浪费比例越高。所以问题不在顶点(顶点很便宜),在于大量小三角形把片段着色的开销放大了好几倍。
顺带说,2×2 这个机制本身是必需的:dFdx/dFdy 要靠邻居才能算,而 mipmap 级别选择又依赖它(第 4 篇)。
小结
| 概念 | 一句话 | 要点 |
|---|---|---|
| 矩形 vs 三角形 | 矩形集合封闭,三角形不封闭 | 所以一个能精确描述,一个只能逐像素问 |
| 边函数 | 二维叉积, | 符号判侧、绝对值给面积 |
| 覆盖判定 | 三条边同号 = 在内部 | 无除法无开方,天然可并行 |
| 增量计算 | 对位置线性 | 扫描相邻像素只要加法 |
| 包围盒 | 先砍范围再逐像素 | 硬件还会再分 8×8 块 |
| 重心坐标 | 三个边函数值归一化 | 与覆盖判定共用同一次计算 |
| 透视校正 | 先除 再插值 | 不做则斜面纹理变形 |
| 2×2 quad | 着色的最小单位 | 为了 dFdx/dFdy,代价是 helper 像素 |
| 小三角形的坑 | 三角形越小浪费比例越高 | 别细分过头;瓶颈在片段不在顶点 |
延伸阅读
- Fabian Giesen《A trip through the Graphics Pipeline》Part 6/7(光栅化与硬件实现细节):https://fgiesen.wordpress.com/2011/07/06/a-trip-through-the-graphics-pipeline-2011-part-6/
- Juan Pineda《A Parallel Algorithm for Polygon Rasterization》(1988,边函数方法的原始论文):https://www.cs.drexel.edu/~david/Classes/Papers/comp175-06-pineda.pdf
- Scratchapixel《Rasterization: a Practical Implementation》:https://www.scratchapixel.com/lessons/3d-basic-rendering/rasterization-practical-implementation/
- AOSP 源码:
frameworks/native/libs/ui/include/ui/Region.h、frameworks/native/services/surfaceflinger/CompositionEngine/src/Output.cpp