Gamma校正

  • 1. Gamma 值
  • 2. Gamma校正
    • 2.1 使用OpenGL内建的sRGB帧缓存
    • 2.2 在片元着色器中自己应用gamma校正
  • 3. sRGB纹理
    • 3.1 使用 与 实验结果
  • 4. 衰减 Attenuation

1. Gamma 值

当我们计算出场景中所有像素的最终颜色以后,我们需要把它们显示在监视器上。过去,大多数监视器是阴极射线管显示器(CRT)。这些监视器有一个物理特性就是两倍的输入电压产生的不是两倍的亮度。两倍输入电压产生约为2.2次幂的亮度,称为显示器的Gamma。

Gamma也叫灰度系数,每种显示设备都有自己的Gamma值,都不相同,有一个公式:设备输出亮度 = 电压的Gamma次幂,任何设备Gamma基本上都不会等于1,等于1是一种理想的线性状态,这种理想状态是:如果电压和亮度都是在0到1的区间,那么多少电压就等于多少亮度。对于CRT,Gamma通常为2.2,因而,输出亮度 = 输入电压的2.2次幂,你可以从本节第二张图中看到Gamma2.2实际显示出来的总会比预期暗,相反Gamma0.45就会比理想预期亮,如果你将Gamma0.45叠加到Gamma2.2的显示设备上,便会对偏暗的显示效果做到校正,这个简单的思路就是本节的核心

人类所感知的亮度恰好和CRT所显示出来相似的指数关系非常匹配。为了更好的理解所有含义,请看下面的图片:

第一行是人眼所感知到的正常的灰阶,亮度要增加一倍(比如从0.1到0.2)你才会感觉比原来变亮了一倍(译注:这里的意思是说比如一个东西的亮度0.3,让人感觉它比原来变亮一倍,那么现在这个亮度应该成为0.6,而不是0.4,也就是说人眼感知到的亮度的变化并非线性均匀分布的。问题的关键在于这样的一倍相当于一个亮度级,例如假设0.1、0.2、0.4、0.8是我们定义的四个亮度级别,在0.1和0.2之间人眼只能识别出0.15这个中间级,而虽然0.4到0.8之间的差距更大,这个区间人眼也只能识别出一个颜色)。然而,当我们谈论光的物理亮度,比如光源发射光子的数量的时候,底部(第二行)的灰阶显示出的才是物理世界真实的亮度。如底部的灰阶显示,亮度加倍时返回的也是真实的物理亮度(译注:这里亮度是指光子数量和正相关的亮度,即物理亮度,前面讨论的是人的感知亮度;物理亮度和感知亮度的区别在于,物理亮度基于光子数量,感知亮度基于人的感觉,比如第二个灰阶里亮度0.1的光子数量是0.2的二分之一),但是由于这与我们的眼睛感知亮度不完全一致(对比较暗的颜色变化更敏感),所以它看起来有差异。

因为人眼看到颜色的亮度更倾向于顶部的灰阶,监视器使用的也是一种指数关系(电压的2.2次幂),所以物理亮度通过监视器能够被映射到顶部的非线性亮度;因此看起来效果不错(译注:CRT亮度是是电压的2.2次幂而人眼相当于2次幂,因此CRT这个缺陷正好能满足人的需要)。(上面两段话不是很理解,我的理解是第一行是人眼从监视器中实际看到的亮度,第二行是物理世界应该呈现的亮度,注意这里的“实际”和“应该”,我们在程序中一般都是直接设置颜色值,但是监视器有个gamma值,使得最终显示的颜色值会比设置的颜色值要暗一些。具体原理继续往下看

监视器的这个非线性映射的确可以让亮度在我们眼中看起来更好,但当渲染图像时,会产生一个问题:我们在应用中配置的亮度和颜色是基于监视器所看到的,这样所有的配置实际上是非线性的亮度/颜色配置。请看下图:

横轴是设置的颜色值,纵轴是像素的亮度值

点线(中间的那条)代表线性空间中的颜色/亮度值(译注:这表示的是理想状态,Gamma为1),实线代表监视器显示的颜色。如果我们把一个点线的颜色翻倍,结果就是这个值的两倍。比如,光的颜色向量 L⃗  L → =(0.5,0.0,0.0)代表的是暗红色。如果我们在线性空间中把它翻倍,就会变成(1.0,0.0,0.0),就像你在图中看到的那样。然而,由于我们定义的颜色仍然需要输出的监视器上,监视器上显示的实际颜色就会是(0.218,0.0,0.0)。在这儿问题就出现了:当我们将理想中直线上的那个暗红色翻一倍时,在监视器上实际上亮度翻了4.5倍以上!因为我们一般都是在线性空间中工作的,即想把颜色(0.5,0.0, 0.,0)变为(1.0,0.0,0.0),这都是在线性空间上定义的,在实际显示的时候,因为监视器的gamma值,导致(0.5,0.0,0.0)在实际显示为(0.218,0.0,0.0),我们最终目标颜色是(1.0,0.0,0.0),那么实际颜色就需要翻4.5倍才到目标颜色。

直到现在,我们还一直假设我们所有的工作都是在线性空间中进行的(译注:Gamma为1)就是假设一直在上图中直线空间中工作),但最终还是要把所有的颜色输出到监视器上,所以我们配置的所有颜色和光照变量从物理角度来看都是不正确的,在我们的监视器上很少能够正确地显示。出于这个原因,我们(以及艺术家)通常将光照值设置得比本来更亮一些(由于监视器会将其亮度显示的更暗一些),如果不是这样,在线性空间里计算出来的光照就会不正确。同时,还要记住,监视器所显示出来的图像和线性图像的最小亮度是相同的,它们最大的亮度也是相同的;只是中间亮度部分会被压暗。实际想要的颜色是上图中点线的情况,但最终显示的是上图中实线的情况,监视器对设置的颜色值有一个衰减的作用。

监视器的衰减作用:监视器显示的颜色 = 设置的颜色值的gamma次幂,如果gamma值为2.2,那么为设置的颜色值的2.2次幂。

因为所有中间亮度都是线性空间计算出来的(译注:计算的时候假设Gamma为1)监视器显以后,实际上都会不正确。当使用更高级的光照算法时,这个问题会变得越来越明显,你可以看看下图:

2. Gamma校正

Gamma校正(Gamma Correction)的思路是在最终的颜色输出上应用监视器Gamma的倒数。回头看前面的Gamma曲线图,你会有一个短划线,它是监视器Gamma曲线的翻转曲线。我们在颜色显示到监视器的时候把每个颜色输出都加上这个翻转的Gamma曲线,这样应用了监视器Gamma以后最终的颜色将会变为线性的。我们所得到的中间色调就会更亮,所以虽然监视器使它们变暗,但是我们又将其平衡回来了。

我们来看另一个例子。还是那个暗红色(0.5,0.0,0.0)。在将颜色显示到监视器之前,我们先对颜色应用Gamma校正曲线。线性的颜色显示在监视器上相当于降低了2.2次幂的亮度,所以倒数就是1/2.2次幂。Gamma校正后的暗红色就会成为 (0.5,0.0,0.0)12.2=(0.5,0.0,0.0)0.45=(0.73,0.0,0.0) ( 0.5 , 0.0 , 0.0 ) 1 2.2 = ( 0.5 , 0.0 , 0.0 ) 0.45 = ( 0.73 , 0.0 , 0.0 ) 。校正后的颜色接着被发送给监视器,最终显示出来的颜色是 (0.73,0.0,0.0)2.2=(0.5,0.0,0.0) ( 0.73 , 0.0 , 0.0 ) 2.2 = ( 0.5 , 0.0 , 0.0 ) 。你会发现使用了Gamma校正,监视器最终会显示出我们在应用中设置的那种线性的颜色。

2.2通常是是大多数显示设备的大概平均gamma值。基于gamma2.2的颜色空间叫做sRGB颜色空间。每个监视器的gamma曲线都有所不同,但是gamma2.2在大多数监视器上表现都不错。出于这个原因,游戏经常都会为玩家提供改变游戏gamma设置的选项,以适应每个监视器(译注:现在Gamma2.2相当于一个标准,后文中你会看到。但现在你可能会问,前面不是说Gamma2.2看起来不是正好适合人眼么,为何还需要校正。这是因为你在程序中设置的颜色,比如光照都是基于线性Gamma,即Gamma1,所以你理想中的亮度和实际表达出的不一样,如果要表达出你理想中的亮度就要对这个光照进行校正)。

有两种在场景中应用gamma校正的方法:
1. 使用OpenGL内建的sRGB帧缓存 ;
2. 在片元着色器中自己应用gamma校正。

2.1 使用OpenGL内建的sRGB帧缓存

第一种方法是最简单的,但是也会没有一些控制权。开启GL_FRAMEBUFFER_SRGB后,OpenGL的后面的每个绘制命令里,在将颜色储存到颜色缓存(color buffer)之前都会先校正sRGB颜色。sRGB这个颜色空间大致对应于gamma2.2,它是家用设备的一个标准。开启GL_FRAMEBUFFER_SRGB以后,每次片元着色器渲染后的结果传到帧缓存的时候(包括默认的帧缓存),OpenGL将自动执行gamma校正。

开启GL_FRAMEBUFFER_SRGB简单的调用glEnable就行:

glEnable(GL_FRAMEBUFFER_SRGB);

自此,你渲染的图像就已经被gamma校正过,不需要做任何事情,硬件就帮你处理了。有时候,你应该记得这个建议:gamma校正把线性颜色空间转变为非线性空间,所以在最后一步进行gamma校正是极其重要的。如果你在最后输出之前就进行gamma校正,所有的后续操作都是在操作不正确的颜色值。例如,如果你使用多个帧缓冲,你可能打算让两个帧缓冲之间传递的中间结果仍然保持颜色在线性空间,只是给发送给监视器的最后的那个帧缓冲应用gamma校正,这时候使用opengl内建的sRGB帧缓存的方法会导致不正确的结果。(这里的理解是在存进帧缓存(framebuffer)之前,片元着色器的计算结果还会先进行gamma校正,gamma校正过的结果再存进帧缓存,此时如果进行延迟渲染、阴影映射等,就是需要帧缓存中的结果做进一步的操作的时候,会导致不正确的结果,帧缓存此时存储的是gamma校正过的值,而我们需要的是没有gamma校正过的值,在最终的显示的时候才去gamma校正,但是第一种方法无法做到

如何理解sRGB帧缓存?我的理解是:启用GL_FRAMEBUFFER_SRGB就是告诉opengl此时framebuffer中的color附件类型为sRGB,这样当片元着色器运行完后,其结果将要存进framebuffer的时候,先对该结果进行gamma值为2.2的gamma校正,再将最终的结果存进framebuffer中,这都是由硬件帮我们完成的。sRGB空间就是上面曲线图中虚线部分,可以理解为如果一个颜色在sRGB空间中,那么该颜色会先被gamma校正后,再作为一个可以操作的颜色(输出or其他)。

2.2 在片元着色器中自己应用gamma校正

第二种方法稍微复杂点,但同时也是我们对gamma操作有完全的控制权。我们在每个片元着色器的最后应用gamma校正,所以在发送到帧缓冲前,颜色就被校正了。

片元着色器的代码如下所示:

void main()
{
    // do super fancy lighting 
    [...]
    // apply gamma correction
    float gamma = 2.2;
    fragColor.rgb = pow(fragColor.rgb, vec3(1.0/gamma));
}

最后一行代码,将fragColor的每个颜色元素应用有一个1.0/gamma的幂运算,校正像素着色器的颜色输出。

这个方法有个问题就是为了保持一致,你必须在片元着色器里加上这个gamma校正,所以如果你有很多片元着色器,它们可能分别用于不同物体,那么你就必须在每个片元着色器里都加上gamma校正了。一个更简单的方案是在你的渲染循环中引入后处理阶段,在后处理四边形上应用gamma校正,这样你只要做一次就好了。

第二种方法就不会出现第一种方法的问题。gamma校正过后,场景会普遍变亮一些。

3. sRGB纹理

因为监视器显示的是应用了gamma校正过后的颜色(在sRGB空间),当你无论什么时候在电脑上绘制、编辑或者画出一个图片的时候,你所选的颜色都是根据你在监视器上看到的那种。这实际意味着所有你创建或编辑的图片并不是在线性空间,而是在sRGB空间中(译注:sRGB空间定义的gamma接近于2.2),假如在你的屏幕上对暗红色翻一倍,便是根据你所感知到的亮度进行的,并不等于将红色元素加倍。==现在选择的颜色值就是上面曲线图虚线部分对应的颜色值。==

结果,纹理编辑者创建的所有纹理都是在sRGB空间中的纹理,所以如果我们在渲染应用中使用这些纹理,必须考虑到这一点。在我们应用gamma校正之前,不会出现任何问题,因为纹理在sRGB空间创建和展示,同样我们还是在sRGB空间中使用,从而不必gamma校正,纹理显示也没问题。然而,现在我们是把所有东西都放在线性空间中展示的,纹理颜色就会变奇怪,如下图展示的那样:

纹理图像实在太亮了,发生这种情况是因为右图实际上进行了两次gamma校正!==使得图片亮度被增强了两次,但监视器gamma只执行一次衰减==。想一想,想一想,当我们基于监视器上看到的情况创建一个图像,我们就已经对颜色值进行了gamma校正,所以再次显示在监视器上就没错。由于我们在渲染中又进行了一次gamma校正,图片就实在太亮了。

为了修复这个问题,我们得确保纹理编辑者是在线性空间中进行创作的。但是,由于大多数纹理制作者并不知道什么是gamma校正,并且在sRGB空间中进行创作更简单,这也许不是一个好办法。一般为了追求模型的真实性,纹理编辑者都是在sRGB空间中处理纹理,这样监视器显示的是真实的颜色值,此时创建的纹理已经被gamma校正过的,但是对于渲染引擎来说,我们需要的是在线性空间中的纹理(那条直线),纹理编辑者给我们的纹理是处于sRGB空间(那条虚线)

一个解决方法是重新校正,或把这些sRGB纹理在进行任何颜色值的计算前变回线性空间。我们可以这样做:

float gamma = 2.2;
vec3 diffuseColor = pow(texture(diffuse, texCoords).rgb, vec3(gamma));

为每个sRGB空间的纹理做这件事非常烦人。幸好,OpenGL给我们提供了另一个方案来解决我们的麻烦,这就是GL_SRGB和GL_SRGB_ALPHA内部纹理格式。

如果我们在OpenGL中创建了一个纹理,把它指定为以上两种sRGB纹理格式其中之一,OpenGL将自动把颜色校正到线性空间中,这样我们所使用的所有颜色值都是在线性空间中的了。我们可以这样把一个纹理指定为一个sRGB纹理:

glTexImage2D(GL_TEXTURE_2D, 0, GL_SRGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, image);

如果你还打算在你的纹理中引入alpha元素,必须将纹理的内部格式指定为GL_SRGB_ALPHA。==注意上面函数的第三个参数的变化,之前我们是GL_RGB,这里我们改成了GL_SRGB。==

因为并不是所有纹理都是在sRGB空间中,所以当你把纹理指定为sRGB纹理的时候需要格外小心。比如diffuse纹理,这种为物体上色的纹理几乎都是在sRGB空间中的。而为了获取光照参数的纹理,像specular贴图和法线贴图几乎都在线性空间中,所以如果你把它们也配置为sRGB纹理的话,光照结果就会不正确。指定sRGB纹理时要当心

diffuse纹理一般在sRGB空间,specular贴图和法线贴图一般是线性空间如果一般我们程序里面处理的纹理应该处于线性空间,因此在读取纹理的时候要判断它此时处于哪个空间,如果处于sRGB空间中,需要像上面一样将该纹理指定为一个sRGB纹理,如果处于线性空间中,则直接处理,最终的显示的结果还要做一次gamma校正.

将diffuse纹理定义为sRGB纹理之后,你将获得你所期望的视觉输出,但这次每个物体都会只进行一次gamma校正。

3.1 使用 与 实验结果

因此,要正确使用gamma校正时, 调用的代码如下:

//在cpu代码上
//在模型导入的过程添加了支持sRGB纹理
glTexImage2D(GL_TEXTURE_2D, 0, GL_SRGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, image);

//在片元着色器代码上
vec3 gammaCorrection(vec3 lighting) {
    float gamma = 2.2;
    return pow(lighting.rgb, vec3(1.0 / gamma));
}

void main(){
    [...]
    vec3 resultColor = gammaCorrection(lighting);
    FragColor = vec4(resultColor, 1.0f);
}

通过实验以后,得到如下的效果图:

  1. 左上图是线性空间的纹理,但在着色器中没有gamma校正;
  2. 右上图是线性空间的纹理,在着色器中使用gamma校正;
  3. 左下图是非线性空间(sRGB空间)的纹理,在着色器中使用gamma校正;
  4. 右下图是非线性空间(sRGB空间)的纹理,但在着色器中没有gamma校正;

总结一下:一般我们使用的diffus纹理是在sRGB空间中,下面两张图是直接使用diffuse纹理,即程序处理的纹理处于sRGB空间中(程序处理的纹理应该处于线性空间),此时在程序中使用纹理数据进行计算的话,得到的计算结果是不正确的,因此下面两张图是错误的,左下图还多进行了一次gamma校正,使得整个显示效果很亮,右下图没有进行gamma校正,这也是我们之前的显示效果,看起来没什么问题,因为纹理在sRGB空间,显示的时候监视器存在gamma,使得在显示的时候纹理处在线性空间,但是其中光照计算使用的是sRGB空间的颜色值进行计算(应该使用线性空间的颜色值),因此光照计算的结果是不正确的,所以右下图也是错误的。再看上面两张图,都是在导入纹理的时候使得纹理的类型为sRGB纹理,这样opengl就会自动将纹理变换到线性空间,此时程序处理的纹理处于线性空间中,后续的处理建立在正确的前提上了。左上图是着色器中没有进行gamma校正,此时光照计算的结果是正确,因为纹理颜色在线性空间中,在显示的时候因为监视器gamma的原因,存在衰弱,导致最终显示的效果很暗,类似原理部分的曲线图中的实线情况。右上图是正确的显示效果,光照计算的结果是正确的,并且最终的颜色值还进行了gamma校正,这样在监视器看到的是线性空间中的颜色值。

4. 衰减 Attenuation

与gamma校正还有不同的是光照衰减(lighting attenuation)。真实的物理世界中,光照的衰减和光源的距离的平方成反比。

float attenuation = 1.0 / (distance * distance);

然而,当我们使用这个衰减公式的时候,衰减效果总是过于强烈,光只能照亮一小圈,看起来并不真实。出于这个原因,我们使用在基本光照教程中所讨论的那种衰减方程,它给了我们更大的控制权,此外我们还可以使用双曲线函数:

float attenuation = 1.0 / distance;

双曲线比使用二次函数变体在不用gamma校正的时候看起来更真实,不过但我们开启gamma校正以后线性衰减看起来太弱了,符合物理事实的二次函数(带gamma校正)突然出现了更好的效果。下图显示了其中的不同:

最终还是选择右上角那一效果作为最终的显示效果。*

这种差异产生的原因是,光的衰减方程改变了亮度值,而且屏幕上显示出来的也不是线性空间,在监视器上效果最好的衰减方程,并不是符合物理的。想想平方衰减方程,如果我们使用这个方程,而且不进行gamma校正,显示在监视器上的衰减方程实际上将变成 (1.0distance2)2.2 ( 1.0 d i s t a n c e 2 ) 2.2 。若不进行gamma校正,将产生更强烈的衰减。这也解释了为什么双曲线不用gamma校正时看起来更真实,因为它实际变成了 (1.0distance)2.2=1.0distance2.2 ( 1.0 d i s t a n c e ) 2.2 = 1.0 d i s t a n c e 2.2 。这和物理公式是很相似的。

我们在基础光照教程中讨论的更高级的那个衰减方程在有gamma校正的场景中也仍然有用,因为它可以让我们对衰减拥有更多准确的控制权(不过,在进行gamma校正的场景中当然需要不同的参数)。

总而言之,gamma校正使你可以在线性空间中进行操作。因为线性空间更符合物理世界,大多数物理公式现在都可以获得较好效果,比如真实的光的衰减。你的光照越真实,使用gamma校正获得漂亮的效果就越容易。这也正是为什么当引进gamma校正时,建议只去调整光照参数的原因。

上面这一节的目的就是为了说明上图中右上角图和左下角图显示效果差不多的原因,从数学公式的角度去解释,他们最终的显示结果(监视器上的)分别如下:
1. 左上角: (1.0distance2)2.2 ( 1.0 d i s t a n c e 2 ) 2.2
2. 右上角: 1.0distance2 1.0 d i s t a n c e 2
3. 左下角: (1.0distance)2.2 ( 1.0 d i s t a n c e ) 2.2
4. 右下角: 1.0distance 1.0 d i s t a n c e

参考链接:
1. Gamma校正

你可能感兴趣的:(OpenGL学习笔记)