news 2026/10/9 4:23:41

Unity3D Shader动态屏幕遮罩:后处理实现聚焦与迷雾效果

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Unity3D Shader动态屏幕遮罩:后处理实现聚焦与迷雾效果

简介:这是一份面向 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 的公开参数整理如下:

参数名类型作用取值范围建议
_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# 调用端也比想象中简单,核心逻辑集中在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); } }

逐块拆解这个脚本:

  1. WorldToScreenPoint返回的x、y是屏幕像素坐标,z是物体到相机的距离。这里只用x、y,z被丢弃了,因为半径radius是手动配置的像素值。
  2. Y 轴翻转是最大的坑:WorldToScreenPoint的坐标原点在屏幕左下角,但送入 Shader 后,片元着色器里的顶点坐标是从屏幕纹理采样来的,原点在左上角。如果不做_tmpScreenHeight - _tmpPos.y这一行,遮罩圆会在 Y 方向偏一整个屏幕高度。
  3. SetVectorArray把 C# 的Vector4[]原样塞进 Shader 的float4 _Item[ItemSize]。如果列表长度超过 Shader 数组长度,超出的部分会被静默丢弃,不会报错。
  4. 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。

几个我常用的参考数值:

屏幕高度推荐半径推荐渐变长度效果
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 之间来回弹,视觉上就像一个呼吸中的探照灯。想要更细腻,可以单独控制目标列表里某个物体的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 里同样的翻转逻辑。绿点如果和目标位置重合,说明数据从传到算都对了;如果对不上,只查坐标转换部分就够,不用整个效果推翻重来。从那以后,我每次做这类屏幕遮罩后处理,都强制走一遍“画坐标 → 翻转验证 → 分辨率基准 → 目标数量校验 → 渐变曲线肉眼检查”五步,踩坑率明显降下来了。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/9 4:23:37

Qt信号与槽机制详解:从解耦原理到Lambda与跨线程实践

1. 破解信号与槽&#xff1a;从回调地狱到广播通讯1.1 为什么我们需要信号与槽刚接触Qt的时候&#xff0c;很多人会有个疑惑&#xff1a;明明C已经有函数指针、回调函数了&#xff0c;为什么Qt还要搞出一套信号与槽机制&#xff1f;这个问题我琢磨了很久&#xff0c;直到自己维…

作者头像 李华
网站建设 2026/10/9 4:23:29

Cursor 配置全攻略:从 .cursorrules 到 .mdc 规则体系实战

1. 为什么大多数人用 Cursor 只发挥了它三成实力我身边不少朋友都在用 Cursor&#xff0c;但聊下来发现一个很普遍的现象&#xff1a;大家基本只把它当成一个"能自动补全的编辑器"&#xff0c;写代码时按 Tab 接受建议&#xff0c;偶尔用 CmdK 让它改改函数&#xff…

作者头像 李华
网站建设 2026/10/9 4:23:19

探矿RAG数据清洗实战:TXT、Word、PDF与网页四类脏源处理全解析

1. 探矿数据清洗为什么成了RAG落地的第一道坎做过探矿业务数据的人都有一个共同感受&#xff1a;数据不是没有&#xff0c;而是太杂。一个中型勘探项目跑下来&#xff0c;地质报告是Word&#xff0c;钻孔编录是Excel导出的TXT&#xff0c;化验单是PDF扫描件&#xff0c;历史资料…

作者头像 李华
网站建设 2026/10/9 4:23:18

AGENTS.md 真的有用吗?用 GPT-4o 在 SWE-bench Lite 上跑一遍验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/9 4:23:00

多AI协作与Agent搭建实战:从模式到部署避坑指南

1. 本期速览&#xff1a;这一周AI圈到底在忙什么先交代一下背景。亨嘉AI周刊是面向一线AI从业者、AI产品负责人和深度玩家的行业周刊&#xff0c;每周梳理过去七天最值得关注的技术趋势、工具动态和实战方法论。第40周的观察周期是9月28日到10月4日&#xff0c;正好跨了一个国庆…

作者头像 李华
网站建设 2026/10/9 4:22:20

NeurIPS时间序列论文解读:基础模型、上下文学习与VLM成主流

1. 论文速览&#xff1a;这届NeurIPS的时间序列到底在卷什么NeurIPS 2026的时间序列论文放出来之后&#xff0c;我花了两天整块时间把标题全部过了一遍&#xff0c;又挑了十几篇和工作相关的精读了一遍。整体感觉是&#xff1a;这届时间序列不再是"算法调参大会"&…

作者头像 李华