Deep Read

线性空间和伽马空间

  写 shader 时给一个像素输出 0.5,屏幕上显示出来的却不是 50% 的物理亮度;两张贴图直接相加混合,结果比预期暗一截。这些问题的根源是同一件事:渲染管线里同时存在两套数值——线性空间和伽马空间,搞不清数据当前在哪个空间,计算就会出错。

同一个亮度,两种记法

  线性空间和伽马空间不是两个"地方",而是同一个亮度的两种记数方法。一盏灯发出 21.8% 的物理亮度(光子数是最大值的 21.8%),要把它存成一个 0~1 的数字,有两种记法:

  • 线性记法:直接存 0.2180.218。数字和光子数成正比,乘 2 就是两倍的光——这个数就"在线性空间"。
  • 伽马记法:先算 0.2181/2.2=0.50.218^{1/2.2} = 0.5,存 0.50.5——这个数就"在伽马空间"。

  灯没有变,亮度没有变,变的只是写下来的数字。就像 1.8 米和 5.9 英尺是同一个身高,0.218(线性)和 0.5(伽马)是同一个亮度。判断一个值在哪个空间,只需要问一句:这个数字被 x1/2.2x^{1/2.2} 抬亮过吗?

把线性值编码进伽马空间的这步幂运算,叫伽马编码/矫正;反方向的 x2.2x^{2.2} 转回线性空间,叫解码或移除伽马矫正。

为什么要发明第二种记法:挡位不够,得挪给暗部

image.png

  8 bit 每通道只有 256 个亮度挡位,总量固定。人眼对暗部敏感、对亮部迟钝,平均分挡会让暗部精度不够(渐变出台阶纹,即色带 banding)、亮部精度浪费,所以存储前用 x1/2.2x^{1/2.2} 把挡位重新分配——多分给暗部、少分给亮部,伽马空间就是这次重分配后的记法。

  这只是重排码值,一个 bit 没省。挡位管够时就没必要:16/32 bit 浮点(HDR 渲染目标)直接存线性值。

CRT 的历史巧合

  伽马编码能成为通用标准还有一个历史原因:CRT 显示器的电子枪物理特性天然就是一条约 2.22.2 次方的响应曲线——输入电压 0.5,屏幕发出的光只有约 0.52.20.220.5^{2.2} \approx 0.22 的亮度。这条硬件曲线恰好和伽马编码的 1/2.21/2.2 互为逆运算:编码时抬亮、显示时压暗,两者抵消,最终屏幕亮度还原为线性。这个巧合让"存储用伽马编码、显示器负责解码"的约定一直沿用到今天,现代 LCD/OLED 虽然物理特性不同,也会在电路上刻意模拟这条 2.2 曲线来保持兼容。回头看:省档位是本质,配显示器是巧合。

sRGB:把伽马编码标准化

  上面说的 1/2.21/2.2 只是一个近似。1996 年 HP 和微软制定了 sRGB 标准,把"一张图像的数值到底代表什么颜色"完整定义下来,包括三基色色度坐标、D65 白点,以及最关键的传输函数(transfer function),也就是精确的伽马编码曲线。

  sRGB 的传输函数不是单纯的幂函数,而是分段的。线性值 Clinear[0,1]C_{linear} \in [0,1] 编码为 sRGB:

Csrgb={12.92Clinear,Clinear0.00313081.055Clinear1/2.40.055,Clinear>0.0031308C_{srgb} = \begin{cases} 12.92 \, C_{linear}, & C_{linear} \le 0.0031308 \\ 1.055 \, C_{linear}^{1/2.4} - 0.055, & C_{linear} > 0.0031308 \end{cases}

反过来,sRGB 解码回线性:

Clinear={Csrgb12.92,Csrgb0.04045(Csrgb+0.0551.055)2.4,Csrgb>0.04045C_{linear} = \begin{cases} \dfrac{C_{srgb}}{12.92}, & C_{srgb} \le 0.04045 \\ \left(\dfrac{C_{srgb} + 0.055}{1.055}\right)^{2.4}, & C_{srgb} > 0.04045 \end{cases}

 

  靠近 0 的地方接一小段直线,是因为幂函数 x1/2.4x^{1/2.4} 在 0 处斜率无穷大,纯幂函数会把最暗部的噪声放大到不可控。整条分段曲线的整体形状近似 x1/2.2x^{1/2.2},所以工程上经常直接用近似互转:

float3 LinearToSrgbApprox(float3 c) { return pow(c, 1.0 / 2.2); }
float3 SrgbToLinearApprox(float3 c) { return pow(c, 2.2); }

  日常接触的 PNG、JPG,以及相机直出的照片、屏幕截图,默认都是 sRGB 编码的。也就是说,美术给你的颜色贴图,数值躺在伽马空间里——这是后面一切转换问题的出发点。

从渲染结果到人眼:完整链路

  伽马矫正到底在"矫正"什么?跟踪一个值为 0.218 的像素,从渲染结果走到人眼:

线性图像 0.218 ①伽马矫正 x1/2.2 0.5 ②显示器 x2.2 屏幕发光 0.218 ③人眼 x1/2.2 感知:中间灰\text{线性图像 } 0.218 \xrightarrow{\ \text{①伽马矫正 } x^{1/2.2}\ } 0.5 \xrightarrow{\ \text{②显示器 } x^{2.2}\ } \text{屏幕发光 } 0.218 \xrightarrow{\ \text{③人眼 } \approx x^{1/2.2}\ } \text{感知:中间灰}

  ①和②互为反函数:显示器天生把画面压暗(给它 0.5 的信号只发出约 0.22 的光),伽马矫正就是提前做一次反向运算去抵消它,(x1/2.2)2.2=x\left(x^{1/2.2}\right)^{2.2} = x。这个弯纯粹是为了迁就 8 bit 精度和显示器硬件才绕的,绕完之后屏幕发出的物理光和渲染算出的线性值完全一致,到这里工程链路的任务就结束了。

  ③人眼那一步不是链路的一部分,而是对所有光都生效的"出厂设置":看真实世界里 18% 反射率的灰卡,人眼同样按 x1/2.2x^{1/2.2} 式的曲线感知,觉得它是中间灰。整条链的目标从来不是矫正人眼,而是让屏幕发出的光和真实场景一样是线性的——之后人眼对屏幕和对现实一视同仁,画面看起来才真实。

  两条曲线的形状可以这样记:指数小于 1 的 x1/2.2x^{1/2.2}[0,1][0,1] 上是上拱的,抬亮暗部,编码和人眼是这个方向;指数大于 1 的 x2.2x^{2.2} 是下凹的,压暗中间调,解码和显示器是这个方向。两条曲线关于对角线 y=xy = x 镜像对称。

为什么光照计算必须在线性空间

  渲染方程和所有着色计算都建立在一个前提上:光是线性叠加的。两盏灯照在同一点,辐射亮度就是相加;光强减半就是乘 0.5。这些运算只对和光子数成正比的数字成立,也就是只在线性空间成立。

  拿伽马空间的值直接算会错在哪?两盏"存储值 0.5"的灯叠加,直接算得 0.5+0.5=10.5 + 0.5 = 1;但物理上是 0.218+0.218=0.4360.218 + 0.218 = 0.436,编码回去是 0.4361/2.20.6860.436^{1/2.2} \approx 0.686,不是 1。根源是幂函数不满足加法分配:(a+b)1/2.2a1/2.2+b1/2.2(a+b)^{1/2.2} \ne a^{1/2.2} + b^{1/2.2}。凡是含加法的环节都会出错:

  • 光照叠加:多盏灯叠加后过曝得比物理上应有的快,中间调发闷;
  • 混合(blending):半透明混合、粒子叠加在伽马空间做,交界处出现难看的深色边;
  • 滤波:mipmap 生成、双线性插值双线性插值算法双线性插值(Bilinear Interpolation)主要用来解决纹理采样中的图像失真。纹理被放大(Magnification)或缩小(Minification)贴到模型表面时,如果不做合适的插值,就会出现明显的像素化(马赛克)或锯齿(走样的成因见 [[锯齿和抗锯齿]])。 问题来源:UV 坐标与纹素的错位 渲染管线里,我们为模型每个顶点指定 UV 坐标(纹理坐标),它对应纹理图上的一个点。三角形被光栅化成屏幕像素(Fragment)时,每个像素的 UV 是由三角形顶点 UV 插值得到的,结果通常是浮点数,例如 uv(0.35, 0.72)。 而纹理图是由离散的纹素(Texel)组成的,纹素坐标是整数。于是就产生了矛盾:我们必须决定如何用一个浮点 UV 去整数网格的纹理上"取色"。双线性插值正是对这个问题的一种回答。 对照:最近邻采样 最直接的做法是把浮点 UV 四舍五入到最近的整数坐标,直接取那个纹素的颜色,即最近邻采样(Nearest-neighbor Sampling)。 优点**:极快,只需一次取整和一次内存读取。 缺点**:纹理放大时,多个屏幕像素会映射到同一、MSAA resolve(见 锯齿和抗锯齿锯齿和抗锯齿锯齿产生的原因 锯齿(Aliasing)的根本原因是采样频率不足以还原原始信号的全部信息,在信号处理领域这叫"走样"。放到计算机图形学里,可以拆成三层来看。 连续的场景 vs. 离散的像素。 我们想渲染的 3D 场景在数学上是连续的:一个三角形的边缘是一条无限细的直线,两种颜色的交界是瞬时突变,这种突变包含了无限高的频率。而屏幕是一张离散的像素网格,光栅化(Rasterization,它在管线中的位置见 [[渲染流水线完全解析]])就是在这张离散网格上对连续场景做采样——每个像素中心可以看作一个采样点,它判断自己是否被三角形覆盖,然后取一个单一颜色。 违反奈奎斯特-香农采样定理。 要无损还原一个信号,采样频率必须至少是信号最高频率的两倍。而理想几何边缘包含无限高频,屏幕分辨率(采样频率)却是有限的,所以无论分辨率多高都无法满足该定理。采样率不足时,捕捉不到的高频信息会"折叠"回低频域,产生原信号里并不存在的错误低频——视觉上就表现为阶梯状的像素块,也就是锯齿。 说得更直白些:光栅化时一个像素只能有一个颜色,当三角形边缘穿过某个像素,这个像素必须做"非黑即白"的决定——要么三角形)本质都是加权平均,在伽马空间平均会让缩小后的贴图和抗锯齿边缘整体偏暗。

  所以正确的**线性工作流(linear workflow)**是三段式:

  1. 输入端解码:采样 sRGB 贴图时先转回线性空间;
  2. 中间全程线性:光照、混合、后处理都在线性空间计算,中间渲染目标用足够精度的格式(如 R11G11B10_FLOATRGBA16F),不需要伽马编码来省精度;
  3. 输出端编码:最终写入交换链前再做一次线性 → sRGB 编码,交给显示器去抵消。

转换在哪里发生:硬件 sRGB 支持

  上面的解码/编码如果在 shader 里手写 pow,既慢又容易漏。现代 GPU 把这两步做进了固定功能硬件,入口就是 sRGB 纹理格式(如 R8G8B8A8_UNORM_SRGB):

  • 采样时:纹理声明为 sRGB 格式后,硬件在滤波之前先把每个纹素解码到线性空间,再做双线性/三线性插值。顺序很关键,先插值后解码等于又在伽马空间做了平均;
  • 写入时:渲染目标声明为 sRGB 格式后,shader 输出的线性值在写入前被硬件自动编码成 sRGB,混合(blending)也是先把目标像素解码、在线性空间混合、再编码写回。

  这一切对 shader 代码完全透明:shader 里读到的、写出的都是线性值,转换零成本地发生在采样器和 ROP 里。

勾选 sRGB 是声明,不是转换

  贴图导入设置里的 sRGB 勾选框容易被误解成"把这张图转到伽马空间"。不对——图里的数据本来就已经在伽马空间(PNG/JPG 出厂就是 sRGB 编码),勾选什么都不改,它只是一个声明,回答前面那个判断口诀"这个数字被抬亮过吗":勾上,等于告诉 GPU 这张图的数字被抬亮过,采样时先做 x2.2x^{2.2} 解码回线性再给 shader;不勾,等于说这些数字本来就是线性含义,原样传递就行。

  所以哪些贴图该勾,取决于数据实际在哪个空间。sRGB 编码只对"颜色"有意义——法线、粗糙度、金属度、高度图、遮罩这类数据贴图根本不是"亮度",数值本身就是线性含义:

贴图数据实际在哪个空间勾选采样时硬件做什么
颜色贴图(albedo、UI 图)伽马空间(美术软件导出即 sRGB)自动解码 x2.2x^{2.2},shader 拿到线性值
法线、粗糙度、金属度、遮罩线性含义不勾原样传递

  勾错的两种症状都能从"声明与实际不符"推出来:颜色贴图忘了勾,伽马空间的值没解码就进了光照,画面泛白偏亮;数据贴图错勾了,被多解码一次,数值被 x2.2x^{2.2} 压小,法线方向、粗糙度全错。

  在 Unity 里对应的设置:

  • Project Settings → Player → Color Space 选 Linear,启用线性工作流;
  • 颜色贴图的导入设置保持 sRGB (Color Texture) 勾选,数据贴图取消勾选(Normal Map 类型会自动按数据处理);
  • shader 里 Color 属性面板给的值,Unity 会按颜色空间设置自动转换后再传入。

  Gamma 工作流下 UI 混合的坑(UI 设计稿是按伽马空间混合出效果的,线性空间下半透明观感会变),见 [[UI Gamma工作流]]。

总结

线性空间伽马空间(sRGB)
数值含义与物理光强成正比按人眼感知均匀分布
存在的理由光照/混合/滤波的数学前提8 bit 下把档位挪给暗部
谁待在这里shader 计算、HDR 渲染目标PNG/JPG 颜色贴图、最终显示输出
转换方式sRGB 分段传输函数,近似 x2.2x^{2.2} / x1/2.2x^{1/2.2}

  存储和显示用伽马空间,迁就人眼和 8 bit;计算用线性空间,迁就物理;两头的转换交给硬件的 sRGB 格式自动完成。

  伽马编码和 DX 的反转 Z 其实是同一个思想的两个方向:档位有限,就把疏密排布对准最需要精度的区域。8 bit 整数的格子是均匀的,伽马编码把数据弄弯去迁就格子;float 的格子天生密集在 0 附近,反转 Z 把映射掉头(近平面 → 1,远平面 → 0),让 float 的密集格子落到被 1/z1/z 投影亏待的远处。法线的八面体编码、HDR 的打包格式,都是"精度分配"这同一个问题的变体。