在渲染项目中时常需要处理颜色混合、色彩空间、预乘和非预成空间等基本问题。处理不好这些问题会导致渲染时
出现黑边、白边、过爆、过暗等瑕疵。另外有很多优秀的文章也讲述了这些问题, 如:
- Alpha compositing, OpenGL blending and premultiplied alpha
- Pre or not Pre
Gotcha
- 预乘和非预乘是两个颜色空间。
- 无论预乘还是非预乘都是用Poter-Duff混合理论:
- 预乘 rgboutpremul=rgbsrcpremul+rgbdstpremul∗(1−asrc)
- 非预乘 rgboutstraight=rgbsrcstraight∗asrc+rgbdststraight∗(1−asrc)
- 假定3 Pass的混合 Blend(Blend(A,B),C)
- 预乘和非预乘经过混合后在数值上不完全等价, 也就是说:
- 在预乘空间缓存制作的数字资产 M=PremulBlend(A,B),
- 放到非预乘空间完成剩下的混合结果 Out=StraightBlend(P1,M), 和
- 放到预乘空间下完成剩下的混合结果 Out=PremulBlend(P1,M)
- 即使做了正确的解预乘/预乘, 两个的结果不是严格一样的
在这篇文章中我讲从最基本的物理/数学公式开始, 逐步介绍如何在 shader 中正确处理此类问题。
RGB 和 Alpha

在计算机图形学中, 我们通常使用RGB色彩空间来表示颜色。RGB色彩空间是一个三维空间, 其中每个轴代表一个颜色通道。
RGB之所以被广泛使用, 是因为RGB直接对应于人类视觉系统的三种颜色感受器, 也同时直接对应LED显示器的三种发光颜色。

当然, 由于人眼对颜色的感知是非线性的, 显示硬件也有自己的特性, 所以在送给显示器之前, 我们需要对RGB进行一些处理。
一种标准的处理方式是将线性RGB转换为 sRGB (standard-RGB), 这样可以使颜色在显示器上更加接近人眼的感知。
需要格外注意的是, sRGB 不是一个线性空间, 这意味着在 sRGB 空间中的颜色混合和计算是不同于线性空间的。我们接下
来的所有讨论都会建立在线性RGB空间上, 在实际引用中, 需要根据具体情况进行转换。
唯一的真理
我们首先需要把alpha混合的数学公式和其中各个参数的物理意义搞明白, 这是后续讨论的基础, 也是我们正确处理问题
时所依赖的唯一真理。
ares=asrc+adst(1−asrc)
rgbres=aresrgbsrc⋅asrc+rgbdst⋅adst⋅(1−asrc)
其中
- 计算发生在非预乘空间
- rgb 代表色值, 也就是rgb三个灯光的强度
- a 代表不透明度, 可以理解为rgb灯光整体的强度
预乘空间
将上面的公式2重新整理下, 可以得到如下形式:
rgbres⋅ares=rgbsrc⋅asrc+rgbdst⋅adst⋅(1−asrc)
不难发现, 上面的公式中, 无论是res, src 还是 dst, 都分别与自己的 alpha 相乘了。我们将 rgb 乘以 alpha
的操作称为预乘, 重新整理公式, 可以得到如下形式:
rgbrespm=rgbsrcpm+rgbdstpm⋅(1−asrc)
上式被称为预乘空间的混合公式。在预乘空间中, 混合公式变得更加简单, 且在混合过程中能够节省3次乘法运算, 提升性能。
预乘与非预乘
假设 cs, as, cd, ad 分别代表源颜色、源alpha、目标颜色、目标alpha, 都非预乘。
从预乘出发
glBlendFunc 设置如下:
| |
|---|
| SRC_FACTOR | ONE |
| DST_FACTOR | ONE_MINUS_SRC_ALPHA |
| SRC_ALPHA_FACTOR | ONE |
| DST_ALPHA_FACTOR | ONE_MINUS_SRC_ALPHA |
ar1cr1⋅ar1=as+ad⋅(1−as)=cs⋅as+cd⋅ad⋅(1−as)
上式中因为我们在预乘空间混合, 所以结果在后续处理中也会被认为是预乘空间, 因此是 (1) 式左侧是 cr1⋅ar1 而不是 cr1。
从非预乘出发
glBlendFunc 设置如下:
| |
|---|
| SRC_FACTOR | SRC_ALPHA |
| DST_FACTOR | ONE_MINUS_SRC_ALPHA |
| SRC_ALPHA_FACTOR | ONE |
| DST_ALPHA_FACTOR | ONE_MINUS_SRC_ALPHA |
ar2cr2=as+ad⋅(1−as)=cs⋅as+cd⋅(1−as)
构造等式
[ar1cr2]=?[ar2cr2]
即需要求证:
cout(premul)=? aoutrgbout(unpremul)
ar1cs⋅as+cd⋅ad⋅(1−as)=?cs⋅as+cd⋅(1−as)
也就是:
csas+cdad(1−as)=?(csas+cd(1−as))ar1=?(csas+cd(1−as))(as+ad(1−as))=?(csas+cd−cdas)(as+ad−adas)=?csas2+csasad−csas2ad+cdas+cdad−cdadas−cdas2−cdasad+cdas2ad
代入两边:
左边(预乘)分子:cs+(1−as)cd
右边:aout(asrgbs+(1−as)rgbd)
用 cs=asrgbs,cd=adrgbd,aout=as+(1−as)ad 展开右边:
(as+(1−as)ad)⋅(as⋅cs+(1−as)⋅cd)
这个乘法化简后恰好等于 cs+(1−as)cd(可逐项展开验证,也可用常见的 Porter-Duff "Over" 公式已知等价性结论)。因此 A 成立。
工程实现
GLSL片段
vec4 alpha_blending(vec4 src, vec4 dst) {
float a = src.a + dst.a * (1.0 - src.a);
vec3 rgb = (src.rgb * src.a + dst.rgb * dst.a * (1.0 - src.a)) / a;
return vec4( rgb, a );
}
vec4 alpha_blending_premultiplied(vec4 src, vec4 dst) {
return vec4(
src.rgb + dst.rgb * (1.0 - src.a),
src.a + dst.a * (1.0 - src.a)
);
}
在shader中混合时, 我们只需要注意3个要点:
- 确保混合时
src 和 dst 都处在同一个色彩空间, 即都是线性空间或都是sRGB空间
- 确保混合时
src 和 dst 都处在同一个预乘空间, 即都是预乘空间或都是非预乘空间
- 混合上下游, 即
OpenGL 中的 glBlendFunc 和 glBlendEquation 需要正确设置来对应所选择的预乘空间
glBlendFunc
永远认真阅读官方文档, 所有问题在这里都有答案 Khronos' Official Documentation - glBlendFunc
现象
正确混合 / Correct blending
混淆预乘 / Confuse premultiplication
正确混合时, 两个图层的边缘是平滑的。
参考资料