当前位置: 代码迷 >> 图形/图像 >> 【图形学】小弟我理解的伽马校正(Gamma Correction)
  详细解决方案

【图形学】小弟我理解的伽马校正(Gamma Correction)

热度:678   发布时间:2016-05-13 02:26:01
【图形学】我理解的伽马校正(Gamma Correction)


写在前面


我相信几乎所有做图像处理方面的人都听过伽马校正(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)之间的关系。

如下图所示:

这里写图片描述


而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的现象。在真实的场景中,原始的场景填充了填充了观察者的所有视野,而显示的亮度往往只局限在一个被周围环境包围的屏幕上。这两个差别使得感知对比度相较于原始场景明显下降了。也就是我们一开始说的,对光的灵敏度对不同亮度是不一样的。如下图所示:
这里写图片描述


为了中和这种现象,所以我们需要乘积不是1的end-to-end gamma,来保证显示的亮度结果在感知上和原始场景是一致的。根据《Real-time Rendering》一书中,推荐的值在电影院这种漆黑的环境中为1.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,从而在视觉上进行了补偿。

重要:我自己认为,伽马校正时广泛使用的2.2是由此而来的,而不是说CRT本身的display gamma就是2.2。相信这个说法和网上绝大部分(包括Wiki、GPU Gems这种)都是不太一样的。网上的普遍说法是,2.2是CRT的平均响应伽马值,所以要用1/2.2来进行伽马校正。但这样的说法似乎默认了伽马和人眼对亮度的灵敏度没有关系。很混乱。。。我只在《Real-time Rendering》看到了这种需要输出为非线性的具体说法。目前也没有得到验证。但我们可以单纯地记住,当进行伽马校正时,使用的encoding gamma通常就是1/2.2(Mac上有所不同,是1/1.8)。

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

这和渲染有什么关系


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

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

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

以下实验均在Unity中进行。

光照


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

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"}


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

这里写图片描述


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

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


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

非线性输入


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

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

Mipmaps


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

扩展


由于未进行伽马校正而造成的混合问题其实非常常见,不仅仅是在渲染中才遇到的。

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

x1gamma+y1gamma2<(x+y2)1gamma

我们可以把x1gammay1gamma看成是两个非线性空间的纹理,如果直接对它们进行混合(如取平均值),得到的结果实际要暗于在线性空间下取平均值再伽马校正的结果。

所以,在处理非线性纹理时一定要格外小心。


进行伽马校正


我们的目标是:保证所有的输入都转换到线性空间,并在线性空间下做各种光照计算,最后的输出在通过一个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 );  


在最后输出前,对输出像素值的校正代码通常长下面这样:

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


但是,手工对输出像素进行伽马校正在使用混合的时候会出现问题。这是因为,校正后导致写入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轻松地完成,需要注意的是对纹理的处理。但不幸的是,不支持移动平台。

下面有一些文章是我觉得很好的资料,但是其中有很多说法是有争议的,希望大家能自己评估:

  • http://http.developer.nvidia.com/GPUGems3/gpugems3_ch24.html
  • 《Real-Time Rendering, Third Edition》5.8. Gamma Correction
  • http://www.klayge.org/2011/02/26/gamma%E7%9A%84%E4%BC%A0%E8%AF%B4/
  • http://qiankanglai.me/misc/2014/12/24/gamma-correction/
  • http://docs.unity3d.com/Manual/LinearLighting.html
  相关解决方案
本站暂不开放注册!
内测阶段只得通过邀请码进行注册!