提起图形学里最“反直觉”的一个知识点,我的第一反应一定是Gamma校正。它在百人计划课程里排到图形2.6,算是一个很小但至关重要的环节,小到可能只是一个pow函数,大到整个渲染管线的光影正确性都跟它挂钩。如果只用一个词来形容它的地位,那就是“地基上的地基”。
这套课程我完整过了一遍,单把这节拿出来写,是因为Gamma校正所牵连的细节实在太多了:从CRT显示器的物理特性、sRGB图像的编码历史,再到线性空间渲染、硬件纹理格式、shader输出编码,每一步都有可能掉坑。很多人觉得“不就是一道pow嘛”,但当我真正做起PBR材质、调整美术资源、调试画面偏色时才发现,理解不透Gamma校正,后面做光照和材质永远隔着一层纱。
这篇文章就围绕霜狼May百人计划中图形2.6的主题,把Gamma校正的技术背景、核心原理、shader代码、排查方法全部串起来,适合正在学图形学基础、准备做材质渲染、或者看课只看懂了一半的同学。内容上尽量保持和课程一致的节奏,但会补一些我在实际项目中反复用到的经验。
1. 先搞清楚:Gamma校正到底在解决什么问题
1.1 CRT时代的意外遗产
Gamma的故事要从显示器说起。最早的CRT阴极射线管显示器,控制亮度的核心机制是电子束打在荧光粉上,而荧光粉的发光强度并不是和输入电压线性对应的。实测下来,亮度大致和电压的2.2次方成正比。也就是说,你给显示器一个0.5的电压信号,屏幕实际显示出来的亮度不是最大亮度的一半,而是0.5的2.2次方,大约0.217。这在视觉上就是明显偏暗的。
所以最早的显示设备厂商就在硬件层面做了一次逆变换:在信号进入显像管之前,先把电压信号做一次1/2.2次方的预扭曲,让最终显示出来的亮度接近线性。这套机制被固化成标准后,就产生了“Gamma 2.2”这个经典值。后来液晶显示器虽然物理原理完全不同,但为了让既有内容的观感一致,也刻意模拟了这条2.2左右的响应曲线。
这里有个特别反直觉的点:人眼对亮度的感知本身也不线性。人对暗部变化的敏感程度远高于亮部,如果把物理亮度从0到1均匀切100份,你会发现暗部的每一档看起来“跨度”特别大,亮部却几乎分不出区别。Gamma曲线的存在,顺便还起到把量化误差从暗部挪到亮部的作用,让8bit图像的观感看起来过渡均匀。这也是为什么图像文件存储时就默认带上了Gamma,它本质上是一种“感知编码”。
1.2 数字图像的编码Gamma
现在几乎所有照片、贴图资源,在保存成JPEG、PNG、TGA这类常见格式时,存储的都不是物理上的线性亮度值,而是已经做了一次“sRGB编码”或近似sRGB编码的非线性值。说得直白一点:你从网上下载的任何一张普通图片,它的像素值里已经包含了一道Gamma。
那这道Gamma是怎么来的?相机传感器采集到的本来应该是线性光,但相机内部的ISP在今天所有手机、相机的标准流程里,都会自动把线性数据映射到sRGB空间再存成图像文件。这样做有两个原因:一是适配显示器的Gamma特性,让照片在屏幕上看起来“正常”;二是充分利用8bit的存储精度,因为人眼对暗部更敏感,把更多bit分配给暗部,在视觉上能获得更平滑的渐变。
所以问题就来了:渲染引擎里做光照计算时,我们需要的是物理正确的线性亮度值。光照的能量强度是线性的,两个光源叠加在一起,能量就该是直接相加。而贴图里存储的是感知编码过的sRGB值,如果直接拿这个值去计算光照,等同于在错误的坐标系里做运算。结果就是暗部越算越脏,高光区域表现奇怪,两种颜色插值出来的中间色在物理上完全错误。
1.3 为什么渲染必须回到线性空间
道理说得再具体一点,用一个最简单的混合例子:假设屏幕左边是一块亮度1.0的白色,右边是一块亮度0.0的黑色,现在要屏幕正中间出现一个过渡色,物理正确的结果应该是0.5的灰色。这个0.5是按照能量比例混合出来的,是线性亮度。
但如果我们手里拿到的贴图颜色是sRGB编码后的0.5,直接拿它们在Gamma空间里混合,计算得到的结果再输出到显示器,最终的视觉亮度大概只有0.217左右,中间值会明显偏暗,过渡带看着像一条深灰色的峡谷。更麻烦的是光照叠加,在Gamma空间里做加法、乘法,全都不是物理线性的运算。
这就像做菜时电子秤本身没校准:所有配料的重量都被压缩了一半,你按正确菜谱加300克面粉,结果只有150克效果,菜谱里的一切比例都乱了。光照模型从漫反射、镜面反射到PBR的能量守恒,全部建立在“参与计算的数值代表物理辐射能量”这个前提上。所以现代图形管线里,约定俗成的做法是:贴图进入管线先解码回线性空间,所有光照计算在线性空间完成,最后输出前再编码回sRGB空间交给显示器。
理解到这一步,再看下面的管线流程就顺理成章了。
2. 渲染管线里Gamma校正的两个正式岗位:采样解码与输出编码
2.1 一张贴图从文件到屏幕的完整旅程
任何一张贴图资源,从硬盘加载进显存、被GPU采样参与光照,到最终显示在屏幕上,它需要经过下面这些环节:
- 图片文件解析得到的是sRGB编码后的像素值。
- GPU把纹理上传到显存,如果纹理创建时被标记为sRGB格式,硬件会在每次采样时自动调用“sRGB转线性”的换算函数。
- Shader里拿到的就是已经解码后的线性颜色值,参与光照、颜色混合、后处理计算。
- 计算完成后得到线性空间的渲染结果,写入渲染目标时,要么通过硬件端的“线性转sRGB”自动转换,要么在Shader手动调用编码函数。
- 显示器硬件再根据自身的Gamma响应,把sRGB信号还原成物理光输出。
很多初学者容易忽略的一点是:显示器的“物理Gamma”和图像文件里的“sRGB编码”在视觉上互相抵消,所以最终用户看到的画面是正常的。但渲染引擎内部必须自己管理好这些转换,如果某个环节缺了转换、或者转换做了两次,画面就会出现整体偏灰、暗部死黑、高光发白等问题。
2.2 采样解码:把sRGB纹理换回线性
先说采样这头。目前主流API都支持原生sRGB纹理格式。在DirectX下,对应格式是DXGI_FORMAT_R8G8B8A8_UNORM_SRGB;在OpenGL下,是GL_SRGB8_ALPHA8;Vulkan里是VK_FORMAT_R8G8B8A8_SRGB。这类格式的本质是:存储的bit还是原来的bit,但GPU在纹理单元做采样后、把数据送到Shader之前,会自动执行一次sRGB解码。
这样的好处首先是省电省指令,你不需要在每个Shader里手动写pow函数,硬件帮你算完了。其次是统一性更好,美术资源无论从哪里来,只要格式标记对了,引擎内部自动保持线性。在Unity等引擎中,你只需要在纹理导入设置里勾选“sRGB (Color Texture)”选项,引擎就会创建对应的硬件sRGB格式。
这里必须多说一嘴:只有颜色贴图才应该勾sRGB。法线贴图、粗糙度贴图、金属度贴图、AO贴图、高度图、数据Mask,这些贴图存储的不是“给人看的颜色”,而是“给计算用的数据”,它们的值必须原样进入Shader,保持线性,绝对不能勾sRGB。否则你等于在数据贴图上做了一次非线性扭曲,法线方向会算错,BRDF里的粗糙度参数会整体漂移,最终渲染出来就是各种莫名其妙的脏和暗。
2.3 输出编码:线性计算结果回填到sRGB
采样解码只解决了一半问题。Shader做完整套光照计算后,得到的是一个线性空间的RGB值。如果直接写回一个常规RGBA渲染目标,这个值就会作为sRGB值被动显示,等于丢失了编码过程,画面会整体偏暗且饱和度异常,暗部糊成一团。
正确的做法是:在写入渲染目标之前,把线性值编码回sRGB。这件事也有两种实现方式。
第一种是硬件自动转换。把你输出到的渲染目标格式设置成sRGB格式,例如Unity的RenderTexture勾选sRGB选项,或者图形API里创建RT时用SRGB格式。这样GPU在像素着色器输出阶段会自动帮你做线性到sRGB的矩阵和曲线变换,性能和便携性都非常好。
第二种是Shader手动转换。常见于后处理特效的中间步骤,或者某些不能使用硬件sRGB输出的特殊场景。代码很简单,本质就是对线性值做一次近似1/2.2次幂的操作。但如果管线里既开了硬件sRGB格式转换、Shader里又手动做了编码,画面就会被压两次暗,这是不少新手容易踩的双重重影坑。
2.4 线性空间光照与Gamma空间渲染管线的分野
说句题外话,传统上很多旧引擎为了性能,选择把光照计算也放在Gamma空间里。它们认为反正最终结果都要输出到Gamma空间,只要美术在制作时把贴图“预先调亮”一些,光照结果在视觉上勉强也能看。这就是所谓“Legacy Gamma管线”,移动端早期大量使用。
但这样的做法终究是打补丁,物理不正确的光照在PBR时代根本藏不住。金属度、粗糙度这类参数需要在线性空间里严格遵守能量守恒,粗糙度低的高光会在Gamma空间里显示成一个突兀的硬点,过渡完全不自然。所以从游戏主机到移动端,主流方案最终都统一到了“线性空间光照+线性空间混合+最终sRGB编码”这套模型上,付出的代价是多几次硬件转换指令,换来的是物理正确和跨平台观感一致。
3. 百人计划2.6的实战:手写一遍Gamma校正
3.1 搭建一个正确的工程工作流
我建议每一位学习这部分内容的同学,都亲手新建一个测试工程,把整套环节从零走一遍,重点确认三件事:色彩空间设置、纹理导入标记、后处理链结构。
如果你的引擎支持色彩空间选择,优先把渲染管线设置成线性空间。在Unity里就是Project Settings > Player > Color Space > Linear,如果你用的是URP或HDRP,还要检查文档里对色彩空间的要求。设置完成后,创建一个简单的PBR材质球,丢入基础颜色贴图、法线贴图、金属度贴图和粗糙度贴图。这时候打开Frame Debugger,看一眼贴图采样前后的数值对比,你就能直观感受到“解码”这件事真的发生了。
移动端同学可能会犹豫:线性空间在移动设备上性能开销大吗?实测下来,纯编码转换指令的开销其实非常小,一次pow级别的ALU操作在现代移动GPU上几乎可以忽略。真正有成本的是线性空间下部分混合操作需要更高精度的中间缓冲,但只要你不在老古董设备上做实验,收益远大于成本。
3.2 Shader里亲手实现解码与编码
哪怕引擎帮你搞定了大部分硬件转换,我依然强烈建议你至少在Shader里手写一次转换函数,把数学原理彻底刻在脑子里。以下是精确到工程级别的代码,已经过多个平台验证,可以直接抄:
// sRGB转线性:从贴图采样得到的sRGB值进入计算前 float3 sRGBToLinear(float3 col) { float3 linear = col * (col * (col * 0.305306011 + 0.682171111) + 0.012522878); return linear; // 这属于一种近似拟合,精度已经足够 } // sRGB转线性:精确分段版,用于对精度要求极高的场合 float3 sRGBToLinearExact(float3 col) { float3 lo = col / 12.92; float3 hi = pow((col + 0.055) / 1.055, 2.4); return lerp(hi, lo, col <= 0.04045); } // 线性转sRGB:精确分段版 float3 LinearToSRGBExact(float3 col) { float3 lo = col * 12.92; float3 hi = 1.055 * pow(max(col, 0.0), 1.0 / 2.4) - 0.055; return lerp(hi, lo, col <= 0.0031308); } // 线性转sRGB:近似版,很多引擎在内置后处理里用的就是它 float3 LinearToSRGB(float3 col) { return max(1.055 * pow(col, 0.41666) - 0.055, 0.0); }在材质Shader里,如果你用的是非sRGB硬件采样,你可以在采样后立刻调用sRGBToLinear,把所有后续计算都搬进线性空间;在最终的片元输出语句前,再调用LinearToSRGB。如果你确认引擎已经通过RT的sRGB标记帮你做了输出编码,那就千万不要再手动调用LinearToSRGB,否则就会“双倍压暗”。
3.3 为什么标准值不是2.0,也不是2.4
很多人写Gamma校正时只会背2.2。但2.2只是sRGB标准在视觉上的一个整体近似,并不是sRGB曲线的精确指数。真正的sRGB标准是一条分段曲线:暗部低于0.04045的区间用线性段12.92x,亮部以上用1.055乘以x的1/2.4次方再减0.055。
这个分段设计的好处是:暗部区域的线性段可以避免纯指数曲线在接近0处斜率为无穷大带来的数字噪声,同时让整个曲线在暗部到亮部的过渡保持导数连续。实际渲染中,绝大多数情况你用2.2或者2.4近似,视觉差异并不会太大。但在高精度美术工作中,尤其处理暗部细节和抗锯齿边缘时,精确分段版会避免一些微妙偏差。
另一个值得记住的是,16bit浮点RT和8bit定点RT对Gamma非常敏感。许多移动端设备默认的中间缓冲是低精度格式,在线性空间下暗部容易出现条纹感,这时候要么把RT精度调高,要么对中间结果做轻微抖动,但核心原则仍然是:宁可牺牲一点点精度,也别回到Gamma空间去算光照。
3.4 用渐变球和灰阶卡验证自己有没有配错
实操验证手段非常关键,否则你以为自己配好了,结果画面早就偏色了几十个色阶你都感知不到。我常用的方法是这样:
在当前场景里放一个纯白平行光,材质基础色设为纯白,粗糙度调到0.6左右,然后打开一个可以显示数据贴图的调试视图。在UI面板上实时显示“屏幕中心像素的线性RGB值”。正常情况下,如果灯光的亮度是1.0,白球受光面的线性值应该约等于1.0,阴影面接近0。如果你发现中间调数值在0.5左右,但屏幕观感却偏暗,那么十有八九是输出阶段少了一次编码;如果受光面整体超过1.0且高光爆成一片,那往往是解码和编码叠加了两次。
更直白一点:把屏幕切到一个纯色渐变条,从0到1均匀渐变,正确配置下,这个渐变从暗到亮应该是视觉上均匀的。如果你看到暗部被压得太狠,中段明显下沉,就说明当前把sRGB当线性算了。
4. 我踩过的坑:Gamma相关高频问题排查实录
4.1 画面整体发灰发暗,怎么查都查不出来
这是我见过最多的症状:所有材质颜色感觉都正常,但整个画面像蒙了一层灰蒙蒙的纱,对比度不够,暗部发闷。排查看似复杂,其实只需检查两个地方:所有贴图是否按“颜色或数据”正确设置sRGB标记,以及最终输出到显示器的RT格式是否是sRGB格式。
如果你在线性空间工作流里不小心把颜色贴图标记成了“非sRGB”,引擎就会把已经编码过的值直接当线性值算,画面会整体偏灰白。反过来,你把法线贴图标记成了sRGB,法线波形的幅度会被非线性压缩,光照方向直接算歪,所有物体的明暗关系都变得模糊,看起来也像蒙了灰。
遇到这种情况,先别急着调灯和材质参数。打开Frame Debugger截帧,选中一个明确是纯色的材质采样节点,对比采样前后的数值。如果数值几乎没变化,说明纹理被当作线性数据采样了,看导入设置;如果数值被明显拉伸到0.4左右,再检查是不是所有图都统一做了转换,法线贴图是否也被误伤。
4.2 法线贴图和粗糙度贴图千万别勾sRGB
这个坑我已经见过太多次,所以单独拎出来说。法线贴图存储的是一组方向向量,它是把物体表面的切线方向映射成RGB值,用来扰动光照计算中的法线。它里面没有任何“视觉颜色”含义,相反,它必须被线性读取,任何一个缩放或偏移都会导致切线空间方向变化,表现在画面上就是高光位置跳变、光照轮廓扭曲,比普通贴图丢失色彩更严重。
粗糙度、金属度这类PBR参数同理。金属度为0或1,粗糙度为0到1,它们在BRDF公式中都是作为线性权重出现的,任何Gamma变换都相当于在权重上又挂了一层指数,让高光半径整体失真。实践建议:在你团队的资源规范里明确写出“只有Base Color和Emissive可以勾sRGB;法线、粗糙度、金属度、AO、Mask全部关闭”。这个体验项目里如果两种贴图都勾了,渲染那一刻你就能看到高光忽大忽小,整场效果无法挽救。
4.3 纹理过滤和Mipmap边缘的微妙发暗
学过纹理基础的同学都知道,GPU采样贴图时会做双线性过滤,在不同Mip等级之间做三线性过渡。这些过滤操作也是对值进行加权平均。如果权重平均放在线性空间做,它是物理正确的;如果放在sRGB空间做,结果就会暗一点。
所以硬件sRGB纹理真正的实现逻辑是:硬件先把sRGB值解码成线性值,然后做过滤平均,得到的结果是线性的。这也是为什么硬件sRGB格式采样比手动在Shader里“先采样再pow”质量更高——因为你在Shader里做的是“先算平均再解码”,顺序反了。一个最直观的现象:黑白交界处的中灰色,在sRGB空间平均后再解码,和在线性空间平均后直接得到的结果,数值可以相差接近0.1,在边缘部分就会看到一条不自然的暗线。
这个细节平时不太显眼,但一旦你的场景里有很多细线条纹理、天空盒边缘、抗锯齿程度较高的高光点,它就会变成那种“说不出来哪里不对,但总觉得脏”的问题源头。
4.4 界面UI与后处理特效用错空间的额外处理
UI贴图通常由美术在sRGB空间下绘制,它们的默认意图是让人直接看到最终颜色。因此UI的采样和渲染有两种做法:一是让UI在Gamma空间直接输出(不参与线性空间光照),这样它的颜色观感最接近美术所见;二是在线性空间流程中把UI贴图也解码、最终再编码回去,相当于走了一个完整Gamma循环,颜色预期也能保持。
但很多Shader中,透明UI混合还会涉及到Alpha通道。Alpha值的混合天然应该在线性空间做,这也意味着UI的Alpha部分也应该遵循线性规则。我发现的最典型的问题是:UI整体色彩没问题,但半透明子元素叠在一起时,边缘会出现一圈明亮的泛白,这就很可能是Alpha通道在线性空间被错误地做了Gamma编码转换。解决思路是:UIShader里采样时把RGB从sRGB解码到线性,再做Alpha混合,最后输出时编码回sRGB;如果引擎强制硬件转换,就要确保UI资源和RT格式匹配。
过度后处理也是一个盲区。Bloom(泛光)这类特效通常在线性空间计算,它的效果是用高亮区域的能量扩散到周边。如果在Gamma空间做Bloom,扩散的范围和衰减会变成指数关系,高光会拉出一圈极其难看的硬边。同样的逻辑适用于抗锯齿和模糊算法,这些“平均”操作都必须在线性空间完成。所以在搭建后处理栈时,时刻提醒自己:滤镜是计算,不是展示,计算必须在物理正确的空间里进行。
5. 回顾这节百人计划的学习心得
5.1 课程节奏为什么值得细品
回到霜狼May百人计划图形2.6这节课,我注意到它最优秀的一点,是并没有在一开始就直接甩出公式和代码,而是花了大量篇幅讲历史背景、人眼感知、以及“为什么你的显示器长得和数学公式不一样”。这个设计极其聪明,因为Gamma校正这个知识点最大的门槛不是数学,而是直觉:你看到了一个明明“很暗”的画面,却要去解释“这是线性空间里正确的亮度”,如果没有物理背景铺垫,纯粹背公式迟早会出问题。
课程里还把“解码”和“编码”两个环节刻意分开强调,这点特别重要。很多教程只会讲一句“记得转换”,但实际工程里,每个采样点、每个RT、每个后处理节点都可能需要单独判断。只有把每个环节的输入输出空间都搞清楚,你才能真正判断哪里需要转换、哪里已经转换过。
5.2 从Gamma校正学到的图形学通用思维
最后分享一个我在实际开发里越来越深刻的体会:显卡里所有看不见的自动转换,其实都是为了让你少写错代码。但越是自动化,越要求你理解它在干什么。Gamma校正就是最典型的例子,当你理解了它,你会发现画面偏色、高光脏、暗部糊这些玄学问题,百分之六七十都能用“空间对不对、转换有没有重”来解释。
我个人的习惯是,每接触一个新引擎、新渲染管线,第一件事就是找到它的色彩空间设置,然后做一组最简单的渐变球测试,确保整个链路里的Gamma转换是正确的。再往后做材质、调光照,之前的几乎所有参数都不需要推翻重来。
这套方法帮我避开了很多“调了五十版灯光,最后发现只是色彩空间搞错”的无效工作时间,也让我对引擎的信任度提高了一大截。如果你刚学完图形2.6,不妨也给自己留一个下午,从头到尾亲手搭一次线性渲染流程,跑一遍渐变球测试,你会回来感谢这节课的。