简介:这是一份面向 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 的OnRenderImage:
void 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 的公开参数整理如下:
| 参数名 | 类型 | 作用 | 取值范围建议 |
|---|---|---|---|
_MainTex | 2D | 后处理输入纹理,由 Blit 自动传入 | 不需要手动设置 |
_DarkColor | Color4 | 遮罩颜色,RGBA 都生效 | alpha 设为 0 可临时关闭 |
_SmoothLength | Float | 边缘渐变像素数量 | 8 ~ 40 比较自然 |
_ItemCnt | Int | 有效目标数量 | 不超过 ItemSize |
_Item | Vector4 数组 | 目标数据,x/y 为屏幕坐标,z 为半径 | 长度固定 9 |
_Item.w在原始代码里一直是 0,属于预留位。后面如果想做非对称衰减或者挂自定义参数,可以直接用 w 通道,不用改数组结构。
3.2 C# 脚本:坐标采集、Y 轴翻转与参数上传
C# 调用端也比想象中简单,核心逻辑集中在OnRenderImage:
using 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 List<Item> _items = new List<Item>(); 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 = GetComponent<Camera>(); } 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、y,z被丢弃了,因为半径radius是手动配置的像素值。- Y 轴翻转是最大的坑:
WorldToScreenPoint的坐标原点在屏幕左下角,但送入 Shader 后,片元着色器里的顶点坐标是从屏幕纹理采样来的,原点在左上角。如果不做_tmpScreenHeight - _tmpPos.y这一行,遮罩圆会在 Y 方向偏一整个屏幕高度。 SetVectorArray把 C# 的Vector4[]原样塞进 Shader 的float4 _Item[ItemSize]。如果列表长度超过 Shader 数组长度,超出的部分会被静默丢弃,不会报错。SetInt("_ItemCnt", ...)传的是int,Shader 里声明的是fixed。Unity 的材质属性系统会自动做隐式转换,但在个别 OpenGL 设备上可能出现警告。我后来一般用SetFloat传float类型,兼容性更稳。
上面代码里还有个容易被忽略的点:_itemDatas数组是在OnRenderImage里按_items.Count创建的,如果运行时增删目标,也不会导致越界。
4. 参数适配与边界:不调这两处,换台设备就翻车
4.1 像素不是百分比:分辨率缩放怎么补
radius和_smoothLength都是像素单位。同一份配置,在 1080p 的编辑器窗口里看很舒服,打包到 720p 手机上,圆会大一圈;竖屏游戏尤其明显。这是因为屏幕像素密度变了,但代码里没有做任何等比缩放。
常见做法是引入一个参考分辨率,运行时把目标半径做缩放后再传给 Shader。我会在 C# 里加一个baseScreenHeight字段,默认按 1080:
public 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。
几个我常用的参考数值:
| 屏幕高度 | 推荐半径 | 推荐渐变长度 | 效果 |
|---|---|---|---|
| 720 | 80 ~ 120 | 6 ~ 10 | 锐利聚焦 |
| 1080 | 120 ~ 180 | 10 ~ 16 | 标准探照灯 |
| 1440 | 160 ~ 240 | 14 ~ 20 | 大范围视野 |
| 2160 | 240 ~ 360 | 20 ~ 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 之间来回弹,视觉上就像一个呼吸中的探照灯。想要更细腻,可以单独控制目标列表里某个物体的radius,C# 的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是 9,C# 数组超过部分被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 里同样的翻转逻辑。绿点如果和目标位置重合,说明数据从传到算都对了;如果对不上,只查坐标转换部分就够,不用整个效果推翻重来。从那以后,我每次做这类屏幕遮罩后处理,都强制走一遍“画坐标 → 翻转验证 → 分辨率基准 → 目标数量校验 → 渐变曲线肉眼检查”五步,踩坑率明显降下来了。希望帮到你。
本文还有配套的精品资源,点击获取