简介这是一份面向 Unity3D 开发者的 Shader 特效学习文档讲解如何用 Shader 实现可跟随目标物体移动的动态屏幕遮罩效果。屏幕可视范围会随物体位置实时变化并支持调节可视范围大小、边缘渐变宽度和遮罩颜色最大追踪物体数量可在 Shader 中修改当前版本默认支持 9 个物体。文档为单个 PDF 文件压缩包仅 66KB体积小巧但内容集中涵盖 Shader 语言、CGPROGRAM、Vertex Shader、Fragment Shader、Properties、SubShader、Cull Off、ZWrite Off、ZTest Always 等关键概念并对每个模块的作用、渲染模式与常用设置做了清晰说明。资料中还包含 DarkEffect Shader 完整代码和对应的 C# 调用示例演示如何通过 WorldToScreenPoint 将目标物体坐标传入 Shader并结合 CalcAlpha 函数计算边缘渐变 alpha适合想快速实现动态屏幕遮罩并理解底层原理的 Unity 学习者对照实践也可作为 Shader 入门和屏幕后处理效果的整理笔记。已有 2394 人浏览学习对游戏特效和交互视觉开发场景有直接参考价值。1. 为什么偏偏是后处理Unity3D Shader动态屏幕遮罩解决了什么做 Unity3D 项目时总有几个场景想让“屏幕某块区域亮着周围暗下去”主角脚下的探照灯、塔防里未探索的迷雾、悬疑游戏里聚焦关键线索。第一反应往往是用网格模型做遮挡或者拿几张 Quad 拼边界不出三次就会因为摄像机移动、物体重叠、边缘锯齿而放弃。这份“Unity3D Shader动态屏幕遮罩”走的是一条更省事的路把整帧画面当作后处理材质C# 侧记录目标物体的屏幕坐标Shader 里按像素算距离并混合暗色。可视范围跟随目标移动、半径可调、边缘渐变可调、颜色可调最多支持 9 个目标。适合做视觉聚焦、探索迷雾、暗区高亮这类屏幕空间效果的小团队和独立开发。2. 全屏后处理遮罩的原理Cull Off、ZWrite Off 与距离衰减2.1 后处理为什么天生适合做这类遮罩屏幕遮罩如果走物体遮挡路线最麻烦的是要预先知道“哪些像素被什么物体挡住了”这需要深度贴图或额外的 RenderTexture复杂度直接上一个台阶。后处理方案把整帧渲染结果当作输入纹理在最后一个环节对每个像素做一次颜色混合不需要关心场景里有多少物体、谁在前面谁在后面天然就是屏幕空间的。这个方案的入口是 MonoBehaviour 的OnRenderImagevoid OnRenderImage(RenderTexture source, RenderTexture destination) { // source 是相机渲染完的整帧画面 // destination 是最终输出到屏幕的目标 Graphics.Blit(source, destination, _mainMaterial); }Graphics.Blit会把source作为_MainTex绑定到材质上然后绘制一个覆盖全屏的四边形。Shader 的顶点着色器只需要把模型空间顶点转到裁剪空间真正干活的是片元着色器。这也是这类 Shader 结构里总是看到Cull Off ZWrite Off ZTest Always的原因Cull Off不剔除任何面避免后处理四边形因为朝向问题被裁掉。ZWrite Off不写入深度缓冲不会污染场景深度。ZTest Always深度测试永远通过保证只要屏幕上有像素就会执行片元着色器。这三行是后处理 Shader 的标准配置少一个都可能出现“屏幕黑一块”或者“被场景物体遮挡导致效果丢失”的怪问题。当初我拿到这份资源时先扫了一眼这三个状态确认是正经的后处理写法才继续往下看。2.2 CalcAlpha 里的三段判断与渐变数学遮罩的核心是CalcAlpha函数给定某个屏幕像素坐标和某个目标物体的坐标判断这个像素应该被遮到什么程度。它的算法不是简单的“圆内亮、圆外暗”而是分成三段fixed CalcAlpha(float4 vt, float4 pt) { // pt.z 0 表示这个目标无效直接返回全暗 if(pt.z 0) { return 1; } // 计算屏幕空间欧氏距离 float distPow2 pow(vt.x - pt.x, 2) pow(vt.y - pt.y, 2); float dist (distPow2 0) ? sqrt(distPow2) : 0; float smoothLength _SmoothLength; if(smoothLength 0) { smoothLength 0; } // 半径是 pt.z渐变区间从半径往回推 smoothLength float maxValue pt.z; float minValue pt.z - smoothLength; if(minValue 0) { minValue 0; smoothLength pt.z; } // 完全在可视圆内 - 不遮罩 if(dist minValue) { return 0; } // 完全在渐变范围外 - 全遮罩 else if (dist maxValue) { return 1; } // 处于渐变带中 - 按线性比例输出 alpha fixed retVal (dist - minValue) / smoothLength; return retVal; }return 0表示“这里完全不遮”return 1表示“这里用遮罩颜色盖住”。中间那段(dist - minValue) / smoothLength就是把距离映射到 0 到 1 的线性渐变。minValue小于 0 时的处理很关键如果_SmoothLength比半径还大渐变区间会延伸到圆心左侧变成负数这时候把minValue钳到 0渐变长度收窄为半径值避免出现“圆心里还有渐变”的错觉。这个函数的两个输入参数格式很特殊第一个vt是当前像素的屏幕坐标来自顶点着色器插值第二个pt的 x、y 是目标的屏幕坐标z 是可视半径w 暂时没用。理解了这个数据结构后面 C# 端组织_Item数组时就不会乱。3. 落地复现把这份 Shader 和 C# 脚本完整拆开3.1 片元着色器遍历目标并取最小不透明度Shader 完整代码是这样的关键部分我加了注释Shader Peter/DarkEffect { Properties { _MainTex (Texture, 2D) white {} } SubShader { // 后处理三件套关剔除、不写深度、深度测试永远过 Cull Off ZWrite Off ZTest Always Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc // 追踪物体最多个数这是硬上限 #define ItemSize 9 struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; fixed4 _DarkColor; // 遮罩颜色 float _SmoothLength; // 边缘渐变像素数量 fixed _ItemCnt; // 有效目标数量 float4 _Item[ItemSize]; // 目标数据数组 v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv v.uv; return o; } fixed CalcAlpha(float4 vt, float4 pt) { // 代码同上一节此处省略重复 } fixed4 frag (v2f i) : SV_Target { fixed alphaVal 1; fixed tmpVal 1; // 遍历所有目标取最小的遮罩强度 for(fixed index 0; index _ItemCnt; index) { tmpVal CalcAlpha(i.vertex, _Item[index]); if(tmpVal alphaVal) { alphaVal tmpVal; } } alphaVal * _DarkColor.a; return tex2D(_MainTex, i.uv) * (1 - alphaVal) _DarkColor * alphaVal; } ENDCG } } }片元着色器的逻辑很直白每个像素对每个目标调用一次CalcAlpha拿到一个“不透明度”在所有目标里挑最小的那个用。这样多个可视圆重叠的区域不会被重复变暗因为只要有一个目标说“这里该亮”就采信那个最小的遮罩值。最后把alphaVal和_DarkColor.a相乘再与原图做线性混合。注意_DarkColor的 alpha 本身也会影响遮罩强度如果设成 0整个效果就相当于关闭了。Shader 的公开参数整理如下参数名类型作用取值范围建议_MainTex2D后处理输入纹理由 Blit 自动传入不需要手动设置_DarkColorColor4遮罩颜色RGBA 都生效alpha 设为 0 可临时关闭_SmoothLengthFloat边缘渐变像素数量8 ~ 40 比较自然_ItemCntInt有效目标数量不超过 ItemSize_ItemVector4 数组目标数据x/y 为屏幕坐标z 为半径长度固定 9_Item.w在原始代码里一直是 0属于预留位。后面如果想做非对称衰减或者挂自定义参数可以直接用 w 通道不用改数组结构。3.2 C# 脚本坐标采集、Y 轴翻转与参数上传C# 调用端也比想象中简单核心逻辑集中在OnRenderImageusing System.Collections.Generic; using UnityEngine; [ExecuteInEditMode] [RequireComponent(typeof(Camera))] public class DarkEffect : MonoBehaviour { [System.Serializable] public class Item { [SerializeField] public Transform target; [SerializeField] public int radius; public Vector3 GetScreenPosition(Camera cam) { return cam.WorldToScreenPoint(target.position); } } public int _smoothLength 20; public Color _darkColor Color.black; public ListItem _items new ListItem(); protected Material _mainMaterial; protected Camera _mainCamera; Vector4[] _itemDatas; Item _tmpItem; Vector4 _tmpVt; Vector3 _tmpPos; int _tmpScreenHeight; private void OnEnable() { _mainMaterial new Material(Shader.Find(Peter/DarkEffect)); _mainCamera GetComponentCamera(); } private void OnRenderImage(RenderTexture source, RenderTexture destination) { // 数组长度跟随目标列表动态变化 if (_itemDatas null || _itemDatas.Length ! _items.Count) { _itemDatas new Vector4[_items.Count]; } _tmpScreenHeight Screen.height; for (int i 0; i _items.Count; i) { _tmpItem _items[i]; // 把世界坐标转成屏幕像素坐标 _tmpPos _tmpItem.GetScreenPosition(_mainCamera); // 关键屏幕坐标系原点在左下角纹理 UV 原点在左上角 _tmpVt.x _tmpPos.x; _tmpVt.y _tmpScreenHeight - _tmpPos.y; _tmpVt.z _tmpItem.radius; _tmpVt.w 0; _itemDatas[i] _tmpVt; } _mainMaterial.SetInt(_SmoothLength, _smoothLength); _mainMaterial.SetColor(_DarkColor, _darkColor); _mainMaterial.SetInt(_ItemCnt, _itemDatas.Length); _mainMaterial.SetVectorArray(_Item, _itemDatas); Graphics.Blit(source, destination, _mainMaterial); } }逐块拆解这个脚本WorldToScreenPoint返回的x、y是屏幕像素坐标z是物体到相机的距离。这里只用x、yz被丢弃了因为半径radius是手动配置的像素值。Y 轴翻转是最大的坑WorldToScreenPoint的坐标原点在屏幕左下角但送入 Shader 后片元着色器里的顶点坐标是从屏幕纹理采样来的原点在左上角。如果不做_tmpScreenHeight - _tmpPos.y这一行遮罩圆会在 Y 方向偏一整个屏幕高度。SetVectorArray把 C# 的Vector4[]原样塞进 Shader 的float4 _Item[ItemSize]。如果列表长度超过 Shader 数组长度超出的部分会被静默丢弃不会报错。SetInt(_ItemCnt, ...)传的是intShader 里声明的是fixed。Unity 的材质属性系统会自动做隐式转换但在个别 OpenGL 设备上可能出现警告。我后来一般用SetFloat传float类型兼容性更稳。上面代码里还有个容易被忽略的点_itemDatas数组是在OnRenderImage里按_items.Count创建的如果运行时增删目标也不会导致越界。4. 参数适配与边界不调这两处换台设备就翻车4.1 像素不是百分比分辨率缩放怎么补radius和_smoothLength都是像素单位。同一份配置在 1080p 的编辑器窗口里看很舒服打包到 720p 手机上圆会大一圈竖屏游戏尤其明显。这是因为屏幕像素密度变了但代码里没有做任何等比缩放。常见做法是引入一个参考分辨率运行时把目标半径做缩放后再传给 Shader。我会在 C# 里加一个baseScreenHeight字段默认按 1080public float baseScreenHeight 1080f; private void OnRenderImage(RenderTexture source, RenderTexture destination) { float scale Screen.height / baseScreenHeight; // 注意radius 也要参与缩放 _tmpVt.z _tmpItem.radius * scale; // _smoothLength 同样按屏幕高度缩放 _mainMaterial.SetFloat(_SmoothLength, _smoothLength * scale); }缩放后无论目标分辨率是多少遮罩圆在屏幕上的相对大小都保持一致。这个参数和你窗口的实际大小无关只和逻辑分辨率高度有关。竖屏项目建议把baseScreenHeight设为设计分辨率的高度比如 1920 或者 1560横屏项目一般用 1080。几个我常用的参考数值屏幕高度推荐半径推荐渐变长度效果72080 ~ 1206 ~ 10锐利聚焦1080120 ~ 18010 ~ 16标准探照灯1440160 ~ 24014 ~ 20大范围视野2160240 ~ 36020 ~ 32高分辨率下的柔和过渡这里注意一个细节如果渐变长度算出来比半径还大Shader 内部会自动把smoothLength收缩到半径值。也就是说半径 30、渐变 100 不会报错但视觉效果等于半径 30、渐变 30。想看清楚渐变边界渐变值不要超过半径的一半。4.2 九个目标上限是设计边界还是硬限制Shader 顶部写着#define ItemSize 9这是编译期数组大小。C# 侧SetVectorArray传入长度超过 9 的数组时Unity 引擎会直接忽略多余元素屏幕上就会“少一个圆”。不报错这是最坑的地方——你对着场景里第 10 个目标的眼睛干瞪眼不知道问题出在哪。如果你确认要支持更多目标最直接的改法是把ItemSize改大#define ItemSize 32同时 C# 端的_Item数组长度自然会跟随_items.Count不需要改。但要权衡每个像素都要遍历一遍数组9 个目标在低端手机上已经有些吃力32 个目标会明显掉帧。移动端我一般会限制在 12 个以内PC 端可以放到 32。另一个思路是只对“离相机一定距离内”的目标做计算超远的直接跳过毕竟屏幕外目标对遮罩毫无贡献。更稳妥的做法是在 C# 侧主动校验并给出日志if (_items.Count 9) { Debug.LogWarning($DarkEffect 最多支持 9 个目标当前 {_items.Count} 个。超出部分已忽略。); }这句日志能帮你省掉不少排查时间。4.3 运行时动态控制从静态圆到呼吸灯效果前面所有参数都暴露为 public 变量运行时改是零成本。比如做一个“受伤后屏幕变红”的效果darkEffect._darkColor Color.red; darkEffect._smoothLength 30; darkEffect._items[0].radius (int)(Mathf.PingPong(Time.time, 120) 60);半径在 60 到 180 之间来回弹视觉上就像一个呼吸中的探照灯。想要更细腻可以单独控制目标列表里某个物体的radiusC# 的OnRenderImage每帧都会重新计算改动会立刻生效。如果要做一个“由中心向四周扩散”的冲击波把smoothLength拉大即可。当渐变长度接近半径时可视圆会变成一个非常柔和的扩散光斑用来做事件聚焦比干切镜头效果好得多。5. 避坑记录动态屏幕遮罩最常见的五个翻车现场5.1 遮罩圆中心和目标物体位置对不上现象物体明明在屏幕右上角遮罩圆却出现在右下角且 Y 方向偏移量恰好接近屏幕高度。原因WorldToScreenPoint返回的是以左下角为原点的像素坐标而 Shader 片元坐标的 Y 轴方向相反。原始代码里有_tmpScreenHeight - _tmpPos.y这一行但很多人会顺手删掉或者注释掉。解决把这一行当作后处理 Shader 的固定套路不要删。测试时可以先让一个目标停在屏幕中心中心点对得上说明坐标转换没问题然后再移向四角验证。5.2 分辨率一变半径就不是那个半径现象PC 上调试好的参数打包到安卓模拟器上圆半径明显偏大屏幕越长越明显。原因radius是固定像素值没有按屏幕高度做缩放。屏幕像素总数变多同一圆占据的视口比例就变了。解决按上面 4.1 的方式用Screen.height和基准高度做缩放。还发现一个问题有些 Android 设备的Screen.height在启动早期是横屏值要等Screen.orientation稳定后再取否则第一帧会闪一下。5.3 第 9 个目标之后的静默失效现象列表里加了第 10 个目标场景里只显示 9 个圆没有任何报错。原因Shader 编译期ItemSize是 9C# 数组超过部分被SetVectorArray丢弃。解决要么把ItemSize改大要么在 C# 侧加数量告警。我在项目里直接用const int MaxItemCount 9与 Shader 保持一致如果策划要求加到 12就两头一起改避免“改了一边忘了另一边”。5.4 UI 被一起压暗了现象场景目标高亮正常但 UI 上的按钮、血量条也被暗色遮罩盖住看起来像蒙了一层黑纱。原因OnRenderImage作用于整个相机的渲染结果UGUI 如果画在这个相机上自然也参与了后处理。这个方案本来就是“整屏效果”不带层级隔离。解决常见做法是 UI 单独用一个相机Screen Space - Camera后处理只挂在场景相机上UI 相机不挂脚本。如果你的 UI 是 Screen Space - Overlay那就绕不开只能接受 UI 也会变暗或者把遮罩改为只在游戏区域内部生效留出顶部 HUD 区域不做后处理。5.5 ExecuteInEditMode 下的空引用现象没按 Play只是在编辑器里选中物体控制台就开始飘 NullReferenceException_items列表全空。原因[ExecuteInEditMode]让脚本在编辑器非运行状态下也会执行OnRenderImage此时场景里可能还没有挂任何目标。解决在OnRenderImage开头判空if (_items null || _items.Count 0) { Graphics.Blit(source, destination); return; }另外 OnEnable 里Shader.Find(Peter/DarkEffect)如果返回 null说明 Shader 名字打错了。建议用Shader.Find之后立刻判空或者直接把 Shader 放进 Always Included Shaders 列表防止漏打包。6. 进阶动态数量、柔性边缘与坐标验证6.1 从固定九个到更多目标把#define ItemSize 9直接改成更大数值是成本最低的扩容方式。但移动端要留个心眼每个像素都要做几次pow和sqrt数组长度 32 时的开销是 9 的 3 倍多。我的习惯是不超过 16 个再往上考虑用空间划分或者多层 RenderTexture。6.2 线性渐变换成自定义曲线CalcAlpha里返回的是线性插值视觉上偏硬。想让边缘更柔和可以在返回前做一次幂运算。把原始代码中的渐变段改成fixed retVal saturate((dist - minValue) / smoothLength); retVal pow(retVal, _Gamma); // _Gamma 1 时边缘更紧 1 时更扩散_Gamma可以复用_Item.w通道每个目标有自己的柔边参数。这样不用额外写一组 Shader 变量编辑器里也能独立调整。6.3 坐标验证的自检习惯坐标翻转这类 bug 用肉眼排查很费时。我现在拿到任何后处理相关的屏幕坐标都会先用OnGUI把目标坐标画成屏幕上的小点void OnGUI() { for (int i 0; i _items.Count; i) { Vector3 pos _items[i].GetScreenPosition(_mainCamera); GUI.color Color.green; GUI.DrawTexture(new Rect(pos.x - 2, Screen.height - pos.y - 2, 4, 4), Texture2D.whiteTexture); } }那个Screen.height - pos.y就是在还原 Shader 里同样的翻转逻辑。绿点如果和目标位置重合说明数据从传到算都对了如果对不上只查坐标转换部分就够不用整个效果推翻重来。从那以后我每次做这类屏幕遮罩后处理都强制走一遍“画坐标 → 翻转验证 → 分辨率基准 → 目标数量校验 → 渐变曲线肉眼检查”五步踩坑率明显降下来了。希望帮到你。本文还有配套的精品资源点击获取