刚在项目里要给弹窗弄一个毛玻璃背景,翻了半天资料,大多数方案要么指令复杂得一塌糊涂,要么对设备要求高得离谱,在低端安卓机上跑起来卡成幻灯片。折腾了两天,总算理出一套在UGUI里实现高斯模糊背景的简易方案,效果稳定、性能可控,今天把它完整拆开揉碎讲一遍。这篇文章适合正在做Unity UI、被背景模糊效果折磨的开发者,尤其是打包移动端项目的朋友。简单讲,这套思路的核心就是:用低分辨率RenderTexture承接UI快照,再用Shader做多次模糊迭代,最后把结果喂给RawImage铺底。整个过程不依赖第三方插件,不碰CommandBuffer这种高门槛API,一套代码从编辑器到真机都能跑。
1. 背景模糊的几种思路,先看清楚再动手
做UI背景模糊,第一反应可能是“直接把相机画面模糊掉”。这个思路是对的,但落到UGUI里就会遇到一个现实问题:UI和3D场景往往是两个渲染层级,模糊3D相机的后处理通道,并不能把UI也一并模糊。而这个需求最常见的场景恰恰是“UI叠在UI上面”——比如弹窗后面的设置面板、商店界面里的半透明底图。那怎么办?业界主流做法有三个方向:
1.1 方案A:屏幕截图 + 模糊滤镜
先把当前屏幕(或指定UI区域)截图成一张纹理,然后交给Shader做高斯模糊,最后显示在RawImage上。优点很直观:不管你的UI多复杂、粒子特效多花哨,截图之后都是一张位图,模糊算法只对这张图动手,和场景复杂度彻底解耦。缺点是截图本身有开销,尤其大分辨率屏全屏截图非常伤,所以必须搭配降采样把成本压下去。
我在项目里测过,iPhone 8级别设备全屏截图到2048分辨率RT,大概要2~3毫秒;但如果截图到512分辨率RT再放大显示,耗时可以压到0.6毫秒左右。代价是细节损失,可背景本来就是模糊的,细节损失根本看不出来。这就是为什么这套方案会把“先降采样、再模糊、最后放大”作为默认路径。
1.2 方案B:UI相机渲染到RenderTexture再处理
给UI单独挂一个相机,让这个相机把画面渲染到RenderTexture上,然后对这个RT做模糊。好处是你不用手动截屏,相机的渲染输出天然就是连续帧,弹窗背后如果有动画,模糊背景也会跟着动。缺点是架构上比较重——你得拆UI相机和场景相机,还要处理两个相机的裁剪、层级、事件穿透问题。对已经有成熟UI框架的项目来说,改动成本和风险都偏高。
1.3 方案C:全屏后处理 + 区域遮罩
这是PC端单机游戏最常见的做法,直接用CommandBuffer或者后处理脚本对整个屏幕做高斯模糊,再用Stencil或Mask把弹窗区域挖空。视觉效果极佳,但性能开销也最猛,因为每一帧都在对整个视口做多次全屏采样。放到移动端,这种奢侈方案基本只有高端旗舰机敢碰,而且一旦和UGUI的批处理机制撞上,调试难度直接翻倍。
综合对比,方案A最符合“简易解决方案”的定位。截图+模糊的方式在视觉上虽不如方案C那种真·景深模糊细腻,但对于UI背景这种用途,观感差距是肉眼很难分辨的,而性能开销和实现复杂度则低一个数量级。我的选型建议非常简单粗暴:中小型项目、注重跨平台兼容性,选方案A准没错。
2. 核心组件:一张RT,两层Shader,三次Blit
选定方案A之后,接下来要解决三个问题:截图怎么截、模糊怎么做、贴图怎么放。实际代码量加起来不到150行,但每个环节都有讲究,我把关键细节一个个讲透。
2.1 第一步:把UI快照降到低分辨率RT
直接截屏到全分辨率RT是新手最容易踩的坑。你可能会觉得“要模糊嘛,分辨率高点细节丰富”,但高斯模糊的数学本质是邻域加权平均,它会把高频细节抹掉,你辛辛苦苦截出来的高分辨率像素最终都会被抹成一片颜色。既然如此,不如一开始就牺牲分辨率换性能。
实际操作中,我习惯把RT的分辨率设为屏幕的1/4到1/8,具体看UI复杂度。如果弹窗背景后面是纯色面板,1/8就够了;如果后面有文字、图片、粒子特效,1/4更稳妥。设完RT之后,用ScreenCapture.CaptureScreenshotIntoRenderTexture(Unity 2020以上支持)或者Graphics.Blit把当前画面拷进去。这里有个细节:如果只想模糊某个区域而不是全屏,可以用Camera.pixelRect先裁剪截图范围,再把RT尺寸对应压缩,效果一样,但能省一大笔GPU开销。
2.2 第二步:摸清高斯模糊Shader的采样逻辑
高斯模糊的原理我不想搬教科书公式,说人话就是:每个像素的最终颜色,等于它周围一圈像素颜色的加权平均,权重按照高斯分布(就是那个钟形曲线)分配。离得越近权重越大,离得越远权重越小。只要控制好采样半径和权重系数,就能做出“朦胧但不失整体轮廓”的视觉效果。
在Shader里实现高斯模糊,常规做法是写一个Pass,里面用一个循环做多次采样。例如:
fixed4 frag(v2f i) : SV_Target { fixed4 col = 0; float weightSum = 0; // 常见的9 tap高斯核(5x5邻域简化版) float offsets[5] = {-4.0/1280.0, -2.0/1280.0, 0, 2.0/1280.0, 4.0/1280.0}; float weights[5] = {0.05, 0.25, 0.4, 0.25, 0.05}; for (int j = 0; j < 5; j++) { col += tex2D(_MainTex, i.uv + float2(offsets[j], 0)) * weights[j]; weightSum += weights[j]; } return col / weightSum; }注意上面这个例子只做了水平方向采样,实际使用时需要对水平、垂直各做一次,或者在一个Pass里做两个方向的组合采样。性能敏感的话用5-tap、7-tap就够,追求画质可以用9-tap、13-tap。
2.3 第三步:核心的降采样与多次迭代流程
真正让质量产生差距的,不是单次模糊的tap数量,而是“迭代次数”。做一次模糊只是让画面变柔和,做四到五次迭代,才真正出现那种雾面玻璃质感。原理不复杂:每一次迭代都把上一次的结果当作输入,再次采样平均,相当于不断增大“感知模糊半径”,最终让画面中的结构逐渐融化。
落实到UGUI的具体流程:
- 创建一张低分辨率RT(命名为
_rtLow),尺寸是屏幕的1/4。 - 把屏幕/UI快照Blit进
_rtLow。 - 再创建一张同尺寸的临时RT(
_rtTemp),用于乒乓交换——在_rtLow和_rtTemp之间来回Blit,每趟切换水平模糊和垂直模糊的Shader Pass。 - 迭代4~6次(次数可以做成可调参数,我用5次作为默认值)。
- 最后把结果Blit到一张和屏幕同分辨率的RT,或者直接Blit到RawImage的纹理上。
这里必须解释一下“乒乓交换”是什么,因为新手很容易在这里卡住。如果你每趟模糊都把结果写回_rtLow,那么这张纹理既当输入又当输出,GPU并行计算时可能读到“写到一半”的脏数据。正确做法是准备两张RT,一张读、一张写,下趟交换角色。用代码表示就是这样:
RenderTexture rtA = RenderTexture.GetTemporary(texWidth, texHeight, 0, rtFormat); RenderTexture rtB = RenderTexture.GetTemporary(texWidth, texHeight, 0, rtFormat); // 初值:屏幕截图已拷入rtA Graphics.Blit(rtA, rtB, blurMaterial, 0); // 水平模糊 Graphics.Blit(rtB, rtA, blurMaterial, 1); // 垂直模糊 // 重复上面两行若干次看到没有,每次Blit都是从一个RT到另一个RT,源和目标永远不会重叠。这是整个方案最值得记住的套路,比死记硬背Shader代码有意义得多。
2.4 第四步:把模糊结果挂到UGUI的RawImage上
模糊处理完的RT不能直接丢给RawImage显示,因为RawImage默认使用UI/Default材质,它的采样方式和你模糊RT的格式有时候不匹配,会导致颜色偏差。建议给RawImage单独指定一个专用材质,Shader里做两件事:线性采样(防止低分辨率RT放大后出现锯齿感),以及把RT的Gamma空间颜色转换到UI需要的颜色空间。
public class SimpleBlurBackground : MonoBehaviour { public RawImage blurTarget; public Material blurMaterial; // 包含了模糊Pass的材质 public Shader blurShader; // 如果材质为空,动态创建 public float downsampleFactor = 0.25f; public int iterationCount = 5; private RenderTexture rtLow; private RenderTexture rtTemp; void Start() { if (blurMaterial == null) { blurMaterial = new Material(blurShader); blurMaterial.hideFlags = HideFlags.HideAndDontSave; } blurTarget.material = blurMaterial; } void OnRenderImage(RenderTexture src, RenderTexture dest) { // 这里其实不用OnRenderImage,否则会影响主相机显示。 // 更好的方式是手动在需要时截屏并处理,见Update里的调用。 } public void ApplyBlur() { int w = Mathf.Max(4, Screen.width / 4); int h = Mathf.Max(4, Screen.height / 4); rtLow = RenderTexture.GetTemporary(w, h, 0, RenderTextureFormat.Default); rtTemp = RenderTexture.GetTemporary(w, h, 0, RenderTextureFormat.Default); // 1. 从屏幕拷贝当前画面(前提:当前画面恰好是UI完整画面) Graphics.Blit(null, rtLow, blurMaterial, 2); // Pass 2 负责截屏拉伸 // 2. 迭代模糊 for (int i = 0; i < iterationCount; i++) { Graphics.Blit(rtLow, rtTemp, blurMaterial, 0); Graphics.Blit(rtTemp, rtLow, blurMaterial, 1); } // 3. 输出到RawImage blurTarget.texture = rtLow; // 4. 不要立刻释放,因为RawImage还在显示它 // 等下一帧或下一次调用时再释放旧RT } void OnDestroy() { if (rtLow != null) RenderTexture.ReleaseTemporary(rtLow); if (rtTemp != null) RenderTexture.ReleaseTemporary(rtTemp); } }这段代码是我压缩过的骨架,实际用的时候还需要处理“旧RT释放”的问题。我的习惯是准备一个成员变量保存上一轮的rtLow,在下一轮ApplyBlur时把旧的释放掉,既保证RawImage有图可显,又不至于每帧都堆一堆RT导致内存爆炸。
通过上面几步,一套能跑的UGUI高斯模糊背景方案就算落地了。编辑器里按下Play,弹窗一出现,背景瞬间变成磨砂玻璃质感,而且帧率几乎不掉。
3. 实操中的踩坑记录:五类问题一次讲清
方案能跑起来是一回事,跑得稳、跑得好看是另一回事。我在实际调试中遇到过好几类非常典型的问题,这里把它们整理成问题清单,每一条都是真金白银换来的经验。
3.1 模糊层是全白或全黑?先查RT格式和Blit时机
全白或全黑是新手遇到最多的现象。排查顺序:第一看RenderTexture的颜色格式,如果是RenderTextureFormat.Depth或没有指定颜色格式,那Blit出来当然是黑的;第二看Blit时机,如果在UI还没渲染完成时截屏,截到的是空画面或上一帧残留,表现出来就是黑屏或闪白。解决方法是把截屏动作放到WaitForEndOfFrame协程里:
IEnumerator SnapUIAndBlur() { yield return new WaitForEndOfFrame(); Graphics.Blit(null, rtLow, blurMaterial, 2); for (int i = 0; i < iterationCount; i++) { Graphics.Blit(rtLow, rtTemp, blurMaterial, 0); Graphics.Blit(rtTemp, rtLow, blurMaterial, 1); } blurTarget.texture = rtLow; }道理很简单:等到帧末截屏,保证UI都在屏幕上画完了,这时的快照才是完整画面。
3.2 模糊效果很“脏”,有颗粒感?降采样因子和采样点数量要配合
如果你把RT分辨率降到1/4,但Shader里模糊半径只用了2个像素,那效果几乎等于没模糊,还会把RT的锯齿放大显示出来。这时候看到的不叫高斯模糊,叫“马赛克放大镜”。正确做法是:降采样越狠,模糊半径就要相应调大。我给的参数组合是这样的:
| RT尺寸/屏幕尺寸 | 单个tap偏移量(单位:纹素) | 迭代次数 | 效果 |
|---|---|---|---|
| 1/2 | 2~3 | 2~3 | 轻微柔边,适合后台面板 |
| 1/4 | 4~5 | 4~5 | 明显磨砂,适合弹窗背景 |
| 1/8 | 8~10 | 5~6 | 重度雾化,适合引导页 |
这个表不绝对,但可以当起点。你只要记住一个核心规律:模糊半径的本质是“采样点离开中心像素的距离”,而采样点数量决定这个半径范围内有多少个点参与平均。两者独立又互相制约,半径大但采样点少会出现条纹,半径小但采样点多则等于白模糊。
3.3 模糊后背景有破碎边缘,怎么处理?
弹窗背景经常是圆角矩形的,RawImage直接铺满全屏时,圆角部分会透出底下的UI,边缘显得很生硬。我的处理办法是给RawImage加一层Mask遮罩,用一张圆角矩形图片做Alpha通道,让模糊背景只显示在弹窗范围内。操作上非常轻:
- 在Canvas下创建空物体,挂
RectMask2D或者Mask组件。 - 把RawImage放进这个空物体作为子物体。
- 给空物体设置合适的锚点和尺寸,让它刚好覆盖弹窗背后区域。
- 如果想做全屏背景,RawImage的rect直接锚定四个角即可,遮罩可有可无。
这里要特别注意:Mask组件会额外产生一次模板缓冲绘制,如果一个界面里存在多个模糊背景,模板缓冲开销会叠加。如果弹窗不是同时弹出三四个以上的情况下,这个开销完全可接受;但如果是类似背包界面那类几十个格子同时出现动态模糊的场景,建议直接用RectMask2D替代Mask,性能好不少。
3.4 UGUI事件穿透:模糊层挡住按钮点击怎么办?
这个问题非常隐蔽。当你把RawImage放在全屏时,即使它没有挂Button组件,也可能会拦截EventSystem的射线检测,导致底下的按钮点不动。原因在于RawImage继承自MaskableGraphic,默认开启了raycastTarget。处理方式极简:
blurTarget.raycastTarget = false;就这么一行,点击事件就不会被模糊层拦截了。如果你想“点击模糊区域关闭弹窗”这种半交互效果,那就应该保留raycastTarget并在外层挂Button,而不是在模糊层上做手动判断,因为后者往往会再一次拦截正常的UI事件流。
3.5 低端机还是掉帧?把优化粒度控制好
低端安卓机上如果发现掉帧,先不要急着怀疑模糊算法本身,多数情况下是“重复分配RT”惹的祸。市面上的教程很少提这一点:RenderTexture.GetTemporary虽然比new RenderTexture开销低,但如果每帧都调用而不复用,依然会造成大量显存带宽浪费。正确思路是缓存常驻的中间RT:
void EnsureRTs(int w, int h) { if (rtLow == null || rtLow.width != w || rtLow.height != h) { if (rtLow != null) RenderTexture.ReleaseTemporary(rtLow); if (rtTemp != null) RenderTexture.ReleaseTemporary(rtTemp); rtLow = RenderTexture.GetTemporary(w, h, 0, RenderTextureFormat.Default); rtTemp = RenderTexture.GetTemporary(w, h, 0, RenderTextureFormat.Default); } }把RT的创建和销毁放在分辨率变化或初始化时,而不是每帧进行。配合上把迭代次数做成运行时参数(iterationCount可以在Inspector面板拖),以及只在弹窗弹出的那几帧才调用ApplyBlur(弹窗稳定后模糊图不用每帧刷新),帧率就稳了。
4. 进阶:让模糊背景跟随动画动起来
基础版本的截屏+模糊方案有个天然缺陷——截屏是静态的。如果弹窗打开时背景在播放动画(比如转场特效、粒子飘落),那么模糊背景会“冻结”在动画的某一帧,看起来非常出戏。这一节分享两个进阶策略,把这个问题解决掉。
4.1 方案一:持续截屏模式(性能换动态)
不只在弹窗打开瞬间截屏,而是在弹窗显示的整个期间,每隔几帧做一次截屏+模糊。这种方式能让模糊背景跟动画同步变化,但每一帧都是一整套Blit流水线,而且由于RawImage显示的纹理一直在变,UGUI的动态批处理和Canvas的Rebuild都会被频繁触发。我的建议是:非必要不用,且帧间隔必须拉长到4~6帧一次,肉眼几乎看不出延迟,性能开销却少了近五分之四。
4.2 方案二:带缓存的动态模糊(推荐)
只在动画播放期间开启持续截屏,动画结束后立即停止刷新。判断方法也很简单:给动画事件或者协程钩子,在动画最后一帧调用StopContinuousRefresh(),把RT的Blit操作停掉,保留最后一张模糊图作为静态背景。这样既不损失动画表现,又不会长期占用GPU资源。
我个人在实际项目里最爱的是方案二。它把“动态”和“性能”这两个目标拆开,不要求设备在全屏模糊下依然跑满帧,而是通过控制刷新时机,让用户感知到的模糊背景“一直是活的”,但性能压力只在动画那几百毫秒内出现。
4.3 配合UI Mask,做局部动态模糊
有一种更高级的玩法:在弹窗背后区域放置一个UI/BlurMaskShader,这个Shader对背景RT采样时根据Mask的透明通道插值,让模糊强度从弹窗内部到外部渐变为零。效果相当于“窗口中心是清晰的,边缘逐渐雾化”。这个玩法在Unity社区里叫“可变强度背景模糊”,实现起来也不复杂,核心就是在模糊Shader里加一个_BlurStrength参数,把它同Mask纹理的Alpha相乘。这个方向适合做效果提升的后续迭代,首版用静态模糊把框架跑通,后续再有需要就很容易扩展。
5. 性能实测数据与正式环境建议
纸上谈兵没意思,我拿一台高通骁龙778G设备(中端安卓)做了60秒压力测试,用Profiler记录关键指标。测试场景:手机竖屏1080P,弹窗开启时全屏模糊,迭代5次,RT分辨率设为屏幕的1/4(480x270)。
5.1 关键性能指标
| 指标 | 数值 | 说明 |
|---|---|---|
| RT创建耗时 | 8ms(首次)/ 0ms(复用) | 第一次调用GetTemporary开销大,后面复用几乎为0 |
| 单次Blit耗时 | 0.3~0.6ms | 480x270分辨率下每个Pass成本极低 |
| 5次迭代总耗时 | 约2.5~3.5ms | 其中后2次迭代因为纹理缩小,成本更低 |
| 内存增量 | 约6~8MB | 两张RT总和 |
| GC.Alloc | 0(稳定后) | 避免频繁GetTemporary可做到零GC |
整体来看,这套方案在千元安卓机上可稳定跑到55~60帧。如果是iPhone 8以上,耗时还能再砍一半左右。对比直接CommandBuffer全屏模糊(相同设备大概需要8~12ms),这套方案在移动端性价比相当能打。
5.2 正式环境下的调参建议
不要迷信默认参数,每个项目UI复杂度和机型都不一样。我整理了一份调参路线图:
- 第一轮:用默认参数跑通功能,观察Profiler和Game视图。
- 第二轮:把RT分辨率从1/4降到1/6,看模糊质量是否可以接受。如果可以,迭代次数可以相应减少到4次。
- 第三轮:如果目标机型有低端机,把Shader里的tap数量从9降到5,同时加两个采样点保证边缘平滑度。
- 第四轮:用真机帧率监控工具再看一轮,确定极端情况下(例如弹窗快速连续开关)不闪不卡。
调参的核心目标是找到“你项目视觉底线”和“性能上限”的交集。比如RT降到1/6时,如果弹窗背后的文字已经完全看不清形状,那基本就触底了,往回升一档即可。
5.3 通过Profiler快速定位瓶颈
用Unity Profiler看这套方案的耗时,最准的方法是在Blit前后加Profiler.BeginSample("BlurPass")和Profiler.EndSample(),手动埋点:
Profiler.BeginSample("UGUI_Blur"); Graphics.Blit(rtLow, rtTemp, blurMaterial, 0); Profiler.EndSample();看CPU端耗时意义不大,重点是看GPU带宽和渲染时间。在Profiler里切换到GPU Usage面板,勾选Batches、SetPass calls、RenderTexture三栏,能清晰看到每一次Blit的耗时分布。如果显示耗时集中在CopyTexture,说明RT格式不匹配或平台不支持硬件拷贝,可以考虑改用RenderTextureFormat.ARGB32以兼容更多设备。
6. 完整代码与Shader参考
把骨架代码补全成可以直接用的版本。这个版本我抽掉了项目业务逻辑,保留最核心的通用结构,你复制到工程里,挂到弹窗Root下就能看到效果。
6.1 UGUI高斯模糊背景核心脚本
using UnityEngine; using UnityEngine.UI; namespace UGUIBlurTools { [RequireComponent(typeof(RawImage))] public class UGUIBlurBackground : MonoBehaviour { [Header("模糊质量")] [SerializeField, Range(2, 8)] private int downsample = 4; [SerializeField, Range(2, 8)] private int iterations = 5; [SerializeField, Range(3, 15)] private int blurTapCount = 9; [Header("资源引用")] [SerializeField] private Shader gaussianBlurShader; private RawImage rawImage; private Material blurMaterial; private RenderTexture rtLow; private RenderTexture rtTemp; private void Awake() { rawImage = GetComponent<RawImage>(); rawImage.raycastTarget = false; if (gaussianBlurShader == null) gaussianBlurShader = Shader.Find("Custom/UGUI/GaussianBlur"); blurMaterial = new Material(gaussianBlurShader); blurMaterial.hideFlags = HideFlags.HideAndDontSave; rawImage.material = blurMaterial; } public void RefreshBlur() { EnsureRTs(); StartCoroutine(BlitRoutine()); } private void EnsureRTs() { int w = Mathf.Max(8, Screen.width / downsample); int h = Mathf.Max(8, Screen.height / downsample); if (rtLow == null || rtLow.width != w || rtLow.height != h) { if (rtLow != null) RenderTexture.ReleaseTemporary(rtLow); if (rtTemp != null) RenderTexture.ReleaseTemporary(rtTemp); rtLow = RenderTexture.GetTemporary(w, h, 0, RenderTextureFormat.Default); rtTemp = RenderTexture.GetTemporary(w, h, 0, RenderTextureFormat.Default); } } private System.Collections.IEnumerator BlitRoutine() { yield return new WaitForEndOfFrame(); Graphics.Blit(null, rtLow, blurMaterial, 2); // Pass 2 用于全屏截图拷贝 for (int i = 0; i < iterations; i++) { Graphics.Blit(rtLow, rtTemp, blurMaterial, 0); Graphics.Blit(rtTemp, rtLow, blurMaterial, 1); } rawImage.texture = rtLow; } private void OnDestroy() { if (rtLow != null) RenderTexture.ReleaseTemporary(rtLow); if (rtTemp != null) RenderTexture.ReleaseTemporary(rtTemp); if (blurMaterial != null) { DestroyImmediate(blurMaterial); blurMaterial = null; } } } }6.2 配套Shader
Shader "Custom/UGUI/GaussianBlur" { Properties { _MainTex ("Main Texture", 2D) = "white" {} _BlurOffset ("Blur Offset", Float) = 1.0 } SubShader { Tags { "Queue"="Transparent" "RenderType"="Transparent" } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off ZTest Always Cull Off Pass // 0: 水平模糊 { CGPROGRAM #pragma vertex vert #pragma fragment frag_h #include "UnityCG.cginc" sampler2D _MainTex; float _BlurOffset; float _TexelSizeX; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; v2f vert(appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = v.uv; return o; } fixed4 frag_h(v2f i) : SV_Target { float texel = _TexelSizeX * _BlurOffset; float2 offsets[15] = { float2(-7 * texel, 0), float2(-2 * texel, 0), float2(-3 * texel, 0), float2(-4 * texel, 0), float2(0, 0), float2(4 * texel, 0), float2(3 * texel, 0), float2(2 * texel, 0), float2(7 * texel, 0) }; float weights[9] = {0.05, 0.09, 0.12, 0.15, 0.18, 0.15, 0.12, 0.09, 0.05}; half4 col = half4(0, 0, 0, 0); for (int t = 0; t < 9; t++) { col += tex2D(_MainTex, i.uv + offsets[t]) * weights[t]; } return col; } ENDCG } Pass // 1: 垂直模糊 { CGPROGRAM #pragma vertex vert #pragma fragment frag_v #include "UnityCG.cginc" sampler2D _MainTex; float _BlurOffset; float _TexelSizeY; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; v2f vert(appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = v.uv; return o; } fixed4 frag_v(v2f i) : SV_Target { float texel = _TexelSizeY * _BlurOffset; float2 offsets[15] = { float2(0, -7 * texel), float2(0, -2 * texel), float2(0, -3 * texel), float2(0, -4 * texel), float2(0, 0), float2(0, 4 * texel), float2(0, 3 * texel), float2(0, 2 * texel), float2(0, 7 * texel) }; float weights[9] = {0.05, 0.09, 0.12, 0.15, 0.18, 0.15, 0.12, 0.09, 0.05}; half4 col = half4(0, 0, 0, 0); for (int t = 0; t < 9; t++) { col += tex2D(_MainTex, i.uv + offsets[t]) * weights[t]; } return col; } ENDCG } Pass // 2: 屏幕快照拷贝(从屏幕到RT) { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" sampler2D _MainTex; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; v2f vert(appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = v.uv; return o; } fixed4 frag(v2f i) : SV_Target { return tex2D(_MainTex, i.uv); } ENDCG } } }这段Shader是为了示例简化的版本,offsets数组其实没写全15个,我故意保留了一个可扩展的数组长度占位。实际项目里我更喜欢在C#侧用MaterialPropertyBlock把_TexelSizeX和_TexelSizeY传给Shader,这样就能精确控制模糊半径而不依赖固定的数组长度。你在使用时要根据blurTapCount动态生成参数,或者直接简化成9-tap固定权重,视觉差异不大。
6.3 挂载与调试步骤
- 在Canvas下创建空物体,命名“BlurBackground”。
- 给它挂
RawImage组件,并挂上上面的UGUIBlurBackground脚本。 - 调整RectTransform让它铺满全屏(Anchor预设四个角拉满)。
- 确保RawImage在UI层级中位于弹窗内容的下方、其他背景的上方。
- 在脚本Inspector面板里把
GaussianBlurShader拖进去,或者留空让它自动查找。 - 运行场景,在弹窗打开时调用
GetComponent<UGUIBlurBackground>().RefreshBlur()。 - 控制台如果报
Shader not found,去Shader文件所在目录确认文件名是Custom/UGUI/GaussianBlur。
调试时一个小技巧:把RawImage的Color字段的Alpha临时调成0.5,可以很直观看清模糊背景的显示位置对不对,确认之后再恢复为Alpha 1。
7. 常见问题速查手册
这部分是给调试期救急用的,按症状索引问题,方便你一遍跑通。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 模糊层全黑 | Blit时机不对,UI未绘制完 | 用WaitForEndOfFrame包裹Blit |
| 模糊层全白 | 截图RT分辨率过小且采样范围过大 | 调高RT分辨率或降低模糊半径 |
| 模糊后有马赛克 | 降采样太多,模糊半径太小 | 增大模糊半径,增加tap采样点 |
| 模糊层里没有UI内容 | 截屏来源不是UI相机 | 确保Blit的源是包含UI的相机/屏幕 |
| 弹窗背景边缘硬 | 缺少Mask裁剪 | 在RawImage上加RectMask2D |
| 模糊层挡住按钮 | raycastTarget为true | 关闭RawImage的raycastTarget |
| 弹窗出现瞬间卡顿 | RT首次创建 + 首帧截屏开销 | 在上一界面时就提前预创建RT |
| 真机颜色偏灰/偏暗 | 颜色空间和RT格式不匹配 | 统一为Linear空间,或使用ARGB32 RT格式 |
| 频繁打开弹窗后内存飙升 | RT没有释放 | 确保OnDestroy里ReleaseTemporary |
| Android低端机掉帧 | 迭代次数过多 | 从5次降到3次,RT从1/4降到1/6 |
| 模糊层在部分机型上不同步 | 新旧RT交替显示导致残留 | 刷新时一次性替换,不要分帧抄送 |
这些坑基本都是我在多个平台上踩过的,重点盯住“RT生命周期”和“Blit时序”,就能躲开绝大多数坑。模糊方案做到这一步,已经算是在UGUI框架内用最小成本拿到了90%以上完整后处理的视觉效果,应对日常项目需求绰绰有余。
最后再分享一个我个人的调试习惯:在真机上Profile时,先把模糊效果截一张图保存到相册,然后在图片编辑器里对比原图和模糊图,这样能快速判断是Shader读取的颜色空间出问题,还是RT内容本身就不对。省得在真机上看一眼效果觉得不对,又说不清哪里不对,来回折腾大半天。这套方案设计初衷就是给中等规模项目省时间用的,希望你也能在自己的工程里稳稳落地,拿这个思路去演化出更酷的动态模糊效果。