Gamma 校正、sRGB 边界与线性光照
Gamma 校正、sRGB 边界与线性光照:保留 LearnOpenGL 3.3 Core 正文机制,以 context—资源—结果合同、GPU 轨迹和章专属单故障完成可重放验收。
学习目标
- 能解释显示器为什么是非线性的:同样的数字它点不出成比例的亮度,以及这件事在整条渲染管线里发生在最后哪一步——光照算完、上屏之前
- 能实现片段着色器里用
pow(color, vec3(1.0/2.2))做 gamma 校正,并说出为什么不校正时两盏灯叠加、衰减、混色都会偏暗失真;还能说出 sRGB 纹理为什么得先解码到线性空间才能参与光照 - 能回答:一张已经是 sRGB 的贴图,采样后不解码、最后又做了一次 gamma 校正,画面会偏亮还是偏暗?这个坑叫什么?
为什么你算对了光照,一上屏却发暗
上一篇你把高光调得平滑漂亮。可你有没有遇到过这种怪事:明明按公式把两盏一样亮的灯叠在一起,画面却没有「两倍亮」,反而灰扑扑、中间调一团暗?或者把光源拉远,衰减得比你预想的快得多、暗得发脏?
问题不在你的算式,而在屏幕这块玻璃本身有脾气。它不是个老实的「数字翻译亮度」的机器——你喂给它一个「一半」的数字,它点出来的亮度远不到一半。于是你在脑子里、在代码里辛辛苦苦按「一半就该是一半」算出来的光照,一过屏幕这道关,全被悄悄压暗、压脏了。
这一章要解决的就是这件事:怎么补偿屏幕这点脾气,让你算出来的亮度原原本本显出来。办法就一行代码,放在所有光照算完、画面上屏之前。没有它,你的光照永远偏暗、叠加和衰减永远不对;有了它,算多少就显多少。
屏幕的脾气:显示器是非线性的
先把屏幕这点脾气说清。理想中,屏幕该是「线性」的:你给它输入 0.5,它就点出一半的亮度;给 1.0 就满亮。可现实里的显示器偏不——它有一条↡显示器把输入数字转成实际亮度时的非线性响应曲线。输出亮度 ≈ 输入^gamma,gamma 通常约等于 2.2。意味着同样的数字点不出成比例的亮度:输入 0.5 实际只亮到约 0.22,中间调被压得最暗。这个特性源于早年 CRT 显像管的物理特性,后来 sRGB 标准把它固定了下来,几乎所有显示器都沿用。响应曲线:实际亮度大约等于 输入^2.2。
代入算算:输入 0.5,输出却是 0.5^2.2 ≈ 0.22——你以为是「一半亮」,屏幕只点出约两成。这条曲线把中间调压得最狠,两端(纯黑、纯白)反而不动。这个数字 2.2 不是某家厂商拍脑袋定的,而是早年 CRT 显像管的物理特性——电压翻倍,亮度并不翻倍,而是按幂律涨;后来 sRGB 标准干脆把这条曲线写进规范,于是今天几乎每块屏幕都背着这条 2.2 的脾气。下面这张图把这条曲线和「理想线性」画在一起,你一眼看出它把中间调压得多狠:
x^2.2 是显示器把信号压暗的非线性响应(0.5 输入只亮成约 0.22);紫线 x^(1/2.2) 是输出前的 gamma 校正,先把中间调提亮(0.5 提到约 0.73)。两条互为反函数,串起来正好回到y=x 这条线性基准——这就是「校正抵消显示器压暗」的全部含义。图里那条向下凹的红线就是显示器响应:在中段它远低于那条线性对角虚线,这就是「同样的数字、点不出对应亮度」的全部真相。记住这条曲线,它是本章一切问题的根。
两个空间:线性空间 vs sRGB 空间
既然屏幕会扭曲数字,那「同一个数字」在不同地方就有了不同含义,我们得分清它待在哪个「空间」。
第一个是↡数值和真实亮度成正比的颜色空间:值翻倍亮度就翻倍,0.5 就是一半亮。物理上正确的光照计算(相加、相乘、衰减、混合)都必须在这个空间里做,否则结果会失真。它是你脑子里默认的那个「老实」的空间。:这里数值和真实亮度成正比,0.5 就是货真价实的一半亮、翻倍就是翻倍。物理上正确的光照——两束光相加、随距离衰减、和材质相乘——全都默认在这个老实空间里成立。你写光照公式时,脑子里想的就是这个空间。
第二个是↡经过 gamma 编码的颜色空间(值 ≈ 线性值^(1/2.2)),是为了配合显示器 ^2.2 的响应而存在的「显示用」空间。屏幕接收的、图片文件存的、美术在屏幕上看到的,几乎都是 sRGB 空间的值。它的中间调被人为提亮过,所以不能直接拿去做光照计算(会失真)。:这是给屏幕看的「显示用」空间,里面的值被预先提亮过(约等于 线性值^(1/2.2)),正好抵消屏幕那条 ^2.2 的压暗曲线。屏幕接收的、图片文件里存的、美术在屏幕上调出来的颜色,几乎都是 sRGB 空间的值。
关键认知来了:这两个空间的值不能混着用。光照计算要在线性空间做(那里加减乘除才正确),而最终给屏幕的、以及从图片读进来的,是 sRGB 空间。本章剩下的事,就是在正确的地方做正确的转换:算之前把输入转进线性,算之后把结果转回 sRGB。
为什么光照非在线性空间做不可
为什么非得较这个真?因为光的物理就是线性的。两盏一样亮的灯照同一处,到达的光是 0.5 + 0.5 = 1.0——这条加法只在线性空间成立。可要是你在 sRGB 空间里硬加:每盏灯的 sRGB 值约是 0.5^(1/2.2) ≈ 0.73,两个一加得 1.46、被截到 1.0——看着像是「直接顶白」,中间该有的过渡全没了;更常见的情形是没顶到白、但整体偏暗偏脏,叠加完全不对。
衰减也一样。光随距离变暗、不同表面颜色相乘混合,这些乘法和加法都假设自己活在线性空间。一旦你的数值其实是 sRGB 的,每一步都带着那条 ^(1/2.2) 的扭曲在算,越算越偏。所以正确的流水线是:先确保所有参与计算的颜色都在线性空间 → 做完整的光照 → 最后一步才转回 sRGB 给屏幕。这就引出了「最后那一步」该怎么做。
解法:输出前做 gamma 校正
「最后那一步」就是 ↡在光照算完、把颜色写进屏幕之前,对颜色做 pow(color, 1/2.2),把线性空间的计算结果编码回 sRGB 空间。它正好抵消显示器 ^2.2 的压暗:你提亮多少、屏幕压暗多少,最终显出的就是你线性算出的真实亮度。是整条渲染管线的最后一步,作用在最终输出颜色上、每个像素只做一次。:在片段着色器输出前,对最终颜色做一次 pow(color, vec3(1.0/2.2))。它做的事就是把你在线性空间算好的结果,主动提亮、编码回 sRGB 空间。
为什么这一下就对了?因为它和屏幕的脾气正好互为反函数:你用 ^(1/2.2) 提亮,屏幕用 ^2.2 压暗,一提一压,(c^(1/2.2))^2.2 = c——线性算出的 c 原原本本显了出来。回头看上面那张图:紫线(校正)和红线(屏幕)关于对角线对称,串起来正好回到那条线性基准。这就是为什么校正必须放在整条管线的最末端:它是专门补偿显示器的,只在「上屏」这一刻、对最终颜色、每像素做一次,多一次少一次都不行。要是不小心校正了两次,就会犯↡把颜色 gamma 校正了两次导致画面发白、过曝、对比度变低的错误。常见于:已经用了 sRGB 帧缓冲(硬件自动校正),又在着色器里手动再 pow(color, 1/2.2) 一遍;或把已是 sRGB 的值当线性值反复处理。修法:gamma 校正全管线只做一次,硬件 sRGB 与手动 pow 二选一。的错——画面会整体发白、对比度变低(这个坑后面误区还会细说)。
别忘了入口:sRGB 纹理要先解码到线性
校正管的是「出口」,可还有个「入口」常被漏掉:纹理。美术在屏幕上画贴图、相机拍照片,存出来的图片文件几乎都是 sRGB 空间的(它们本就是给屏幕看的)。你在着色器里 texture() 采样得到的颜色,已经是被提亮过的 sRGB 值——直接拿去参与光照计算就错了,因为光照要的是线性值。
所以颜色类纹理进来后,得先解码回线性:要么手动 pow(采样色, vec3(2.2))(屏幕脾气的逆操作的逆操作——把 sRGB 压回线性),要么干脆用 sRGB 纹理格式让硬件采样时自动解码(WebGL2 里用 gl.SRGB8 等格式,更省事也更准)。一句话记牢这条流水线:入口处把 sRGB 纹理解码进线性 → 全程在线性空间算光照 → 出口处 gamma 校正回 sRGB。注意:只有颜色/漫反射贴图是 sRGB 要解码;法线贴图、镜面遮罩这类存数据而非颜色的贴图本来就是线性的,千万别解码(解码了反而毁掉数据),这个坑后面误区还会细说。
衰减也要回到线性空间重调
原教程还指出一个容易被旧参数掩盖的变化:真实点光源的起点是平方反比 1 / d^2。未校正的显示器会额外把它压成近似 (1 / d^2)^2.2,看起来衰减过快,于是旧项目常改用 1 / d 或可调的经验曲线。进入线性工作流后,平方反比重新成为合理起点;可调衰减仍可保留,但参数要在校正后的画面里重新定。
数学:三个幂运算讲清一切
本章的数学就是「幂运算」,不烧脑,但要分清谁压暗、谁提亮。先把符号摊开:
- :线性空间里的颜色值(物理上正确、可直接做光照的那个,)
- :sRGB 空间里的颜色值(给屏幕看的、被提亮过的「显示值」)
- :屏幕最终点出来的实际亮度
- (gamma):显示器响应指数,本章取
第一式·显示器的脾气。屏幕拿到一个显示值,点出来的实际亮度是它的 次幂:
这个式子在说:屏幕会把输入压暗。代入 、,得 ——你以为半亮,屏幕只给两成。这就是上面那条向下凹的红线。
第二式·gamma 校正。为了抵消压暗,我们在输出前主动把线性值提亮——做它的 次幂,得到该送给屏幕的 sRGB 值:
这个式子在说:把线性算出的 提亮成 sRGB 值再上屏。代入 ,得 ——这就是上面那条向上凸的紫线(GLSL 里就是 pow(color, vec3(1.0/2.2)))。
第三式·一提一压正好抵消。把第二式代进第一式,看看屏幕最终点出什么:
这个式子在说:你提亮 、屏幕压暗 ,两个指数相乘等于 1,屏幕点出来的正好等于你线性算出的 。这就是 gamma 校正「奏效」的全部数学——互为反函数、严丝合缝地抵消。
第四式·纹理入口解码。从图片读进来的是 sRGB 值,要参与线性光照就得先解码回线性——做 次幂(注意是 、不是 ,方向和校正相反):
这个式子在说:把采样到的 sRGB 纹理色压回线性空间(GLSL 里 pow(texColor, vec3(2.2))),之后才能正确地参与相加、相乘、衰减。它和第二式刚好互逆——一个管入口解码、一个管出口编码,方向千万别搞反。
动手:拖一条渐变,看校正把中间调提回来
猜一猜:下面是一条从黑(左)到白(右)的渐变条。先看「未校正」的样子(
uCorrect= 0)——线性值直接送显示器。你觉得正中间那块(线性0.5)看起来会是干净的中灰,还是偏暗、发黑?然后把uCorrect拨到 1 做 gamma 校正,中间那块会怎样变?先动手拖,再看下面。
这条渐变里,每个横向位置的线性值就是它的横坐标(左 0、右 1)。uCorrect = 0 时直接把线性值喂给屏幕,被 ^2.2 一压,中间调塌下去、整条挤向亮端;uCorrect = 1 时输出前做 pow(c, 1/uGamma) 校正,中间调被提亮、过渡变均匀。uGamma 滑块让你试不同的校正强度(标准是 2.2):
实时演示加载中…
谜底:uCorrect = 0 时,正中间那块线性 0.5 被屏幕压成约 0.22 的亮度——所以它偏黑,整条渐变看着挤在右半边的亮端;切到 uCorrect = 1,校正把中间调提到约 0.73 送屏、被屏幕压回约 0.5,那块就成了干净的中灰,从黑到白过渡顺滑。这正是 gamma 校正最直观的功效:把被屏幕偷走的中间调还回来。
左右擦一擦:未校正 vs 校正
下面这个对比把上面的差异定格成两张静态渐变条,左拖右拉直接比中间调。两侧是同一条黑到白渐变,只差「上屏前有没有做 gamma 校正」:
把滑块拖到中线两侧对照那条「中间调(线性 0.5)」竖虚线:左边未校正,中段那几格明显偏黑、整条过渡挤向亮端;右边已校正,中段是均匀的中灰、黑白之间过渡顺滑。区别全在中间调——这就是「显示器把中间调压暗」与「gamma 校正把它提回来」的一体两面。
单步检查:颜色到底在哪个空间
猜一猜:同一个颜色值什么时候该做
pow(c, 2.2),什么时候该做pow(c, 1/2.2)?按颜色从纹理到屏幕走一遍,先标出边界再写代码。
① 颜色纹理先解码,数据纹理不动
漫反射和 albedo 贴图通常是 sRGB,采样后要转线性;法线、粗糙度和遮罩存的是数据,不应做 gamma 解码。
代码对照:出口那一行 pow
落到代码,gamma 校正就是片段着色器输出前的一行。先算完全部光照得到线性结果 color,最后一步做 pow(color, vec3(1.0/gamma)) 再写进 FragColor:
#version 330 core
out vec4 FragColor;
// ……前面是完整光照,得到线性空间的结果 color……
void main() {
vec3 color = doAllLighting(); // 线性空间算出的最终色
float gamma = 2.2;
// 出口:把线性结果编码回 sRGB,抵消显示器的 ^2.2 压暗
color = pow(color, vec3(1.0 / gamma));
FragColor = vec4(color, 1.0);
}#version 300 es
precision highp float; // WebGL2 片段着色器必写:声明浮点精度
out vec4 FragColor;
// ……前面是完整光照,得到线性空间的结果 color……
void main() {
vec3 color = doAllLighting(); // 线性空间算出的最终色
float gamma = 2.2;
// 出口:把线性结果编码回 sRGB,抵消显示器的 ^2.2 压暗
color = pow(color, vec3(1.0 / gamma));
FragColor = vec4(color, 1.0);
}校正这一行两端 GLSL 一字不差。要注意的只有:它必须是整条管线的最后一步——所有相加、相乘、衰减都算完,才在写 FragColor 之前做这一下;放早了,后面的计算又在 sRGB 空间里跑,前功尽弃。
入口处的 sRGB 纹理解码同理——采样后立刻 pow(texColor, vec3(2.2)) 压回线性,再参与光照(或用 gl.SRGB8 纹理格式让硬件自动解码)。出口提亮、入口压暗,方向相反,对应 §4 的第二式和第四式,别记反。
容易踩的坑
小结
- 显示器非线性:实际亮度 ≈
输入^2.2,把中间调压暗(0.5只亮到约0.22),源于 CRT、由 sRGB 沿用 - 分清两个空间:线性空间(数值正比亮度、光照计算在此做)vs sRGB 空间(给屏幕看的显示值、纹理与输出都在此)
- 光照的加减乘除只在线性空间成立,所以两盏灯叠加、衰减、混色必须在线性空间算,否则偏暗失真
- gamma 校正 = 管线最后一步对最终色做
pow(color, 1/2.2),提亮以抵消屏幕压暗,每像素只做一次 - 入口要把 sRGB 颜色纹理先
pow(c, 2.2)解码到线性(数据贴图不解码);校正只做一次,做两次就双重 gamma 发白
练习
问题 1(问答型) 一盏灯把某处照到线性亮度 0.5。如果你不做 gamma 校正、直接把 0.5 送屏,屏幕实际点出多少亮度?做了校正(pow(0.5, 1/2.2) 再上屏)之后,屏幕又点出多少?
问题 2(改 Demo 代码型) 在上面那条渐变 Demo 里,亲手造出双重 gamma 校正(发白过曝)的效果:把校正那行从只做一次改成连做两次。说出你怎么改,并解释为什么会发白。
问题 3(问答型) 一张已经是 sRGB 的木箱漫反射贴图,你采样后没解码就拿去做光照,最后又对输出做了一次 gamma 校正。这张木箱看起来会偏亮还是偏暗?问题出在入口还是出口?
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 显示器 gamma
显示器把输入数字转成实际亮度时的非线性响应:实际亮度 ≈
输入^2.2。意思是同样的数字点不出成比例的亮度——输入0.5实际只亮到约0.22,中间调被压得最暗,纯黑纯白不动。这个特性源于早年 CRT 显像管,后来被 sRGB 标准固定下来,几乎所有屏幕都背着它。详见本章「屏幕的脾气」一节。- 线性空间
数值和真实亮度成正比的颜色空间:值翻倍亮度就翻倍,
0.5就是货真价实的一半亮。两束光相加、随距离衰减、和材质相乘这些物理上正确的光照计算,全都必须在这个空间里做,否则结果会失真。它就是你写光照公式时脑子里默认的那个「老实」空间。详见本章「两个空间」一节。- sRGB 空间
给屏幕看的「显示用」颜色空间,里面的值被预先提亮过(约等于
线性值^(1/2.2)),正好抵消屏幕^2.2的压暗。屏幕接收的、图片文件存的、美术在屏幕上看到的颜色几乎都是 sRGB 值。它的中间调被人为提亮过,所以不能直接拿去做光照(要先解码回线性)。详见本章「两个空间」一节。- gamma 校正
在光照全部算完、把颜色写进屏幕之前,对最终颜色做一次
pow(color, 1/2.2),把线性空间的结果提亮、编码回 sRGB 空间。它和屏幕^2.2的压暗正好互为反函数:你提亮多少、屏幕压暗多少,最终显出的就是你线性算出的真实亮度。是整条渲染管线的最后一步,每个像素只做一次。详见本章「解法」一节。- 双重 gamma 校正
把颜色校正了两次导致画面发白、过曝、对比度变低的错误。常见于:已经用了 sRGB 帧缓冲(硬件自动校正),又在着色器里手动再
pow(color, 1/2.2)一遍;或把已是 sRGB 的纹理当线性值反复处理。修法:gamma 校正全管线只做一次,硬件 sRGB 与手动pow二选一。详见本章「容易踩的坑」一节。
版本、来源与运行边界
本章以 Joey de Vries 的 LearnOpenGL 原章 为授权改编依据,教学运行基线是 OpenGL 3.3 Core Profile。Khronos 当前发布的规范参照是 OpenGL 4.6 Core Profile;这里用 4.6 规范核查术语和状态合同,但不把 4.6 API 偷偷倒填为原教程内容。GLFW、GLAD、Assimp 与驱动版本都属于运行环境,不能拿“编译通过”替代对 context、资源和 framebuffer 结果的验证。
正式概念与状态责任
- gamma correction:在“Gamma 校正、sRGB 边界与线性光照”中由纹理内部格式、shader 线性运算与 framebuffer sRGB state负责解释其输入、受控状态和可观察结果;运行时以texture internal format、FRAMEBUFFER_SRGB、线性中间值、输出曲线与像素定位它的第一处变化。
- srgb:在“Gamma 校正、sRGB 边界与线性光照”中由纹理内部格式、shader 线性运算与 framebuffer sRGB state负责解释其输入、受控状态和可观察结果;运行时以texture internal format、FRAMEBUFFER_SRGB、线性中间值、输出曲线与像素定位它的第一处变化。
- linear space:在“Gamma 校正、sRGB 边界与线性光照”中由纹理内部格式、shader 线性运算与 framebuffer sRGB state负责解释其输入、受控状态和可观察结果;运行时以texture internal format、FRAMEBUFFER_SRGB、线性中间值、输出曲线与像素定位它的第一处变化。
章专属 OpenGL 状态实验
先预测“对颜色纹理解码到线性,完成全部光照,再且仅再编码一次”发生后,纹理内部格式、shader 线性运算与 framebuffer sRGB state应怎样改变输入编码、线性 texel、光照/衰减、输出编码和显示值;再操作三个实验。实验不生成变化率或正确率等虚构总分,只显示真实 GL 状态、资源、命令和可观察结果。
实验一:Context—资源—结果合同
选择任一正式概念与基线/单故障场景,核对它是否进入本章状态合同。正式概念只有同时出现在解释、可视状态和交付证据中才算覆盖。
Context · resource · observable result
Gamma 校正、sRGB 边界与线性光照:状态合同
划分纹理解码、线性光照、sRGB framebuffer 编码与显示出口,避免双重 gamma
验证场景
官方教程正式概念
logl-30 · 基线帧
gamma correction:固定 context、资源内容与输入事件,执行“对颜色纹理解码到线性,完成全部光照,再且仅再编码一次”
冻结输入:gamma correction
纹理内部格式、shader 线性运算与 framebuffer sRGB state记录输入编码、线性 texel、光照/衰减、输出编码和显示值
结果:得到可重复的初始 GL 状态与资源身份
观测:texture internal format、FRAMEBUFFER_SRGB、线性中间值、输出曲线与像素中的初始快照
预期:纹理内部格式、shader 线性运算与 framebuffer sRGB state得到可复查结果,并持续满足“光照和混合发生在线性空间;normal/metallic 等数据纹理不做 sRGB 解码”
实验二:CPU 命令到 GPU 结果的五段轨迹
逐段执行“对颜色纹理解码到线性,完成全部光照,再且仅再编码一次”,在每一步记录资源身份、状态变化与第一个可观察结果,并持续核对“光照和混合发生在线性空间;normal/metallic 等数据纹理不做 sRGB 解码”。
CPU command · GL state · GPU result
Gamma 校正、sRGB 边界与线性光照:五段轨迹
当前观测:texture internal format、FRAMEBUFFER_SRGB、线性中间值、输出曲线与像素中的初始快照
不变量:光照和混合发生在线性空间;normal/metallic 等数据纹理不做 sRGB 解码
实验三:单故障与同输入恢复
注入“使用 GL_SRGB 纹理自动解码后又在 shader 中 pow(2.2),输入被解码两次”,保存首个分岔;撤销后沿用完全相同的 context、资源内容、uniform 和 draw 输入重放。只有texture internal format、FRAMEBUFFER_SRGB、线性中间值、输出曲线与像素一起恢复才算修复。
Single fault · first divergence · replay
Gamma 校正、sRGB 边界与线性光照:反例与恢复
故障:使用 GL_SRGB 纹理自动解码后又在 shader 中 pow(2.2),输入被解码两次
第 1 次沿用同一 context、资源、uniform 与 draw 输入
保持其余输入不变,仅注入“使用 GL_SRGB 纹理自动解码后又在 shader 中 pow(2.2),输入被解码两次”
光照和混合发生在线性空间;normal/metallic 等数据纹理不做 sRGB 解码
texture internal format、FRAMEBUFFER_SRGB、线性中间值、输出曲线与像素
最小可重放检查
unit: logl-30
owner: 纹理内部格式、shader 线性运算与 framebuffer sRGB state
state_or_resource: 输入编码、线性 texel、光照/衰减、输出编码和显示值
command: 对颜色纹理解码到线性,完成全部光照,再且仅再编码一次
pass_invariant: 光照和混合发生在线性空间;normal/metallic 等数据纹理不做 sRGB 解码
single_fault: 使用 GL_SRGB 纹理自动解码后又在 shader 中 pow(2.2),输入被解码两次
required_evidence: texture internal format、FRAMEBUFFER_SRGB、线性中间值、输出曲线与像素复核者先仅依据以上合同写出预期,再运行基线、单故障和清理后重放。若两次基线的资源身份、首个状态变化或 framebuffer 结果不同,必须保留差异,不能用最终截图相似掩盖中间状态错误。