这是一份关于 RGBA8888 格式下,Pre-multiplied Alpha (预乘 Alpha, 简称 PreA) 与 Straight Alpha (非预乘 Alpha/直通 Alpha, 简称 Non-PreA) 在精度损失和不同颜色数据范围内表现的深度分析。
1. 基本定义与存储差异
在 RGBA8888 格式中,每个通道(R, G, B, A)占用 8 bits,取值范围为 $[0, 255]$。
Non-PreA (Straight Alpha):
- 存储原始颜色值。
- $R, G, B$ 的取值与 $A$ 无关。
- 公式: $(R, G, B, A)$
- 约束: $R, G, B \in [0, 255]$
PreA (Premultiplied Alpha):
- 存储颜色值乘以 Alpha 后的结果。
- 公式: $(R \times \frac{A}{255}, G \times \frac{A}{255}, B \times \frac{A}{255}, A)$
- 约束: 存储的 RGB 值通常不能大于 A(在整数运算中)。即 $R_{pre} \le A$。
2. 精度损失分析 (The Precision Loss)
精度损失的核心原因在于:我们是在有限的整数空间(0-255)内进行乘法和除法运算,必然涉及舍入(Rounding)或截断。
2.1 从 Non-PreA 转换到 PreA
这是一个有损过程,特别是当 Alpha 值很小时。
- 计算: $C_{pre} = \text{Round}(C_{src} \times \frac{A}{255})$
- 现象:
- 当 $A = 255$ 时:无损失。
- 当 $A = 128$ 时:颜色空间从 256 阶被压缩到 129 阶(0-128),损失 1 bit 精度。
- 当 $A = 10$ 时:原始 0-255 的颜色范围被强行压缩到 0-10 的范围内。
- 当 $A = 0$ 时:所有 RGB 信息被强制置为 0,颜色信息完全丢失。
2.2 从 PreA 还原到 Non-PreA (Un-premultiply)
这是对精度损失的放大过程。
- 计算: $C_{dst} = \text{Round}(C_{pre} \times \frac{255}{A})$
- 现象: 如果你拿到一张 PreA 的图片,且某个像素 $A=10, R_{pre}=5$。
- 还原时,$R = 5 \times 25.5 = 127.5 \approx 128$。
- 但是,原始的 Non-PreA 值可能是 115 到 140 之间的任何数,在这个过程中,细节已经不可逆地丢失了。这会导致直方图出现巨大的断层(Banding)。
3. 不同颜色/Alpha 数据范围内的表现
我们将情况分为三个区段来分析:完全不透明、半透明、完全透明。
3.1 高 Alpha 区段 ($A \approx 255$)
- 范围: $A \in [240, 255]$
- 分析:
- Non-PreA: 完美保留。
- PreA: 精度损失极小,肉眼几乎无法察觉。RGB 的有效值域依然接近 0-255。
- 结论: 此时两者表现基本一致。
3.2 低 Alpha 区段 ($A \in [1, 50]$) —— 精度灾难区
- 范围: 淡淡的阴影、玻璃的反光、几乎透明的特效。
- 分析:
- Non-PreA: 依然可以存储纯正的红色 $(255, 0, 0, 10)$。虽然看不清,但数据是高精度的。
- PreA: 必须将红色存储为 $(10, 0, 0, 10)$。
- 原始的 $RGB(255)$ 被压缩为 $10$。
- 原始的 $RGB(24)$ 被压缩为 $0$ 或 $1$。
- 后果:
- 如果在渲染管线后期需要对这个低 Alpha 图像进行 Color Balance(色彩平衡) 或 Gamma 校正,PreA 格式由于只有很少的几个台阶(Quantization steps),会出现严重的色带(Banding)。
- 混合运算: PreA 虽然存储精度低,但在混合(Composing)时通常是数学正确的。而 Non-PreA 如果直接混合,精度高但计算量大。
3.3 Zero Alpha 区段 ($A = 0$) —— 信息毁灭区
- 范围: 完全透明区域。
- 分析:
- Non-PreA: 可以存储 “隐藏颜色”。例如 $(255, 0, 0, 0)$。这在游戏开发中很有用,用于存储一些额外数据(如法线、遮罩),或者为了在纹理过滤(Bilinear Filtering)时不产生黑边。
- PreA: 强制变为 $(0, 0, 0, 0)$。
- 后果:
- PreA 无法在完全透明的像素中携带颜色信息。
- 黑边问题(Dark Halo)的逆向思考: 如果使用 Non-PreA 且透明区域是黑色 $(0,0,0,0)$,线性插值到不透明白色 $(255,255,255,255)$ 时,中间值会变黑。PreA 天生解决了插值黑边问题,因为它在插值前已经把颜色和 Alpha 绑定了。
4. 综合对比总结表
| 特性 | Non-PreA (RGBA) | PreA (RGBA) | 精度/效果评价 |
|---|---|---|---|
| 存储空间 | RGBA 各自独立 | RGB $\le$ A | PreA 有效颜色空间随 Alpha 减小而呈线性衰减。 |
| A=255 精度 | 100% (8-bit) | 100% (8-bit) | 一致。 |
| A=10 精度 | 100% (8-bit) | < 4% (约 3-4 bits) | PreA 严重丢失颜色精度。 |
| A=0 数据 | 保留原始 RGB | 丢失 (强制 RGB=0) | PreA 无法存储透明像素的颜色。 |
| 线性插值 (缩放) | 错误 (容易产生黑边/白边) | 正确 (物理上符合光线叠加) | PreA 在渲染质量上完胜。 |
| 混合公式 | $Src \times A + Dst \times (1-A)$ | $Src + Dst \times (1-A)$ | PreA 也就是少了一次乘法,效率略高。 |
| 色彩调整 | 安全 | 危险 | 对 PreA 图片做亮度/对比度调整会放大量化误差。 |
5. 结论与建议
作为存储格式 (Assets on Disk):
- 推荐 Non-PreA (如 PNG)。
- 原因:最大限度保留颜色精度,不破坏原始数据。如果设计师画了一个透明度为 1% 的红色光晕,Non-PreA 能保留它是“红色”的事实,而 PreA 存盘后再读出来可能就变成黑色或极低精度的噪点了。
作为渲染纹理 (Texture in GPU):
- 推荐 PreA。
- 原因:
- 插值正确性:GPU 进行双线性插值(Bilinear Interpolation)或生成 Mipmap 时,PreA 才是数学上正确的。Non-PreA 会导致透明物体边缘出现脏边。
- 混合效率:无需在 Shader 中每像素执行一次乘法。
特殊情况:
- 如果利用 RGB 通道做非颜色数据存储(如法线贴图、数据包),绝对不能使用 PreA,否则 Alpha 通道的变化会破坏数据。
一句话总结:
Non-PreA 赢在存储精度(特别是低 Alpha 时),PreA 赢在渲染正确性和混合效率。转换过程(Non-PreA -> PreA)是不可逆的精度丢失过程。
这是一个非常经典且反直觉的问题。要理解“脏边”(Dark Halo / Artifacts),我们需要深入到线性插值(Linear Interpolation)的数学计算步骤中去。
核心原因可以一句话概括:非 PreA 模式下,插值算法把“透明像素的RGB值”也算进去了,而绝大多数透明像素的 RGB 默认是黑色(0,0,0)。
我们通过一个具体的数值推演来演示这个“灾难”是如何发生的。
1. 场景设定:白色的边缘
假设你有一张图片,里面有一个纯白色的圆,边缘是平滑过渡到透明背景的。
我们在纹理(Texture)上选取相邻的两个像素:
- 像素 A (前景): 纯白,完全不透明。
- 数据:
R=255, G=255, B=255, A=255(归一化为 1.0)
- 数据:
- 像素 B (背景): 完全透明。
- 关键点: 在通常的图片格式(如 PNG)中,完全透明的像素通常被存储为
R=0, G=0, B=0, A=0。虽然它看不见,但它的 RGB 数据确实是黑色。
- 关键点: 在通常的图片格式(如 PNG)中,完全透明的像素通常被存储为
2. 渲染时的插值过程
当你在屏幕上绘制这张图,且进行了缩放或正好采样到 A 和 B 中间的位置时,GPU 会进行线性插值(Bilinear Filtering)。GPU 会分别对 R, G, B, A 四个通道取平均值(假设采样位置正好在正中间,权重各 50%)。
情况一:非 PreA (Straight Alpha) 的插值灾难
GPU 并不懂 RGB 和 A 的逻辑关系,它只是无脑地对四个通道做数学平均:
- R 通道插值: $(255 \times 0.5) + (0 \times 0.5) = 127.5$
- G 通道插值: $(255 \times 0.5) + (0 \times 0.5) = 127.5$
- B 通道插值: $(255 \times 0.5) + (0 \times 0.5) = 127.5$
- A 通道插值: $(255 \times 0.5) + (0 \times 0.5) = 127.5$ (即 0.5 的透明度)
得到的结果像素 P:R=127.5, G=127.5, B=127.5, A=127.5 (即 0.5)
渲染混合阶段 (Composing Stage):
现在的显卡要根据 Alpha 混合这个像素。公式通常是 Src.rgb * Src.a + Dst.rgb * (1 - Src.a)。
注意:因为是非 PreA,Shader 中需要先乘一次 Alpha。
- 最终显示的颜色贡献 = $P.rgb \times P.a$
- 计算: $127.5 \times 0.5 \approx 63.75$
结果分析:
我们期望的是“50% 透明度的白色”,也就是亮度应该约为 127.5。
但实际算出来的是亮度为 63.75 的颜色。
63.75 是什么颜色?是深灰色!
结论:原本应该呈现“半透明白色”的地方,显示成了“半透明灰色”。这就是黑边/脏边。
情况二:PreA (Premultiplied Alpha) 的完美表现
在 PreA 格式下,像素存储时 RGB 已经乘过 Alpha 了。
- 像素 A:
(255, 255, 255, 255)(因为 A=1,RGB不变) - 像素 B:
(0, 0, 0, 0)(因为 A=0,RGB必须是0)
看起来数据一样?别急,看混合。
插值过程:
- R 通道: $(255 + 0) / 2 = 127.5$
- A 通道: $(255 + 0) / 2 = 127.5$
得到像素 P:(127.5, 127.5, 127.5, 127.5)
渲染混合阶段:
PreA 的混合公式是 One, OneMinusSrcAlpha,也就是 Src.rgb + Dst.rgb * (1 - Src.a)。不需要再乘一次 Alpha,因为插值出来的 127.5 已经是物理上的光强了。
- 最终显示的颜色贡献 = $P.rgb$ (直接使用)
- 数值: 127.5
结果分析:
我们期望亮度 127.5,实际得到 127.5。
结果是完美的“半透明白色”。
3. 为什么会这样?(直观理解)
问题的本质在于:插值(Interpolation)和 预乘(Premultiplication)的顺序不能颠倒。
- 正确的物理逻辑: 先计算出每个点发出的光强(Pre-multiply),再把光强混合起来(Interpolate)。这就是 PreA。
- 错误的逻辑 (Non-PreA): 先把颜色混合起来,再根据透明度去衰减它。
在 Non-PreA 的例子中:
你把白色 (255) 和 黑色 (0) 进行了混合,得到了 **灰色 (128)**。
然后你告诉 GPU:“这个灰色是半透明的”。
于是 GPU 就画了一个半透明的灰色。
但在物理上,那个 黑色 (0) 是完全透明的,它不应该贡献任何颜色信息。但是线性插值愚蠢地把它的“黑色成分”拉进了平均值里,稀释了原本纯净的白色。
4. 只有黑色会导致脏边吗?(扩展知识)
不一定。
如果你的透明背景不是黑色 (0,0,0,0),而是白色 (255,255,255,0)(这叫 “Bleeding” 处理),那么 Non-PreA 插值时:(255+255)/2 = 255。
最终得到半透明白色,就没有黑边。
但是,在实际的游戏引擎和压缩格式中,为了节省空间或默认行为,透明区域的 RGB 通道几乎总是会被清零或压缩成乱码,所以黑边问题在 Non-PreA 中几乎是必然发生的。
总结:
非 PreA 出现脏边,是因为它让透明像素的 RGB 值(通常是黑色)错误地参与了颜色的混合,把边缘的颜色“染黑”了。