尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Unity3D Shader动态屏幕遮罩:从后处理到CommandBuffer实战

Unity3D Shader动态屏幕遮罩:从后处理到CommandBuffer实战 简介面向需要为游戏场景添加动态遮罩效果的 Unity3D 开发者这份 PDF 用 Shader 实现可实时跟随目标物体的屏幕可视范围遮罩适合地图迷雾、区域探索、局部高亮等交互场景。内容围绕 CGPROGRAM 编写思路展开覆盖 Properties、SubShader、Pass、顶点/片元着色器以及 Cull Off、ZWrite Off、ZTest Always 等关键渲染设置并给出完整 Shader 与 C# 调用脚本。脚本通过 WorldToScreenPoint 将目标物体位置转为屏幕坐标再经 CalcAlpha 计算像素透明度可调整遮罩半径、边缘渐变大小与混合颜色当前版本支持最多 9 个追踪物体修改 Shader 中 ItemSize 即可扩展数量。压缩包共 1 个 PDF 文件大小仅 66KB内容轻量且结构完整适合有 Unity3D 基础、希望深入理解屏幕后处理遮罩原理的开发者直接参考。目前已有 2394 人学习。1. 动态屏幕遮罩Unity3D Shader实现不只是做个圆形黑边在Unity3D里用Shader实现动态屏幕遮罩通常是为了解决演出和提示问题主角走进黑暗区域画面边缘要压暗镜头锁定某件关键道具其余像素全部降调怪物现身时只有它周围一圈保持清晰。用UGI的Mask做改坐标、转世界点又卡又别扭直接叠一张黑图又无法跟随画面里的物体。大多数项目的做法是用Shader写一个全屏后处理配合C#每帧把场景物体的屏幕坐标传进去再由片元函数决定每个像素显示原图还是遮罩色。这篇笔记把它拆到能直接抄的粒度适合刚接触后处理和二次元Shader以外常规渲染管线的动手派。如果只是想要一个圆洞聚光灯效果一个材质球加一个脚本就能跑通想做得更像成品我会在这篇里顺便把边缘柔化、多中心遮罩和CommandBuffer替换路线也一起讲明白。读者按自己的镜头需求选深度方案是同一套。2. 先选实现路径全屏Quad、额外相机与后处理选错后面全要返工实现动态屏幕遮罩有不少于三条路很多初学者上来就摆一个Quad或者往Canvas里塞Mask等到要跟场景物体联动时才翻车。这一章先把三条路径的边界说清楚再给结论哪个该做原型哪个该上正式。2.1 全屏Quad只适合遮罩和镜头都不动的过场最常见的土办法是在主相机前放一个带遮罩贴图的Quad相当于把一张半透明贴图贴到屏幕上。好处是绕开了后处理管线Scene窗口里能直接看见遮罩长什么样美术也能手动K缩放和透明度动画。用来做固定镜头的黑场、开场的圆洞、过场时的血迹飞溅完全够用顶点色和Tiling/Offset还能做纹理动画成本极低。但它跟不上“遮罩跟随场景物体”的需求。物体一动你就得每帧把物体的世界坐标投影成屏幕坐标再去更新Quad的位置和scale稍微算慢一帧遮罩就会拖在物体后面半拍非常明显。而且Quad在透明队列里会参与深度测试放太近挡UI放太远被场景物体遮挡排查起来相当费时间。我的建议是只在临时验证遮罩颜色和形状时用Quad正式功能别选这条。2.2 额外相机 RenderTexture遮罩本身也是特效时的出路如果遮罩不是简单圆形而是一张随时间流动的纹理比如墨迹扩散、血痕爬行、光斑修补单靠一个后处理材质传两个参数是画不出来的。这时可以用第二台相机单独渲染一张遮罩RenderTexture主相机后处理再采样这张RT做合成。遮罩层和场景层完全解耦你可以用Shader、粒子系统甚至动画序列帧去驱动这张遮罩纹理灵活性是三条路里最高的。代价也直白每帧多一次相机渲染和一张RT的读写移动端压力不小。还要处理两台相机的cullingMask、depth、clear flags稍不留神第二台相机就把场景又渲了一遍GPU时间直接翻倍。我一般会在遮罩纹理确实复杂到“参数画不出来”时才用这条不然就是拿性能换省事不划算。2.3 屏幕后处理动态遮罩的主流也是这篇的主干最终选择是屏幕后处理。原理一句话相机先把场景渲进一张RenderTextureOnRenderImage或CommandBuffer拿到这张图和一个遮罩材质Shader在片元函数里根据每个像素的uv与遮罩中心计算距离半径外的像素替换成遮罩色。中心坐标和半径由C#每帧传入遮罩就能跟住任意世界坐标物体。选它的理由很具体第一用WorldToViewportPoint可以直接把场景物体的世界坐标转成0到1的屏幕归一化坐标跟Shader里的uv天然对齐第二边缘柔化只要一个smoothstep就能做参数调整直观第三迁移到URP时OnRenderImage虽然失效但同一套材质可以直接挂到CommandBuffer的相机事件上改动量最小。对于标题里“动态”两个字这是最稳的一条路。方案学习成本跟随动态物体遮罩复杂度性能开销全屏Quad低差需每帧反算中低额外相机RT高好高高多次渲染屏幕后处理中好中中一次全屏片元结论做原型可以用Quad做复杂纹理可以用额外相机做“跟随物体移动的遮罩”用后处理。下面进入能直接复现代码的部分。3. 最小可运行实现一份遮罩Shader加一个C#驱动器这章是全文的核心。我会给出一份单Pass的遮罩Shader和挂相机上的C#脚本两个文件就能跑。Shader负责在像素层面判断当前显示原图还是遮罩色C#负责把场景物体的位置换算成Shader能用的坐标。下面先拆Shader。3.1 遮罩Shader不要用现成UI Mask从一张全屏贴图开始在Built-in管线里后处理Shader的套路是固定的Properties声明参数SubShader里关闭深度写入和裁剪顶点函数把全屏三角形的位置和uv传进片元片元函数做像素逻辑。这份Shader的要点在于用中心坐标加半径计算圆形遮罩并用smoothstep把硬边变成软边参数全部留出来给C#每帧改写。Shader Custom/ScreenDynamicMask { Properties { _MainTex (主场景, 2D) white {} _Center (遮罩中心, Vector) (0.5, 0.5, 0, 0) _Radius (遮罩半径, Float) 0.3 _Softness (边缘柔化, Float) 0.08 _MaskColor (遮罩颜色, Color) (0, 0, 0, 1) _ShowOutside (反向显示, Float) 0 } SubShader { Cull Off ZWrite Off ZTest Always Pass { HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_TexelSize; float4 _Center; float _Radius; float _Softness; float4 _MaskColor; float _ShowOutside; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv v.uv; #if UNITY_UV_STARTS_AT_TOP if (_MainTex_TexelSize.y 0) o.uv.y 1.0 - o.uv.y; #endif return o; } fixed4 frag (v2f i) : SV_Target { float2 uv i.uv; float aspect _ScreenParams.x / _ScreenParams.y; float2 dir uv - _Center.xy; dir.x * aspect; float dist length(dir); float soft max(_Softness, 0.0001); float mask 1.0 - smoothstep(_Radius - soft, _Radius, dist); mask lerp(mask, 1.0 - mask, _ShowOutside); fixed4 col tex2D(_MainTex, uv); return lerp(col, _MaskColor, mask); } ENDHLSL } } }这份Shader的逻辑拆开看顶点函数把uv原样传给片元重点在UNITY_UV_STARTS_AT_TOP判断。OpenGL系API的uv原点在左下DirectX在左上后处理如果不处理这个差异遮罩位置会上下颠倒这个坑在第五章细说。片元函数里dir.x * aspect是为了修正宽高比否则屏幕越宽遮罩越像横着的椭圆圆形变椭圆是这类Shader最容易忽略的问题。参数方面_Center接收的是归一化屏幕坐标左上角是(0,0)右下角是(1,1)与Unity的Screen.height坐标不一样C#端做换算时别混。_Radius同样用归一化数值0.3表示遮罩可见半径约占屏幕短边的三成。_Softness是过渡带宽度0.08对应较柔的边缘取太大会让遮罩边界模糊到看不见新手常把这个当成透明度注意区别。3.2 C#驱动器把世界坐标送到Shader里C#端要做的事情有三件拿到场景物体的屏幕归一化坐标、把半径和柔化值写进材质、调用Graphics.Blit完成后处理。脚本挂在主相机上用OnRenderImage是Built-in管线最直接的方式。using UnityEngine; public class DynamicScreenMask : MonoBehaviour { public Material maskMaterial; public Transform followTarget; public float radius 0.3f; public float softness 0.08f; public bool showOutside false; private Camera _cam; void Start() { _cam GetComponentCamera(); if (_cam null) _cam Camera.main; } void OnRenderImage(RenderTexture src, RenderTexture dest) { if (maskMaterial null || followTarget null) { Graphics.Blit(src, dest); return; } Vector3 viewPos _cam.WorldToViewportPoint(followTarget.position); if (viewPos.z 0) { Vector3 behindPos _cam.transform.position _cam.transform.forward * 100f; viewPos _cam.WorldToViewportPoint(behindPos); } maskMaterial.SetVector(_Center, new Vector4(viewPos.x, viewPos.y, 0, 0)); maskMaterial.SetFloat(_Radius, Mathf.Max(radius, 0.01f)); maskMaterial.SetFloat(_Softness, softness); maskMaterial.SetFloat(_ShowOutside, showOutside ? 1f : 0f); Graphics.Blit(src, dest, maskMaterial); } }WorldToViewportPoint返回的坐标x和y天然在0到1之间正好对应Shader里的uv。这里有个细节当物体转到相机背后时viewPos.z会变成负值坐标虽然在但位置跳变画面会闪一下。我用了一个兜底策略物体在背后时直接把遮罩中心推到相机前方100米处的投影点让遮罩滑出屏幕而不是原地跳变。如果你做的是第一人称视角这块必须处理否则转视角时遮罩会像抽搐一样乱跳。使用这套脚本时把组件挂到主相机maskMaterial拖入上面Shader创建的材质followTarget拖入要让遮罩跟随的物体即可。建议把材质球的RenderQueue保持默认因为Graphics.Blit只认材质Pass不参与透明队列排序别学UI那套去改Queue。4. 把遮罩做“活”参数联动、多中心与动态过渡的常用套路能跑通最小实现之后实际问题往往是跟主角的遮罩半径怎么随距离变化多个角色同时高亮怎么做遮罩切换时怎么不硬切。这一章从参数设计和Shader结构两个方向拆解“动态”这两个字。4.1 参数联动半径和柔化别写死用缓动驱动固定半径在静态过场里没问题但一旦主角走近又走远遮罩边缘一下大一下小观感很差。常见做法是每帧根据物体到摄像机的距离换算目标半径再用Lerp做缓动。这段Update可以直接加进上面的C#脚本void Update() { if (followTarget null || _cam null) return; float dist Vector3.Distance( _cam.transform.position, followTarget.position); float targetRadius Mathf.Clamp01(dist * 0.04f); targetRadius Mathf.Max(targetRadius, 0.08f); maskMaterial.SetFloat(_Radius, Mathf.Lerp(maskMaterial.GetFloat(_Radius), targetRadius, Time.deltaTime * 3f)); float targetSoftness Mathf.Clamp(softness * (1f - targetRadius), 0.02f, 0.2f); maskMaterial.SetFloat(_Softness, Mathf.Lerp(maskMaterial.GetFloat(_Softness), targetSoftness, Time.deltaTime * 3f)); }这段逻辑说明距离越远遮罩半径越大符合人眼透视——物体离镜头远时在画面里占的位置小但遮罩如果不变就会出现“人都走远了还盯着原点”的违和感。柔化与半径成反比是因为半径很小时过渡带如果还保持0.08遮罩会被整个软化成一片模糊所以小半径必须压紧柔化值。Time.deltaTime * 3f给出大约0.3秒的追赶时间不会太快也不会拖泥带水。这里有个容易被忽略的点GetFloat在Update里读再写回去是常见的“窄接口”用法比在脚本里存一份变量再SetFloat更稳避免材质被外部修改后脚本里的缓存过期。4.2 多中心遮罩在Shader循环中心点数组少用多次Blit有时需要同时高亮多个物体比如队友和任务目标都要保持可见。最直接但低效的做法是连续调用多次Graphics.Blit每次都叠一层材质中间还要倒腾临时RT性能和显存开销都难看。更常见的做法是Shader里开一个固定长度的中心点数组循环计算每个中心对当前像素的遮罩贡献取最小值作为最终遮罩值。float mask 1.0; float aspect _ScreenParams.x / _ScreenParams.y; for (int i 0; i _CenterCount; i) { float2 dir uv - _Centers[i].xy; dir.x * aspect; float d length(dir); float oneMask 1.0 - smoothstep(_Radii[i] - _Softness, _Radii[i], d); mask min(mask, oneMask); }这段Shader里_Centers是float4数组C#端用maskMaterial.SetVectorArray传值_CenterCount限制循环次数Shader里循环次数写成可变值在某些平台会退化成很慢的通用版本所以_CenterCount在发布时应该写死或用[Range(1, 4)]限制范围。用min而不是乘法的原因是乘法会把重叠区域的遮罩越乘越深两个遮罩边缘叠在一起会出现一道暗痕min保证只要任意一个中心认为像素可见就保持可见。这也是多高亮场景里最容易出现的视觉bug记好min这一步。这套做法建议最多4个中心点。再多的话Shader里循环体开始有分支GPU占用率和调试难度都会上升不如回到额外相机方案用纹理Mask画任意形状。边界在“几个圆洞”和“任意形状”之间前者数组够用后者老老实实上RT。4.3 状态切换遮罩渐显渐隐别直接改显示方向_ShowOutside参数能从遮罩内部可见切到外部可见但直接改会产生硬切。一个简单的技巧是C#端维护一个0到1的过渡值每帧向目标值靠拢再把它作为权重传给ShaderShader里把_MaskColor的alpha和场景原图按这个权重混合。实际操作时我会把_ShowOutside从Bool换成一个Float的_Transition变量Shader里mask lerp(mask, 1.0 - mask, _Transition)C#只用Mathf.MoveTowards去改它。这样遮罩反转就带上了过渡动画不用额外写协程代码量更少效果足够顺滑。5. 避坑与排查动态屏幕遮罩的四个常翻车点这部分看名字就知道是血泪经验集中区。下面四条在动态屏幕遮罩的落地过程中出现频率最高覆盖坐标、渲染顺序、输入兼容和资源打包四个方面。5.1 全屏一片黑连原场景都看不见现象材质和脚本挂好后Game视图直接黑屏连场景轮廓都没了。原因最常见的是_Center默认值为(0.5, 0.5)而C#脚本因为followTarget未赋值走了Graphics.Blit(src, dest)这不会黑屏真正的黑屏黑在Shader上——_ShowOutside默认0如果C#没给_MaskColor赋值遮罩色是透明的黑叠加后场景变暗属于正常但如果整屏全黑多半是_Center被设成了(0,0)且半径很小所有像素都被判定在半径外全被替换成了遮罩色。另一种可能是OnRenderImage里用了Graphics.Blit(src, src, material)这种原地Blit在部分平台上直接破坏输入缓冲。解决先在Inspector面板把材质球的_Center临时改成(0.5, 0.5)、_Radius改成0.4确认Shader本身没写错然后把C#脚本里followTarget的赋值时机提前到Awake而不是Start避免第一帧OnRenderImage时坐标为默认值。排查时用Frame Debugger看一下最后一个DrawCall确认Shader属性面板里_Center数值是否符合预期这比盲目调参数快得多。5.2 遮罩上下颠倒世界坐标对不上画面现象物体在屏幕上方遮罩却出现在下方或者正好相反。原因图形API的uv原点差异。OpenGL系的uv原点在左下角DirectX在左上角而WorldToViewportPoint返回的坐标是以屏幕左上角为原点的。后处理Shader如果不处理这个差异传给_Center的y值就和片元函数里的uv直接反掉了。这个问题在编辑器里不一定触发因为编辑器默认用OpenGL模拟但打包到Windows上跑DirectX就会翻船。解决Shader里用UNITY_UV_STARTS_AT_TOP和_MainTex_TexelSize.y判断TexelSize.y为负时翻转uv.y。这也是为什么我的Vertex函数里特地带了那段判断不要图省事删掉。如果你坚持在C#端翻转用1.0f - viewPos.y再传给_Center也有效但跨平台时Shader里的判断更稳妥因为真正决定uv语义的是最终渲染APIC#端猜测容易出错。5.3 柔化调到很大边缘还是生硬或出现锯齿现象Softness调到0.2遮罩边缘依然有一圈明显的锯齿或者反过来边缘过度均匀得像糊了一层雾。原因第一种情况多半是RT分辨率太高边缘逐像素变化梯度不够锯齿被放大第二种情况是smoothstep的方向写反了smoothstep(_Radius - soft, _Radius, dist)返回的0到1之间数值在半径外侧硬边感消失但遮罩范围也跟着缩了。还有一个隐藏原因是把_Softness用在了顶点函数里而没在片元计算后处理全屏三角形顶点只有三个顶点差值根本过渡不出来。解决先确认_Softness在片元函数里计算Shader里任何跟像素边缘相关的量都不能走顶点差值。其次排查RT分辨率OnRenderImage里如果手动GetTemporaryRT创建了低分辨率RT哪怕分辨率只有屏幕的四分之一边缘也会在放大时发糊分辨率太高又锯齿明显。一般做法是保持src原生分辨率靠_Softness数值找平衡而不是动RT尺寸。锯齿特别明显时在Shader里对dist做一次smoothstep重映射齿感通常立刻消失。5.4 换场景后材质变粉遮罩直接消失现象过场切换或加载新场景后屏幕上的遮罩不见了同时物体表面出现标志性的粉紫色。原因这是Shader被剥离的典型症状。Unity在打包或场景切换时会剔除未引用的Shader变体材质球引用的Shader如果不在“Always Included Shaders”列表里加载时就会丢失引用材质球变成默认的粉紫色。解决在Project Settings的Graphics面板里把Custom/ScreenDynamicMask手动添加到Always Included Shaders列表。或者更保险的方案是代码里Shader.Find(Custom/ScreenDynamicMask)重新赋值给材质的shader放在Awake里做一次检查。注意动态Shader加载有轻微耗时千万别在OnRenderImage里每帧调用Shader.Find那会让主线程卡出明显的掉帧。这个坑在新手demo里不常见但一进正式项目必踩。6. 进阶与验证用Frame Debugger和CommandBuffer把这套方案做进正式管线这一章收尾放两个最实用的技巧怎么验证后处理真的按预期执行以及怎么在SRP管线下用CommandBuffer替换OnRenderImage。这两点是“demo能跑”和“能进正式包”之间的分界线。6.1 验证管线的执行顺序Frame Debugger是最好的黑匣子后处理的问题很难用眼睛直接找引擎渲染顺序是黑匣子。打开Window下的Frame Debugger进入单帧模式逐步事件往后翻能明确看到场景的DrawCall之后有没有出现我们挂载的Blit事件以及Blit前后的RT尺寸和Shader属性。我排查遮罩位置错乱时就是先在Frame Debugger里确认_Center传入的数值和屏幕坐标是否一致省掉了大量盲猜时间。建议养成习惯每次改完Shader后处理先开Frame Debugger看一遍事件流再调参数。6.2 CommandBuffer替换OnRenderImage为SRP迁移铺路Built-in管线下OnRenderImage好用但URP里它会被直接忽略。要保证方案能跟着工程升级用CommandBuffer把同一份材质挂到相机事件上改动量最小。下面这段脚本可以整体替换第三章的C#驱动器using UnityEngine; using UnityEngine.Rendering; public class CommandBufferMask : MonoBehaviour { public Material maskMaterial; public Transform followTarget; private CommandBuffer _cb; private Camera _cam; void OnEnable() { _cam GetComponentCamera(); _cb new CommandBuffer { name DynamicScreenMask }; int tempRT Shader.PropertyToID(_MaskTempRT); _cb.GetTemporaryRT(tempRT, -1, -1); _cb.Blit(BuiltinRenderTextureType.CameraTarget, tempRT, maskMaterial); _cb.Blit(tempRT, BuiltinRenderTextureType.CameraTarget); _cb.ReleaseTemporaryRT(tempRT); _cam.AddCommandBuffer(CameraEvent.AfterEverything, _cb); } void OnDisable() { if (_cb null) return; _cam.RemoveCommandBuffer(CameraEvent.AfterEverything, _cb); _cb.Release(); _cb null; } }这段脚本把遮罩挂到了AfterEverything时机即在所有相机渲染完成后执行。GetTemporaryRT临时RT的-1表示与屏幕同尺寸两个Blit先遮罩后回写ReleaseTemporaryRT释放避免内存累积。C#传参部分和前面一样关键是换了挂载方式Shader不用动。万一要用URP把CameraEvent.AfterEverything换成Rendering.RenderPipelineManager的相机事件方法挂上去Shader材质基本原样可用。我在项目里的习惯是Built-in先跑通然后立刻把C#换成CommandBuffer版防着后面管线升级时返工。这套动态屏幕遮罩的Shader核心逻辑不依赖管线API换到URP时成本很有限。最后一句话是自己的经验不是结论出正式版本前帮我反复对一下_Center的计算逻辑坐标系永远是这类Shader最容易骗人的地方。希望帮到你。本文还有配套的精品资源点击获取
返回列表