这篇文章被拖延得这么久是因为我没有找到合适的引言
-- XXX
这一篇接着讲Gamma。近几年基于物理的渲染(Physically Based Shading, 后文简称PBS)开始在游戏业界受到关注并迅速流行。10年、12年以及今年的siggraph都专门开了一个course来介绍基于物理的渲染的基本理论和工程实践,这在很大程度上推动了PBS的普及。如果你对PBS还不太了解的话,不妨先看一看龚大写的系列文章。在从传统渲染管线切换到PBS管线的过程中,一个非常基础而又重要的概念叫做伽马正确(Gamma Correct),为了保证伽马正确需要做的一个操作叫做伽马校正(Gamma Correction)。Gamma Correction其实就是一个颜色的变换,它将输入的颜色从Nonlinear Color Space转换到Linear Color Space,然后在输出到Frame Buffer时又重新转换到Nonlinear Color Space。到这里问题来了,为什么我们需要Gamma Correction? 为什么输入的颜色会是Nonlinear而不是Linear的? 为什么再将图像送到Frame Buffer进行显示时又需要重新转换到Nonlinear Color Space?为了解释这些问题,先来谈一谈什么是Gamma。
网上有很多介绍Gamma的文章,但是其中一些写得啰里吧嗦大家不愿意看(比如维基百科),另一些又解释得不十分准确(比如百度百科)。其实要理解Gamma,只需要抓住下面两点:
- 历史原因: Gamma Correction首先是为了补偿老式CRT显示器的输入电压(Voltage)到输出强度(Intensity)的非线性效应;
- 编码的效率问题 : 在使用定点数编码(比如最常用的R8B8G8格式)的情况下,将颜色表示在更符合视觉响应曲线的Nonlinear Color Space有助于提高bits的利用率,避免出现肉眼可观察的色阶断层(Banding)。
如果你之前就已经对Gamma Correction有所了解,看到上面两点应该就已经很明白了。如果还是不太理解,没关系,下面就再来详细解释一下。首先来看第一点。CRT显示器在显示一幅图像时,从Frame Buffer里面的RGB颜色值到Voltage的转化是线性的,而从Voltage到最终输出的Intensity是非线性的,这个非线性关系近似于 $I = alpha V^{gamma_d}$。这个$gamma_d$叫做Display Gamma, 其值通常在2.35和2.55之间($gamma_d$和$alpha$的值还可以手动进行微调,分别对应于显示器的亮度和对比度参数)。现在的LCD显示器本来不存在这个问题,但是为了与CRT显示器保持兼容,也都采用了这样一种非线性的转换关系。因此当我们计算出或者捕捉到一幅图像时,为了让最终的显示效果正确,需要将颜色值预先pow一个$gamma_e$以补偿这个非线性效应,这个过程叫做Gamma Encoding,$gamma_e$也叫做Encoding Gamma。理论上,为了完全抵消掉了Display Gamma的pow关系,应该有$gamma_e gamma_d = 1$,但事实上并非如此,比如一般所采用的$gamma_e$在0.45左右,而$gamma_d$约在2.5左右,这使得$gamma_t = gamma_e gamma_d = 1.125 $。这里的$gamma_t$叫做End-to-End Gamma,其值通常不会是1,这是为了补偿观察环境不同造成的人眼对颜色的感知变化(Surround Effect)。
现在说第二点,来考察一下人眼的非线性效应与编码的效率之间的关系。显示器输出的Intensity与人眼所感知到的亮度(Lightness)也是非线性的(近似于一个对数关系),我们用函数$y = v(x)$来表示。如果假设没有显示器的非线性效应,没有Gamma Correction,那从真实的物理上的亮度(Radiance)到被人眼观察到形成视觉信号(Lightness)的完整过程是这样的:
- 输入图像,用$x$来表示;
- 将$x$进行编码(比如按照R8B8G8表示为24位色),这实际上是一个量化的过程,得到$q(x)$。因为编码的位深是有限的,因此在这一步会出现误差;
- 将$q(x)$送入Frame Buffer,并在显示器上输出,因为这里没有非线性效应,所以输出还是$q(x)$;
- 人眼进行观察,最终观察到的Lightness为$(v cdot q) (x)$.
分析一下因为量化而造成的Banding。因为我们是使用的定点数编码,这意味着量化抽样是均匀分布的。所以$q(x)$的Banding即为抽样的间隔$delta$,而人眼所感受到的Lightness的Banding可以近似表示为$K(v)delta$,其中$K(v)$表示$v(x)$的最大斜率。现在如果加上Gamma Correction,也就是在$x$量化前后分别进行一次Gamma Encoding(用$e(x)$表示)和Display Transfer(用$d(x)$表示),那么此时Lightness的表达式为$(v cdot d cdot q cdot e) (x)$。注意误差的扩大总是由$q$左边的项所引起,因此最终的Banding即为$K(v cdot d)delta$。一个幸运的巧合时,人眼的亮度感知曲线$v$与Display Transfer的曲线刚好差不多相反,于是有$v cdot d approx identity$,因此$K(v cdot d)delta approx delta < K(v) delta$。也就是说,虽然人眼感知的非线性效应会将量化误差扩大化,但是Display Transfer正好将此抵消掉了。所以为了减轻Banding,Display Transfer是很有必要的,相应的Gamma Encoding当然也必不可少。
好了,前面说了一通Gamma,现在我们再回到Alpha。Alpha跟Gamma有什么关系?答案是没有关系。在上一篇中我们说了Alpha的两种意义,一种是表示Coverage,一种是表示Opacity,但是后者并没有物理上的意义,如果非要找一个的话,它其实还是Coverage。而Coverage按理来说自然与Gamma Encoding或者Tone Mapping都没什么关系,或者说,Alpha Blending应该发生在这二者之前。但是,图像处理软件的默认颜色空间一般是sRGB,混合也是在sRGB空间中进行的,这在下图中表现得很明显,
上图的opacity对应于PS中的图层不透明度。如果你的显示器正常的话,你应该可以看到checkerboard(50% coverage)大概与73% opacity看上去一样(73%约等于pow(50%, 0.45))。在PS中将上图缩小至一半大小,
此时checkerboard又变得跟50% opacity完全相同。有点奇怪是不是? 这是因为PS中的filter也是在sRGB空间中进行的。我们当然可以认为在sRGB空间进行filter或者alpha compositing都是错的,但是现在美术的Workflow大多都还是基于sRGB的,“理论正确”的洁癖在这里没有什么意义。sRGB的Workflow与Linear RGB的渲染管线共存是目前必须面对的局面(Fox Engine对Linear Workflow的探索似乎指出了未来发展的方向),这样带来的一个小麻烦就是美术与程序对Alpha的感觉不太一样,就如上图所示的那样,美术认为的73% opacity在程序的眼中应该是50%(注意这里的不透明度换算关系仅当背景是黑色的才有效)。那么怎么解决这一问题呢?分两种情况,
- 如果是带Alpha通道的3D贴图,opacity有些不一样关系并不会太大,有美术抱怨的话向他们解释一下原因,必要时让他们参照实际渲染出来的效果对贴图的Alpha相应做一下调整即可,
- 如果是UI贴图,因为需要准确地还原在DCC软件中的效果,唯一合理的做法是将UI的渲染放到Gamma Correction之后,关闭sRGB Read/Write直接写到LDR Buffer。相信你的引擎肯定也的确是这么干的。有些引擎比较奇葩一点(比如 Unreal),它使用sRGB Read,但并不开启sRGB Write而是自己来做Gamma Correction,注意sRGB space != pow(1/2.2) space,在这种情况下简单的一个pow(2.2)的精确度是不够的,正确的做法可以参考sRGB spec。
另外再说一下在前一篇中提到的3D UI问题。当时说的一个方案是先将底层的UI渲染到Linear的Render Target上,然后再往上面渲染3D内容。 但是经过前面的讨论你应该也可以感受到把UI渲染到Linear Buffer上会有多么的麻烦。如果你的UI底图有多层的话,那么它们应该先在sRGB空间中混合; 另外在拷贝到Linear Buffer上的时候同样也需要注意到sRGB space != pow(1/2.2) space的问题,如果你最后的Gamma Correction是pow(1/2.2),那这里就不能使用sRGB Read而是需要手动pow(2.2); 如果还有Tone Mapping的话,这个地方也需要有相应的处理; 如果Tone Mapping还是自动exposure的或者还有Color Grading,这就基本上搞不定了。所以还是别纠结了,换另一种方案,用Premultiplied Alpha吧。
在上一篇中讲到,Premultiplied Alpha混合比传统混合更强大更优雅。那么当Premultiplied Alpha遇上Gamma会发生什么事呢?其实并没有什么特别的地方,只是在将sRGB空间中的颜色$(sR, sG, sB, A)$转换成Premultiplied Alpha表示时需要注意一下,如果直接按照与Linear RGB相同的方式处理,即:
$C_1 = (sR cdot A, sG cdot A, sB cdot A, A)$
在渲染时,将$C_1$按照sRGB纹理进行读取,得到
$C_2 = (R cdot A^{2.2}, G cdot A^{2.2}, B cdot A^{2.2}, A)$
因此若想还原得到Linear的$(R cdot A, G cdot A, B cdot A, A)$,还需要先计算出$A^{2.2}$,然后再用它来除一下$C_2$的RGB通道,相当繁琐。这里主要的麻烦在于那个$A^{2.2}$,为了避免它,可以预先将$A$也转换到sRGB空间,
$C_3 = (sR cdot sA, sG cdot sA, sB cdot sA, A)$
这样对$C_3$进行sRGB decoding之后就直接得到$(R cdot A, G cdot A, B cdot A, A)$。