☰
Unity3D后处理技巧:用Shader实现动态屏幕遮罩与光圈效果
2026/10/6 11:13:05 网站建设 项目流程

简介:面向Unity3D开发者的Shader特效实现资料,以动态屏幕遮罩作为核心案例,解决游戏场景中可视区域跟随目标移动、遮罩半径与边缘软硬程度实时调节、多物体同时追踪等实际需求,适合具备基础Unity操作经验、希望深入理解屏幕后处理与Shader渲染管线的初中级开发者。资源为单个PDF文档,压缩包约66KB,虽体量不大,但内容组织清晰:既有可直接运行的Shader完整脚本,也有配套C#调用代码,并对CGPROGRAM语法、顶点与片元着色器分工、Properties参数声明、SubShader渲染状态、Cull Off与ZWrite Off等关键知识点逐一拆解说明,方便按章节对照学习。已有2394人学习下载,实用性和参考价值经过较多学习者验证。方案默认支持最多9个目标物体同时追踪,可在面板上调整遮罩颜色、渐变像素数量等参数,便于快速移植到自己的项目,或基于此扩展自定义屏幕特效。

1. 动态屏幕遮罩:一个 Pass 把全屏压暗和光圈一起画完

做潜入玩法或者塔防的暗区时,屏幕遮罩几乎是件躲不开的事:整屏压暗,角色或目标物体周围留一圈亮区。很多项目第一反应是上 UI Mask 加两张图,但角色屏幕坐标要每帧回传 UI、多目标还要排序,真跑起来平行光照亮一片区域时常常对不齐。其实 Unity3D 里动态屏幕遮罩更适合做全屏后处理:C# 每帧跟踪目标,把屏幕坐标、半径、渐变宽度和遮罩颜色打包成一个数组交给 Shader,片元着色器逐像素算距离,一个 Draw Call 就同时完成压暗和透明光圈。下面拆的 Peter/DarkEffect 是其中一个典型实现,最多跟到 9 个目标,半径、边缘渐变宽度、遮罩颜色全部实时可调,适合当距离场后处理的第一份练手代码。

2. 坐标准备:WorldToScreenPoint 之后那个 Y 翻转是怎么来的

2.1 三个渲染状态是后处理的地基

一个全屏后处理 Shader 写出来第一眼,很多人把注意力先放在了 Properties 上,却容易忽略最顶上那三行:

Cull Off ZWrite Off ZTest Always

这是后处理 Pass 的默认底盘。Cull Off 是让全屏三角形无论朝向都画出来;ZWrite Off 是不往深度缓冲写东西,因为我们这个 Pass 跑在场景渲染完之后,深度缓冲里已经是场景了,再去写只会破坏后面的透传;ZTest Always 则是让片元永远通过深度测试,保证所有像素都跑到片元着色器里。对屏幕空间效果来说,这三行是保底配置,缺了哪个都可能在某些渲染路径下莫名看不见或者被遮挡。

这里有个细节值得留意——如果项目开了 HDR,深度缓冲可能反而不是最终场景深度,ZTest Always 在这种后处理里通常没问题,但如果哪天你在这个 Shader 里加深度比较,记得把 ZTest 换回 LEqual。

提示:写后处理 Shader 第一件事就是把这三行摆上,就算暂时用不到,也能避免很多渲染路径差异带来的玄学问题。

2.2 WorldToScreenPoint 的坐标系和片元坐标系差了一个翻转

C# 侧核心是这一行:

_tmpPos = _tmpItem.GetScreenPosition(_mainCamera);

GetScreenPosition 内部调的就是 Camera.WorldToScreenPoint。这个接口返回的坐标,原点在屏幕左下角,Y 轴向上。而后处理 Shader 里,像素着色器拿到的 SV_POSITION,在不同图形 API 下 Y 轴方向并不统一,常见实现里 Y 轴是反的,所以原作者紧接着写了这句:

_tmpVt.y = _tmpScreenHeight - _tmpPos.y;

这样把左下原点翻成左上原点的像素坐标,再传给 Shader 用。要是漏掉这一行,本来在屏幕右上方的目标,发光区域会跑到右下角,整体图像上下颠倒。

很多刚接触后处理的人会在这一步卡很久,因为 WorldToScreenPoint 有时和屏幕 UV 定位是一致的,有时又差一个镜像——取决于你用 Unity 内置的 UV 坐标还是自己构造的顶点坐标。判断标准就一条:你在 C# 侧到底把坐标当成什么语义用。原代码里是拿原始屏幕像素坐标算距离,所以必须转成从上往下的像素坐标。如果不想管坐标语义,也可以统一转成归一化设备坐标,把 x 除以 source.width、y 除以 source.height,这样 Shader 里用 i.uv 去算就行,但注意半径也要换算成 UV 单位,别把像素值的半径直接塞过去。

为了把两个坐标系的差别讲清楚,整理了个小表:

坐标系原点Y 方向典型来源
屏幕左下像素坐标左下角 (0,0)Y 向上WorldToScreenPoint
屏幕左上像素坐标左上角 (0,0)Y 向下WorldToScreenPoint 翻转后
后处理片元 SV_POSITION视具体 API常见为 Y 向下或 Y 向上v2f 传递
UV 纹理坐标左下角 (0,0)Y 向上o.uv

这个表不是用来背的,是用来排查的。当你的光圈位置不对,先别急着改 Shader 逻辑,拿这个表对照一下你传进去的坐标到底是哪个原点的坐标,通常一眼就能看出问题。

2.3 九个目标怎么塞进一个数组

C# 侧把每个目标打成一个 Vector4:

_tmpVt.x = _tmpPos.x; _tmpVt.y = _tmpScreenHeight - _tmpPos.y; _tmpVt.z = _tmpItem.radius; _tmpVt.w = 0;

x 和 y 是屏幕像素坐标,z 是可视半径,w 暂时空着没用到,原作者留了个扩展位。整个数组最后通过 SetVectorArray 一次性传到 Shader。这一步的讲究在于:GPU 常量缓冲有对齐要求,Vector4 天然是 16 字节对齐,比传三个独立 Float 数组更省心,而且 SetVectorArray 一次调用就能更新最多 9 个目标的数据。

Shader 端对应的声明是:

fixed4 _DarkColor; float _SmoothLength; fixed _ItemCnt; float4 _Item[ItemSize];

这里注意一个坑:_ItemCnt 用的是 fixed 类型,虽然 C# 传过来的是整数,但 fixed 在部分移动 GPU 上精度有限,数量超过 9 以后循环条件容易出误差。后面避坑章节专门讲这个。另一个要注意的是,数组长度和 _ItemCnt 不一定相等,C# 里 _items 的长度可以随意填,你传多了 Shader 里循环到 _ItemCnt 就停,传少了就有几个目标永远不会生效,所以 C# 侧最好在 OnValidate 或者 OnEnable 里做一次长度校验。

还有一点,原代码在 OnRenderImage 里动态申请数组:

if (_itemDatas == null || _itemDatas.Length != _items.Count) { _itemDatas = new Vector4[_items.Count]; }

这是一个值得照抄的习惯。不要在 Start 里一次性定死数组长度,否则运行时增删目标,要么越界要么漏画。每次 OnRenderImage 判断长度,等于让 Shader 能实时适配列表变动,代价只是一个数组比较操作,几乎可以忽略。

3. Shader 算法:CalcAlpha 的三段区间和多目标的 min 合并

3.1 距离计算为什么要用像素坐标

片元着色器一进来的第一件事,是遍历所有目标:

for(fixed index = 0; index < _ItemCnt; ++index) { tmpVal = CalcAlpha(i.vertex, _Item[index]); if(tmpVal < alphaVal) { alphaVal = tmpVal; } }

CalcAlpha 第一个参数是当前像素,第二个参数就是上面说的那个 Vector4。它先算两点欧氏距离:

float distPow2 = pow(vt.x - pt.x, 2) + pow(vt.y - pt.y, 2); float dist = (distPow2 > 0) ? sqrt(distPow2) : 0;

这里直接拿像素坐标做平方距离。只要坐标系统一,像素坐标的距离就是屏幕上的实际像素距离,半径和渐变宽度都用像素值,直觉上非常直观。缺点是这个距离没有考虑屏幕宽高比——严格说屏幕像素是矩形,距离应该按 x 和 y 的物理密度换算,不过对游戏 UI 遮罩来说,像素距离完全够用。

另外这行还有个细节:先判断 distPow2 大于 0 才开平方,避免 sqrt(0) 在部分 GPU 上出现精度波动,虽然是微小优化,但写成这样可以让结果更稳定。实际开发里我会顺便加个距离上限预判,比如距离已经超过半径加渐变了,直接返回 1,省掉后续一次分支判断——不过原版的写法已经能跑,没必要为了性能把代码改得不可读。

3.2 三段区间判定里的边界逻辑

CalcAlpha 的核心是半径、渐变宽度两个参数决定三段区间:

float maxValue = pt.z; float minValue = pt.z - smoothLength; if(minValue < 0) { minValue = 0; smoothLength = pt.z; }

maxValue 就是半径,从圆心到 maxValue 范围内是完全透明区。渐变发生在内圈:minValue 到 maxValue 之间是过渡带,smoothLength 越大,过渡带越宽。

这里有个很容易被忽视的行为:渐变宽度加大时,完全透明区域实际上在缩小。因为 minValue = 半径 - smoothLength,smoothLength 一增大,minValue 就往圆心缩,过渡带往内侧扩张。如果你想要的效果是半径不变、边缘变柔和,那这里逻辑刚好符合;但如果你想要的是透明区一直固定、只是边缘向外延展,就要把公式改成 minValue = pt.z、maxValue = pt.z + smoothLength。两种渐变方向在视觉上一个像探照灯扩宽,一个像光圈外缘羽化,做需求确认时最好先跟美术对齐是哪种。

当 minValue 小于 0 时,说明渐变宽度已经把整个半径盖住了,这时强行把 minValue 剪裁到 0,把 smoothLength 设置成半径,让渐变从圆心开始一路过渡到边缘。这行判断是典型的数值保护,不写的话会出现负数范围内的距离比较,结果在圆心附近产生一片不该有的暗区或者透明区乱跳。

3.3 混合公式和参数表

最后回到颜色混合:

return tex2D(_MainTex, i.uv) * (1 - alphaVal) + _DarkColor * alphaVal;

注意语义:alphaVal 是遮罩的密度,不是透明度。alphaVal 等于 0 时,原画面完全透出,也就是我们说的亮圈;alphaVal 等于 1 时,完全被 _DarkColor 覆盖,就是压暗区域。默认 _DarkColor 是黑色,看起来就是纯黑变暗,调成蓝色会有蓝色滤光感觉。这个方法给美术调氛围其实挺灵活的。

需要 C# 传的参数一共有四个,列个表方便照着配:

参数类型说明建议初始值
_SmoothLengthint边缘渐变宽度(像素)20
_DarkColorColor遮罩混合颜色,alpha 建议小于 1Color.black
_ItemCntint实际追踪目标个数动态
_Item[]Vector4[]每个元素的 xy 是屏幕坐标,z 是半径,w 预留动态

其中 _DarkColor 的 alpha 有个额外作用:alphaVal 最终会乘以 _DarkColor.a,所以想要全局减弱遮罩强度、露出更多主画面,直接把 _DarkColor.a 调成小于 1 的值就行,不用改 Shader 代码。

多目标用 min 合并的语义也值得讲透。alphaVal 初始化为 1,表示默认全遮罩。每算一个目标的 tmpVal,如果它更小就用它。这样任意一个目标把它照亮,该像素的最终 alpha 就变小,对应画面变透明。用灯来理解最直观:多个目标叠加时,只要有灯照到,画面就亮。如果你想要的是多个目标叠加反而更暗,例如多只怪物的恐惧光环叠加,就要把 min 改成 max 或者取 sum,工程里常见做法是换成 max 加一个加权系数,让重叠区域逐渐压暗而不是突变。

4. C# 侧联动:OnRenderImage 的参数推送和编辑器实时预览

4.1 材质生命周期,别把 Material new 了一地

C# 脚本通过 [ExecuteInEditMode] 和 [RequireComponent(typeof(Camera))] 挂到相机上。OnEnable 里创建材质:

_mainMaterial = new Material(Shader.Find("Peter/DarkEffect"));

这里有个隐患是原版没写释放逻辑,我在工程里一般会补一个 OnDisable 销毁材质:

private void OnDisable() { if (_mainMaterial != null) { if (Application.isPlaying) Destroy(_mainMaterial); else DestroyImmediate(_mainMaterial); } }

为什么不能偷懒直接复用某个 sharedMaterial?因为每个相机实例的材质参数是独立副本,多个相机挂同一脚本时,共享材质会导致彼此参数串扰。new 一个自己的材质,参数互不干扰。但代价就是必须记得释放,否则编辑器下反复启用禁用脚本,内存里 Material 数量会像滚雪球一样涨。后面避坑章节把这条当成常见问题写。

4.2 数据打包循环里必须放 OnRenderImage 的事

OnRenderImage 每次渲染前做数据同步:

_tmpScreenHeight = Screen.height; for (int i = 0; i < _items.Count; i++) { _tmpItem = _items[i]; _tmpPos = _tmpItem.GetScreenPosition(_mainCamera); _tmpVt.x = _tmpPos.x; _tmpVt.y = _tmpScreenHeight - _tmpPos.y; _tmpVt.z = _tmpItem.radius; _tmpVt.w = 0; _itemDatas[i] = _tmpVt; }

关键点是每次循环都取一次 Screen.height,不要缓存成成员变量在 Start 里读一次。因为编辑器下 Game 视图的窗口尺寸随时可能被拖着变,Minimize 和 Maximize 切换时分辨率也会变;运行时如果做屏幕自适应或分屏,同样需要实时刷新。这里的 Screen.height 是 Unity 帮我们取的屏幕像素高,但注意它是整个屏幕的高度,如果目标是 Render Texture,那就要换成 source.height。避坑章节把这块和 Render Texture 场景一起讲。

另一个容易被忽略的点:GetScreenPosition 接收的参数是 _mainCamera,这个相机必须是挂了脚本的相机。如果你是多相机策略,目标物体在世界坐标里要能被这个相机看见才有意义,否则 WorldToScreenPoint 会返回一个在屏幕外的坐标,CalcAlpha 用那个坐标参与距离计算时会把光圈画在屏幕边缘外,表现为目标明明在屏幕里光圈却画偏了。

4.3 用 Gizmos 在 Scene 视图画出遮罩圆,调参数不用靠猜

这个 Shader 本身是后处理,在 Game 视图里才能看到效果。问题是调半径和渐变宽度时,看不到光圈边界在哪,每次都得切 Game 视图看一眼,来回切非常烦。我一般会在 C# 脚本里补一段 Gizmos 可视化:

private void OnDrawGizmos() { if (_items == null || !_mainCamera || _items.Count == 0) return; Gizmos.color = new Color(0, 1, 1, 0.6f); for (int i = 0; i < _items.Count; i++) { if (_items[i].target == null) continue; Vector3 pos = _items[i].target.position; Gizmos.DrawWireSphere(pos, _items[i].radius); } }

这样在 Scene 视图里能直接看到光圈覆盖范围,调整 radius 时不用离开 Scene 视图。注意 DrawWireSphere 的半径是按世界单位画的,它只是帮你标出目标在场景里的范围,不直接等同于屏幕上的像素半径——因为屏幕半径和世界半径的关系取决于相机距目标的距离。这个 Gizmos 的作用更多是定位,而不是精确显示屏幕遮罩边界。如果要精确显示,得根据 WorldToScreenPoint 把半径也换算成屏幕像素后再取射线还原回世界坐标,复杂很多,实际调参时一个粗略的圆就够用了。

5. 避坑笔记:这个 Shader 最容易翻车的五个地方

5.1 光圈位置偏了半个屏幕:Y 翻转和坐标系不统一

现象:目标的亮圈出现在屏幕正中央偏下的位置,或干脆上下颠倒。

原因:漏了 Screen.height - pos.y,或者把屏幕坐标当成 UV 坐标去参与距离计算。

解决:先打印 _tmpPos 和 _tmpVt,确认 y 是否翻转。如果要用 UV 坐标计算距离,就要把半径换成 UV 单位,不能用像素半径。我个人的排查顺序是:先对比 WorldToScreenPoint.y 和实际鼠标屏幕坐标系的 y,确认差异方向,再决定要不要加翻转。

5.2 目标走到相机背后,光圈位置乱跳或者整屏突变

现象:角色走到相机后方后,光圈要么瞬间消失要么跑到屏幕另一侧,甚至整个遮罩强度发生跳变。

原因:WorldToScreenPoint 返回的三分量里,x 和 y 是屏幕像素坐标,z 是目标在相机前方的深度。目标在相机背后时这个 z 是负值,同时 x、y 会变成镜像后的坐标。原代码里 C# 把这三分量拆开,只把 x、y 和 radius 拼进 Vector4,没有做可见性判断,于是负深度目标照样参与距离计算,结果光圈画在奇怪的位置。

解决:在 C# 侧做一次可见性预判,WorldToScreenPoint 返回的 z 小于等于 0 时跳过该目标,或者给它设置一个屏幕边缘外的坐标,让 CalcAlpha 自然返回全遮罩。

提示:如果想让目标出屏后光圈有个平滑淡出的过渡,可以把这个 z 深度值映射成一个 0 到 1 的权重,乘到 alphaVal 上,而不是直接丢弃。

5.3 光圈歪斜或变形:坐标尺度与分辨率口径不一致

现象:同一个目标的遮罩光圈在屏幕中央看起来是圆,拖到边缘变成扁的,或者整体偏移。

原因:两种常见情况。第一种,有的坐标用了 0 到 1 的归一化值,有的用了像素值,两者直接相减,半径是像素单位,距离却是 UV 单位,圆自然被拉伸。第二种,相机渲染到了自定义 RenderTexture 上,OnRenderImage 里拿到的 source 宽高和 Screen.height 不一致,C# 用了 Screen.height 做翻转,Shader 里用的又是实际渲染目标的像素坐标,对不上就会偏移或者变形。

解决:统一尺度。最省事的是在 C# 里改为用 source.height 做翻转,或者干脆传一个 _ScreenSize 参数给 Shader,把所有坐标都归一化后再算距离。我一般会优先选归一化方案,因为后续要接模糊、深度等效果,归一化坐标复用率高。

5.4 支持的目标数量改大后边缘闪烁

现象:把#define ItemSize 从 9 改成 64,C# 侧也塞了更多目标,结果有些目标时亮时暗,光圈边缘在动。

原因:_ItemCnt 是 fixed 类型。fixed 在 PC 上可能是 fp32,但到了部分移动 GPU 上会退化成 fp16,指数部分精度不够,数量靠近上限时循环条件和值比较出现舍入误差,看起来就是随机闪烁。另一个隐藏问题是如果 _items.Count 超过 ItemSize,Shader 的数组只声明了 9 个元素,SetVectorArray 传更多数据是越界的,第 10 个目标要么不生效要么引发渲染错误。

解决:把 Shader 里的 _ItemCnt 和循环变量 index 统一改成 int,数组本身保持 float4,但计数器和循环不要用 fixed。同时 C# 侧做一次 clamp,比如 _items.Count 超过 ItemSize 时只取前 ItemSize 个,并在 Inspector 里给出警告。

5.5 反复 Enable/Disable 脚本后内存上涨

现象:在编辑器里开关脚本,Profiler 里 Material 数量一直涨,退出 Play 模式也没回收。

原因:OnEnable 里每次 new Material,但原版 OnDisable 没有销毁版本。编辑器模式下资源是持久化的,不调用 DestroyImmediate 就会一直挂着。

解决:补齐生命周期,运行模式用 Destroy,编辑模式用 DestroyImmediate。这是所有编辑器扩展和后处理脚本的通病,不只是这个 Shader 的特例。

6. 进阶验证:用 Frame Debugger 核对 Draw Call 和给光圈加深度的扩展

6.1 用 Frame Debugger 验证是否一个 Pass 跑完

这个效果最让人心动的点是它把压暗和光圈压到了一个全屏四边形渲染上。想验证最直接的方式是 Window > Analysis > Frame Debugger,找到 OnRenderImage 对应的 Blit 那一帧,展开看到的应该是:

Draw Mesh Quad Pass 0 Cull Off ZWrite Off ZTest Always

除了这个 Draw Call,后处理本身没有再生成额外三角形。如果这个 Pass 重复出现了两次或更多,往往是被多个相机或多个脚本重复执行了,回 OnRenderImage 里加个断点看一下调用栈,别急着改 Shader。

6.2 一个深度感知扩展方向

如果需要做到目标被遮挡时不产生亮圈,光靠屏幕坐标是不够的。WorldToScreenPoint 返回的三分量里 z 其实是相机空间深度,原代码没有把它传给 Shader。你可以把它放进 Vector4 的 w 分量里传进去,在 CalcAlpha 开头用相机深度纹理比对:

float sceneDepth = tex2D(_CameraDepthTexture, uv).r; float targetDepth = LinearEyeDepth(pt.w); if(targetDepth > sceneDepth + 0.05) return 1;

注意这样就得在 Shader 里声明 sampler2D _CameraDepthTexture,并确保相机开启了 depth texture,这是常见的后处理深度三件套。这种思路适合做迷雾遮蔽、战争迷雾边缘软化等玩法,比只做固定光圈更接近商用项目。

6.3 从 fixed 到 half 的性能与精度取舍

最后提一下 Shader 里的精度类型。原代码大量用 fixed,这在高通的 Adreno 上实际等于 fp16 或 fp20,对纯颜色计算够用,但对距离计算(动辄几百上千的像素值)精度吃紧。移动端我一般会把所有距离计算换成 half,统一用半精度,既能保持性能,比老 GPU 上的 fp32 更快,也避免了大像素坐标下的精度断裂。在帧调试器里如果看到光圈边缘出现锯齿压缩感,优先检查这个。

这个效果我在不同项目里反复调整过,从那以后,每次写后处理脚本我都会先确认三件事:渲染目标是不是屏幕分辨率、摄像机是否开启深度纹理、材质的生命周期有没有收尾。全部确认完才开始动算法。这个过程就像做菜前先检查锅和火,免得菜炒糊了才发现油温不对。希望这篇拆解帮你在自己项目里少走一段弯路。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询