
刚在项目里要给弹窗弄一个毛玻璃背景翻了半天资料大多数方案要么指令复杂得一塌糊涂要么对设备要求高得离谱在低端安卓机上跑起来卡成幻灯片。折腾了两天总算理出一套在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 方案BUI相机渲染到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.CaptureScreenshotIntoRenderTextureUnity 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/22~32~3轻微柔边适合后台面板1/44~54~5明显磨砂适合弹窗背景1/88~105~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开销低但如果每帧都调用而不复用依然会造成大量显存带宽浪费。正确思路是缓存常驻的中间RTvoid 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/4480x270。5.1 关键性能指标指标数值说明RT创建耗时8ms首次/ 0ms复用第一次调用GetTemporary开销大后面复用几乎为0单次Blit耗时0.3~0.6ms480x270分辨率下每个Pass成本极低5次迭代总耗时约2.5~3.5ms其中后2次迭代因为纹理缩小成本更低内存增量约6~8MB两张RT总和GC.Alloc0稳定后避免频繁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 GetComponentRawImage(); 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 配套ShaderShader Custom/UGUI/GaussianBlur { Properties { _MainTex (Main Texture, 2D) white {} _BlurOffset (Blur Offset, Float) 1.0 } SubShader { Tags { QueueTransparent RenderTypeTransparent } 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拖进去或者留空让它自动查找。运行场景在弹窗打开时调用GetComponentUGUIBlurBackground().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里ReleaseTemporaryAndroid低端机掉帧迭代次数过多从5次降到3次RT从1/4降到1/6模糊层在部分机型上不同步新旧RT交替显示导致残留刷新时一次性替换不要分帧抄送这些坑基本都是我在多个平台上踩过的重点盯住“RT生命周期”和“Blit时序”就能躲开绝大多数坑。模糊方案做到这一步已经算是在UGUI框架内用最小成本拿到了90%以上完整后处理的视觉效果应对日常项目需求绰绰有余。最后再分享一个我个人的调试习惯在真机上Profile时先把模糊效果截一张图保存到相册然后在图片编辑器里对比原图和模糊图这样能快速判断是Shader读取的颜色空间出问题还是RT内容本身就不对。省得在真机上看一眼效果觉得不对又说不清哪里不对来回折腾大半天。这套方案设计初衷就是给中等规模项目省时间用的希望你也能在自己的工程里稳稳落地拿这个思路去演化出更酷的动态模糊效果。