
做游戏UI的朋友大概率都遇到过这种需求背包、商城、设置这种弹窗打开时背后的场景要糊成一片好让玩家把目光全放在弹窗上。这两年我经手的几个Unity项目里UGUI高斯模糊背景基本成了标配需求。这篇文章想分享的是一个不依赖第三方插件、不需要换管线、代码量也不大的简易方案先用相机缓存一帧低分辨率画面再用高斯模糊Shader做多次Blit最后显示在UGUI的RawImage上。不管你是刚接触Unity的新人还是已经用UGUI写过不少界面的老手这套思路都值得参考因为它不仅能跑通还能帮你避开我当年踩过的那些坑。1. 为什么UGUI很难直接做背景模糊1.1 一个看似简单、但路走不通的思路很多第一次做这个功能的人会想Canvas上挂一个模糊组件把整个UI区域模糊掉不就行了实际做下去就会发现UGUI的UI元素是直接绘制到屏幕空间的它是一个个独立的矩形渲染时不会像场景物体那样有“后处理”的插入点。UI元素根本拿不到“自己背后已经画了哪些像素”这个信息。你给某个Image加一个模糊Shader它只能模糊自身的那张贴图没办法把楼下那层场景、按钮、头像框统统揉进去。这就导致网上搜“UGUI高斯模糊”时方案五花八门但很多其实都在绕弯路有人把整个Canvas塞进RenderTexture有人用第三方后处理插件还有人干脆让美术出两张预烘焙模糊图。这些方案要么改动太大要么卡在特定渲染管线里很难直接抄进项目。1.2 核心矛盾UGUI是屏幕空间叠加层要理解这个问题的根源得先搞清UGUI的渲染位置。UGUI的Canvas有三种渲染模式。默认的Screen Space - Overlay模式是直接把UI绘制在屏幕坐标上它不经过3D相机的渲染流程。也就是说你想对屏幕做后处理模糊相机那边只处理到了3D场景压根碰不到Overlay Canvas上的UI像素。即使你给主相机加了高斯模糊后处理弹窗背后的按钮、文字、面板依然清晰得扎眼。这也是为什么很多“UGUI模糊”教程最后做出来的效果其实是“场景模糊 半透明黑底遮罩”UI本身并没有跟着虚化。如果你的游戏主界面本来就很干净这种效果够用但如果你想要“整个背景UI全部糊掉只留当前弹窗”就必须把UI的渲染方式重新分层。1.3 两条可行路线快照式 vs 实时式在实际项目里UGUI背景模糊最终落地的方案基本可以分成两类。第一类是快照式。弹窗打开时先抓取一帧当前画面对这张图做高斯模糊把模糊结果当作RawImage的纹理显示在弹窗底部。这种做法最省性能弹窗打开后背景是静止的但绝大多数UI弹窗背包、商城、角色面板并不需要背景实时动起来静态模糊图完全够看。第二类是实时式。每一帧都把场景渲染到RenderTexture然后立即做一次模糊再上屏背景会跟着场景一起动。视觉效果确实无缝但代价是每帧都要跑几次全屏Blit加几次后处理采样移动端很容易发热掉帧。这篇文章主推快照式理由很简单它才是真正意义上的“简易解决方案”。你在弹窗打开的那一瞬间做一次模糊后面就不用再管它了。除了内存里多一张RT几乎没有持续性能开销。2. 高斯模糊要有“成本意识”原理与参数2.1 从二维高斯核到一维分离滤波高斯模糊的原理简单说就是每个像素取周围一圈像素的加权平均值权重服从高斯分布。离中心越近的像素权重越大离得越远权重越小所以模糊后的画面既柔和又不会出现生硬的边缘线条。如果直接用二维高斯核做卷积GPU上每个像素要采样周围一个方形区域里的很多个点成本很高。好在高斯核有一个非常实用的数学性质二维高斯核可以拆成两个一维高斯核。先在水平方向做一维模糊再在垂直方向做一维模糊最终效果和直接做二维卷积几乎一样但计算量从O(n^2)降到了O(2n)。所以你会看到几乎所有GPU高斯模糊Shader都是两个Pass第一个Pass做水平采样第二个Pass做垂直采样。中间用临时RenderTexture接力。2.2 用降采样和迭代把模糊半径做大模糊效果强不强的关键是采样半径够不够大。但GPU上采样半径越大每个像素要读取的纹理次数就越多性能会指数级变差。一个非常划算的做法就是降采样。先把图片缩小到1/2、甚至1/4分辨率再在这个低分辨率图上做模糊最后放大到原始尺寸。分辨率降低之后一次采样在原始画面里对应的范围就变大了模糊半径等效放大一倍甚至更多。这也是为什么所有后处理模糊方案里降采样都是标配。还有一个词叫迭代。一次5Tap的高斯模糊只能把像素信息扩散到周围几个像素扩散半径有限。如果对同一张图连续做两三次模糊扩散范围会一层层往外滚最终效果比一次性用大核模糊更柔和性能反而更可控。我常用的起点参数是降采样倍数downsample2迭代次数iterations3采样扩散距离blurSpread1.2到1.5。这个组合在移动端能跑视觉效果也基本达到弹窗背景的常见要求。想要更糊就加iterations想省性能就把downsample调到4。2.3 采样优化5次采样顶9次刚才说的5Tap采样是指每Pass只采集5个像素点按照高斯权重加权平均。这其实是一个经典的GPU优化技巧。本来一个9点的高斯模糊要采样9个纹理点但GPU纹理采样器支持双线性插值一次采样就能获取相邻两个像素的加权平均结果。利用这个特性可以把两个相邻采样点合并成一个位于中间位置的采样点采样次数直接从9次降到5次视觉差异几乎看不出来。经典的权重和偏移量如下中心点偏移0权重0.227027两侧第一对采样点偏移1.3846个像素权重0.316216两侧第二对采样点偏移3.2308个像素权重0.070270这五个权重加起来约等于1而且正好是一组按高斯曲线离散化后的近似值。Shader里可以直接抄这组数效果比那种盲目取一大圈像素求平均的坏方案细腻得多。3. 简易方案完整落地截图缓存 Blit模糊 RawImage3.1 第一步搭好UI层级把模糊层放对位置方案的大前提是弹窗Canvas仍然使用默认的Screen Space - Overlay模式这样弹窗本身不会被主相机渲染进缓存RT抓屏时就不需要额外隐藏弹窗。实际场景中你在弹窗Canvas下先建一个全屏RawImage命名为BlurBG放在层级最底部。RawImage的RectTransform用Anchor拉伸铺满全屏也就是Min和Max都设成(0,0)-(1,1)四边Offset全为0。然后记住一个重要操作把RawImage上的Raycast Target勾掉。不然这个全屏图会把整个界面变成一个大号按钮下面所有UI的点击事件全部被吃掉。弹窗里的其他内容放在这个RawImage之后创建渲染顺序自然就在模糊层之上。如果项目同时用多个Canvas检查sortingOrder确保弹窗Canvas排在主界面上面。3.2 第二步写模糊Shader模糊Shader是整个方案的性能核心。下面这个Shader是我一直在用的两个Pass分别是水平模糊和垂直模糊可调参数只有三个采样扩散距离、降采样倍数由脚本控制、迭代次数由脚本控制。Shader Custom/UI/GaussianBlur { Properties { _MainTex (Base (RGB), 2D) white {} _BlurSize (Blur Size, Range(0, 8)) 1 } SubShader { ZTest Always Cull Off ZWrite Off CGINCLUDE #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_TexelSize; float _BlurSize; struct v2f { float4 pos : SV_POSITION; half2 uv[5] : TEXCOORD0; }; v2f vertBlur (appdata_img v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); half2 uv v.texcoord; half2 d _MainTex_TexelSize.xy * _BlurSize; o.uv[0] uv; o.uv[1] uv d * 1.3846153846; o.uv[2] uv - d * 1.3846153846; o.uv[3] uv d * 3.2307692308; o.uv[4] uv - d * 3.2307692308; return o; } fixed4 fragBlur (v2f i) : SV_Target { fixed4 c tex2D(_MainTex, i.uv[0]) * 0.2270270270; c tex2D(_MainTex, i.uv[1]) * 0.3162162162; c tex2D(_MainTex, i.uv[2]) * 0.3162162162; c tex2D(_MainTex, i.uv[3]) * 0.0702702703; c tex2D(_MainTex, i.uv[4]) * 0.0702702703; return c; } ENDCG Pass { NAME BLUR_H CGPROGRAM #pragma vertex vertBlur #pragma fragment fragBlur ENDCG } Pass { NAME BLUR_V CGPROGRAM #pragma vertex vertBlur #pragma fragment fragBlur ENDCG } } }注意SubShader开头三行ZTest Always Cull Off ZWrite Off。后处理Blit时我们不在意深度测试结果也不希望把RT写入深度缓冲。加这三行能避免很多奇怪的渲染问题。还有个细节_MainTex_TexelSize是Unity自动传入的源纹理单像素尺寸。水平Pass里靠它算出每个像素的UV偏移量垂直Pass同理。所以这个Shader无论处理多大分辨率的RT模糊的物理范围都能保持正确。3.3 第三步用OnRenderImage缓存屏幕画面内置渲染管线下最稳的抓屏时机是OnRenderImage。在这个回调里相机的画面已经渲染完毕我们可以把低分辨率拷贝存一份再正常把画面输出到屏幕。挂在主相机的脚本长这样using UnityEngine; public class ScreenCache : MonoBehaviour { [SerializeField] private int cacheDownsample 2; private RenderTexture cacheRT; public RenderTexture GetCacheTexture() { return cacheRT; } private void OnRenderImage(RenderTexture source, RenderTexture destination) { int w Mathf.Max(2, source.width / cacheDownsample); int h Mathf.Max(2, source.height / cacheDownsample); if (cacheRT null || cacheRT.width ! w || cacheRT.height ! h) { if (cacheRT ! null) { RenderTexture.ReleaseTemporary(cacheRT); } cacheRT RenderTexture.GetTemporary(w, h, 0, RenderTextureFormat.Default); } Graphics.Blit(source, cacheRT); Graphics.Blit(source, destination); } private void OnDestroy() { if (cacheRT ! null) { RenderTexture.ReleaseTemporary(cacheRT); cacheRT null; } } }为什么这个方法比临时把相机targetTexture切走更稳因为OnRenderImage只是截获“相机已经渲染好的中间结果”你不会打断相机的正常渲染流程也不会出现一帧黑屏。缓存RT的分辨率固定为主画面的四分之一内存和带宽开销都很小。弹窗打开时直接拿这张缓存RT做模糊即可不需要再拉高分辨率画面。如果你已经有一套自定义后处理链也可以把这份缓存拷贝逻辑合并进已有的OnRenderImage只要保证最后一行Graphics.Blit(source, destination)不丢就行。3.4 第四步弹窗里做模糊并赋给RawImage核心的模糊管理器脚本如下。它处理三件事创建临时RT、迭代做高斯模糊、把结果交给RawImage。using UnityEngine; using UnityEngine.UI; public class UGUIGaussianBlur : MonoBehaviour { [Header(UI绑定)] [SerializeField] private RawImage blurImage; [SerializeField] private ScreenCache screenCache; [SerializeField] private Shader blurShader; [Header(模糊参数)] [Range(1, 8)] public int downsample 2; [Range(1, 8)] public int iterations 3; [Range(0f, 4f)] public float blurSpread 1.2f; private Material blurMaterial; private RenderTexture sourceRT; private RenderTexture tempRT; private void Awake() { if (blurImage null) blurImage GetComponentRawImage(); if (screenCache null) screenCache FindObjectOfTypeScreenCache(); if (blurShader null) blurShader Shader.Find(Custom/UI/GaussianBlur); blurMaterial new Material(blurShader); blurImage.raycastTarget false; } public void ApplyBlur() { if (blurMaterial null || screenCache null) { Debug.LogWarning(UGUIGaussianBlur: 缺少必要引用); return; } RenderTexture cacheRT screenCache.GetCacheTexture(); if (cacheRT null) return; int w Mathf.Max(2, cacheRT.width / downsample); int h Mathf.Max(2, cacheRT.height / downsample); sourceRT RenderTexture.GetTemporary(w, h, 0, RenderTextureFormat.Default); tempRT RenderTexture.GetTemporary(w, h, 0, RenderTextureFormat.Default); blurMaterial.SetFloat(_BlurSize, blurSpread); Graphics.Blit(cacheRT, sourceRT); for (int i 0; i iterations; i) { Graphics.Blit(sourceRT, tempRT, blurMaterial, 0); Graphics.Blit(tempRT, sourceRT, blurMaterial, 1); } blurImage.texture sourceRT; } public void ClearBlur() { if (blurImage ! null) blurImage.texture null; if (sourceRT ! null) { RenderTexture.ReleaseTemporary(sourceRT); sourceRT null; } if (tempRT ! null) { RenderTexture.ReleaseTemporary(tempRT); tempRT null; } } private void OnDestroy() { ClearBlur(); if (blurMaterial ! null) Destroy(blurMaterial); } }注意一个细节我先用Graphics.Blit(cacheRT, sourceRT)把缓存图拷进一个与模糊迭代尺寸一致的RT这样后续迭代中的水平Pass和垂直Pass不会互相污染。两个临时RT轮换用来回迭代结束后sourceRT里就是最终模糊结果。模糊后的纹理直接赋给RawImage的texture字段。弹窗关闭时调用ClearBlur()释放临时RT并清掉RawImage的纹理引用避免Unity里常见的“引用不存在的RenderTexture”报错。3.5 调用方式和可复用流程调用顺序极其简单。弹窗打开时先执行模糊再显示弹窗Canvas即可。public void ShowDialog() { blurPanel.gameObject.SetActive(true); blurBackground.ApplyBlur(); dialogCanvas.gameObject.SetActive(true); } public void CloseDialog() { blurBackground.ClearBlur(); blurPanel.gameObject.SetActive(false); dialogCanvas.gameObject.SetActive(false); }如果你是Screen Space - Overlay模式上面的顺序完全没问题。因为Overlay UI不参与主相机渲染抓屏时根本不包含弹窗本身。但如果你因为某些原因把弹窗Canvas切成了Screen Space - Camera模式那抓屏前必须先隐藏弹窗Canvas否则弹窗自己会被抓进那张背景图里出现套娃效果。这种情况我建议先把Canvas模式改回Overlay或者专门拆一个独立相机处理弹窗层不要让弹窗和场景共用同一个后处理流。如果想要弹窗打开时有一点点淡入动画RawImage的CanvasGroup做个alpha渐变即可模糊RT本身不需要重新生成。4. URP、HDRP与进阶玩法4.1 URP里取代OnRenderImage的方案Unity在URP里已经把OnRenderImage这条路堵死了。想沿用这套快照式模糊思路得改用ScriptableRendererFeature加ScriptableRenderPass。核心思路是在相机渲染的后处理阶段把当前相机画面拷贝到一张临时RT按照同样的迭代Blit方式做模糊最后把模糊结果写入一个静态可访问的RenderTexture。UGUI那边RawImage每帧从静态变量里读取纹理并显示。using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public class BlurBackgroundFeature : ScriptableRendererFeature { [System.Serializable] public class Settings { public RenderPassEvent renderEvent RenderPassEvent.BeforeRenderingPostProcessing; public int downsample 2; public int iterations 3; public float blurSpread 1.2f; } public Settings settings new Settings(); private BlurPass blurPass; public override void Create() { blurPass new BlurPass(settings); } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { renderingData.cameraData.camera.TryGetComponentCamera(out Camera cam); // 按项目需求决定哪些相机参与模糊这里简化为主相机 if (renderingData.cameraData.camera ! Camera.main) return; renderer.EnqueuePass(blurPass); } private class BlurPass : ScriptableRenderPass { private Settings settings; private Material material; private RenderTexture blurResult; public static RenderTexture BlurResult; public BlurPass(Settings settings) { this.settings settings; renderPassEvent settings.renderEvent; material new Material(Shader.Find(Custom/UI/GaussianBlur)); } public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData) { // 在URP中你需要在此处申请临时RT具体版本间API略有差异 } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { CommandBuffer cmd CommandBufferPool.Get(BlurBackground); // 1. 获取当前相机的color target // 2. 拷贝到临时RT // 3. 迭代模糊 // 4. 最终结果写入blurResult // 5. 把blurResult赋值给静态字段BlurResult context.ExecuteCommandBuffer(cmd); cmd.Clear(); CommandBufferPool.Release(cmd); } } }这段代码是骨架不同URP版本里获取cameraColorTarget的方式有差异Unity 2021之后普遍用renderer.cameraColorTarget但有些版本会要求你在OnCameraSetup里做一次ConfigureTarget。具体细节我建议以你安装的URP包版本官方ScriptableRenderPass示例为准。RenderPassEvent选BeforeRenderingPostProcessing能让你的模糊效果在Bloom、Color Grading之前生效画面更自然。如果项目本身没有其他后处理选AfterRenderingOpaques也行但尽量避开默认的AfterRenderingTransparents因为那时UI已经被画上去了会污染抓屏内容。4.2 让主界面UI也一起模糊的几种方式快照式方案天然只模糊3D场景因为Overlay UI不经过相机。如果你的主界面本身就是一套World Space或Screen Space - Camera模式的UI那它会被渲染进相机画面自然也会被模糊。最好的解法是把主界面UI从Overlay改成World Space模式。具体做法是单独建一个世界空间的Canvas放在相机前方固定距离让它的坐标跟着相机移动。这样OnRenderImage里抓到的画面就同时包含了3D场景和主界面UI。弹窗继续用Overlay Canvas抓屏时不会把弹窗自己装进去。第二种办法是用两个相机分层合成。一个相机专门渲染3D场景另一个相机专门渲染主界面UI层两张渲染结果先合并到同一张RT再对此RT做模糊。这么做视觉效果最精细能单独控制每层是否需要模糊但代码量和调试成本都上来了不太符合“简易”的定位。还有一种通用做法是CommandBuffer截帧。挂在相机上在UI渲染前后各插入一次拷贝指令把UI层的画面单独捞出来和场景层合并后再模糊。这个方案能应对各种复杂Canvas结构但需要比较扎实的CommandBuffer基础适合项目已经有自定义渲染管线时用。4.3 动态模糊、拖动模糊、局部模糊这类花样怎么做快照式模糊虽然只生成一次但可以做成低成本动态。弹窗打开后每隔几秒重新调用一次ApplyBlur()背景就会随着场景变化缓慢刷新看起来像实时模糊性能压力又比每帧刷新小得多。如果你知道弹窗背后有重要动画正在播放可以设一个计时器在动画播放期间按60帧刷新动画结束后立即停止刷新。拖动模糊的玩法类似在拖拽事件的回调里重新执行模糊。比如切牌、翻页这类交互拖动过程让背景持续模糊松手后定格。局部模糊可以通过Mask实现。你在RawImage外面套一层圆形或圆角矩形的Mask模糊背景只在特定区域可见其余部分保持原始清晰度。这种效果比较适合角色展示、道具详情这类需要局部聚焦的界面。这些扩展方向本质上都在“模糊算法”和“UI呈现”两个维度上做文章。把底层Shader和RT流转逻辑搞清楚玩法可以随便加。5. 常见问题与排错实录5.1 模糊层挡点击怎么办症状很典型弹窗打开后底下所有按钮全部点不动连弹窗自身的按钮都觉得“有点迟钝”。不用怀疑就是那层全屏RawImage的问题。RawImage继承自MaskableGraphic默认Raycast Target是开启的它把整块屏幕都变成了可点击区域阻挡了所有下层UI的事件。处理方式是在RawImage的Inspector里把Raycast Target勾掉或者在代码里设置blurImage.raycastTarget false。我习惯在Awake里统一设置这样即使美术在编辑器里忘了勾运行时也能兜底。如果模糊层下面还有其他需要保持点击交互的UI比如地图上的一堆标记那最好用CanvasGroup控制blocksRaycasts把模糊层单独放在一个独立的Canvas中通过开关这个Canvas来切换交互状态。5.2 黑屏、画面抖动、模糊太弱黑屏问题先检查Shader。最常见的是Shader.Find(Custom/UI/GaussianBlur)找不到对应ShaderMaterial创建失败所有Blit全部白执行。解决方案在Inspector里把Shader拖到序列化字段上用[SerializeField] private Shader blurShader来引用比字符串查找靠谱。第二常见的是RT尺寸为0。如果在某些极端分辨率下Screen.width / downsample算出来是0GetTemporary会返回一张无效RTRawImage显示黑屏。用Mathf.Max(2, ...)兜底基本能解决。画面抖动通常是因为抓屏时机不对。如果你在Update里临时把相机targetTexture切走再调camera.Render()那一帧屏幕必然会闪一下。换成OnRenderImage缓存方案后抖动的根因就消失了。模糊太弱这个问题最好调。优先加iterations从3加到5效果立竿见影。如果还不够把downsample从2改成3或4分辨率越低一次模糊扩散的物理范围越大。最后再动blurSpread它只是对采样偏移的缩放单纯调它很难出现“糊到妈都不认识”的效果。5.3 移动端与WebGL的注意事项移动端做这个效果最关心的就是发热和掉帧。OnRenderImage本身在绝大多数移动设备上能用但个别GPU驱动有兼容问题建议在目标真机上多测几款机型。RT格式尽量用RenderTextureFormat.Default不要随手用DefaultHDR。HDR格式在高光区域确实更细腻但对移动端带宽是额外负担而一个UI模糊背景根本不需要那么高的动态范围。参数策略上移动端我推荐downsample4iterations2。画面会偏“油画感”一点但模糊背景本来就是退到后景的装饰没人会贴在屏幕上找细节。等到正式发布包再根据性能报告微调。WebGL平台要特别关注RT的生命周期。浏览器切换标签页之后GPU资源可能被回收WebGL上下文也可能重建。所有RT都要在OnEnable或第一次使用时重新检查发现IsCreated()返回false就重新生成。另外WebGL下尽量别频繁GetTemporary和ReleaseTemporary同一尺寸的RT内存碎片会让页面越来越卡。像ScreenCache这种常驻缓存RT该缓存就缓存不要在生命周期里反复横跳。5.4 多相机与多次渲染的兼容性如果你的项目不止一个相机比如主摄像机加角色展示相机、UI相机那要特别注意ScreenCache挂在哪个相机上。OnRenderImage里的source只代表“当前正在执行这个脚本的那个相机”的输出并不是最终混合到屏幕上的合成结果。如果UI相机后渲染、角色相机先渲染UI相机最终覆盖了画面那你就应该把ScreenCache挂到UI相机上同时把角色相机的内容想办法先合并进这个相机的渲染结果里。一个比较偷懒的做法是让角色相机把渲染结果输出到一张RT再作为贴图放在UI相机画面中的一个RawImage上最后由UI相机的ScreenCache统一抓屏。这样整个画面的最终形态就都在一个相机里了模糊逻辑不用变。但归根结底UGUI背景模糊这个需求在这个简易方案里最怕的就是“多个相机各自渲染、彼此不统一”。所以架构上最好先保证所有需要被模糊的内容都汇入同一个相机的最终画面。最后说点实际做下来的体会。最早我在项目里用的是临时接管targetTexture的做法虽然能出效果但遇到多相机、Camera模式Canvas这种坑非常容易翻车。后来换成OnRenderImage缓存低分辨率帧问题一下子少了大半。这套方案虽然定位是简易但核心思路是通用的抓一帧、降采样、迭代模糊、交给RawImage。“抓得住帧别去碰相机本身”这句话基本就是所有UGUI模糊方案的关键。你项目里如果已经有一套自定义后处理流程完全可以把上面的模糊Shader直接插进去复用。要是你也在折腾UGUI背景模糊希望这篇分享能帮你少走几步弯路。