要判断一个渲染工程有没有把Gamma校正做好,最快的方法是拿一张50%灰的半透明片去叠背景。在正确的线性空间里,两层0.5叠加后应该接近纯白;如果叠加结果明显发暗,那双儿十有八九又是在Gamma空间里“裸奔”。这篇是百人计划图形系列2.6课的内容沉淀,我把Gamma相关的原理、管线接入、项目验证方法和踩坑记录一次讲清楚。适合刚开始接触渲染、对颜色管理还一头雾水,或者已经在项目里被“画面偏灰、偏暗、偏亮”反复折腾的开发者。
1.1 从一条幂曲线说起:显示器天生不诚实
很多人第一次接触Gamma时,听到的解释是“显示器有伽马,要校正”。但真正做图形开发之后会发现,Gamma不是一个需要“修正的Bug”,而是整个显示系统从硬件到内容都默认遵守的一套规则。
传统CRT显示器有一个物理特性:输入的电压和荧光粉发出的亮度不是线性关系。你给它输入0.5的电压,它发出来的亮度并不是0.5,而大约是0.18。这个关系近似一条幂函数:亮度 ≈ 电压^2.4,那个指数就是常说的显示Gamma。也就是说,显示器天生会把数值从“低亮度”往“更低亮度”压,暗部尤其明显。
现代显示器虽然从CRT换成了LCD、OLED,面板驱动方式完全不同,但行业没有推翻这套规则,而是继续兼容它。原因很实际:无论行业怎么更迭,全世界已经有海量图片、视频、游戏内容都按“显示端会做一次幂运算”的前提来编码。如果哪天所有屏幕突然变成完全线性输出,所有旧内容的画面都会发灰、发白没法看。
所以我们要做的事不是消灭Gamma,而是在渲染流程中正确管理它:知道内容什么时候处于“编码空间”,什么时候处于“线性空间”,在需要转换的地方做转换。图形学里说“Gamma校正”,指的是这套完整的颜色管理流程,而不是某个单独的pow函数。
1.2 编码Gamma与解码Gamma:一张图要走完的两段路
一张数字图片从制作到显示,至少要经过两次Gamma相关的处理。
第一阶段叫编码Gamma,发生在内容存储时。美术在软件里调出来的颜色,如果直接按线性亮度存进8位通道,暗部会因为量化步长过大出现明显色阶断层。于是行业约定:存储之前先对线性颜色做一次幂变换,指数约为1/2.2,把更多数值精度分配给暗部。经过这个变换存储的SDR颜色,就是常见的sRGB颜色。
第二阶段叫解码Gamma,发生在显示前。显示器的物理特性会自动把编码后的数值再压回近似线性亮度。编码Gamma和解码Gamma叠加在一起,最终亮度大致能回到内容制作者想要的样子。
这一来一回两个过程才是完整的“Gamma传输”。在图形管线里,我们通常把存储的sRGB颜色视为非线性颜色,把经过解码、用于光照计算的颜色视为线性颜色。两者之间的转换关系可以用标准sRGB函数精确描述,也常用2.2次幂近似。
注意:sRGB标准其实不是一条简单的2.2次幂曲线,而是一段分段函数。低于某个阈值的暗部区域使用线性映射,高于阈值部分使用2.4次幂。许多引擎和美术工具为了性能和简洁,会直接用pow(x, 2.2)或pow(x, 1.0/2.2)近似,这在多数SDR场景下差异很小,但做严格颜色流程时需要知道精度差异存在。
1.3 人眼感知跟2.2有什么关系
显示系统为什么挑了2.2这个数值,背后还有人眼感知的因素。人眼对亮度变化不是线性感知的,在暗部区域,我们能分辨很小的亮度差异;在亮部区域,需要更大亮度差才会觉得“变了”。也就是说,人眼对暗部更敏感,对亮部更迟钝。
如果把有限的8位存储空间平均分配给每一种线性亮度,暗部只有几个数值可用,亮部却分配了大量几乎看不出差异的数值,这是巨大的浪费。Gamma编码相当于把存储数值按人眼的敏感程度来分配,暗部多分一些数值,亮部少分一些,同样的位深下,视觉上看起来的灰阶过渡更平滑。
这也是为什么即使技术早就变了很多,sRGB/Gamma这套体系仍然没有退出历史舞台的根本原因。它不只是一个兼容性包袱,它本身就符合人眼的视觉特性。在渲染管线里保留Gamma编码的帧缓冲,从感官上也能减少色带问题,尤其在暗部场景里非常明显。
2. 渲染管线里的三道Gamma关卡
Gamma校正不是只在最终输出屏幕前做一次,而是在纹理采样、光照计算、输出编码三个关键位置分别处理。哪个环节漏掉,颜色链就会断掉。
2.1 关卡一:纹理采样,sRGB标记到底在干什么
美术做的贴图存储的是sRGB编码值。如果直接把这个值送进光照计算,画面会显得整体偏暗,并且中间调过渡很奇怪。正确做法是采样后先把sRGB解码成线性值,再做后续计算。
硬件层面已经支持这个操作:给纹理设置“sRGB标记”后,GPU在采样该纹理时会对颜色通道自动做一次解码,从sRGB转到线性。这意味着在Shader里不写任何转换代码,只要资源标记正确,拿到的就已经是线性颜色。
但这里有个容易翻车的点:sRGB标记是逐纹理设置的,不是所有贴图都适合做解码。一般来说,由美术绘制、用于描述表面颜色信息的贴图(比如基础色、自发光、UI图集)要标sRGB;而存数值、存方向、存物理参数的贴图(比如法线、粗糙度、金属度、高度图、遮罩图)绝不能标sRGB。
后几类贴图里的数值本来就是开发者在线性空间里定义好的,编码方式很可能还是美术导出时顺手处理的,再强行解码一次反而会把数据弄乱。例如法线贴图里的RGB编码的是切线空间方向分量,和亮度Gamma毫无关系,标了sRGB会直接导致法线方向旋转、光照形状扭曲。
2.2 关卡二:光照与混合,为什么必须在线性空间算
光照计算从物理上就要求在线性空间里完成。一束光的能量是线性的,两盏灯叠加就是两倍能量,0.5能量的光和0.5能量的光合起来就是1.0。如果两个0.5先在sRGB编码空间里混合,得到的是0.5的sRGB值,解码成线性后只有约0.216,整整少了一半多。用生活化的例子说,就是在“拧过劲的刻度尺”上做算术,每一步都会累积偏差。
PBR尤其敏感。BRDF模型里的反射、能量守恒、微表面分布都是基于线性光推导的,在Gamma空间算高光,即使肉眼“看起来还行”,也一定出现了非物理的亮带或暗角。
半透明混合和Bloom这类效果也吃同样的亏。混合操作本质是在做线性插值,两个半透明片叠加,物理亮度应该往“更亮”方向走。如果混合发生在sRGB空间,GPU插值的对象是非线性编码值,结果会比正确线性混合更暗,这也是UI灰边和半透明粒子颜色脏的重要来源。
所以引擎里的颜色空间设置,本质上是在问一个问题:默认的渲染目标和Shader计算,是在线性空间里进行,还是直接在sRGB空间里将就。现代主流引擎都提供了切换选项,但切到线性工作流之后,配套的资源管理和Shader写法都得跟上来。
2.3 关卡三:输出后端,Tonemapping和编码的顺序
光照计算完成后,场景颜色还是线性HDR范围。要把这个HDR结果变成显示器能显示的LDR sRGB信号,需要经过两步:先做Tonemapping(把动态范围压到0到1附近),再做Gamma编码(把线性值写成sRGB编码值)。
这两个步骤的顺序不能反。如果先做sRGB编码,把线性值压缩到非线性空间,再去做曝光压缩和色调映射,高光细节会先被乘幂放大,Tonemapping的亮部曲线也会被二次扭曲,最后画面的高光过渡会非常脏。
坐在这一步的工程实现通常有两个方案。第一种是渲染时不写任何编码,把线性颜色输出到线性格式的渲染目标,最后在一个后处理pass里手动调用线性转sRGB函数。第二种是直接把当前渲染目标标记为sRGB格式,让硬件在写入RT时自动完成编码。引擎封装好之后,大部分业务Shader不需要感知细节,但自己写后处理链时,必须清楚当前RT到底是线性还是sRGB编码。
3. 实操:把项目从Gamma工作流切到Linear工作流
只是理解概念不够,项目里真正落地才是关键。下面这套流程我按自己在项目里的实际操作整理,可以当成一份可直接照做的清单。
3.1 第一步:颜色空间设置与贴图标记
先确认工程默认颜色空间。主流引擎在工程设置里都能找到颜色空间选项,通常有Gamma和非线性/线性两种。选择线性后,画面变化会非常明显——所有材质会比之前亮一截,因为之前贴图颜色被“双重压暗”了,现在采样后先解码成线性,光照计算在正确空间进行,物理亮度回到正确水平。
这一步做完,马上要做的是把工程里的贴图逐个检查一遍,给应该标sRGB的贴图补上标记。最规范的做法是美术在导出资源时就做好命名约定,例如颜色贴图带_color后缀,数据贴图带_metal/_rough后缀。然后在导入流程里按后缀自动设置sRGB属性。如果项目是半路切换,就要人工分批筛选,千万别用“全选,全部标记”这种偷懒办法,否则法线和粗糙度贴图会一起遭殃。
实操心得:我每次改完一批贴图标记后,会先跑一遍场景里的法线材质,看高光形状有没有异常。法线贴图标错sRGB时,曲面的高光方向会发生明显偏移,比看颜色更直观。
3.2 第二步:Shader里的手动解码与编码
如果项目里大量材质使用标准表面Shader,引擎切换颜色空间后大部分工作会自动完成。但自定义Shader不会有这个福利,材质节点里的颜色和向量参数都需要自己控制。
在Shader里实现sRGB解码的标准写法大致如下:
float srgbToLinearChannel(float color) { if (color <= 0.04045) { return color / 12.92; } return pow((color + 0.055) / 1.055, 2.4); } vec3 srgbToLinear(vec3 color) { return vec3( srgbToLinearChannel(color.r), srgbToLinearChannel(color.g), srgbToLinearChannel(color.b) ); }对应的编码函数(线性转sRGB)可以写成:
float linearToSrgbChannel(float color) { if (color <= 0.0031308) { return color * 12.92; } return 1.055 * pow(color, 1.0 / 2.4) - 0.055; }如果对性能要求高、且对精度要求不那么苛刻,也可以用近似版本:
vec3 srgbToLinearApprox(vec3 color) { return pow(color, vec3(2.2)); } vec3 linearToSrgbApprox(vec3 color) { return pow(color, vec3(1.0 / 2.2)); }我个人的习惯是:颜色贴图尽量靠硬件sRGB标记处理,不写额外代码;只有实在无法给纹理打标记、或者遇到采样结果需要二次处理时,才手动调用解码函数。手动调用时要特别小心,如果贴图已经标了sRGB,再手动解码一次,就等于做了两次颜色转换,画面又会往诡异方向跑。
3.3 第三步:用灰渐变和半透明叠加做验证
切完颜色空间、改完Shader,怎么确认整个链路真的对了?
最快速的验证方法是做半透明叠加测试。在场景里摆一个白色平面作为背景,再放一个半透明白色平面叠在前面,透明度设为0.5。如果渲染管线正确,两个0.5线性alpha的混合结果应该接近纯白。如果工程还在Gamma空间裸奔,叠加区域会明显暗一块,这个差异肉眼就能分辨,不需要借助示波器。
另一个有效测试是用灰渐变条。在一个线性渐变纹理上贴一个材质,观察画面从黑到白的过渡位置。Gamma空间下,渐变的中点会明显偏向暗部或者亮部,具体偏哪边取决于误差源。正确的线性工作流会让灰渐变的中间值更接近物理意义上的0.5亮度。
我建议把这些测试场景保存成一个独立的验证场景,颜色空间改动后先在场景里肉眼检查一遍,再配合抓帧工具看颜色值是否满足预期,最后再放行到业务场景。
3.4 第四步:美术和UI工作流的对齐
Gamma校正不只是程序问题,也是工作流问题。很多美术同事在Photoshop里看到的颜色,和引擎里的最终颜色并不一致,因为美术工具通常在sRGB空间显示,而引擎做的是线性空间计算。如果美术没有这个概念,调出来的颜色放进引擎里会感觉纯度不对、对比度不对。
合理的做法是把合作协议定清楚:颜色贴图在sRGB空间绘制,导出时保持sRGB,引擎负责解码;光照和材质参数在线性空间调整;UI图在Linear工作流下要重新校验半透明效果,尤其是一些带发光、柔光、叠加混合模式的UI特效,数值可能需要重新调一遍。
这一步听着琐碎,但它决定上线时美术返工多不多。程序在工程里改个颜色空间只花一分钟,后续所有美术资产的预期都要花至少一周来对齐。
4. 踩坑实录:Gamma校正最容易翻车的五个地方
光讲正确流程不够,下面这些坑我基本都踩过,每条都能对应一个实际开发的“鬼故事”。
4.1 贴图标了sRGB,Shader里又写了一次pow
这是最常见的重复转换问题。很多Shader在网上抄下来的时候带有类似pow(color, 2.2)的代码,但工程里的贴图已经正确标记为sRGB。于是采样时硬件解码了一次,Shader里又人为编码了一次,一来一回等于没做Gamma处理。
典型现象:场景整体偏暗,高光形状奇怪,尤其是金属材质亮斑周围有一圈不自然过渡。排查时先不要急着改光照参数,先把所有自定义Shader里的颜色转换代码列出来,对照贴图标记一项一项过。
4.2 半透明混合:UI灰边和粒子变亮
线性工作流下,半透明混合表现会和Gamma空间截然不同。在Gamma空间写了多年的UI特效,切到线性空间后最常见的反应是:原本通透的玻璃突然变“实”了,粒子叠加部分明显比之前亮。
这是因为线性空间的混合在物理上更正确,叠加区域亮度确实会更高,但项目里所有美术参数都是照着Gamma空间效果调的。这个问题不是Bug,而是资产规范要更新。处理办法是先统一所有混合模式的预期,再调透明材质的颜色和透明度,千万不要为了找回旧效果,在Shader里把混合结果再pow回去,那样会让半透明层级之间的混合变得更乱。
4.3 后处理特效里RT格式弄混
做Bloom、径向模糊这类后处理时,中间RT是线性还是sRGB编码,直接影响后续效果。一个常见错误是:Bloom的亮部提取在sRGB RT上做,导致高光区域被Gamma曲线放大,光晕范围和强度都偏离预期。
正确习惯是把后处理链路里的中间RT全部保持在线性格式,只有最终输出到屏幕的那一张才做sRGB编码。如果RT标记错误,抓帧工具里看到的中间结果会暴露问题:Bloom输入图里高光区域已经发灰发白,而不是正常的HDR亮部。
4.4 移动端纹理格式与sRGB兼容性
移动端GPU对sRGB纹理的支持比桌面端更参差。一些老型号设备上,ASTC和ETC2等压缩格式虽然都有对应的sRGB变体,但实际手机GPU实现里的采样结果可能并不完全一致,甚至部分平台在特定驱动下会忽略sRGB标记。
在移动项目里,我吃过最大的亏是在真机测试时发现部分安卓机型画面偏亮,最后追踪下来,是压缩后的法线贴图在某个驱动版本下被错误当成sRGB采样。这种问题在编辑器里永远复现不了,只能通过真机颜色对比列表逐机型筛查。预防方式是在导入管线里对压缩格式和sRGB标记做一次最终核对,上线前把关键机型都跑一遍基础验证场景。
4.5 美术工具与引擎预览不一致
美术在外部工具里把颜色调得很好看,放到引擎里立刻觉得脏,这种情况在刚切换线性工作流时特别频繁。常见的原因是预览环境不同:美术软件里看到的颜色是经过编码Gamma显示的,引擎里线性光照结果再编码输出,两个环节叠加之后效果自然不同。
与其在每个材质上反复试,不如在项目规范里直接写明:颜色贴图修色靠美术软件,光照和材质响应靠引擎验证,两者永远不要指望一模一样。给美术提供一份“线性工作流下常见材质最佳区间”的参考表,比一遍遍远程截图反馈效率高得多。
5. 一张速查表:贴图类型到底要不要标sRGB
每次新项目开工,我都会把这张表发给团队,谁的贴图拿不准就过来对着查。
| 贴图类型 | 是否标记sRGB | 原因 |
|---|---|---|
| 基础色/漫反射贴图 | 是 | 美术绘制结果按sRGB编码存储 |
| 自发光/发光贴图 | 是 | 通常由美术在sRGB空间绘制 |
| UI图集/背景图 | 是 | 需要保持美术原始观感 |
| 法线贴图 | 否 | 编码的是方向数据,与亮度无关 |
| 粗糙度贴图 | 否 | 数值是物理属性,不是颜色 |
| 金属度贴图 | 否 | 数值是物理属性,不是颜色 |
| 高度图/位移图 | 否 | 需要保持数值的线性意义 |
| 遮罩/AO贴图 | 否 | 参与乘法计算,需要线性值 |
| HDR环境贴图 | 否 | 通常存储线性HDR数据 |
这张表之外还有两条通用准则。第一条:凡是名字里带color、diffuse、basecolor、emissive、ui的,先按“需要sRGB”考虑;凡是名字里带normal、rough、metal、ao、height、mask的,直接按“不标sRGB”处理。第二条:同一张贴图如果在多个地方被使用,以它的主要用途为准,不要因为某个小用途而改变标记。
需要注意,sRGB标记在纹理导入和运行时压缩两套流程里都可能会被重置。资源上传平台、包体裁剪工具、驱动优化都有可能重新生成纹理,却丢掉sRGB标志。我习惯在每次打包后抽检最终包里的纹理格式,确认sRGB标志确实带上了,而不是只在编辑器里正确。
对自研引擎或定制管线来说,还有一件容易被忽略的事:纹理标记正确只代表采样端做了解码,Shader里读取的“颜色参数”如果是从美术面板输入,默认仍然是美术感知到的sRGB值。为了让参数在Shader里直接参与光照计算,要么在CPU端提前把参数转换成线性,要么在Shader里对材质参数做一次解码。很多渲染效果在Gamma工作流下被吐槽“怎么调都不对”,往往就是参数空间没有统一。
颜色管理这个问题,认真地讲三个小时也讲不完,但实际项目里需要固化的核心规则其实就那几条。我自己的习惯是每次新建渲染工程,第一件事就是先把颜色空间、纹理标记规则、Shader参数空间约定这三件事定死,因为画面复杂之后再回头找颜色问题,所花的时间永远是初始化时的好几倍。希望这篇能帮你少走几次弯路。