做Unity项目,尤其是带商城、背包、副本入口这一类界面的时候,弹窗背景的高斯模糊几乎是躲不掉的审美需求。之前被Asset Store里的UI Gaussian Blur插件坑过一阵,装上去之后整个Canvas的渲染层级直接乱掉,URP下还有兼容问题;后来换成半透明黑底,又总觉得廉价。折腾几天之后,我沉淀下来一个真正适合UGUI的高斯模糊背景简易方案:把当前画面截一帧,降到低分辨率RT,用分离高斯模糊跑几遍,最后丢给RawImage当弹窗背景。不依赖额外插件、不碰渲染管线,一个Shader加一个C#脚本就能跑通。
有类似需求的Unity开发可以直接照着抄,美术想要毛玻璃质感的弹窗背景、策划想要背包界面底下带虚化的角色展示,都能套用这个方案。文章后面我还会把踩过的坑完整记录下来,包括截屏黑屏、层级错乱、内存泄漏这几个高频问题,照着排查能省不少时间。
1. 为什么UGUI的高斯模糊背景这么难做
先说清楚一个误区:UGUI背景模糊这件事,难点不在于写一个模糊Shader,而在于想拿到“UI这一层”的画面本身就很别扭。如果你只是想把一张图片模糊,Image组件的材质或Shader改改就行;但你要的是“把弹窗底下的所有UI和场景虚化掉”,这等于要先抓取当前整个屏幕内容,再对屏幕内容做模糊处理。
1.1 Overlay模式下后处理根本抓不到UI
大部分Unity项目UI都用Screen Space - Overlay模式,这个模式下Canvas不经过摄像机渲染,画面是渲染完整个3D场景之后,以屏幕贴纸的方式直接叠上去的。于是内置管线的OnRenderImage、URP的CommandBuffer这一类后处理钩子,能处理到的只是摄像机画面,UI根本不在里面。
打个比方:你给显示器装一个滤镜,滤镜效果只会作用在显示器显示的3D场景上,屏幕表面贴的那层UI贴纸滤镜碰不到。这正是“UGUI不支持后处理模糊”这一说法的真正来源——准确说是Overlay模式下的UGUI不支持常规后处理。
如果UI换成Screen Space - Camera模式,让单独一架UICamera去渲染UI,后处理就够得着了。但一个老项目把所有UI从Overlay切到Camera模式,会牵扯字体渲染、Mask、屏幕适配、层级等一系列连锁改动,代价非常大。这也是为什么很多人一搜“UGUI 高斯模糊”就头疼。
1.2 Asset Store插件和“假模糊”方案各自的局限
Asset Store里最有名的UI Gaussian Blur插件,很多项目都在用。它本质上是把摄像机渲染结果实时模糊后叠加到UI层,效果确实不错,但限制也明显:需要一个专门的相机和Canvas配合方式、Mask裁剪支持不好、部分版本在URP下直接罢工。我自己的项目里装上它之后,ScrollView的Mask区域开始穿帮,模糊层把不该露出来的屏幕边缘都糊到了弹窗周围,调了一晚上最后只能回滚。
另一种常见的“假模糊”是美术直接出一张静态模糊背景图,或者用半透明黑底罩一层。静态模糊图对固定界面没问题,但只要弹窗底下的内容会变化——背包滚动、场景换模型、角色换装——假背景就对不上了,一眼穿帮。半透明黑底更是治标不治本,什么都看不清但也没有高级感。
我把常见做法放在一起对比,方便你按自己的项目背景选型:
| 方案 | 是否实时跟随背景 | 对UGUI层级影响 | 实现成本 | 移动端性能 |
|---|---|---|---|---|
| 美术静态模糊图 | 否 | 无 | 低 | 无额外开销 |
| 半透明黑底 | 否 | 无 | 极低 | 无额外开销 |
| UI Gaussian Blur插件 | 是 | 明显 | 中 | 中高 |
| 全屏后处理(OnRenderImage) | 是 | 无 | 中 | 中高 |
| 本文的截屏+降采样方案 | 打开瞬间更新 | 无 | 低 | 低 |
插件和全屏后处理的问题在于,它们默认你要做的是“实时模糊”——模糊画面要跟着底层内容动。但弹窗场景的真实诉求往往是:弹窗打开那一瞬间,底下内容是什么,背景就虚化什么,打开之后我可以接受它静止。想通这一点,方案就瞬间简单了。
1.3 这个认知转变才是关键
很多人卡在“UGUI高斯模糊”这个需求上,是因为一开始就默认必须实时模糊。其实弹窗从打开到关闭,底层画面不会频繁剧烈变化,用户关注的是弹窗内容,不是背景每一帧都跟着动。所以“打开瞬间截一帧、生成一次模糊图”这个思路,在绝大多数弹窗场景里都够用,而且实现成本比实时方案低一个量级。
这也是我后来读UGUI渲染原理相关源码时更确认的一点:UGUI的Overlay渲染路径本质上是把UI网格直接提交给屏幕空间,中间几乎没有留给后处理插入的缝隙。你硬要往上叠后处理,等于和UGUI的渲染机制对着干;还不如绕开它,用截图的方式拿到结果。
2. 破局思路:截屏-降采样-分离模糊-RawImage
当你接受“打开瞬间更新一次”这个前提,整条路就通了:先把弹窗显示前的完整Game视图截一帧,然后对这张图做高斯模糊,再把模糊后的图作为RawImage的texture显示在弹窗最底层。弹窗一关,释放相关RenderTexture。
2.1 全流程拆解
具体分五步:
- 隐藏弹窗(或确认弹窗尚未显示),等待当前帧绘制完成。
- 用
ScreenCapture.CaptureScreenshotIntoRenderTexture把当前画面截到一张全分辨率RenderTexture。 - 把全分辨率RT缩小(Blit)到一张低分辨率RT,这一步同时完成了低频化。
- 在低分辨率RT上用分离高斯模糊Shader横向、纵向交替迭代2-3轮。
- 把最终RT赋值给
RawImage.texture,开启RawImage,弹窗内容叠在上面。
第1步和第2步的顺序我踩过坑,到第5节踩坑记录里细说。这里先强调一点:截屏不能截到弹窗自己,所以调用时机一定要在弹窗真正显示之前。
有人会问:直接截全屏会不会把不想模糊的东西也截进去?不会,因为你的需求就是“整块背景都模糊”,截屏内容和弹窗背景范围天然一致。就算界面上有血条、小地图这类常驻UI,它们也会一起被模糊掉,视觉上反而统一。
2.2 为什么“降采样+小核模糊”能顶替大半径高斯
高斯模糊在数学上是可分离的:一个二维高斯核可以拆成水平方向和竖直方向各跑一次一维高斯。这就是为什么Shader里只需要横向采样和纵向采样两个Pass,而不是做一个巨大的二维采样核。
那为什么还要降采样?因为模糊的本质是抹掉高频细节,而降采样本身就是天然的低通滤波。把1920x1080截屏先缩到480x270,高频信息已经丢了一大半,这时候哪怕只用半径2-3像素的小核跑两遍横向、两遍纵向,视觉上已经接近原始分辨率下半径十几像素的大核模糊效果,开销却差了一个数量级。
生活里也这样:近视眼看不清远处的东西,眯一下眼会稍微清晰一点,再眯一下又更糊一点。降采样就是眯眼,迭代模糊就是反复眯眼,最后达到“看不清细节但能感觉到色块氛围”的效果。
2.3 这个方案的边界要心里有数
“截图一次”方案不适合以下场景:
- 弹窗打开后底层画面还在持续大幅变化,比如视频播放、地图拖动、动态角色展示。这种场景下模糊背景像一张静止壁纸,和动态内容放在一起会显得不自然。
- 弹窗本身带透明区域,并且希望透过透明区域实时看到底层内容,截屏模糊只能显示模糊结果,看不到实时画面。
- WebGL平台下
CaptureScreenshotIntoRenderTexture的表现不够稳定,个别浏览器会拿到黑屏,需要做平台分支处理。
我自己项目里的判断标准是:只要弹窗从打开到关闭期间,底层动效幅度不大,这个方案完全够用。大部分弹窗背景本来就是个氛围衬托,没人会盯着它看实时不实时。
3. 开工:Shader与C#脚本一步步落地
下面直接给可复制的完整代码,环境是Unity 2020.3 LTS,内置渲染管线。URP下需要注意的点放在第5节,别急。
3.1 分离高斯模糊Shader
新建一个Shader,名字随意,我这里叫Custom/UIBlur。这个Shader不直接挂到任何UI材质上,它只给Graphics.Blit做RT之间的搬运模糊用。
模糊采样我用了13个tap,即中心点加左右(上下)各6个采样点,权重按高斯分布近似。横向和纵向写成两个Pass,通过Graphics.Blit的pass参数选择。
Shader "Custom/UIBlur" { Properties { _MainTex ("Texture", 2D) = "white" {} _BlurSize ("Blur Size", Range(0, 8)) = 1 } SubShader { Cull Off ZWrite Off ZTest Always Pass // 0 横向模糊 { CGPROGRAM #pragma vertex vert_img #pragma fragment frag #include "UnityCG.cginc" sampler2D _MainTex; float4 _MainTex_TexelSize; float _BlurSize; fixed4 frag (v2f_img i) : SV_Target { float2 offset = _MainTex_TexelSize.xy * float2(1, 0) * _BlurSize; float3 col = tex2D(_MainTex, i.uv).rgb * 0.180; col += tex2D(_MainTex, i.uv + offset * 1).rgb * 0.145; col += tex2D(_MainTex, i.uv - offset * 1).rgb * 0.145; col += tex2D(_MainTex, i.uv + offset * 2).rgb * 0.110; col += tex2D(_MainTex, i.uv - offset * 2).rgb * 0.110; col += tex2D(_MainTex, i.uv + offset * 3).rgb * 0.070; col += tex2D(_MainTex, i.uv - offset * 3).rgb * 0.070; col += tex2D(_MainTex, i.uv + offset * 4).rgb * 0.045; col += tex2D(_MainTex, i.uv - offset * 4).rgb * 0.045; col += tex2D(_MainTex, i.uv + offset * 5).rgb * 0.025; col += tex2D(_MainTex, i.uv - offset * 5).rgb * 0.025; col += tex2D(_MainTex, i.uv + offset * 6).rgb * 0.015; col += tex2D(_MainTex, i.uv - offset * 6).rgb * 0.015; return fixed4(col, 1); } ENDCG } Pass // 1 纵向模糊 { CGPROGRAM #pragma vertex vert_img #pragma fragment frag #include "UnityCG.cginc" sampler2D _MainTex; float4 _MainTex_TexelSize; float _BlurSize; fixed4 frag (v2f_img i) : SV_Target { float2 offset = _MainTex_TexelSize.xy * float2(0, 1) * _BlurSize; float3 col = tex2D(_MainTex, i.uv).rgb * 0.180; col += tex2D(_MainTex, i.uv + offset * 1).rgb * 0.145; col += tex2D(_MainTex, i.uv - offset * 1).rgb * 0.145; col += tex2D(_MainTex, i.uv + offset * 2).rgb * 0.110; col += tex2D(_MainTex, i.uv - offset * 2).rgb * 0.110; col += tex2D(_MainTex, i.uv + offset * 3).rgb * 0.070; col += tex2D(_MainTex, i.uv - offset * 3).rgb * 0.070; col += tex2D(_MainTex, i.uv + offset * 4).rgb * 0.045; col += tex2D(_MainTex, i.uv - offset * 4).rgb * 0.045; col += tex2D(_MainTex, i.uv + offset * 5).rgb * 0.025; col += tex2D(_MainTex, i.uv - offset * 5).rgb * 0.025; col += tex2D(_MainTex, i.uv + offset * 6).rgb * 0.015; col += tex2D(_MainTex, i.uv - offset * 6).rgb * 0.015; return fixed4(col, 1); } ENDCG } } Fallback Off }两点说明:所有采样权重的和约等于1,所以模糊后整体亮度不会漂移;vert_img和v2f_img是UnityCG里现成的结构,不用自己写顶点Shader。_BlurSize在低分辨率RT下用1.0到2.0就够,后面性能部分给具体调参经验。
记得把文件名改成UIBlur.shader,或者用Shader的默认创建方式,保证Shader.Find("Custom/UIBlur")能找到。如果你在Inspector里手动指定了Shader,这个查找逻辑就不是必须的,但保险一点留着没坏处。
3.2 UIFrostedBackground组件
新建C#脚本UIFrostedBackground,挂在弹窗最底层的RawImage上。脚本的核心是生成模糊RT并赋给RawImage,同时管理RT生命周期。
using System.Collections; using UnityEngine; using UnityEngine.UI; [RequireComponent(typeof(RawImage))] public class UIFrostedBackground : MonoBehaviour { [Tooltip("降采样系数,越大越模糊也越省性能")] [Range(2, 8)] public int downsampleFactor = 4; [Tooltip("横纵模糊迭代次数,2-3次足够")] [Range(1, 5)] public int blurIterations = 3; [Tooltip("模糊半径,低分辨率RT下通常用1-2")] [Range(0.5f, 4f)] public float blurSize = 1.5f; public Shader blurShader; private RawImage rawImage; private Material blurMaterial; private RenderTexture fullRT; private RenderTexture lowRT; private RenderTexture swapRT; private bool isBusy; void Awake() { EnsureInitialized(); } public void Show(System.Action onReady = null) { if (isBusy) return; EnsureInitialized(); StartCoroutine(BlurAndShow(onReady)); } public void Hide() { StopAllCoroutines(); isBusy = false; if (rawImage != null) { rawImage.texture = null; rawImage.enabled = false; } if (lowRT != null) { RenderTexture.ReleaseTemporary(lowRT); lowRT = null; } if (swapRT != null) { RenderTexture.ReleaseTemporary(swapRT); swapRT = null; } if (fullRT != null) { RenderTexture.ReleaseTemporary(fullRT); fullRT = null; } } private void EnsureInitialized() { if (rawImage == null) { rawImage = GetComponent<RawImage>(); } if (blurMaterial == null) { if (blurShader == null) blurShader = Shader.Find("Custom/UIBlur"); blurMaterial = new Material(blurShader); rawImage.enabled = false; } } IEnumerator BlurAndShow(System.Action onReady) { isBusy = true; yield return new WaitForEndOfFrame(); fullRT = RenderTexture.GetTemporary(Screen.width, Screen.height, 0, RenderTextureFormat.ARGB32); ScreenCapture.CaptureScreenshotIntoRenderTexture(fullRT); int w = Mathf.Max(1, Screen.width / downsampleFactor); int h = Mathf.Max(1, Screen.height / downsampleFactor); lowRT = RenderTexture.GetTemporary(w, h, 0, RenderTextureFormat.ARGB32); swapRT = RenderTexture.GetTemporary(w, h, 0, RenderTextureFormat.ARGB32); Graphics.Blit(fullRT, lowRT); blurMaterial.SetFloat("_BlurSize", blurSize); for (int i = 0; i < blurIterations; i++) { Graphics.Blit(lowRT, swapRT, blurMaterial, 0); // 横向 Graphics.Blit(swapRT, lowRT, blurMaterial, 1); // 纵向 } rawImage.texture = lowRT; rawImage.enabled = true; RenderTexture.ReleaseTemporary(fullRT); fullRT = null; RenderTexture.ReleaseTemporary(swapRT); swapRT = null; isBusy = false; onReady?.Invoke(); } void OnDestroy() { if (blurMaterial != null) Destroy(blurMaterial); } }脚本几个设计点要说明:EnsureInitialized用了惰性初始化,这样即使弹窗根节点初始是SetActive(false),第一次调用Show时也能把组件拿全,不会出现空引用。lowRT在Show执行完不能释放,因为RawImage还要引用它,Hide时再释放;swapRT作为双缓冲的中间RT,Show结束时就可以释放。isBusy防止用户在模糊生成过程中反复点击导致重复截屏,这个在快速连点弹窗按钮时特别重要。
3.3 在场景中的挂载和调用顺序
层级结构建议这样:
- 弹窗根节点(初始
SetActive(false))- Background(RawImage,挂UIFrostedBackground组件)
- Content(弹窗实际内容,放在Background之后保证渲染在模糊层之上)
调用代码推荐用协程组织,因为要等截屏那一帧结束:
public IEnumerator OpenPopup() { popupRoot.SetActive(false); // 先保证弹窗本体不在画面里 var bg = popupRoot.GetComponentInChildren<UIFrostedBackground>(); bool ready = false; bg.Show(() => ready = true); yield return new WaitUntil(() => ready); popupRoot.SetActive(true); // 模糊背景就绪后再显示弹窗 } public void ClosePopup() { popupRoot.GetComponentInChildren<UIFrostedBackground>().Hide(); popupRoot.SetActive(false); }这样能保证截屏时画面里绝对没有弹窗内容,模糊背景生成完再一次性显示弹窗,体验上就是弹窗“带着毛玻璃背景”瞬间出现。注意GetComponentInChildren对非激活物体也能取到组件,但脚本的Awake可能没执行过,所以EnsureInitialized在Show里再兜底一次是必要的。
4. 性能实测与参数调优思路
先说结论:在1080p屏幕上,downsampleFactor=4、blurIterations=3、blurSize=1.5这套组合,视觉上已经接近“毛玻璃”质感,单次生成耗时在PC上约2-3ms,在骁龙中端机型上约8-12ms。因为只在弹窗打开时执行一次,这个开销完全可接受。
4.1 参数组合的实测对比
我拿一个带背景文字的背包界面做了组对比,RT迭代总开销大致如下:
| downsampleFactor | blurIterations | blurSize | 视觉观感 | 1080p生成耗时(PC) | 适用建议 |
|---|---|---|---|---|---|
| 2 | 2 | 1.0 | 还能看出文字轮廓 | 约1.5ms | 需要保留一点底层信息时 |
| 4 | 2 | 1.5 | 明显模糊,细节丢失 | 约2.0ms | 常用起点 |
| 4 | 3 | 1.5 | 均匀毛玻璃感 | 约2.8ms | 推荐配置 |
| 8 | 3 | 2.0 | 非常糊,光斑感 | 约1.4ms | 纯氛围背景 |
| 8 | 5 | 2.0 | 光斑太重,像对焦失败 | 约2.2ms | 不推荐,除非刻意 |
有意思的是downsampleFactor从4提到8,总耗时反而下降了,因为RT面积降到原来的四分之一,虽然迭代次数相同,每次Blit处理像素少了很多。代价是越低的采样率越容易出现大面积颜色晕开,反而丢失玻璃质感。我的经验是1080p下用4最稳,2K屏可以提到6,手机上可以适当降到3,因为小屏对细节感知本来就不如大屏。
4.2 移动端和WebGL要注意什么
移动端最大的瓶颈不是模糊本身,而是CaptureScreenshotIntoRenderTexture这一步。部分Android机型在做全屏截屏时会有额外的内存拷贝,建议在截屏前先确保没有大块显存占用,不要在弹窗和战斗特效同一帧出现时触发截屏。如果你的项目用了一套自定义的Overlay相机栈,截屏RT的尺寸可能会和屏幕实际尺寸不一致,最好在截屏前用Screen.width和Screen.height双重确认。
WebGL平台我对这个API的稳定性持保留态度。如果发布WebGL后截出来是黑屏,先检查项目是否开了多线程渲染,必要时可以给WebGL单独做一个静态模糊图兜底,或者改成在弹窗打开前用协程等两帧再截屏,这个方案在部分浏览器下能绕开黑屏问题。
移动端还有一点:RawImage的texture是低分辨率RT,UGUI在放大显示时会有双线性过滤的柔化效果,这个特性其实是好事,正好让边缘更柔和。如果你发现低分辨率下颜色出现条纹,检查RT的filterMode是不是Bilinear,默认应该就是这个,别改成Point。
4.3 参数背后的核心权衡
降采样系数、迭代次数、模糊半径三个参数不是孤立的。降采样直接决定后续每个像素代表多大的屏幕区域,所以降采样越狠,_BlurSize可以越小;迭代次数决定高斯核被重复应用的次数,迭代越多低频越均匀,但超过3次收益递减;blurSize在低分辨率RT上超过2.0后,13个tap会漏掉中间的过渡区间,产生“花朵绽放”式的不自然分离感,这时候应该加迭代而不是继续加大半径。
我给一个简易口诀:先定降采样,再调迭代,最后微调blurSize。降采样决定性能上限,迭代决定氛围,blurSize只做最后润色。如果调了半天还是觉得模糊得不均匀,先回去看降采样是不是太低,而不是死磕半径。
5. 踩坑记录:黑屏、层级穿透与内存泄漏
方案本身不复杂,但实际接入项目时容易出问题。我把自己踩过的四个坑完整记录下来,照着排查能省不少时间。
5.1 第一次打开黑屏或截到旧帧
现象:弹窗首次打开时背景是黑的,关掉再开变成上一次的画面。
根因排查链路是这样的:先确认截屏调用是否在WaitForEndOfFrame之后。CaptureScreenshotIntoRenderTexture必须在UI全部绘制完成后再调用,否则拿到的RT是空的或半成品。其次确认弹窗根节点是否在截屏前SetActive(false),如果弹窗已经显示,截下来当然包含弹窗自己,效果就直接穿帮了。最后检查是不是透明通道问题:如果Game视图或相机目标是透明背景,ARGB32的alpha是0,RawImage显示出来就会整块透明。遇到这种情况,截屏RT格式改成RGB24,或者把RawImage的color.a保持255。
排查顺序建议固定为:时机、层级、颜色格式,三步走。我见过一个项目两个问题同时存在,先从时机入手判断,等帧结束的问题解决了一半,再处理颜色格式,黑屏就彻底消失了。
5.2 模糊层盖住弹窗内容或点击穿透
第一种表现是弹窗内容被模糊层压住。原因是RawImage在层级顺序上排在内容后面,但Unity UI的渲染顺序由Hierarchy顺序决定,如果RawImage挂载节点在Content节点之后,它就会渲染在内容上方。把Background节点放到Content之前即可。
第二种表现是点击穿透:如果RawImage的raycastTarget是false,点击事件会直接穿透到下层UI,用户以为自己在点弹窗按钮,实际上点到了底层界面的按钮。我的做法是把RawImage的raycastTarget保持true,并在它的RectTransform上铺满全屏,这样它能同时承担“隔离点击”的职责。如果你希望点击空白处关闭弹窗,就再给RawImage挂一个Button,点击事件里做关闭逻辑。
这里要特别提醒:如果弹窗背景RawImage的RectTransform没有铺满全屏,raycastTarget=true也会漏掉外面的区域。我习惯让它Stretch全屏,然后让真正的弹窗内容面板控制点击范围。
5.3 URP和旧版本API的兼容问题
项目是URP时,我实测CaptureScreenshotIntoRenderTexture在内置管线可以正常工作,URP大部分设备上也没问题。但如果遇到黑屏,最可能的原因是URP的相机栈和后处理Feature干预了截屏内容。一个绕开办法是把UICamera单独渲染到RT,复杂度会上去一些;更快的方案是直接放弃截屏API,改用Camera.targetTexture配合相机渲染,但那个改动涉及的分支更多。
Unity 2019及以下版本没有CaptureScreenshotIntoRenderTexture这个API,只能退回去用Texture2D.ReadPixels读屏幕。注意读之前也要WaitForEndOfFrame,并且记得把Texture2D设置为不可读或及时销毁,否则每开一次弹窗就会多一块无法立刻回收的托管内存,长时间玩会出现明显的卡顿和内存上涨。
5.4 RenderTexture泄漏和频繁开关弹窗
不止一次看到有人把RenderTexture.GetTemporary当成普通new,用完不释放。这个方案里RT的释放时机很明确:fullRT在模糊迭代开始前就可以释放,swapRT在迭代结束后释放,lowRT要保留到Hide。Hide里要释放lowRT并且把rawImage.texture置空,否则RawImage还引用着一块已经被回收的RT,下一帧可能出现紫屏或者花屏。
还有个隐藏问题:弹窗还没生成完就被关闭,协程还在跑,继续截屏、继续申请RT,最终结果却没人显示。所以我在Hide里调用了StopAllCoroutines并置isBusy=false。如果你在同一帧里连续调用Show再Hide,也要保证Hide的逻辑能覆盖“协程尚未走完”的情况。用Profiler的Memory Profiler模块检查RenderTexture数量,是验证这类泄漏最直接的方式。
到这里,核心实现和排错链路都完整了。我自己后来还在这个方案基础上做了一个扩展:既然模糊后的lowRT是低分辨率下的结果,我直接把它当纹理用在两个UI界面之间的切换动画上,在模糊RT上叠一层透明度渐变,做出了类似下拉通知中心那种过渡效果,反馈很自然。拿这个思路去做动态模糊过渡,比单独用实时模糊插件灵活得多,后面等我把这套过渡动画整理稳定了再单独写一篇细聊。