Gamma 与线性空间
Gamma 与线性空间
1. 0.5 不是一半亮
先看结论。
图:屏幕上码值 0.5 的那个灰块,实际发出的光量只有最大值的约 21.4%——连四分之一都不到。
把 0.5 代进 sRGB 的解码公式算一遍。因为 ,走幂函数那支:
这不是屏幕的缺陷,是刻意设计的。
为什么要这么设计
人眼对亮度的感知不是线性的:在暗部,一点点亮度变化就很明显;在亮部,同样的变化几乎看不出来。
而 8 位一个通道只有 256 个码值可用。如果把这 256 个值均匀分给光量(0、1/255、2/255……),会发生什么?
- 暗部:相邻两级之间人眼看得出跳变,出现色带(banding);
- 亮部:好几级看起来一模一样,码值被浪费了。
解决办法就是不均匀分配:给暗部多分一些码值,给亮部少分一些。而"给暗部多分"这件事,写成公式就是一条上凸的曲线——把线性光量 压成码值 时用 这类幂函数。
所以 sRGB 编码本质上是一种针对人眼的数据压缩,用同样的 256 个码值换取更好的观感。
顺带解释一个常见混淆:很多地方说 sRGB "是 gamma 2.2"。严格说不对——sRGB 用的是 的幂函数加上一段直线,整体形状近似于纯 gamma 2.2,但不相等。上面算出的 0.2140 和 就差了一点点。做快速估算用 2.2 没问题,写正式的转换代码要按分段参数来。
2. 那条曲线长什么样
图左是解码方向(码值 → 光量),图右是编码方向(光量 → 码值)。两条曲线互为反过程,一个下凹一个上凸,串起来就回到原样。灰色虚线是纯 gamma 2.2 / 1/2.2 的近似,可以看到贴得相当近但不重合。
这就是问题 2 的答案
两个方向各有一个名字,因为它们出现在管线的两端:
| 缩写 | 全称 | 方向 | 在哪儿发生 | sRGB 标准 |
|---|---|---|---|---|
| OETF | Opto-Electronic Transfer Function | 光 → 电(光量 → 码值) | 拍摄/生成端:相机传感器、渲染器输出 | 分段点约 (),暗部走直线、亮部走幂函数 |
| EOTF | Electro-Optical Transfer Function | 电 → 光(码值 → 光量) | 显示端:屏幕把码值变成实际发光 | 分段点 ,暗部走直线、亮部走 幂函数 |
记忆法:看第一个字母就是输入。 OETF 的输入是 Opto(光),EOTF 的输入是 Electro(电信号/码值)。
为什么不能只叫"gamma 编解码"? 因为 gamma 只是其中一族传递函数。到了 HDR,PQ 和 HLG 用的是完全不同形状的曲线(见选读附录《色域、YUV 与 HDR》),但它们同样是 OETF/EOTF。用 OETF/EOTF 这个名字,说的是"它在管线的哪一端",而不是"它长什么样"——这才是不会过时的分类方式。
3. 为什么要接一小段直线
图:把最暗的那一小段放大看。橙色是 sRGB 的实际曲线,在 左边是一条直线;灰色虚线是"如果直接用纯幂函数"的样子。
纯幂函数 在 附近的斜率趋近于 0,也就是说:一大堆不同的码值会被压到几乎一样的暗。这带来两个问题:
- 浪费码值:最暗的那几十个码值,解码出来的光量差别微乎其微;
- 放大噪点:编码方向上斜率趋近无穷大,意味着传感器的一点点噪声会被放大成很大的码值波动。
接一段斜率有限的直线,就避开了这两个麻烦。这就是问题 3 的答案,也解释了解码公式为什么是两段——它不是什么特殊优化,是标准本身就规定了两段。
(顺带一提,编码方向的分段点写的是 ,不是 。因为编码方向的输入是光量,分段点要用直线段的斜率 换算过去:。两个方向的分段点必须严格对应,否则来回转一趟就对不上了。)
4. 线性空间铁律
现在到了本篇最重要的一节。
规则:
凡是"能量会叠加"的运算,必须在线性空间做。
因为线性空间里的数字正比于实际的光量,只有在那里做加法才对应物理上"两束光叠在一起"。而编码值是被那条曲线压过的,直接对它做加权平均,得到的东西没有物理意义。
直观演示
图:从黑到白的渐变。上排直接在编码值上插值,中段明显偏暗,整条渐变"塌"向暗部;下排先转线性、插值、再转回来,过渡才是均匀的。中点码值:错误做法 0.500,正确做法 0.735。
再看一个更能说明问题的例子:
图:一张黑白相间的细条纹图,一半像素是黑(光量 0)一半是白(光量 1)。缩小之后应该是"一半的光"。
- 在编码值上平均: → 码值 0.500
- 在线性空间平均: 的光量 → 编码回去是 0.735
两个结果差得非常远。而 0.735 才是对的——因为物理上这块区域确实发出了一半的光,而"一半的光"对应的码值就是 0.735。
注意错误的方向永远是偏暗,因为编码曲线是上凸的。这就是很多图形 bug 的典型症状:"说不上哪里不对,就是整体感觉暗了一点、脏了一点"——尤其在半透明边缘、缩略图、模糊、渐变这些地方。
哪些运算受这条规则约束
| 运算 | 受约束? | 为什么 |
|---|---|---|
| alpha 混合、图层叠加 | ✅ 必须 | 两束光叠加 |
| 缩放、双线性、多级缩略图生成 | ✅ 必须 | 是邻域的加权平均 |
| 模糊、卷积 | ✅ 必须 | 加权和 |
| 多帧混合(TAA、降噪) | ✅ 必须 | 加权平均 |
| 光照计算 | ✅ 必须 | 能量的乘加 |
| 求最大值、最小值 | ❌ 不必 | 单调变换不改变大小顺序 |
| 判断"这里变化剧不剧烈"(边缘检测的方向性结论) | ⚠️ 通常可以将就 | 不涉及跨像素的能量叠加,只求相对判据 |
最后一行值得展开一句:如果你只想知道"哪里是边、哪里是平坦区",在编码空间算梯度得到的方向性结论通常仍然可用。但只要这个梯度值后面要参与加权混合,就必须回到线性空间——"只是拿来当判据"和"参与最终的能量计算",是两码事。
5. 工程上怎么落地
好消息是:大部分时候你不需要手写转换。
| 机制 | 谁来做转换 |
|---|---|
| sRGB 纹理格式(采样时自动解码) | 纹理单元硬件:采样时自动解码成线性,免费 |
| sRGB 帧缓冲(写入时自动编码) | ROP 硬件:写入时自动编码,混合发生在线性空间 |
| 颜色空间作为独立参数 | 任何认真的图形库都把颜色空间当作独立参数保存,并在采样/混合路径自动插入转换;本课程的 lab 用 Python 完成同样的正反向转换(见 lab_08 的输出) |
要小心的是那些"绕过了机制"的路径:自己写的计算着色器对存储图像做读写、自己实现的重采样、把缓冲区当裸字节处理的地方——硬件不知道这些数采用哪条传递函数,不会仅凭"归一化整数"自动替你转换。 归一化只规定整数如何映射到 ,数据究竟是线性值还是 sRGB 等编码值,要由颜色空间元数据和资源语义说明。
一个务实的建议:看到任何对像素做加权平均的代码,先问一句"这里的数是线性的吗"。答不上来就去查数据的来源格式。这个习惯能挡掉绝大部分色彩类的问题。
自测
问题 1:0.5 为什么不是一半亮?
因为存进缓冲区的码值是按人眼感知压过的,不正比于光量。把 0.5 代进 sRGB 解码公式,结果约是 0.214——只有最大值约 21.4% 的光。这是刻意设计:暗部分到更多码值,亮部少分一些,用同样 256 级换更好的观感。
问题 2:那条曲线长什么样?
两个方向各一条、互为反过程:解码(码值 → 光量)下凹,编码(光量 → 码值)上凸。正式名字是 EOTF(电→光,显示端解码)和 OETF(光→电,拍摄/生成端编码)。它们近似纯 gamma 2.2,但不相等——sRGB 用的是 再加一段直线。
问题 3:为什么要接一小段直线?
纯幂函数在 0 附近斜率趋近 0,会把一堆暗部码值压成几乎一样的值(浪费码值),同时在编码方向放大噪声。接一段斜率有限的直线避开了这两个问题。分段点 是标准规定的。注意编码方向的分段点是 ,两者必须严格对应。
以后再看到一段"对像素做加权平均"的代码,你应该条件反射地问:这些数是线性的吗? 若资源元数据表明它存的是 sRGB 等编码值,却通过不会自动解码的路径读出后直接平均,这段代码就会系统性地压暗画面。不能只看到"8 位归一化整数"就下结论——格式本身没有声明传递函数。
1. 码值 0.5 对应多少线性光量?用 sRGB 的精确公式算。
约 0.2140。
,走幂函数支:
用纯 gamma 2.2 估算是 ,差一点点——估算够用,正式代码要用精确公式。
2. 一半像素是纯黑、一半是纯白,正确地缩小成一个像素,结果码值是多少?
约 0.735。
线性光量的平均是 ,再用 OETF 编码回去:
直接在码值上平均会得到 0.500,明显偏暗。
3. OETF 和 EOTF,哪个在相机里、哪个在屏幕里?
OETF 在相机(以及渲染器输出)——把光转成电信号/码值;EOTF 在屏幕——把码值转回光。
记忆法:第一个字母就是输入。Opto→Electronic 是光进电出,Electro→Optical 是电进光出。
4. 下面哪些运算必须在线性空间做?(a) 高斯模糊 (b) 求一片区域的最大亮度 (c) 生成多级缩略图 (d) alpha 混合
(a)、(c)、(d) 必须,(b) 不必。
(a)(c)(d) 都是加权平均或能量叠加——只有线性空间的数才正比于实际光量。
(b) 求最大值只关心大小顺序,而传递函数是单调递增的,不改变谁大谁小。所以在哪个空间求都能找到同一个像素(当然,得到的数值含义不同)。
5. 一段自定义的图像读写代码直接对 8 位归一化整数缓冲区做邻域平均再写回。仅凭这些信息,能判断它有问题吗?
不能仅凭格式判断。 归一化整数只说明读出来会把整数映射到 ,没有说明内容是线性值还是传递函数编码值。
- 如果资源约定存的是线性值,直接平均没有 gamma 问题;
- 如果资源约定存的是 sRGB 等编码值,这条裸读写路径通常不会像 sRGB 采样器那样自动解码,必须先手动解码成线性,算完再编码写回,或改走明确支持 sRGB 解码的采样路径。
这正是"格式"与"颜色空间语义"必须分开的典型场景——先确认数据代表什么,再决定是否需要转换。
小结
| 概念 | 一句话 | 要点 |
|---|---|---|
| 码值 ≠ 亮度 | 0.5 只对应约 21.4% 的光 | 代入解码公式 ≈ 0.214 |
| 为什么要编码 | 人眼对暗部更敏感,把 256 个码值不均匀分配 | 本质是针对人眼的压缩 |
| sRGB ≠ gamma 2.2 | 形状近似但不相等 | 加一段直线 |
| 为什么分段 | 纯幂函数在 0 附近浪费码值、放大噪声 | 解码在 处分段 |
| 两个分段点 | 解码用 ,编码用 | 必须严格对应 |
| OETF | 光→电,编码,在相机/渲染器输出端 | 分段点约 |
| EOTF | 电→光,解码,在屏幕端 | 分段点 |
| 线性空间铁律 | 能量会叠加的运算必须在线性空间做 | 混合、缩放、模糊、多帧累积、光照 |
| 错误的方向 | 永远偏暗(编码曲线上凸) | 症状是"说不上哪不对,就是有点脏" |
| 硬件会帮忙 | sRGB 纹理格式、sRGB 帧缓冲自动转 | 但裸读写不会 |
延伸阅读
- GAMES101 Lecture 20:Color and Perception:https://games-cn.org/intro-graphics/
- 《What every coder should know about gamma》:https://blog.johnnovak.net/2016/09/21/what-every-coder-should-know-about-gamma/
- Wikipedia: sRGB(含精确的分段公式与参数):https://en.wikipedia.org/wiki/SRGB

觉得有用?关注公众号「阿豪讲Framework」
Android 系统开发,新文章第一时间推送。