我相信几乎所有做图像处理方面的人都听过伽马校正(Gamma Correction)这一个名词,但真正明白它是什么、为什么要有它、以及怎么用它的人其实不多。我也不例外。
最初我查过一些资料,但很多文章的说法都不一样,有些很晦涩难懂。直到我最近在看《Real Time Rendering,3rd Edition》这本书的时候,才开始慢慢对它有所理解。
本人才疏学浅,写的这篇文章很可能成为网上另一篇误导你的“伽马传说”,但我尽可能把目前了解的资料和可能存在的疏漏写在这里。如有错误,还望指出。
关于这个方面,龚大写过一篇文章,但我认为其中的说法有不准确的地方。
从我找到的资料来看,人们使用伽马曲线来进行显示最开始是源于一个巧合:在早期,CRT几乎是唯一的显示设备。但CRR有个特性,它的输入电压和显示出来的亮度关系不是线性的,而是一个类似幂律(pow-law)曲线的关系,而这个关系又恰好跟人眼对光的敏感度是相反的。这个巧合意味着,虽然CRT显示关系是非线性的,但对人类来说感知上很可能是一致的。
我来详细地解释一下这个事件:在很久很久以前(其实没多久),全世界都在使用一种叫CRT的显示设备。这类设备的显示机制是,使用一个电压轰击它屏幕上的一种图层,这个图层就可以发亮,我们就可以看到图像了。但是,人们发现,咦,如果把电压调高两倍,屏幕亮度并没有提高两倍啊!典型的CRT显示器的伽马曲线大致是一个伽马值为2.5的幂律曲线。显示器的这类伽马也称为display gamma。由于这个问题的存在,那么图像捕捉设备就需要进行一个伽马校正,它们使用的伽马叫做encoding gamma。所以,一个完整的图像系统需要2个伽马值:
- encoding gamma:它描述了encoding transfer function,即图像设备捕捉到的场景亮度值(scene radiance values)和编码的像素值(encoded pixel values)之间的关系。
- display gamma:它描述了display transfer function,即编码的像素值和显示的亮度(displayed radiance)之间的关系。
如下图所示:
今天很幸运听了知乎上韩世麟童鞋的讲解。在听了他的讲座后,我听到了另一个版本的伽马传说。和上面的讨论不同,他认为伽马的来源完全是由于人眼的特性造成的。对伽马的理解和职业很有关系,长期从事摄影、视觉领域相关的工作的人可能更有发言权。我觉得这个版本更加可信。感兴趣的同学可以直接去知乎上领略一下。
我在这里来大致讲一下他的理解。
事情的起因可以从在真实环境中拍摄一张图片说起。摄像机的原理可以简化为,把进入到镜头内的光线亮度编码成图像(例如一张JEPG)中的像素。这样很简单啦,如果采集到的亮度是0,像素就是0,亮度是1,像素就是1,亮度是0.5,像素就是0.5。这里,就是这里,出现了一点问题!如果我们假设只用8位空间来存储像素的话,以为着0-1可以表示256种颜色,没错吧?但是,人眼有的特性,就是对光的灵敏度在不同亮度是不一样的。还是这张图Youtube: Color is Broken:
其实,对伽马传说的理解就算有偏差,也不会影响我们对伽马校正的使用。我们只要知道,根据sRGB标准,大部分显示器使用了2.2的display gamma来显示图像。
前面提到了,和渲染相关的是encoding gamma。我们知道了,显示器在显示的时候,会用display gamma把显示的像素进行display transfer之后再转换成显示的亮度值。所以,我们要在这之前,像图像捕捉设备那样,对图像先进行一个encoding transfer,与此相关的就是encoding gamma了。
而不幸的是,在游戏界长期以来都忽视了伽马校正的问题,也造成了为什么我们渲染出来的游戏总是暗沉沉的,总是和真实世界不像。
回到渲染的时候。我们来看看没有正确进行伽马校正到底会有什么问题。
以下实验均在Unity中进行。
我们来看一个最简单的场景:在场景中放置一个球,使用默认的Diffuse材质,打一个平行光:
混合其实是非常容易受伽马的影响。我们还是在Unity里创建一个场景,使用下面的shader渲染三个Quad:
Shader "Custom/Gamma Correction For Quad" {
Properties {
_MainTex ("Base (RGB)", 2D) = "white" {}
_Color ("Color", Color) = (1, 1, 1, 1)
}
SubShader {
Tags
{
"Queue" = "Transparent"
"IgnoreProjector" = "True"
"RenderType" = "Transparent"
}
Pass {
// Blend One One
Blend SrcAlpha OneMinusSrcAlpha
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
sampler2D _MainTex;
float4 _Color;
struct v2f {
float4 pos : SV_POSITION;
float4 uv : TEXCOORD0;
float4 normal : TEXCOORD1;
};
v2f vert(appdata_base i) {
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, i.vertex);
o.uv = i.texcoord;
return o;
}
float4 circle(float2 pos, float2 center, float radius, float3 color, float antialias) {
float d = length(pos - center) - radius;
float t = smoothstep(0, antialias, d);
return float4(color, 1.0 - t);
}
float4 frag(v2f i) : SV_Target {
float4 background = float4(0.0);
float4 layer1 = circle(i.uv, float2(0.5, 0.5), 0.3, _Color.rgb, 0.2);
float4 fragColor = float4(0.0);
fragColor = lerp(fragColor, layer1, layer1.a);
// fragColor = pow(fragColor, 1.0/1.8);
return fragColor;
}
ENDCG
}
}
FallBack "Diffuse"
}
上面的shader其实很简单,就是在Quad上画了个边缘模糊的圆,然后使用了混合模式来会屏幕进行混合。我们在场景中画三个这样不同颜色的圆,三种颜色分别是(0.78, 0, 1),(1, 0.78, 0),(0, 1, 0.78):
shader中非线性的输入最有可能的来源就是纹理了。
为了直接显示时可以正确显示,大多数图像文件都进行了提前的校正,即已经使用了一个encoding gamma对像素值编码。但这意味着它们是非线性的,如果在shader中直接使用会造成在非线性空间的计算,使得结果和真实世界的结果不一致。
在计算纹理的Mipmap时也需要注意。如果纹理存储在非线性空间中,那么在计算mipmap时就会在非线性空间里计算。由于mipmap的计算是种线性计算——即降采样的过程,需要对某个方形区域内的像素去平均值,这样就会得到错误的结果。正确的做法是,把非线性的纹理转换到线性空间后再计算Mipmap。
由于未进行伽马校正而造成的混合问题其实非常常见,不仅仅是在渲染中才遇到的。
Youtube上有一个很有意思的视频,非常建议大家看一下。里面讲的就是,由于在混合前未对非线性纹理进行转换,造成了混合纯色时,在纯色边界处出现了黑边。用数学公式来阐述这一现象就是:
我们的目标是:保证所有的输入都转换到线性空间,并在线性空间下做各种光照计算,最后的输出在通过一个encoding gamma进行伽马校正后进行显示。
在Unity中,有一个专门的设置是为伽马校正服务的,具体可以参见官方文档(Linear Lighting)。
简单来说就是靠Edit -> Project Settings -> Player -> Other Settings中的设置:
sRGB模式是在近代的GPU上才有的东西。如果不支持sRGB,我们就需要自己在shader中进行伽马校正。对非线性输入纹理的校正通常代码如下:
float3 diffuseCol = pow(tex2D( diffTex, texCoord ), 2.2 );
在最后输出前,对输出像素值的校正代码通常长下面这样:
fragColor.rgb = pow(fragColor.rgb, 1.0/2.2);
return fragColor;
但是,手工对输出像素进行伽马校正在使用混合的时候会出现问题。这是因为,校正后导致写入color buffer的颜色是非线性的,这样混合就发生在非线性空间中。一种解决方法时,在中间计算时不要对输出进行伽马校正,在最后进行一个屏幕后处理操作对最后的输出进行伽马校正,但很显然这会造成性能问题。
还有一些细节问题,例如在进行屏幕后处理的时候,要小心我们目前正在处理的图像到底是不是已经伽马校正后的。
总之,一切工作都是为了“保证所有的输入都转换到线性空间,并在线性空间下做各种光照计算,最后的输出(最最最最后的输出)进行伽马校正后再显示”。
虽然Unity的这个设置非常方便,但是其支持的平台有限,目前还不支持移动平台。也就是说,在安卓、iOS上我们无法使用这个设置。因此,对于移动平台,我们需要像上面给的代码那样,手动对非线性纹理进行转换,并在最后输出时再进行一次转换。但这又会导致混合错误的问题。
如果我们在Edit -> Project Settings -> Player -> Other Settings中使用了Linear Space,那么之前的光照、混合问题都可以解决(这里的解决是说和真实场景更接近)。但在处理纹理时需要注意,所有Unity会把所有输入纹理都设置成sRGB格式,也就说,所有纹理都会被硬件当成一个非线性纹理,使用一个display gamma(通常是2.2)进行处理后,再传递给shader。但有时,输入纹理并不是非线性纹理就会发生问题。
例如,我们绘制一个亮度为127/255的纹理,传给shader后乘以2后进行显示:
伽马校正一直是个众说纷纭的故事,当然我写的这篇也很可能会有一些错误,如果您能指出不胜感激。
即便关于一些细节问题说法很多,但本质是不变的。GPU Gems上的一段话可以说明伽马校正的重要性:
This is one reason why most (but not all) CG for film looks much better than games—a reason that has nothing to do with the polygon counts, shading, or artistic skills of game creators. (It’s also sometimes a reason why otherwise well-made film CG looks poor—because the color palettes and gammas have been mismatched by a careless compositor.)
最后,给出GPU Gems中的一段总结,以下步骤应该在游戏开发中应用:
1. 假设大部分游戏使用没有校正过的显示器,这些显示器的display gamma可以粗略地认为是2.2。(对于更高质量要求的游戏,可以让你的游戏提供一个伽马校正表格,来让用户选择合适的伽马值。)
2. 在对非线性纹理(也就是那些在没有校正的显示器上看起来是正确的纹理)进行采样时,而这些纹理又提供了光照或者颜色信息,我们需要把采样结果使用一个伽马值转换到线性空间中。不要对已经在线性颜色空间中的纹理,例如一些HDR光照纹理、法线纹理、凹凸纹理(bump heights)、或者其他包含非颜色信息的纹理,进行这样的处理。对于非线性纹理,尽量使用sRGB纹理格式。
3. 在显示前,对最后的像素值应用一个伽马校正(即使用1/gamma对其进行处理)。尽量使用sRGB frame-buffer extensions来进行有效自动的伽马校正,这样可以保证正确的混合。
所幸的是,在Unity中,上面的过程可以通过设置Edit -> Project Settings -> Player -> Other Settings->Color Space轻松地完成,需要注意的是对纹理的处理。但不幸的是,不支持移动平台。
最后,一句忠告,在游戏渲染的时候一定要考虑伽马校正的问题,否则就很难得到非常真实的效果。
下面有一些文章是我觉得很好的资料,但是其中有很多说法是有争议的,希望大家能自己评估: