
前两天一个做Unity的老同事跟我吐槽说项目一直跑在gamma space美术把PBR材质调得挺满意结果他偷偷把Player Settings切到linear space试了一下整个场景立刻变了个调子阴影变深了高光变锐了看着确实更“物理”了可UI、贴图、光感全对不上号只能灰溜溜再切回来。这篇文章要聊的就是这种“想用linear效果、但项目被绑在gamma space”的处境下怎么做才能在gamma管线里把linear space的核心效果还原出来。内容主要面向正在维护Unity老项目、又不想大动干戈重做美术资源的同学也适合刚接触色彩空间的Unity开发者当作一份实践笔记来读。我会把可直接用的shader代码、后处理脚本、以及我踩过的各种坑都摊开讲清楚尽量让你看完就能在项目里动手试。1. 项目为什么卡在gamma space先对色彩空间有个清醒认知1.1 gamma到底是什么显示器、sRGB与一条幂曲线要理解这套还原方案势必要先搞清楚“色彩空间”这四个字背后到底发生了什么。先说结论我们眼睛看到的图片文件里存的数值和屏幕真正发出的光线能量并不是一一对应的关系。老式的CRT显示器天生就带一个大约2.2的gamma响应曲线也就是说如果给显示器输入0.5的电压/数值屏幕发出来的实际光亮度不是0.5而是接近0.5的2.2次方也就是0.218左右。为了在显示时能还原出正确的亮度图像文件在存储时就会提前做一次逆运算把0.218这个能量值存成0.5。这套存储标准就是sRGB也是绝大多数图片格式默认的颜色编码方式。放到游戏渲染里就出现了两个概念linear space线性空间参与光照计算的值代表真实的光能强度。两个线性值相加、相乘结果在物理上都是成立的。这也是PBR、实时全局光照这些算法的前提。gamma space伽马空间参与计算的值是经过sRGB编码的数值和物理光能之间隔了一条幂曲线。这句话说人话就是在gamma space里直接做光照乘法算出来的结果是“错的”只是错得很符合老一代显示设备的观看习惯。1.2 unity里gamma和linear渲染的差别到底在哪Unity里切换色彩空间只需要在Player Settings - Other Settings - Color Space里选Gamma或Linear。但这一下切的不只是一个开关而是改变了一整条渲染链路的运算规则环节gamma spacelinear space纹理采样直接拿到sRGB编码值标记为sRGB的贴图会自动解码成线性值光照计算在sRGB编码值上直接乘在线性光能上计算混合/加法在gamma编码域里混合在线性域里混合最终输出直接输出到帧缓冲输出线性值最后由硬件/后处理编码成sRGB实际拿一个场景对比gamma到linear的最明显差异是中间调普遍会发生变化gamma画面容易发“灰”、发“肉”高光和阴影的分界更钝gamma下同一个材质的高光会显得更宽、更散法线贴图的细节在gamma下会被压扁凹凸感变弱颜色混合、贴图相乘这类操作在gamma下偏亮颜色容易“浑”在一起。这些差异的累积效果就是很多美术会评价“linear更通透、更有质感”。可惜的是切换成本往往不是一句话能解决的。1.3 想切linear但不舍得切的项目有什么共性我接触过不少被卡在gamma space的项目它们通常有这几个共同点老项目UI/图集是按gamma制作和调色的。直接切到linear后UI会发灰、发淡美术一看就想把原来的工程推翻重调。第三方插件或内部老shader写死了gamma算法。比如一些自定义的描边shader、溶解shader内部直接采样、直接输出没有考虑色彩空间切过去就会出现脏色、叠色、曝光异常。目标平台里有大量低端安卓机。虽然现在支持linear framebuffer的GPU越来越多但老机型的驱动兼容性还是会让人头疼尤其是一些OpenGL ES 2.0设备。线性空间渲染需要额外的sRGB framebuffer支持对带宽和ALU的消耗也更高。项目临近上线没有底气做全量回归。色彩空间切换影响所有Camera、所有Shader、所有UI、所有后处理回归工作量极大。这些情况凑到一起就催生了“在gamma space里还原linear space效果”的需求。说白了我们不动色彩空间的全局配置只把最影响视觉的几个计算环节单独拎出来做线性化用最小的成本换来接近linear的观感。2. 还原思路在gamma管线上开一条线性车道2.1 核心三段式解码、计算、再编码方案的设计思路可以归纳成一句话想办法让光照计算在线性域发生但让最终画面仍然按照gamma的规则输出。具体落到每一个shader里就是固定的三段式流程解码Decode采样贴图后把从sRGB纹理里读出来的gamma编码值手动转换回线性光能值。这就是文章标题里“还原”的关键动作。线性计算Linear Lighting用解码后的颜色、解码后的光源颜色做NDotL、高光、间接光等光照运算。再编码Encode得到线性域的光照结果后再手动转回gamma/sRGB编码值输出到帧缓冲。逻辑看起来很简单但真正操作起来坑全在“哪些贴图要解码、哪些不能碰”以及“编码时机”上。2.2 哪些贴图要解码哪些绝对不能碰这是这套方案里最容易翻车的地方。不是所有纹理都代表“颜色”有些纹理存的其实是数据。盲目的统一解码反而会毁掉材质。贴图类型是否做gamma解码原因漫反射albedo贴图需要它描述的是曲面对光的反射率是标准的sRGB颜色高光/金属度/粗糙度贴图通常不需要存的是线性数据不是“给人看的颜色”法线贴图绝对不能法线贴图存的是方向向量sRGB解码会直接把它压坏AO/遮罩/高度图不需要同样是线性数据UI图集/渐变图当调色板用需要只要是作为颜色直接参与光照就需要解码光照贴图分情况LDR光照贴图按sRGB解码HDR光照贴图本身就是线性数据不处理天空盒cubemap需要作为环境光参与间接光时应在线性域采样我见过最典型的翻车案例是某个项目的材质贴图里同时混了albedo和一张mask图美术把mask图导入时默认勾选了sRGB。还原方案接入后有人顺手把mask也做了gamma解码结果金属度信息全乱原本应该是哑光的物体突然变成了反光大镜面。原因是mask里的金属度数值是线性数据解码之后0.5变成了0.218等于直接改写了材质属性。所以这里要记住一条铁律解码对象只针对“颜色”不能针对“数据”。如果你的数据贴图导入设置里误勾了sRGB请先回编辑器去修资源设置而不是指望shader里加一个pow能救回来。2.3 高精度sRGB换算函数与快速近似版的取舍具体写shader时会面临两个选择用精确sRGB公式还是用2.2次幂近似。很多教程图省事直接写pow(color, 2.2)表示解码pow(color, 1.0 / 2.2)表示编码。这个做法在大多数场景下够用但严格来说sRGB标准并不是一条纯幂曲线它在暗部有一段线性区间。如果做的是高标准PBR或者后处理精度要求很苛刻建议用下面这组准确版本// 精确sRGB解码gamma - linear half3 AccurateGammaToLinear(half3 c) { half3 lo c / 12.92; half3 hi pow((c 0.055) / 1.055, 2.4); return c 0.04045 ? lo : hi; } // 精确sRGB编码linear - gamma half3 AccurateLinearToGamma(half3 c) { half3 lo c * 12.92; half3 hi 1.055 * pow(c, 1.0 / 2.4) - 0.055; return c 0.0031308 ? lo : hi; }Unity自身的UnityCG.cginc里其实也提供了GammaToLinearSpace和LinearToGammaSpace这两个函数旧版本实现是多项式近似性能很好但精度比精确版略差。如果你懒得自己写函数可以直接调Unity内置的只是在极暗部会有一点点误差。而pow(c, 2.2)这种写法优点是能省几条指令缺点是暗部偏暗、亮部偏亮误差在中间调上肉眼还是能察觉的。实际项目里我一般这样取舍移动端、低端机用近似pow甚至可以直接用Unity内置的多项式近似把性能省下来。PC/主机、对画面要求高用精确sRGB版本。同时建议在shader里用#ifdef UNITY_COLORSPACE_GAMMA包住解码/编码逻辑这样同一个shader切到linear space项目里也能正常编译不会重复转换。3. 实操从零手写一个gamma下线性光照的shader3.1 准备工作和失败案例先别急着改一堆材质动手之前建议先把测试环境搭干净。我的做法是复制一份工程出来或者至少用版本管理工具拉一个分支。色彩空间改动影响面巨大必须保证可回退。新建一个空场景只放一个方向光、一个标准球体、一个标准立方体、一个地面。准备一张中灰色的albedo贴图、一张带高反差细节的贴图、一张法线贴图、一张金属度贴图。新建一个普通材质挂上一个从零写的shader。为什么要先搭干净环境因为如果你直接在正式场景里对照很容易被场景里已有的雾效、后处理、灯光参数干扰搞不清到底是shader写错了还是场景本身就不干净。失败的案例也很典型。我见过有人想“偷懒”不写自定义shader只把Standard Shader里的albedo在surf函数里多乘一个GammaToLinearSpace然后发现物体变得巨黑无比。原因很简单surface shader的Standard光照函数在gamma工程里默认是按照gamma域去理解输入的你喂进去一个线性值它还以为这是个gamma值一顿运算后输出当然不对。所以还原方案的核心是自己掌握光照计算的全部环节——要么写自定义的顶点/片元shader要么在surface shader里写自定义光照模型把解码、计算、编码全部包在自己手里。下面我从最简单的版本开始。3.2 第一个可用的逐像素光照shader这里我给一个基于内置渲染管线的自定义shader支持一个主方向光、逐像素漫反射高光、以及常规阴影接收。代码里已经写了详细注释。Shader Custom/GammaSpaceLinearEmulation/Diffuse { Properties { [NoScaleOffset] _MainTex (Albedo (sRGB), 2D) white {} _Gloss (Gloss, Range(0, 1)) 0.5 _SpecColor (Specular, Color) (0.2, 0.2, 0.2, 1) } SubShader { Tags { RenderTypeOpaque QueueGeometry } Pass { Tags { LightModeForwardBase } CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fwdbase #include UnityCG.cginc #include AutoLight.cginc sampler2D _MainTex; half _Gloss; fixed4 _SpecColor; struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 worldNormal : TEXCOORD1; float3 worldPos : TEXCOORD2; SHADOW_COORDS(3) }; // 近似版解码gamma - linear half3 GammaToLinear(half3 c) { return pow(c, 2.2); } // 近似版编码linear - gamma half3 LinearToGamma(half3 c) { return pow(c, 1.0 / 2.2); } v2f vert(appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv v.uv; o.worldNormal UnityObjectToWorldNormal(v.normal); o.worldPos mul(unity_ObjectToWorld, v.vertex).xyz; TRANSFER_SHADOW(o); return o; } fixed4 frag(v2f i) : SV_Target { // 1. 采样albedo fixed4 albedo tex2D(_MainTex, i.uv); // 2. 解码gamma工程下手动把颜色转到线性域 half3 normal normalize(i.worldNormal); half3 lightDir normalize(_WorldSpaceLightPos0.xyz); half3 specColor _SpecColor.rgb; #ifdef UNITY_COLORSPACE_GAMMA albedo.rgb GammaToLinear(albedo.rgb); specColor GammaToLinear(specColor); half3 lightColor GammaToLinear(_LightColor0.rgb); #else half3 lightColor _LightColor0.rgb; #endif // 3. 核心光照计算全部在线性域进行 half ndl saturate(dot(normal, lightDir)); half3 diffuse albedo.rgb * lightColor * ndl; half3 viewDir normalize(_WorldSpaceCameraPos.xyz - i.worldPos); half3 halfDir normalize(lightDir viewDir); half ndh saturate(dot(normal, halfDir)); half spec pow(ndh, _Gloss * 128.0 1.0); half3 specular specColor * spec * lightColor * ndl; half shadow SHADOW_ATTENUATION(i); half3 finalLinear (diffuse specular) * shadow; // 4. 再编码把线性结果转回gamma域输出模拟linear管线的最终sRGB编码 #ifdef UNITY_COLORSPACE_GAMMA finalLinear LinearToGamma(finalLinear); #endif return fixed4(finalLinear, 1); } ENDCG } } FallBack Diffuse }这个shader已经能让你在gamma space工程里看到比较明显的线性光照质感——中间调变干净了高光边缘变利索了。但注意它的代价也非常直观光支持一个主方向光加一个阴影。你可能会问明明是还原linear为什么不做完整PBR因为完整的PBR光照模型GGX、间接光、IBL在自定义shader里手写成本非常高而且多光源的加法混合问题不解决做出来也是错的。我建议的实际路线是先把主光、阴影这两个对视觉影响最大的环节做对然后再逐步扩展。3.3 给shader补上阴影和多光源小心加法混合陷阱阴影部分其实已经很方便了内置管线在ForwardBase里通过SHADOW_COORDS、TRANSFER_SHADOW、SHADOW_ATTENUATION三件套就能拿到主光源阴影。上面的代码已经包含了具体使用时确认主方向光开启了Shadow Type渲染路径是Forward物体在ShadowDistance范围内。多光源则是另一个大坑。按最常规的做法要加一个ForwardAddPass来处理附加的点光源/聚光灯Blend模式用Blend One One。如果每个Pass都是“解码-计算-编码”再相加那问题就来了你往gamma framebuffer里多次叠加gamma值本质上是在gamma域里做加法和真正的线性加法结果并不一致。具体表现是多个灯叠加时颜色会偏亮、偏“粉”高光叠加区容易过曝发白。解决方式有几种只对影响最大的主光做线性化附加光沿用gamma逻辑视觉上通常能接受。把整个场景渲染到一张线性HDR RenderTexture上在这个RT里完成所有光源的加法最后一步做sRGB编码并输出。这就是所谓的“半线性管线”工程量更大但更接近真·linear。换用URP/HDRP这两个渲染管线本身就是线性优先设计多光源累加是自然发生在线性域的。我个人在维护老项目时通常会先用第一种方案快速验证效果确认美术认可了再评估要不要上第二种。毕竟还原方案的目标不是理论完美而是视觉达标。3.4 后处理和屏幕特效怎么接进这套流程shader层面改完以后你会发现后处理还在用gamma值做Bloom阈值、颜色分级这也会让最终画面和linear项目不一样。如果只改材质不动后处理还原效果会被“砍一半”。这里给一个简易的接法写一个挂在相机上的脚本在OnRenderImage里先把整帧画面从gamma转到linear然后在这个线性帧上跑你要的后处理最后再编码成gamma输出。using UnityEngine; [RequireComponent(typeof(Camera))] public class GammaLinearPost : MonoBehaviour { public Material convertMat; // 挂GammaLinearConvert这个shader的材质 void OnRenderImage(RenderTexture src, RenderTexture dest) { if (convertMat null) { Graphics.Blit(src, dest); return; } // 第一步gamma - linear RenderTexture linearRT RenderTexture.GetTemporary(src.width, src.height, 0, src.format); Graphics.Blit(src, linearRT, convertMat, 0); // pass 0 解码 // 在这里插入你的真正后处理所有计算都基于线性数据 // 例如Bloom、Color Grading、ToneMapping // 示例直接拿linearRT做输入再输出到finalRT RenderTexture finalRT RenderTexture.GetTemporary(src.width, src.height, 0, src.format); Graphics.Blit(linearRT, finalRT); // 占位这里替换成实际后处理材质 // 第二步linear - gamma Graphics.Blit(finalRT, dest, convertMat, 1); // pass 1 编码 RenderTexture.ReleaseTemporary(linearRT); RenderTexture.ReleaseTemporary(finalRT); } }对应的转换ShaderShader Custom/GammaLinearConvert { SubShader { Pass // 0: gamma - linear { CGPROGRAM #pragma vertex vert_img #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; half4 frag(v2f_img i) : SV_Target { half4 c tex2D(_MainTex, i.uv); c.rgb pow(c.rgb, 2.2); return c; } ENDCG } Pass // 1: linear - gamma { CGPROGRAM #pragma vertex vert_img #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; half4 frag(v2f_img i) : SV_Target { half4 c tex2D(_MainTex, i.uv); c.rgb pow(c.rgb, 1.0 / 2.2); return c; } ENDCG } } }这套接法相当于给整条后处理链套了一层“线性壳”。要注意的是如果你项目里已经有Bloom等后处理切换空间后阈值参数要重新调因为关键在于——同一个阈值数字在gamma域和线性域的意义完全不同。比如Bloom在gamma下阈值1就已经很亮到线性域后同样的场景亮度值整体变低阈值可能需要降到0.2、0.3附近才能有接近原版的Bloom效果。4. 常见问题与排查技巧实录还原方案看着简单实际调试时你可能会被一堆“看起来不对”的画面折磨。下面整理了我遇到过的问题和排查思路直接当成速查表用。现象可能原因排查/解决方案场景整体发灰、发暗像蒙了一层雾贴图解码了但最后没有编码回gamma检查frag函数最后是否有pow(c, 1.0/2.2)或AccurateLinearToGamma高光爆亮、颜色发浊对数据贴图如金属度、AO做了gamma解码确认mask、金属度、粗糙度贴图没有进解码函数部分物体暗得离谱albedo没做解码就直接参与计算确认采样后、光照计算前已经pow(albedo, 2.2)法线凹凸感消失/出现条纹法线贴图被误做gamma解码法线贴图绝对不要进解码逻辑UI和3D画面严重不搭UI仍走gamma3D被手动线性化两者风格割裂保留UI的gamma风格只调3D整体色调或给UI材质单独做适配Bloom在后处理里几乎不生效Bloom阈值是在gamma域调的接线性帧后阈值语义变了调低Bloom threshold或进入线性帧后先做一次曝光归一化阴影边缘颜色偏浅/偏怪阴影衰减结果没有进入线性计算或shadow bias不匹配确保shadow系数在编码前就作用到线性结果上适当调整光照阴影Bias同一个shader在linear工程里颜色变两遍解码/编码逻辑没有用#ifdef UNITY_COLORSPACE_GAMMA包住所有手动转换代码都套上宏判断让shader在两种色彩空间下都能编译4.1 场景整体发灰、发暗这类问题90%出在编码缺失。很多人记得采了贴图要解码却忘了最后输出到帧缓冲之前还要编码回去。结果就是光照结果在线性域里是对的但输出到gamma framebuffer时没有做逆变换画面会整体暗半档中间调的灰度尤其明显。排查办法很简单把shader编码那一步临时注释掉和没注释的版本各截一张图放到Photoshop里对比色阶。如果线性结果本身不错只是输出时变暗那基本就是编码时机的问题。4.2 UI、贴图、粒子“看起来不搭”这个属于还原方案的“先天副作用”。因为你的3D世界被局部线性化了但UI、粒子、特效可能还在原来的gamma逻辑里两种风格并排呈现时会产生割裂感。实操中我最常用的缓解办法保留UI所在的Overlay Canvas是gamma渲染不去动它。由于UI不参与3D光照它的“色感”更多是美术调出来的通常不需要跟着3D一起变。粒子、特殊效果单独评估如果粒子使用的贴图是颜色图且它参与场景光照那考虑给粒子材质也加解码如果粒子是纯叠加型特效保持gamma通常问题不大。3D场景的灯光整体亮度统一上调因为转成线性后很多中间调会变暗一些美术如果觉得整体偏暗先调灯光强度再去动材质的颜色能少走很多弯路。4.3 阴影与高光表现异常阴影在gamma和linear下的差异很大。gamma下的阴影通常比较“软”linear下阴影更硬、更深。如果还原后的阴影看起来太黑可以去Directional Light组件里把Shadow Strength往低调一点而不是急着改shader。高光异常则要多看材质数据。很多美术在gamma下把Smoothness调得很高才好看切到线性后同样的Smoothness会让高光聚集度大幅增强看起来像抛了光的塑料。这是PBR在正确光照模型下的正常反应解决办法是重新调整材质的高光参数而不是改shader去“压”。4.4 性能开销评估与优化建议最后聊性能。这套方案的核心开销来自每像素的pow运算。尤其在低端移动GPU上一个片元里多做两三次pow可能比一次常规光照还贵。优化建议有几条优先用近似函数pow(c, 2.2)确实贵Unity内置的多项式近似GammaToLinearSpace在多数GPU上更划算。能离线算的别在线算如果某张贴图确定永远是线性或gamma可以直接在导入阶段把数据转换好运行时不用处理。只在关键材质上开启主视角里的角色、核心物件用还原shader远处地形的自定义shader可以保留gamma肉眼几乎看不出差异。用Half精度移动端shader里大量使用half3而不是float3能显著降低ALU压力。5. 什么时候别硬还原直接换linear反而更省事写到最后我必须泼一盆冷水还原方案永远是妥协方案不是终点方案。如果你的项目满足以下条件我不建议你手工还原linear效果直接切linear space更划算目标平台基本是现代PC、主机、中高端移动设备项目里有大量自定义shader但都有源码且维护团队能接受改动美术资源不是核弹级体量UI重调在可接受范围内没有那种写死gamma逻辑、无法替代的第三方插件项目处于研发期离发版还有足够回归测试时间。5.1 线性空间的天然优势切到linear后你获得的是一整套正确的链路纹理会自动按sRGB解码、光照在物理一致的数值域里计算、多光源加法发生在正确域、后处理可以在正确亮度里做Bloom和ToneMapping。这些都是手动还原方案很难完整复制的能力尤其当你的场景里有大量点光源、实时GI、阴影交替叠加时手动还原的误差会越积越多。5.2 迁移到linear前必须处理的三件事如果你决定走正统路线迁移前记得先处理这三件事不然回归测试会做得很痛备份并切换复制工程或开分支Player Settings里切Linear然后让Unity重新导入资源。等待过程顺便检查控制台里有哪些报错很多老shader在Linear模式下会直接暴露编译问题。重点检查UI和特效UI是否发灰、图集是否出现颜色偏色、粒子是否过曝。这三个区域是线性化后反馈最明显的。UI可以在Canvas组件和Shader层面做兼容处理但通常更建议直接让美术按新空间重新调一遍关键界面的色值。后处理参数整体重置Bloom阈值、曝光值、颜色分级里的对比度、饱和度基本都要重调。千万不要用gamma下的参数直接套不然你会觉得线性画面“又黑又脏”。跑完一轮完整回归测试后线性空间会给美术和程序都带来更稳定的表现预期后续再做PBR材质、光照、颜色分级大家会在同一个空间里沟通减少大量“为什么我这个材质到你这儿变样了”的扯皮问题。我个人的实际体会是如果只是为了“让一个上线前的老项目观感更好”手动在gamma里还原linear的关键效果是一个性价比很高的手段改动面小、回退容易、美术可快速预览。但如果你是在做一个还有半年以上开发期的项目那我的建议永远是——尽早切到linear空间不要想着用补丁的方式模拟一个本该属于渲染管线的能力。手动还原可以作为临时过渡但长期维护下来它只会让你的shader越来越复杂越来越难被下一个接手的人理解。最后再分享一个实用小技巧改造过程中做一个“还原前/还原后”的快捷切换开关把这个开关放到一个全局材质参数或者Shader关键字里会让你在对照调参时省掉大量反复切换材质的麻烦。