需要预先学习:


1. 为什么需要伽马校正


1.1 显示器是非线性的

伽马(gamma)问题源于一个物理事实:显示器的亮度响应不是线性的。CRT 时代的显像管,输入电压与输出亮度的关系近似幂函数 L=VγL = V^{\gamma},γ≈2.2\gamma \approx 2.2——输入电压翻倍,亮度远达不到两倍。LCD 虽原理不同,但为了兼容,也模拟这条曲线。

所以显示器上"0.5 的像素值"显示出来的物理亮度,只有"1.0 的像素值"的 0.52.2≈0.220.5^{2.2} \approx 0.22,而不是一半。


1.2 人眼对暗部更敏感

人眼对暗部的亮度差异比亮部敏感得多——暗处稍微亮一点就能察觉,亮处变化却不易发现。利用这个特性,sRGB 编码(sRGB encoding)在存储颜色时给暗部分配更多精度:编码曲线近似 γ=1/2.2\gamma = 1/2.2 的幂函数,正好与显示器的 2.22.2 抵消。

于是图像文件(.png、.jpg 等)里存的不是线性亮度,而是经过 sRGB 编码的值。以 8bit 存储为例,如果直接存线性亮度,暗部的量化台阶肉眼可见(banding);经过编码后暗部台阶被细分,观感平滑。

注:sRGB 是一个颜色空间标准,编码曲线只是它的一部分。日常说的"伽马校正"通常指在线性空间(linear space,亮度与数值成正比)与 sRGB 空间(显示器直接显示的空间)之间转换。


2. 线性空间与 sRGB 空间


2.1 为什么光照必须在线性空间计算

光照计算里到处都是线性运算:光的叠加、距离平方衰减、颜色插值、alpha 混合。线性运算只有在"数值与物理亮度成正比"的空间里做才正确——也就是线性空间。

反例:两束亮度相同的光叠加。在线性空间,0.5+0.5=1.00.5 + 0.5 = 1.0,正确。但如果直接在 sRGB 空间相加:sRGB 的 0.50.5 对应线性亮度 0.52.2≈0.2180.5^{2.2} \approx 0.218,两束光叠加后的物理亮度是 0.4360.436,换算回 sRGB 应该是 0.4361/2.2≈0.690.436^{1/2.2} \approx 0.69,而不是 1.01.0。在 sRGB 空间做线性运算,结果必然偏亮或偏暗。

实际渲染中典型的错误表现:

错误 现象
高光过亮、过硬 高光强度在 sRGB 空间计算被放大,(value)p(\text{value})^{p} 的指数运算结果扭曲
暗部细节丢失 衰减、混合在 sRGB 空间偏暗,暗部提前沉入黑色
颜色插值偏暗 两个颜色在 sRGB 空间 lerp,中间色比线性空间的正确结果暗

3. 伽马校正的做法


3.1 正确流程:先解码,后编码

完整的渲染流程要把颜色空间管起来:

  1. 解码:采样纹理时,把 sRGB 编码的纹素转换回线性空间(pow(c,2.2)\text{pow}(c, 2.2));
  2. 计算:所有光照、混合、后处理在线性空间进行;
  3. 编码:输出到屏幕前,把线性值转回 sRGB(pow(c,1/2.2)\text{pow}(c, 1/2.2)),交给显示器。

最后一步就是伽马校正(gamma correction)——"校正"掉显示器的 γ=2.2\gamma = 2.2,让屏幕显示出的物理亮度与线性空间计算的结果一致。


3.2 方案一:着色器手动转换

最直接的做法是在 shader 里手动 pow:

// 片段着色器:手动伽马校正
#version 330 core

out vec4 FragColor;

in vec2 TexCoords;
uniform sampler2D texture1;

void main()
{
    // 采样后解码:sRGB -> 线性
    vec3 color = pow(texture(texture1, TexCoords).rgb, vec3(2.2));

    // ... 光照计算在线性空间进行 ...

    // 输出前编码:线性 -> sRGB
    color = pow(color, vec3(1.0 / 2.2));
    FragColor = vec4(color, 1.0);
}

这个方案能工作,但有两个问题:一是每个采样点都要手动解码,容易遗漏;二是 sRGB 的编码曲线并非严格的 γ=2.2\gamma = 2.2 幂函数(它有一段线性区间),手动 pow 只是近似。


3.3 方案二:硬件自动转换(推荐)

OpenGL 提供两个开关,让解码与编码由硬件完成,shader 里始终面对线性值:

sRGB 纹理:加载颜色纹理时用 GL_SRGB8_ALPHA8 内部格式,硬件在每次采样时自动解码:

// 加载纹理时指定 sRGB 内部格式,采样时硬件自动解码为线性
glTexImage2D(GL_TEXTURE_2D, 0, GL_SRGB8_ALPHA8, width, height,
             0, GL_RGBA, GL_UNSIGNED_BYTE, data);

sRGB 帧缓冲:帧缓冲的颜色附件用 sRGB 格式,并在写入时启用转换,硬件在写入帧缓冲时自动编码:

// 颜色附件用 sRGB 格式
glTexImage2D(GL_TEXTURE_2D, 0, GL_SRGB8_ALPHA8, width, height,
             0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);

// 确保 sRGB 自动编码生效(core profile 默认开启,显式开启更明确)
glEnable(GL_FRAMEBUFFER_SRGB);

启用后,片段着色器输出的线性值在写入帧缓冲前被自动编码成 sRGB——shader 里不需要任何 pow。默认帧缓冲(屏幕)也可以用 glEnable(GL_FRAMEBUFFER_SRGB) 开启同样的转换。

注:GL_FRAMEBUFFER_SRGB 只在颜色附件是 sRGB 格式时生效。后处理流程中,如果中间纹理用普通 GL_RGBA8,写入时不会发生编码,需要手动处理。


4. 应用中的注意点


4.1 不是所有纹理都是 sRGB

只有颜色贴图(albedo、UI 贴图等)才是 sRGB 编码。数据贴图不是颜色,不能当 sRGB 解码:

纹理类型 内部格式 理由
albedo 贴图、UI 贴图 GL_SRGB8_ALPHA8 存的是颜色,需要解码后参与光照
法线贴图 GL_RGB8 存的是向量,不是亮度
粗糙度 / 金属度贴图 GL_RGB8 存的是材质参数,不是颜色
数据纹理(噪声、高度图) 按用途定 数值直接参与计算,不能扭曲

注意:把法线贴图误设成 sRGB 是最常见的错误——采样后向量被"解码",长度不再为 1,光照结果莫名怪异。


4.2 各阶段的颜色空间

阶段 颜色空间 说明
纹理采样 解码后进入线性 sRGB 纹理由硬件自动完成
光照、混合、后处理 线性 所有线性运算正确
写入 LDR 帧缓冲 编码为 sRGB 硬件自动,或手动 pow
HDR 帧缓冲(浮点) 线性 浮点纹理没有编码问题(见 色调映射)

最后一个常见陷阱是双重伽马:纹理已经用 sRGB 格式自动解码,shader 里又手动 pow 一次,等于解了两遍码,画面整体发灰偏暗。选择了方案二,就不要再在 shader 里做任何 pow。

伽马校正是整个渲染管线的"地基":色调映射 把 HDR 值压缩到 [0,1] 后,正是靠伽马编码输出到屏幕;PBR 的物理一致性也建立在线性空间的前提上——非线性空间里能量守恒的假设全部失效。