关于preA精度损失分析

这是一份关于 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. 结论与建议

  1. 作为存储格式 (Assets on Disk):

    • 推荐 Non-PreA (如 PNG)。
    • 原因:最大限度保留颜色精度,不破坏原始数据。如果设计师画了一个透明度为 1% 的红色光晕,Non-PreA 能保留它是“红色”的事实,而 PreA 存盘后再读出来可能就变成黑色或极低精度的噪点了。
  2. 作为渲染纹理 (Texture in GPU):

    • 推荐 PreA
    • 原因:
      1. 插值正确性:GPU 进行双线性插值(Bilinear Interpolation)或生成 Mipmap 时,PreA 才是数学上正确的。Non-PreA 会导致透明物体边缘出现脏边。
      2. 混合效率:无需在 Shader 中每像素执行一次乘法。
  3. 特殊情况:

    • 如果利用 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 数据确实是黑色。

2. 渲染时的插值过程

当你在屏幕上绘制这张图,且进行了缩放或正好采样到 A 和 B 中间的位置时,GPU 会进行线性插值(Bilinear Filtering)。GPU 会分别对 R, G, B, A 四个通道取平均值(假设采样位置正好在正中间,权重各 50%)。

情况一:非 PreA (Straight Alpha) 的插值灾难

GPU 并不懂 RGB 和 A 的逻辑关系,它只是无脑地对四个通道做数学平均:

  1. R 通道插值: $(255 \times 0.5) + (0 \times 0.5) = 127.5$
  2. G 通道插值: $(255 \times 0.5) + (0 \times 0.5) = 127.5$
  3. B 通道插值: $(255 \times 0.5) + (0 \times 0.5) = 127.5$
  4. 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)

看起来数据一样?别急,看混合。

插值过程:

  1. R 通道: $(255 + 0) / 2 = 127.5$
  2. 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 值(通常是黑色)错误地参与了颜色的混合,把边缘的颜色“染黑”了。