☰
Unity URP Kawase Blur 毛玻璃性能优化实战
2026/10/24 2:42:48 网站建设 项目流程

简介:面向Unity开发者的一份URP后处理实验工程,聚焦Kawase模糊在通用渲染管线中的实现方式,适合已了解RendererFeature基本概念、希望动手验证多Pass渲染的中级学习者。工程演示如何借助CommandBuffer完成多次绘制、按需增减模糊迭代次数,并将中间结果存入临时纹理,配合毛玻璃演示场景,可直观体会降采样层级与模糊半径的关系。压缩包共66个文件,约133KB,以asset场景与管线配置、preset预设、mat材质、shader与shadergraph着色器、C#脚本为主,另含工程设置、README说明与演示截图,体量轻巧但结构完整、可直接运行。已有865人学习下载。读者能获得可导入URP工程的后处理范例、多Pass命令缓冲的调用方式,以及Kawase模糊的着色器与参数组织思路,便于改造成背景虚化、UI毛玻璃等效果。

1. 屏幕毛玻璃不该拖垮帧率

做 UI 背景毛玻璃或者角色受伤时的屏幕模糊,很多人第一反应是套一个高斯模糊后处理。等真在 Unity URP 里跑起来才发现:效果是有了,手机上一测,帧率直接掉一截。这份 urp_kawase_blur 走的是另一条路——用 Kawase Blur 这种采样次数少、天然分级的算法,把整套逻辑封进一个 RendererFeature,挂在任意 URP 相机上就能开模糊。它本身是个渲染功能测试工程,Shader、RenderPass、参数面板和示例场景都摊开摆在一起,没有额外依赖。适合两类人:刚接触 URP 自定义渲染、想照着抄一个能跑通的 RendererFeature 的;以及已经写了模糊、但卡在采样数和分辨率缩放上的。下面按我拆这份工程的顺序讲。

2. 工程结构与选型:为什么是 Kawase 而不是高斯

拿到一个渲染工程,我习惯先看目录再看管线。目录告诉你作者把哪些东西当成了一等公民,管线配置告诉你这段代码介入的是渲染流程的哪一环。这个工程的目录结构很干净,三类文件分得很清楚:Feature、Pass、Shader,没有多余的 Manager 和单例。

2.1 目录结构与资源组成

不同人整理的测试工程目录名字会有出入,但认准这三类文件就够了。下面是我按照它实际包含的内容整理出的结构,后面所有章节都会落到这些文件上:

urp_kawase_blur/ ├─ Assets/ │ ├─ KawaseBlur/ │ │ ├─ Runtime/ │ │ │ ├─ KawaseBlurFeature.cs # RendererFeature 入口,挂在 Renderer Data 上 │ │ │ ├─ KawaseBlurPass.cs # ScriptableRenderPass,降/升采样循环 │ │ │ └─ KawaseBlurSettings.cs # 可序列化参数容器 │ │ ├─ Shaders/ │ │ │ ├─ DualKawase.shader # 降采样 / 升采样 / 合成三个 Pass │ │ │ └─ KawaseCommon.hlsl # 顶点函数与公共采样宏 │ │ └─ Materials/ │ │ └─ KawaseBlur.mat # 引用 DualKawase.shader │ ├─ Scenes/ │ │ └─ BlurTest.unity # 示例场景,含 UI 面板与测试模型 │ └─ Settings/ │ ├─ URP-Performance.asset # URP Asset │ └─ URP-Renderer.asset # Universal Renderer Data
文件职责改动频率
KawaseBlurFeature.cs生命周期入口,Create()建 Pass,AddRenderPasses()入队低
KawaseBlurPass.cs真正干活的渲染逻辑,管临时 RT 和迭代高
KawaseBlurSettings.cs参数序列化,Inspector 里能直接调中
DualKawase.shader采样核,决定模糊质量与性能中

这套拆分的好处是,改效果不动 C#,改流程不动 Shader。我在项目里见过把采样核写进 C# 里拼字符串的版本,调一次参数要重新编译一次,那才叫遭罪。

2.2 用 RendererFeature 而不是 PostProcessVolume

URP 里做全局后处理有两条路:一条是自定义ScriptableRendererFeature+ScriptableRenderPass,另一条是塞进 Volume 框架走PostProcessVolume。这个工程选的是前者,原因很实际。

Volume 那套要求你实现VolumeComponent和PostProcessPass,还要跟 URP 内置的后处理栈抢执行顺序,稍微调错一点就会跟 Bloom、Color Grading 打架。而 RendererFeature 是官方给的扩展点,renderPassEvent一个字段就能决定这段代码插在渲染流程的哪一步,跟内置后处理互不干扰。对于「我就想要一个模糊」这个需求,Feature 是最短路径。

再看算法本身。高斯模糊想做半径 8 像素的效果,二维核需要采 17×17 次(利用可分离性也要 17+17 次),采样次数随半径线性甚至平方增长。Kawase Blur 的思路完全不同:它不追求数学上的精确高斯分布,而是通过反复的降采样 + 升采样,在每一级里只用 4 次采样,就能造出接近高斯的柔化效果,而且天然带分级信息。

维度可分离高斯双 Kawase
大半径采样次数随半径线性增长每级固定 4 次
是否天然分级否是,降采样自带 mip
大半径质量均匀、干净轻微带状,可通过偏移抖动缓解
适用场景静态图、离线实时相机后处理、UI 毛玻璃

提示:Kawase 不是「高斯的下位替代」。半径小的时候两者肉眼几乎没差别,半径大到 30 像素以上,Kawase 的采样开销优势才真正拉开。选它是因为你的画面在动,而不是因为它便宜。

2.3 一条模糊请求从进入到返回的完整链路

把工程跑起来之后,我用 Frame Debugger 顺着看了一遍,整条链路的顺序是这样的:

  1. URP 渲染完不透明物体,结果写进相机 Color Target;
  2. 相机调用AddRenderPasses,Feature 判断材质是否有效、相机类型是否合法,然后EnqueuePass;
  3. OnCameraSetup里根据相机描述符算出迭代次数和临时 RT 尺寸;
  4. Execute里连续 Blit:先逐级降到 1/2、1/4、1/8,再逐级升回去;
  5. 最后一级结果写回相机颜色,后面的透明物体和后处理继续在模糊后的画面上叠加。

这里有个容易被忽略的细节:第 5 步写回的是相机自己的 Color Target。如果你把renderPassEvent设在了透明物体渲染之后,那透明物体(包括大部分 UI)就不会被糊到;设在了之后又会让后面的东西全糊。选择哪个事件值,本质上是在回答「我要模糊的是哪些物体」,这个后面第 4 章会展开。

3. 核心模块拆解:双通道降采样与升采样

这一章是整个工程的重头戏。Shader 决定糊出来好不好看,C# 决定这块糊得对不对、贵不贵。我按「先看像素怎么采、再看 RT 怎么走、最后看参数怎么进」的顺序拆。

3.1 Shader 里的采样核:4 次采样等效 3×3 均值

双 Kawase 的降采样 Pass 只做一件事——在四个对角方向上各采一次,取平均。因为 GPU 的双线性采样本身就在 2×2 纹素内做了插值,四个对角采样点加起来,等效于一个 3×3 的均值核,但只花了 4 次采样。

Shader "Hidden/UrpKawaseBlur/DualKawase" { Properties { _MainTex ("Source", 2D) = "white" {} } SubShader { Tags { "RenderPipeline" = "UniversalPipeline" } Cull Off ZWrite Off ZTest Always // 全屏 Blit 的固定搭配 HLSLINCLUDE #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex); float4 _MainTex_TexelSize; // x = 1/width, y = 1/height float _Offset; // 采样偏移,单位是像素,逐级递增 float _Blend; // 与原画面混合的权重 struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; }; Varyings Vert(Attributes v) { Varyings o; o.positionCS = TransformObjectToHClip(v.positionOS.xyz); o.uv = v.uv; return o; } ENDHLSL // Pass 0:降采样,四点对角采样 Pass { Name "KawaseDown" HLSLPROGRAM #pragma vertex Vert #pragma fragment FragDown float4 FragDown(Varyings i) : SV_Target { float2 texel = _MainTex_TexelSize.xy * _Offset; float4 sum = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv + float2( texel.x, texel.y)); sum += SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv + float2(-texel.x, texel.y)); sum += SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv + float2( texel.x, -texel.y)); sum += SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv + float2(-texel.x, -texel.y)); return sum * 0.25; } ENDHLSL } // Pass 1:升采样,把上一级结果叠回来 Pass { Name "KawaseUp" HLSLPROGRAM #pragma vertex Vert #pragma fragment FragUp float4 FragUp(Varyings i) : SV_Target { float2 texel = _MainTex_TexelSize.xy * _Offset; float4 sum = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv + float2( texel.x, 0)); sum += SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv + float2(-texel.x, 0)); sum += SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv + float2( 0, texel.y)); sum += SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv + float2( 0, -texel.y)); return sum * 0.25; } ENDHLSL } // Pass 2:合成,把模糊结果按强度混回原图 Pass { Name "KawaseComposite" HLSLPROGRAM #pragma vertex Vert #pragma fragment FragComposite sampler2D _SourceTex; // C# 侧通过 cmd.SetGlobalTexture 传入 float4 FragComposite(Varyings i) : SV_Target { float4 blurred = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv); float4 src = tex2D(_SourceTex, i.uv); return lerp(src, blurred, _Blend); } ENDHLSL } } }

_Offset是这个采样核里唯一需要动态喂的参数。降采样时它的含义是「在对角方向上离中心多远」,值越大,单次采样的覆盖范围越广;升采样时它用来做采样点抖动,让叠回来的结果更接近高斯曲线而不是一串台阶。

_MainTex_TexelSize由 Unity 在每次 Blit 前自动写入,但要注意它的正负号:在部分图形 API 下渲染纹理是上下翻转的,y会是负数。所以别写死假设_MainTex_TexelSize.y > 0,直接用float2整体乘就行,这正是上面代码里float2 texel = _MainTex_TexelSize.xy * _Offset;这个写法的原因。

合成 Pass 里的_SourceTex是我加的一个常见扩展点:如果你想让模糊强度做成滑块,就需要原图和模糊图共存,靠_Blend插值。这个工程默认走的是「直接覆盖」,blendWithSource打开后才走这个 Pass。

3.2 C# 渲染流程:临时 RT 的分配、复用与释放

Pass 里最容易翻车的地方不是采样,是 RT 的生命周期。分配了不释放,跑几分钟就爆显存;每帧重新new CommandBuffer,GC 会在 Profiler 里画出漂亮的锯齿。

using System.Collections.Generic; using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public class KawaseBlurPass : ScriptableRenderPass { private const string k_Tag = "Kawase Blur"; private readonly ProfilingSampler m_Sampler = new ProfilingSampler(k_Tag); // RT 的 id 缓存起来,避免每帧 Shader.PropertyToID 产生字符串哈希开销 private readonly List<int> m_RTs = new List<int>(16); private readonly KawaseBlurSettings m_Settings; private Material m_Material; private RenderTargetIdentifier m_Source; private int m_Iterations; public KawaseBlurPass(KawaseBlurSettings settings, Material material) { m_Settings = settings; m_Material = material; } public void SetSource(RenderTargetIdentifier source) => m_Source = source; public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData data) { // 明确告诉 URP:这一帧要读相机颜色,保证 Color Target 已就绪 ConfigureInput(ScriptableRenderPassInput.Color); var desc = data.cameraData.cameraTargetDescriptor; // 迭代次数随分辨率缩放,1080p 为基准 // 不然 4K 屏上同样的 iteration 会糊成一坨 float scale = Mathf.Max(1f, desc.height / 1080f); m_Iterations = Mathf.Clamp(Mathf.RoundToInt(m_Settings.iteration * scale), 0, 8); } public override void Execute(ScriptableRenderContext context, ref RenderingData data) { if (m_Material == null || m_Iterations <= 0) return; CommandBuffer cmd = CommandBufferPool.Get(); using (new ProfilingScope(cmd, m_Sampler)) { int w = data.cameraData.cameraTargetDescriptor.width; int h = data.cameraData.cameraTargetDescriptor.height; m_RTs.Clear(); RenderTargetIdentifier last = m_Source; // ---- 降采样:分辨率逐级减半,偏移逐级递增 ---- for (int i = 0; i < m_Iterations; i++) { w = Mathf.Max(w / 2, 1); h = Mathf.Max(h / 2, 1); int id = Shader.PropertyToID("_KawaseDown_" + i); cmd.GetTemporaryRT(id, w, h, 0, FilterMode.Bilinear, RenderTextureFormat.Default); cmd.SetGlobalFloat("_Offset", m_Settings.offset + i * m_Settings.offsetStep); cmd.Blit(last, id, m_Material, 0); // pass 0 = Down last = id; m_RTs.Add(id); } // ---- 升采样:从最小一级往上叠,最终回到全分辨率 ---- for (int i = m_RTs.Count - 2; i >= 0; i--) { cmd.SetGlobalFloat("_Offset", m_Settings.offset + i * m_Settings.offsetStep); cmd.Blit(last, m_RTs[i], m_Material, 1); // pass 1 = Up last = m_RTs[i]; } // ---- 写回相机颜色 ---- if (m_Settings.blendWithSource) { m_Material.SetFloat("_Blend", m_Settings.intensity); cmd.Blit(last, m_Source, m_Material, 2); } else { cmd.Blit(last, m_Source); } // 必须成对释放,否则临时 RT 会一直挂在显存里 foreach (int id in m_RTs) cmd.ReleaseTemporaryRT(id); } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } }

逐段说明几处关键逻辑:

OnCameraSetup里做的不是渲染,是「算账」。cameraTargetDescriptor给你的是相机当前 Color Target 的完整描述,宽高决定临时 RT 的尺寸,msaaSamples和depthBufferBits则是要减掉的开销——模糊不需要 MSAA,也不需要深度,把这两个字段清零能省下可观的带宽。迭代次数按height / 1080缩放,解决的是「在小窗口里调好的参数,全屏看完全不一样」这个经典问题。

Execute里的两个 for 循环是这个算法的主体。第一个循环每次都把宽高折半,同时_Offset加上offsetStep,这意味着降采样层级越深,采样点覆盖的原始像素范围越大——这正是 Kawase 能用固定采样次数造出大半径模糊的原因。第二个循环从最深层往回走,每次把上一级的结果叠到当前级的 RT 上,注意这里直接 Blit 进已有的m_RTs[i],不需要重新分配。

最后写回m_Source这一段,有个隐患要提醒:目标就是源,同一个 RT 不能同时读写。cmd.Blit内部会做临时切换,多数情况下安全,但如果你的图形 API 或 URP 版本比较严格,可能出现画面撕裂或闪烁。稳妥的做法是多申请一张全分辨率 RT 做中转,代价是多一次读写。我在真机上遇到过这个翻车,从那以后凡是要写回相机颜色,都强制走中转 RT。

3.3 参数封装:Settings 与 Feature 的职责边界

参数容器的设计直接决定这个工具好不好用。这个工程把参数拆成了「影响效果的」和「影响流程的」两类,前者放在 Settings 里给策划调,后者只在 Feature 里生效。

using UnityEngine; using UnityEngine.Rendering.Universal; [System.Serializable] public class KawaseBlurSettings { [Header("效果")] [Range(0, 8)] public int iteration = 3; // 降采样级数,越大越糊也越贵 [Range(0.5f, 4f)] public float offset = 1.0f; // 首级采样偏移(像素) [Range(0f, 2f)] public float offsetStep = 1.0f; // 每级偏移增量 [Range(0f, 1f)] public float intensity = 1f; // 与原画面的混合权重 public bool blendWithSource = false; [Header("流程")] public RenderPassEvent renderPassEvent = RenderPassEvent.BeforeRenderingTransparents; public Material blurMaterial; }

iteration是性价比最高的旋钮。从 2 调到 4,画质提升肉眼可见;从 4 调到 8,画质几乎没变化,开销却翻倍。我在实际项目里一般定在 3 到 5 之间,超过 6 就该考虑是不是该用降分辨率渲染了。

offset和offsetStep决定模糊的「手感」。offset太小,模糊会带明显的方形块状感;太大,画面会丢失整体结构,看起来像隔着毛玻璃看。这两个值的比例如实反映了作者对视觉的取舍,改的时候建议一次只动一个。

Feature 这一层则很薄,只负责创建 Pass、判断该不该入队:

using UnityEngine; using UnityEngine.Rendering.Universal; public class KawaseBlurFeature : ScriptableRendererFeature { public KawaseBlurSettings settings = new KawaseBlurSettings(); private KawaseBlurPass m_Pass; public override void Create() { m_Pass = new KawaseBlurPass(settings, settings.blurMaterial); m_Pass.renderPassEvent = settings.renderPassEvent; } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData data) { if (settings.blurMaterial == null) return; // 场景视图和材质预览窗口别糊,不然编辑器里没法干活 var type = data.cameraData.cameraType; if (type == CameraType.Preview || type == CameraType.Reflection) return; m_Pass.SetSource(renderer.cameraColorTarget); renderer.EnqueuePass(m_Pass); } }

cameraColorTarget这个属性在较新的 URP 里已经标记为过时,替代品是cameraColorTargetHandle,返回的是RTHandle。如果你把这份工程移植到新版本,这一行大概率要改,改完记得同步调整SetSource的参数类型。这类 API 迁移是 URP 项目升级时最常见的返工点,血泪经验是别等到打包前一周才升版本。

4. 跑起来:从打开工程到看见第一帧模糊

理论讲完,落到操作。这一章按我实际跑通的顺序写,每一步都标了失败时该看哪里。

4.1 环境准备与工程导入

新建或打开一个使用 URP 模板的 Unity 工程,把这个资源包里的KawaseBlur文件夹整体拖进Assets。拖完先别急着开场景,检查两件事:Shader 有没有报编译错误,材质球上的 Shader 引用有没有断。

# 用命令行做一次静默编译检查,适合接 CI Unity -batchmode -quit -projectPath ./urp_kawase_blur \ -logFile ./build/shader_compile.log

这条命令的价值在于,它会把 Shader 编译错误写进日志文件而不弹窗。团队协作时我一般把它挂成提交前检查,比人肉点开 Unity 看控制台靠谱。-quit保证编译完就退出,-logFile指定输出路径,路径不存在会直接失败,所以要先建目录。

导入后确认 URP Asset 已经生效:Project Settings 里 Graphics 的 Scriptable Render Pipeline Settings 不为空,Quality 里各等级的 Render Pipeline Asset 也指向同一个 URP Asset。这一步漏了的话,场景能打开、模型能显示,但 Feature 永远不会被调用——属于典型的「代码没问题,配置没生效」。

4.2 挂载 RendererFeature 与事件值选择

找到Settings/URP-Renderer.asset,在 Inspector 底部点 Add Renderer Feature,选KawaseBlurFeature。挂上之后把KawaseBlur.mat拖进Blur Material字段,Iteration先给 3,其他保持默认。

然后是Render Pass Event,这个字段决定模糊作用在哪些物体上:

事件值模糊范围典型用途
BeforeRenderingTransparents仅不透明物体景深、背景虚化
BeforeRenderingPostProcessing不透明 + 透明屏幕受伤特效、整体柔化
AfterRenderingPostProcessing全部,含后处理结果刻意的「隔着玻璃」观感

第一次跑建议先用BeforeRenderingPostProcessing,这样场景里的模型和半透明特效都能被糊到,效果最直观。如果放上去发现完全没变化,先看 Frame Debugger 里有没有出现名为 Kawase Blur 的绘制事件,没有就是配置问题,有就是 Shader 或 RT 的问题,两者的排查方向完全不同。

4.3 用 Frame Debugger 和参数面板做一次验证

点开 Window > Analysis > Frame Debugger,Enable 之后逐条展开绘制调用。你要找的是这样一组事件:先出现若干次分辨率递减的 KawaseDown,再出现等量的 KawaseUp,最后回到相机分辨率。如果 KawaseDown 只出现一次就结束了,说明iteration被算成了 1,回头检查OnCameraSetup里的缩放逻辑是不是被 1080 除出了 0。

参数调试的推荐顺序是:先把iteration定下来(决定模糊的「硬度」),再调offset(决定颗粒感),最后用intensity收口。反过来调会一直在两个变量之间反复横跳,很难收敛。

4.4 运行时开关与性能采样

工程里通常还会带一个简单的开关脚本,方便对比开与不开的帧率差异:

using UnityEngine; using UnityEngine.Rendering.Universal; public class BlurSwitch : MonoBehaviour { [SerializeField] private UniversalAdditionalCameraData cameraData; [SerializeField] private KawaseBlurFeature feature; // Inspector 里拖资源引用 private void Update() { // 按 B 键切换,做性能对比时最省事 if (Input.GetKeyDown(KeyCode.B) && feature != null) feature.SetActive(!feature.isActive); } }

SetActive之后AddRenderPasses不会被调用,Pass 自然不入队,开销归零,比去改材质球的开关干净。UniversalAdditionalCameraData在这里其实用不到,如果你的开关只控制 Feature,把它删掉即可——我保留它是因为常在这块加相机级别的判断,比如只在主相机上开模糊。

性能采样的时候别只看总体帧率。Profiler 里切到 Rendering 模块,看Kawase Blur这一项占多少毫秒,再对比关掉时的差距。手机上这个值超过 2ms 就该考虑降迭代次数或者把分辨率降到 0.5 倍再升回来。

5. 避坑与排查:五种「看起来像玄学」的故障

渲染问题最气人的地方在于,同样的代码在别人机器上跑得好好的,到你这就是一片黑。下面五条是我在这类工程上真实踩过的,按「现象 → 原因 → 解决」整理。

5.1 完全没效果,Frame Debugger 里也找不到 Pass

现象:场景正常显示,就是一点都不糊,Frame Debugger 里搜不到 Kawase 字样。

原因:三种可能,一是 Feature 挂错了 Renderer Data(工程里有多个 Renderer,相机用的是另一个);二是blurMaterial字段空着,AddRenderPasses里被提前 return;三是相机是 Overlay 类型,而 Feature 只挂了 Base 相机的 Renderer。

解决:先在AddRenderPasses里加一行Debug.Log(data.cameraData.cameraType + " / " + (settings.blurMaterial != null));,跑一帧看日志。九成情况下日志里会出现你没想到的相机类型。

5.2 模糊生效了,但 UI 也跟着糊成一团

现象:3D 场景糊得很漂亮,HUD 和按钮也一起变模糊了,文字完全没法看。

原因:renderPassEvent设成了BeforeRenderingPostProcessing或更靠后,而 UI 是在相机之后单独渲染的,属于 Overlay 相机的合成内容,被一起处理了。

解决:把事件值改回BeforeRenderingTransparents,只糊不透明物体;或者给 UI 单独开一个 Overlay 相机,让主相机的 Feature 不作用于它。用 Canvas 的 Screen Space - Camera 模式时尤其要注意这层关系。

5.3 画面边缘出现黑边或彩色漏光

现象:屏幕四角发暗,或者模糊后边缘出现一圈奇怪的色带。

原因:采样时 UV 越界。_Offset逐级递增,到最后一级时采样点已经跑出了 [0,1] 范围,采样器默认的 wrap 模式会把对边的像素拉过来,形成漏光。

解决:把采样器的寻址模式改成 Clamp。UML 里可以在 Shader 中显式声明SAMPLER(sampler_ClampLinear)并在材质上配置,也可以在 C# 侧给临时 RT 建一个RenderTexture并设wrapMode = TextureWrapMode.Clamp。这是最容易被忽略的一条,因为它只在大半径时暴露。

5.4 开模糊之后整体画面变暗或发灰

现象:不开模糊时画面正常,一开就整体偏暗,颜色也没那么饱和了。

原因:临时 RT 的格式和相机的 Color Target 格式不一致。相机在 Linear 色彩空间下用的是 sRGB 格式,而你用RenderTextureFormat.Default申请的 RT 可能是线性格式,一来一回做了两次错误的色彩转换。

解决:别用固定的RenderTextureFormat.Default,从cameraTargetDescriptor.colorFormat里取,原样传给GetTemporaryRT。这是我在一个项目里排查了整整一下午才定位到的问题,当时还以为是 Shader 里的lerp权重出了问题。

5.5 编辑器里很流畅,打包到手机上掉帧严重

现象:PC 上跑 200 帧,安卓真机上开模糊直接掉到 30 以下。

原因:移动端 GPU 对带宽极其敏感。全分辨率 RT 的读写、msaaSamples没清零、每帧重新申请临时 RT,这三点叠加起来,带宽会被吃干净。

解决:一是确认OnCameraSetup里把msaaSamples和depthBufferBits清零了;二是把降采样的起始分辨率直接设成屏幕的一半,反正最后都要降到 1/8,从一半开始只是少一级;三是检查m_RTs有没有在每次 Execute 后清空,List.Clear()只清元素不释放 RT,必须配ReleaseTemporaryRT。

注意:移动端上ProfilingSampler是有开销的,发布包里记得把 ProfilingScope 相关代码剥掉,或者用#if DEVELOPMENT_BUILD包起来。

6. 二次开发:把它改成你自己的模糊工具链

测试工程能跑通只是起点,真正要落地,一般还得往前推三步。

第一步,把参数接到 Volume 上。现在参数锁死在 Renderer Data 里,场景美术改一次要重新打开资产。常见做法是新建一个KawaseBlurVolume : VolumeComponent,把iteration、offset、intensity声明成ClampedIntParameter和ClampedFloatParameter,然后在 Pass 里通过VolumeManager.instance.stack.GetComponent<KawaseBlurVolume>()取值。这样同一份 Feature 可以在不同场景、不同后处理 Profile 下呈现完全不同的模糊强度。

第二步,加上遮罩。全局模糊用得多,局部模糊用得更多——比如只糊屏幕中心以外、只糊某个物体背后。思路是额外传一张 mask 图,在合成 Pass 里用lerp(src, blurred, mask.r * _Blend)替代现在的统一插值。mask 可以由另一个相机渲染到 RT,也可以直接给一张静态贴图。

第三步,做分辨率降级。与其在OnCameraSetup里按 1080 硬除,不如把降采样起始分辨率做成一个 0 到 1 的系数,低端机设 0.5、高端机设 1.0,模糊半径靠offset补偿回来。这样同一套参数在中低端设备上也不会崩。

改造点涉及文件改动量风险
Volume 参数化Settings、Pass中需要处理 Volume 为空时的兜底
遮罩模糊Shader Pass 2、Pass中mask 的 UV 对齐容易错
分辨率降级Pass.OnCameraSetup小缩放系数要同步调 offset
写回中转 RTPass.Execute小多一次全屏读写

改完怎么验证?我的习惯是准备三个固定场景:一个高对比度棋盘格(看采样核有没有问题)、一个纯色渐变(看有没有色带)、一个带文字 UI 的界面(看字会不会糊)。每次改完跑一遍这三个,基本能兜住大部分回归。

有一回我图省事,直接在主工程里改了采样核没做备份,结果升采样那级的偏移量算错,整个画面糊成一片灰,回滚 Git 发现上一次提交是三天前。从那以后,凡是动 Shader 的采样参数,我都强制先把原始工程复制一份单独放,改完在副本里对着棋盘格验证通过,再合回主工程。渲染这东西,肉眼真的不可靠,还是得靠那几个不会说话的标准场景。

希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询