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亮。可现实里的显示器偏不——它有一条响应曲线:实际亮度大约等于 输入^2.2

代入算算:输入 0.5,输出却是 0.5^2.2 ≈ 0.22——你以为是「一半亮」,屏幕只点出约两成。这条曲线把中间调压得最狠,两端(纯黑、纯白)反而不动。这个数字 2.2 不是某家厂商拍脑袋定的,而是早年 CRT 显像管的物理特性——电压翻倍,亮度并不翻倍,而是按幂律涨;后来 sRGB 标准干脆把这条曲线写进规范,于是今天几乎每块屏幕都背着这条 2.2 的脾气。下面这张图把这条曲线和「理想线性」画在一起,你一眼看出它把中间调压得多狠:

三条 gamma 幂曲线:输出怎样随输入变化101输入值 →输出值0.5≈0.22≈0.73x^2.2 显示器响应(压暗)x^(1/2.2) gamma 校正(提亮)y=x 线性基准
红线 x^2.2 是显示器把信号压暗的非线性响应(0.5 输入只亮成约 0.22);紫线 x^(1/2.2) 是输出前的 gamma 校正,先把中间调提亮(0.5 提到约 0.73)。两条互为反函数,串起来正好回到y=x 这条线性基准——这就是「校正抵消显示器压暗」的全部含义。

图里那条向下凹的红线就是显示器响应:在中段它远低于那条线性对角虚线,这就是「同样的数字、点不出对应亮度」的全部真相。记住这条曲线,它是本章一切问题的根。

两个空间:线性空间 vs sRGB 空间

既然屏幕会扭曲数字,那「同一个数字」在不同地方就有了不同含义,我们得分清它待在哪个「空间」。

第一个是:这里数值和真实亮度成正比0.5 就是货真价实的一半亮、翻倍就是翻倍。物理上正确的光照——两束光相加、随距离衰减、和材质相乘——全都默认在这个老实空间里成立。你写光照公式时,脑子里想的就是这个空间。

第二个是:这是给屏幕看的「显示用」空间,里面的值被预先提亮过(约等于 线性值^(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, vec3(1.0/2.2))。它做的事就是把你在线性空间算好的结果,主动提亮、编码回 sRGB 空间。

为什么这一下就对了?因为它和屏幕的脾气正好互为反函数:你用 ^(1/2.2) 提亮,屏幕用 ^2.2 压暗,一提一压,(c^(1/2.2))^2.2 = c——线性算出的 c 原原本本显了出来。回头看上面那张图:紫线(校正)和红线(屏幕)关于对角线对称,串起来正好回到那条线性基准。这就是为什么校正必须放在整条管线的最末端:它是专门补偿显示器的,只在「上屏」这一刻、对最终颜色、每像素做一次,多一次少一次都不行。要是不小心校正了两次,就会犯的错——画面会整体发白、对比度变低(这个坑后面误区还会细说)。

别忘了入口: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 或可调的经验曲线。进入线性工作流后,平方反比重新成为合理起点;可调衰减仍可保留,但参数要在校正后的画面里重新定。

数学:三个幂运算讲清一切

本章的数学就是「幂运算」,不烧脑,但要分清谁压暗、谁提亮。先把符号摊开:

  • clinearc_{linear}:线性空间里的颜色值(物理上正确、可直接做光照的那个,0c10 \le c \le 1
  • csrgbc_{srgb}:sRGB 空间里的颜色值(给屏幕看的、被提亮过的「显示值」)
  • cdisplayc_{display}:屏幕最终点出来的实际亮度
  • γ\gamma(gamma):显示器响应指数,本章取 γ=2.2\gamma = 2.2

第一式·显示器的脾气。屏幕拿到一个显示值,点出来的实际亮度是它的 γ\gamma 次幂:

cdisplay=csrgb γc_{display} = c_{srgb}^{\ \gamma}

这个式子在说:屏幕会把输入压暗。代入 csrgb=0.5c_{srgb}=0.5γ=2.2\gamma=2.2,得 0.52.20.220.5^{2.2}\approx 0.22——你以为半亮,屏幕只给两成。这就是上面那条向下凹的红线。

第二式·gamma 校正。为了抵消压暗,我们在输出前主动把线性值提亮——做它的 1/γ1/\gamma 次幂,得到该送给屏幕的 sRGB 值:

csrgb=clinear 1/γc_{srgb} = c_{linear}^{\ 1/\gamma}

这个式子在说:把线性算出的 clinearc_{linear} 提亮成 sRGB 值再上屏。代入 clinear=0.5c_{linear}=0.5,得 0.51/2.20.730.5^{1/2.2}\approx 0.73——这就是上面那条向上凸的紫线(GLSL 里就是 pow(color, vec3(1.0/2.2)))。

第三式·一提一压正好抵消。把第二式代进第一式,看看屏幕最终点出什么:

cdisplay=(clinear 1/γ)γ=clinearc_{display} = \left(c_{linear}^{\ 1/\gamma}\right)^{\gamma} = c_{linear}

这个式子在说:你提亮 1/γ1/\gamma、屏幕压暗 γ\gamma,两个指数相乘等于 1,屏幕点出来的正好等于你线性算出的 clinearc_{linear}。这就是 gamma 校正「奏效」的全部数学——互为反函数、严丝合缝地抵消。

第四式·纹理入口解码。从图片读进来的是 sRGB 值,要参与线性光照就得先解码回线性——做 γ\gamma 次幂(注意是 γ\gamma、不是 1/γ1/\gamma,方向和校正相反):

clinear=csrgb γc_{linear} = c_{srgb}^{\ \gamma}

这个式子在说:把采样到的 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)黑 0白 1
已校正(中段提亮 · 均匀)
中间调(线性 0.5)黑 0白 1
未校正(中段偏黑)

把滑块拖到中线两侧对照那条「中间调(线性 0.5)」竖虚线:左边未校正,中段那几格明显偏黑、整条过渡挤向亮端;右边已校正,中段是均匀的中灰、黑白之间过渡顺滑。区别全在中间调——这就是「显示器把中间调压暗」与「gamma 校正把它提回来」的一体两面。

单步检查:颜色到底在哪个空间

猜一猜:同一个颜色值什么时候该做 pow(c, 2.2),什么时候该做 pow(c, 1/2.2)?按颜色从纹理到屏幕走一遍,先标出边界再写代码。

分步1 / 3

① 颜色纹理先解码,数据纹理不动

漫反射和 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);
}

校正这一行两端 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、资源内容与输入事件,执行“对颜色纹理解码到线性,完成全部光照,再且仅再编码一次”

状态所有者纹理内部格式、shader 线性运算与 framebuffer sRGB state
受控状态/资源输入编码、线性 texel、光照/衰减、输出编码和显示值
触发命令对颜色纹理解码到线性,完成全部光照,再且仅再编码一次

冻结输入: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 边界与线性光照:五段轨迹

选择一段命令—资源—结果1 / 5

当前观测: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. 冻结输入一致

第 1 次沿用同一 context、资源、uniform 与 draw 输入

2. 注入单故障一致

保持其余输入不变,仅注入“使用 GL_SRGB 纹理自动解码后又在 shader 中 pow(2.2),输入被解码两次”

3. 定位首差一致

光照和混合发生在线性空间;normal/metallic 等数据纹理不做 sRGB 解码

4. 清理并重放一致

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 结果不同,必须保留差异,不能用最终截图相似掩盖中间状态错误。

出处声明

本文为改编重写,改编自 Joey de Vries 的 LearnOpenGL。原文:learnopengl.com

原作及译作以 CC BY-NC 4.0 协议授权,本改编版同样遵循该协议(署名—非商业性使用)。

讨论

评论区加载中…