开头先交代一个场景。我前阵子接手一个Unity项目,Android端真机画面灰蒙蒙的,暗部细节糊成一片,渐变色带严重到像是用8位色硬拉的。美术那边在PC上同一个场景切到Linear Space对比,差异一眼就能看出来——暗部更通透,漫反射过渡更自然,高光衰减也更柔。项目老大上来就一句:能不能不切Linear Space,把Linear的效果还原出来?
为什么不能直接切?原因很现实:项目要兼容一批老旧Android设备,那些GPU的帧缓冲不支持Linear格式,切过去直接黑屏或者性能崩掉;另外项目里还依赖了好几个老插件,Shader是在Gamma Space下写的,切了以后这些材质会整体“变性”,连锁炸出一堆问题。所以最终方案定为:继续跑Gamma Space,但通过自己改Shader和调整管线,在Gamma Space下手工复刻Linear Space的渲染链路。
这活儿说难不算难,说简单也绝对不简单。核心就一句话:把线性域的光照计算,硬搬到Gamma Space的项目里来,自己接管纹理解码和输出编码。下面是我完整的实操记录和踩坑过程。
1. gamma和linear到底差在哪:先看懂渲染链路
1.1 我们平时说的gamma space,本质是全链路不做转换
先说清楚一个事:Gamma Space不是“错”的,它只是一个没有色彩管理的链路。这里有个背景,早年CRT显示器的物理特性决定了亮度和电压之间不是线性关系,而是近似2.2次幂关系,所以画面是偏暗的。为了补偿,信号在传输时人为做了一次1/2.2次幂的“预加重”,这样最终显示出来的亮度才是正常的。这个编码曲线后来被标准化成sRGB。
现在的LCD、OLED屏和操作系统都沿用了这套非线性编码逻辑。简单说,你看到的图片文件、UI贴图、美术出的Diffuse贴图,在绝大多数情况下都是“sRGB编码值”,不是真正的物理线性亮度。
在这个前提下,Gamma Space项目里的渲染流程是:直接拿着sRGB编码值去采样贴图,然后在编码域的数值上做光照计算,算完直接输出到屏幕。屏幕上看到的是“编码值经过显示器的2.2次幂还原”以后的结果,看起来“好像正常”,但光照计算这件事全程都是在错误的空间里做的。
1.2 Linear Space为何观感更好:解码、计算、再编码
Linear Space项目的管线是:采样sRGB贴图时先做一次解码(sRGB编码值转换回物理线性亮度),然后所有光照计算都在线性域完成,最后在输出到帧缓冲前再编码回sRGB值。
这看似只是多了两步转换,但效果差别巨大。给个直观的例子:假设一个中间调的反射率为0.5线性亮度,在sRGB编码下存储值是0.735左右。如果直接用0.735去做光照计算,光照强度为1.0时,计算结果是0.735乘以光照,得到的还是0.735。但线性域里0.5经过正确的编码再显示,屏幕亮度却是0.5的物理亮度。麻烦在于,用0.735做各种衰减运算,比如Phong高光、N·L半影过渡、Shadow衰减,那么参与乘法的每个系数都偏亮了,结果中间调会整体发灰、高光边缘会显得硬。
我用一张表格把两者的关键差异列出来:
| 环节 | Gamma Space | Linear Space |
|---|---|---|
| 纹理采样值 | 直接用sRGB编码值 | 解码为物理线性值 |
| 光照计算域 | 非线性编码域 | 线性物理域 |
| 中间调表现 | 整体偏亮,发灰 | 亮暗过渡准确 |
| 高光衰减 | 过渡生硬,容易过曝 | 柔和、有层次 |
| 阴影暗部 | 对比不足,细节糊 | 层次分明 |
| 屏幕输出 | 不编码,直接显示 | 编码回sRGB |
所以,要让Gamma Space项目看起来像Linear Space,不是改一个开关,而是要把上面这条“解码-线性计算-编码”的链路,用代码手动搭起来。
1.3 为什么不能直接用Unity内置的转换函数
Unity其实给Shader提供了两个现成函数:GammaToLinearSpace()和LinearToGammaSpace()。但注意,这两个函数在Gamma Space项目里是“空操作”——它们只有在项目设置为Linear Space时才会做真正的转换。Unity的设计思路是“你的项目已经声明了色彩管理方式,Shader里的函数会跟随项目设置自动匹配”。
所以在Gamma Space项目里,别指望调用GammaToLinearSpace能自动救你。我一开始就是在这个地方被坑了半小时,心想函数调了怎么纹丝不动,后来查文档才发现这个行为。正确做法是自己写一个sRGB解码函数,比如用pow(color, 2.2)近似,或者用精确的分段sRGB解码公式。
2. 不切linear space,就要手动扛起这条转换链
2.1 手动还原的整体思路:采样处解码,输出处编码
既然是“手工复刻Linear链路”,那Shader里的结构就得改成三阶段:
- 纹理采样后,对颜色类贴图做一次sRGB解码
- 光照、衰减、阴影、AO等运算全部基于解码后的线性值
- 最终输出到帧缓冲前做一次sRGB编码
这个流程中,最容易被忽略的是“所有运算”这四个字。很多人的第一反应是只对主光照乘积做转换,但环境光、间接光、反射探针、Lightmap、ShadowMap的采样结果,全部都需要在同一个颜色空间里参与运算,否则就会出现某些部分还原了、某些部分还是Gamma味道的“阴阳脸”。
下面是一个标准的伪代码结构,很能说明整条链路:
// Gamma Space项目里手动还原Linear效果的标准结构 fixed4 frag(v2f i) : SV_Target { // 1. 采样颜色贴图,解码到线性域 half4 albedo = tex2D(_MainTex, i.uv); albedo.rgb = sRGBToLinear(albedo.rgb); // 自己实现的解码 // 2. 采样法线、遮挡等数据贴图,这些不需要解码 // 3. 在线性域内做光照计算 half ndl = saturate(dot(normalize(i.worldNormal), lightDir)); half3 directLighting = _LightColor0.rgb * ndl * albedo.rgb; half3 ambientLighting = SHIndirectLighting(albedo.rgb); // 环境光在线性域 // 4. 合并所有光照结果 half3 finalColor = directLighting + ambientLighting; // 5. 输出前编码回sRGB return half4(LinearToSRGB(finalColor), 1.0); }2.2 法线贴图和数据贴图的隐藏身份问题
这里提前打个预防针,是整个还原工程里最隐蔽的坑。一张贴图是不是要解码,取决于它存的是“颜色”还是“数据”。颜色贴图如Diffuse/Albedo是要解码的,因为它来自美术在sRGB空间下的绘制。但法线贴图、金属度贴图、粗糙度贴图、AO贴图、高度贴图、FlowMap,这些都是“数据贴图”,它们的数值是通过特定编码规则存储在纹理通道里的,不需要也不能做sRGB解码。
如果你把法线贴图做了一次pow(2.2),法线会被拉偏,光照方向整个错乱,模型会呈现一种“塑料反光+凹凸错位”的诡异效果。而粗糙度贴图如果被错误解码,高光范围会全体偏移,金属度错误的后果更不用说。
所以在项目里做资产排查的时候,第一步不是打开Shader改代码,而是先把所有材质用到的贴图分类:哪些是颜色贴图,哪些是数据贴图。后面我会详细讲这个分类过程。
2.3 能用宏或开关控制,就不要写死
由于我们可能会同时维护多个项目版本,或者需要在真机上对比“修复前/修复后”的效果,强烈建议在Shader里加一个开关。我用的是[Toggle]_MANUAL_LINEAR材质属性,加上一个变体关键字,这样同一个材质既可以保持原样,也可以一键切到“手动线性还原”模式。出问题时也能快速定位是不是这条链路的问题。
#pragma multi_compile _ _MANUAL_LINEAR // 在片元着色器里用关键字控制是否执行线性还原这个开关的成本极低,但排查问题的时候能帮你省下大量A/B对比的时间。
3. Shader改造实战:从一个小例子开始动手做
3.1 改造一个最简单的漫反射Shader
先从一个最简单的Unlit加Lambert漫反射的Shader开始。改造前它是这个样子:
// 改之前:典型的Gamma Space写法,全程不做转换 fixed4 frag(v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); float ndl = saturate(dot(normalize(i.worldNormal), _WorldSpaceLightPos0.xyz)); fixed3 diff = col.rgb * _LightColor0.rgb * ndl * _DiffuseStrength; fixed3 ambient = ShadeSH9(float4(i.worldNormal, 1)) * col.rgb; return fixed4(diff + ambient, col.a); }这个Shader在Gamma Space项目里出来的效果就是发灰、高光过渡硬的。改造后:
// 改之后:手动在Gamma Space里还原Linear效果 half3 sRGBToLinear(half3 c) { return pow(c, 2.2); } half3 LinearToSRGB(half3 c) { return pow(c, 1.0 / 2.2); } fixed4 frag(v2f i) : SV_Target { half4 col = tex2D(_MainTex, i.uv); #if defined(_MANUAL_LINEAR) col.rgb = sRGBToLinear(col.rgb); #endif float ndl = saturate(dot(normalize(i.worldNormal), _WorldSpaceLightPos0.xyz)); half3 diff = col.rgb * _LightColor0.rgb * ndl * _DiffuseStrength; // 环境光也要在线性域计算,否则环境光部分还是Gamma味道 half3 ambient = ShadeSH9(float4(i.worldNormal, 1)) * col.rgb; half3 finalColor = diff + ambient; #if defined(_MANUAL_LINEAR) finalColor = LinearToSRGB(finalColor); #endif return fixed4(finalColor, col.a); }就这十几行的改动,在真机上跑了一圈,画面的中间调立刻沉下去了,暗部开始有层次,漫反射的过渡也柔顺很多。这里有个很重要的认知:Gamma Space里的漫反射在暗部会“拱”起来一块,像蒙了一层灰;线性化之后暗部会“塌”下去,但细节反而出来了。
3.2 多光源和阴影的处理
上面的Shader只处理了主平行光。实际项目里还有点光源、聚光灯、ShadowMap衰减。这些都要统一在线性域处理,不能只处理了主光,点光源又来一个Gamma值,结果暗部还是花的。
多光源的处理要点是:所有光源的颜色强度值,在进入Shader时其实都是美术/策划在Inspector里设置的“编码值”。在线性域计算时,如果你觉得光照强度不对,不要直接去改灯泡的Intensity,而是应该在Shader里把光源颜色也解码。
具体来说,在自定义的多光源循环或ForwardAdd Pass里,对_LightColor0.rgb也做一次解码:
// ForwardAdd Pass里统一处理 inline half3 DecodeLightColor(half3 lightColor) { #if defined(_MANUAL_LINEAR) return sRGBToLinear(lightColor); #else return lightColor; #endif }阴影衰减也有讲究。如果项目是Baked Shadowmask或者Soft Shadow,Shadow采样出来的是一个遮罩值,它属于“数据”范畴,一般不需要做sRGB转换。但阴影参与乘法运算时,比如直接shadow * ndl,由于ndl是在线性域算好的,shadow也必须在同一个域。绝大多数情况下阴影贴图的值是0到1的线性遮罩,直接乘就行,不需要额外处理。但如果你觉得阴影整体偏浅或者偏深,别急着调阴影强度,回到线性域检查一下这个遮罩值是不是被中间某个步骤污染了。
3.3 URP项目里的差异处理
如果你的项目用的是URP而不是Built-in管线,流程有点类似,但代码位置不同。URP里处理这个问题的标准做法是写一个自定义Renderer Feature,在Blit阶段对全屏做sRGB解码,然后在下一个Pass做完光照后,再编码输出。但这属于管线级别的全局方案,Shder内的写法依然推荐用关键字控制。
URP里做Shader改造比Built-in麻烦的点在于,URP自带的Lit Shader是支持Linear和Gamma两种模式的,它在内部会检查_RenderingMode和相关关键字。如果你想在URP下用Gamma Space但还能让自定义Shader做线性还原,记得把渲染目标格式设置成R8G8B8A8_SRGB会在部分设备上出问题,所以更稳妥的还是渲染到普通RT,Shader内部手动转换,和我们在Built-in里的做法一致。
3.4 后处理的转换位置:晚转换不如早转换
项目里一般都有Bloom、Tonemapping之类的后处理。在后处理链路里,线性还原的扩展是:如果后处理拿到的帧缓冲内容已经是“编码后的sRGB值”,那你在做Bloom阈值提取时,高光阈值会偏高,暗部提亮会更容易噪。最理想的顺序是:整帧先做sRGB解码,再跑Bloom和Tonemapping,最后输出前编码回去。这个可以写一个自定义Pass挂在后处理栈最前面和最后面。
// C#侧示例:在OnRenderImage里给后处理链路手动加线性还原 void OnRenderImage(RenderTexture source, RenderTexture destination) { // 1. Blit source -> rtLinear,在Shader里做sRGB解码 Graphics.Blit(source, rtLinear, decodeMaterial); // 2. 中间跑Bloom等效果,全部基于rtLinear Graphics.Blit(rtLinear, rtBloom, bloomMaterial); // 3. 输出前编码回sRGB Graphics.Blit(rtBloom, destination, encodeMaterial); }不过要提醒一句,后处理阶段全链路线性化改动范围大,如果项目只是需要“角色和物品的视觉效果更像Linear”,完全没必要动后处理。只改模型Shader,保留后处理在Gamma域,实际观感已经能找回80%的Linear味道。
4. 纹理与资产:哪些该解码,哪些动了就废
4.1 核心分类规则:颜色贴图解码,数据贴图不动
前面说过了,贴图分成两大类。颜色贴图是给人看的,数据贴图是给计算用的。在实际排查中,我总结了下面这张分类表,你可以直接照着套:
| 贴图类型 | 是否sRGB解码 | 原因 |
|---|---|---|
| Albedo / Diffuse / Base Map | 是 | 美术画的颜色,sRGB空间 |
| 法线贴图 | 否 | 方向数据,RGB映射到XYZ |
| 金属度贴图 | 否 | 0-1数据,代表材质特性比率 |
| 粗糙度贴图 | 否 | 0-1数据,代表微表面粗糙度 |
| AO贴图 | 否 | 0-1遮罩,乘算系数 |
| 高度图 / 视差贴图 | 否 | 高度数据 |
| 自发光贴图 | 是(如果当颜色用) | 视觉颜色,且常需要HDR扩展 |
| Lightmap烘焙贴图 | 视情况 | 需检查烘焙管线,通常不额外解码 |
| Mask贴图(R/G/B通道混合) | 否 | 通道内是开关或权重数据 |
| UI图集 | 否(通常) | UI在Canvas空间有自己的计算方式 |
| 流动贴图FlowMap | 否 | 向量数据 |
| 雪花/污渍细节贴图 | 看用途 | 若用于叠加颜色则解码,用于遮罩则不 |
这个表看着简单,但实际项目里最麻烦的是“看用途”这一列。比如自发光贴图,如果只是单纯加个自发光颜色,解码和不解码的差别不是特别大,但如果自发光还要参与Bloom,那么解码与否会直接影响Bloom的亮度和范围。我的经验是:自发光贴图统一解码,因为Bloom拿到一个线性域的光源密度,计算更可控。
4.2 法线贴图为什么绝对不能解码
拿法线贴图举例。法线贴图的每个像素存的其实是压缩后的法线向量的x, y, z分量,范围映射到[0, 1]。正常解码是normal.xyz = tangentNormal * 2 - 1。如果你先做了pow(tangentNormal, 2.2),那么原来0到1的线性编码值被非线性拉伸了,恢复成[-1, 1]后,向量的方向和长度都错了。尤其是Z分量(就是指向表面的那个分量)会被压得特別大,导致原本凹凸起伏的小细节全变成“大鼓包”,整个模型就像被吹胀了的气球。
这个坑我在迭代中踩过一次。是我自己做批量材质替换工具时,统一对所有贴图做了解码,结果第二天美术群里炸了,所有角色的脸型都像塑料捏的。排查半天才发现是法线贴图被误伤。确认方法很简单,把法线贴图拿到Photoshop里看,它整体是蓝紫色的,因为B通道(Z轴)在0.5以上,这是“方向数据”的典型外观,不是自然颜色。
4.3 UI、粒子与图集:别大面积乱动
UI是另一个敏感区。UGUI的Canvas有自己的混合模式,UI的图集通常就是为了在Gamma Space下直出而制作的。如果你对UI Shader也做线性解码,再编码,输出的UI颜色会和你Photoshop里设计的完全不一样,要么过艳,要么发灰。这套还原方案,我强烈建议“锁死在3D世界渲染范围内”,UI保持原样。粒子要看情况,如果是纯加色混合来做特效,线性还原会让高亮的特效更亮、更有体积感,但如果是半透明白发丝、还有柔和边缘的那种2D立绘特效,就不要动。
4.4 一个资产检查的实操流程
我实际排查资产时用的流程是:
- 把项目的贴图按目录分类,标记出哪些是Albedo、哪些是数据贴图
- 写一个编辑器脚本,批量检测
Texture Importer里的sRGB (Color Texture)选项,颜色贴图通常勾选,数据贴图取消勾选 - 对Shader里的采样进行分类标记,颜色纹理gamma解码,数据纹理原样采样
- 用材质开关切换
_MANUAL_LINEAR,在同一场景从多角度截图对比 - 检查角色皮肤、服装高光、地面反射这三个最容易暴露Gamma缺陷的场景
资产检查这一步花的时间可能比改Shader多一倍,但非常值得。因为Shader改完如果资产不匹配,效果可能比不改还差。项目迭代到后期,最大的问题往往不是Shader逻辑,而是某个美术同事把金属度贴图当颜色贴图导出了。
5. 混合、半透明与后处理:最难还原的三个地方
5.1 半透明混合在Gamma下的“面团感”
Alpha混合是经典难题。在Gamma Space项目里,Unity对半透明物体的混合是在混合目标上直接进行的,而混合目标里存的是DstColor当前值(可能已经是编码后的值),这个混合过程在sRGB域发生,混合结果和Linear Space的混合有肉眼可见的差异。
最典型的表现是:两个半透明材质叠加的区域,在Linear Space里看起来是“物理叠加”的,在Gamma Space里则发灰、偏白、像刷了一层浆糊。如果你在项目里做了“手动线性还原”,那么半透明物体会出现一个尴尬的局面:修改完Shader输出时,半透明物体最终输出到帧缓冲前又做了一次sRGB编码,这会让混合源值变成一个编码值,混合到屏幕上的结果可能还不如不改。
解决思路有两个,看项目接受度来选择:
- 把半透明物件的混合挪到后期:先正常线性渲染到RT,然后用全屏Pass把RT和半透明对象分开混合,复杂。
- 接受半透明区域的差异:只保证不透明物体具有Linear品质,半透明特效保留原样,大多数项目视觉问题不大。
我在实际项目里选的是第二种,专攻不透明物体的还原,半透明特效保持Gamma域的混合,整体效果差别非常小。
5.2 后处理的Bloom:阈值和扩散全变了
Bloom这类后处理特效对色彩空间的敏感度极高。Gamma域里跑Bloom,亮度阈值设置的是sRGB编码值,比如阈值设0.8,实际对应的线性亮度是0.8的2.2次方左右,大约0.6。这意味着很多原本不该发光的中间调被误判为高光,Bloom效果会显得“脏、糊、一坨”。
还原的方式是把Bloom放进线性域。最简单的做法是给后处理栈前面接一个decode Pass,后面接一个encode Pass,中间所有效果都在线性域。前面已经给过一个OnRenderImage的C#片段,在实际项目里要迁就后处理栈的插入顺序。以Post Processing Stack v2为例,你可以在Before Stack和After Stack各挂一个自定义效果,分别做解码和编码。
实测下来这样搞完,Bloom的高光边缘干净了,发光物体的核心区不会再“糊成一块”,光晕的衰减也更自然。代价是后处理在低端机上多两个全屏Pass,GPU开销会增加几毫秒,但换来的是和Linear项目几乎一致的后处理观感,划算。
5.3 环境光和烘焙GI的处理
环境光这块容易翻车。许多人在Shader里做了主光源线性还原,但环境光用ShadeSH9直接采样,结果中间调还是灰的,因为环境光在线性域里本来应该是暗的,但你在Gamma域采到一个较亮的编码值,两者相乘导致整体变亮。
处理方法是:把环境光采样结果也做一次解码。在Built-in管线里ShadeSH9返回的其实就是编码域的颜色,需要pow(envColor, 2.2)。但要注意,如果场景用的是渐变环境光或自定义SH,有时这个解码会让环境光暗过头。这是正常的,因为之前Gamma域的SH本身就是偏亮的,还原成线性后需要适当调节Scene面板里的Ambient Intensity,往上提一点强度来平衡。
烘焙Lightmap的处理也有类似问题。如果你的项目用了Baked GI,Lightmap的编码方式在不同管线里不一样,常见的RGBM编码和HDR格式都不同。这时你需要谨慎判断要不要对Lightmap采样结果解码。我的经验是,直接先做一次pow(lightmapColor, 2.2)测试,如果暗部细节出来了并且高光不过曝,说明解码方向是对的;如果画面整体暗成一片,说明Lightmap本身已经在线性域(某些烘焙引擎输出线性值),不用再解码。
5.4 真机上的一个额外变量:部分GPU的帧缓冲是sRGB格式
最后说一个真机才遇到的坑。部分移动GPU的帧缓冲会被驱动或者Unity指定为R8G8B8A8_SRGB格式,这时候如果你在Shader里做了“输出前sRGB编码”,GPU在写帧缓冲时还会再做一次转换,结果就是双重编码,画面整体偏亮、发白、像过曝。
排查方法很简单:在Shader里把编码函数去掉,只保留解码和线性光照计算,看真机画面是否恢复正常。如果去掉编码后画面反而正常,说明是帧缓冲自动做了编码。这时候正确做法是:Shader内部不要手动编码,只做解码和线性计算,输出的线性值交给帧缓冲自动编码。
这个问题在Editor里很难模拟,因为PC预览时通常不会用sRGB RT。如果你遇到真机画面“修复后过曝”,优先检查这一步。我用下面的矩阵帮你快速判断:
| 帧缓冲格式 | Shader手动编码 | 结果 |
|---|---|---|
| 非sRGB RT | 有 | 正常还原 |
| 非sRGB RT | 无 | 偏灰,还原失败 |
| sRGB RT | 有 | 双重编码,过曝发白 |
| sRGB RT | 无 | 正常还原 |
所以在Shader里用宏做一档开关:如果检测到帧缓冲是自动sRGB编码,就跳过手动编码步骤,只做解码和线性光照。
6. 实测对比与参数校准:别让还原变成另一种“脏”
6.1 用参考机建立“标准答案”
改完Shader后最大的疑问是:这个效果对不对?最靠谱的办法是准备一台能够正常跑Linear Space项目的PC或者高端Android设备,用同一个场景切到Linear Space渲染一张“参考图”,然后把Gamma Space项目里改完的Shader渲染结果放到一起对比。
具体操作可以用Unity的RenderTexture同时抓两张图存成PNG,然后放到Photoshop里比对色阶分布。重点关注三个区域的数值:暗部(0-0.3中间调)、中灰(0.3-0.7)、高光(0.7-1.0)。线性还原后的Gamma Space项目,暗部应该比原来的Gamma域暗5%-10%,中灰过渡更平滑,高光收敛到更小的区域。
6.2 灯光强度和环境光需要重新调参
我记得第一次给项目改完Shader,美术反馈“画面太暗了”。这是因为原来Gamma域的光照强度是由一堆编码值算出来的,转到线性域后,同样的数值在线性域里会显得暗。这不是Shader的问题,而是参数需要重新校准。
实操时,我把场景里所有平行光、点光的Intensity按经验调成原来的1.2到1.5倍,环境光强度从默认的1.0略微上调到1.1左右。这个数值不是固定的,跟场景环境光的SH亮度分布有关,建议逐步调整,每次只动0.1,并用参考图去对。
还有一点,调光时不要只看最终画面亮度,要观察暗部和高光的曲线形态。如果画面“暗得死黑”,说明灯光强度调太低;如果“亮得发闷”,说明中间调的编码值还在干扰计算。
6.3 用Debug视图检查像素值和线性域特征
推荐一个检查方法:写一个Debug Shader,把最终色彩分成三个通道分别显示,或者直接输出是否在线性域的明暗渐变。比如有个_DebugMode开关,值为1时输出线性化的Albedo,值为2时输出线性光照计算过程中的NdotL,值为3时输出最终编码后的结果。
这样在场景里拉一个纯灰的渐变球体,如果你的还原链路正确,纯灰球体上的光照过渡应该是线性平滑的,不出现一端硬黑一端死白的情况。这个Debug Shader在调参阶段帮了我大忙,它比人眼观察准确得多,因为人眼会自动适应亮度,根本分不清到底是全局偏亮还是中间调曲线问题。
6.4 有些Shader真不值得全部改
最后说点实在话。不是所有Shader都需要做线性还原。角色模型、武器、地面、建筑这些主要视觉载体的Shader值得改;但小道具、一次性特效、纯色片体、UI的3D化表现,改它们的性价比很低,有时还会引入新问题。
我当时的取舍标准是:看这个物体能不能让人在1秒内注意到它。能,改;不能,先放着。项目最后改了几十个关键材质Shader,覆盖了80%的画面主体,其余20%保持原样,观感已经很统一。真正要紧的是先把“画面大效果”拉回Linear品位,再逐个别纠结局部细节。
还有个现实问题:Shader里的pow运算量在低端机会增加大概几个百分点的GPU负载。如果项目性能余量本来就紧,也可以把sRGBToLinear换成一次LUT查表,或者只对Albedo做解码而输出端不做编码(前提是帧缓冲自动编码),用性能换效果。我个人实测下来,主流中端机型上这批pow完全扛得住,但如果你要带的是几年前的百元机,那就要谨慎评估了。
经过这几个星期的折腾,这个Gamma Space项目最终在不动Color Space设置的前提下,把画面质感拉回了接近Linear的效果。这次的实操带来的经验是:色彩空间不是一个简单开关,而是一条从纹理到输出贯穿到底的链路,你在中间任何一个环节偷偷省掉转换,最后都会在屏幕上以某种形式“还回来”。所以别指望找到某个一键开关就能解决,老老实实把链路捋顺,才是唯一靠谱的路径。