简介这份资源面向Unity3D开发者和Shader学习者聚焦动态屏幕遮罩效果的实现可用于游戏视野限制、暗黑探索、聚光灯式可视范围等场景。资源以PDF文档形式呈现压缩包内共1个文件体积约66KB内容围绕Shader代码与C#调用脚本展开便于对照理解渲染管线各环节。文档详细讲解了Unity3D Shader语言、CGPROGRAM、Vertex Shader与Fragment Shader的职责划分以及Properties、SubShader、Pass、Sampler、Struct等基础概念并说明Cull Off、ZWrite Off、ZTest Always等渲染状态的用途。核心部分给出完整Shader代码通过CalcAlpha函数计算遮罩透明度支持最多9个追踪物体可调整可视范围大小、边缘渐变长度与遮罩颜色屏幕可视范围能跟随目标物体实时移动。配套C#脚本展示了目标物体列表、渐变像素数量与遮罩混合颜色的配置方式。目前已有2394人学习适合希望掌握屏幕后处理与遮罩算法的开发者参考。1. 动态屏幕遮罩从“全屏变暗”到“跟随目标开洞”的实战拆解很多做 Unity3D 项目的朋友都遇到过这种需求新手引导时想让某个按钮高亮、其余屏幕压暗或者做潜行玩法时只让玩家看到角色周围一圈视野。最直接的做法是拉一个全屏 UI 图片盖住屏幕再在目标位置放一个圆形图片“挖洞”。但目标一动UI 就得跟着动分辨率一变圆形就拉伸变形多目标时更是手忙脚乱。这份 Unity3D Shader 动态屏幕遮罩资源走的是另一条路用屏幕后处理的方式在片元着色器里逐像素计算到目标屏幕坐标的距离动态生成遮罩 alpha。屏幕可视范围跟随目标物体移动半径、边缘渐变、遮罩颜色都能在面板上调当前版本最多支持 9 个目标物体。它适合做新手引导、视野限制、聚光灯式交互反馈的开发者尤其是已经会用 OnRenderImage 但还没把 Shader 参数和 C# 传参对齐的人。2. 屏幕后处理管线为什么选 OnRenderImage 而不是 UI 遮罩2.1 屏幕空间距离场的基本原理这套方案的核心思路并不复杂把目标物体的世界坐标转成屏幕坐标然后在片元着色器里算当前像素到每个目标屏幕坐标的欧氏距离。距离小于内半径的像素完全透明距离大于外半径的像素完全被遮罩色覆盖中间那段做线性插值就得到了边缘渐变。因为计算发生在屏幕空间所以无论目标在 3D 场景里怎么移动、摄像机怎么旋转遮罩始终精确跟随不会出现 UI 方案那种“图片和实际物体对不上”的错位。Shader 里用了一个float4 _Item[ItemSize]数组来存目标数据每个 float4 的 x、y 是屏幕坐标z 是半径w 留空备用。#define ItemSize 9决定了数组上限想支持更多目标就改这个宏但要注意移动端 uniform 数组别开太大9 个是性能和灵活性的平衡点。片元着色器里循环_ItemCnt次每次调用CalcAlpha算出该目标对当前像素的遮罩贡献取所有目标里最小的 alpha 值——也就是“只要有一个目标覆盖到就优先透明”。2.2 C# 侧的数据准备与坐标转换C# 脚本挂在摄像机上用[ExecuteInEditMode]让编辑器里也能实时预览。OnRenderImage是后处理的入口每一帧都会调用。关键步骤是把每个目标的WorldToScreenPoint结果转成 Shader 能用的坐标Unity 的屏幕坐标原点在左下角而 Shader 里i.vertex对应的屏幕坐标原点在左上角所以 y 要做一次翻转。// DarkEffect.cs 核心数据准备片段 private void OnRenderImage(RenderTexture source, RenderTexture destination) { // 目标数量变化时重建数组避免每帧 GC 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]; // 世界坐标转屏幕坐标z 为到摄像机的距离 _tmpPos _tmpItem.GetScreenPosition(_mainCamera); _tmpVt.x _tmpPos.x; // 翻转 y 轴匹配 Shader 的屏幕坐标原点 _tmpVt.y _tmpScreenHeight - _tmpPos.y; _tmpVt.z _tmpItem.radius; _tmpVt.w 0; _itemDatas[i] _tmpVt; } // 逐帧上传参数注意 SetVectorArray 的长度要和 Shader 数组容量匹配 _mainMaterial.SetInt(_SmoothLength, _smoothLength); _mainMaterial.SetColor(_DarkColor, _darkColor); _mainMaterial.SetInt(_ItemCnt, _itemDatas.Length); _mainMaterial.SetVectorArray(_Item, _itemDatas); Graphics.Blit(source, destination, _mainMaterial); }这里有几个参数需要说清楚。_smoothLength是渐变像素数量设成 20 表示从完全透明到完全遮罩过渡 20 个像素值越大边缘越柔和但太大在低分辨率下会显得糊。_darkColor的 alpha 通道控制遮罩整体强度设成 0.8 就是半透明压暗设成 1 就是全黑。radius是每个目标独立配置的单位是屏幕像素所以不同分辨率下需要按比例调整或者用屏幕高度做归一化——原版没做归一化这是后面避坑章节要展开的点。2.3 Shader 端 CalcAlpha 的边界处理逻辑CalcAlpha函数是整个遮罩的数学核心它处理了三种情况目标在摄像机背后、距离在内半径以内、距离在外半径以外。先看代码fixed CalcAlpha(float4 vt, float4 pt) { // pt.z 0 表示目标在摄像机背后直接返回 1完全遮罩 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; } float maxValue pt.z; // 外半径 目标半径 float minValue pt.z - smoothLength; // 内半径 半径 - 渐变长度 // 内半径不能小于 0否则渐变逻辑会翻转 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; }逻辑说明pt.z存的是半径_SmoothLength是渐变宽度。当半径小于渐变宽度时内半径会被钳到 0同时把smoothLength改成实际半径保证除法不会出现除零或负数。这个细节原版处理得不错但很多人抄代码时会忽略minValue 0这个分支结果半径设小了就出现全屏闪烁。片元着色器里循环取最小 alpha 值最后用lerp混合原图和遮罩色tex2D(_MainTex, i.uv) * (1 - alphaVal) _DarkColor * alphaVal。注意这里 alphaVal 已经乘过_DarkColor.a所以遮罩色的透明度是全局生效的。3. 从零接入Shader 与 C# 脚本的完整配置流程3.1 创建 Shader 文件与材质在 Unity 项目的 Assets 下新建一个 Shader命名随意把Shader Peter/DarkEffect那段完整贴进去。保存后回到编辑器右键 Create → Material把材质的 Shader 选成Peter/DarkEffect。这个材质不需要手动挂到任何物体上C# 脚本会在OnEnable里用Shader.Find动态创建。private void OnEnable() { // 动态创建材质实例避免污染项目里的材质资源 _mainMaterial new Material(Shader.Find(Peter/DarkEffect)); _mainCamera GetComponentCamera(); }这里有个容易翻车的点Shader.Find在打包后如果 Shader 没有被任何场景引用可能会被剥离导致运行时找不到。稳妥做法是在 Graphics Settings 的 Always Included Shaders 里把这个 Shader 加进去或者直接在脚本上暴露一个 Material 字段手动赋值。我一般会暴露字段编辑器里拖一下打包后最稳。3.2 挂载 DarkEffect 脚本并配置目标把DarkEffect.cs挂到主摄像机上。因为脚本带了[RequireComponent(typeof(Camera))]如果挂错物体会自动补一个 Camera但建议还是手动挂到正确的摄像机上。挂好后 Inspector 里会出现三个可调项Smooth Length、Dark Color、Items 列表。Items 列表里每个元素有两个字段target 是 Transformradius 是 int。把需要“开洞”的物体拖到 target 上radius 设成 100 到 300 之间试试效果。注意 radius 的单位是屏幕像素在 1080p 下 200 像素大概占屏幕高度的 18% 左右。如果目标物体在摄像机背后WorldToScreenPoint返回的 z 是负数Shader 里pt.z 0会直接返回完全遮罩所以不用担心背后物体产生错误的透明区域。3.3 多目标叠加与 alpha 取最小值的逻辑当 Items 里有多个目标时片元着色器会遍历所有目标对每个目标算一个 alpha然后取最小值。这意味着如果两个目标的遮罩区域有重叠重叠部分会保持透明不会出现“两个洞叠在一起变暗”的问题。这个取最小值的策略是符合直觉的只要任意一个目标覆盖了当前像素这个像素就应该可见。fixed4 frag (v2f i) : SV_Target { fixed alphaVal 1; fixed tmpVal 1; // 遍历所有目标取最小 alpha 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; }参数说明_ItemCnt是实际目标数量由 C# 每帧设置不要超过ItemSize宏定义的值否则数组越界在部分平台上会直接花屏或崩溃。_Item数组里未使用的槽位数据是上一帧残留的但因为_ItemCnt限制了循环次数所以不会读到脏数据。如果你在运行时动态增删目标记得_itemDatas数组长度变化时重新分配原版脚本已经处理了这一点。4. 避坑与排查动态遮罩最常见的五个翻车现场4.1 遮罩区域偏移或上下颠倒现象运行后遮罩的洞不在目标物体上而是跑到了屏幕上方或下方对称的位置。原因屏幕坐标 y 轴方向不一致。Unity 的WorldToScreenPoint原点在左下角Shader 里i.vertex对应的屏幕坐标原点在左上角。原版脚本里_tmpVt.y _tmpScreenHeight - _tmpPos.y就是做翻转如果你抄代码时漏了这行或者自己改了坐标转换逻辑就会上下颠倒。解决确认翻转逻辑存在并且_tmpScreenHeight取的是Screen.height而不是Screen.currentResolution.height后者在窗口模式下可能不匹配。4.2 半径在不同分辨率下表现不一致现象在 1080p 下半径 200 刚好切到 720p 或 4K 后洞的大小明显不对。原因radius 是绝对像素值没有做分辨率归一化。屏幕高度变了同样的像素数占屏幕比例就变了。解决在 C# 里把 radius 乘以Screen.height / 1080f做归一化或者干脆在 Shader 里用归一化坐标计算距离。我一般会在 C# 侧做一次缩放这样 Inspector 里填的还是 1080p 基准值直观好调。4.3 目标在摄像机背后时遮罩闪烁现象目标物体转到摄像机背后时遮罩区域突然消失或全屏闪烁。原因WorldToScreenPoint对背后物体会返回负的 z 值原版 Shader 里if(pt.z 0) return 1;已经处理了但如果你自己改过 CalcAlpha 或者把判断去掉了就会出问题。解决保留pt.z 0的判断并且注意pt.z存的是半径而不是距离别把语义搞混。原版里_tmpVt.z _tmpItem.radius所以pt.z是半径不是摄像机距离判断背后物体靠的是_tmpPos.z但原版没传这个值——实际上原版用pt.z 0判断的是半径小于 0这其实是个隐藏 bug半径不可能小于 0所以这个分支永远不会触发。正确做法应该是把_tmpPos.z也传进来或者单独用一个字段标记目标是否在背后。4.4 Shader 数组越界导致移动端花屏现象在编辑器里正常打包到 Android 或 iOS 后遮罩区域出现随机色块或直接黑屏。原因_Item数组在 Shader 里声明为float4 _Item[9]但 C# 侧SetVectorArray传入的数组长度如果超过 9或者_ItemCnt设成了大于 9 的值就会越界。解决在 C# 里加一个Mathf.Min(_items.Count, 9)的钳制并且确保ItemSize宏和 C# 里的上限一致。移动端 GPU 对 uniform 数组的容量更敏感9 个 float4 是 36 个 float加上其他参数接近一些低端设备的极限如果目标更多建议改用纹理传数据。4.5 编辑器预览正常但运行后遮罩不生效现象在 Scene 视图里能看到遮罩效果但点 Play 后屏幕没有任何变化。原因OnRenderImage只在摄像机有渲染目标时调用如果摄像机的 Culling Mask 没包含任何物体或者摄像机被禁用后处理不会执行。另一个常见原因是Shader.Find在运行时找不到 Shader材质变成粉色。解决检查摄像机是否启用、Culling Mask 是否合理把 Shader 加入 Always Included Shaders或者在脚本上暴露 Material 字段手动赋值避免运行时查找。5. 进阶技巧把遮罩做成可复用的屏幕特效组件5.1 用 RenderTexture 做遮罩纹理缓存原版每帧都在片元着色器里循环计算距离目标少的时候没问题但如果场景里有几十个需要遮罩的物体或者要在低端机上跑逐像素循环就会成为瓶颈。一个优化思路是把遮罩计算从屏幕后处理里拆出来单独渲染到一张低分辨率的 RenderTexture 上再在主后处理里采样这张纹理。具体做法是新建一个摄像机或用一个 CommandBuffer把每个目标渲染成一个圆圆内 alpha 为 0圆外 alpha 为 1边缘用 smoothstep 做渐变。然后主摄像机的后处理只需要采样这张遮罩图乘以遮罩色再混合。这样片元着色器里没有循环性能跟目标数量基本脱钩。// 用 CommandBuffer 渲染遮罩到 RT 的思路片段 var maskRT RenderTexture.GetTemporary(Screen.width / 2, Screen.height / 2, 0); cmd.SetRenderTarget(maskRT); cmd.ClearRenderTarget(false, true, Color.white); // 默认全遮罩 foreach (var item in _items) { // 用 GL 或 Graphics.DrawMesh 画一个圆材质里做边缘渐变 // 圆心和半径通过 MaterialPropertyBlock 传入 } _mainMaterial.SetTexture(_MaskTex, maskRT);参数说明RT 分辨率可以降到屏幕的一半甚至四分之一因为遮罩边缘本来就是渐变的低分辨率采样后双线性插值反而更柔和。_MaskTex在 Shader 里用tex2D采样r 通道存 alpha 值。这个方案的好处是目标数量不再受 uniform 数组限制坏处是需要额外管理 RT 的分配和释放以及处理摄像机 resize 时的重建。5.2 遮罩边缘的 smoothstep 优化原版用线性插值算渐变边缘过渡是线性的视觉上会有一点“硬”。把(dist - minValue) / smoothLength换成smoothstep(minValue, maxValue, dist)边缘会更自然。smoothstep 在 HLSL 里是内置函数性能几乎没差别但过渡曲线是三次的看起来更舒服。改完之后记得把minValue和maxValue的顺序传对smoothstep 要求第一个参数小于第二个参数否则结果会翻转。// 把线性插值替换为 smoothstep fixed retVal smoothstep(minValue, maxValue, dist); return retVal;这个改动很小但效果提升明显尤其是半径较大、渐变较宽的时候。我一般会在项目里直接改成 smoothstep除非美术明确要求线性过渡。5.3 多摄像机叠加时的遮罩顺序如果场景里有多个摄像机比如一个主摄像机加一个 UI 摄像机后处理脚本挂在哪个摄像机上遮罩就只影响那个摄像机的输出。UI 摄像机如果不挂脚本UI 元素会盖在遮罩上面导致“洞”被 UI 挡住。解决方式有两种把 UI 也纳入同一个摄像机的渲染或者给 UI 摄像机也挂上同样的后处理但把_DarkColor.a设成 0 来跳过。更干净的做法是用Camera.main的targetTexture做离屏渲染最后统一合成。这个坑在做新手引导时特别常见因为引导高亮的按钮往往是 UI 元素而遮罩挂在主摄像机上结果按钮被遮罩盖住了。从那以后我每次接动态遮罩的需求都会先确认三件事目标是不是 UI、摄像机是不是多个、分辨率要不要归一化。这三件事确认完后面基本就是调参数的事了。希望帮到你。本文还有配套的精品资源点击获取