GPU Gems 3 · Chapter 24. The Importance of Being Linear

从显示设备的非线性响应出发,解释为什么纹理、光照、过滤和帧缓冲必须在正确的线性边界上处理。

学习目标

  • 能解释显示编码为什么不能直接参与光照、过滤和颜色合成
  • 能修改 Linear Color Lab 的输入、运算、输出与 gamma,定位错误的颜色边界
  • 能回答:为什么颜色通道要转换而 alpha、法线和高度等数据通道不能一并做 gamma 变换

先问:为什么同一个数字不一定代表同样多的光

想象两把刻度看起来一样的尺子:一把每一格都代表相同的长度,另一把把更多刻度挤在起点附近。你拿第二把尺子做加法,结果看似整齐,实际距离却会越来越不对。

数字图像也有类似问题:保存和显示时为了适应人的眼睛,常把中间明暗重新编码。若把这种“显示刻度”直接拿去做光照、混合或缩小,画面会变暗、颜色会漂移,远处的纹理还会忽明忽暗。

本章给出一条稳定边界:颜色进入计算前先还原,所有光照和过滤在同一把“真实光强刻度”上完成,最后一步才重新编码给屏幕。

1. 先把三种工作分开

在一条完整的图像链路中,保存图片、进行光照和送到显示器是三件不同的工作。图片文件常使用方便观看的编码,着色器却假设输入是可做加法的光强;显示器又会把写出的数值按自己的响应曲线变成光。

因此不要把“看起来像一半亮”的数值当作“发出一半的光”。正确的管线是:输入颜色先解码到线性空间,shader 在线性空间中做加法、乘法与过滤,最终结果只在显示边界编码一次。

one image, three different jobscapture / storageconvenient display valuesnot yet safe for light mathshader mathadd light, filter, blendcontributions should add correctlydisplayencode for the monitorlast step before the eyedecode once, compute in light space, encode once

2. gamma 是显示刻度的弯曲程度

数学上可以把显示近似成 light = code^gamma。所以编码值 0.5 并不等于光强 0.5;黑和白仍在两端不动,最容易出错的是中间调。反向过程就是在显示前使用 code = light^(1/gamma),让显示器的响应把它抵消回来。

code value is not emitted lightcode 0.50emitted light ≈ 0.22stored/display code →light ↑typical monitor response, gamma ≈ 2.2black and white stay fixed; midtones move

3. 颜色纹理要在读取处回到线性

JPEG、照片和许多美术颜色贴图通常是为普通显示器预先编码的。它们直接显示看起来正常,但若 shader 把它们当作线性反射率,就会在乘灯光时把颜色比例改变。明暗变化越大,偏色越明显。

把纹理声明为 sRGB 格式,读取时让硬件完成逆变换;这比每次采样后手写 pow(color, gamma) 更不容易漏掉过滤顺序。若资源本来就是线性的 HDR、法线或高度数据,就不要再走颜色解码。

convert color channels, preserve data channelssRGB textureRGB: decodecolor / reflectanceA: unchangedcoverage / opacitylinear shaderRGBAfilter / light / blendsRGB framebufferRGB: encodeafter blendingA: linearno gamma

4. 过滤和 mipmap 也必须在线性光上做

缩小纹理看起来只是取平均,但平均的是光还是显示刻度,结果完全不同。黑白各占一半时,线性光的平均确实是 0.5;如果先把两个非线性编码值直接平均,得到的 0.5 会被显示成远低于一半的光,边缘因此过暗。

这也是为什么“我已经离线生成过 mipmap”仍可能出错:GPU 在运行时会在相邻 mip 之间过滤和混合,如果这些 mip 或采样值仍处于非线性空间,LOD 切换时就会出现亮度脉动。先解码、在线性空间生成和过滤、最后才编码,才能让距离变化稳定。

filter light, not the monitor encoding2 × 2 source texelshalf bright, half darkaverage in stored code0.50code 0.50 emits less than half lightedge looks too dark and can shimmerdecode → average in linear space → encode the mip level

5. 最后一个输出边界才做显示编码

如果 shader 已经在线性空间算完结果,最后写入 sRGB framebuffer 可以让硬件负责输出编码;支持正确混合的实现会在混合前把已有 RGB 还原到线性空间,混合完成后再编码。这样不会把“显示刻度”当成光强相加。

没有 sRGB 帧缓冲时,可以在最终输出处使用 pow(linearColor, 1.0 / gamma)。但不要在中间后处理 pass 就编码:后面的模糊、叠加、色调映射仍然需要线性输入,否则每个 pass 都会重复制造误差。

the safe boundary around linear mathdecodesRGB → Ltexture fetchcompute+×light, filter, blendencodeL → sRGBframebuffer writealpha, normals, and heights stay linear; only color data crosses the curve

第 1 / 3 步 · 从颜色纹理读取时,把显示编码还原成线性光值

逐步观察显示编码被限制在输入和输出边界,中间的光照与过滤保持线性。

// WebGL2-style pseudocode: color texture enters linear math.
vec3 diffuseLinear = pow(texture(uDiffuse, uv).rgb, vec3(uGamma));
vec3 shadedLinear = diffuseLinear * lightContribution;
outColor = vec4(shadedLinear, texture(uDiffuse, uv).a);

这段伪代码表达的是边界,而不是鼓励每个采样都手写指数:实际项目优先用 sRGB 纹理格式,让采样、过滤和缓存路径保持一致。

6. alpha 不代表光,不能一起变换

alpha 描述的是“这个样本覆盖了多少”,不是“它发出了多少光”。同理,法线、粗糙度、金属度、位移和高度等通道也表达方向、比例或几何数据。它们通常已经是线性的,重复做 gamma 会破坏插值与合成。

把颜色与数据通道分开管理,是比“整张 RGBA 一起 pow”更可靠的工程边界:RGB 在颜色格式边界转换,alpha 继续参与线性覆盖率计算,最终显示编码只作用于颜色。

convert color channels, preserve data channelssRGB textureRGB: decodecolor / reflectanceA: unchangedcoverage / opacitylinear shaderRGBAfilter / light / blendsRGB framebufferRGB: encodeafter blendingA: linearno gamma

动手走一遍:从输入到显示

分步1 / 3

先识别输入边界

把照片或颜色纹理视为“为显示准备的刻度”,在采样处解码;法线、alpha 和高度则保持原数值。

one image, three different jobscapture / storageconvenient display valuesnot yet safe for light mathshader mathadd light, filter, blendcontributions should add correctlydisplayencode for the monitorlast step before the eyedecode once, compute in light space, encode once
GPU Gems 3 · Chapter 24

Linear Color Lab

可交互

固定一个中间灰值,切换输入、运算和输出边界,观察相同的代码值如何变成不同的线性光强。

decode color input light encode output01source code 0.50shader result 0.392linear input 0.218axis: relative light intensitydisplay code 0.653 · emitted light 0.392middle-value drift 0% · 颜色在两端完成解码/编码,shader 中间保持线性。
线性输入0.218
shader 结果0.392
显示代码0.653
中间值偏差0%

小结

  • 颜色输入与显示输出可以是非线性编码,shader 的光照与合成应在线性空间完成。
  • gamma 描述编码值到显示光强的弯曲关系,中间灰最容易暴露错误。
  • sRGB texture 在读取时解码,sRGB framebuffer 在最终写入时编码。
  • mipmap、过滤和混合都要平均与相加线性光,而不是直接平均显示值。
  • alpha、法线、高度等数据通道保持线性,不能跟 RGB 一起 gamma 变换。

练习

练习

问题 1|判断输入是否需要解码。

你拿到一张 JPEG 颜色贴图、一张 HDR 光照图和一张切线空间法线图。分别说明它们在 shader 读取时是否应走颜色解码,并解释理由。

问题 2|修改实验台。

在 Linear Color Lab 中依次把 framebuffer 切到 raw、把输入切到 linear,再恢复默认设置。记录中间灰的显示代码和中间值偏差分别如何变化,并说明哪个开关对应真实的显示边界。

问题 3|修复一个错误的后处理管线。

某管线在 bloom pass 前把颜色 pow(color, 1.0 / 2.2),然后在最终输出又做一次相同操作。请指出错误,并改写成“中间缓冲线性、最后一次编码”的顺序。

名词解释

本章出现的专业名词,用大白话再讲一遍。

linear color space

以真实光强为刻度的颜色空间,光照贡献可以在这里直接相加和相乘。

gamma correction

用显示设备响应曲线的逆变换,把编码值与实际显示光强对齐。

sRGB texture

读取时自动把显示编码的 RGB 还原到线性空间的颜色纹理格式。

mipmap

为不同距离准备的逐级缩小纹理;生成与过滤时应使用线性颜色。

sRGB framebuffer

在最终写入时编码 RGB,并在正确混合路径中保持线性光计算的帧缓冲。

alpha channel

表示覆盖率或透明度的通道,通常已是线性比例,不能做 gamma 变换。

资料与写作方式声明

本章以GPU Gems 系列权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…