09. 抗锯齿
09. 抗锯齿
本篇位置:模块二 · 第 9 篇 | 前置:阴影 | 预计阅读:30 分钟
你已经在用它了
RenderEngine 创建离屏渲染目标时,采样数写死成 1(libs/renderengine/skia/compat/GaneshGpuContext.cpp):
sk_sp<SkSurface> GaneshGpuContext::createRenderTarget(SkImageInfo imageInfo) {
constexpr int kSampleCount = 1; // enable AA
constexpr SkSurfaceProps* kProps = nullptr;
constexpr bool kMipmapped = false;
return SkSurfaces::RenderTarget(mGrContext.get(), skgpu::Budgeted::kNo, imageInfo, kSampleCount,
kTopLeft_GrSurfaceOrigin, kProps, kMipmapped,
mGrContext->supportsProtectedContent());
}而真正画图层的地方,抗锯齿是看情况开的(libs/renderengine/skia/SkiaRenderEngine.cpp,已删去厂商定制分支):
} else if (!bounds.isRect()) {
paint.setAntiAlias(true);
canvas->drawRRect(bounds, paint);
} else {
canvas->drawRect(bounds.rect(), paint);
}三个问题:
- 为什么普通矩形不开抗锯齿,圆角矩形就要开? 差别到底在哪?
- 采样数写死成 1,注释却写着
enable AA。 不多采几个点,锯齿是怎么消掉的? - 游戏里说的 MSAA、TAA,和这个
setAntiAlias(true)是一回事吗?
第 2 个问题是这一篇的枢纽——它逼你承认,"抗锯齿"底下藏着好几条完全不同的技术路线。
学完你会什么
- 说清几何锯齿和 M1 第 7 篇讲的纹理走样是两种不同的问题,以及为什么前者没法靠"先低通"根治。
- 说清覆盖率是什么,为什么它是所有抗锯齿方案的公共分母。
- 分清四条路线(多采样、解析覆盖率、后处理、时域),说出每条在管线的哪一步下手。
- 说清 MSAA 凭什么比 SSAA 便宜,以及它在手机上为什么反而更划算。
- 说清 TAA 拿什么换了什么。
坐标约定:本篇用图像坐标——原点左上、 向右、 向下。
1. 这和 M1 讲的走样不是同一件事
M1 第 7 篇讲过走样:信号里有超过一半采样率的高频,它们会折叠成假的低频,于是有了摩尔纹。那一篇也给了解法——采样前先低通,mipmap 就是把低通预先算好。
几何边缘的锯齿看起来很像,成因却不同。
一条三角形的边把像素分成"里"和"外"。沿着某条扫描线看过去,"这个位置属不属于三角形"这个函数长这样:——一个阶跃。
阶跃有个要命的性质:它的频谱是无限宽的。无论采样率提到多高,总有超出一半采样率的高频成分留在那里。
直觉版:低通滤波是"把变化抹平"。可边缘的本质就是那个突变——把它抹平,边缘就没了。你没法既保留一条清晰的边界,又让它不含高频。
这带来一个必须接受的结论:
| 纹理走样(M1 第 7 篇) | 几何锯齿(本篇) | |
|---|---|---|
| 信号是什么 | 纹理图案,带宽有限 | 覆盖函数,是个阶跃,带宽无限 |
| 能根治吗 | 能。先低通再采样,mipmap 预先算好 | 不能,只能逼近 |
| 提高采样率有用吗 | 到某个点之后就够了 | 永远有残留,只是越来越不明显 |
所以这一篇讲的全部是"怎么逼近得更划算",没有一个方案能把锯齿彻底消掉。这不是工程没做好,是问题本身的性质。
2. 覆盖率:所有方案的公共分母
先把"锯齿到底是什么"说准。
关键的一步是别再把像素当成点。像素是屏幕上一个有面积的小方块。一条边穿过它时,它被盖住的是一部分——这个比例叫覆盖率(coverage),记作 。
这个像素理应显示的颜色是:
锯齿的定义就是:把 强行取整成 0 或 1。
图:同一条边穿过 8×8 的像素网格。左边把覆盖率取整(过半算 1,否则算 0),边缘成了台阶。右边按真实覆盖率混色,边缘成了一条渐变带。被框住那格实际只盖住 70%,左边却把它画成了满格。
这张图里,17.2% 的像素覆盖率是小数——正是这些像素决定了边缘看起来是毛的还是顺的。
注意上面那个公式的形状:它和你熟的 alpha 混合(第 6 篇)长得一模一样。区别在于 的来源——混合的 来自素材本身的透明度,这里的 来自几何覆盖了多少面积。同一个方程,两种含义。抗锯齿之所以能和半透明配合得这么自然,根子就在这儿。
3. 回答第一个问题:矩形凭什么不用
现在看那个 if。
图左:一个边界正好压在像素边界上的轴对齐矩形,0.0% 的像素覆盖率是小数——每个像素要么全被盖住、要么完全没有。图右:同样大小加上圆角,18.8% 的像素覆盖率变成了小数。被框住那格盖住 55%。
答案藏在"边的方向"里。
轴对齐矩形的四条边都平行于像素网格。沿着任何一条边走过去,每个像素的覆盖率都是同一个值——不会一格 30%、下一格 70%。没有起伏,就没有台阶,也就没有锯齿。
图里那个矩形的边界还正好落在整数像素上,于是更极端:覆盖率干脆非 0 即 1(0.0% 的像素是小数),"取整"这个动作不丢任何信息,开不开抗锯齿结果逐位相同。这也是合成器里最常见的情形——图层的位置和尺寸通常就是整数像素。
圆角完全不同。那段圆弧斜穿过一片像素,覆盖率沿着边缘一路变化(55%、然后是别的值……),取整就把这条连续变化的边压成了一串台阶。
所以这个 if 不是性能调优,是逻辑上的精确判断:对轴对齐矩形,抗锯齿是纯粹的浪费;对圆角,它是正确性的一部分。
那图层被旋转了呢?
先说清一件容易看错的事:
bounds是图层自己坐标系里的形状,由getBoundsAndClip()从layer.geometry.boundaries和圆角半径拼出来。isRect()判的只有一件事——这个图层有没有圆角。旋转是通过canvas->concat(...)单独作用在画布矩阵上的,压根不进入这个判断。于是理论上有个空档:一个没有圆角、却被转了任意角度的图层,会走下面那条
drawRect分支,而那里的SkPaint paint;是默认构造的——antiAlias默认是false。斜边上就会有锯齿。实践中基本碰不到,因为合成器的变换几乎只有平移、缩放和 90° 整数倍旋转(
ui::Transform的ROT_90/FLIP_H/FLIP_V那一套,见 M1 第 2 篇 2D 变换与矩阵)。这几种都把轴对齐矩形映射成轴对齐矩形,边仍然平行于像素网格,本来就不需要抗锯齿。代码没写错,只是它的正确性依赖了一个没写在代码里的前提。
4. 路线一:多采几个点(SSAA 与 MSAA)
最朴素的想法:一个像素只采一个点不够,那就多采几个。
图:放大看一个被边缘斜穿的像素,它的精确覆盖率是 55%。左:只在中心采一个点,点落在里面,于是判成 100%。中:SSAA 4x,4 个采样点命中 2 个,判成 50%,而且着色器跑了 4 次。右:MSAA 4x,同样 4 个采样点、同样判成 50%,但着色器只跑 1 次(蓝色 × 是唯一的着色点)。
SSAA(超采样)就是字面意思:按 N 倍分辨率渲染,最后缩小。每个采样点都完整跑一遍片段着色器。效果最好,代价是 N 倍的着色 + N 倍的带宽 + N 倍的显存。手机上基本不用。
MSAA(多重采样)的洞察是一句话:覆盖率需要高分辨率,颜色不需要。
一个像素内部,如果它整个都在同一个三角形里,那 4 个采样点算出来的颜色几乎一样——跑 4 遍纯属浪费。真正需要高分辨率的只有"这个像素被盖住了多少"这件事。
于是 MSAA 把两件事拆开:
| 每像素做几次 | 内容 |
|---|---|
| N 次 | 覆盖测试、深度测试(每个采样点各存一份深度) |
| 1 次 | 片段着色器(在像素中心算一次颜色) |
得到的结果是:一个像素存着 N 份"这个采样点归谁",最后 resolve(把 N 个采样点平均成 1 个像素值)写出去。
代价对比(4x 为例):
| 着色次数 | 深度/颜色存储 | 效果 | |
|---|---|---|---|
| SSAA 4x | 4× | 4× | 几何边缘 + 纹理内部都变好 |
| MSAA 4x | 1× | 4× | 只有几何边缘变好 |
最后那一列是 MSAA 的软肋:它只在三角形边界上多采样,像素内部的颜色是一次算出来的。所以纹理本身的走样、shader 里算出来的高频(比如程序化的细条纹、高光的闪烁)MSAA 一点忙都帮不上——那些得靠 M1 第 7 篇的 mipmap 和滤波去管。
MSAA 在手机上反而更划算,这和很多人的直觉相反。原因在于移动 GPU 的 tile 架构:多采样的那份数据全程待在片上 tile 内存里,resolve 也在片上完成,只有平均后的最终像素才写回系统内存——多出来的带宽开销大部分被吃在了片上。这条留到第 11 篇展开。
5. 路线二:把覆盖率直接算出来
回到那行 kSampleCount = 1; // enable AA。
采样数是 1,一个像素只有一个采样点,按上一节的逻辑根本不可能有抗锯齿。但 Skia 压根没走多采样这条路。
如果你知道图形的数学定义,覆盖率是可以直接算出来的。一个圆角矩形的边界是一段圆弧,圆弧和一个正方形像素相交出来的面积,是道解析几何题——算出来的 是精确值,不是 4 个采样点估出来的 2/4。
回头看上一节那张图:真实覆盖率 55%,MSAA 4x 只能给出 50%(4 个采样点,精度就 5 档:0、25%、50%、75%、100%)。解析法直接给 55%。
这条路的代价是它有一个硬前提:你得知道图形是什么。
| 知不知道图形的数学定义 | 能不能用解析法 | |
|---|---|---|
| 2D 矢量合成(Skia、SurfaceFlinger) | 知道——就是矩形、圆角矩形、路径 | 能 |
| 3D 场景 | 不知道——是几十万个三角形按深度堆叠的结果 | 不能 |
3D 里,一个像素最终显示什么,取决于哪些三角形盖到了它、谁在前谁在后、有没有被剔除。这不是一道能解析求解的几何题。这就是为什么合成器可以用解析覆盖率,而游戏引擎必须回到采样。
这也就回答了第 2 个问题:kSampleCount = 1 和 enable AA 不矛盾,因为 Skia 的抗锯齿从来就不靠多采样点——它靠算面积。那行注释的意思是"采样数保持 1,抗锯齿交给 Skia 自己做",而不是"把采样数设成 1 就能开启抗锯齿"。
⚠️ 别搞混
setAntiAlias(true) 和 MSAA 中文都叫"抗锯齿",但它们是两套东西:
paint.setAntiAlias(true) | MSAA | |
|---|---|---|
| 谁做的 | Skia(软件或 GPU shader 里算面积) | GPU 固定功能硬件 |
| 怎么算覆盖率 | 解析,精确面积 | 采样,N 个点数出来 |
| 精度 | 连续值 | N+1 档 |
| 前提 | 得知道图形的数学定义 | 任意三角形都行 |
| 额外显存 | 无 | N 倍的深度/颜色缓冲 |
| 在哪配置 | 每次绘制的 SkPaint | 创建渲染目标时的 sampleCount |
同一份画面上,两者可以同时存在也可以都没有。RenderEngine 就是"sampleCount=1(无 MSAA)+ Skia 解析 AA"的组合。
6. 路线三:画完了再补救(FXAA / SMAA)
前两条路都要在画的时候下手。还有一类方案反过来:接受一张已经有锯齿的图,事后修。
思路是:在成品图上找出那些看起来像边缘的地方(相邻像素亮度差得多),然后沿着边缘的方向把像素混一混,把台阶抹顺。FXAA(Fast Approximate AA)是最有名的一个,SMAA 是它的改进版,边缘判断更讲究。
| 优点 | 缺点 |
|---|---|
| 便宜——就是一个全屏 pass,几乎不占显存 | 它在猜。分不清"这是几何边缘"还是"这本来就是一条细线纹理" |
| 和管线完全解耦,任何渲染器都能挂上去 | 会把真实的细节一起抹糊,文字和细纹理尤其明显 |
| 对任何来源的锯齿都有效(包括 shader 算出来的) | 拿不回亚像素信息——原图里 70% 的覆盖率已经被取整成 100% 了,事后没法还原 |
最后那条是根本限制:前两条路是在信息丢失之前动手,FXAA 是在丢失之后补。
7. 路线四:把采样摊到时间上(TAA)
还剩最后一个思路,而且它是今天绝大多数游戏的选择。
既然一帧里采 4 个点太贵,那就一帧采 1 个点、连续 4 帧采不同的位置。
图:每一帧把采样点抖动(jitter)到像素内的不同亚像素位置。单看任何一帧都只有 1 个采样点,但把 4 帧攒起来,就等价于这个像素有了 4 个采样点。
这就是 TAA(Temporal Anti-Aliasing,时域抗锯齿)。代价从"空间上多采 4 倍"变成了"时间上摊 4 帧",单帧成本几乎不变。
听起来太美好,所以它有两个必须解决的麻烦:
麻烦一:画面在动。上一帧那个像素里的东西,这一帧已经挪到别处去了,直接把历史帧叠上来就是糊成一片。解法是运动矢量(motion vector)——渲染时额外输出一张图,记录每个像素相对上一帧移动了多少,然后按它把历史帧重投影到当前帧的位置再叠加。
麻烦二:东西会被挡住、会消失。一个像素上一帧还是墙,这一帧墙被角色挡住了,历史值就是彻底错的。解法是邻域 clamp——把重投影来的历史值限制在"当前帧这个像素周围一圈颜色的范围"内,超出范围说明历史不可信,丢掉。
即便这样,TAA 还是会有它的招牌毛病:运动物体后面的拖影(ghosting)、快速移动时的糊、细小物体的闪烁。
它凭什么还是主流?因为它是唯一一个能同时管住几何锯齿和着色锯齿的方案(MSAA 管不了后者),成本又低。而且它顺手打开了另一扇门:既然已经在累积多帧样本了,那把渲染分辨率降下来、靠时域累积补回细节,就是很自然的下一步。
8. 怎么选
| 方案 | 在哪一步下手 | 主要代价 | 管几何锯齿 | 管着色锯齿 |
|---|---|---|---|---|
| 解析覆盖率(Skia) | 光栅化时算面积 | 只适用于知道数学定义的图形 | ✅ 精确 | — |
| SSAA | 整体提高分辨率 | N 倍着色 + N 倍带宽 | ✅ | ✅ |
| MSAA | 光栅化多采样、着色一次 | N 倍深度/颜色存储 | ✅ | ❌ |
| FXAA / SMAA | 成品图上后处理 | 会糊掉真实细节 | ⚠️ 猜的 | ⚠️ 猜的 |
| TAA | 跨帧累积 | 拖影、需要运动矢量 | ✅ | ✅ |
回到源码
问题 1:为什么普通矩形不开 AA,圆角矩形要开?
因为轴对齐矩形的边平行于像素网格,沿着边走过去每个像素的覆盖率都一样——没有起伏就没有台阶。边界再落在整数像素上(合成器的常态),覆盖率干脆非 0 即 1,抗锯齿算了也白算,结果逐位相同。
圆角那段圆弧斜穿像素,覆盖率沿边缘一路变化(图里那格是 55%),取整就把它压成了台阶。
这个 if 判的是正确性边界,不是性能开关。但要注意它判的是"有没有圆角",不是"变换后还是不是轴对齐"——旋转走的是画布矩阵,不进这个判断,详见第 3 节末尾。
问题 2:kSampleCount = 1 怎么还能 enable AA?
因为 Skia 的抗锯齿不靠多采样点,靠解析地算出覆盖面积。它知道自己在画一个圆角矩形,圆弧和像素方块的相交面积是可以直接求解的。采样数保持 1,只是说"不额外开 MSAA 缓冲",抗锯齿由 Skia 在着色阶段自己完成。
顺带说清一件事:解析法的精度反而比 MSAA 高——4x MSAA 只有 5 档精度,解析法给的是连续值。它的限制不在精度,在适用范围。
问题 3:MSAA、TAA 和 setAntiAlias 是一回事吗?
不是,是三条不同的路线,唯一的共同点是都在处理同一个量——覆盖率。
setAntiAlias= 解析算面积,需要知道图形是什么,只有 2D 矢量场景能用;- MSAA = 光栅化时多采几个点、着色仍只做一次,任意三角形都能用,但管不了着色锯齿;
- TAA = 把采样摊到多帧上,成本最低、覆盖最全,代价是拖影和对运动矢量的依赖。
以后再看到"抗锯齿"三个字,先问一句:它是在几何阶段、着色阶段,还是画完之后动的手?这一问就能定位它属于哪条路线,以及它管不管得了你眼前那个锯齿。
自测
1. 为什么说几何锯齿"没法根治",而纹理走样可以?
因为两者的信号性质不同。
纹理是个带宽有限的信号,只要在采样前把超过一半采样率的高频滤掉(低通),走样就不会发生——mipmap 就是把这件事预先算好。
几何边缘的"信号"是覆盖函数,一个 0/1 阶跃。阶跃的频谱是无限宽的,无论采样率多高都有高频残留。而且你没法对它低通——把突变抹平,边缘本身就没了。
所以几何抗锯齿的所有方案都是"逼近",只是逼近得多划算的区别。
2. 一个像素真实覆盖率是 55%,4x MSAA 会给出什么值?为什么?
50%(4 个采样点里命中 2 个)。
4 个采样点只能给出 5 档精度:0、25%、50%、75%、100%。55% 落在 50% 这一档上。
对比之下,Skia 的解析法直接算出 55% 这个连续值。MSAA 的精度受采样点数量限制,解析法不受——但解析法要求你知道图形的数学定义,3D 场景做不到。
3. MSAA 4x 的着色器跑几次?为什么这是它比 SSAA 便宜的关键?
1 次(SSAA 4x 跑 4 次)。
关键洞察是:覆盖率需要高分辨率,颜色不需要。一个像素内部如果都属于同一个三角形,4 个采样点算出来的颜色几乎一样,跑 4 遍是浪费。
所以 MSAA 只把覆盖测试和深度测试做 N 次,着色仍然每像素一次。代价是它只能改善几何边缘——像素内部的颜色本来就只算了一次,纹理走样、高光闪烁这类着色锯齿它管不了。
4. 一个 shader 用程序化的方式画出很密的细条纹,画面在闪。开 MSAA 4x 有用吗?
基本没用。
闪烁来自着色阶段——细条纹是 shader 在像素中心算出来的高频,属于纹理/着色走样,不是几何边缘。MSAA 只在三角形边界上多采样,像素内部仍然只着色一次,那次着色算出来是什么就是什么。
有用的是:给条纹用真实纹理 + mipmap(M1 第 7 篇),或者在 shader 里根据 dFdx/dFdy 主动降低条纹频率,或者上 TAA / SSAA(这两个会真的多采样着色结果)。
5. SurfaceFlinger 用 Skia 的解析 AA 就够了,游戏引擎为什么不能照抄?
因为前提不成立。
解析法要求知道图形的数学定义。合成器画的就是矩形和圆角矩形,边界是一个可以写出方程的形状,圆弧与像素的相交面积能直接求解。
3D 场景里,一个像素最终显示什么,是几十万个三角形经过深度测试、剔除、混合之后的结果——这不是一道能解析求解的几何题。所以游戏必须回到采样路线(MSAA/TAA)或事后补救(FXAA)。
方案的适用性来自它对输入的假设,不是它有多先进。
小结
| 概念 | 一句话 | 要点 |
|---|---|---|
| 几何锯齿 | 覆盖率被取整成 0/1 | 和纹理走样成因不同,根治不了 |
| 覆盖率 | 这个像素被盖住的面积比例 | 所有方案的公共分母 |
| 正确的边缘色 | 形状同 alpha 混合, 来源不同 | |
| 轴对齐矩形 | 边平行网格 → 覆盖率不起伏 → 无台阶 | 所以不需要抗锯齿;isRect() 判的是有没有圆角 |
| SSAA | 整体提分辨率再缩小 | N 倍着色,手机上太贵 |
| MSAA | 覆盖测 N 次,着色 1 次 | 只管几何边缘;在 tile 架构上特别划算 |
| 解析覆盖率 | 直接算面积,不采样 | 精度更高,但要知道图形的数学定义 |
kSampleCount = 1 | 不开 MSAA,AA 交给 Skia | 两件事,不矛盾 |
| FXAA / SMAA | 成品图上找边缘再抹 | 便宜、通用,但在猜,会糊细节 |
| TAA | 把采样摊到多帧 | 需要运动矢量 + 邻域 clamp;代价是拖影 |
| 选型第一问 | 它在管线哪一步动手? | 几何阶段 / 着色阶段 / 画完之后 |
延伸阅读
- GAMES101 Lecture 6:Rasterization 2(Antialiasing):https://games-cn.org/intro-graphics/
- NVIDIA《FXAA》白皮书(后处理 AA 的原始论文):https://developer.download.nvidia.com/assets/gamedev/files/sdk/11/FXAA_WhitePaper.pdf
- Alex Tardif《Temporal Antialiasing Starter Pack》(TAA 的工程要点汇总):https://alextardif.com/TAA.html
- Skia 官方文档《Skia Graphics Library》中
SkPaint的 anti-alias 说明:https://skia.org/docs/user/api/skpaint_overview/ - AOSP 源码:
frameworks/native/libs/renderengine/skia/compat/GaneshGpuContext.cpp、frameworks/native/libs/renderengine/skia/SkiaRenderEngine.cpp