搞过内置管线(Built-in Render Pipeline)的老项目应该都有过这种体验:需求方说“这里弹窗背景要糊一点”,你以为只是调个透明度,结果越调越像马赛克。真正想让背景变成类似 iOS 控制中心那种自然的毛玻璃,靠 UI 上叠一层半透明图是骗不了人的,得让 Shader 去读取已经画好的屏幕内容,再做模糊。而这件事在 Unity 内置渲染管线下并没有现成的 UI 组件,很多人一搜就撞上“用 URP 吧”“用 HDRP 吧”的建议,可项目代码、资源包、第三方插件全是 Built-in 管线写的,迁移成本不是一般的高。这篇文章就从一个能落地的项目经验出发,聊聊在内置管线里怎么做屏幕模糊 Shader,涉及 GrabPass、高斯模糊、OnRenderImage、临时 RenderTexture 的复用和性能取舍,适合那些还在维护 Built-in 工程的 Unity 开发者,尤其是做休闲游戏、小游戏、工具类应用的团队参考。
1. 先分清楚你要的“模糊”是哪一种
做模糊之前最容易犯的错,就是把三种需求混在一起聊。它们虽然都叫“屏幕模糊”,解决方案完全不同。
1.1 全屏后处理模糊
场景切换、关卡开始、弹窗背景整块变糊,这种属于后处理。做法是把整帧画面渲染到一张 RenderTexture 上,然后用模糊 Shader 把这张图处理一遍,再输出到屏幕。完整流程通过 MonoBehaviour 的OnRenderImage挂在任意摄像机上都行,或者在 CommandBuffer 里主动抓取一次屏幕。特点是影响整个画面,和 UI 层级没关系。
这种方案最简单的评判指标是:能不能在低端手机上跑得动。全屏后处理意味着每一个像素都要经过多次采样,1080p 的屏幕一帧就是两百万像素起步,稍微多叠几步模糊,fillrate 立刻爆掉。所以全屏方案必须搭配降采样、分辨率缩放和迭代次数控制。
1.2 局部面板背景模糊
比如商城弹窗后面只糊中间一块区域,四周保持清晰。这种需求经常出现在 UI 上,本质不是“全屏模糊”,而是“部分区域模糊”。可选的实现路线有两条:
- 给需要模糊的 UI 元素单独用带 GrabPass 的材质,抓屏后采样它背后的内容。
- 把屏幕抓取到一个低分辨率 RT,在 RT 上做完整模糊,最后把一个带透明度的局部 mesh 贴到 UI 上。
两条路我都试过。第一条实现简单,但成本藏在 GrabPass 里;第二条可控性强,适合 UI 里面有多个模糊区、且模糊半径需要统一调优的情况。很多人误以为 GrabPass 一定很慢,真实情况是:移动端现在的 GPU 对 GrabPass 的适应程度比想象中好,但要控制抓取次数和采样范围,不能让每个模糊面板都独立触发一次屏幕拷贝。
1.3 玻璃折射和伪透明
还有一种“模糊”其实是折射,比如主角在水晶球后面看到扭曲的画面,边缘还有色散。这时候不一定要做真正的高斯模糊,反而是采样周围像素坐标做偏移、扰动、拉伸更常见。真正的模糊只是其中一个可选项。
如果你做的是类似“隔着磨砂玻璃看背景”的效果,那思路就和 2D 局部模糊不一样:需要考虑法线扰动、折射偏移、焦散模拟,甚至要将背面法线图参与计算。先把需求定清楚,才不会写到一半发现方向错了。
2. 内置管线下做高斯模糊的核心套路
内建管线本身不提供模糊后处理,Shader 得自己写。任何模糊算法绕不开一个概念:卷积。高斯模糊就是用一个符合高斯分布的权重核,去和当前像素周围的颜色做加权平均。
2.1 高斯权重核的本质
通俗讲,就是把“周围颜色对当前像素的影响”按距离排列出来。越靠近当前像素,权重越高;越远,权重越低。这个权重表按二维排列就是 3x3、5x5 甚至 9x9 的矩阵。
举个 3x3 的例子,如果不考虑归一化,中间像素权重是 4,上下左右权重是 2,四个角权重是 1。想算某个像素模糊后的颜色,就把周围 9 个像素颜色各自乘以对应权重再累加。画面里所有像素都做一遍,就是一次完整的二维卷积模糊。
但直接做二维卷积的问题很现实:5x5 核需要 25 次纹理采样,1080p 全屏就是 5400 万次采样;9x9 核就要 8100 万次采样。移动端 GPU 的纹理采样带宽是有限资源,这么干一个后效果就足够把帧率砸到个位数。
2.2 可分离卷积是移动端活下去的关键
高斯核有一个非常好的数学性质——它可以分离成横向一维核和纵向一维核。也就是说,二维高斯模糊可以先在水平方向做一次一维模糊,再在垂直方向做一次一维模糊,效果等价于直接做二维模糊,但采样次数从 N 的平方变成 2N。
对于 5x5 核:原来 25 次采样,分离后 10 次。对于 9x9 核:原来 81 次,分离后 18 次。差距巨大,所以几乎所有实用模糊 Shader 都会拆成两个 Pass:Pass1 做水平模糊,Pass2 做垂直模糊。
如果你在网上看到的模糊 Shader 只有一个 Pass 里写一大片二维偏移采样,那它一般只是教学演示,不建议直接塞进项目。除非它用的是一个很大的间隔尺寸来模拟“大范围扩散”,否则性能和效果都很难令人满意。
2.3 降采样:视觉不明显,性能收益巨大
另一个关键套路是先降低分辨率再做模糊。屏幕是 1080p,如果先拷贝到 540p 的 RenderTexture,像素总量立刻变成原来的四分之一;再做模糊的时候,每个像素要处理的邻居数量不变,但是像素密度低了,最终效果只是“模糊半径相对更大”,观感反而更柔和。
实际操作中,降采样倍数通常 2 到 4 倍比较安全,再高就会出现明显锯齿和像素颗粒。配合双线性过滤放大回来,视觉上的损失很小,性能上的收益却很实在。后面 C# 代码里我一般把downSample参数直接暴露出来,美术同事想在编辑器里看效果,改一下即可,不用等打包后再调。
3. 用 GrabPass 做局部模糊的基础 Shader
先看一个快速能跑的 GrabPass 局部模糊 Shader。这种写法适合把一个透明面板作为对象放进场景,或者放在 UI 的 Screen Space Camera 下面,直接对面板背后的画面做采样模糊。
Shader "Custom/ScreenBlurGrab" { Properties { _Color ("Tint", Color) = (1,1,1,1) _BlurSize ("Blur Size", Range(0.1, 10)) = 3 } SubShader { Tags { "Queue"="Transparent" "RenderType"="Transparent" } GrabPass { "_GrabBlurTex" } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float4 grabPos : TEXCOORD1; }; sampler2D _GrabBlurTex; float4 _GrabBlurTex_TexelSize; half _BlurSize; fixed4 _Color; v2f vert (appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = v.uv; o.grabPos = ComputeGrabScreenPos(o.pos); return o; } fixed4 frag (v2f i) : SV_Target { float2 screenUV = i.grabPos.xy / i.grabPos.w; float2 texelSize = _GrabBlurTex_TexelSize.xy * _BlurSize; fixed4 col = 0; // 3x3 简易模糊,只用于快速验证效果 col += tex2D(_GrabBlurTex, screenUV + float2(-texelSize.x, -texelSize.y)) * 0.0625; col += tex2D(_GrabBlurTex, screenUV + float2(0, -texelSize.y)) * 0.125; col += tex2D(_GrabBlurTex, screenUV + float2(texelSize.x, -texelSize.y)) * 0.0625; col += tex2D(_GrabBlurTex, screenUV + float2(-texelSize.x, 0)) * 0.125; col += tex2D(_GrabBlurTex, screenUV) * 0.25; col += tex2D(_GrabBlurTex, screenUV + float2(texelSize.x, 0)) * 0.125; col += tex2D(_GrabBlurTex, screenUV + float2(-texelSize.x, texelSize.y)) * 0.0625; col += tex2D(_GrabBlurTex, screenUV + float2(0, texelSize.y)) * 0.125; col += tex2D(_GrabBlurTex, screenUV + float2(texelSize.x, texelSize.y)) * 0.0625; return col * _Color; } ENDCG } } }这个 Shader 里有几个点值得解释一下。
GrabPass { "_GrabBlurTex" }表示:在渲染这个物体之前,把当前屏幕内容抓取到一张名为_GrabBlurTex的纹理里。以后同一个帧里再有其他物体也用这个名字,就不会重复抓取,直接复用同一张纹理。这一点非常关键,如果你写的是空名字GrabPass {},Unity 会自动生成一个唯一纹理名,同一帧内多个材质会各自抓一次,性能直接翻车。
ComputeGrabScreenPos是把裁剪空间坐标转成 GrabPass 纹理的采样坐标,必须使用它才能保证在屏幕空间正确对位。如果只是用常规的模型 UV,材质会被错误的坐标拉扯,模糊区域和面板位置完全对不上。
_GrabBlurTex_TexelSize是 Unity 为抓取纹理自动生成的一个变量,表示一张纹理中单个像素的宽高。模糊偏移要以它为基础,否则不同分辨率下面板的模糊半径会感观不一致。
不过我得提醒,这个 Shader 里的 3x3 模糊只是验证用,真放到项目里效果太粗糙。因为 GrabPass 方案本身没有额外的临时 RT 来保存中间模糊结果,直接在一个 Pass 里做二维卷积,采样范围再大一点就会消耗暴增。真要在 UI 上做出精致模糊,我更推荐下面这套用OnRenderImage控制多次 Blit 的方案。它虽然代码多一点,但能把降采样和水平垂直模糊完整串联起来,效果稳定可控。
4. 更可控的全屏方案:OnRenderImage 加可分离模糊
内置渲染管线里最标准的屏幕后处理入口就是OnRenderImage。把脚本挂在任意摄像机,Unity 会先渲染完场景,再调用这个方法,并把当前画面传到src中。我们在这里对src做模糊,最后输出到dest。
4.1 C# 端控制降采样和迭代
using UnityEngine; [ExecuteInEditMode] [RequireComponent(typeof(Camera))] public class ScreenBlurEffect : MonoBehaviour { public Shader blurShader; public Material blurMaterial; [Range(0, 8)] public int iterations = 3; [Range(1, 8)] public int downSample = 2; [Range(0.2f, 3f)] public float blurSpread = 1f; private void OnRenderImage(RenderTexture src, RenderTexture dest) { if (blurMaterial == null || blurShader == null) { Graphics.Blit(src, dest); return; } int width = src.width / downSample; int height = src.height / downSample; RenderTexture buffer0 = RenderTexture.GetTemporary(width, height, 0, src.format); RenderTexture buffer1 = RenderTexture.GetTemporary(width, height, 0, src.format); buffer0.filterMode = FilterMode.Bilinear; buffer1.filterMode = FilterMode.Bilinear; // 先用第一个 Pass 做一次水平模糊,并顺便完成降采样 Graphics.Blit(src, buffer0, blurMaterial, 0); for (int i = 0; i < iterations; i++) { // 垂直模糊 Graphics.Blit(buffer0, buffer1, blurMaterial, 1); // 水平模糊 Graphics.Blit(buffer1, buffer0, blurMaterial, 0); } // 最后一次输出的 buffer1,其实是垂直模糊后的结果 // 如果 loop 次数是偶数,需要判断最后一次到底写在哪个 RT Graphics.Blit(buffer0, dest); RenderTexture.ReleaseTemporary(buffer0); RenderTexture.ReleaseTemporary(buffer1); } }上面代码里有个细节很多人会写错:循环里的交替 Blit 会改变结果所在的 RT。比如第一次循环:
Graphics.Blit(buffer0, buffer1, material, 1);把 buffer0 垂直模糊到 buffer1。Graphics.Blit(buffer1, buffer0, material, 0);把 buffer1 水平模糊回 buffer0。
循环结束如果 iterations 是奇数,最后结果在 buffer0;如果是偶数,最后结果在 buffer1。为了避免这种“硬记”带来的 bug,更稳妥的做法是循环后把结果统一拷贝到 dest:
Graphics.Blit(buffer0, dest);前提是你确保最后一次水平模糊确实写回了 buffer0。实际上不管 iterations 是多少,循环体内部总是从 buffer0 开始、到 buffer0 结束,所以最后直接 Blit buffer0 即可。
如果 final 输出时想要更柔合的放大效果,也可以先blurMaterial.SetTexture("_MainTex", buffer0)再用一个特殊 Pass 输出。但多数情况下 Bilinear 过滤加上低分辨率放大已经够用,不必额外增加复杂度。
4.2 Shader 端两种模糊 Pass
对应的 Shader 至少要有两个 Pass:一个水平权重,一个垂直权重。下面是基于 Unity 官方早期 BlurEffect 思路精简的双 Pass 版本,权重核是优化后的 5 tap 高斯近似,采样次数少,移动端比较友好。
Shader "Hidden/ScreenBlurEffect" { Properties { _MainTex ("Texture", 2D) = "white" {} _BlurSize ("Blur Size", Float) = 1.0 } SubShader { Cull Off ZWrite Off ZTest Always Pass { NAME "GAUSS_H" CGPROGRAM #pragma vertex vert #pragma fragment frag_h #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; sampler2D _MainTex; float4 _MainTex_TexelSize; float _BlurSize; v2f vert (appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = v.uv; return o; } fixed4 frag_h (v2f i) : SV_Target { float2 offset = float2(_MainTex_TexelSize.x * _BlurSize, 0); half3 col = tex2D(_MainTex, i.uv).rgb * 0.227027; col += tex2D(_MainTex, i.uv + float2(offset.x * 1.384615, 0)).rgb * 0.316216; col += tex2D(_MainTex, i.uv - float2(offset.x * 1.384615, 0)).rgb * 0.316216; col += tex2D(_MainTex, i.uv + float2(offset.x * 3.230769, 0)).rgb * 0.070270; col += tex2D(_MainTex, i.uv - float2(offset.x * 3.230769, 0)).rgb * 0.070270; return fixed4(col, 1); } ENDCG } Pass { NAME "GAUSS_V" CGPROGRAM #pragma vertex vert #pragma fragment frag_v #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; sampler2D _MainTex; float4 _MainTex_TexelSize; float _BlurSize; v2f vert (appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = v.uv; return o; } fixed4 frag_v (v2f i) : SV_Target { float2 offset = float2(0, _MainTex_TexelSize.y * _BlurSize); half3 col = tex2D(_MainTex, i.uv).rgb * 0.227027; col += tex2D(_MainTex, i.uv + float2(0, offset.y * 1.384615)).rgb * 0.316216; col += tex2D(_MainTex, i.uv - float2(0, offset.y * 1.384615)).rgb * 0.316216; col += tex2D(_MainTex, i.uv + float2(0, offset.y * 3.230769)).rgb * 0.070270; col += tex2D(_MainTex, i.uv - float2(0, offset.y * 3.230769)).rgb * 0.070270; return fixed4(col, 1); } ENDCG } } }这里的权重和偏移量是配套出现的,不要随便改其中一个。offset.x * 1.384615这个系数是通过高斯分布推导出来的优化采样位置,能让 5 次采样近似出 9 次采样的效果。权重加起来正好是 1,所以不会让画面整体变亮或变暗。
4.3 为什么迭代次数不是越多越好
一个常见的认知误区是“模糊迭代越多越糊”。其实迭代的作用并不是提高模糊半径,而是让模糊后的分布更接近理想高斯。第一次模糊能把近邻像素混合起来,第二次模糊会让这个混合继续扩散,但扩散存在上限。迭代从 1 次增加到 3 次,视觉效果变化明显;从 3 次增加到 6 次,变化很小,性能却实打实翻倍。
我日常调参的顺序是:先把downSample固定为 2,再看清晰度是否够;如果画面出现锯齿感,就调低downSample到 1 或 1.5(整数除法只能写 1 或 2,想要 1.5 得在 C# 里用浮点算 RT 宽高);如果模糊半径还差一点,增加blurSpread,而不是盲目加iterations。
5. 精度、坐标和 UI 适配这些容易翻车的地方
很多模糊效果在电脑上看好好的,打包到 Android 或 iOS 上就出问题,通常是下面几个原因。
5.1 UV 翻转问题
在内置管线里,不同图形 API 的屏幕坐标原点不一样。DX 系的坐标原点在左上,OpenGL 系在左下。如果 Shader 里对垂直方向做了有方向的偏移,比如实现“只向上模糊”,在 OpenGL 设备上可能发现方向反了。
解决方案是使用 Unity 提供的_MainTex_TexelSize,而不是自己硬编码1.0 / _ScreenParams.y。Unity 会在需要时让_MainTex_TexelSize.y变成负值,用来匹配当前图形 API 的纹理采样方向。上面 Shader 里直接使用_MainTex_TexelSize.y,通常能自动适配大多数情况。
5.2 HDR 和颜色精度
如果场景里使用了 HDR 渲染,或者相机的 RenderTexture 格式是 ARGBHalf,模糊后颜色可能会出现很亮的边缘。内置管线默认的后处理链有时会用低动态范围,导致高光区域被削平。
处理方案有两种:要么在上面的 C# 脚本里把src.format换成RenderTextureFormat.ARGBHalf这样的硬件格式,要么在最终输出前多写一个 Tonemap Pass,把颜色压低。一般 UI 模糊需求用不到 HDR,所以我通常直接用src.format,保持和源一致,避免额外的颜色转换。
5.3 UI Canvas 的适配
如果你是在 UI 里做毛玻璃,最省事的前置条件是 Canvas 使用 Screen Space - Camera 模式,并且面板材质放在合适的层级。Screen Space - Overlay 模式下,UI 的渲染顺序很特殊,GrabPass 抓到的内容往往会漏掉一些刚画的 UI 元素,容易让人以为“Shader 抓不到屏幕”。
把 Canvas 改为 Screen Space - Camera 后,就能利用 Canvas 内部的 SortingGroup 和材质渲染队列来控制模糊元素到底该在 UI 的哪一层触发抓屏。这样做虽然会引入一个额外相机,但对复杂界面的适配会从容很多。
5.4 边缘漏色与黑边
模糊后物体边缘出现半透明黑边或漏色,一般是采样到了抓取纹理的外面,被 Texture 的 Wrap Mode 的黑色补边影响了。GrabPass 抓取的纹理在 Shader 里默认可能是 Clamp,也有可能因为平台差异变成 Repeat。保险做法是在 Shader 里手动用tex2Dlod或把 UV 限制在0.003到0.997之间,至少在 UI 边缘不会出现明显的黑线。
在 OnRenderImage 方案中,因为后处理覆盖了整张屏幕,正常不需要担心边缘;但如果项目里同时开启多个后处理,中间 RT 的尺寸不一致可能导致边缘半透明像素扩散,这时候就要注意每次GetTemporary时设置相同的 filter mode。
6. 实操中遇到的常见问题与排查清单
把这段时间踩过的坑整理成一张速查表,照着排查比重新翻资料快得多。
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 屏幕全黑但 UI 正常 | 模糊 Shader 有编译错误,或者OnRenderImage中 material 为空 | 打开 Frame Debugger 看 Blit 调用的 Pass 是否正常;检查 Console 是否报 Shader error |
| 模糊范围不对,面板偏移 | 使用了普通 UV 而不是ComputeGrabScreenPos | 顶点着色器里必须把裁剪空间坐标转成 grab UV |
| 移动端模糊区域闪烁 | 多个 GrabPass 使用了不同纹理名,每帧抓多次 | 统一使用同一个 GrabPass 纹理名,或者手动抓一次屏幕后复用 |
| 模糊后画面明显变亮 | 权重核没有归一化,或者混合阶段没有关闭 Blend | 检查所有偏移采样权重相加是否等于 1 |
| 边缘发黑 | 采样越界后得到 0 值颜色 | 把抓取纹理采样坐标做 Clamp,或使用固定小数位防止 UV 跳出 |
| Android 低端机发热严重 | 迭代次数和降采样没有平衡 | 先强制downSample=4,iterations=2,再逐步提高 |
| Scene 视图看不到效果 | OnRenderImage在 Scene 视图默认不一定执行 | 把[ExecuteInEditMode]加上,但要留意编辑器性能 |
这里面最坑的是第一个。Unity 的 Shader 编译错误不会像 C# 那样弹一个醒目的窗口,尤其后处理 Shader 挂到OnRenderImage后,如果 Pass 索引和 Shader 定义对不上,画面上看不到任何报错,只会一直黑屏。排查时先用最简单的方式验证:
- 把
iterations设为 0。 - 把
downSample设为 1。 - 确认 Material 的 Shader 已经选择正确。
如果这样还是黑屏,多半是 Shader 文件里 Pass 语法错了。直接在材质面板点 Open Shader 编译器报错信息,比在场景里瞎猜快得多。
7. 内置管线的性能调优:从能用跑到流畅
性能问题永远是后处理逃不掉的话题。同样是全屏模糊,项目可以是每帧 2 毫秒,也可以是每帧 30 毫秒,差距不在理论,而在实现细节。
帧耗时的大头有三个:抓屏拷贝、临时 RT 分配、像素填充量。
抓屏拷贝是一次全屏 RenderTexture 到 RenderTexture 的拷贝,1080p 下成本很小,但如果一个界面里有 10 个模糊面板且每个面板都触发一次 GrabPass,成本就要乘以 10。我实际项目中会把所有模糊面板的材质统一指向同一个 GrabPass 纹理名,让 Unity 在一帧内只抓一次屏幕。
临时 RT 分配使用的是GetTemporary,这个函数自带内部缓存,比直接new RenderTexture高效,但也别滥用。OnRenderImage里每帧 Get、每帧 Release,只要顺序正确,重复利用的是内部池里的资源,不会触发 GC。真正要避免的是在 Update 里 GetTemporary,那个频率会打爆缓存池。
像素填充量是模糊效果的重灾区。1080p 全屏效果、5 tap 采样、3 次迭代,GPU 需要处理大约 200 万像素乘以 5 次采样乘以 6 次左右 Blit,最后有效采样次数非常可观。如果把分辨率降到 540p,像素量变成四分之一,采样次数成比例下降,视觉效果却不会跟着差四倍。
下面的参数组合是我们在不同设备上的起点:
| 设备大类 | 目标帧率 | 推荐参数 |
|---|---|---|
| iPhone 中低端 | 60 FPS | downSample=2, iterations=2, blurSpread=1.0 |
| 中端 Android | 60 FPS | downSample=3, iterations=2, blurSpread=1.2 |
| 高端 Android / iOS | 60 FPS | downSample=2, iterations=3, blurSpread=1.5 |
| PC 编辑器预览 | 不强制 | downSample=1, iterations=3, blurSpread=1.5 |
这套参数不是万能解药,但它能给你一个合理的出发点。如果模糊后文字看不清,就先调低blurSpread,别动downSample;如果出现像素颗粒,就先降低downSample,别急着加迭代。
还有一个值得考虑的小技巧:不是每一帧都需要实时模糊。很多 UI 弹窗打开后,背景变化其实是可控的,可以在弹窗打开的瞬间抓一次屏幕、做一次模糊,存成静态 RenderTexture,后续几帧只要直接 Blit 出来就行。这种缓存策略在一些“过度设计”的界面交互里非常有效,能直接把模糊开销从持续每帧变成一次性开销。根据我个人体验,这个思路比单纯压参数更能保住画面质量。
最后再补一句:Built-in 管线做屏幕模糊并不难,难的是在性能和效果之间找到平衡。别一上来就追求那种超大半径的梦幻毛玻璃,先跑通降采样、双 Pass 高斯、临时 RT 复用这条链路,再逐步往里面加折射、色散、动态半径这些花活,项目维护起来会轻松很多。