# Gamma校正
# Gamma校正的前生今世
Gamma校正是用来抵消阴极射线管(CRT)显示器的输入和输出特性。电子枪的电流,也就是光的亮度,与输入的正极电压的变化是非线性的。通过Gamma校正来改变输入信号抵消这个非线性,以确保输出图像能达到预期的亮度。
Gamma校正与人眼特性无关,仅仅和CRT有关。更新的显示器,比如LCD和等离子之类等,为了保证兼容,也都选择了和当年CRT一样的非线性特性。(其实和系统也有关,例如,Mac OS X 10.6用的是1.8,其他系统,包括电视,都用2.2)

Gamma计算公式:
其中的γ就是用来校正的gamma值。
输入和输出
- 理想的输入和输出:相机是线性的,显示器也是线性的,那么输入和输出的关系就是:
这样通过相机拍照好,在显示器上看到的和真实场景的色彩一样。
- 现实的输入和输出:由于显示器的Gamma值为2.2,所以如果相机仍然实现线性的,那么结果就会变成:
- 校正后的输入和输出:把相机的Gamma值设为1/2.2,这样经过两次调整之后就能得到真实场景的色彩:
对渲染的影响
渲染中的所有光照计算都是在线性空间的,因为在设计光照的时候都是人为1的亮度是0.5的2倍。因此,光照计算输入的颜色(Color)和纹理(Texture)也需要在线性空间。纹理一般有两个来源,一个是照片,一个是美工手工绘制的。前文提到了,照片所在空间的Gamma值是1/2.2。而通过图像处理软件绘制的图片Gamma值也是1/2.2。
我们在pixel shader中常可以看到这样的代码:
float4 diffuse = tex2D( diffuseTexture, uv );
return diffuse * max( 0, dot( lightDir, normal ) );
2
这段代码对吗?不对也对。
说它不对,是因为这里没有显式的做Gamma校正,加上校正后的shader应该是这样的:
float4 diffuse = pow( tex2D( diffuseTexture, uv ), 2.2 ); // 将输入的纹理转换到线性空间
return pow( diffuse * max( 0, dot( lightDir, normal ) ), 1 / 2.2 ); // 将输出结果再次转换回Gamma = 1/2.2空间
2
说它对,是因为如果diffuse texture是sRGB格式的,那么在读取的时候硬件会把它自动转换到线性空间:
// OpenGL中通过下面接口实现
glTexImage2D(GL_TEXTURE_2D, 0, GL_SRGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, image);
// WebGL中通过下面接口实现
texImage2D(gl.TEXTURE_2D, 0, gl.SRGB8_ALPHA8, gl.RGBA, g.UNSIGNED_BYTE, image);
2
3
4
5
如果render target的texture也是sRGB格式,在输出的时候硬件也会把它自动转到Gamma = 1/2.2空间
// OpenGL中通过下面的接口实现
glEnable(GL_FRAMEBUFFER_SRGB);
// WebGL暂不支持需要手动在pixel shader中实现
2
3
4
除了渲染,另一个需要注Gamma的地方就是mipmap。如果原始texture是Gamma = 1/2.2空间的,那么在建立mipmap chain的时候,需要将原texture先转到线性空间,来计算各级mipmap;完成计算后,再将格级mipmap转回Gamma = 1/2.2空间。
另外,Gamma变换只作用于RGB通道,Alpha透明度则不受影响。因此,对于normal texture、mask texture等存放的不是颜色信息的纹理不需要进行Gamma变换。
# three.js中的Gamma校正
在three.js中启用Gamma校正,首先需要将WebGLRenderer的outputColorSpace属性设置为“SRGBColorSpace”,然后需要将输入的颜色(Color)和纹理(Texture)转换到线性空间:
对于纹理,只需要将Texture的colorSpace属性设置为“SRGBColorSpace”,这样three.js在提交纹理(uploadTexture)时,通过调用“texImage2D”接口将纹理转换到线性空间。
对于颜色,139版本之前需要通过调用“convertSRGBToLinear”转换到线性空间。从139版本开始three.js新增了“ColorManagement”,只需要将它的“enabled”属性设置为“true”,这样three.js会根据颜色来源自动进行颜色空间转换[1]。
最后需要将输出颜色转回sRGB空间(在片元着色器中实现)[2],这样就会在屏幕上得到正确的结果。
光照模式
光照模式下,由于光照计算必须在线性空间,所以输入的颜色和纹理需要先转换到线性空间,片元着色器在线线性空间完成光照计算后再转回sRGB空间,如下图所示。
非光照模式
非光照模式下,存在两种校正方案。方案1与光照模式的校正方案类似,只是在片元着色器中省略了光照计算,如下图方案1所示。
由于非光照模式不需要光照计算,所以在方案2中省略将输入的颜色和纹理转到线性空间这一步,片元着色器直接在sRGB空间进行颜色混合同时省略最后转回sRGB空间这一步(删掉pixel shader中的“#include<encodings_fragment>”即可),这样就可以得到和方案1相同的结果,如下图2所示。
方案2仅适合ShaderMaterial和RawShaderMaterial,不适合内置Material。此外,如果使用了方案2同时开启了后期处理则会得到错误的渲染结果,原因下文会说明。因此在非光照模式下推荐使用方案1的Gamma校正。
后期处理
后期处理是先将场景分多次渲染到不同的纹理(Render Target)上面,然后将这些纹理进行混合,最后输出到屏幕上面。
在three.js中如果设置了RenderTarget(WebGLRenderer.setRenderTarget),则材质的outputColorSpace会被强制设置为“LinearSRGBColorSpace”,这就意味着片元着色器在执行“linearToOutputTexel”时不会转换到sRGB空间。
后期处理时需要RenderTarget都是在线性空间中的,最后在输出到屏幕前进行一次Gamma校正转换到sRGB空间,这样就会得到正确的渲染结果,如下图所示。
由于RenderTarget是在线性空间的,所以要求片元着色器也要线性空间进行计算,计算结果不进行转换直接以线性方式存储到RenderTarget中。这也是上文提到“非光照模式”方案2结合后期处理会得到错误的渲染结果原因。因此,在three.js中不论是光照模式还是非光照模式都应确保片元着色器是在线性空间进行计算。
# 参考
- [1] sRGB标准与伽马校正 (opens new window)
- [2] 线性空间与Gamma校正 (opens new window)
- [3] Color management in three.js (opens new window)
- [4] Color management (opens new window)
- [5] Updates to Color Management in three.js r152 (opens new window)
three.js的Color对象可以通过“hexadecimal(0xffffff)”、“css strings('#ffffff')”、“rgb(1.0, 1.0, 1.0)”构建。其中“hexadecimal”和“css strings”属于sRGB空间,“rgb”按three.js解释属于线性空间(此处存疑:gltf文件中颜色按rgb格式存储,解析的时候如果按照线性空间处理得到错误的结果,如果按sRGB空间处理则会得到正确的结果)。 ↩︎
在three.js内置的片元着色器中我们会看到这样的代码:
它是用来实现输出颜色空间转换的。three.js在构建program时候会根据outputEncoding类型决定转换哪个颜色空间(LinearEncoding->线性空间、sRGBEncoding->sRGB空间),详细可以参考three.js->WebGLProgram实现 ↩︎
光照模型 →