如果你在Unity里折腾过自定义后处理,大概率遇到过这几个问题:一个pass写完效果太单薄,想多pass串联却发现中间RT的管理很麻烦;又或者你辛辛苦苦写好混合、模板、深度状态,最后跑起来却发现某个状态根本没生效,画面黑成一团、轮廓线飘到天边。URP 14.x(对应Unity 2022 LTS那条线)里,自定义后处理已经是成熟玩法,但很多教程还是停留在“复制一段Blit代码”的层面,碰到多pass和渲染状态整体配置就直接断档。
这篇文章要聊的就是URP Shader编程里的自定义后处理多pass渲染,以及围绕RenderStateBlock的整体状态配置。我会从框架原理、RT流转、状态封装讲到完整可跑的示例,再把调试经验一并放出来。适合的对象是:已经在URP里做过基础Shader、想把手伸到后处理管线,或者被多pass和渲染状态折磨到怀疑人生的开发者。
1. 先搞懂URP自定义后处理的运行骨架
1.1 三个核心组件各司其职
URP里自定义后处理不是只写个Shader就完事,你需要三样东西配合:
- VolumeComponent:负责在Volume面板上暴露参数,让美术调节数值。
- ScriptableRendererFeature:生命周期入口,负责创建Pass,并决定是否把Pass注入渲染流程。
- ScriptableRenderPass:真正干活的类,在某个执行点拿到CommandBuffer,操作RT,Draw全屏三角形。
这三个组件的关系可以这样理解:Feature是开关和调度器,Pass是流水线上的一台加工机器,VolumeComponent是机器面板上的旋钮。你写Shader后处理,本质是让这台机器按你定的顺序处理画面。
URP 14.x里,ScriptableRenderPass的主要注入点有几个:BeforeRendering、BeforeRenderingPostProcessing、AfterRenderingPostProcessing、AfterRenderingLastCamera等。大多数自定义后处理会选BeforeRenderingPostProcessing或AfterRenderingPostProcessing。选哪个取决于你要不要和URP内置的后处理栈(Bloom、ToneMapping这些)叠加。如果你希望自己的效果在Blum之前跑,就选BeforeRenderingPostProcessing;希望它最后输出,就选AfterRenderingPostProcessing。
1.2 为什么不要直接Blit了事
很早的URP教程里,大家喜欢在Execute里写cmd.Blit(source, dest)。这个写法在简单场景下没问题,但一旦你把Blit塞进ScriptableRenderPass里,会碰到几个麻烦:
第一,Blit内部会处理渲染目标切换,有些底层状态你控制不到;第二,Blit在URP的RenderGraph模式下有兼容性问题,会触发重载方案(FakeBlitToBackbuffer),造成不必要的拷贝性能损耗;第三,多pass串联时,你依赖Graphics.Blit的source/dest管理,很容易写成一串“老鼠窝一样的临时贴图链”。
所以在URP 14.x的后处理里,我更推荐用DrawProcedural画全屏三角形。思路是:设置好渲染目标,把一张RT塞进全局Shader属性,然后cmd.DrawProcedural(Matrix4x4.identity, overrideMaterial, passIndex, MeshTopology.Triangles, 3)。一次调用画3个顶点,生成全屏覆盖。Shader端用SV_POSITION和屏幕UV做插值,完全可控,配合RenderStateBlock也很自然。
2. 多pass后处理:中间RT是灵魂
2.1 为什么一个pass搞不定复合效果
很多后处理效果本质上是多阶段流水线。举个典型例子:模糊+描边合成。你至少需要两个pass:
第一个pass做模糊本身。模糊如果只用一次采样,结果非常弱,正经做法是水平模糊+垂直模糊分成两个pass,或者用一个pass同时做多方向采样(但那样采样数和权重控制会变复杂)。再加上你想把模糊结果和原图叠加、描边、加颜色,一个pass里堆变量,Shader会变得很难维护,参数耦合严重。
更合理的拆法是:
- pass 0:对原图做某种处理(比如提取高亮、模糊、边缘检测),输出到中间RT。
- pass 1:读取中间RT作为_SourceTex,和原图或某种颜色混合,叠加额外效果,输出到屏幕。
多pass的核心收益在于:每个pass职责单一,Shader可维护性好;中间RT可以在多个pass之间反复流转,做级联效果;渲染状态也可以按pass切换——某个pass需要纯覆盖输出,另一个pass需要半透明叠加,这正是RenderStateBlock发挥价值的地方。
2.2 多pass里的RTHandle分配与复用
URP 14.x里,临时RT的分配不再是你自己new RenderTexture,而是用RTHandle系统。先看一段分配代码:
private RTHandle m_BlurRT; private RTHandle m_TempRT; public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData) { RenderTextureDescriptor desc = renderingData.cameraData.cameraTargetDescriptor; desc.depthBufferBits = 0; desc.colorFormat = RenderTextureFormat.DefaultHDR; m_BlurRT = RTHandles.Alloc(desc, name: "BlurRT"); m_TempRT = RTHandles.Alloc(desc, name: "TempRT"); }注意几个点:
- desc来源于cameraTargetDescriptor,能保证RT分辨率和当前相机目标一致,避免多pass里RT尺寸不匹配导致拉伸。
- depthBufferBits设为0。后处理中间RT通常不需要深度,除非你要做深度相关的效果。
- 用DefaultHDR给中间RT,避免颜色精度丢失。如果你的效果不需要HDR,可以换R8G8B8A8_SRGB等格式,但前提是你清楚URP管线的HDR开关状态。
- RTHandle不是用完就扔的东西。每帧Alloc、每帧Release会造成反复建RT,非常消耗性能。正确做法是在OnCameraSetup里检查尺寸是否变化,如果已有RT且尺寸匹配就复用,不匹配再Release重挂。OnCameraCleanup里释放。
多pass之间的流转逻辑是:pass0输出到m_BlurRT,然后把m_BlurRT作为全局贴图塞给Shader,渲染到相机目标;或者继续作为pass1的输入。只要保证Shader的全局属性名和C#端SetGlobalTexture对应,RT流转就没问题。踩过坑的人都懂,“中间结果看不见”通常就是你把贴图塞错了对象。
3. RenderStateBlock整体配置:一次设置,全场生效
3.1 之前为什么会“配置混乱”
很多人在Shader里写后处理时,喜欢在每个pass里直接堆状态指令:
Pass { Name "Blur" Blend Off ZWrite Off Cull Off // ... }这种写法本身没错,问题在于:当你有多个pass、多个Feature、多套状态切换时,状态分散在Shader的各个Pass里,C#端很难在运行时动态调整。比如你想让同一个Shader在不同环境下走不同的混合策略,或者在模板测试通过与否之间切换,你得写多个变体、多个材质实例,维护成本直线上升。
RenderStateBlock解决的就是这个痛点:它把渲染管线上的一组状态(混合、深度、模板、光栅化)打包成一个可编程的结构体,由C#端在Pass执行前整体设置。Shader里写什么状态不重要,C#端通过overrideRenderState会把状态覆盖掉。于是“状态”不再是一堆散落在Pass里的Magic命令,而是可以被逻辑控制的、可复用的配置块。
3.2 RenderStateBlock结构拆解
RenderStateBlock在UnityEngine.Rendering命名空间下,URP的ScriptableRenderPass直接对它做了支持。核心字段包括:
- mask:告诉管线哪些状态被覆盖。可选的RenderStateMask包括Blend、Raster、Depth、Stencil、Everything。这是个很关键的字段,新手经常漏掉——你只设置blendState,但mask没有包含Blend,那配置就是白写。
- blendState:混合开关、src/dst混合系数、混合操作符。
- rasterState:剔除模式、深度偏移。
- depthState:深度写入、深度比较函数。
- stencilState:模板启用状态、读写掩码、比较函数、三个操作(stencil fail、depth fail、depth pass)。
- stencilReference:模板参考值(配合上方stencilState比较时所用)。
初始化方式需要注意构造函数的mask参数:
RenderStateBlock block = new RenderStateBlock(RenderStateMask.Everything) { blendState = new BlendState { enabled = true, srcBlend = BlendMode.SrcAlpha, dstBlend = BlendMode.OneMinusSrcAlpha, srcBlendAlpha = BlendMode.One, dstBlendAlpha = BlendMode.OneMinusSrcAlpha, blendOp = BlendOp.Add, blendOpAlpha = BlendOp.Add }, depthState = new DepthState { enableWrite = false, compareFunction = CompareFunction.Always }, rasterState = new RasterState { cullMode = CullMode.Off }, stencilState = new StencilState { enabled = true, readMask = 255, writeMask = 255, compareFunction = CompareFunction.Always, passOperation = StencilOp.Replace, failOperation = StencilOp.Keep, zFailOperation = StencilOp.Keep }, stencilReference = 1 };注意BlendState和StencilState这类内部结构体,构造时有一些重载差异。像BlendState的构造函数有internalConstructor参数,如果你new BlendState()后不显式赋全部字段,某些字段可能保持默认零值。实测下来最保险的做法是:你用什么字段就全部显式赋值一遍,别贪省事。RenderStateBlock的默认mask是Nothing,这是最容易踩的坑——你以为设置了,实际上管线压根没启用覆盖。
3.3 接入ScriptableRenderPass的正确姿势
在ScriptableRenderPass子类里,RenderStateBlock有两种常见接入方式。
方式一:通过overrideRenderState整体设置
public class MyPostPass : ScriptableRenderPass { private RenderStateBlock m_StateBlock; public MyPostPass() { renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing; m_StateBlock = new RenderStateBlock(RenderStateMask.Everything) { // 按需配置 }; } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { overrideRenderState = m_StateBlock; // ...后续DrawProcedural都会应用这个状态 } }设置好overrideRenderState之后,这个Pass里后续所有DrawProcedural、DrawRenderer调用(在未单独覆盖状态的情况下)都会使用该状态块。如果你希望某个Pass在第一个Draw前用状态A、第二次Draw用状态B,手动在Execute里切换overrideRenderState即可。
方式二:用CommandBuffer.SetRenderState配合DrawProcedural(在RenderGraph外)
cmd.SetRenderState(m_StateBlock); cmd.DrawProcedural(Matrix4x4.identity, m_Material, 0, MeshTopology.Triangles, 3);但我实际项目中很少直接混用两种方式,因为容易造成状态管理的二义性。我的习惯是:如果这个Pass的状态需求相对固定,直接用overrideRenderState;如果一次Execute里要对不同对象施以不同状态,那就在Execute中途切换overrideRenderState,每次切换后立刻Draw你想要的对象。
还有一个隐藏要点:overrideMaterial与overrideMaterialPassIndex要配合好。overrideMaterial会在执行时强制替换材质,配合DrawProcedural指定passIndex使用。比如Shader里有pass0和pass1两个子Shader,C#端:
overrideMaterial = m_Material; overrideMaterialPassIndex = 0;然后reset passIndex到1,再Draw一次,就形成多pass执行。
4. 实操:两pass复合后处理从零到跑通
4.1 Volume参数与Shader
我用一个实际项目里写过的效果做展示:第一个pass对画面做横向模糊,第二个pass把模糊结果以透明度叠加到原画面上,顺便给画面四周加一点描边感。这个效果拆两个pass正好演示多pass和RenderStateBlock两种状态的切换。
先定义VolumeComponent:
using System; using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; [Serializable, VolumeComponentMenu("Custom/BlurMix")] public sealed class BlurMixVolume : VolumeComponent { public FloatParameter blurSize = new FloatParameter(4f); public FloatParameter mixOpacity = new FloatParameter(0.5f); public ColorParameter edgeColor = new ColorParameter(Color.black); }然后是Shader,注意两个pass。
URP后处理Shader要包含HLSLPROGRAM,用Core RP的库,关键是顶点函数里用vertexId生成全屏三角形坐标:
Shader "Custom/BlurMix" { SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" } Cull Off ZWrite Off Pass { Name "HorizontalBlur" HLSLPROGRAM #pragma vertex FullscreenVert #pragma fragment HorizontalBlurFrag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" TEXTURE2D(_SourceTex); SAMPLER(sampler_SourceTex); float4 _SourceTex_TexelSize; float _BlurSize; struct Attributes { uint vertexId : SV_VertexID; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; }; Varyings FullscreenVert(Attributes input) { Varyings output; output.positionCS = GetFullScreenTriangleVertexPosition(input.vertexId); output.uv = GetFullScreenTriangleTexCoord(input.vertexId); return output; } half4 HorizontalBlurFrag(Varyings input) : SV_Target { half3 sum = 0; float weightTotal = 0; for (int i = -3; i <= 3; i++) { float2 uv = input.uv + float2(_SourceTex_TexelSize.x * i * _BlurSize, 0); half3 col = SAMPLE_TEXTURE2D(_SourceTex, sampler_SourceTex, uv).rgb; float w = 1.0 - abs(i) * 0.2; sum += col * w; weightTotal += w; } return half4(sum / weightTotal, 1); } ENDHLSL } Pass { Name "MixAndEdge" HLSLPROGRAM #pragma vertex FullscreenVert #pragma fragment MixAndEdgeFrag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" TEXTURE2D(_SourceTex); TEXTURE2D(_BlurTex); SAMPLER(sampler_SourceTex); SAMPLER(sampler_BlurTex); float4 _SourceTex_TexelSize; float _MixOpacity; half4 _EdgeColor; struct Attributes { uint vertexId : SV_VertexID; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; }; Varyings FullscreenVert(Attributes input) { Varyings output; output.positionCS = GetFullScreenTriangleVertexPosition(input.vertexId); output.uv = GetFullScreenTriangleTexCoord(input.vertexId); return output; } half4 MixAndEdgeFrag(Varyings input) : SV_Target { half3 original = SAMPLE_TEXTURE2D(_SourceTex, sampler_SourceTex, input.uv).rgb; half3 blurred = SAMPLE_TEXTURE2D(_BlurTex, sampler_BlurTex, input.uv).rgb; half edge = 1.0; half2 offset = _SourceTex_TexelSize.xy * 1.5; edge *= step(0.5, distance(input.uv, saturate(input.uv + offset)) + 0.0001); half3 finalColor = lerp(original, blurred, _MixOpacity); finalColor = lerp(finalColor, _EdgeColor.rgb, edge * 0.3); return half4(finalColor, 1); } ENDHLSL } } }这段Shader有两个关键点。一是顶点函数用SV_VertexID,不需要传VertexBuffer,DrawProcedural画3个顶点就覆盖整个屏幕。二是_SourceTex的命名不是随便定的,它是后处理里的“潜规则”——URP后处理环境会把可用的输入RT通过这个名字传进来,我们在C#端用SetGlobalTexture控制时也优先沿用这个命名,方便别人阅读。
4.2 Feature与Pass完整代码
Feature负责创建Pass并注入:
public class BlurMixFeature : ScriptableRendererFeature { private BlurMixPass m_Pass; public override void Create() { m_Pass = new BlurMixPass(); } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { var volume = VolumeManager.instance.stack.GetComponent<BlurMixVolume>(); if (volume == null) return; m_Pass.Setup(volume); renderer.EnqueuePass(m_Pass); } }Pass是本篇最核心的部分。先看完整代码,再逐段讲:
public class BlurMixPass : ScriptableRenderPass { private BlurMixVolume m_Volume; private RTHandle m_BlurRT; private Material m_Material; private RenderStateBlock m_Pass0Block; private RenderStateBlock m_Pass1Block; public BlurMixPass() { renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing; ConfigureInput(ScriptableRenderPassInput.Color); Shader shader = Shader.Find("Custom/BlurMix"); if (shader != null) m_Material = new Material(shader); m_Pass0Block = new RenderStateBlock(RenderStateMask.Everything) { blendState = new BlendState { enabled = false }, depthState = new DepthState { enableWrite = false, compareFunction = CompareFunction.Always }, rasterState = new RasterState { cullMode = CullMode.Off }, stencilState = new StencilState { enabled = false } }; m_Pass1Block = new RenderStateBlock(RenderStateMask.Everything) { blendState = new BlendState { enabled = true, srcBlend = BlendMode.SrcAlpha, dstBlend = BlendMode.OneMinusSrcAlpha, srcBlendAlpha = BlendMode.One, dstBlendAlpha = BlendMode.OneMinusSrcAlpha, blendOp = BlendOp.Add, blendOpAlpha = BlendOp.Add }, depthState = new DepthState { enableWrite = false, compareFunction = CompareFunction.Always }, rasterState = new RasterState { cullMode = CullMode.Off }, stencilState = new StencilState { enabled = false } }; } public void Setup(BlurMixVolume volume) { m_Volume = volume; } public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData) { RenderTextureDescriptor desc = renderingData.cameraData.cameraTargetDescriptor; desc.depthBufferBits = 0; desc.colorFormat = RenderTextureFormat.DefaultHDR; m_BlurRT = RTHandles.Alloc(desc, name: "BlurRT"); } public override void OnCameraCleanup(CommandBuffer cmd) { if (m_BlurRT != null) { RTHandles.Release(m_BlurRT); m_BlurRT = null; } } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { CommandBuffer cmd = CommandBufferPool.Get(); cmd.Clear(); if (m_Volume == null || m_Material == null) return; RTHandle cameraTarget = renderingData.cameraData.renderer.cameraColorTargetHandle; overrideMaterial = m_Material; // pass0: 横向模糊输出到BlurRT overrideMaterialPassIndex = 0; cmd.SetGlobalTexture("_SourceTex", cameraTarget); cmd.SetGlobalFloat("_BlurSize", m_Volume.blurSize.value); cmd.SetRenderTarget(m_BlurRT); cmd.ClearRenderTarget(false, true, Color.clear); overrideRenderState = m_Pass0Block; cmd.DrawProcedural(Matrix4x4.identity, m_Material, 0, MeshTopology.Triangles, 3); // pass1: 叠加混合输出到相机目标 overrideMaterialPassIndex = 1; cmd.SetGlobalTexture("_SourceTex", cameraTarget); cmd.SetGlobalTexture("_BlurTex", m_BlurRT); cmd.SetGlobalFloat("_MixOpacity", m_Volume.mixOpacity.value); cmd.SetGlobalColor("_EdgeColor", m_Volume.edgeColor.value); cmd.SetRenderTarget(cameraTarget); overrideRenderState = m_Pass1Block; cmd.DrawProcedural(Matrix4x4.identity, m_Material, 1, MeshTopology.Triangles, 3); context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } }这段代码里最值得注意的两个细节:
第一,m_Pass0Block和m_Pass1Block只在构造函数里初始化了一次,Execute里只是把引用赋给overrideRenderState。这意味着状态块对象本身不会每帧分配,没有GC压力。如果你在Execute里new RenderStateBlock,每帧都会产生堆内存垃圾,频率高了对Unity的GC是个负担。
第二,RenderStateBlock覆盖状态是有粒度的。我把Everything都mask上了,实际用到的只是Blend和Depth,Raster、Stencil我也一并初始化了,避免有些字段被Shader里的旧状态干扰。如果你只设mask为Blend|Depth,那么Cull和模板状态就会继续用Shader里Pass的既有设置,这有时候反而是好事——灵活度更高。但新手阶段,建议全mask,状态完全可控。
4.3 挂接与调参
在URP Renderer Asset上添加BlurMixFeature。然后在场景里创建一个空物体挂Volume,或者创建Global Volume,添加Blur Mix组件。调整blurSize看模糊强度,调整mixOpacity看混合占比。
如果你发现效果没出来,先检查两个地方:
- Feature的AddRenderPasses里volume是否为空。Global Volume如果没有加对应的VolumeComponent,VolumeManager.instance.stack里拿不到组件,Pass根本不会注入。
- Shader名字是否和Shader.Find里一致。不一致时m_Material为null,Execute里直接return,屏幕毫无变化。
调参时注意,blurSize这个参数我直接乘到了uv偏移上,没做像素等比换算。不同分辨率下同样的blurSize视觉强度不一致。正式项目里建议把参数跟_ScreenParams挂钩,或使用_TexelSize做归一化。
5. 常见问题与排查实录
5.1 后处理在Game视图有效,Scene视图没反应
这是URP后处理最常见的疑惑。原因很简单:Scene视图的相机默认不跑后处理管线的全部流程,除非你在Scene视图相机的设置里打开Post Processing相关开关。很多效果只在Game视图和打包后正确显示。排查时无需焦虑,正常现象。
但要注意一个例外:如果你的后处理Pass在renderPassEvent里注入得太早或太晚,可能导致Game视图也不生效。一个经验法则是:尽量使用BeforeRenderingPostProcessing或AfterRenderingPostProcessing,不要随便插到Opaque或者Transparent之前——那些位置你拿到的cameraTarget可能还没准备好。
5.2 RenderStateBlock配置了没反应
我至少见过三种情况导致这个现象,而且它们都很隐蔽。
第一,mask没有包含你要覆盖的状态类别。你只new了RenderStateBlock(),然后设置blendState.enabled = true,但mask是默认的Nothing,等于没设置。解决办法是在构造函数里传入RenderStateMask.Everything,或者手动把mask |= RenderStateMask.Blend。
第二,你设置了overrideRenderState,但Shader里某些状态写死了。例如Shader的Pass里写了Blend SrcAlpha One,而你的RenderStateBlock设置的Blend状态虽然被传到管线里,但URP在某些绘制路径下优先读取Shader的混合状态。这时需要在材质本身上把混合相关属性保持清白,或者你把Shader里的混合状态全部去掉,完全依赖RenderStateBlock控制。我这里就是让Shader里的混合指令留白,所有混合交到RenderStateBlock。
第三,你在Execute里设置了overrideRenderState,但后面你用的cmd.DrawProcedural重载里传了material,URP内部执行时可能会重新生成一份RendererList,这个过程中如果RenderStateBlock被当作渲染状态的一部分捕捉,在某些URP版本里只在RendererList模式下才完整覆盖。URP 14.x实测,ScriptableRenderPass里的overrideRenderState对cmd.DrawProcedural是生效的,但需要确保你在设置overrideRenderState之后再调用DrawProcedural,顺序反了就会无效。
5.3 模板缓冲与混合状态组合的坑
我的经验是:后处理里用模板测试,比在Geometry阶段用模板更折腾。因为后处理的输入RT通常没有模板缓冲。
如果你想做“只处理屏幕中心区域”这类效果,需要先在OnCameraSetup里给RT分配模板位:
desc.depthBufferBits = 0; desc.stencilFormat = GraphicsFormat.D24_UNorm_S8_UInt;然后在pass0里用RenderStateBlock配置模板写入:
stencilState = new StencilState { enabled = true, readMask = 255, writeMask = 255, compareFunction = CompareFunction.Always, passOperation = StencilOp.Replace, failOperation = StencilOp.Keep, zFailOperation = StencilOp.Keep }, stencilReference = 1pass1里再改成CompareFunction.Equal。这个过程如果发现模板没生效,大概率是你没有对目标RT做Clear,模板旧值残留导致比较结果和你预期不一致。cmd.ClearRenderTarget(false, true, Color.clear)只能清颜色,清模板需要额外指定:
cmd.ClearRenderTarget(RTClearFlags.All, Color.clear, 1f, 0);5.4 RT泄漏与花屏
RTHandle用不好会有两类症状:抽帧掉帧、屏幕闪烁花屏。
最常见的错误是每帧在Execute里Alloc RTHandle,但忘记Release;或者OnCameraCleanup里Release了,OnCameraSetup里又Alloc新的。频繁创建RT会触发底层分配,PC上还好,移动端直接掉帧。
我的推荐写法是:
- OnCameraSetup里先判空:如果m_BlurRT != null,检查尺寸是否和descriptor一致,不一致才Release再Alloc。
- OnCameraCleanup里必定Release并把引用置null。
- Execute里不要再做Alloc/Release操作,只管用。
花屏还有一个常见原因是SetRenderTarget和SetGlobalTexture的顺序问题。多pass时,如果你先SetGlobalTexture("_SourceTex", cameraTarget),又把cameraTarget当渲染目标,然后中途改SetGlobalTexture("_SourceTex", m_BlurRT),这中间如果存在RT别名或者共享,某些平台上会读出脏数据。稳妥做法是pass0完成、渲染目标切走之后,再设置下一轮GlobalTexture。
5.5 调试工具怎么用
URP后处理出问题时,强烈建议打开Window > Analysis > Rendering Debugger。在Rendering Debugger里你能看到每个Pass的执行列表,双击对应的DrawProcedural可以查看它绑定的RT、使用的Material、RenderStateBlock状态。这是排查“状态到底有没有生效”的一线工具。
如果要做像素级检查,RenderDoc是更利器的选择。RenderDoc抓帧后,一帧里每个Draw的Render State、Shader资源、RT内容一目了然。URP 14.x是支持RenderDoc回放Compute和Draw的。特别是当你怀疑模板问题时,RenderDoc的Stencil Test窗口能直接告诉你每个像素的模板值是几。
经验之谈:先看Frame Debugger确认Pass进去了,再看RenderDoc确认状态块数值实际应用了,最后才考虑是不是Shader算法问题。我见过太多人Shader算法还没调对,先怀疑渲染状态,绕了很久回头才发现是权重算错了。
6. 一些值得记住的实操习惯
整套方案跑通之后,我养成了几个固定习惯,在这里分享出来。
RenderStateBlock尽量在Pass构造时预创建,不要在Execute里new。看似只是一行代码差异,但真到目标平台上做性能分析时,堆分配和GC的影响非常明显。后处理Pass每帧都要执行,任何微小开销都会被帧时机放大。
多pass时给每个中间RT起一个清晰的名字。BlurRT、EdgeRT、CompositeRT,调试时Frame Debugger里一眼就能分辨。我在项目里见过有人起名rt1、rt2、rt3,出了问题根本分不清谁是谁。
RenderStateBlock的mask建议显式声明,哪怕你只需要覆盖Blend,也可以把Raster、Depth一起mask进去,这样管线行为有确定性。你要是真的需要某个状态走Shader里的默认,那才考虑把对应的mask位单独去掉。
关于多pass的扩展,我想多说一句。你完全可以把这套“Feature+Pass+RenderStateBlock”的架子扩展成一套效果链框架:每个效果一个Feature,Feature里若干Pass,Pass之间通过RTHandle流转数据。URP 14.x的RenderGraph模式下有些API还在过渡,但传统ScriptableRenderPass在这条版本线上仍然是稳定的主流选择。我这个示例里的所有代码用的都是URP 14.x默认管线,没有启用Render Graph,如果你项目里开了Render Graph,部分API的调用方式和执行时机需要微调,核心的RenderStateBlock思路不变。
我在实际项目里最终是把这套逻辑做成了公司内部的后处理效果库,不同效果之间靠Volume参数切换,RenderStateBlock定义在对应的Pass里,每个Pass维护自己的状态块。后续想加新效果,只需要写Shader和Volume参数,Feature和Pass的骨架完全复用。希望这篇东西能帮你少踩几个我之前交过学费的坑。