线性空间和伽马空间
目录+
写 shader 时给一个像素输出 0.5,屏幕上显示出来的却不是 50% 的物理亮度;两张贴图直接相加混合,结果比预期暗一截。这些问题的根源是同一件事:渲染管线里同时存在两套数值——线性空间和伽马空间,搞不清数据当前在哪个空间,计算就会出错。
同一个亮度,两种记法
线性空间和伽马空间不是两个"地方",而是同一个亮度的两种记数方法。一盏灯发出 21.8% 的物理亮度(光子数是最大值的 21.8%),要把它存成一个 0~1 的数字,有两种记法:
- 线性记法:直接存 。数字和光子数成正比,乘 2 就是两倍的光——这个数就"在线性空间"。
- 伽马记法:先算 ,存 ——这个数就"在伽马空间"。
灯没有变,亮度没有变,变的只是写下来的数字。就像 1.8 米和 5.9 英尺是同一个身高,0.218(线性)和 0.5(伽马)是同一个亮度。判断一个值在哪个空间,只需要问一句:这个数字被 抬亮过吗?
把线性值编码进伽马空间的这步幂运算,叫伽马编码/矫正;反方向的 转回线性空间,叫解码或移除伽马矫正。
为什么要发明第二种记法:挡位不够,得挪给暗部

8 bit 每通道只有 256 个亮度挡位,总量固定。人眼对暗部敏感、对亮部迟钝,平均分挡会让暗部精度不够(渐变出台阶纹,即色带 banding)、亮部精度浪费,所以存储前用 把挡位重新分配——多分给暗部、少分给亮部,伽马空间就是这次重分配后的记法。
这只是重排码值,一个 bit 没省。挡位管够时就没必要:16/32 bit 浮点(HDR 渲染目标)直接存线性值。
CRT 的历史巧合

伽马编码能成为通用标准还有一个历史原因:CRT 显示器的电子枪物理特性天然就是一条约 次方的响应曲线——输入电压 0.5,屏幕发出的光只有约 的亮度。这条硬件曲线恰好和伽马编码的 互为逆运算:编码时抬亮、显示时压暗,两者抵消,最终屏幕亮度还原为线性。这个巧合让"存储用伽马编码、显示器负责解码"的约定一直沿用到今天,现代 LCD/OLED 虽然物理特性不同,也会在电路上刻意模拟这条 2.2 曲线来保持兼容。回头看:省档位是本质,配显示器是巧合。
sRGB:把伽马编码标准化
上面说的 只是一个近似。1996 年 HP 和微软制定了 sRGB 标准,把"一张图像的数值到底代表什么颜色"完整定义下来,包括三基色色度坐标、D65 白点,以及最关键的传输函数(transfer function),也就是精确的伽马编码曲线。
sRGB 的传输函数不是单纯的幂函数,而是分段的。线性值 编码为 sRGB:
反过来,sRGB 解码回线性:
靠近 0 的地方接一小段直线,是因为幂函数 在 0 处斜率无穷大,纯幂函数会把最暗部的噪声放大到不可控。整条分段曲线的整体形状近似 ,所以工程上经常直接用近似互转:
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.5 的信号只发出约 0.22 的光),伽马矫正就是提前做一次反向运算去抵消它,。这个弯纯粹是为了迁就 8 bit 精度和显示器硬件才绕的,绕完之后屏幕发出的物理光和渲染算出的线性值完全一致,到这里工程链路的任务就结束了。
③人眼那一步不是链路的一部分,而是对所有光都生效的"出厂设置":看真实世界里 18% 反射率的灰卡,人眼同样按 式的曲线感知,觉得它是中间灰。整条链的目标从来不是矫正人眼,而是让屏幕发出的光和真实场景一样是线性的——之后人眼对屏幕和对现实一视同仁,画面看起来才真实。
两条曲线的形状可以这样记:指数小于 1 的 在 上是上拱的,抬亮暗部,编码和人眼是这个方向;指数大于 1 的 是下凹的,压暗中间调,解码和显示器是这个方向。两条曲线关于对角线 镜像对称。
为什么光照计算必须在线性空间
渲染方程和所有着色计算都建立在一个前提上:光是线性叠加的。两盏灯照在同一点,辐射亮度就是相加;光强减半就是乘 0.5。这些运算只对和光子数成正比的数字成立,也就是只在线性空间成立。
拿伽马空间的值直接算会错在哪?两盏"存储值 0.5"的灯叠加,直接算得 ;但物理上是 ,编码回去是 ,不是 1。根源是幂函数不满足加法分配:。凡是含加法的环节都会出错:
- 光照叠加:多盏灯叠加后过曝得比物理上应有的快,中间调发闷;
- 混合(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)**是三段式:
- 输入端解码:采样 sRGB 贴图时先转回线性空间;
- 中间全程线性:光照、混合、后处理都在线性空间计算,中间渲染目标用足够精度的格式(如
R11G11B10_FLOAT、RGBA16F),不需要伽马编码来省精度; - 输出端编码:最终写入交换链前再做一次线性 → sRGB 编码,交给显示器去抵消。
转换在哪里发生:硬件 sRGB 支持
上面的解码/编码如果在 shader 里手写 pow,既慢又容易漏。现代 GPU 把这两步做进了固定功能硬件,入口就是 sRGB 纹理格式(如 R8G8B8A8_UNORM_SRGB):
- 采样时:纹理声明为 sRGB 格式后,硬件在滤波之前先把每个纹素解码到线性空间,再做双线性/三线性插值。顺序很关键,先插值后解码等于又在伽马空间做了平均;
- 写入时:渲染目标声明为 sRGB 格式后,shader 输出的线性值在写入前被硬件自动编码成 sRGB,混合(blending)也是先把目标像素解码、在线性空间混合、再编码写回。
这一切对 shader 代码完全透明:shader 里读到的、写出的都是线性值,转换零成本地发生在采样器和 ROP 里。
勾选 sRGB 是声明,不是转换
贴图导入设置里的 sRGB 勾选框容易被误解成"把这张图转到伽马空间"。不对——图里的数据本来就已经在伽马空间(PNG/JPG 出厂就是 sRGB 编码),勾选什么都不改,它只是一个声明,回答前面那个判断口诀"这个数字被抬亮过吗":勾上,等于告诉 GPU 这张图的数字被抬亮过,采样时先做 解码回线性再给 shader;不勾,等于说这些数字本来就是线性含义,原样传递就行。
所以哪些贴图该勾,取决于数据实际在哪个空间。sRGB 编码只对"颜色"有意义——法线、粗糙度、金属度、高度图、遮罩这类数据贴图根本不是"亮度",数值本身就是线性含义:
| 贴图 | 数据实际在哪个空间 | 勾选 | 采样时硬件做什么 |
|---|---|---|---|
| 颜色贴图(albedo、UI 图) | 伽马空间(美术软件导出即 sRGB) | 勾 | 自动解码 ,shader 拿到线性值 |
| 法线、粗糙度、金属度、遮罩 | 线性含义 | 不勾 | 原样传递 |
勾错的两种症状都能从"声明与实际不符"推出来:颜色贴图忘了勾,伽马空间的值没解码就进了光照,画面泛白偏亮;数据贴图错勾了,被多解码一次,数值被 压小,法线方向、粗糙度全错。
在 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 分段传输函数,近似 / |
存储和显示用伽马空间,迁就人眼和 8 bit;计算用线性空间,迁就物理;两头的转换交给硬件的 sRGB 格式自动完成。
伽马编码和 DX 的反转 Z 其实是同一个思想的两个方向:档位有限,就把疏密排布对准最需要精度的区域。8 bit 整数的格子是均匀的,伽马编码把数据弄弯去迁就格子;float 的格子天生密集在 0 附近,反转 Z 把映射掉头(近平面 → 1,远平面 → 0),让 float 的密集格子落到被 投影亏待的远处。法线的八面体编码、HDR 的打包格式,都是"精度分配"这同一个问题的变体。