弹窗背景模糊这个需求凡是用Unity做UI的团队基本都躲不开。尤其是用FairyGUI的项目美术那边十个需求里八个都想要毛玻璃效果但FairyGUI官方又不提供一键开启的背景模糊组件。我最早接到这个任务时文档翻遍了也只看到Filter的只言片语最后在一个中重度卡牌项目里前后折腾了快一周试过改材质、截屏、后处理好几条路子才沉淀下来三条能稳定落地、也经得起真机检验的方案。本文会把这三种方法完整拆开能解决什么问题、局限在哪、完整代码怎么接以及实测中容易被坑到的几个点。如果你也正被弹窗背景模糊折磨这篇应该能直接帮你抄作业。1. 背景模糊的需求拆解FairyGUI为什么没有现成的背景模糊1.1 FairyGUI的显示架构决定了实现路径先理清楚FairyGUI在Unity里是怎么把内容画到屏幕上的。所有UI元素GComponent、GImage、GTextField、Window等最终都挂在GRoot下面而GRoot对应一个独立显示的UI根节点。FairyGUI在Unity端有自己的渲染流程一般是把整个UI树画到一个独立的相机FGUI内部会自动处理相机或者叠加到主相机渲染链路上。这个架构带来的一个直接后果是FairyGUI并不直接知道背景是什么。它看到的只有自己的GObject树至于背景是一张静态图、一个3D场景还是另一套UI对FairyGUI来说都属于外部世界。所以官方的Filter滤镜只能作用于FairyGUI自己的显示对象没法去模糊外部的东西。这就引出了实现背景模糊的三条路径路径AUI内部解决模糊FairyGUI内部的底层显示对象比如主界面容器视觉上让弹窗之下的内容变得模糊。这条路最轻量不碰相机、不碰渲染管线。路径B截图解决)把当前画面3D场景UI截到RenderTexture里用高斯模糊Shader处理一遍再把模糊结果作为一张图片塞回FairyGUI的UI树里垫在弹窗底下。路径C渲染管线解决在URP的RendererFeature里加一个全屏模糊后处理Pass让整个屏幕先模糊再让FairyGUI的弹窗层叠加在模糊结果之上。三条路没有绝对的对错取决于你的项目吃不吃得下性能消耗、是否包了URP、以及要模糊的是纯UI还是包含3D场景。下面的内容按从简到重的顺序逐个展开。1.2 三种方法的核心区别模糊谁、在哪个层级模糊这个模糊谁的问题非常关键。我见过不少同事一上来就找全屏后处理方案结果发现项目还是内置渲染管线白搞一通。反过来也有一些团队图省事只用BlurFilter结果弹窗一开3D场景一点没模糊美术直接打回。方案模糊的对象是否需要改渲染管线对3D场景是否生效性能开销BlurFilterFairyGUI内指定GObject否否极低RT截屏Shader整帧画面相机输出否内置/URP通用是中主要截屏和全屏BlitURP全屏后处理整帧画面是仅URP是较高但多在GPU可控性强理解了这个差异选型就不会摇摆不定。2. 方法一用FairyGUI的BlurFilter直接模糊主界面UI层最优解2.1 BlurFilter是什么、能干什么FairyGUI在Unity端的GObject上提供了一个Filter接口可以给显示对象挂滤镜。官方内置了两种BlurFilter模糊滤镜和HighlightFilter高亮滤镜。BlurFilter做的是针对这个GObject渲染结果的模糊处理类似给UI对象套了一个高斯模糊效果。我最早看到BlurFilter时觉得这正是我要的东西结果一试才发现它模糊的不是背后的东西而是这个对象自己。比如你给一个主界面Panel挂了BlurFilter模糊的是主界面UI本身不是主界面背后的3D场景。但它非常适合一类项目纯UI游戏、聊天室、SLG主城这类背景本身就是UI的画面。你只需要在弹窗打开时把主界面容器设上模糊弹窗自然清晰视觉上就是标准的背景模糊。在实际代码里弹窗打开时如果直接把主界面整棵子树模糊通常能得到非常干净的毛玻璃效果而且性能开销极小因为模糊的是UI层的一张小RT和屏幕分辨率解耦。这是它最大的优势。2.2 弹窗打开/关闭的完整接入代码FairyGUI的Window类有完整的生命周期我们通常在OnShow和OnHide里做模糊的开和关。核心就两行代码给目标对象挂BlurFilter以及把filter置空解除模糊。using FairyGUI; public class BlurPopupWindow : Window { private GObject _mainUIRoot; // 主界面容器在外部赋值或查找 private BlurFilter _blurFilter; protected override void OnShow() { base.OnShow(); // 拿到主界面所在的容器 // 比如你在GRoot下面有一个名为mainView的组件 _mainUIRoot GRoot.inst.GetChild(mainView); if (_mainUIRoot null) return; // 挂上模糊滤镜 _blurFilter new BlurFilter(); _blurFilter.BlurSize 4f; // 模糊强度数值越大越糊 _mainUIRoot.filter _blurFilter; } protected override void OnHide() { base.OnHide(); // 关闭弹窗时移除滤镜恢复主界面清晰显示 if (_mainUIRoot ! null) { _mainUIRoot.filter null; _mainUIRoot null; _blurFilter null; } } }如果你不是用Window而是手动AddChild弹窗也完全可以照搬这个思路。只要记住一个原则谁需要变模糊就把filter挂到谁身上。2.3 效果边界为什么它模糊不了3D场景这是在用这个方法前必须接受的限制。BlurFilter的模糊是在FairyGUI的显示对象渲染链路上做的它从UI对象自己的纹理里取色再通过Shader做加权采样根本拿不到3D场景的像素。因此如果你的游戏背景是实打实的3D主城、战斗场景那BlurFilter是帮不上忙的。另外模糊UI本身也会带来一个常见副作用文字变糊。如果主界面上有大量小字号文本一模糊起来就是一团马赛克观感反而变差。我实际项目里的处理方式是给主界面的关键文字区域和图标区域做抗模糊处理——要么模糊强度降低到2~3要么干脆在弹窗打开时只模糊一部分主界面组件保留核心状态栏和聊天栏清晰这样既满足毛玻璃氛围又不影响玩家读信息。性能方面需要注意一点BlurFilter内部会为目标对象生成一张RT然后每帧重绘模糊结果。如果模糊的对象太大、层级太深比如一棵有几十个子节点的超复杂UI树在低端Android机上帧率会明显往下掉。实测中把模糊挂到顶部根节点和挂到某个叶子节点开销差距能到数倍。所以能模糊父级就模糊父级但父级也不能真的是全屏超大容器——需要进行性能测试。3. 方法二RenderTexture截屏 高斯模糊Shader全屏通用3.1 思路与全局流程如果你需要连3D场景一起模糊或者你是内置渲染管线、又不想为了一个弹窗升级到URP那么截屏到RT高斯模糊Shader就是最通用的方案。整体流程可以拆成5步弹窗打开前把当前屏幕画面截到一张RenderTexture中。将截屏RT通过高斯模糊Shader做横向、纵向两次Blit。得到模糊后的RT用FairyGUI的NTexture把它包一层。创建一个全屏大小、不可点击的GImage把NTexture赋给它AddChild到GRoot且保证它位于弹窗层级之下。弹窗关闭时清理GImage、NTexture并释放临时RT。这个方法的核心价值在于它跟FairyGUI的UI树没有绑定关系你截什么就模糊什么可以是3D、2D、UI的混合画面。3.2 高斯模糊Shader完整代码这里不搞太复杂的数学用经典的9次采样/加权平均就能得到不错的毛玻璃效果。为了横向、纵向分离Shader里做两个PassShader Custom/GaussianBlurUI { Properties { _MainTex (Texture, 2D) white {} _BlurSize (Blur Size, Range(0, 10)) 2.0 } SubShader { Tags { RenderTypeOpaque RenderPipelineUniversalPipeline } LOD 100 CGINCLUDE #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_TexelSize; float _BlurSize; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; v2f vert(appdata_base v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv v.texcoord.xy; return o; } fixed4 frag_horizontal(v2f i) : SV_Target { float2 offset float2(_MainTex_TexelSize.x * _BlurSize, 0); half4 col 0; // 五个采样点 权重 col tex2D(_MainTex, i.uv - 2 * offset) * 0.05; col tex2D(_MainTex, i.uv - offset) * 0.25; col tex2D(_MainTex, i.uv) * 0.4; col tex2D(_MainTex, i.uv offset) * 0.25; col tex2D(_MainTex, i.uv 2 * offset) * 0.05; return col; } fixed4 frag_vertical(v2f i) : SV_Target { float2 offset float2(0, _MainTex_TexelSize.y * _BlurSize); half4 col 0; col tex2D(_MainTex, i.uv - 2 * offset) * 0.05; col tex2D(_MainTex, i.uv - offset) * 0.25; col tex2D(_MainTex, i.uv) * 0.4; col tex2D(_MainTex, i.uv offset) * 0.25; col tex2D(_MainTex, i.uv 2 * offset) * 0.05; return col; } ENDCG Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag_horizontal ENDCG } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag_vertical ENDCG } } }C#端调用也不复杂。核心是先用Graphics.Blit把截屏RT按横向Pass走一遍再按纵向Pass走一遍得到模糊结果public static RenderTexture BlurRenderTexture(RenderTexture source, Material blurMat, float blurSize) { int w source.width; int h source.height; // 申请一个临时RT再Blit避免直接改原RT RenderTexture tempRT RenderTexture.GetTemporary(w, h, 0, RenderTextureFormat.Default); blurMat.SetFloat(_BlurSize, blurSize); int halfSize Mathf.Max(1, Mathf.Min(w, h) / 8); // 可以用半分辨率/四分之一分辨率来降低开销 RenderTexture lowRes RenderTexture.GetTemporary(halfSize * 2, halfSize * 2, 0); Graphics.Blit(source, lowRes); // 先缩到低分辨率 Graphics.Blit(lowRes, tempRT, blurMat, 0); // 横向模糊 Graphics.Blit(tempRT, lowRes, blurMat, 1); // 纵向模糊 Graphics.Blit(lowRes, source); // 拷贝回最终结果 RenderTexture.ReleaseTemporary(tempRT); RenderTexture.ReleaseTemporary(lowRes); return source; }这里故意先降到低分辨率再模糊是因为模糊背景对清晰度不敏感低分辨率RT能省掉一大截GPU开销。我一般取屏幕最短边的1/8或1/16出来的毛玻璃效果已经很够用。3.3 截屏成RT的几种姿势与踩坑截屏主要三种做法各有用武之地姿势ACamera.Render到RTpublic RenderTexture CaptureCameraToRT(Camera cam, int width, int height) { RenderTexture rt RenderTexture.GetTemporary(width, height, 24); RenderTexture oldTarget cam.targetTexture; cam.targetTexture rt; cam.Render(); cam.targetTexture oldTarget; return rt; }这个方式的优点是可控可以指定相机、指定分辨率。缺点是你必须保证参与渲染的相机树都覆盖到位有UI相机、又有主相机时得分别处理。姿势BScreenCapture.CaptureScreenshotIntoRenderTextureRenderTexture rt RenderTexture.GetTemporary(Screen.width, Screen.height, 24); ScreenCapture.CaptureScreenshotIntoRenderTexture(rt);这是最省事的写法它抓的是当前屏幕上已经渲染好的最终画面天然包含所有相机。但要注意两个坑一是如果弹窗已经显示了它会把弹窗也截进去二是部分Android机的高分辨率屏幕上全屏RT瞬时开销比较大容易造成掉帧。姿势CCommandBuffer异步拷贝适合URP或性能要求更高的项目在渲染管线里用CommandBuffer把CameraColor复制到指定RT。这个写法更体面但相对复杂适合和下一节的URP方案融合。关于截屏把弹窗自己截进去的问题标准解法是在弹窗显示之前完成截屏和模糊。实际操作可以这样弹窗的ShowModal先不显示内容而是先加一层纯黑色或纯色过渡遮罩盖住画面然后截屏、模糊、把模糊图插入GRoot底层最后再显示弹窗内容。这样玩家视觉上是先看到模糊背景再看到弹窗弹出体验反而更自然。3.4 把模糊图塞进FairyGUI层级拿到模糊后的RT后把它交给FairyGUI的NTexture再让GImage显示出来即可using FairyGUI; public class BlurBackgroundView { private GImage _bgImage; private NTexture _nTexture; public void Show(RenderTexture blurredRT) { // 用模糊RT包一层NTexture _nTexture new NTexture(blurredRT); _bgImage new GImage(); _bgImage.texture _nTexture; _bgImage.SetSize(GRoot.inst.width, GRoot.inst.height); _bgImage.touchable false; // 绝对不能让背景拦截触摸 _bgImage.alpha 0.92f; // 可以稍微透一点辅助加深效果 // 插入到GRoot最底层 GRoot.inst.AddChildAt(_bgImage, 0); } public void Hide() { if (_bgImage ! null) { _bgImage.Dispose(); _bgImage null; } if (_nTexture ! null) { _nTexture.Dispose(); _nTexture null; } } }注意NTexture如果持有的是临时RT那么在GImage不再使用后一定要把它Dispose掉同时把RenderTexture.ReleaseTemporary归还给Unity的RT池。这里最容易出的问题就是重复打开弹窗时RT泄露Unity编辑器里看内存持续上涨就是这里没释放干净。尺寸方面FairyGUI的GRoot.inst.width/height通常已经是逻辑分辨率/内容尺寸按实际项目的播放器设置来适配即可。3.5 实测调参模糊强度、分辨率、色彩溢出这个方法我在一个放置养成项目里实际用过调参经验可以分享模糊强度_BlurSize我一般取2到4。小于2几乎看不出模糊大于6画面会糊成一个色块文字完全不可读。毛玻璃风格建议3左右。RT分辨率上文已经提到可以用全屏RT也可以把RT降为原分辨率的1/4甚至1/8再模糊效果差异不大但性能差异明显。低端机建议直接用1/4分辨率RT再结合alpha 0.9的遮罩观感也能接受。透明模糊如果你的背景有很多半透明UI截屏后RT里的Alpha通道可能有惊喜也可能有惊吓。建议在模糊Shader里直接强制把Alpha置为1避免FairyGUI把RT的透明区域又做一次混合产生灰边。4. 方法三URP全屏后处理模糊性能优先方案4.1 为什么在URP里优先考虑RenderFeature如果你的项目已经是URP管线再回头用截屏到RT的做法就有点绕了——URP本身提供了ScriptableRendererFeature机制可以在相机渲染完成之后、UI叠加之前对整帧画面做后处理。这就是做弹窗背景模糊最正统的路径。这个方案的优势在于不需要手动创建RT、不需要控制时序、不依赖FairyGUI内部渲染顺序。你只需要在弹窗打开时让模糊效果开起来关闭时让模糊效果关掉剩下的由渲染管线自动完成。它也有适用前提FairyGUI的UI层通常通过独立的Overlay相机渲染URP的RenderFeature可以不影响Overlay相机从而保证弹窗在模糊背景之上保持清晰。如果你的FGUI也是挂在主相机上按Overlay方式渲染那么后处理模糊会把弹窗一起糊掉这点务必先确认清楚。4.2 RenderFeature全屏模糊核心代码先定义一个RenderPassusing UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public class FullScreenBlurPass : ScriptableRenderPass { private Material _blurMat; private RenderTargetIdentifier _source; private RenderTargetIdentifier _destination; private int _tempRT1; private int _tempRT2; public FullScreenBlurPass(Material blurMat) { _blurMat blurMat; _tempRT1 Shader.PropertyToID(_FullScreenBlurTemp1); _tempRT2 Shader.PropertyToID(_FullScreenBlurTemp2); renderPassEvent RenderPassEvent.AfterRenderingTransparents; // 在透明物体渲染完后 } public void Setup(RenderTargetIdentifier source) { _source source; } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { CommandBuffer cmd CommandBufferPool.Get(FullScreenBlur); int width renderingData.cameraData.camera.pixelWidth; int height renderingData.cameraData.camera.pixelHeight; cmd.GetTemporaryRT(_tempRT1, width, height, 0, FilterMode.Bilinear); cmd.GetTemporaryRT(_tempRT2, width, height, 0, FilterMode.Bilinear); cmd.Blit(_source, _tempRT1); cmd.Blit(_tempRT1, _tempRT2, _blurMat, 0); // 横向模糊 cmd.Blit(_tempRT2, _tempRT1, _blurMat, 1); // 纵向模糊 cmd.Blit(_tempRT1, _source); // 写回屏幕 context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } public override void FrameCleanup(CommandBuffer cmd) { cmd.ReleaseTemporaryRT(_tempRT1); cmd.ReleaseTemporaryRT(_tempRT2); } }再定义一个RendererFeatureusing UnityEngine; using UnityEngine.Rendering.Universal; public class FullScreenBlurFeature : ScriptableRendererFeature { public Material blurMaterial; public bool enableBlur; private FullScreenBlurPass _pass; public override void Create() { _pass new FullScreenBlurPass(blurMaterial); } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { if (blurMaterial null || !enableBlur) return; _pass.Setup(renderer.cameraColorTarget); renderer.EnqueuePass(_pass); } }在URP的Renderer资产里添加这个Feature勾选enableBlur时整帧画面就会被模糊关闭时恢复原样。你可以把它绑定在一个全局Manager上弹窗打开时置true关闭时置false。不同URP版本10.x、12.x、14.x、17.x在ScriptableRenderer.cameraColorTarget上可能有细微API差异老版本部分字段是internal的需要反射或升级URP包。这块打包时注意检查对应Unity版本的编译错误。4.3 与FairyGUI弹窗组合的层级层级的核心是模糊后处理作用于主相机而FairyGUI的弹窗通过Overlay相机或额外的UI渲染阶段叠加在模糊结果之上。这样玩家看到的就是背景全模糊、弹窗清晰的效果。实际操作中弹窗本身不需要再往FairyGUI的UI树里塞全屏模糊背景图了只需一个普通的半透明黑色遮罩来压暗背景、引导视觉焦点// FGUI弹窗中加半透明遮罩 GComponent popup UIPackage.CreateObject(Common, Popup).asCom; Graph mask new Graph(); mask.DrawRect(GRoot.inst.width, GRoot.inst.height, 1, Color.black); mask.alpha 0.45f; mask.touchable true; // 阻断点击穿透 GRoot.inst.AddChild(mask);性能上全屏模糊毕竟是全分辨率Blit在移动端要注意控制BlueSize和采样次数。我的做法是动态降低模糊RT分辨率如果SystemInfo.graphicsDeviceType是低端GPU就只用全屏的一半分辨率做模糊效果依然可以接受。4.4 移动端降级策略全屏后处理在所有机器上都全开是不现实的。我建议做二级模糊高端机BlurSize3全分辨率模糊渲染效果拉满。中端机BlurSize2半分辨率临时RT效果有一定损失但可以接受。低端机/发热严重时直接关闭RenderFeature只保留纯色半透明遮罩。这等于退化成传统弹窗样式至少保证不卡顿、不掉帧。降级开关做成一个静态属性让QA和策划在设备上也能动态切换public static class BlurQualitySettings { public static int BlurSize 3; public static bool UseHalfResRT false; public static bool EnableFullScreenBlur true; }5. 三种方法横向对比与最终选型建议5.1 效果、性能、兼容性对照表对比项方法一BlurFilter方法二RT截屏Shader方法三URP后处理模糊对象范围仅FairyGUI对象全屏画面全屏画面是否影响3D场景否是是渲染管线要求内置/URP均可内置/URP均可仅URP从开始到落地所需时间半小时半天到一天一天以上代码侵入性最低仅FGUI层中需要管理RT和Shader高需要改渲染管线移动端性能优秀一般截屏是大头较好可控性强弹窗是否会被误糊否可能取决于时序不会FairyGUI走Overlay相机可调节程度低中高5.2 我在实测中总结的几条硬结论纯UI游戏后宫、卡牌、聊天室直接用BlurFilter。集成最快效果也最自然完全没必要动渲染管线。内置渲染管线且有3D场景老老实实用截屏Shader。虽然峰值性能不如URP后处理但胜在通用不用为了一个毛玻璃弹窗升级URP。URP项目且弹窗出现频率高、要精细控制用RenderFeature。能把模糊做成一个可以随时增删的后期效果后续想做其他后处理描边、颜色校正也顺手。千万不要一开始就想着全分辨率9采样的暴力高斯模糊。大多数毛玻璃场景缩到1/4分辨率效果和性能能找到一个非常好的平衡点。5.3 决定成败的三个隐蔽细节第一个坑截屏把弹窗自己也截进去了。这是方法二最容易踩的坑。解法前面提过先显示过渡遮罩再截屏、模糊、插底层最后显示弹窗。时序上宁可多一帧延迟也不要出现弹窗被自己模糊的诡异画面。第二个坑模糊后的RT在FairyGUI里显示方向反了。尤其是当你用Camera.Render到RT再交给NTexture时由于纹理坐标方向差异图片可能上下颠倒。遇到这件事只检查一个地方NTexture的uvRect是否要翻转或者RT采样时是否要翻转uv.y。别乱试先确认是不是uv方向问题。第三个坑某个平台不支持模糊Shader中的特定语法。我遇到过一次在iOS上正常、在部分Android机型上整个画面是黑的最后定位到是Shader里采样次数太多导致寄存器溢出。建议把模糊采样控制在5到9次之间并多用half精度而不是float能显著提高兼容性。最后再分享一个个人习惯我会把弹窗背景模糊做成一个独立的公共组件而不是把模糊逻辑写死在每一个弹窗里。这样无论游戏里有几十个弹窗都只需要传入打开/关闭两个状态组件内部自动判断用哪种方案。以后如果你也想从方法一换到方法三只动组件内部逻辑就够了业务层代码一行都不用改。这个抽象才是这三套方法真正在企业级项目里能长期活下来的关键。