☰
UGUI中实现高性能高斯模糊背景的简易方案
2026/10/5 9:43:43 网站建设 项目流程

刚在项目里要给弹窗弄一个毛玻璃背景,翻了半天资料,大多数方案要么指令复杂得一塌糊涂,要么对设备要求高得离谱,在低端安卓机上跑起来卡成幻灯片。折腾了两天,总算理出一套在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的具体流程:

  1. 创建一张低分辨率RT(命名为_rtLow),尺寸是屏幕的1/4。
  2. 把屏幕/UI快照Blit进_rtLow。
  3. 再创建一张同尺寸的临时RT(_rtTemp),用于乒乓交换——在_rtLow和_rtTemp之间来回Blit,每趟切换水平模糊和垂直模糊的Shader Pass。
  4. 迭代4~6次(次数可以做成可调参数,我用5次作为默认值)。
  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通道,让模糊背景只显示在弹窗范围内。操作上非常轻:

  1. 在Canvas下创建空物体,挂RectMask2D或者Mask组件。
  2. 把RawImage放进这个空物体作为子物体。
  3. 给空物体设置合适的锚点和尺寸,让它刚好覆盖弹窗背后区域。
  4. 如果想做全屏背景,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.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 = 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 挂载与调试步骤

  1. 在Canvas下创建空物体,命名“BlurBackground”。
  2. 给它挂RawImage组件,并挂上上面的UGUIBlurBackground脚本。
  3. 调整RectTransform让它铺满全屏(Anchor预设四个角拉满)。
  4. 确保RawImage在UI层级中位于弹窗内容的下方、其他背景的上方。
  5. 在脚本Inspector面板里把GaussianBlurShader拖进去,或者留空让它自动查找。
  6. 运行场景,在弹窗打开时调用GetComponent<UGUIBlurBackground>().RefreshBlur()。
  7. 控制台如果报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内容本身就不对。省得在真机上看一眼效果觉得不对,又说不清哪里不对,来回折腾大半天。这套方案设计初衷就是给中等规模项目省时间用的,希望你也能在自己的工程里稳稳落地,拿这个思路去演化出更酷的动态模糊效果。

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

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

立即咨询