08. Gamma 与线性空间
08. Gamma 与线性空间
本篇位置:模块一 · 第 8 篇 | 前置:采样、走样与滤波 | 预计阅读:40 分钟
你已经在用它了
ui::ColorSpace 里,sRGB 是这么定义的(frameworks/native/libs/ui/ColorSpace.cpp):
const ColorSpace ColorSpace::sRGB() {
return {
"sRGB IEC61966-2.1",
{{float2{0.640f, 0.330f}, {0.300f, 0.600f}, {0.150f, 0.060f}}},
{0.3127f, 0.3290f},
{2.4f, 1 / 1.055f, 0.055f / 1.055f, 1 / 12.92f, 0.04045f, 0.0f, 0.0f}
};
}最后那七个数是 TransferParameters {g, a, b, c, d, e, f},它们被喂进这两个函数:
static constexpr float response(float x, const ColorSpace::TransferParameters& p) {
return x >= p.d ? std::pow(p.a * x + p.b, p.g) : p.c * x;
}
static constexpr float rcpResponse(float x, const ColorSpace::TransferParameters& p) {
return x >= p.d * p.c ? (std::pow(x, 1.0f / p.g) - p.b) / p.a : x / p.c;
}而 ColorSpace.h 把它们包装成一对名字很讲究的接口:
/** Encodes the supplied RGB value using this color space's
* opto-electronic transfer function. */
constexpr float3 fromLinear(const float3& v) const noexcept {
return apply(v, mOETF);
}
/** Decodes the supplied RGB value using this color space's
* electro-optical transfer function. */
constexpr float3 toLinear(const float3& v) const noexcept {
return apply(v, mEOTF);
}三个问题:
response为什么是分段的? 为什么码值小于 0.04045 时走一条直线,大于时才用幂函数?- OETF 和 EOTF,哪个是哪个? 为什么要有两个名字,直接叫"gamma 编解码"不行吗?
- 既然存进 buffer 的都是编码值,为什么"混合前必须先转线性"? 不转会怎样?
第 3 个问题是本篇的中心,也是图形代码里最常见、最难被肉眼发现的一类错误。
学完你会什么
- 说清"编码值 0.5"和"一半的亮度"为什么不是一回事,并说出 0.5 到底对应多少光。
- 说清 sRGB 那条曲线为什么要分段,分段点在哪、为什么在那儿。
- 分清 OETF 和 EOTF,并说清它们各自出现在管线的哪一端。
- 说清"线性空间铁律"管的是哪些运算、不管哪些。
- 拿到一段可疑的图像处理代码,能判断它有没有踩这个坑。
1. 0.5 不是一半亮
先看结论。
图:屏幕上码值 0.5 的那个灰块,实际发出的光量只有最大值的约 21.4%——连四分之一都不到。
把 0.5 代进 response() 算一遍。因为 ,走上面那支:
这不是屏幕的缺陷,是刻意设计的。
为什么要这么设计
人眼对亮度的感知不是线性的:在暗部,一点点亮度变化就很明显;在亮部,同样的变化几乎看不出来。
而 8 位一个通道只有 256 个码值可用。如果把这 256 个值均匀分给光量(0、1/255、2/255……),会发生什么?
- 暗部:相邻两级之间人眼看得出跳变,出现色带(banding);
- 亮部:好几级看起来一模一样,码值被浪费了。
解决办法就是不均匀分配:给暗部多分一些码值,给亮部少分一些。而"给暗部多分"这件事,写成公式就是一条上凸的曲线——把线性光量 压成码值 时用 这类幂函数。
所以 sRGB 编码本质上是一种针对人眼的数据压缩,用同样的 256 个码值换取更好的观感。
顺带解释一个常见混淆:很多地方说 sRGB "是 gamma 2.2"。严格说不对——sRGB 用的是 的幂函数加上一段直线,整体形状近似于纯 gamma 2.2,但不相等。上面算出的 0.2140 和 就差了一点点。做快速估算用 2.2 没问题,写正式的转换代码要按
TransferParameters来。
2. 那条曲线长什么样
图左是解码方向(码值 → 光量),图右是编码方向(光量 → 码值)。两条曲线互为反过程,一个下凹一个上凸,串起来就回到原样。灰色虚线是纯 gamma 2.2 / 1/2.2 的近似,可以看到贴得相当近但不重合。
这就是问题 2 的答案
两个方向各有一个名字,因为它们出现在管线的两端:
| 缩写 | 全称 | 方向 | 在哪儿发生 | AOSP 里 |
|---|---|---|---|---|
| OETF | Opto-Electronic Transfer Function | 光 → 电(光量 → 码值) | 拍摄/生成端:相机传感器、渲染器输出 | fromLinear() / rcpResponse() |
| EOTF | Electro-Optical Transfer Function | 电 → 光(码值 → 光量) | 显示端:屏幕把码值变成实际发光 | toLinear() / response() |
记忆法:看第一个字母就是输入。 OETF 的输入是 Opto(光),EOTF 的输入是 Electro(电信号/码值)。
为什么不能只叫"gamma 编解码"? 因为 gamma 只是其中一族传递函数。到了 HDR,PQ 和 HLG 用的是完全不同形状的曲线(第 9 篇讲),但它们同样是 OETF/EOTF。用 OETF/EOTF 这个名字,说的是"它在管线的哪一端",而不是"它长什么样"——这才是不会过时的分类方式。
3. 为什么要接一小段直线
图:把最暗的那一小段放大看。橙色是 sRGB 的实际曲线,在 左边是一条直线;灰色虚线是"如果直接用纯幂函数"的样子。
纯幂函数 在 附近的斜率趋近于 0,也就是说:一大堆不同的码值会被压到几乎一样的暗。这带来两个问题:
- 浪费码值:最暗的那几十个码值,解码出来的光量差别微乎其微;
- 放大噪点:编码方向上斜率趋近无穷大,意味着传感器的一点点噪声会被放大成很大的码值波动。
接一段斜率有限的直线,就避开了这两个麻烦。这就是问题 1 的答案,也解释了 response() 里那个三目运算符——它不是什么特殊优化,是标准本身就规定了两段。
(顺带一提,rcpResponse() 的分段点写的是 p.d * p.c,不是 p.d。因为编码方向的输入是光量,分段点要用直线段的斜率 换算过去:。两个方向的分段点必须严格对应,否则来回转一趟就对不上了。)
4. 线性空间铁律
现在到了本篇最重要的一节。
规则:
凡是"能量会叠加"的运算,必须在线性空间做。
因为线性空间里的数字正比于实际的光量,只有在那里做加法才对应物理上"两束光叠在一起"。而编码值是被那条曲线压过的,直接对它做加权平均,得到的东西没有物理意义。
直观演示
图:从黑到白的渐变。上排直接在编码值上插值,中段明显偏暗,整条渐变"塌"向暗部;下排先转线性、插值、再转回来,过渡才是均匀的。中点码值:错误做法 0.500,正确做法 0.735。
再看一个更能说明问题的例子:
图:一张黑白相间的细条纹图,一半像素是黑(光量 0)一半是白(光量 1)。缩小之后应该是"一半的光"。
- 在编码值上平均: → 码值 0.500
- 在线性空间平均: 的光量 → 编码回去是 0.735
两个结果差得非常远。而 0.735 才是对的——因为物理上这块区域确实发出了一半的光,而"一半的光"对应的码值就是 0.735。
注意错误的方向永远是偏暗,因为编码曲线是上凸的。这就是很多图形 bug 的典型症状:"说不上哪里不对,就是整体感觉暗了一点、脏了一点"——尤其在半透明边缘、缩略图、模糊、渐变这些地方。
哪些运算受这条规则约束
| 运算 | 受约束? | 为什么 |
|---|---|---|
| alpha 混合、图层叠加 | ✅ 必须 | 两束光叠加 |
| 缩放、双线性、mipmap 生成 | ✅ 必须 | 是邻域的加权平均 |
| 模糊、卷积 | ✅ 必须 | 加权和 |
| 多帧混合(TAA、降噪) | ✅ 必须 | 加权平均 |
| 光照计算 | ✅ 必须 | 能量的乘加 |
| 求最大值、最小值 | ❌ 不必 | 单调变换不改变大小顺序 |
| 判断"这里变化剧不剧烈"(边缘检测的方向性结论) | ⚠️ 通常可以将就 | 不涉及跨像素的能量叠加,只求相对判据 |
最后一行值得展开一句:如果你只想知道"哪里是边、哪里是平坦区",在编码空间算梯度得到的方向性结论通常仍然可用。但只要这个梯度值后面要参与加权混合,就必须回到线性空间——"只是拿来当判据"和"参与最终的能量计算",是两码事。
5. 工程上怎么落地
好消息是:大部分时候你不需要手写转换。
| 机制 | 谁来做转换 |
|---|---|
GL_SRGB8_ALPHA8 之类的 sRGB 纹理格式 | 纹理单元硬件:采样时自动解码成线性,免费 |
sRGB framebuffer(GL_FRAMEBUFFER_SRGB) | ROP 硬件:写入时自动编码,混合发生在线性空间 |
| Skia / RenderEngine 的 color space 参数 | 库内部按 SkColorSpace 自动插入转换 |
ui::ColorSpace::toLinear() / fromLinear() | 你自己在 CPU 上转 |
要小心的是那些"绕过了机制"的路径:自己写的 compute shader 直接对 R8G8B8A8_UNORM 做 imageLoad/imageStore、自己实现的重采样、把 buffer 当裸字节处理的地方——硬件不知道你在干什么,不会替你转。
一个务实的建议:看到任何对像素做加权平均的代码,先问一句"这里的数是线性的吗"。答不上来就去查数据的来源格式。这个习惯能挡掉绝大部分色彩类的问题。
回到源码
问题 1:response 为什么分段?
纯幂函数在 0 附近斜率趋近 0,会把一堆暗部码值压成几乎一样的值(浪费码值),同时在编码方向放大噪声。接一段斜率有限的直线避开了这两个问题。分段点 是标准规定的。注意编码方向的分段点是 ,两者必须严格对应。
问题 2:OETF 和 EOTF 哪个是哪个?
看第一个字母就是输入:OETF 输入光(Opto),是编码方向,对应 fromLinear();EOTF 输入电信号(Electro),是解码方向,对应 toLinear()。用这套命名而不是"gamma 编解码",是因为它描述的是"在管线的哪一端",对 PQ、HLG 这些形状完全不同的曲线同样适用。
问题 3:为什么混合前必须转线性?
因为编码值不正比于光量。直接在编码值上做加权平均,结果没有物理意义,而且系统性偏暗(编码曲线上凸)。一半黑一半白的区域,正确结果是码值 0.735,直接平均得到 0.500——差得非常远。
以后再看到一段"对像素做加权平均"的代码,你应该条件反射地问:这些数是线性的吗? 如果它来自 R8G8B8A8_UNORM 又没经过 toLinear(),那这段代码大概率在系统性地压暗画面——而且这种错误不会崩溃、不会报警,只会让人觉得"画面有点脏"。
自测
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) 生成 mipmap (d) alpha 混合
(a)、(c)、(d) 必须,(b) 不必。
(a)(c)(d) 都是加权平均或能量叠加——只有线性空间的数才正比于实际光量。
(b) 求最大值只关心大小顺序,而传递函数是单调递增的,不改变谁大谁小。所以在哪个空间求都能找到同一个像素(当然,得到的数值含义不同)。
5. 一个 compute shader 直接对 R8G8B8A8_UNORM 图像做 imageLoad,然后算 3x3 邻域平均写回去。这段代码有问题吗?
有。 R8G8B8A8_UNORM 存的是未解码的编码值,imageLoad 只是把字节除以 255,不会做 EOTF。在这些数上直接求平均,就是在编码空间做加权平均——结果系统性偏暗。
正确做法:imageLoad 之后先手动 toLinear,算完再 fromLinear 写回;或者改用 _SRGB 格式让硬件自动转(但注意 storage image 对 sRGB 格式的支持有限制)。
这正是"绕过了机制"的典型路径——用采样器读 sRGB 纹理时硬件会自动解码,但 imageLoad 这条路不会。
小结
| 概念 | 一句话 | 在源码里 |
|---|---|---|
| 码值 ≠ 亮度 | 0.5 只对应约 21.4% 的光 | response(0.5) ≈ 0.214 |
| 为什么要编码 | 人眼对暗部更敏感,把 256 个码值不均匀分配 | 本质是针对人眼的压缩 |
| sRGB ≠ gamma 2.2 | 形状近似但不相等 | 加一段直线 |
| 为什么分段 | 纯幂函数在 0 附近浪费码值、放大噪声 | x >= p.d ? ... : p.c * x |
| 两个分段点 | 解码用 ,编码用 | 必须严格对应 |
| OETF | 光→电,编码,在相机/渲染器输出端 | fromLinear() / rcpResponse() |
| EOTF | 电→光,解码,在屏幕端 | toLinear() / response() |
| 线性空间铁律 | 能量会叠加的运算必须在线性空间做 | 混合、缩放、模糊、多帧累积、光照 |
| 错误的方向 | 永远偏暗(编码曲线上凸) | 症状是"说不上哪不对,就是有点脏" |
| 硬件会帮忙 | sRGB 纹理格式、sRGB framebuffer 自动转 | 但 imageLoad 这类裸访问不会 |
延伸阅读
- 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
- AOSP 源码:
frameworks/native/libs/ui/ColorSpace.cpp、frameworks/native/libs/ui/include_types/ui/ColorSpace.h