
简介该资源为Unity3D开发者提供一套完整的动态屏幕遮罩Shader实现方案适用于需要实现跟随目标物体的可视范围遮罩、边缘渐变、遮罩颜色自定义等效果的游戏或交互项目。压缩包仅含1个PDF文档大小约66KB文档内完整展示了Shader源码、C#调用代码以及效果图与控制面板示意并梳理了CGPROGRAM、Vertex/Fragment Shader、Properties、SubShader、Cull Off、ZWrite Off等核心知识点方便开发者理解关键原理并快速移植到自己的项目中。目前已有2394人学习下载适合具备一定Unity基础、希望深入掌握Shader屏幕特效实现的开发者参考学习。1. 动态屏幕遮罩先做对UV才谈得上“动起来”做技能范围提示的时候美术给了一张圆环贴图我按老办法放到UI上结果角色一动遮罩就穿帮多分辨率下还容易对不齐。换成Unity3D Shader实现动态屏幕遮罩后一个全屏Pass就把问题收掉了遮罩中心、半径、柔和度全部做成参数还能随时间呼吸或跟随鼠标。核心其实就两步屏幕空间的UV变换加一个mask权重没有任何黑科技。适合刚开始碰后处理和Shader的Unity开发者也适合不想被UI贴图反复调精度的开发者。下面我会拆一个能跑的OnRenderImage方案讲清楚原理、参数和翻车点。2. 屏幕UV与距离场动态屏幕遮罩的原理和选型理由一个全屏遮罩本质上是在相机渲染完成之后对每一帧屏幕颜色再做一次重新着色。这个过程在Unity里最常见的行为是OnRenderImage(source, destination)其中source是当前相机渲染结果destination是最终输出。Shader要做的事情只有一个把source当纹理按屏幕坐标采样再用一个权重把采样结果和遮罩颜色混合。这个权重就是遮罩。2.1 为什么是后处理而不是UI贴图UI方案也做遮罩但那是把遮罩图按UI树的层级叠加在界面上面依赖Canvas的合批逻辑。如果你要做Boss出场时屏幕中央一个圆孔逐渐扩大并且圆孔中心要跟着Boss走UI方案往往需要四张边缘图拼框或者用MaskableGraphic做反向镂空维护成本不低。屏幕后处理没有这些概念它拿到的是整张屏幕画面叫你显示什么就显示什么像素级精确。而且UI贴图在Canvas缩放和多分辨率适配下容易出现边缘错位。后处理方案直接工作在屏幕UV上中心点和半径都是归一化坐标换分辨率不会出现“模糊的圆跑偏”这种问题。我改造这个效果时把原来的8个UI节点删到只剩一个脚本和一个材质DrawCall也直接少了几条。这里有必要区分三种做法选型基本可以照抄方案实现方式优点主要限制UI贴图Canvas下放Alpha贴图直观、易编辑多分辨率适配麻烦、批次多、难做像素级动态SpriteMask配合SpriteRenderer可以遮Sprite只对Sprite有效UI和不透明物体不好办后处理ShaderOnRenderImage 材质像素级控制、开销稳定需要渲染管线支持无法单独作用于UI层如果你只是给某个UI按钮加一个点击反馈遮罩那用UI贴图就够了但凡是“全屏范围内、位置会变、形状会变”的动态屏幕遮罩后处理Shader是更省的方案。2.2 遮罩形状的本质用距离函数生成圆形遮罩后处理Shader拿到每个像素的UV坐标后先做一个宽高比修正。Unity的屏幕UV是左下角(0,0)、右上角(1,1)但实际屏幕不一定是正方形。如果直接算distance(i.uv, _Center)横向和纵向的像素数不一致会把一个半径0.25的圆拉伸成椭圆。修正方法是用屏幕宽高比把UV的x方向缩放或者缩放y方向这里用一个常用写法// 屏幕UV下圆形遮罩的雏形 float2 aspect float2(_ScreenParams.x / _ScreenParams.y, 1.0); float2 p (i.uv - _Center) * aspect; // 中心偏移后再做宽高比修正 float d length(p); // d 距离中心的欧氏距离 float mask 1.0 - smoothstep(_Radius, _Radius _Softness, d);这段代码里_Center是归一化的屏幕中心_Radius是圆形半径_Softness是边缘柔化宽度。smoothstep(a, b, x)在x小于a时返回0大于b时返回1中间按平滑曲线过渡前面加一个1 -是为了让mask在圆内为1、圆外为0。mask为1时显示原始屏幕mask为0时显示遮罩颜色中间过渡就是半影。边缘柔化宽度如果太小就会出现锯齿如果太大看起来就像水墨晕开需要按实际分辨率调整。这里“距离函数”是整套动态遮罩的地基。圆形用distance如果要椭圆修改aspect的系数如果要扇形再加一个角度判断如果要条带用abs(p.x)。总之都是对UV做数学运算最后产生一个0到1的灰度值。The Book of Shaders 里有很多2D的SDF和噪声练习本质上就是这套思路搬到Unity后处理里就是现成的动态屏幕遮罩。如果你想做反向遮罩——中央隐藏、四周显示直接把mask取反float mask 1.0 - mask;或者把lerp的第二个颜色和第三个颜色对调。这个操作在需求里经常会遇到别在Shader里写两套统一用同一个距离函数加一个_Invert开关最省事。2.3 动态遮罩的三种来源数值计算、采样贴图、外部RTT遮罩的mask不一定非要靠公式算出来。按项目需求可以分成三种输入来源第一是数值计算。距离、角度、时间、噪声、网格这些都能直接生成mask。适合规则形状比如Boss技能的范围预警圆、屏幕扫描线、马赛克烧屏。优点是每帧开销低、参数可控缺点是形状一旦复杂Shader代码里要堆很多数学公式。第二是采样贴图。美术画一张带Alpha的遮罩图在Shader里用tex2D(_MaskTex, i.uv).a作为mask。这种方式适合复杂美术轮廓比如地图迷雾的边界、血迹飞溅的斑块、二次元NPR卡通渲染里常用的装饰性暗角。缺点是纹理分辨率要和屏幕适配放大会糊而且美术多一张资源图要维护。第三是外部RTT。把另一个相机、或者Unity3D视频流、或者上一个后处理结果渲染到RenderTexture再把这个RT作为遮罩纹理放进当前Shader。这样遮罩的形状每帧都在变不受公式和静态贴图限制适合刮奖、区域战争迷雾、视频信号故障这类效果。代价是需要额外的RenderTexture分配和一次Pass的拷贝开销。选型时我的顺序是规则形状用数值计算不规则的静态形状用贴图不规则且动态的内容用RTT。如果你只是先把一个圆形遮罩跑通用数值计算最省事后面的动态控制也最好接。还有一个容易被忽略的边界后处理的目标载体。如果你要遮罩的是Screen Space Overlay的UI画层后处理是做不到的因为Overlay Canvas是在相机后处理之后单独渲染的。这时可以考虑把UI切换到Screen Space Camera或者用多个相机分层处理。这个边界决定方案选型别等做完才后悔。3. 用OnRenderImage跑通最小工程完整Shader与C#脚本在很多Unity教程里屏幕后处理只讲两个词OnRenderImage、Graphics.Blit。但这两个API在不同渲染管线下的行为差别很大。这里以Built-in管线为主如果你项目用的是URPOnRenderImage不会自动调用得改用Renderer Feature。这也是为什么很多照抄老教程的人发现自己画面完全没有变化。3.1 挂在相机上的C#脚本先解决“在哪跑”的问题后处理脚本必须挂在相机上由相机渲染结束后调用。C#脚本本身不决定任何特效它只负责创建材质、传递参数、触发Blit。一个最小脚本如下using UnityEngine; public class DynamicScreenMask : MonoBehaviour { public Shader shader; public Color maskColor new Color(0, 0, 0, 0.8f); public Vector2 center new Vector2(0.5f, 0.5f); public float radius 0.25f; public float softness 0.02f; private Material material; void OnEnable() { if (shader null) shader Shader.Find(Hidden/DynamicScreenMask); material new Material(shader); } void OnDisable() { if (material ! null) DestroyImmediate(material); } void OnRenderImage(RenderTexture source, RenderTexture destination) { if (material null) { Graphics.Blit(source, destination); return; } material.SetColor(_MaskColor, maskColor); material.SetVector(_Center, center); material.SetFloat(_Radius, radius); material.SetFloat(_Softness, Mathf.Max(0f, softness)); Graphics.Blit(source, destination, material); } }这里有一个我早期的翻车点我在OnRenderImage里每次new Material结果每帧都会创建一个新材质GC开销大不说偶尔还会让帧率抖成心电图。现在改成OnEnable时创建、OnDisable时销毁保证整个生命周期只有一个材质。Shader.Find也是字符串查找切换后台或热重载时有可能返回空所以我推荐把Shader字段直接拖到Inspector而不是靠Find。参数含义center和radius都在屏幕归一化坐标系里0到1之间取值范围和Shader里的属性对应。maskColor是遮罩颜色但注意我们Shader里最后输出的alpha始终是1因为屏幕后处理输出的是不透明帧遮罩的“透明度”通过权重来决定而不是Alpha混合。提示如果项目用的是URP请确认当前相机上没有挂载Custom Render Pipeline相关组件否则这个脚本不会产生任何效果。3.2 最小Shader完整代码和关键行拆解在Project里创建一个名为DynamicScreenMask的Shader粘贴下面的HLSL/CG代码。这是和上面C#脚本配套的例子Shader Hidden/DynamicScreenMask { Properties { _MainTex (Screen, 2D) white {} _MaskColor (Mask Color, Color) (0,0,0,0.8) _Center (Center, Vector) (0.5,0.5,0,0) _Radius (Radius, Range(0,1)) 0.25 _Softness (Softness, Range(0,0.5)) 0.02 } SubShader { Tags { RenderTypeOpaque } Pass { ZWrite Off Cull Off CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; float4 _MaskColor; float2 _Center; float _Radius; float _Softness; v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv v.uv; return o; } fixed4 frag (v2f i) : SV_Target { // 修正宽高比让圆形遮罩不被拉成椭圆 float2 aspect float2(_ScreenParams.x / _ScreenParams.y, 1.0); float2 p (i.uv - _Center) * aspect; // 距离场得到0~1之间的mask float d length(p); float mask 1.0 - smoothstep(_Radius, _Radius _Softness, d); // 用mask把屏幕原色和遮罩色做插值 float3 col lerp(_MaskColor.rgb, tex2D(_MainTex, i.uv).rgb, mask); return fixed4(col, 1.0); } ENDCG } } Fallback Off }逻辑很简单vert把全屏三角形铺满裁剪空间frag针对每个像素执行一遍。_MainTex由Unity自动绑定在OnRenderImage时它就是source纹理。_ScreenParams.x / _ScreenParams.y算的是宽高比。比如屏幕是1920x1080aspect.x约1.78p.x会被放大于是原本扁的椭圆会被拉回正圆。中心点偏移在修正宽高比之前所以中心点的相对位置不会因分辨率变化而偏移。smoothstep的三个参数都只依赖_Radius和_Softness意味着只要调这两个Property边缘宽度立刻变化。很多初学Shader的开发者会问为什么遮罩是“全屏”而不是一个圆形物体因为这里整个pass都是全屏三角形每个像素都执行了fragmask1的像素显示原图mask0的像素显示遮罩色。圆只是视觉上看起来“亮区”。3.3 挂载与调参怎么让这个工程立刻出效果场景搭建步骤很简单新建一个场景把Main Camera的Clear Flags设成Solid Color找一个带文字或模型的画面。然后把上面DynamicScreenMask脚本挂到Main Camera上Shader字段不填也能运行OnEnable里会Search Find但为了稳妥建议在Project里创建材质把材质拖到一个公开material字段上。由于脚本里只有shader字段我一般会手动加一个public Material material字段把Shader预先设置好的材质拖进去这样可以直接在材质面板调参数。如果你像我一样偷懒直接在脚本面板调整center、radius、softness三个公开字段也行。以下是三个关键参数的调节经验参数默认值效果表现调节注意center(0.5,0.5)遮罩圆心超出0~1范围会导致圆被截断故意做边缘露光时可利用radius0.25圆形亮区半径结合aspect后是横向半径纵向范围会小一点softness0.02边缘过渡宽度越接近0越硬边移动端建议至少0.01以上检查是否生效的方法把radius调成0.1center调成(0.5,0.5)你会看到屏幕中央出现一个亮圆、周围是暗边。然后把softness调到0.2边缘会像雾气一样散开。这是最基本的动态屏幕遮罩接下来我们就可以把radius和center做成实时变化的。4. 让遮罩动起来时间驱动、鼠标跟随和视频流三种实时控制动态的本质就是让Shader的这个mask每帧都不一样。最直接的方式是脚本在每一帧修改参数或者在Shader内部用Unity内置的时间变量。下面三种方式都值得单独落地它们分别对应不同交互需求。4.1 _Time驱动呼吸、扫光和开关过渡在Shader里可以直接用UnityCG.cginc自带的_Time。它是一个float4_Time.y是从场景加载起经过的秒数。做呼吸效果只需要让半径随时间波动// 呼吸圆半径在原始半径上下做正弦变化 float pulse 0.5 0.5 * sin(_Time.y * 2.0); float r _Radius * (0.8 0.2 * pulse); // 后面用r代替_Radius参与smoothstep这样遮罩会像心跳一样一收一放。sin(_Time.y * 2.0)的周期约3.14秒数值范围是-1到1先让它变成0到1的脉冲再映射到半径的0.8到1.2倍就能得到类似呼吸的节奏。想调快一点就把系数改成4.0想慢就改成0.8。如果要做扫描线式的动态遮罩可以在C#脚本里用PingPong函数直接驱动radiusfloat t Mathf.PingPong(Time.time, 1f); float currentRadius Mathf.Lerp(minRadius, maxRadius, t); material.SetFloat(_Radius, currentRadius);这种方式适合“技能进入CD时圆环慢慢收拢”之类的状态条。要注意的是CPU每帧SetFloat的值很小但尽量在Update里做不要把这个计算放进OnRenderImage里做避免渲染期间做不必要的时间计算。4.2 把鼠标或角色坐标换算成屏幕UV让遮罩跟着目标走动态遮罩最常用的功能是“跟着目标走”比如角色身边的探照灯。做法是把目标位置转成屏幕UV再传给_Center。如果目标是鼠标代码很直白// 鼠标坐标转屏幕UV鼠标原点在左下角和Shader UV一致 Vector2 mouseUV new Vector2( Input.mousePosition.x / Screen.width, Input.mousePosition.y / Screen.height ); material.SetVector(_Center, mouseUV);如果目标是场景里的一个模型需要先通过摄像机转成视口坐标Vector3 viewportPos Camera.main.WorldToViewportPoint(target.position); // 如果相机没有翻转这里直接就是0~1的屏幕UV material.SetVector(_Center, new Vector2(viewportPos.x, viewportPos.y));这里有一个容易翻车的点WorldToViewportPoint返回的是视口坐标它和屏幕UV只差一个分辨率缩放但如果你用了Flip或镜像相机Y轴可能是反的。另外如果你的目标在相机背后viewportPos的z会是负数这时即使x,y在0~1范围内也要做遮挡判断否则遮罩会“穿”到屏幕对面。处理办法是判断viewportPos.z 0时直接跳过或让遮罩失效。4.3 用Unity3D视频流或RenderTexture当遮罩源打破公式限制数值计算和静态贴图能覆盖大部分场景但有些需求要求遮罩本身也在实时变化。比如做个刮刮乐用户刮开区域就是遮罩被擦除这用公式和贴图都没法低成本实现。常见做法是把“动态遮罩”的数据来源换成RenderTexture另一个相机先渲染一个只画遮罩的层或者VideoPlayer把视频流输出到RenderTexture再把这个纹理作为_MaskTex传给Shader。实际操作分三步先在场景里创建RenderTexture把VideoPlayer的targetTexture指到它再在DynamicScreenMask脚本里加一个公开的Texture maskTex每个时间帧把视频帧赋给材质public RenderTexture maskRT; maskRT renderTexture; // 把外部RTT赋值进来 material.SetTexture(_MaskTex, maskRT);Shader里的frag对应片段如下// 采样遮罩纹理的红色通道作为mask float mask tex2D(_MaskTex, i.uv).r; // 如果想边缘更利落对mask做一次阈值 mask smoothstep(0.1, 0.5, mask);为什么用红色通道因为视频流或RTT往往是彩色图如果直接拿它做混合会出现颜色污染。只取r通道等于把灰度/Alpha信息抽出来得到黑白遮罩。如果你渲染的是带透明通道的格式直接采样a更好。用Unity3D视频流做遮罩的好处是视频的每一帧天然就是一个动态时间轴遮罩的变化根本不需要你写任何动画逻辑坏处是RTT的分辨率、格式和内存都要额外维护别一股脑开到1920x1080如果只是边缘形状用512x512足够。到这里遮罩已经能跟着任意参数动起来。但动态效果最容易出的不是功能问题而是画面表现问题。下面整理几个高频翻车现场。5. 避坑与排查动态屏幕遮罩的4个翻车现场写了几个月后处理90%的问题不在Shader语法而在坐标系、生命周期和渲染管线的边界条件。下面4条是按真实频率排的遇到问题先对号入座。5.1 现象遮罩中心和鼠标/角色位置怎么都对不上原因坐标原点不一致。Unity的鼠标坐标Input.mousePosition和Shader的UV原点都在左下角所以直接转换能对上但如果你用Screen Space - Camera模式的CanvasUI坐标经过了Canvas缩放或者你用Camera.ScreenToViewportPoint后又在脚本里加了偏移位置就会整体偏掉。解决先固定一个标准坐标系。后处理Shader使用的是视口坐标所以从任何输入转换到视口坐标后不要再做二次缩放。鼠标坐标除以屏幕宽高就能得到视口坐标角色坐标用WorldToViewportPoint得到的就是视口坐标。如果这样还是偏检查相机的targetTexture是不是存在或者是否开启了垂直翻转。必要时在C#里打印出传入的_Center值再用同屏渲染一个Debug小球看小球位置和遮罩圆心是否重叠。这是最直接的定位方式。5.2 现象屏幕整体变成一片纯色既看不到原图也看不到遮罩原因mask计算反了导致所有像素都落在遮罩侧。最常见的是smoothstep的参数顺序写反或者lerp的两个颜色顺序反了。比如我早期写成lerp(originalColor, maskColor, mask)圆内离原图更近圆外显示遮罩色虽然看起来很像反向遮罩但至少能看到内容如果你把smoothstep(_Radius _Softness, _Radius, d)会让d越大mask越小最后所有像素的mask都接近0整个屏幕都被_MaskColor覆盖。解决先固定一个最小实验。把_Center设成(0.5, 0.5)_Radius设成0.2_Softness设成0.05然后用FrameDebugger查看这一个Pass的颜色输出。如果画面依旧纯色把_MaskColor调成红色你会发现整个画面变成红色说明mask在绝大多数像素上为0问题一定出在mask的数学表达而不是Blit管线。用step(_Radius, d)这种简单函数替换smoothstep先确认距离d的数值范围。5.3 现象动态效果一顿一顿的像PPT原因CPU和GPU之间发生同步阻塞或脚本每帧触发了高成本操作。常见行为是在Update里每帧调用new Material、在OnRenderImage里调用RenderTexture.GetPixels或者给Shader属性赋值时使用了字符串查找而不是Shader.PropertyToID。字符串查找在低版本Unity里确实会小幅引起GC但更多时候是GetPixels这类回读操作把CPU停住了。解决把材质缓存成字段在OnEnable创建OnDisable销毁所有属性ID用静态缓存的整数比如private static readonly int CenterID Shader.PropertyToID(_Center);。脚本这边只做SetVector/SetFloat不做GetPixels。如果卡顿还是存在打开Profiler的GPU帧时间看看重点看Camera.Render里的RenderPass。大部分Unity后处理的卡顿是玄学其实是被别的深拷贝流程拖累的。5.4 现象移动端边缘锯齿明显发热还高原因后处理是全屏像素计算软边宽度太小会直接表现为锯齿同时全分辨率RT会让像素数量接近4K发热自然上来。另一个原因是某些移动GPU对smoothstep精度不高半影区域会出现明显的色阶断层。解决调整_Softness到0.03以上如果需要非常硬边不如用step配合fwidth做一个抗锯齿float aa fwidth(d); float mask 1.0 - smoothstep(_Radius - aa, _Radius aa, d);这样边缘宽度会根据屏幕像素密度自适应。移动端性能方面可以先通过RenderTexture.GetTemporary把后处理降到屏幕分辨率的0.5倍Blit完成后再放大视觉损失通常不明显但发热和耗电能降一大截。这是我从多个上线项目里得到的血泪经验。6. 进阶用Gradient贴图柔边再用FrameDebugger验证开销如果把上面整套方案跑通动态屏幕遮罩已经能应付大多数交互需求。剩下要打磨的是“边缘好不好看”和“到底值多少性能”。6.1 用Gradient贴图代替smoothstep做柔边smoothstep是一段固定的平滑曲线它的问题是“所有软边看起来都一样”。很多项目中特效边缘需要露出少量噪点或羽化直接用公式写会很痛苦。常见做法是准备一张1D的Gradient贴图用遮罩的归一化距离查表。比如float t clamp(length(p) / _Radius, 0.0, 1.0); float mask tex2D(_EdgeGradient, float2(t, 0.5)).r;这样你可以把边缘过渡曲线完全交给美术中部是硬边尾部带一点虚线都能在纹理里画出来。如果不想引入美术资源也可以用AnimationCurve在脚本里生成一张Texture2D这样参数仍然留在C#面板上。6.2 用FrameDebugger验证这个Pass到底花了多少GPU时间验证要敢于相信工具。打开Window Analysis Frame Debugger点击截帧在RenderPass列表里找到DynamicScreenMask这个Pass。如果它出现了两次说明有额外的Blit如果没出现说明Shader没被正确渲染先去检查C#脚本是不是挂在非主相机上。配合Profiler可以看到GPU总耗时但我更推荐先用FrameDebugger看pass数量因为后处理的开销大部分奉送给了全屏像素填充。如果你发现DrawCall数量只增加了1但帧率有明显下降大概率是分辨率太高或RT分配过多先把后处理目标降一半。6.3 一个Shader多形态用Keyword切换三种遮罩模式做动态屏幕遮罩时间长了你会发现公式、贴图、RTT三种方式其实可以共用一套Shader用Keyword切分支。#pragma multi_compile MASK_DISTANCE MASK_TEXTURE MASK_RTC#里用Material.EnableKeyword切换不同模式下只改mask来源混合逻辑统一。这样同一套脚本可以服务技能范围、关卡迷雾和视频故障特效不用为每一个需求复制一个Shader文件。我现在的习惯是拿到“动态屏幕遮罩”需求时先问三个问题什么坐标系什么渲染管线遮罩要不要每帧更新这三个答案直接决定了后面代码怎么写。早期我不问上来就改Shader结果在URP项目里白白折腾了半个下午。希望这些排查顺序和参数经验帮到你省下一段不必要的翻车时间。本文还有配套的精品资源点击获取