ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity3D后处理技巧:用Shader实现动态屏幕遮罩与光圈效果

Unity3D后处理技巧:用Shader实现动态屏幕遮罩与光圈效果 简介面向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 半径 - smoothLengthsmoothLength 一增大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 改成 64C# 侧也塞了更多目标结果有些目标时亮时暗光圈边缘在动。原因_ItemCnt 是 fixed 类型。fixed 在 PC 上可能是 fp32但到了部分移动 GPU 上会退化成 fp16指数部分精度不够数量靠近上限时循环条件和值比较出现舍入误差看起来就是随机闪烁。另一个隐藏问题是如果 _items.Count 超过 ItemSizeShader 的数组只声明了 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 更快也避免了大像素坐标下的精度断裂。在帧调试器里如果看到光圈边缘出现锯齿压缩感优先检查这个。这个效果我在不同项目里反复调整过从那以后每次写后处理脚本我都会先确认三件事渲染目标是不是屏幕分辨率、摄像机是否开启深度纹理、材质的生命周期有没有收尾。全部确认完才开始动算法。这个过程就像做菜前先检查锅和火免得菜炒糊了才发现油温不对。希望这篇拆解帮你在自己项目里少走一段弯路。本文还有配套的精品资源点击获取
返回列表