伽马校正
需要预先学习:
1. 为什么需要伽马校正
1.1 显示器是非线性的
伽马(gamma)问题源于一个物理事实:显示器的亮度响应不是线性的。CRT 时代的显像管,输入电压与输出亮度的关系近似幂函数 ,——输入电压翻倍,亮度远达不到两倍。LCD 虽原理不同,但为了兼容,也模拟这条曲线。
所以显示器上"0.5 的像素值"显示出来的物理亮度,只有"1.0 的像素值"的 ,而不是一半。
1.2 人眼对暗部更敏感
人眼对暗部的亮度差异比亮部敏感得多——暗处稍微亮一点就能察觉,亮处变化却不易发现。利用这个特性,sRGB 编码(sRGB encoding)在存储颜色时给暗部分配更多精度:编码曲线近似 的幂函数,正好与显示器的 抵消。
于是图像文件(.png、.jpg 等)里存的不是线性亮度,而是经过 sRGB 编码的值。以 8bit 存储为例,如果直接存线性亮度,暗部的量化台阶肉眼可见(banding);经过编码后暗部台阶被细分,观感平滑。
注:sRGB 是一个颜色空间标准,编码曲线只是它的一部分。日常说的"伽马校正"通常指在线性空间(linear space,亮度与数值成正比)与 sRGB 空间(显示器直接显示的空间)之间转换。
2. 线性空间与 sRGB 空间
2.1 为什么光照必须在线性空间计算
光照计算里到处都是线性运算:光的叠加、距离平方衰减、颜色插值、alpha 混合。线性运算只有在"数值与物理亮度成正比"的空间里做才正确——也就是线性空间。
反例:两束亮度相同的光叠加。在线性空间,,正确。但如果直接在 sRGB 空间相加:sRGB 的 对应线性亮度 ,两束光叠加后的物理亮度是 ,换算回 sRGB 应该是 ,而不是 。在 sRGB 空间做线性运算,结果必然偏亮或偏暗。
实际渲染中典型的错误表现:
| 错误 | 现象 |
|---|---|
| 高光过亮、过硬 | 高光强度在 sRGB 空间计算被放大, 的指数运算结果扭曲 |
| 暗部细节丢失 | 衰减、混合在 sRGB 空间偏暗,暗部提前沉入黑色 |
| 颜色插值偏暗 | 两个颜色在 sRGB 空间 lerp,中间色比线性空间的正确结果暗 |
3. 伽马校正的做法
3.1 正确流程:先解码,后编码
完整的渲染流程要把颜色空间管起来:
- 解码:采样纹理时,把 sRGB 编码的纹素转换回线性空间();
- 计算:所有光照、混合、后处理在线性空间进行;
- 编码:输出到屏幕前,把线性值转回 sRGB(),交给显示器。
最后一步就是伽马校正(gamma correction)——"校正"掉显示器的 ,让屏幕显示出的物理亮度与线性空间计算的结果一致。
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 的编码曲线并非严格的 幂函数(它有一段线性区间),手动 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 的物理一致性也建立在线性空间的前提上——非线性空间里能量守恒的假设全部失效。



