
最近有个朋友问我FairyGUI里怎么给弹窗背景做高斯模糊。他说网上搜到的基本都是UGUI的OnRenderImage方案搬到自己项目里发现要么完全没反应要么把整个游戏画面糊成一团根本分不清UI和3D内容。这个问题我太熟了FairyGUI虽然底层也是Mesh加材质渲染但它的显示链路跟UGUI的Canvas那套完全不一样所以高斯模糊的接入点也完全不同。这篇文章我打算从FairyGUI的渲染特点讲起把模糊的“切入点”讲清楚然后给一套能直接落地的RenderTexture抓屏加Shader模糊方案再补充局部模糊、动态刷新、批处理、移动端和微信小游戏平台上的注意事项。项目正好要用FairyGUI做弹窗背景虚化、活动界面背景模糊这类效果的可以直接参考。1. FairyGUI显示链路与模糊“切入点”分析1.1 为什么UGUI的OnRenderImage到了FairyGUI就不灵先说一个常见误区。UGUI里做全屏模糊很多人用的是OnRenderImage也就是把一个后处理脚本挂到相机上在相机渲染完整个画面后拦截渲染结果做一次模糊再输出。这个思路在UGUI下能跑通是因为UGUI的Canvas通常挂在某个相机下面整个屏幕最终都来自这一个相机的渲染输出。但FairyGUI的机制不太一样。FairyGUI有自己的一套显示列表它会把UI对象合批成Mesh然后由StageCamera舞台相机来渲染。这个相机跟主相机是分离的你在主相机上做后处理模糊到的只是3D世界画面FairyGUI的UI反而不会被处理你要是直接把后处理挂到StageCamera上又很容易把UI和背景一起糊掉而且模糊范围很难控制。换句话说FairyGUI里的高斯模糊不能简单依赖“相机后处理”这个万能方案你得想清楚到底是想模糊FairyGUI里的某个显示对象还是想模糊FairyGUI背后那层动态UI画面。1.2 两条路线对象级模糊滤镜 vs 屏幕截图模糊结合我自己的使用经验FairyGUI项目里做高斯模糊有两条比较成熟的路线。路线A对象级模糊滤镜。FairyGUI本身为显示对象提供了滤镜支持其中就有模糊滤镜BlurFilter。这种做法的思路是给某个GObject挂上滤镜然后这个对象以及它子树内容在渲染时就会被模糊。适合的场景是你有一张静态背景图、一个图标、一个底板想让它本身呈现出模糊质感。比如设计稿里那种“磨砂玻璃卡片”直接用这个就够了性能开销也小。需要注意的是不同FairyGUI版本的filter属性和BlurFilter具体API有细微差异用的时候先查一下当前版本的FairyGUI文档别凭记忆写。路线B屏幕截图/区域抓取 高斯模糊后处理。这条路线就是本文的重点。思路是先把当前屏幕上需要做背景的内容抓成一张纹理然后对这张纹理做高斯模糊最后把模糊结果贴到一个全屏或局部的GRawImage上放在弹窗底下。它最核心的价值是支持模糊“动态背景”比如弹窗底下还有滚动的列表、变化的数字、播放的视频都能被模糊掉。这个路线也是绝大多数游戏UI弹窗的首选。两条路线对比对比维度对象级模糊滤镜屏幕截图模糊能否模糊动态内容不能模糊的是对象本身能抓的是当前画面性能开销较低较高需要RT和多次Blit实现复杂度低中等适合场景静态底板、图标、按钮弹窗背景、活动页背景平台兼容性好需注意移动端显存和RT限制如果项目里只是“某个圈出来的图片区域要毛玻璃感”用路线A就够了。但如果要的效果是“打开弹窗后背后的界面整体变模糊”那就得用路线B。下面我从路线B讲起。2. RenderTexture抓屏方案从截屏到模糊显示2.1 抓屏方式选择与FairyGUI坐标适配这条路线第一步是把当前屏幕或者某个区域的内容抓成纹理。Unity里抓屏幕的常规做法有两种一种是ScreenCapture.CaptureScreenshotAsTexture()一种是ReadPixels。前者是引擎帮你把整个屏幕读回来用起来最省事后者需要配合Texture2D.ReadPixels从当前RenderTexture里读取指定矩形区域适合做局部抓取。在纯FairyGUI项目里我更推荐先从CaptureScreenshotAsTexture入手把整条链路跑通再做局部裁剪优化。原因很简单FairyGUI的坐标和屏幕坐标之间的换算虽然不复杂但第一次做很容易搞错方向比如动态分辨率变化、设备安全区适配、文字缩放模式等都会影响坐标。全屏抓取可以避开这些干扰。一个典型的抓屏流程是这样的using System.Collections; using UnityEngine; using FairyGUI; public class BlurBackground : MonoBehaviour { public GComponent view; // 用来承载模糊背景的FairyGUI组件 private GRawImage _blurImage; private bool _isBlurWorking; public IEnumerator ApplyFullScreenBlur(float blurSize 1.5f) { if (_isBlurWorking) yield break; _isBlurWorking true; // 等待当前帧渲染结束确保抓到的画面是完整的 yield return new WaitForEndOfFrame(); // 方式1直接抓取屏幕纹理 Texture2D screenTex ScreenCapture.CaptureScreenshotAsTexture(); // 方式2如果只需要局部可以用 ReadPixels后面第3章会讲到 // Texture2D regionTex CaptureRegion(screenRect); if (screenTex null) { _isBlurWorking false; yield break; } // 对抓到的纹理做高斯模糊返回一张RT RenderTexture blurRT BlurUtility.GaussianBlur(screenTex, downSample: 2, iterations: 2, blurSize: blurSize); if (_blurImage null) { _blurImage new GRawImage(); _blurImage.SetSize(GRoot.inst.width, GRoot.inst.height); _blurImage.touchable false; // 放到弹窗所在组件的最底层 view.AddChildAt(_blurImage, 0); } // 把模糊结果贴到GRawImage上 _blurImage.texture new NTexture(blurRT); // 释放临时纹理 Object.Destroy(screenTex); _isBlurWorking false; } }这里有几个细节要注意。第一WaitForEndOfFrame很重要。如果不是等这一帧完整渲染完就抓抓出来的画面可能是半成品甚至全是黑的。第二GRawImage的纹理在赋值时用NTexture包一层这是FairyGUI的纹理封装直接给Texture2D或RenderTexture外面包NTexture才能被UI正确采样。第三touchable false不然这个全屏背景会挡住后面弹窗以外的点击事件。2.2 高斯模糊Shader与C#工具实现抓屏只是第一步真正吃性能的是后面的高斯模糊计算。高斯模糊本质上是一种卷积操作用一组符合高斯分布权重的采样点把周围像素的颜色按权重混合到当前像素上。最准确的做法是对每个像素做N×N采样但那样开销太大实际项目里几乎不会这么干。工程上最常用的近似方案是拆成两步先水平方向做一次一维高斯采样再垂直方向做一次一维高斯采样。两次一维卷积叠加效果上可以近似二维高斯但采样次数从N×N降到了2N性能提升非常明显。我常用的Shader长这样Shader Custom/FairyGUI/GaussianBlur { Properties { _MainTex (Texture, 2D) white {} _BlurSize (Blur Size, Range(0, 4)) 1.0 } SubShader { Cull Off ZWrite Off ZTest Always Pass // 水平模糊 { CGPROGRAM #pragma vertex vert_img #pragma fragment frag_horizontal #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_TexelSize; float _BlurSize; fixed4 frag_horizontal(v2f_img i) : SV_Target { float2 offset float2(_MainTex_TexelSize.x * _BlurSize, 0); fixed4 col tex2D(_MainTex, i.uv) * 0.4026; col tex2D(_MainTex, i.uv offset) * 0.2442; col tex2D(_MainTex, i.uv - offset) * 0.2442; col tex2D(_MainTex, i.uv offset * 2) * 0.0545; col tex2D(_MainTex, i.uv - offset * 2) * 0.0545; return col; } ENDCG } Pass // 垂直模糊 { CGPROGRAM #pragma vertex vert_img #pragma fragment frag_vertical #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_TexelSize; float _BlurSize; fixed4 frag_vertical(v2f_img i) : SV_Target { float2 offset float2(0, _MainTex_TexelSize.y * _BlurSize); fixed4 col tex2D(_MainTex, i.uv) * 0.4026; col tex2D(_MainTex, i.uv offset) * 0.2442; col tex2D(_MainTex, i.uv - offset) * 0.2442; col tex2D(_MainTex, i.uv offset * 2) * 0.0545; col tex2D(_MainTex, i.uv - offset * 2) * 0.0545; return col; } ENDCG } } Fallback Off }这组权重是5点采样的近似高斯分布中间0.4026相邻0.2442次相邻0.0545。只做一次水平加一次垂直模糊效果其实还不够所以我会在C#里把它迭代多次。对应的C#工具类如下using UnityEngine; public static class BlurUtility { private static Material _blurMaterial; private static Material BlurMaterial { get { if (_blurMaterial null) { Shader shader Shader.Find(Custom/FairyGUI/GaussianBlur); _blurMaterial new Material(shader); } return _blurMaterial; } } public static RenderTexture GaussianBlur(Texture source, int downSample 2, int iterations 2, float blurSize 1.0f) { int w Mathf.Max(1, source.width / downSample); int h Mathf.Max(1, source.height / downSample); RenderTexture rt RenderTexture.GetTemporary(w, h, 0); // 先把原图按降采样比例Blit到RT Graphics.Blit(source, rt); BlurMaterial.SetFloat(_BlurSize, blurSize); for (int i 0; i iterations; i) { RenderTexture tmpH RenderTexture.GetTemporary(w, h, 0); Graphics.Blit(rt, tmpH, BlurMaterial, 0); // Pass 0: 水平 RenderTexture.ReleaseTemporary(rt); RenderTexture tmpV RenderTexture.GetTemporary(w, h, 0); Graphics.Blit(tmpH, tmpV, BlurMaterial, 1); // Pass 1: 垂直 RenderTexture.ReleaseTemporary(tmpH); rt tmpV; } return rt; } }为什么一定要降采样这个我多说一句。屏幕分辨率通常很高比如1080P直接在这么大一张纹理上做多次模糊每一帧都要处理几百万个像素再加上多次Blit主线程和GPU压力都会上来。而降采样到1/2甚至1/4之后参与计算的像素量直接变成原来的1/4、1/16模糊半径在视觉上还会被放大效果反而更自然。这也是很多商业游戏里后处理模糊的共同做法先缩小再模糊最后放大显示。2.3 接入FairyGUI的GRawImage上面代码里已经创建了一个GRawImage来接收模糊结果。这里再补充一下层级管理的细节。GRawImage不是一个普通图片它可以在FairyGUI里直接显示任意Unity纹理非常适合做这种动态生成的模糊底图。用的时候需要注意几点它应该被放到弹窗组件的底层也就是AddChildAt(view, 0)的位置这样其他弹窗内容都会渲染在它上面。如果弹窗本身的透明度是渐入渐出的模糊底一般也要一起做透明渐变否则会看到背景已经模糊了但弹窗内容还在淡入视觉上脱节。当弹窗关闭要记得把_blurImage的纹理释放掉尤其在高频打开关闭的场景里不然RenderTexture会一直被占用内存只增不减。关闭时释放纹理的参考写法public void ClearBlur() { if (_blurImage ! null) { NTexture tex _blurImage.texture; if (tex ! null) { if (tex.nativeTexture is RenderTexture rt) RenderTexture.ReleaseTemporary(rt); tex.Dispose(); } _blurImage.texture null; } }RenderTexture是GPU资源移动端和微信小游戏对这类资源都比较敏感宁可多写几行释放逻辑也不要指望系统自动回收。3. 局部背景模糊与动态刷新策略3.1 弹窗区域裁剪与模糊很多场景不需要全屏模糊只需要模糊弹窗正后方那一块区域。比如弹窗在屏幕中间偏左那右边大片区域根本不需要模糊全屏模糊纯属浪费GPU。局部模糊的思路是确定弹窗在屏幕上的矩形范围然后只抓取这个范围的画面再做模糊。关键一步是坐标换算因为FairyGUI的坐标原点在GRoot左上角而ReadPixels的坐标原点在屏幕左下角两者Y轴方向相反。而且FairyGUI的GRoot会自动适配各种分辨率你的弹窗坐标可能是相对GRoot的要先转成屏幕像素坐标。参考代码public static Texture2D CaptureRegion(Rect screenRect) { int x Mathf.FloorToInt(screenRect.x); int y Mathf.FloorToInt(screenRect.y); int w Mathf.FloorToInt(screenRect.width); int h Mathf.FloorToInt(screenRect.height); RenderTexture prevRT RenderTexture.active; Texture2D tex new Texture2D(w, h, TextureFormat.RGBA32, false); // 读取当前屏幕指定区域 tex.ReadPixels(new Rect(x, y, w, h), 0, 0); tex.Apply(); RenderTexture.active prevRT; return tex; }在使用这个函数前需要用GRoot.inst的分辨率换算接口把弹窗在FairyGUI里的坐标左上角为原点转成Unity屏幕坐标左下角为原点。大致思路是// fairyGUI坐标gobj.x, gobj.y 是相对GRoot的左上角坐标 float fx gobj.x; float fy gobj.y; // GRoot的高度和屏幕高度做换算Y方向翻转 float screenX fx / GRoot.inst.width * Screen.width; float screenY Screen.height - (fy / GRoot.inst.height * Screen.height);这里我用了最简单的等比缩放实际项目如果有FairyGUI的适配策略、非整数缩放比、安全区偏移还要再仔细对一下。第一次做的时候建议在最外层包一层Rect可视化调试确认抓取区域跟弹窗位置一致再继续往下做。3.2 什么时候刷新模糊底图模糊底图的刷新时机直接决定性能。如果弹窗打开后背景内容不会变那么打开时抓一次就够了后面没必要再动。如果背景里有滚动列表、倒计时、视频这类动态内容那就要考虑定时刷新。我的经验是把刷新策略分成三档静态背景弹窗打开瞬间抓一次全程不再刷新。性能开销最小。低频动态背景每隔0.2到0.3秒抓一次或者每5到6帧抓一次。肉眼看背景是“缓缓变模糊”的能接受性能压力也小。高频动态背景比如背景里在播视频那就只能每帧刷新这时必须做好降采样并且强烈建议只抓局部区域否则手机很容易发热。说白了不要无脑每帧刷新。很多所谓“卡顿”问题其实都出在开发者给背景模糊设了个Update循环每帧都在读取屏幕、Blit、模糊光这一个操作就能把低端机帧率拖垮一半。4. 性能、批处理与平台兼容性4.1 FairyGUI合批与RawImage批次冲突FairyGUI引以为傲的一点就是合批能力强它会自动把相邻的、使用同一图集纹理的UI对象合并到一个批次里。但是GRawImage这种对象有个天然劣势它显示的是RenderTexture纹理和普通图集不是同一个FairyGUI在切换纹理时只能断掉当前批次单独发送一个DrawCall。我实际测试下来一个全屏模糊底一般会额外增加1到2个DrawCall这个数量本身不大但如果反复切换_blurImage的纹理或者把它放在N个不同层级的父节点里可能会导致其他UI的合批被打散DrawCall一下子就涨上去了。几个实用建议模糊底作为弹窗的第一个子对象让其他UI在它之后继续合批。弹窗关闭时不要简单visible false最好直接Dispose掉或从显示列表移除避免它残留在列表里影响后续合批。如果项目里同时有多个弹窗带模糊底尽量只保留一个可见的模糊层不要叠加。4.2 移动端、微信小游戏与VR设备注意事项不同平台的GLES、Vulkan、Metal环境下RenderTexture的内存布局有所差异模糊方案踩的坑也完全不同。移动端最大的问题是显存带宽。全屏1080P的RT即使在降采样后做两到三次模糊GPU的读写压力依然不小。所以移动端我建议直接把downSample设为4。不要担心像素感太强迭代次数提到2次以后肉眼几乎看不出区别。如果目标机器是低端安卓可以把迭代次数降到1效果差一点但帧率能稳住。微信小游戏方面主要是两个坑。一个是RenderTexture越大内存占用越容易触碰平台限制尤其部分低端安卓机在微信小游戏环境里内存上限很紧另一个是某些小游戏引擎版本对MSAA RT的支持有问题可能出现模糊底是花的、或者颜色不对的异常。建议小游戏项目里不要给RT开抗锯齿直接用默认无MSAA的RT反正模糊本身就是一种抗锯齿。VR设备比如Pico 4这类一体机上做FairyGUI情况更特殊。全屏后处理方案和单眼/双眼RT的渲染机制容易冲突处理不好会导致左右眼画面错位或者一只眼看到模糊一只眼看不到。我的建议是VR项目尽量不用全屏屏幕截图模糊而是改用在FairyGUI内部对特定显示对象做模糊或者用局部小尺寸RT只覆盖UI面板区域。这样能绕开大多数XR渲染的坑。4.3 参数调优参考下面这组参数是我在不同分辨率、不同机型上测试后得出的经验值可以直接抄场景downSampleiterationsblurSize备注1080P手机弹窗背景221.5效果和性能均衡低端安卓弹窗背景412.0保帧率优先2K/4K屏幕421.0高分辨率下降采样收益大微信小游戏41~21.5显存敏感局部模糊区域221.0区域小可以多算一点注意blurSize不是越大越好。它控制的是采样偏移距离值过大会导致采样点之间跨度太大画面出现明显的颗粒感或“振铃”。要更柔和的模糊优先加迭代次数而不是猛拉blurSize。5. 实测中容易踩的坑5.1 颜色空间不一致导致模糊后发灰我在一个Linear色彩空间的项目里第一次跑通模糊方案时发现模糊出来的背景明显发灰颜色像蒙了一层雾。排查了半天问题出在RenderTexture的读写格式上。Linear色彩空间下纹理采样和RT写入如果对sRGB的处理不一致模糊后的颜色就会偏。解决办法是把抓屏得到的数据在模糊前明确标记为sRGB或者在创建临时RT时按项目色彩空间选择RenderTextureReadWrite。最简单的方式是把模糊RT创建为RenderTextureFormat.ARGB32并根据项目实际情况设置RenderTextureReadWrite如果还偏色就手动在Shader里做一次颜色空间转换。这种偏色问题在Gamma空间的项目里一般不出现所以如果你用默认工程模板做测试大概率不会踩到真踩到了先查色彩空间再查模糊权重别在Shader里瞎试。5.2 背景空黑、边缘闪烁与脏矩形问题FairyGUI有自己的一套脏矩形和可见性剔除机制。简单说它不会每帧渲染所有对象只会渲染“需要更新的区域”和“可见区域”。这个机制在正常显示时能省大量性能但如果你抓屏时正好有一块区域没有被它判定为“需要更新”那抓出来的画面上那一块就有可能是上一次的残留甚至空黑。实际表现就是弹窗打开瞬间背景模糊是黑的闪一下后恢复正常。解决思路有两个一种是抓屏前先调用一次GRoot.inst.InvalidateBatchingState()或者在FairyGUI内部强制刷新一次显示列表确保界面所有区域都被重新绘制另一种是不要立刻抓先等一两帧再开始截屏和模糊。边缘闪烁通常来自分辨率对齐问题。抓取区域和显示区域如果存在半个像素的偏差模糊底会在UI边缘出现白边或者闪边。可以给目标RT尺寸做整数对齐并且GRawImage的显示尺寸也做整数化处理一般就能消除。5.3 3D内容与UI分离问题最后说一个最容易忽略的需求分歧。屏幕截图方案抓的是整个屏幕所以模糊背景里既包含FairyGUI的UI也包含3D游戏世界。如果你希望模糊背景只有UI层里面干干净净那屏幕截图方案就不合适了得改成让FairyGUI单独渲染到一个RT再对这个RT做模糊。但这样改有一个新问题3D内容和UI会被剥离开视觉上弹窗背后的世界会被“去掉”而不是“模糊”。所以你要先想清楚产品要什么。如果弹窗打开时希望玩家还能隐约看到背后的3D场景那就用屏幕截图方案把整个世界一起糊掉这是最自然的。如果弹窗是一个纯UI面板背后不需要3D那用单独UI相机渲染再模糊也可以。大多数游戏的弹窗比如签到、商店、活动页都倾向于保留模糊的世界轮廓这种时候屏幕截图方案最省事。另外如果弹窗背景里本来就有视频播放器在播放视频而且这个视频是放在FairyGUI的GRawImage上显示的那么屏幕截图方案可以正常抓到视频画面模糊出来的效果是OK的。但如果视频是放在UGUI层或者原生层播放的抓屏时能不能抓到取决于视频播放器的渲染方式是纹理输出还是硬件层叠加如果是硬件层叠加抓屏只会抓到一片黑。这个点在小游戏平台特别常见微信小游戏的视频播放不少走的是原生组件全屏截图抓不到它。遇到这种情况就只能把视频画面单独输出到RT再合成进FairyGUI做模糊了。我在实际项目里最终用的方案其实很简单弹窗打开时抓一次全屏降采样到四分之一分辨率做两轮水平加垂直高斯模糊再贴到全屏GRawImage上。背景只要不变这张模糊底就一直复用背景要滚动就每五帧重新抓一次。这套组合既躲开了合批的大坑也没让低端机掉帧调试起来还特别直观。如果你的项目也卡在FairyGUI模糊这一环建议照着这个顺序先把链路跑通再根据自己项目的动静状态去选刷新策略大概率能少走好几条弯路。