0x00 前言的前言

这篇小文其实是在清明节前后起的头,不过后来一度搁笔。一直到这周末才又想起来起的这个头还没有写完,所以还是直接用一个月前的开头,再将过程和结尾补齐。

0x01 前言

结束了在南方一周的出差,清明时节回到了刚好下过雪并且和南方有20多度温差的北京之后,终于有时间来写点文字了。这篇小文,我主要想来聊一聊在使用Unity时和gamma校正相关的话题。事实上关于Gamma校正的来源历史以及理论知识已经有很多相关的文章了,比如龚大的《gamma的传说》、Nvidia的Gpu Gems的文章等等。所以我在理论知识上只是稍作着墨,主要还是要来聊聊Unity中的Gamma校正的相关内容。

0x02 显示器和gamma校正

关于gamma校正来源的说法很多,具体可以参考龚大的《gamma的传说》的显示器说以及乐乐的《我理解的伽马校正》中所提到的人眼视觉特点说。两者都有道理,并且客观上这两个说法发生了有趣的巧合,最后达到了一个还不错的效果。

简单来说,过去的CRT显示器存在一个特点,即屏幕上显示的颜色对于传递而来的原始值并不是线性的(非线性) ,在这里非线性意味着以一个比率增加某个颜色分量,并不会导致显示器屏幕上的光强度增加相同的比例。举一个例子,假如我们将一个颜色的红色分量变成之前的两倍,显示器的屏幕所显示的红光并不会变成之前的两倍。

事实上CRT显示器的输入和输出之间的关系近似于一个指数关系,而这个指数便是我们常常听到的gamma。典型的gamma范围在2.0到2.4之间,一般该值常常以2.2作为折中。虽然后来的LCD并不存在这个特点,但是为了保证兼容,也选择了和当年CRT一样的非线性特性。
聊聊Unity的Gamma校正以及线性工作流-LMLPHP
上图中的红色实心线是在gamma = 2.2的情况下,显示器实际显示色彩强度的方式。 这一部分是由显示器的特性导致的。所以如果图片不做任何处理,经过pow(2.2)的操作之后显然会变得更暗,所以gamma校正就显得很有必要了。而gamma校正要做的事情也十分简单,即通过pow(1/2.2)将颜色强度提高,也就是上方的红色虚线,这样经过显示器时就会将显示器的pow(2.2)抵消掉。

同时,人眼对暗部的变化更加敏感,而对亮部变化其实不是很敏感。这可以以摄像作为一个例子,使用摄像机时,摄像机会把进入到镜头内的光线亮度编码成图像中的像素。
例如人们看到下面这张图,会自然而然的认为中间的地方即灰度为0.5的地方。
聊聊Unity的Gamma校正以及线性工作流-LMLPHP
事实上,摄像机“看到的”光线亮度如下图,上图中间部分的灰度其实只是在0.2左右。
聊聊Unity的Gamma校正以及线性工作流-LMLPHP

所以在只有8bit的情况下,没有必要在亮部浪费过多,这样就可以表现更多暗部的细节变化,所以实际亮度只有0.2经过gamma校正后实际被编码成了0.5的像素值。

当然,我个人认为显示器的特性是gamma校正出现的主要原因,而人眼对暗部的敏感而出现的gamma校正则更像是为了适应显示器这种特性而为的一种编码策略的“优化”。

0x03 硬件实现

sRGB 颜色空间是一个可以直接用来在显示器上显示的非线性颜色空间。
以OpenGL作为图形库为例,Unity实现Gamma校正以及Linear workflow借助了OpenGL的 texture_sRGB 以及 framebuffer_sRGB 相关拓展,事实上是一种硬件层面的实现。

EXT_texture_sRGB会对texture做pow2.2的gamma校正,将输入从sRGB空间转换到线性空间;而framebuffer_sRGB如果开启,并且输出的目标是sRGB颜色空间,则硬件会将结果再做一次pow0.45(为了方便,下文使用0.45代替1/2.2)的gamma校正,将结果从线性空间转换到sRGB颜色空间。这样保证输入的是正确的数据,并且中间的计算是线性的过程,最后的结果再转移到sRGB空间来中和显示器的输出,这样就能保证一个正确的光照计算结果。

下面我们可以通过RenderDoc来分别分析一下gamma workflow和linear workflow在安卓手机上的渲染流水线。
不过我在使用RenderDoc的目前正式版本(v1.0 - 6 Mar, 2018)时遇到了一些小问题,即我无法正常的通过RenderDoc启动我的App,总是会报下图中的错误。
聊聊Unity的Gamma校正以及线性工作流-LMLPHP

这其实是这个版本的一个bug,相关issue可以参考(https://github.com/baldurk/renderdoc/issues/903),解决方案的话就是不使用这个正式版,而是下载latest nightly build。

ok,回到正题。大家都知道,利用renderdoc我们可以很方便的查看渲染流水线的各种数据以及各种资源的参数等等,所以首先我们来看看在gamma空间下的整个工作流。
首先我在工程中倒入两张一样的图片,分别叫做gamma和linear,在gamma空间下一个勾选了导入设置中的sRGB,另一个则不勾选,并且都是用了ETC2的压缩格式。
聊聊Unity的Gamma校正以及线性工作流-LMLPHP

聊聊Unity的Gamma校正以及线性工作流-LMLPHP

可以看到两张图片并没有什么变化。下面我们来抓一帧来看一看两者在OpenGL中的纹理格式:
聊聊Unity的Gamma校正以及线性工作流-LMLPHP
聊聊Unity的Gamma校正以及线性工作流-LMLPHP
两者的格式都是GL_COMPRESSED_RGB8_ETC2。有趣吧,可以看到Unity设置了Gamma空间后,图片导入时无论是否选择勾选sRGB的结果都是一样的。

之后我们再来看一看FrameBuffer的情况:
聊聊Unity的Gamma校正以及线性工作流-LMLPHP
没有什么意外,同样是我们常见且习惯的格式——GL_RGBA8

这样整个gamma workflow的过程中没有涉及到所谓的gamma校正,整个过程和上一节中描述的一样——导入了经过处理的图片,最后再经过显示器的处理中和——传统且充满了巧合与错误。

接下来,我们将整个工程切换到Linear空间。同样,两张一样的图片一个勾选sRGB,另一个不勾选,并且同样是用了ETC2的压缩格式。
聊聊Unity的Gamma校正以及线性工作流-LMLPHP
聊聊Unity的Gamma校正以及线性工作流-LMLPHP
这次就更有趣了,我们可以看到勾选了sRGB的图片变暗了,而没有勾选的则仍然保持原样。并且,勾选sRGB的图片在下面的信息中显示是sRGB——它被作为一张sRGB纹理来看待,需要进行gamma校正;而另一张,则显示的是Linear——它被当作一张Linear纹理来看待,不需要经过gamma校正。
所以勾选了sRGB的纹理变的更暗了,这是因为经过了pow(2.2)的gamma校正处理。

下面我们来抓一帧来看一看在linear workflow下两者在OpenGL中的纹理格式:
聊聊Unity的Gamma校正以及线性工作流-LMLPHP
可以看到,勾选了sRGB选项的Texture在OpenGL中的格式为GL_COMPRESSED_SRGB8_ETC2,即硬件会对其作一次Pow2.2的gamma校正,将它转化到线性空间中。
聊聊Unity的Gamma校正以及线性工作流-LMLPHP
而没有勾选sRGB选项的Texture在OpenGL中的格式仍然是GL_COMPRESSED_RGB8_ETC2,所以硬件不会对它进行pow2.2的gamma校正操作,所以针对真正的线性空间图片不要勾选sRGB选项也就是这个原理——否则的话,颜色会比正确的结果更暗、数据也会错误。

不过有意思的还在后面,即framebuffer的格式。下面我们就来看一看framebuffer的抓帧结果:
聊聊Unity的Gamma校正以及线性工作流-LMLPHP

framebuffer的格式为GL_SRGB8_ALPHA8,即此时保存的结果经过了pow0.45的gamma校正,从线性空间转换到了sRGB空间——这当然是合理的,因为它要中和最后显示器的gamma校正——但是,有一件事情这时会变的比较棘手......

0x03 透明混合

是的,这件事情就是透明混合的问题。由于透明混合是一个线性的过程,因此在混合中作为Dst的那一方的framebuffer的数据就要是线性空间的了。
所以此时混合的操作事实上会先将framebuffer的内容从sRGB空间再次pow2.2转换到线性空间,和src进行混合,再将混合后的结果pow0.45转换回sRGB空间保存到framebuffer中。
是不是有点乱?
我们来写一下公式,代入一个数据就明白了:

ret = (srcColor^2.2 * srcAlpha + dstColor^2.2 * (1 - srcAlpha) ) ^(1/2.2)

ok,这时我们假设src的color值的g分量为1,alpha为0.2;dst的color值的g分量为0。则计算结果为:
0.481156505。

但是在我们的传统的认知下,或者是在Gamma workflow的情况下,这次混合的结果是什么呢?我们来写一下混合公式:

ret = srcColor * srcAlpha + dstColor * (1 - srcAlpha)

代入同样的数据,计算的结果为:
0.2。

两个差别颇大的结果,而如果混合的次数越多,则结果的差别也会更大。

事实上这个问题的处理目前也并没有一个特别十全十美的解决方案,目前常见的几种做法大概包括以下几种方案:

  • 使用gamma 1.0来制作资源,即在线性空间中制作资源。
  • 自己在ui的shader中对alpha进行pow(2.2)的操作,但是这个只是稍微修复问题,并没有真正的解决问题。
  • 仍然使用gamma space的workflow,但是涉及光照的shader自己来做pow 2.2和pow 0.45的校正。这样的话ui还在gamma space,而光照计算在linear space。

当然这里只是抛砖引玉,希望有更好解决方案的朋友能够在此多多分享一下经验。

Ref

http://www.klayge.org/2011/02/26/gamma的传说/
https://developer.nvidia.com/gpugems/GPUGems3
https://blog.csdn.net/candycat1992/article/details/46228771
https://renderdoc.org
https://www.khronos.org/registry/OpenGL/extensions/EXT/EXT_texture_sRGB.txt
https://www.khronos.org/registry/OpenGL/extensions/ARB/ARB_framebuffer_sRGB.txt

04-27 04:51