06. 混合:半透明怎么叠
06. 混合:半透明怎么叠
本篇位置:模块二 · 第 6 篇 | 前置:深度 | 预计阅读:30 分钟
你已经在用它了
给图层建 SkImage 之前,要先声明它的 alpha 类型(libs/renderengine/skia/SkiaRenderEngine.cpp):
// isOpaque means we need to ignore the alpha in the image,
// replacing it with the alpha specified by the LayerSettings. See
// https://developer.android.com/reference/android/view/SurfaceControl.Builder#setOpaque(boolean)
// Signal this with kUnknown_SkAlphaType, since kOpaque_SkAlphaType is a promise that
// the image's contents are already opaque.
const auto alphaType = item.isOpaque ? kUnknown_SkAlphaType :
item.usePremultipliedAlpha ? kPremul_SkAlphaType
: kUnpremul_SkAlphaType;三个分支,三种 alpha 语义。
三个问题:
kPremul和kUnpremul差在哪?为什么这是个必须声明的属性,而不是随便挑一个?isOpaque为什么给kUnknown而不是kOpaque?注释说kOpaque是个"承诺"——承诺什么?违约会怎样?- 你把图层一层层叠上去,那个"叠"在 GPU 里是哪个方程?
学完你会什么
- 写出并解释 src-over 方程,说清它和第 9 篇覆盖率的关系。
- 说清预乘 alpha 是什么、为什么它不是可选项而是必需品。
- 说清"声明错 alpha 类型"会在画面上表现成什么。
- 说清半透明为什么必须排序,以及排序解决不了的情况怎么办。
- 说清混合必须在什么颜色空间里做。
1. 那个"叠",就是一个方程
先回答问题 3。
把一层半透明的东西盖到已有画面上,结果颜色是:
是新画上去的(source), 是已经在帧缓冲里的(destination), 是新东西的不透明度。
图: 从 0 到 1,结果从纯背景色平滑过渡到纯前景色。
这个方程叫 src-over(源覆盖在目标之上),是所有混合模式里最常用的一个,也是 SkBlendMode::kSrcOver 的含义——Skia 的默认混合模式。
你早就在用它了。每一次图层合成、每一个半透明的对话框背景、每一处圆角边缘,走的都是这个方程。
它和第 9 篇是同一个方程。抗锯齿那一篇的核心公式是
一模一样。区别只在 的来源:混合的 来自素材的透明度,抗锯齿的 来自几何覆盖了多少面积。
这不是巧合。两者问的是同一个问题——"这个像素有多少属于新东西"。所以抗锯齿和半透明能天衣无缝地配合:一个半透明物体的抗锯齿边缘, 就是两者相乘。
混合方程不止 src-over 一种。硬件允许你分别指定 src 和 dst 各乘什么系数、怎么合并,常见的还有:
| 模式 | 方程 | 用途 |
|---|---|---|
| src-over | 默认,绝大多数半透明 | |
| src | 直接覆盖,不看背景 | |
| 加法(additive/plus) | 发光、火焰、粒子——叠得越多越亮 | |
| 乘法(multiply) | 阴影、染色——叠得越多越暗 |
代码里那些 paint.setBlendMode(SkBlendMode::kSrc) 用的就是第二种——它不读背景色,用在"我要完全覆盖这块区域"的场合(比如把模糊结果写回去),比 src-over 少一次帧缓冲读取,更快。
2. 预乘 alpha:不是可选项
现在回答问题 1,这是这一篇最值钱的部分。
一个半透明像素要存两样东西:颜色和透明度。有两种存法:
| 存什么 | 例子:50% 透明的白 | |
|---|---|---|
| 非预乘(straight / unpremultiplied) | RGB 存原始颜色,A 单独存 | |
| 预乘(premultiplied) | RGB 已经乘过 A,A 也存着 |
看起来只是换了个存法,信息量一样。但一旦要做插值,两者天差地别。
图:一张只有 2 个像素的纹理——左边是不透明的白,右边是全透明(RGB 存成黑,这是最常见的情况)。在两者之间做双线性插值,合成到蓝色背景上。上排是非预乘直接插值:中点的 RGB 被插成了 0.50,画面上是一条明显发灰发暗的过渡带。下排是预乘后插值:中点的 RGB 仍然是 1.00,只有透明度在变,过渡干净。
为什么会这样?
透明像素的 RGB 是没有意义的——它完全透明,颜色是多少都不该影响任何东西。但实际存储里它总得是某个值,通常是 0(黑)。
非预乘存法直接对 RGB 插值,就把这个本该被忽略的黑色混了进来。 也在同步下降,但画面上你看到的是"边缘发暗"——这就是著名的黑边(dark fringe)问题。
预乘存法从根上避免了它:透明像素预乘之后是 ,它对插值的贡献天然就是零,不会污染任何东西。
更根本的理由:预乘让混合可结合。把两层半透明先合成、再盖到第三层上,和逐层依次合成,结果必须一样。非预乘做不到这一点,预乘可以。这是所有多层合成系统(包括 SurfaceFlinger)必须用预乘的原因。
所以问题 1 的答案是:这不是"随便挑一个",是必须如实声明的事实。
那块 buffer 里的数据到底是不是预乘过的,取决于生产者怎么写的。Skia 拿到
kPremul就直接混合,拿到kUnpremul会先帮你乘一遍。声明错了,颜色就错了——把非预乘的数据声明成预乘,半透明区域会整体偏暗;反过来声明,会偏亮发白。
3. kOpaque 是个承诺
问题 2 的关键在注释里那个词:promise。
kOpaque_SkAlphaType 的含义不是"请把它当不透明处理",而是"我保证这块数据的 alpha 通道全是 1"。这是一个可以被优化器信任的事实——Skia 收到之后可以据此走快路径:跳过混合、直接覆盖、不去读 alpha 通道。
而 isOpaque 这个 LayerSettings 标志的语义完全不同:"忽略 buffer 里的 alpha,改用我给的 alpha"。
两者的差别是:buffer 里的 alpha 可能是任意垃圾值。应用申请了一个 RGBA 的 buffer 却只写了 RGB,alpha 通道里是什么完全不确定。这时候:
- 声明
kOpaque= 撒谎。Skia 会信任这个承诺,走上"不用管 alpha"的快路径,而实际数据里的垃圾 alpha 可能在某些路径上被读到,画面出现随机的透明或者色块。 - 声明
kUnknown= 诚实。"我不对这块数据的 alpha 做任何保证",Skia 不会做基于 alpha 的假设,后续由LayerSettings指定的 alpha 接管。
这是个很典型的类型系统用法:kOpaque 不是"我想要的效果",是"我对数据的断言"。断言错了,优化器基于错误前提做的一切都会出问题,而且症状往往诡异、依赖具体路径。
4. 混合必须在线性空间里做
M1 第 8 篇立过一条铁律:加权平均必须在线性空间里做。
混合就是最典型的加权平均,这条铁律在这里原样成立。
如果拿 sRGB 编码值直接混合, 白 黑 得到的不是"一半亮",而是明显偏暗的结果——因为 sRGB 编码值和实际亮度之间是那条幂曲线的关系。
画面上的症状是:半透明过渡带偏暗、发脏,尤其在明暗对比强的边界处最明显。
M1 已经讲透了原理,这里只补一句管线上的事实:GPU 硬件支持在混合前后自动做 sRGB 解码/编码——只要把渲染目标声明成 sRGB 格式,混合就自动发生在线性空间。这几乎是免费的(转换电路在 ROP 里),没有理由不用。
5. 顺序:半透明的老大难
上一篇说不透明物体应该从前往后画(吃 Early-Z 的红利)。半透明正好相反,必须从后往前。
图:两片各 50% 透明的色片叠在一起。从后往前画得到 RGB (0.775, 0.629, 0.611),从前往后画得到 (0.666, 0.665, 0.725)——最大分量差 0.115,肉眼明显不同。
为什么顺序会影响结果?因为 src-over 不满足交换律。。物理上也说得通:光先穿过近处那片、再穿过远处那片,和反过来是两回事。
而且半透明不能写深度。如果一个半透明片段更新了深度缓冲,它后面的东西就会被 Early-Z 拦掉——可你本该能透过它看到后面。所以半透明物体的标准配置是:深度测试开、深度写入关。
于是完整的绘制流程是:
- 不透明物体:从前往后画,深度测试开、写入开
- 半透明物体:按深度排序,从后往前画,深度测试开、写入关
6. 排序解决不了的时候
眼熟吗?这正是上一篇画家算法的困境,只不过换了个地方回来了。
半透明物体没法用 z-buffer(它的整个意义就是"不遮挡后面"),所以只能退回排序;而排序会遇到和上一篇一样的问题——相互穿插的两片半透明,排不出正确顺序。
游戏里这是个真实且常见的痛点:交叉的草叶、烟雾里的粒子、透过玻璃看另一块玻璃。
工业界的做法分两类:
一、绕过去(绝大多数游戏的选择)
- 把半透明物体拆细一点,让排序的粒度更小,错误不明显;
- 能用 alpha test 就别用半透明(树叶、栅栏用
discard抠掉,就变回不透明物体,可以用 z-buffer)——代价是打破 Early-Z(上一篇); - 接受排序错误,靠美术调整避开最难看的情况。
二、真正解决:OIT(Order-Independent Transparency,顺序无关透明)
思路是不再依赖顺序:把每个像素上所有半透明片段都记下来,最后统一排序合成。代表方案有 per-pixel linked list(每像素存一条链表)、加权平均近似(weighted blended OIT)等。
代价是内存和带宽都很可观——每像素要存不定数量的片段。所以移动端基本不用,PC/主机上也只在必要处局部使用。
记住这个结论就够了:半透明的正确渲染至今没有一个又快又对的通用解。这是实时渲染里少数几个"还没解决"的问题之一。
⚠️ 别搞混
你熟的图层合成和这一篇的混合,用的是同一个方程,但粒度差了好几个量级:
| SurfaceFlinger 图层合成 | 片段级混合 | |
|---|---|---|
| 粒度 | 整张 layer | 单个片段 |
| 一帧发生几次 | 几十次 | 几百万次 |
| 谁执行 | HWC 硬件合成器,或回落 GPU | GPU 的 ROP 固定功能单元 |
| 顺序谁定 | SF 按 z-order 排 | 由 draw call 提交顺序决定 |
| 方程 | src-over(同一个) | src-over(同一个) |
| 能不能乱序 | 不能 | 不能 |
方程相同,层级不同。你在 SF 里叠的是几十张已经画好的图;GPU 在管线末端叠的是几百万个刚算出来的片段。
一个有用的视角:你的整个合成流程,可以看成 GPU 混合阶段的一个"粗粒度版本"——同样的数学,只是把"片段"换成了"图层",把每帧几百万次换成了几十次。这也是为什么 HWC 能用专用硬件做掉它:量级小到可以用固定电路直接算。
回到源码
问题 1:kPremul 和 kUnpremul 差在哪?
差在 RGB 有没有预先乘过 alpha。
这不是可以随便挑的偏好,是对数据的如实声明——那块 buffer 里的数据究竟是不是预乘过的,由生产者决定。声明错了颜色就错:把非预乘声明成预乘,半透明区域整体偏暗;反过来偏亮发白。
为什么必须存在预乘这种格式?因为透明像素的 RGB 是垃圾值(通常是黑),非预乘存法在插值时会把这个黑混进来,产生黑边(图里中点 RGB 从 1.00 掉到 0.50)。预乘之后透明像素是 ,对插值的贡献天然为零。更根本的是,只有预乘才能让多层合成满足结合律。
问题 2:isOpaque 为什么给 kUnknown 而不是 kOpaque?
因为 kOpaque 是对数据的承诺——"这块 buffer 的 alpha 通道全是 1",Skia 会信任它并走快路径。
而 isOpaque 的语义是"忽略 buffer 里的 alpha,用我给的",这不代表 buffer 里的 alpha 真的是 1——它可能是应用没写过的任意垃圾值。声明 kOpaque 就是撒谎,优化器基于错误前提做的事会导致诡异的、依赖具体路径的画面问题。
kUnknown 是诚实的说法:"我不对这块数据的 alpha 做任何保证。"
问题 3:那个"叠"是哪个方程?
src-over:。
它同时也是抗锯齿的方程(第 9 篇),区别只在 来自素材透明度还是几何覆盖率。下次看到任何"叠加",先问 是哪来的——这一问能立刻分清你在处理透明度还是覆盖率。
自测
1. 为什么半透明纹理必须用预乘格式存?
因为透明像素的 RGB 是没有意义的垃圾值(通常是黑),而双线性插值会把它混进来。
图里那个例子:不透明白像素和全透明黑像素之间插值,非预乘存法在中点得到 RGB=0.50——一条肉眼可见的灰暗过渡带,这就是黑边。预乘之后透明像素是 ,对插值的贡献天然为零,中点 RGB 仍是 1.00。
更根本的理由:只有预乘能让多层合成满足结合律——先合成两层再叠第三层,和逐层依次叠,结果必须一致。多层合成系统(包括 SurfaceFlinger)没得选。
2. 把一块非预乘的 buffer 声明成 kPremul,画面会怎样?
半透明区域整体偏暗。
因为 Skia 会以为 RGB 已经乘过 alpha,于是直接拿去混合。而实际数据里 RGB 是原始颜色(更亮),少乘了一次 alpha——等价于把颜色又乘了一遍 alpha,结果偏暗。
反过来(把预乘声明成非预乘)则会偏亮发白,因为被多除了一次 alpha。
完全不透明的区域看不出问题(,乘不乘一样),所以这类 bug 常常只在半透明边缘暴露。
3. src-over 不满足交换律,这在物理上说得通吗?
说得通。
光先穿过近处那片半透明、再穿过远处那片,和反过来是两个不同的物理过程——近处那片对最终颜色的影响权重更大。
图里两片各 50% 透明的色片,正确顺序得到 (0.775, 0.629, 0.611),反过来得到 (0.666, 0.665, 0.725),最大分量差 0.115,肉眼明显。
所以半透明必须按深度排序、从后往前画。
4. 半透明物体为什么要「深度测试开、深度写入关」?
测试要开:半透明物体也会被不透明物体挡住,该挡的还得挡。
写入要关:如果半透明片段更新了深度缓冲,它后面的东西就会被深度测试拦掉——可你本该能透过它看到后面。半透明的整个意义就是不遮挡后面的东西。
这也是为什么半透明享受不到 Early-Z 的红利,只能退回排序。
5. 两片相互穿插的半透明草叶,排序能画对吗?有什么办法?
排不对。这正是第 5 篇画家算法困境的重演——相互穿插的物体没有确定的前后关系,而半透明又用不了 z-buffer(它的意义就是不遮挡)。
工业界的做法:
- 绕过去(主流):把物体拆细让排序粒度更小;能用 alpha test 抠图就别用半透明(变回不透明物体,可以用 z-buffer,代价是打破 Early-Z);或者接受误差、靠美术规避。
- 真解决:OIT——每像素记下所有半透明片段,最后统一排序合成。代价是内存和带宽都很大,移动端基本不用。
半透明的正确渲染至今没有又快又对的通用解,这是实时渲染里少数几个还没解决的问题。
小结
| 概念 | 一句话 | 要点 |
|---|---|---|
| src-over | 默认混合模式;和抗锯齿是同一个方程 | |
| 的两个来源 | 素材透明度 / 几何覆盖率 | 看到"叠加"先问 哪来的 |
| 其他混合模式 | src / 加法 / 乘法 | 加法用于发光,kSrc 不读背景更快 |
| 预乘 alpha | RGB 已乘过 A | 必需品不是选项:防黑边、保结合律 |
| 声明错 alpha 类型 | 偏暗或偏亮 | 只在半透明区域暴露 |
kOpaque | 是承诺不是意图 | 承诺 alpha 全为 1;撒谎会出诡异 bug |
| 线性空间 | 混合必须在线性域做 | 渲染目标声明成 sRGB 即可,几乎免费 |
| 半透明的顺序 | 必须从后往前 | src-over 不满足交换律 |
| 半透明的深度 | 测试开、写入关 | 否则会挡住本该透出来的东西 |
| 穿插的半透明 | 排序解决不了 | OIT 是真解法但太贵;至今无通用解 |
延伸阅读
- LearnOpenGL-CN《混合》:https://learnopengl-cn.github.io/04 Advanced OpenGL/03 Blending/
- Tom Forsyth《Premultiplied alpha》(预乘为什么是必需品的经典论述):https://tomforsyth1000.github.io/blog.wiki.html#[[Premultiplied alpha]]
- NVIDIA《Weighted, Blended Order-Independent Transparency》:https://jcgt.org/published/0002/02/09/
- AOSP 源码:
frameworks/native/libs/renderengine/skia/SkiaRenderEngine.cpp