Alpha混合的终极指南

March 18

在渲染项目中时常需要处理颜色混合、色彩空间、预乘和非预成空间等基本问题。处理不好这些问题会导致渲染时 出现黑边、白边、过爆、过暗等瑕疵。另外有很多优秀的文章也讲述了这些问题, 如:

  1. Alpha compositing, OpenGL blending and premultiplied alpha
  2. Pre or not Pre

Gotcha

  1. 预乘和非预乘是两个颜色空间。
  2. 无论预乘还是非预乘都是用Poter-Duff混合理论:
  • 预乘 rgboutpremul=rgbsrcpremul+rgbdstpremul(1asrc)rgb_{out}^{premul} = rgb_{src}^{premul} + rgb_{dst}^{premul} * (1 - a_{src})
  • 非预乘 rgboutstraight=rgbsrcstraightasrc+rgbdststraight(1asrc)rgb_{out}^{straight} = rgb_{src}^{straight} * a_{src} + rgb_{dst}^{straight} * (1 - a_{src})
  1. 假定3 Pass的混合 Blend(Blend(A,B),C)Blend(Blend(A, B), C)
  2. 预乘和非预乘经过混合后在数值上不完全等价, 也就是说:
  • 在预乘空间缓存制作的数字资产 M=PremulBlend(A,B)M = PremulBlend(A, B),
  • 放到非预乘空间完成剩下的混合结果 Out=StraightBlend(P1,M)Out = StraightBlend(P1, M), 和
  • 放到预乘空间下完成剩下的混合结果 Out=PremulBlend(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(1asrc)\displaystyle a_{res} = a_{src} + a_{dst} (1 - a_{src})

rgbres=rgbsrcasrc+rgbdstadst(1asrc)ares\displaystyle rgb_{res} = \frac{rgb_{src} \cdot a_{src} + rgb_{dst} \cdot a_{dst} \cdot (1 - a_{src})}{a_{res}}

其中

  • 计算发生在非预乘空间
  • rgbrgb 代表色值, 也就是rgb三个灯光的强度
  • aa 代表不透明度, 可以理解为rgb灯光整体的强度

预乘空间

将上面的公式2重新整理下, 可以得到如下形式:


rgbresares=rgbsrcasrc+rgbdstadst(1asrc)\displaystyle rgb_{res} \cdot a_{res} = rgb_{src} \cdot a_{src} + rgb_{dst} \cdot a_{dst} \cdot (1 - a_{src})

不难发现, 上面的公式中, 无论是resres, srcsrc 还是 dstdst, 都分别与自己的 alphaalpha 相乘了。我们将 rgbrgb 乘以 alphaalpha 的操作称为预乘, 重新整理公式, 可以得到如下形式:


rgbrespm=rgbsrcpm+rgbdstpm(1asrc)\displaystyle rgb_{res_{pm}} = rgb_{{src_{pm}}} + rgb_{dst_{pm}} \cdot (1 - a_{src})

上式被称为预乘空间的混合公式。在预乘空间中, 混合公式变得更加简单, 且在混合过程中能够节省3次乘法运算, 提升性能。


预乘与非预乘

假设 csc_s, asa_s, cdc_d, ada_d 分别代表源颜色、源alpha、目标颜色、目标alpha, 都非预乘。

从预乘出发

glBlendFunc 设置如下:

SRC_FACTORONE
DST_FACTORONE_MINUS_SRC_ALPHA
SRC_ALPHA_FACTORONE
DST_ALPHA_FACTORONE_MINUS_SRC_ALPHA
ar1=as+ad(1as)cr1ar1=csas+cdad(1as)\begin{align} a_{r_1} &= a_s + a_d \cdot (1 - a_s)\nonumber \\ c_{r_1} \cdot a_{r_1} &= c_s \cdot a_s + c_d \cdot a_d \cdot (1 - a_s) \end{align}

上式中因为我们在预乘空间混合, 所以结果在后续处理中也会被认为是预乘空间, 因此是 (1)(1) 式左侧是 cr1ar1c_{r_1} \cdot a_{r_1} 而不是 cr1c_{r_1}

从非预乘出发

glBlendFunc 设置如下:

SRC_FACTORSRC_ALPHA
DST_FACTORONE_MINUS_SRC_ALPHA
SRC_ALPHA_FACTORONE
DST_ALPHA_FACTORONE_MINUS_SRC_ALPHA
ar2=as+ad(1as)cr2=csas+cd(1as)\begin{align} a_{r_2} &= a_s + a_d \cdot (1 - a_s) \nonumber \\ c_{r_2} &= c_s \cdot a_s + c_d \cdot (1 - a_s) \end{align}

构造等式

[ar1cr2]=?[ar2cr2]\begin{bmatrix} a_{r_1}\\ c_{r_2} \end{bmatrix} \stackrel{?}{=} \begin{bmatrix} a_{r_2}\\ c_{r_2} \end{bmatrix}

即需要求证:

cout(premul)=? aoutrgbout(unpremul)c^{(premul)}_{out} \stackrel{?}{=}\ a_{out} rgb^{(unpremul)}_{out} csas+cdad(1as)ar1=?csas+cd(1as)\frac{c_s \cdot a_s + c_d \cdot a_d \cdot (1 - a_s)}{a_{r_1}} \stackrel{?}{=} c_s \cdot a_s + c_d \cdot (1 - a_s)

也就是:

csas+cdad(1as)=?(csas+cd(1as))ar1=?(csas+cd(1as))(as+ad(1as))=?(csas+cdcdas)(as+adadas)=?csas2+csasadcsas2ad+cdas+cdadcdadascdas2cdasad+cdas2ad\begin{align} c_s a_s + c_d a_d (1 - a_s) &\stackrel{?}{=} (c_s a_s + c_d (1 - a_s)) a_{r_1} \nonumber\\ &\stackrel{?}{=} (c_s a_s + c_d (1 - a_s)) (a_s + a_d (1 - a_s)) \nonumber\\ &\stackrel{?}{=} (c_s a_s + c_d - c_d a_s) (a_s + a_d - a_d a_s) \nonumber\\ &\stackrel{?}{=} c_s a_s^2 + c_s a_s a_d - c_s a_s^2 a_d \nonumber \\ &\quad + c_d a_s + c_d a_d - c_d a_d a_s \nonumber \\ &\quad - c_d a_s^2 - c_d a_s a_d + c_d a_s^2 a_d \nonumber \end{align}

代入两边:

左边(预乘)分子:cs+(1as)cdc_s + (1-a_s) c_d

右边:aout(asrgbs+(1as)rgbd)a_{out} (a_s rgb_s + (1-a_s) rgb_d)

cs=asrgbs,cd=adrgbd,aout=as+(1as)adc_s = a_s rgb_s, \quad c_d = a_d rgb_d, \quad a_{out} = a_s + (1-a_s) a_d 展开右边:

(as+(1as)ad)(ascs+(1as)cd)(a_s + (1-a_s) a_d) \cdot (a_s \cdot c_s + (1-a_s) \cdot c_d)

这个乘法化简后恰好等于 cs+(1as)cdc_s + (1-a_s) c_d(可逐项展开验证,也可用常见的 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个要点:

  1. 确保混合时 srcdst 都处在同一个色彩空间, 即都是线性空间或都是sRGB空间
  2. 确保混合时 srcdst 都处在同一个预乘空间, 即都是预乘空间或都是非预乘空间
  3. 混合上下游, 即 OpenGL 中的 glBlendFuncglBlendEquation 需要正确设置来对应所选择的预乘空间

glBlendFunc

永远认真阅读官方文档, 所有问题在这里都有答案 Khronos' Official Documentation - glBlendFunc

现象

正确混合 / Correct blending
混淆预乘 / Confuse premultiplication
正确混合时, 两个图层的边缘是平滑的。

参考资料

© 2020 - 2025 Ruiyao Luo

25/12/10 11:30

PROD

#68e6fa8