资讯动态

Unity灰度Shader从原理到实战:UI置灰、全屏后处理与性能避坑

发布时间:2026/10/14 8:57:38 来源:尧图企业网站定制
灰度图像Shader听起来像个特别小的Demo题但真要在Unity里把它做干净、做可控、做完还能放心丢进正式项目其实隐藏着一整套渲染基础。这几年我在好几个模拟项目、回合制卡牌Demo、以及一个横版动作原型里反复写过类似的Shader每次需求都略有差异但底层的思路几乎一样把彩色画面变成黑白灰同时尽量保留Alpha、性能、扩展性和不同渲染管线下的正确性。这篇文章就从这颗“小”Shader出发把它的应用场景、亮度公式选择、UI灰显、全屏后处理、动态过渡以及我在实际项目里踩过的那些坑一次讲清楚。1. 灰度Shader到底解决什么问题先想清楚再动手1.1 三个最常见的需求场景第一个场景是UI禁用态。几乎所有游戏里的按钮、物品图标、技能卡牌都会在“不可用”时置灰。你当然可以让美术提前做一套灰度贴图但贴图多一张就是一份资源、一份包体、一份维护成本而且如果做动态开关就非常麻烦。第二个场景是角色死亡或受击反馈。动作游戏里经常用“屏幕瞬间变成黑白”来表现角色濒死、阵亡或者一次重击的眩晕。这时候如果靠切换场景相机或替换贴图流程很容易扭成一团不如直接套一层全屏后处理。第三个场景是“解锁/未解锁”的演出。很多游戏会把新角色、新关卡在界面里先展示成剪影或者灰度状态玩家通关后再把颜色放出来。用Shader来处理这种状态非常自然因为你在运行时只需要把灰度参数从1调到0画面就会从黑白平滑过渡到彩色美术不需要再制作“半灰度”的中间态素材。这些场景背后有一个共同点你需要的不是“一张黑白图”而是“一个在运行时能够随时控制黑白程度、作用在任意对象上的效果”。这件事用Shader比用PhotoShop预制、比在CPU上改像素、比换贴图都更合适。1.2 为什么不用程序里直接改贴图有同学可能会说我可以在C#里把Texture2D读出来遍历像素改成灰度再重新传回GPU。这种方案在分辨率小、数量少、只跑一次的情况下确实能实现但一旦涉及实时刷新、多个物体切换、移动端内存消耗就会非常难受。CPU像素遍历是老办法这里只适合离线工具不适合运行时。Shader的优势在于它运行在GPU上采样纹理之后立马能对每个像素做运算几乎不占额外内存不需要生成新的纹理资源而且你通过一个参数就能控制灰度插值。用一句话总结它是把“结果贴在脸上”的实时着色器不是把“纹理改掉”的离线工具。2. 灰度化的数学原理亮度公式不是随便选一个2.1 为什么不能简单地对RGB求平均如果你直接写half3 gray (col.rgb.r col.rgb.g col.rgb.b) / 3.0;出来的效果往往不是“灰度图”而是“一张有点脏、整体发闷的图”。原因是人眼对红、绿、蓝三种颜色的感知亮度不同。绿色在视觉中“看起来最亮”蓝色“看起来最暗”红色居中。给三种颜色平均的权重会让本来就偏亮的大面积绿色被拉暗而蓝色被莫名抬高整体观感非常奇怪。举个生活里的例子你把一张蓝天白云的照片直接平均灰度天空会变成灰蒙蒙的一片完全丢失层次但用正确的亮度公式加权后蓝天的灰度值会明显降低层次还在。2.2 不同亮度权重该怎么选图像处理领域经常用两个标准权重一个是BT.601老标清电视一个是BT.709高清电视与计算机显示。Unity内置Shader里也常常能看到这两种写法的变体。常见的代码是half gray dot(col.rgb, half3(0.2126, 0.7152, 0.0722));这是Rec.709的线性亮度权重优点是绿色主导、蓝色占比很低在高清显示设备上观感更自然。也经常看到half gray dot(col.rgb, half3(0.299, 0.587, 0.114));这是BT.601权重。它在很多老游戏、CRT屏幕时代更流行对比度稍高像素感更强。如果你做的是复古像素风游戏或者给某个复古滤镜做底层这套权重的观感反而很对口。实际操作中我建议不要纠结于“绝对标准”先默认用Rec.709权重再看美术反馈。如果觉得整体偏暗或偏亮可以顺手增加一个_Brightness参数对灰度结果做微调这是性价比最高的做法。2.3 Gamma与Linear色彩空间带来的差异Unity项目分Gamma空间和Linear空间。在Gamma空间下你在Shader里拿到的纹理颜色和你用PhotoShop看到的颜色数字基本一致直接用权重公式算出的灰度效果接近设计预览。而在Linear空间下GPU拿到的是经过解码的线性光值如果直接拿同样的权重去算最终显示到屏幕时会被再次编码实际观感和Gamma空间的预览可能会有偏差。这不是Shader写错了而是色彩空间本身的工作方式问题。我的处理习惯是如果只是UI图标灰度化、局部材质灰度化尽量在Gamma空间下调试效果直观如果项目强制Linear就别在Shader里自己反复做色彩空间转换直接接受线性空间的灰度结果然后让美术在最终显示器上校准一个亮度补偿参数。别为了“理论正确”把一个小Shader搞成一套色彩科学系统那是工具链团队该干的事。3. 手写第一个UI灰度Shader完整代码与逐行拆解3.1 先搭一个可用的UI基础框架用于UI的灰度Shader不是直接改Sprite的默认Shader就行需要满足UI渲染的几个硬性要求透明混合要正确要支持图集SpriteAtlas不能深度写入不能影响Canvas的合批设置。其实这些要求大部分可以靠SubShader里的Tags和Blend完成。我从一个比较标准的UI Shader模板改起。标签设置如下Tags { Queue Transparent RenderType Transparent IgnoreProjector True PreviewType Plane CanUseSpriteAtlas True }QueueTransparent和RenderTypeTransparent保证它在半透明物体队列渲染CanUseSpriteAtlas非常关键没有这个标签精灵一旦打进图集材质上绑定的纹理就会被图集替换或抽帧你经常会发现“换了个首帧图”之类的鬼问题。至于PreviewTypePlane只是让材质面板预览更友好不影响实际渲染。混合模式必须写Blend SrcAlpha OneMinusSrcAlpha这是UI最常用的Alpha混合。ZWrite Off避免UI物体写入深度防止它在半透明队列里遮挡后面的UI层。Cull Off保证正反面都能看到虽然UI大多是平面但保险起见写上。3.2 完整Shader代码与灰度计算位置下面是我在项目里最常用的UI灰度Shader不花哨但干净可直接用Shader UI/Grayscale { Properties { [PerRendererData] _MainTex (Sprite Texture, 2D) white {} _Color (Tint, Color) (1,1,1,1) _GrayAmount (Gray Amount, Range(0,1)) 1.0 _Brightness (Brightness, Range(0,2)) 1.0 } SubShader { Tags { Queue Transparent RenderType Transparent IgnoreProjector True PreviewType Plane CanUseSpriteAtlas True } Cull Off Lighting Off ZWrite Off Blend SrcAlpha OneMinusSrcAlpha Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float4 color : COLOR; }; struct v2f { float4 vertex : SV_POSITION; float2 uv : TEXCOORD0; fixed4 color : COLOR; }; sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color; half _GrayAmount; half _Brightness; v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); o.color v.color; return o; } fixed4 frag (v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv) * i.color * _Color; half gray dot(col.rgb, half3(0.2126, 0.7152, 0.0722)); gray * _Brightness; col.rgb lerp(col.rgb, half3(gray, gray, gray), _GrayAmount); col.a col.a; return col; } ENDCG } } }代码里值得注意的地方有三个。第一[PerRendererData] _MainTex是关键。UI的Image和SpriteRenderer在运行时会把各自的Sprite图集纹理塞给_MainTex不需要你给每张图单独创建一份材质实例。有了这个标签同一个材质可以被大量Image共用才能不破坏合批。第二我在片元Shader里用i.color乘了顶点色。UI的Image.color在底层是通过顶点色传给Shader的所以你保留这一层乘法后在编辑器里直接更改Image组件自带的Color也能正常对灰度结果叠加颜色效果不会出现“设置了Image Color却完全没反应”的困惑。第三灰度计算用的是Rec.709权重然后用_GrayAmount做lerp。当_GrayAmount0时图片完全保持原始彩色等于1时完全灰度。中间值就是渐变过度。_Brightness专门用来校正色彩空间或美术主观亮度默认1.0基本框架不动。3.3 在Canvas中把这个Shader用起来用起来有好几种层次。最直接的方式是把Shader文件创建成材质然后拖到Image组件的Material槽里。但要注意如果你用编辑器在Inspector里直接给Image指定MaterialUnity会自动帮你做一次材质实例化每个Graphic持有一份自己的材质。只要数量不多这是最简单、最稳的方式。如果你的项目里禁用按钮特别多比如卡牌游戏里几十张卡牌每张都实例化材质就太浪费。这时候有两个替代方向一是把材质挂到Canvas组件上。Canvas本身也有一个Material属性你把它设成同一个灰度材质那么该Canvas下所有UI都会用这份材质渲染。你可以再搭配一个全局参数把所有UI一起置灰。像整个背包界面在某个剧情阶段灰掉就适合这种方案。二是不要直接改GameObject的Material而是做一个最简单的材质池。由于_MainTex是[PerRendererData]同一个材质实例能自动适配每一张Image的纹理所以一个Canvas下只需要一个材质实例配合一个“灰化状态开关”的MonoBehaviour来切换_GrayAmount就能做到整屏UI动态灰化且DrawCall不受影响。我强烈不建议为了“方便”在运行时不断new Material()每new一次就是一份GPU资源回收不及时还会造成持续的卡顿。如果你受过这种苦应该能明白我在说什么。4. 从静态灰化到动态控制加了参数玩法就打开了4.1 用_GrayAmount做淡入淡出的UI状态切换有了_GrayAmount你可能第一个念头就是能不能用它做按钮禁用和解禁的平滑过渡当然可以而且实现方式比改贴图优雅太多。比如某个图鉴系统里卡牌解锁后需要从灰度逐渐变成彩色你只需要写一个简单的协程IEnumerator RestoreColor(float duration) { float timer 0f; while (timer duration) { timer Time.deltaTime; float t Mathf.Clamp01(timer / duration); material.SetFloat(_GrayAmount, 1f - t); yield return null; } }单张卡、单个图标、整一块界面面板都可以套这层逻辑。只要材质是共享的接口统一你甚至能做出一套全局的UIHueManager所有UI都通过它来切换状态。我自己的经验是_GrayAmount这个参数命名尽量保持通用尽量别叫_IsGray这种布尔命名。因为你以后肯定会想在灰度里加些小效果比如对灰度结果再叠一层冷暖色调、加一点扫描线到那时候布尔参数会逼着你写一堆if分支远不如直接传一个0到1的float方便。4.2 加一个_Tint变成单色调复古滤镜灰度Shader扩展出单色效果其实只要多一个颜色参数把灰度值乘到颜色上就行_TintColor (Tint Color, Color) (1,1,1,1)在frag里把最后的结果改成col.rgb lerp(col.rgb, half3(gray, gray, gray) * _TintColor.rgb, _GrayAmount);这有什么用呢用途太常见了。很多游戏“回忆杀”桥段画面要整体偏褐色、偏黄或者偏冷紫。做成一个单色调色器后你既能使用灰度权重保住明暗结构又能在上面罩一层环境色瞬间得到怀旧滤镜、恐怖深色滤镜甚至“红外夜视仪”这类很风格化的视觉效果。美术只要把_TintColor调节好就能快速出好几个版本。4.3 区域灰度让画面焦点像聚光灯一样突出有些需求是想让画面大部分区域灰度只保留某个圆形区域是彩色最常见的是“聚光灯聚焦”效果角色周围一小圈有颜色周围全灰制造强烈的叙事感。这个效果和灰度Shader的思路一致只是要在灰度结果和原彩之间再插入一个基于距离的权重。用屏幕UV或者模型UV都可以核心公式是用distance()计算当前像素与某个中心点的距离再用smoothstep把距离映射成0到1的平滑权重。代码大概这样half2 center i.uv - _Center; half dist length(center); half focusWeight 1 - smoothstep(_Radius - _Softness, _Radius _Softness, dist); half gray dot(col.rgb, half3(0.2126, 0.7152, 0.0722)); col.rgb lerp(half3(gray, gray, gray), col.rgb, focusWeight);_Center控制光斑中心_Radius控制彩色区域大小_Softness控制边缘羽化程度。我建议把_Center定义为UV坐标比如(0.5,0.5)就是正中央方便从C#传入角色在屏幕上的投影UV。另外给灯光边缘留一点软边看起来比硬切更高级这也是我做了好几次之后比较满意的形态。这种圆形区域灰度如果配合角色受伤闪红、时间冻结特效可以直接铺成一套简单的剧情演出方案。它也不需要一整张遮罩贴图两个参数就能调非常适合快速原型验证。5. 后处理方案让全屏画面直接变成黑白电影5.1 OnRenderImage模式下10行C#搞定全屏灰度刚才讲的都是单物体材质层面的灰度但很多渲染表现需求是“整个屏幕都灰掉”比如角色死亡、剧情回忆、时间停止。这种需求最佳方式是在相机端做一次全屏后处理而不是给场景里每个物体都换材质。全屏灰度的C#端极其简单就一个继承MonoBehaviour的脚本using UnityEngine; public class GrayscalePostEffect : MonoBehaviour { public Material material; private void OnRenderImage(RenderTexture source, RenderTexture destination) { if (material ! null) { Graphics.Blit(source, destination, material); } else { Graphics.Blit(source, destination); } } }把这个脚本挂到相机上再给Material槽拖入写好的灰度Shader材质整个相机渲染的画面就会经过一次全屏灰化。Shader部分甚至可以比UI版还简单因为不需要处理顶点色和复杂混合只要采样屏幕贴图、算灰度、输出。5.2 URP和HDRP下该注意什么如果你用的是URP上面的OnRenderImage默认不会执行。URP里全屏后处理要借助Render Objects、Full Screen Pass Renderer Feature或者自己写一个ScriptableRendererFeature在相机栈里插入Pass。Shader的主体思路仍然可用只是在屏幕纹理的获取方式上从内置管线变成_BlitTexture或者FrameBuffer相关接口。我在URP项目里处理全屏灰度时通常会把渲染特性放在相机栈的“After Opaque”或者“Before UI”位置。因为URP里后处理节点非常灵活你可以控制它在不透明物体之后、半透明之前或者在UI之前这让“只灰化3D场景但保留UI色彩”这种需求变得非常简单。HDRP的处理思路类似但HDRP对后处理Pass的要求更严格需要对颜色缓冲和深度缓冲有更明确的声明。细节差异容易写一大篇这里不铺开你只要记住先确认管线再决定用什么样的屏幕后处理入口否则在Built-in管线里测试好的脚本贴到URP项目里会一脸懵。5.3 后处理顺序灰化应该在Bloom之前还是之后做屏幕特效的时候顺序问题不能忽略。如果你的项目挂了Bloom、Tonemapping等后处理灰度特效的位置会直接影响画面风格。我的建议是如果是“写实电影风”灰度后处理放在Bloom之后这样Bloom的高光模糊也一起被灰度化整体统一成黑白调如果是“游戏风格UI表演”想保留一点高光闪烁感可以放在Bloom前让Bloom继续对颜色做处理。但你要知道绝大多数情况下我们其实不关心物理上绝对正确的顺序只关心“看起来是不是想要的样子”。灰化放在最后往往最直观也最不容易出现“UI没灰但背景灰了”的杂音。我更建议做一个“灰度程度”参数让后处理强度能从0平滑过度到1这样顺序造成的小差异都可以用强度过渡来掩盖。6. 常见问题与避坑实录6.1 UI的Image用了材质但画面没变灰遇到这个问题先检查三件事。第一材质是否真的被赋给了正确的Image组件第二Shader文件编译是否通过可以在Project窗口里点开Shader看看有没有粉色报错第三也是最常见的_MainTex在运行时被Image的Sprite覆盖但你的ShaderProperties里没有声明[PerRendererData]导致Unity无法把精灵图集纹理正确传入。还有一个隐蔽陷阱如果你的纹理不是一个sprite而是一张普通Texture类型把它拖到Image的Source Image上其实是无效的Image只接受Sprite类型。这个问题看似不相关但会让_MainTex一直引用到默认白色纹理于是你的灰度Shader看起来“根本没效果”。6.2 灰度后颜色偏暗或偏亮美术不满意这是最高频的反馈。原因大多不是公式问题而是色彩空间差异及权重选择带来的观感差异。我自己的处理套路是先在_Brightness参数上做整体亮度补偿如果还不行再考虑换BT.601权重。多数情况下美术只是需要一个能快速调节的旋钮而不是一场严肃的学术讨论。如果你面对的是一个追求极致审美的项目优先在Linear空间底下对同一张图像使用slide参数反复对比把_Brightness默认值最终固化到Shader面板里。这个过程其实不是调数学而是调美术感知。6.3 UI合批被破坏、DrawCall暴涨这是UI灰化里最值得警惕的性能问题。Unity的UI系统追求在同一Canvas下把相同材质的UI元素合批。一旦某个Image挂上了独立材质它和周边相同材质UI之间的合批就被打断严重的时候DrawCall从2跳到25尤其卡牌、物品栏这类大量图标的界面。缓解方案有三个方向一是尽量用Canvas级材质来统一二是让Shader声明[PerRendererData] _MainTex并保证所有 Image共享同一个Material实例三是如果你真的需要个别图标单独灰化、其他保持彩色那么把“需要单独灰化”的图标单独放进另一个Canvas让它自己自成一组减少对主Canvas的合批破坏。6.4 移动端性能精度、指令数和分支灰度Shader本身极轻量但如果跑在大量UI元素上再加上复杂的区域灰度计算移动端还是会有压力。常用优化手段包括用half而不是float计算灰度移动端GPU对half的运算管线更友好。尽量别写if (_GrayAmount 0.5)这种动态分支直接换成lerp在统一着色器模型下更稳。distance()运算在片元着色器里不是免费的如果只是比较圆半径可以改成比较距离平方来减少一次开方运算。不要每帧去SetFloat同一个值改成只在值变化时更新。我在一个中端安卓机上做过压测纯灰度UI材质叠加几十个图标DrawCall几乎没变化反而是因为单独Material导致合批破坏的DrawCall增加更明显。所以在移动端先解决合批问题比抠Shader性能指令更有意义。6.5 图集、精灵打包和灰度材质一起用出问题项目用了SpriteAtlas之后Image传给材质的是打包图集的某一张子图_MainTex拿到整张图集纹理UV则被限制在子图对应的小块区域。这里如果Shader里的TRANSFORM_TEX或者图集标签没配对会出现花屏、错位。解决办法就是之前提到的在SubShader Tags里加CanUseSpriteAtlasTrue并保持properties中_MainTex标记为[PerRendererData]。另外提醒一句如果图片有非常细小的边缘透明像素灰化时边缘会明显这是因为灰度权重把RGB压扁后透明区域的纹素边缘在高对比度背景上会显得发黑。这种情况可以在Shader里把灰度计算限制在不透明区域或者直接依赖Sprite的透明裁剪用clip(col.a - 0.01)把极透明区域丢弃。回看这些坑其实灰度Shader本身不难难的是把它嵌进整个项目的渲染环境里还不出问题。我从第一次手写灰度Shader到现在最大的感受就是所有看似“简单”的着色器效果最终都会在色彩空间、UI合批、图集、渲染管线顺序这些外围环境里暴露出你没想过的细节。所以每次写新Shader我都先问自己三个问题它作用在哪一层它要控制到什么程度它会跟哪些合批、后处理、色彩空间联动想清楚之后再动手Shader基本一次就能调好。最后分享一个我自己的小习惯灰度Shader里最后输出的颜色一定要保留Alpha通道原样输出不要因为RGB转灰就把Alpha也搞乱了。UI界面和全屏后处理对于透明度的要求不同但统一保留Alpha是安全值不会让你在某个界面层级里突然发现元素消失了。如果你也想做个更通用的灰度工具不妨先从这个框架开始把_GrayAmount、_Brightness、_TintColor、区域权重都做成参数后面面对新需求就不用重写了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑