☰
Unity3D动态屏幕遮罩Shader实战:从抓屏到距离场
2026/9/30 3:01:37 网站建设 项目流程

简介:本资源面向Unity3D开发者和Shader初学者,提供一套用Shader实现动态屏幕遮罩的完整方案,可解决屏幕可视范围跟随目标物体移动、边缘渐变与遮罩颜色自定义等常见需求,适用于游戏视野限制、聚光灯效果或镜头聚焦等场景。压缩包内共1个PDF文件,约66KB,以图文形式讲解Shader代码与C#调用逻辑,涵盖Properties、SubShader、Pass、CGPROGRAM、顶点与片元着色器、Cull Off、ZWrite Off、ZTest Always、Sampler、Struct等关键概念,并给出CalcAlpha函数计算遮罩透明度的实现思路。当前版本支持最多9个物体,可修改可视范围大小、边缘渐变大小及遮罩颜色,Shader中通过ItemSize宏调整上限。已有2394人学习,读者可从中掌握动态遮罩的编写方法、屏幕坐标转换与多目标混合逻辑,并参考C#脚本完成相机挂载与参数配置,适合需要快速落地屏幕遮罩效果的中级开发者。

1. 动态屏幕遮罩:从“贴一张图”到“算一片场”的认知跃迁

很多做 Unity3D 游戏开发的朋友第一次接到“屏幕遮罩”需求时,第一反应是往 UI 上盖一张带透明通道的 PNG。这招在静态场景里能跑,一旦遇到镜头推拉、角色移动或者遮罩边缘需要呼吸感,立刻翻车——图片边缘会跟着屏幕缩放糊成锯齿,遮罩位置和世界物体对不上,改一次需求就得重新导一张图。动态屏幕遮罩要解决的核心问题是:让遮罩的形状、位置、软硬边在每一帧都由 Shader 实时计算,而不是靠美术预先画死。它适合做场景切换的圆形聚焦、角色受击时的屏幕暗角、扫描雷达的扩散波纹,以及技能释放时的全屏扭曲边界。这篇文章不讲空泛的图形学概论,只拆一条能直接复现的路径:从抓取屏幕纹理开始,到在 Shader 里用屏幕 UV 和距离场算出遮罩,再到把参数暴露给 Timeline 或代码做动态驱动。读完你能拿到一套可抄的 Shader 骨架、三个必调参数的取值逻辑,以及我在真机适配时踩过的五个坑。

2. 屏幕遮罩的底层账本:抓屏、UV 与距离场

2.1 为什么必须用 GrabPass 或 _CameraOpaqueTexture

动态屏幕遮罩的本质是“对已经渲染好的画面做二次加工”。如果直接在 UI 层盖半透明图,你拿到的是 UI 自己的混合结果,而不是场景颜色。常见做法有两种:内置渲染管线里用GrabPass抓取当前帧的屏幕纹理,URP 里则用_CameraOpaqueTexture配合Camera的Opaque Texture选项。两者的区别在于 GrabPass 每帧触发一次额外的纹理拷贝,在移动端是实打实的带宽开销;URP 的方案由管线统一管理,更可控。

我一般会先确认项目管线。如果是 URP,直接在 Shader 里声明sampler2D _CameraOpaqueTexture;,然后确保场景里至少有一个 Camera 勾选了 Opaque Texture。如果是内置管线,GrabPass 的写法如下:

GrabPass { "_ScreenTex" } sampler2D _ScreenTex; float4 _ScreenTex_TexelSize; v2f vert (appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.grabPos = ComputeGrabScreenPos(o.pos); return o; } fixed4 frag (v2f i) : SV_Target { float2 screenUV = i.grabPos.xy / i.grabPos.w; fixed4 screenColor = tex2D(_ScreenTex, screenUV); return screenColor; }

这段代码的逻辑是:ComputeGrabScreenPos把裁剪空间坐标转成抓屏纹理的采样坐标,grabPos.w做透视除法得到归一化 UV。参数上,_ScreenTex_TexelSize的xy是纹理尺寸的倒数,后面做边缘柔化时会用到。注意 GrabPass 的名字要和 Shader 里声明的 sampler 一致,否则抓不到图,屏幕会直接黑掉——这是新手最常遇到的玄学问题之一。

2.2 屏幕 UV 和世界坐标的换算关系

遮罩要跟着世界物体走,就必须把世界坐标投影到屏幕 UV。做法是在顶点着色器里算好裁剪空间坐标,片元里做透视除法:

float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; float4 clipPos = mul(UNITY_MATRIX_VP, float4(worldPos, 1.0)); float2 screenUV = clipPos.xy / clipPos.w * 0.5 + 0.5;

这里* 0.5 + 0.5是把 NDC 的 [-1,1] 映射到 UV 的 [0,1]。如果遮罩中心要绑定某个角色,就把角色的世界坐标传进 Shader,用distance(screenUV, centerUV)算距离场。注意屏幕宽高比:直接算欧氏距离在非正方形屏幕上会得到椭圆,需要乘一个float2(_ScreenParams.x / _ScreenParams.y, 1.0)做修正。这个修正是很多“遮罩看起来扁了”问题的根因。

2.3 距离场遮罩的数学形式与软硬边控制

距离场遮罩的核心公式就一行:

float mask = 1.0 - smoothstep(_Radius - _Softness, _Radius + _Softness, dist);

_Radius控制遮罩半径,_Softness控制边缘过渡宽度。smoothstep的好处是过渡曲线平滑,不会出现硬切。如果要做反向遮罩(中间透明、四周黑),把结果用1.0 - mask即可。参数取值上,_Radius一般按屏幕短边比例给,比如 0.3 到 0.5;_Softness建议是_Radius的 5% 到 15%,太小会有锯齿,太大遮罩会糊成一片没有聚焦感。我通常会把这两个参数暴露到材质面板,方便美术在 Scene 视图里实时拖。

提示:smoothstep的第二个参数如果小于第一个参数,结果会反转,这是 HLSL 的规范行为,不是 bug。做反向遮罩时可以直接交换参数,省一次减法。

3. 从零搭一个可复现的动态屏幕遮罩 Shader

3.1 完整 Shader 骨架与逐段说明

下面这份是内置渲染管线可用的完整骨架,URP 版本只需把 GrabPass 换成_CameraOpaqueTexture声明,其余逻辑通用。

Shader "Custom/DynamicScreenMask" { Properties { _Radius ("Mask Radius", Range(0, 1)) = 0.4 _Softness ("Edge Softness", Range(0.001, 0.2)) = 0.05 _Center ("Center UV", Vector) = (0.5, 0.5, 0, 0) _MaskColor ("Outside Color", Color) = (0, 0, 0, 1) _Aspect ("Aspect Fix", Float) = 1.0 } SubShader { Tags { "Queue"="Overlay" "RenderType"="Opaque" } GrabPass { "_ScreenTex" } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" sampler2D _ScreenTex; float4 _ScreenTex_TexelSize; float _Radius; float _Softness; float4 _Center; fixed4 _MaskColor; float _Aspect; struct appdata { float4 vertex : POSITION; }; struct v2f { float4 pos : SV_POSITION; float4 grabPos : TEXCOORD0; }; v2f vert (appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.grabPos = ComputeGrabScreenPos(o.pos); return o; } fixed4 frag (v2f i) : SV_Target { float2 screenUV = i.grabPos.xy / i.grabPos.w; fixed4 sceneColor = tex2D(_ScreenTex, screenUV); float2 delta = screenUV - _Center.xy; delta.x *= _Aspect; float dist = length(delta); float mask = 1.0 - smoothstep(_Radius - _Softness, _Radius + _Softness, dist); fixed3 finalColor = lerp(_MaskColor.rgb, sceneColor.rgb, mask); return fixed4(finalColor, 1.0); } ENDCG } } }

逐段看:Properties 里_Center用 Vector 是为了在代码里能直接传new Vector4(x, y, 0, 0);_Aspect由脚本每帧写入Screen.width / Screen.height。顶点着色器只做两件事——裁剪空间变换和抓屏坐标计算。片元里先采样屏幕颜色,再算距离场,最后用lerp混合遮罩色和场景色。注意Queue设为 Overlay 保证它在所有不透明物体之后绘制,否则抓到的屏幕纹理不完整。

3.2 用 C# 脚本驱动遮罩中心与半径

Shader 写好了,得有人喂参数。下面这个脚本挂在场景里任意 GameObject 上,把目标物体的世界坐标转成屏幕 UV 传给材质:

using UnityEngine; public class ScreenMaskDriver : MonoBehaviour { public Transform target; public Material maskMaterial; public float radius = 0.4f; public float softness = 0.05f; void Update() { if (target == null || maskMaterial == null) return; Vector3 screenPoint = Camera.main.WorldToScreenPoint(target.position); float u = screenPoint.x / Screen.width; float v = screenPoint.y / Screen.height; maskMaterial.SetVector("_Center", new Vector4(u, v, 0, 0)); maskMaterial.SetFloat("_Radius", radius); maskMaterial.SetFloat("_Softness", softness); maskMaterial.SetFloat("_Aspect", (float)Screen.width / Screen.height); } }

逻辑说明:WorldToScreenPoint返回的是像素坐标,除以Screen.width/height得到 [0,1] 的 UV。参数上,radius和softness可以在 Inspector 里实时调,_Aspect每帧更新是为了应对窗口缩放。如果目标在相机背后,screenPoint.z会小于 0,此时 UV 会翻转,需要加一个判断把遮罩隐藏或钳制到边缘。这个脚本的更新频率是每帧,如果场景里有大量遮罩实例,建议改成事件驱动,只在目标移动时更新。

3.3 把遮罩挂到全屏:Blit 与 CommandBuffer 的取舍

上面的 Shader 挂在一个 Quad 上也能跑,但更干净的做法是用Camera的OnRenderImage做全屏 Blit:

void OnRenderImage(RenderTexture src, RenderTexture dst) { Graphics.Blit(src, dst, maskMaterial); }

这样 Shader 里就不需要 GrabPass 了,直接用_MainTex采样src。CommandBuffer 的方案更灵活,可以插在渲染管线的特定阶段,但代码复杂度高,适合需要和后期栈深度集成的项目。我一般先上OnRenderImage,性能不够再换 CommandBuffer。注意OnRenderImage要求脚本挂在有 Camera 组件的物体上,且 Camera 不能是 Overlay 模式。

注意:如果项目用了 URP 的 Renderer Feature,OnRenderImage不会被调用,需要写ScriptableRenderPass。这是管线切换时最容易漏掉的适配点。

4. 参数调优与真机适配的五个血泪坑

4.1 坑一:遮罩边缘在移动端出现锯齿

现象是编辑器里边缘平滑,打包到手机后锯齿明显。原因是移动端默认没有开 MSAA,而smoothstep的过渡宽度在低分辨率下不够覆盖一个像素。解决办法是把_Softness的下限从 0.001 提到 0.01 以上,或者用fwidth(dist)做屏幕空间自适应:

float aa = fwidth(dist) * 1.5; float mask = 1.0 - smoothstep(_Radius - aa, _Radius + aa, dist);

fwidth返回的是相邻像素间距离场的变化率,乘 1.5 是经验系数,能保证过渡至少覆盖一个半像素。

4.2 坑二:GrabPass 在部分安卓机型上抓不到图

现象是屏幕全黑或显示上一帧的残留。原因是某些安卓 GPU 对 GrabPass 的支持不完整,尤其是 GLES 2.0 设备。解决路径有两条:一是升级到 GLES 3.0 并在 Player Settings 里勾选 Auto Graphics API 时把 GLES3 排在前面;二是改用_CameraOpaqueTexture或手动Graphics.Blit到一张 RenderTexture。我现在的习惯是移动端项目直接放弃 GrabPass,用 RenderTexture 方案,虽然多一步管理,但兼容性稳得多。

4.3 坑三:遮罩中心在屏幕旋转后偏移

现象是横屏切竖屏后,遮罩跑到屏幕外。原因是_Center存的是归一化 UV,但旋转后Screen.width和Screen.height互换,之前算好的 UV 不再对应同一个世界点。解决办法是在Update里每帧重算 UV,而不是只在Start里算一次。另外_Aspect也要跟着更新,否则距离场会被拉伸。

4.4 坑四:多个遮罩叠加时互相覆盖

现象是两个遮罩重叠区域颜色不对。原因是每个遮罩都独立采样屏幕纹理再输出,后画的会覆盖先画的。正确做法是把多个遮罩的距离场合并成一个,取min或max后再做一次smoothstep。如果遮罩数量动态变化,可以用一个float数组传进 Shader,在片元里循环算最小值。注意数组长度要固定,移动端不建议超过 8 个。

4.5 坑五:遮罩色和场景色的混合模式选错

现象是遮罩区域看起来发灰或过曝。原因是lerp是在线性空间还是伽马空间做的。Unity 默认在伽马空间渲染,如果项目开了 Linear Color Space,lerp的结果会偏暗。解决办法是在片元里先做LinearToSRGB转换,或者直接用mix配合pow调整。我一般会在材质面板加一个_GammaFix开关,真机上看效果再决定。

5. 进阶:用遮罩做场景切换与性能兜底

5.1 把遮罩半径接到 Timeline 做转场动画

动态屏幕遮罩最出效果的地方是场景切换。把_Radius从 0 拉到 1.5,配合_Softness从 0.05 收到 0.01,能做出“光圈收拢再打开”的转场。具体做法是在 Timeline 里加一个PlayableDirector,用Animation Track驱动一个挂载了ScreenMaskDriver的 GameObject,把radius和softness打成关键帧。注意 Timeline 驱动的是脚本字段,不是材质属性,所以脚本里要把这两个值实时SetFloat到材质。如果转场时相机在移动,_Center也要跟着做关键帧,否则遮罩会飘。

5.2 半分辨率渲染遮罩以降低带宽

全屏 Blit 在移动端是带宽大户。一个实用的优化是把遮罩渲染到半分辨率 RenderTexture,再在最终 Blit 时用双线性采样放大。因为遮罩本身是低频信号(大块颜色过渡),半分辨率几乎看不出差别,但带宽能省一半。具体操作是在OnRenderImage里创建一张Screen.width/2的 RT,把遮罩 Shader 渲染进去,再用一张简单的 Blit Shader 把它和屏幕纹理混合。注意 RT 的filterMode要设成Bilinear,否则放大后会有块状感。

5.3 验证遮罩正确性的三个检查点

第一,在 Scene 视图里把相机切到 Game 视图,拖动目标物体,遮罩中心应该严格跟随,没有延迟或偏移。第二,把_Softness拉到 0,遮罩边缘应该是硬切但无锯齿;拉到 0.2,边缘应该平滑过渡到全屏。第三,在真机上旋转屏幕,遮罩形状应该保持圆形,不变成椭圆。这三个检查点过了,基本就能交付。

参数推荐范围作用调错后果
_Radius0.3 ~ 0.5遮罩半径太小看不清场景,太大没有聚焦感
_Softness0.01 ~ 0.15边缘过渡宽度太小锯齿,太大糊成一片
_Aspect每帧写入宽高比修正不写遮罩变椭圆
_Center每帧重算遮罩中心 UV不重算旋转后偏移

我自己的习惯是,每接一个遮罩需求,先问清楚是“跟物体”还是“跟屏幕固定点”。跟物体的,中心每帧算;跟屏幕的,中心写死但宽高比要每帧更新。这个判断做对了,后面能省掉一半的调试时间。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询