【视频开发】伽马校订(gamma correction)学习笔记

我相信几乎全部作图像处理方面的人都听过伽马校订(Gamma Correction)这一个名词,但真正明白它是什么、为何要有它、以及怎么用它的人其实很少。我也不例外。 

最初我查过一些资料,但不少文章的说法都不同,有些很晦涩难懂。直到我最近在看《Real Time Rendering,3rd Edition》这本书的时候,才开始慢慢对它有所理解。 

本人才疏学浅,写的这篇文章极可能成为网上另外一篇误导你的“伽马传说”,但我尽量把目前了解的资料和可能存在的疏漏写在这里。若有错误,还望指出。 

css

伽马的传说


关于这个方面,龚大写过一篇文章,但我认为其中的说法有不许确的地方。 

从我找到的资料来看,人们使用伽马曲线来进行显示最开始是源于一个巧合:在早期,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)之间的关系。 

以下图所示: 
html

这里写图片描述


而encoding gamma和display gamma的乘积就是真个图像系统的end-to-end gamma。若是这个乘积是1,那么显示出来的亮度就是和捕捉到的真实场景的亮度是成比例的。 

上面的情景是对于捕捉的相片。那么对于咱们渲染的图像来讲,咱们须要的是一个encoding gamma。若是咱们没有用一个encoding gamma对shader的输出进行校订,而是直接显示在屏幕上,那么因为display gamma的存在就会使画面失真。 

至此为止,就是龚大 所说的伽马传说 。由此,龚大认为所有的问题都出在CRT问题上,跟人眼没有任何关系。 

可是,在《Real-time Rendering》一书中,指出了这种乘积为1的end-to-end gamma的问题。看起来,乘积为1的话,可让显示器精确重现原始场景的视觉条件。可是,因为原始场景的观察条件和显示的版本之间存在两个差别:1)首先是,咱们可以显示的亮度值其实和真实场景的亮度值差了好几个数量级,说通俗点,就是显示器的精度根本达不到真实场景的颜色精度(大天然的颜色种类几乎是无穷多的,而若是使用8-bit的编码,咱们只能显示256^3种颜色);2)这是一种称为surround effect的现象。在真实的场景中,原始的场景填充了填充了观察者的全部视野,而显示的亮度每每只局限在一个被周围环境包围的屏幕上。这两个差异使得感知对比度相较于原始场景明显降低了。也就是咱们一开始说的,对光的灵敏度对不一样亮度是不同的。以下图所示(来源: Youtube: Color is Broken ): 
这里写图片描述


为了中和这种现象,因此咱们须要乘积不是1的end-to-end gamma,来保证显示的亮度结果在感知上和原始场景是一致的。根据《Real-time Rendering》一书中,推荐的值在电影院这种漆黑的环境中为1.5,在明亮的室内这个值为1.125。 

我的电脑使用的一个标准叫sRGB,它使用的encoding gamma大约是0.45(也就是1/2.2)。这个值就是为了配合display gamma为2.5的设备工做的。这样,end-to-end gamma就是0.45 * 2.5 = 1.125了。 

这意味着,虽然CRT的display gamma是2.5,但咱们使用的encoding gamma应该是1.125/2.5 = 1/2.2,而不是1/2.5。这样才能保证end-to-end gamma为1.125,从而在视觉上进行了补偿。 

虽然如今CRT设备不多见了,但为了保证这种感知一致性(这是它一直沿用至今的很重要的一点),同时也为了对已有图像的兼容性(以前不少图像使用了encoding gamma对图像进行了编码),因此仍在使用这种伽马编码。并且,如今的LCD虽然有不一样的响应曲线(即display gamma不是2.5),可是在硬件上作了调整来提供兼容性。 

重要: 上面的说法主要来源于Real-time Rendering》一书。 

来自其余领域的伽马传说


今天很幸运听了知乎上韩世麟童鞋的讲解。在听了他的讲座后,我听到了另外一个版本的伽马传说。和上面的讨论不一样,他认为伽马的来源彻底是因为人眼的特性形成的。对伽马的理解和职业颇有关系,长期从事摄影、视觉领域相关的工做的人可能更有发言权。我以为这个版本更加可信。感兴趣的同窗能够直接去知乎上领略一下。 

我在这里来大体讲一下他的理解。 

事情的原由能够从在真实环境中拍摄一张图片提及。摄像机的原理能够简化为,把进入到镜头内的光线亮度编码成图像(例如一张JEPG)中的像素。这样很简单啦,若是采集到的亮度是0,像素就是0,亮度是1,像素就是1,亮度是0.5,像素就是0.5。这里,就是这里,出现了一点问题!若是咱们假设只用8位空间来存储像素的话,觉得着0-1能够表示256种颜色,没错吧?可是,人眼有的特性,就是对光的灵敏度在不一样亮度是不同的。仍是这张图Youtube: Color is Broken: 
c++

这里写图片描述


这张图说明一件事情,即亮度上的线性变化在人眼看来是非均匀的,再通俗点,从0亮度变到0.01亮度,人眼是能够察觉到的,但从0.99变到1.0,人眼可能就根本差异不出来,以为它们是一个颜色。也就是说,人眼对暗部的变化更加敏感,而对亮部变化其实不是很敏感。也就是说,人眼认为的中灰其实不在亮度为0.5的地方,而是在大约亮度为0.18的地方(18度灰)。强烈建议去看一下Youtube上的视频, Color is Broken 。 

那么,这和拍照有什么关系呢?若是在8位图中,咱们仍然用0.5亮度编码成0.5的像素,那么暗部和亮部区域咱们都使用了128种颜色来表示,但实际上,亮部区域使用这么多种其实相对于暗部来讲是种存储浪费。不浪费的作法是,咱们应该把人眼认为的中灰亮度放在像素值为0.5的地方,也就是说,0.18亮度应该编码成0.5像素值。这样存储空间就能够充分利用起来了。因此,摄影设备若是使用了8位空间存储照片的话,会用大约为0.45的encoding gamma来对输入的亮度编码,获得一张图像。0.45这个值彻底是因为人眼的特性测量获得的。 

那么显示的时候到了。有了一张图片,显示的时候咱们仍是要把它还原成原来的亮度值进行显示。毕竟,0.454只是为了充分利用存储空间而已。咱们假设一下,当年CRT设备的输入电压和产生亮度之间彻底是线性关系,咱们仍是要进行伽马校订的。这是为了把用0.45伽马编码后的图像正确重如今屏幕上。巧合的是,当年人们发现CRT显示器居然符合幂律曲线!人们想,“天哪,太棒了,咱们不须要作任何调整就可让拍摄的图像在电脑上看起来和原来的同样了”。这就是咱们一直说的“那个巧合”。当年,CRT的display gamma是2.5,这样致使最后的end-to-end gamma大约是0.45 * 2.5 = 1.125,实际上是非1的。 

直到后来,微软联合爱普生、惠普提供了sRGB标准,推荐显示器中display gamma值为2.2。这样,配合0.45的encoding gamma就能够保证end-to-end gamma为1了。固然,上一节提到的两个观察差别,有些时候咱们其实更但愿end-to-end gamma非1的结果,例如,在电影院这种暗沉沉的环境中,end-to-end gamma为1.5咱们人看起来更爽、更舒服,而在明亮的办公室这种环境中1.125的end-to-end gamma值更舒服、更漂亮。因此,咱们能够根据环境的不一样,去选择使用什么样的display gamma。 

总之, 伽马校订一直沿用至今说究竟是人眼特性决定的 。你会说,伽马这么麻烦,何时能够舍弃它呢?按 韩世麟童鞋 的说法,若是有一天咱们对图像的存储空间可以大大提高,通用的格式再也不是8位的时候,例如是32位的时候,伽马就没有用了。由于,咱们不须要为了提升精度而把18度灰编码成0.5像素,由于咱们有足够多的颜色空间能够利用,不须要考虑人眼的特性。 

好啦,上面就是来自摄影、建筑领域的见解和理解。但愿这两种见解可让你们更深地理解伽马校订的存在乎义。 

这和渲染有什么关系


其实,对伽马传说的理解就算有误差,也不会影响咱们对伽马校订的使用。咱们只要知道,根据sRGB标准,大部分显示器使用了2.2的display gamma来显示图像。app

前面提到了,和渲染相关的是encoding gamma。咱们知道了,显示器在显示的时候,会用display gamma把显示的像素进行display transfer以后再转换成显示的亮度值。因此,咱们要在这以前,像图像捕捉设备那样,对图像先进行一个encoding transfer,与此相关的就是encoding gamma了。 

而不幸的是,在游戏界长期以来都忽视了伽马校订的问题,也形成了为何咱们渲染出来的游戏老是暗沉沉的,老是和真实世界不像。less

回到渲染的时候。咱们来看看没有正确进行伽马校订到底会有什么问题。 

如下实验均在Unity中进行。 

性能

光照


咱们来看一个最简单的场景:在场景中放置一个球,使用默认的Diffuse材质,打一个平行光: 
ui

Gamma


看起来很对是吗?但实际上,这和咱们在真实场景中看到的是不同的。在真实的场景中,若是咱们把一个球放在平行光下,它是长这个样子的: 
Linear


假设球上有一点B,它的法线和光线方向成60°,还有一点A,它的法线和光线方向成90°。那么,在shader中计算diffuse的时候,咱们会得出B的输出是(0.5, 0.5, 0.5),A的输出的(1.0, 1.0, 1.0)。 

在第一张图中,咱们没有进行伽马校订。所以,在把像素值转换到屏幕亮度时并非线性关系,也就是说B点的亮度其实并非A亮度的一半,在Mac显示器上,这个亮度只有A亮度的1/1.8呗,约为四分之一。在第二章图中,咱们进行了伽马校订,此时的亮度才是真正跟像素值成正比的。 

混合


混合实际上是很是容易受伽马的影响。咱们仍是在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"
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62


上面的shader其实很简单,就是在Quad上画了个边缘模糊的圆,而后使用了混合模式来会屏幕进行混合。咱们在场景中画三个这样不一样颜色的圆,三种颜色分别是(0.78, 0, 1),(1, 0.78, 0),(0, 1, 0.78): 
atom

这里写图片描述


看出问题了吗?在不一样颜色的交接处出现了不正常的渐变。例如,从绿色(0, 1, 0.78)到红色(0.78, 0, 1)的渐变中,居然出现了蓝色。 

正确的显示结果应该是: 
这里写图片描述


第一张图的问题出在,在混合后进行输出时,显示器进行了display transfer,致使接缝处颜色变暗。 

非线性输入


shader中非线性的输入最有可能的来源就是纹理了。 

为了直接显示时能够正确显示,大多数图像文件都进行了提早的校订,即已经使用了一个encoding gamma对像素值编码。但这意味着它们是非线性的,若是在shader中直接使用会形成在非线性空间的计算,使得结果和真实世界的结果不一致。 

spa

Mipmaps


在计算纹理的Mipmap时也须要注意。若是纹理存储在非线性空间中,那么在计算mipmap时就会在非线性空间里计算。因为mipmap的计算是种线性计算——即降采样的过程,须要对某个方形区域内的像素去平均值,这样就会获得错误的结果。正确的作法是,把非线性的纹理转换到线性空间后再计算Mipmap。 

扩展


因为未进行伽马校订而形成的混合问题其实很是常见,不只仅是在渲染中才遇到的。 

Youtube上有一个颇有意思的视频,很是建议你们看一下。里面讲的就是,因为在混合前未对非线性纹理进行转换,形成了混合纯色时,在纯色边界处出现了黑边。用数学公式来阐述这一现象就是: 

x1gamma+y1gamma2<(x+y2)1gamma

咱们能够把 x1gamma y1gamma 当作是两个非线性空间的纹理,若是直接对它们进行混合(如取平均值),获得的结果实际要暗于在线性空间下取平均值再伽马校订的结果。 

因此,在处理非线性纹理时必定要格外当心。


进行伽马校订


咱们的目标是:保证全部的输入都转换到线性空间,并在线性空间下作各类光照计算,最后的输出在经过一个encoding gamma进行伽马校订后进行显示。 

在Unity中,有一个专门的设置是为伽马校订服务的,具体能够参见官方文档(Linear Lighting)。 

简单来讲就是靠Edit -> Project Settings -> Player -> Other Settings中的设置: 

这里写图片描述


它有两个选项:一个是Gamma Space,一个Linear Space。 

- 当选择Gamma Space时,实际上就是“听任模式”,不会对shader的输入进行任何处理,即便输入多是非线性的;也不会对输出像素进行任何处理,这意味着输出的像素会通过显示器的display gamma转换后获得非预期的亮度,一般表现为整个场景会比较昏暗。

  • 当选择Linear Space时,Unity会背地里把输入纹理设置为sRGB模式,这种模式下硬件在对纹理进行采样时会自动将其转换到线性空间中;而且,也会设置一个sRGB格式的buffer,此时GPU会在shader写入color buffer前自动进行伽马校订。若是此时开启了混合(像咱们以前的那样),在每次混合是,以前buffer中存储的颜色值会先从新转换回线性空间中,而后再进行混合,完成后再进行伽马校订,最后把校订后的混合结果写入color buffer中。这里须要注意,Alpha通道是不会参与伽马校订的。 

sRGB模式是在近代的GPU上才有的东西。若是不支持sRGB,咱们就须要本身在shader中进行伽马校订。对非线性输入纹理的校订一般代码以下:

float3 diffuseCol = pow(tex2D( diffTex, texCoord ), 2.2 ); 
  • 1


在最后输出前,对输出像素值的校订代码一般长下面这样:

fragColor.rgb = pow(fragColor.rgb, 1.0/2.2);
return fragColor;
  • 1
  • 2


可是,手工对输出像素进行伽马校订在使用混合的时候会出现问题。这是由于,校订后致使写入color buffer的颜色是非线性的,这样混合就发生在非线性空间中。一种解决方法时,在中间计算时不要对输出进行伽马校订,在最后进行一个屏幕后处理操做对最后的输出进行伽马校订,但很显然这会形成性能问题。 

还有一些细节问题,例如在进行屏幕后处理的时候,要当心咱们目前正在处理的图像究竟是不是已经伽马校订后的。 

总之,一切工做都是为了“保证全部的输入都转换到线性空间,并在线性空间下作各类光照计算,最后的输出(最最最最后的输出)进行伽马校订后再显示”。 

虽然Unity的这个设置很是方便,可是其支持的平台有限,目前还不支持移动平台。也就是说,在安卓、iOS上咱们没法使用这个设置。所以,对于移动平台,咱们须要像上面给的代码那样,手动对非线性纹理进行转换,并在最后输出时再进行一次转换。但这又会致使混合错误的问题。 

在Unity中使用Linear Space


若是咱们在Edit -> Project Settings -> Player -> Other Settings中使用了Linear Space,那么以前的光照、混合问题均可以解决(这里的解决是说和真实场景更接近)。但在处理纹理时须要注意,全部Unity会把全部输入纹理都设置成sRGB格式,也就说,全部纹理都会被硬件当成一个非线性纹理,使用一个display gamma(一般是2.2)进行处理后,再传递给shader。但有时,输入纹理并非非线性纹理就会发生问题。 

例如,咱们绘制一个亮度为127/255的纹理,传给shader后乘以2后进行显示: 

这里写图片描述 这里写图片描述

能够看出,Gamma Space的反而更加正确。这是由于,咱们的输入纹理已是线性了,而Unity错误地又进行了sRGB的转换处理。这样一来,右边显示的亮度实际是,(pow(0.5, 2.2) * 2, 1/2.2)。 

为了告诉Unity,“嘿,这张纹理就是线性的,不用你再处理啦”,能够在Texture的面板中设置: 
这里写图片描述

上面的“Bypass sRGB Sample”就是告诉Untiy要绕过sRGB处理,“它是啥就是啥!”。 

这样设置后,就能够获得正确采样结果了。 

写在最后


伽马校订一直是个众说纷纭的故事,固然我写的这篇也极可能会有一些错误,若是您能指出不胜感激。 

即使关于一些细节问题说法不少,但本质是不变的。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轻松地完成,须要注意的是对纹理的处理。但不幸的是,不支持移动平台。 

最后,一句忠告,在游戏渲染的时候必定要考虑伽马校订的问题,不然就很可贵到很是真实的效果

下面有一些文章是我以为很好的资料,可是其中有不少说法是有争议的,但愿你们能本身评估: 

相关文章
相关标签/搜索