资讯详情

资讯详情

Gamma Space下还原Linear渲染效果:Unity Shader手动色彩空间转换实战

开头先交代一个场景。我前阵子接手一个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 SpaceLinear 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 ShadowShadow采样出来的是一个遮罩值它属于“数据”范畴一般不需要做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会在部分设备上出问题所以更稳妥的还是渲染到普通RTShader内部手动转换和我们在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的高光边缘干净了发光物体的核心区不会再“糊成一块”光晕的衰减也更自然。代价是后处理在低端机上多两个全屏PassGPU开销会增加几毫秒但换来的是和Linear项目几乎一致的后处理观感划算。5.3 环境光和烘焙GI的处理环境光这块容易翻车。许多人在Shader里做了主光源线性还原但环境光用ShadeSH9直接采样结果中间调还是灰的因为环境光在线性域里本来应该是暗的但你在Gamma域采到一个较亮的编码值两者相乘导致整体变亮。处理方法是把环境光采样结果也做一次解码。在Built-in管线里ShadeSH9返回的其实就是编码域的颜色需要pow(envColor, 2.2)。但要注意如果场景用的是渐变环境光或自定义SH有时这个解码会让环境光暗过头。这是正常的因为之前Gamma域的SH本身就是偏亮的还原成线性后需要适当调节Scene面板里的Ambient Intensity往上提一点强度来平衡。烘焙Lightmap的处理也有类似问题。如果你的项目用了Baked GILightmap的编码方式在不同管线里不一样常见的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的效果。这次的实操带来的经验是色彩空间不是一个简单开关而是一条从纹理到输出贯穿到底的链路你在中间任何一个环节偷偷省掉转换最后都会在屏幕上以某种形式“还回来”。所以别指望找到某个一键开关就能解决老老实实把链路捋顺才是唯一靠谱的路径。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →