一、前言
- Premultiplied Alpha 的概念,做过游戏开发的应该都知道,Xcode 的工程选项里有一项 Compress PNG Files,会对 PNG 进行 Premultiplied Alpha,Texture Packer 中也有Premultiplied Alpha 的选项。那么,Premultiplied Alpha 到底是什么呢?
- 在 Alpha Blending: To Pre or Not To Pre 一文中,详情地阐明了 Premultiplied Alpha 的相关解释,如果还需要深入理解的可以阅读《Real Time Rendering》这本书。
- 在图形学中,Alpha 指的是除了颜色的三个分量(RGB)外的第四个分量:透明度。因此一个真彩色(指利用 RGB 分量合成颜色)的像素就变成由四个分量组成:R、G、B、A。我们这里讨论,设 R、G、B、A 均为从 0 到 1 的值,其中 Alpha = 0 为完全透明,Alpha = 1 为完全覆盖,中间的数值代表半透明,这样的设定是为了能使本文独立于显示硬件,我们把诸如(R,G,B,A)这样的东西称为四元组。一个这样的四元组代表一个由 RA、GA、B*A 组合而成的颜色。
- 有一点重要的是,要清楚分辨如下两个关键像素的意义:
黑色 = (0,0,0,1)
完全透明 = (0,0,0,0)
- 那么,如何根据 Alpha 通道数据进行混合的算法呢?
-
- 简单地,只需要把需要组合的颜色计算出不含 Alpha 分量的原始 RGB 分量然后相加便可,比如现在有两幅图象,分别称为图象 A 和图象 B,由这两幅图象组合而成的图象称为 C,则有如下的四元组:
A: (Ra,Ga,Ba,Alpha_a)
B: (Rb, Gb, Bb, Alpha_b)
-
- 以及组合后的 RGB 三元组:
C: (Rc, Gc, Bc)
-
- 那么:
Rc = Ra * Alpha_a + Rb * Alpha_b
Gc = Ga * Alpha_a + Gb * Alpha_b
Bc = Ba * Alpha_a + Bb * Alpha_b
-
- 便可得出混合后的颜色。如果有多幅图像需要混合,则按照以上方法两幅两幅地进行混合。
- 最常见的像素表示格式是 RGBA8888 即 (r, g, b, a),每个通道 8 位,0255。例如红色 60% 透明度就是(255, 0, 0, 153),为了表示方便,Alpha 通道一般记成正规化后的 0~1 的浮点数,也就是(255, 0, 0, 0.6)。而 Premultiplied Alpha 则是把 RGB 通道乘以透明度也就是(r * a, g * a, b * a, a),50% 透明红色就变成了(153, 0, 0, 0.6)。
- 透明通道在渲染的时候通过 Alpha Blending 产生作用,如果一个透明度为 as 的颜色 Cs 渲染到颜色 Cd 上,混合后的颜色通过以下公式计算:
文章图片
- 以 60% 透明的红色渲染到白色背景为例:
文章图片
- 也就是说,从视觉上(255, 0, 0, 0.6)渲染到白色背景上和(255, 102, 102)是同一个颜色。如果颜色以 Premultiplied Alpha 形式存储,也就是 Cs 已经乘以透明度了,所以混合公式变成:
文章图片
三、为什么要 Premultiplied Alpha?
- Premultiplied Alpha 后的像素格式变得不直观,因为在画图的时候都是先从调色板中选出一个 RGB 颜色,再单独设置透明度,如果 RGB 乘以透明度就搞不清楚原色是什么。
- 从前面的 Alpha Blending 公式可以看出,Premultiplied Alpha 之后,混合的时候可以少一次乘法,这可以提高一些效率,但这并不是最主要的原因,最主要的原因是:没有 Premultiplied Alpha 的纹理无法进行 Texture Filtering(除非使用最近邻插值)。
- 以最常见的 filtering 方式线性插值为例,一个宽 2px 高 1px 的图片,左边的像素是红色,右边是绿色 10% 透明度,如果把这个图片缩放到 1x1 的大小,那么缩放后 1 像素的颜色就是左右两个像素线性插值的结果,也就是把两个像素各个通道加起来除以2,如果使用没有 Premultiplied Alpha 的颜色进行插值,那么结果就是:
((255, 0, 0, 1) + (0, 255, 0, 0.1)) * 0.5 = (127, 127, 0, 0.55)
- 如果绿色 Premultiplied Alpha,也就是(0, 255 * 0.1, 0, 0.1),和红色混合后:
((255, 0, 0, 1) + (0, 25, 0, 0.1)) * 0.5 = (127, 25, 0, 0.55)
- Premultiplied Alpha 最重要的意义是使得带透明度图片纹理可以正常的进行线性插值,这样旋转、缩放或者非整数的纹理坐标才能正常显示,否则就会像上面的例子一样,在透明像素边缘附近产生奇怪的颜色。
- 使用的 PNG 图片纹理,一般是不会 Premultiplied Alpha 的。游戏引擎在载入 PNG 纹理后会手动处理,然后再 glTexImage2D 传给 GPU,比如 Cocos2D-x 中的 CCImage::premultipliedAlpha:
void Image::premultipliedAlpha() {
unsigned int* fourBytes = (unsigned int*)_data;
for (int i = 0;
i < _width * _height;
i++) {
unsigned char* p = _data + i * 4;
fourBytes[i] = CC_RGB_PREMULTIPLY_ALPHA(p[0], p[1], p[2], p[3]);
}
_hasPremultipliedAlpha = true;
}
- 而 GPU 专用的纹理格式,比如 PVR、ETC 一般在生成纹理都是默认 Premultiplied Alpha 的,这些格式一般是 GPU 硬解码,引擎用 CPU 处理会很慢。
- 总之 glTexImage2D 传给 GPU 的纹理数据最好都是 Multiplied Alpha 的,要么在生成纹理时由纹理工具 Pre-multiplied,要么载入纹理后由游戏引擎或 UI 框架 Post-multiplied。
- Core Graphics 的 CGImage.h 对图像透明度信息有如下定义:
typedef CF_ENUM(uint32_t, CGImageAlphaInfo) {
// ...
/* For example, premultiplied RGBA */
kCGImageAlphaPremultipliedLast,
/* For example, premultiplied ARGB */
kCGImageAlphaPremultipliedFirst,
// ...
};
- 预乘透明度(Premultiplied Alpha)图像简单地说,即每个颜色分量都乘以 alpha 通道值作为结果值:
color.rgb *= color.alpha
- 为什么关注预乘透明度图像?微信团队因 AR 抢红包场景的 OpenGL 混色结果出错引起注意:
Premultiplied alpha is better than conventional blending for several reasons:It works properly when filtering alpha cutouts _(see below)_It works properly when doing image composition _(stay tuned for my next post)_It is a superset of both conventional and additive blending. If you set alpha to zero while RGB is non zero, you get an additive blend. This can be handy for particle systems that want to smoothly transition from additive glowing sparks to dark pieces of soot as the particles age.It plays nice with DXT compression, which only supports transparent pixels with an RGB of zero.
六、理解 Premultiplied Alpha 的 Tips ① 理解 Alpha 混合
- 最常见的混合是“over”混合,假设已经有一张 RenderTexture,RT 上像素的 RGB 称其为 RGBdst,Alpha 为 Adst 。现在有一个像素(RGBsrc,Asrc)要和 RT 上的像素混合,那正确的混合会这样进行:
文章图片
文章图片
- 最终混合出来的颜色由两部组成:
-
- Asrc * RGBsrc 代表 RGBsrc 对最终颜色的贡献,它受 Alpha 影响,如果 Alpha 为 0 则对最终像素没有影响,如果 Alpha 为 1 则贡献 100% 的 RGBsrc;
-
- Adst * RGBdst 是 RT 中像素原本没有其它像素覆盖时的贡献值,但是现在被一个新来的像素遮挡了,被遮挡了 (1 - Asrc),因此 RT 中像素最终贡献 (Adst * RGBdst)*(1 - Asrc)。
- 由此可见,无论对于 src 还是 dst, A * RGB 才是实际的有效颜色,称其为 premultiplied alpha。令:
文章图片
- 则颜色混合可以改写为:
文章图片
- 这么看就更加清楚:
最终的输出 = 新叠加像素的有效颜色 + RT 中原有像素的有效颜色 * 新像素的遮挡
② SrcAlpha,OneMinusSrcAlpha 颜色混合正确有前提
- 在 Unity 中的透明混合,默认采用 SrcAlpha,OneMinusSrcAlpha 方式,用符号表示出来:
文章图片
- 对比之前给出的计算:
文章图片
- 加号右侧少乘 Adst ,这是为什么呢?因为 SrcAlpha, OneMinusSrcAlpha 正确的前提是混合目标是不透明的,即 Adst 为 1。
- 平时渲染时,常见的情况是先渲染不透明物体,再渲染不透明的天空盒,最后再渲染半透明物体做 Alpha 混合,在这种情况下渲染目标是不透明的,不透明物体的有效颜色即其颜色本身。
- 在满足这个前提下:
文章图片
- 才会成立,其本质为:
文章图片
- 依然是符合上文中给出的结论:
文章图片
- 只不过此时的 RGB’dst 等于 RGBdst。可能有人会产生疑问,不透明背景上混合半透明后,怎么看待混合后的透明度?我们看看上文中的 Alpha 的计算:
文章图片
- 发现没有,其本质是以 Asrc 作为参数的 Adst 到 1.0 线性插值。当 Adst 为 1.0 时,无论 Asrc 是何值,最终输出都是 1.0。回到现实中,这很好理解,砖墙前放一块玻璃,当我们将玻璃和墙看作一个整体时,它们是不透明的。
- 这种常见混合方式根据上文 ② 中的,其已默认渲染目标的 Alpha 为 1,因此它不关心 Alpha 结果的正确性。根据其表达式:
文章图片
- 可以清晰的看到,这里没有出现 Adst ,得出正确的 RGB 与 RT 中的 Alpha 存什么没有任何关联。
- 通过 SrcAlpha, OneMinusSrcAlpha 方式计算得到:
文章图片
- 这个结果没有意义。有些情况下,可以利用这种性质,将 RT 中没有被用到的 Alpha 通道利用起来,例如存储 bloom 系数。
- Premultiplied alpha 混合采用 One, OneMinusSrcAlpha,其实我们在 ① 中就已经看到:
文章图片
- 即:
文章图片
- One 就是这里的 1.0 而 OneMinusSrcAlpha 就是 (1 - Asrc) 。RGB’dst 来自于混合的结果,真正的问题是 RGB’rsc 如何获得,最简单方式就是纹理中的 RGB 预乘好 Alpha,那么采样得到的颜色直接就是有效 RGB。
- 在实践中,纹理的数据源大多是 RGBA32,即单通道 8 比特,只能表示 0-255 的整数,同时游戏资产还会根据目标平台做纹理压缩。
- 由于精度问题,原本相近的颜色在预乘后会存储为更相近,甚至相同的颜色,经压缩后很容易产生大量 artifacts。要使用预乘 Alpha 的纹理,一般会建议采用单通道 16 位的存储。
- 由于这种情况,即使预乘有很好的纹理过滤特性,也没有被广泛采用,我所了解 WebGL 由于网页对于 Alpha composition 的天然需求,做了这方面的支持。
- 采用 One, OneMinusSrcAlpha 混合有个很好的特性,可以统一 Blend 和 Additive,减少 BlendState 切换,还能增加效果,推荐阅读:A Mind Forever Programming。
- 简单理一下思路:
-
- 把非预乘纹理的采样到的 RGBA,在 shader 中输出 (RGB*A, A) 就是 Blend 模式;
-
- 把非预乘纹理的采样到的 RGBA,在 shader 中输出 (RGB*A, 0) 就是 Additive 模式。
- 输出的 Alpha 可以定义一个 uniform t 控制,输出 (RGBA, At ),这样通过 t 就是控制 Blend 和 Additive 模式之间的过渡。
- 如果再定义一个 uniform s,输出 (RGBAs, Ats),还可以通过 s 控制其整体透明度,用于淡入淡出,简直就是特效的救星。
- 众所周知,采用 Additive 模式的特效,在亮的场景中几乎看不到效果,而 Blend 模式的特效在暗的场景中提不亮。采用 One OneMinusSrcAlpha 就可以使用中间态来做出适配比较好的特效,而且不需要 framebuffer fetch。
- 换言之,预乘 alpha 混合得到的颜色也是预乘 alpha 的。细心的你可能会注意到,在 ① 中:
文章图片
- 作为运算结果的 RGB’result 是有 prime 符号的,正是想提示这一点。最终输出的有效颜色来自两部分:
-
- 叠加上去的 src 像素贡献的有效颜色;
-
- 背景 dst 像素贡献的有效颜色,它被 src 遮挡掉一部分,遮挡的量是 (1 - Asrc)。
- 观察 ① 中给出的两式:
文章图片
- (1)(2) 的计算过程是一样的,这就不禁会产生疑问:(1)式混合两个未预乘 alpha 的RGB,结果是预乘 Alpha 的RGB?这没错,未预乘 Alpha 的颜色经混合得到的是预乘 Alpha 的颜色。
- 那平时用 SrcAlpha, OneMinusSrcAlpha 为什么能得到未预乘的结果呢?正是 ② 中的原因,由于 SrcAlpha, OneMinusSrcAlpha 混合隐含了一个假设,渲染目标是不透明的,在这个前提下,用正确的混合公式计算,可以得到:
-
- 预乘 Alpha 的 RGB’result;
-
- Aresult = 1.0。
- 在 ② 中已经讲过,与不透明目标混合得到的 Alpha 恒为 1。显而易见,当 Alpha 为 1 时, RGB’result 等于 RGBresult 。因此(1)式在当渲染目标是不透明时,改成下式是成立的:
文章图片
⑧ 理解预乘 Alpha 混合公式的 Alpha 部分
- 预乘 Alpha 混合时,颜色分量和 Alpha 分量的运算是一致的,对比一下:
文章图片
- 都是:
文章图片
- 因此,不需要额外指定 Alpha 分量的混合公式,就能得到有意义的 Alpha 值,而且无论渲染目标是透明还是不透明,结果都是正确的。
- 凡是讲 premultiplied alpha 都会告诉你,可以通过以下方式,还原未预乘的颜色值:
文章图片
- 常见的、未预乘的颜色值也叫 straight alpha 或 unassociated alpha,而预乘好的叫 premultiplied alpha 或 associated alpha。这种还原操作在渲染自己可控的环境下几乎用不到。
- 根据上文中的 Premultiplied Alpha 运算是封闭的,预乘 Alpha 混合时运算封闭,可以多次混合不需要还原 straight alpha。但如果用未预乘 Alpha 混合时,如果渲染目标是半透明的,每次混合完成都要 unmultiply 回 straight alpha 才能继续混合,而且当一个网格有多层透明叠加时结果是错误的。
- 从实践上讲,预乘 Alpha 混合的结果需要 unmultiply 主要就这种情况:三方组件只接受 straight alpha 表示的纹理。Framebuffer 显示到屏幕上输出时,RT 最终总是不透明的,不透明的 Alpha 为 1,预乘和未预乘没有区别,也不用特殊处理。
- 预乘 alpha 和 bleed alpha 目的都是减少半透明纹理过滤产生的瑕疵,但它们有一些比较显著的区别:
-
- Bleed alpha 不需要修改混合公式;
-
- Bleed alpha 只能优化完全透明和非完全透明像素边缘的过滤瑕疵;
-
- 预乘 alpha 不仅可以达到 bleed alpha 的结果,半透像素之间的过滤效果也能得到优化;
-
- 预乘 alpha 需要修改混合公式,可能产生 tip6 中提到的情况。
- 当不使用 premultiplied alpha 时,预处理贴图 bleed alpha 是一个“免费”替代品。虽然效果上会有折扣,但性价比极高。
- 纹理预乘 alpha 可以减少 downsampling、upsampling、非 pixel perfect 各种情况下半透纹理过滤产生的 artifacts,推荐阅读:
-
- Alpha Blending: To Pre or Not To Pre;
-
- Beware of Transparent Pixels。