ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

UGUI弹窗毛玻璃背景新方案:截屏降采样+分离模糊,不依赖插件

UGUI弹窗毛玻璃背景新方案:截屏降采样+分离模糊,不依赖插件 做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 GetComponentRawImage(); } 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作为双缓冲的中间RTShow结束时就可以释放。isBusy防止用户在模糊生成过程中反复点击导致重复截屏这个在快速连点弹窗按钮时特别重要。3.3 在场景中的挂载和调用顺序层级结构建议这样弹窗根节点初始SetActive(false)BackgroundRawImage挂UIFrostedBackground组件Content弹窗实际内容放在Background之后保证渲染在模糊层之上调用代码推荐用协程组织因为要等截屏那一帧结束public IEnumerator OpenPopup() { popupRoot.SetActive(false); // 先保证弹窗本体不在画面里 var bg popupRoot.GetComponentInChildrenUIFrostedBackground(); bool ready false; bg.Show(() ready true); yield return new WaitUntil(() ready); popupRoot.SetActive(true); // 模糊背景就绪后再显示弹窗 } public void ClosePopup() { popupRoot.GetComponentInChildrenUIFrostedBackground().Hide(); popupRoot.SetActive(false); }这样能保证截屏时画面里绝对没有弹窗内容模糊背景生成完再一次性显示弹窗体验上就是弹窗“带着毛玻璃背景”瞬间出现。注意GetComponentInChildren对非激活物体也能取到组件但脚本的Awake可能没执行过所以EnsureInitialized在Show里再兜底一次是必要的。4. 性能实测与参数调优思路先说结论在1080p屏幕上downsampleFactor4、blurIterations3、blurSize1.5这套组合视觉上已经接近“毛玻璃”质感单次生成耗时在PC上约2-3ms在骁龙中端机型上约8-12ms。因为只在弹窗打开时执行一次这个开销完全可接受。4.1 参数组合的实测对比我拿一个带背景文字的背包界面做了组对比RT迭代总开销大致如下downsampleFactorblurIterationsblurSize视觉观感1080p生成耗时(PC)适用建议221.0还能看出文字轮廓约1.5ms需要保留一点底层信息时421.5明显模糊细节丢失约2.0ms常用起点431.5均匀毛玻璃感约2.8ms推荐配置832.0非常糊光斑感约1.4ms纯氛围背景852.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是低分辨率RTUGUI在放大显示时会有双线性过滤的柔化效果这个特性其实是好事正好让边缘更柔和。如果你发现低分辨率下颜色出现条纹检查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是0RawImage显示出来就会整块透明。遇到这种情况截屏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没有铺满全屏raycastTargettrue也会漏掉外面的区域。我习惯让它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并置isBusyfalse。如果你在同一帧里连续调用Show再Hide也要保证Hide的逻辑能覆盖“协程尚未走完”的情况。用Profiler的Memory Profiler模块检查RenderTexture数量是验证这类泄漏最直接的方式。到这里核心实现和排错链路都完整了。我自己后来还在这个方案基础上做了一个扩展既然模糊后的lowRT是低分辨率下的结果我直接把它当纹理用在两个UI界面之间的切换动画上在模糊RT上叠一层透明度渐变做出了类似下拉通知中心那种过渡效果反馈很自然。拿这个思路去做动态模糊过渡比单独用实时模糊插件灵活得多后面等我把这套过渡动画整理稳定了再单独写一篇细聊。
返回列表