资讯详情 URP 14.x自定义多Pass后处理与RenderStateBlock状态配置全解析
📅 2026/10/6 8:21:43
在URP 14.x 里自己写后处理很多人第一步就卡在状态配置上。你以为是写一个 Blit 就完事结果画面黑屏、物体被吞掉、UI也跟着一起透明多半不是算法写错而是各个 pass 的 RenderStateBlock 整体状态没有想清楚。这篇文章就用一个真实的多 pass 自定义后处理案例把从 ScriptableRendererFeature 到 Shader Pass再到 C# 侧 RenderStateBlock 的每一步都拆开讲。适合 Unity 2022 LTS URP 14 的开发者无论你是第一次写后处理还是想给项目加描边、风格化效果都可以直接参考。1. 自定义后处理为什么绕不开多 pass 与状态配置1.1 URP 14.x 留给我们的后处理扩展点URP 不是内置 PostProcessing Pro它给你留了两个关键扩展入口ScriptableRendererFeature 和 ScriptableRenderPass。前者负责向某个渲染事件注入“当前帧我要跑哪个 Pass”后者负责真正执行绘制命令。你可以在 BeforeRenderingPostProcessing、AfterRenderingPostProcessing 这类 RenderPassEvent 前后插入自己的全屏三角形绘制。这里有个很容易误解的点URP 的“后处理”并不像 Built-in 管线那样一个 OnRenderImage 就完事。你要自己管理临时 RenderTarget、自己处理恢复渲染状态、自己决定在哪个时机拷贝相机图像。RTHandle 是 URP 推荐的 RT 管理方式它不是随便 new 一个 RenderTexture而是走管线内部的纹理池能减少每帧分配和碎片。复杂效果一个 pass 根本不够。比如最基础的 Bloom 风格化版本至少需要“提取高光 → 模糊 → 合成”三个阶段。如果你把这些阶段全部塞进一个 fragment 函数结果往往是控制不了中间结果、调参靠猜、性能还差。多 pass 的意义不是炫技而是把每一阶段的计算隔离到独立的临时 RT 里让每一层效果都能单独排查、单独复用。1.2 多 pass 后处理的典型场景我把日常项目里真正需要多 pass 后处理的场景归了几类Bloom 家族高光提取、降采样、横向/纵向模糊、上屏合成4 个 pass 很常见。风格化描边深度边缘检测 屏幕模糊 颜色量化至少要两三个 pass。色彩分级后的微特效胶片颗粒、扫光、噪点叠加通常需要单独一个 pass 在 UI 之前作用。NPR/二次元渲染中全屏阈值量化、边缘抖动、局部区域 mask 处理也离不开多 pass。多 pass 的顺序可以由 Feature 里 Enqueue 多个 RenderPass 控制也可以在同一个 RenderPass 的 Execute 里连续多次调用 Blitter。更灵活的是每个 pass 都可以配合 RenderStateBlock 控制自己的混合、深度、模板、剔除状态。这种统一管理方式比把状态散落在多个 Shader 里要可靠得多。1.3 状态配置为什么是后处理最容易翻车的地方后处理绘制的是全屏三角形它不需要普通物体的深度语义。绝大多数后处理 pass 都要求 Cull Off、ZWrite Off、ZTest Always。这三个是基本功但不是唯一状态。不同 pass 对混合模式的需求完全不同。比如高光提取 pass 必须关闭混合否则上一帧的颜色会残留在临时 RT 里后续模糊全是脏数据合成 pass 用 One One 加法混合才能把光亮叠加回场景得到一个“发光感”而不是“贴图感”。深度和模板也不是永远不需要某些局部后处理只在指定区域内生效这时模板测试就是最好的约束。在 Shader 里把状态写死只是其中一种做法。如果你需要在 C# 侧根据参数动态覆盖某个状态的开关比如在描边 pass 里切换 Cull Front / Cull Back就要用 RenderStateBlock。它才是真正意义上“渲染状态块”的整体配置。2. RenderStateBlock 整体配置字段、掩码与和 Shader 声明的关系2.1 RenderStateBlock 到底是什么UnityEngine.Rendering.RenderStateBlock 是 SRP Core 里的一个结构体专门用来打包“渲染状态覆盖指令”。它内部包含四个子状态块BlendState、RasterState、DepthState、StencilState外加一个 RenderStateMask 掩码用来告诉渲染管线“你这次只覆盖哪几个状态”。它的经典使用场景是配合 ScriptableRenderContext.DrawRenderers 绘制场景物体时临时覆盖物体 Shader 里声明的状态。比如某个角色 Shader 原本写死了 Cull Back但在描边辅助 pass 里你希望它是 Cull Front这时候不需要复制一份 Shader 变体只要在 DrawRenderers 时传一个 RenderStateBlock 进去把剔除模式改成 Front 就行。需要提醒的是RenderStateBlock 并不直接作用于 Blitter.BlitCameraTexture 这类全屏 blit 操作。如果你只是做全屏后处理状态仍然建议写在 Shader Pass 里。RenderStateBlock 的定位更偏向“后处理流程中需要重新绘制场景或额外网格时从 C# 侧动态覆盖状态”的场景级统一管理。2.2 四个子状态块的字段逐项解析我在实际项目里最常配置的就是下面这些字段。子状态块对应 Shader 关键字核心字段后处理常见设置BlendStateBlend Off / One One / SrcAlpha OneMinusSrcAlphaenabled、sourceColorBlendMode、destinationColorBlendMode、sourceAlphaBlendMode、destinationAlphaBlendMode提取/模糊 pass 用 Off合成 pass 用 One OneRasterStateCull Back/Front/Off、OffsetcullMode、offset、offsetSlop全屏三角形 Cull Off描边辅助 pass 用 Cull FrontDepthStateZWrite On/Off、ZTest LEqual/AlwayswriteEnabled、compareFunction全屏后处理优先 ZWrite Off ZTest AlwaysStencilStateStencil Ref 0 / Comp Always / Pass ReplacecompareFunction、reference、passOperation、failOperation、zFailOperation、readMask、writeMask局部后处理时限制像素区域BlendState 里面最容易被忽略的是 Alpha 通道的混合。只改颜色混合不改 Alpha经常会出现半透明边缘发灰的问题。RasterState 里的 offset 参数在对轮廓做深度偏移时很有用可以避免描边和被描边物体产生 z-fighting。DepthState 的控制逻辑比较直接但要注意 compareFunction 的默认值如果后处理 pass 里不显式改成 Always某些低端设备会沿用相机深度缓冲里的脏值。StencilState 是最容易写错的一块。它的 reference、readMask、writeMask 都是 byte 类型很多人在 C# 侧设置时把 int 直接塞进去编译没问题但运行时数据被截断。另外一个坑是模板缓冲区在 URP 里默认可能没有启用你需要在相机的 Depth Texture 模式开启时配合相机 clear 或者 RenderPass ConfigureInput 明确请求 Stencil。2.3 RenderStateMask 是隐形开关RenderStateBlock 最容易被忽略的就是那个 mask 掩码。我见过太多人这样写RenderStateBlock block new RenderStateBlock(); block.rasterState.cullMode CullMode.Front; block.depthState.writeEnabled false;结果什么都没变。因为 RenderStateBlock 的 mask 默认是 Nothing相当于“这次绘制我不覆盖任何状态”。你不显式告诉它要覆盖哪些状态它就把你写好的字段全部当空气。正确写法是先把要覆盖的位打开RenderStateBlock block new RenderStateBlock(); block.mask RenderStateMask.Raster | RenderStateMask.Depth; block.rasterState.cullMode CullMode.Front; block.depthState.writeEnabled false;这句话的意思是“我只通知 GPU 改这两块其余保持原状”。如果只设了 Raster那么 Depth 不管你怎么填都不生效。排查 RenderStateBlock 问题第一步永远先看 mask。2.4 和 Shader Pass 内声明的分工那什么时候写在 Shader 里什么时候用 RenderStateBlock我的经验是看“这个状态是固定不变还是可能要动态调整”。维度Shader Pass 内声明C# RenderStateBlock 覆盖适用场景全屏 blit、工具 Shader、固定效果同一套材质要动态切换渲染状态可维护性写在 ShaderLab 里美术能看懂由脚本/Inspector 控制灵活覆盖粒度整个 Pass 编译期固定在 DrawRenderers 时对指定物体覆盖性能开销和普通绘制一致有 API 调用但恢复状态由管线完成后处理全屏阶段我强烈建议把 Cull Off、ZWrite Off、ZTest Always 写死在 Pass 里不要依赖外界。因为全屏三角形每次 blit 都是高性能路径任何多余的状态覆盖都可能造成额外的验证开销。涉及场景网格的辅助 pass比如描边复制、深度膨胀、复用深度缓冲等再用 RenderStateBlock 覆盖状态这样不用为了一个描边效果写几十个变体 Shader。3. 实操一个多 pass 后处理与 RenderStateBlock 覆盖的完整案例3.1 先搭好 Renderer Feature 骨架在 Unity 2022.3 LTS 里建好 URP 工程添加一个 Renderer Feature 脚本。using UnityEngine.Rendering.Universal; using UnityEngine.Rendering; using UnityEngine; public class MultiPassPostFeature : ScriptableRendererFeature { [System.Serializable] public class Settings { public Material material; public RenderPassEvent renderEvent RenderPassEvent.BeforeRenderingPostProcessing; public bool drawOutlinePass; } public Settings settings new Settings(); private MultiPassPostPass pass; public override void Create() { pass new MultiPassPostPass(settings.renderEvent, settings.material) { drawOutlinePass settings.drawOutlinePass }; } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { if (settings.material null) return; renderer.EnqueuePass(pass); } }Create 在 Asset 加载时调用AddRenderPasses 每帧对每个相机都会调用。material 为空直接 return避免空引用把整个渲染循环带崩。这里我没有在 AddRenderPasses 里 new Pass而是在 Create 里预先创建减少每帧的 GC 压力。3.2 后处理 Shader 的多 pass 写法下面这个 Shader 是一个简化版 Bloom 风格效果一共三个 passPass 0 阈值提取把超过亮度阈值的像素保留下来其他像素置黑。Pass 1 模糊对临时 RT 做四方向采样平均模拟模糊。Pass 2 合成把模糊结果用加法混合叠加回相机颜色 RT产生发光感。这里最关键的细节是顶点函数。URP 的 Blitter 绘制全屏三角形时走的是 DrawProcedural没有传统顶点缓冲所以顶点数据里只有一个 SV_VertexID。很多人从旧教程里抄过来用 POSITION 语义接收顶点坐标结果后处理 Shader 白屏。正确做法是用 URP 内置的 GetFullScreenTriangleVertexPosition 和 GetFullScreenTriangleTexCoord。另一个容易被旧教程误导的点是采样纹理。URP 14.x 的 Blitter 设置的是 _BlitTexture不是 _MainTex。如果你的 Shader 只声明 _MainTexBlit 时纹理是黑屏或者上一次残留。Shader Custom/MultiPassPost { SubShader { Tags { RenderPipelineUniversalPipeline } HLSLINCLUDE #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl struct Attributes { uint vertexID : SV_VertexID; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; }; Varyings Vert(Attributes input) { Varyings output; output.positionCS GetFullScreenTriangleVertexPosition(input.vertexID); output.uv GetFullScreenTriangleTexCoord(input.vertexID); return output; } ENDHLSL Pass { Name Threshold Cull Off ZWrite Off ZTest Always Blend Off HLSLPROGRAM #pragma vertex Vert #pragma fragment ThresholdFrag TEXTURE2D(_BlitTexture); SAMPLER(sampler_BlitTexture); float _Threshold; half4 ThresholdFrag(Varyings input) : SV_Target { half4 col SAMPLE_TEXTURE2D(_BlitTexture, sampler_BlitTexture, input.uv); half lum Luminance(col.rgb); return lum _Threshold ? half4(col.rgb, 1) : half4(0, 0, 0, 1); } ENDHLSL } Pass { Name Blur Cull Off ZWrite Off ZTest Always Blend Off HLSLPROGRAM #pragma vertex Vert #pragma fragment BlurFrag TEXTURE2D(_BlitTexture); SAMPLER(sampler_BlitTexture); float2 _BlurOffset; half4 BlurFrag(Varyings input) : SV_Target { float2 uv input.uv; half4 sum 0; sum SAMPLE_TEXTURE2D(_BlitTexture, sampler_BlitTexture, uv _BlurOffset); sum SAMPLE_TEXTURE2D(_BlitTexture, sampler_BlitTexture, uv - _BlurOffset); sum SAMPLE_TEXTURE2D(_BlitTexture, sampler_BlitTexture, uv float2(_BlurOffset.x, -_BlurOffset.y)); sum SAMPLE_TEXTURE2D(_BlitTexture, sampler_BlitTexture, uv - float2(_BlurOffset.x, -_BlurOffset.y)); return sum * 0.25; } ENDHLSL } Pass { Name Composite Cull Off ZWrite Off ZTest Always Blend One One HLSLPROGRAM #pragma vertex Vert #pragma fragment CompositeFrag TEXTURE2D(_BlitTexture); SAMPLER(sampler_BlitTexture); half4 CompositeFrag(Varyings input) : SV_Target { return SAMPLE_TEXTURE2D(_BlitTexture, sampler_BlitTexture, input.uv); } ENDHLSL } } }我特意把每个 pass 的状态都写得很直白哪怕重复也无所谓。多 pass 后处理 Shader 最重要的是可读性不要为了省几行把状态藏在 include 里。你藏得越深后面排查越痛苦。3.3 自定义 RenderPass 与 RenderStateBlock 的配合接下来是执行层。先用 Blitter 跑一遍全屏多 passusing UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public class MultiPassPostPass : ScriptableRenderPass { private Material material; private bool drawOutlinePass; private RTHandle tempRT1; private RTHandle tempRT2; public MultiPassPostPass(RenderPassEvent evt, Material mat) { renderPassEvent evt; material mat; } public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData) { var desc renderingData.cameraData.cameraTargetDescriptor; desc.depthBufferBits 0; tempRT1 RTHandles.Alloc(desc, colorFormat: RenderTextureFormat.DefaultHDR); tempRT2 RTHandles.Alloc(desc, colorFormat: RenderTextureFormat.DefaultHDR); } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { var cmd CommandBufferPool.Get(); var cameraColor renderingData.cameraData.renderer.cameraColorTargetHandle; material.SetFloat(_Threshold, 0.6f); material.SetVector(_BlurOffset, new Vector4(0.002f, 0.001f, 0f, 0f)); Blitter.BlitCameraTexture(cmd, cameraColor, tempRT1, material, 0); Blitter.BlitCameraTexture(cmd, tempRT1, tempRT2, material, 1); Blitter.BlitCameraTexture(cmd, tempRT2, cameraColor, material, 2); context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } public override void OnCameraCleanup(CommandBuffer cmd) { RTHandles.Release(tempRT1); RTHandles.Release(tempRT2); } }这段代码里合成 pass 直接 Blit 到 cameraColorTargetHandle。如果相机 RT 上已经有场景颜色加法混合会把模糊结果叠加到原图上。URP 的 Blitter 在绘制时不会自动清目标 RT所以背景不会被覆盖掉。如果你发现画面只剩下亮光、原场景没了大概率是目标 RT 被 clear 了或者你把合成 pass 的 Blend 写成了 Off。现在加入 RenderStateBlock 的实际用例。假设你在同一个后处理流程里要做一个轮廓辅助 pass不通过边缘检测而是重新绘制一遍场景里所有不透明物体并把 Cull 强制改成 Front、深度写入打开、模板写入设为 Replace。这是 NPR 描边里很常见的“背面膨胀”思路。public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { var cmd CommandBufferPool.Get(); // 先处理全屏多 pass 后处理 // ... if (drawOutlinePass) { var shaderTagId new ShaderTagId(UniversalForward); var drawSettings new DrawingSettings(shaderTagId, SortingCriteria.CommonOpaque); var filterSettings new FilteringSettings(RenderQueueRange.opaque); RenderStateBlock block new RenderStateBlock(); block.mask RenderStateMask.Raster | RenderStateMask.Depth | RenderStateMask.Stencil; block.rasterState.cullMode CullMode.Front; block.depthState.writeEnabled true; block.depthState.compareFunction CompareFunction.LessEqual; // 具体模板字段名以当前 Unity 版本 API 为准 block.stencilState.reference 1; block.stencilState.compareFunction CompareFunction.Always; block.stencilState.passOperation StencilOp.Replace; context.DrawRenderers( renderingData.cullResults, ref drawSettings, ref filterSettings, ref block); } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); }注意几个关键点。第一DrawRenderers 操作的是 CullingResults不是全屏三角形所以它不属于常规 blit而是把一个额外的场景绘制插入到后处理阶段。第二RenderStateBlock 是 struct传入时必须用 ref否则 Unity 内部拿到的是一次状态覆盖。第三这个覆盖只对当前这次 DrawRenderers 生效绘制完管线会自动切回默认状态。这个辅助 pass 和全屏 blit 在同一个 Execute 里执行就是所谓的“多 pass 渲染”。状态不是散落的全屏 blit 的状态写在 Shader Pass 里场景重绘的状态写在 C# RenderStateBlock 里两者合起来就是一套完整的整体配置。3.4 RT 与 Pass 索引的参数选择后处理临时 RT 的格式选择直接影响画面和带宽。我的建议是在开发阶段统一用 RenderTextureFormat.DefaultHDR保证颜色范围不会被截断。到真机优化阶段再按效果决定换成 RGB111110Float 还是 RHalf。Pass 索引是从 0 开始数的Blitter.BlitCameraTexture 的第 5 个参数就是 Shader Pass 索引。很多人会在 Shader 里给 Pass 起 Name然后以为 Name 就是索引。不是。Name 只是标识索引就是文件里从上往下数的顺序。如果你中间插入了一个 Pass后面的索引全部要跟着改。材质实例不要直接 new Material建议用 CoreUtils.CreateEngineMaterial。资源释放和出厂材质的生命周期管理都更可控尤其是在热重载和多次创建 Renderer Feature 时不会产生一堆孤儿材质。4. 常见问题与调试验证速查4.1 后处理 Pass 没执行、画面黑屏第一步查 Feature 有没有加到 Renderer asset 的 Renderer Features 列表里。这步漏掉后面一切白搭。第二步查 Pass 有没有 Enqueue 进去。如果 AddRenderPasses 里 material 为 null或者 Create 没有被调用Pass 都不会进队列。注意 URP 的 Renderer Feature 在热重载时 Create 不一定会被重新调用修改脚本后如果发现 Pass 丢失优先检查 Inspector 里的状态。第三步查 RenderPassEvent 插入位置。如果放在 BeforeRenderingPostProcessing但相机没有实际跑后处理也就是相机设置里 Post Processing 没勾选URP 可能不会经过这个事件点。虽然自定义 Feature 通常还是会调用但某些版本有差异最简单的排查方式是换成 AfterRenderingOpaques 试试有没有画面。第四步检查 Shader 采样纹理。URP 14.x 的 Blitter 传入的是 _BlitTexture不是 _MainTex。顺手把 Shader 里的采样名改对八成白屏问题直接消失。4.2 RenderStateBlock 没生效这是最典型的一类问题。我对号的排查步骤是确认你走的是 DrawRenderers不是 Blitter.BlitCameraTexture。RenderStateBlock 对 blit 根本无效。确认 mask 设置了对应位。只设置 mask RenderStateMask.Raster却想改 Stencil那是做梦。确认 DrawRenderers 的 renderStateBlock 参数是 ref 传入。传值副本不会修改状态。确认不是被后续绘制覆盖。你在 Execute 里先画了描边辅助 pass后面又画了一遍正常物体正常物体的状态会盖掉之前的状态。还有一个经验RenderStateBlock 实例最好在 Pass 构造时初始化好Execute 里只改 reference 之类的参数。这样既避免每帧产生 GC又能防止多线程渲染时出现随机状态。4.3 临时 RT 生命周期问题OnCameraSetup 里 AllocOnCameraCleanup 里 Release这个节奏是 URP 官方推荐的做法。不要在 Execute 里 Release因为 RTHandle 走的是渲染纹理池下一帧可能还在被引用提前释放会拿到已被回收的纹理。如果你在 Execute 里用 cmd.SetGlobalTexture 暴露了临时 RT记得在指令末尾清掉全局引用。虽然大多数情况下 URP 会在下一帧覆盖但某些后处理栈里会出现“脏引用”导致画面出现上一帧残留。调试时用 Frame Debugger 看全局纹理列表一眼就能发现。4.4 和相机深度、UI 的关系自定义后处理如果遇到深度不对优先检查相机是否开启了 Depth Texture。很多后处理效果需要读取场景深度但 URP 相机默认不生成深度纹理。这不是 Shader 问题是设置问题。UI 和自定义后处理的先后关系由 RenderPassEvent 决定。如果 UI 是在 Overlay 相机中渲染的主体相机的后处理事件通常发生在 Overlay 之前UI 不一定会被后处理影响。想让 UI 也吃后处理需要在整体相机栈的渲染顺序上做调整而不是只改 Feature。4.5 移动端避坑移动端 GPU 全屏绘制很贵多 pass 后处理意味着多轮全屏 RT 读写。Shader 里尽量少用动态分支临时 RT 分辨率尽量匹配相机分辨率的一半或更低模糊类效果可以先降采样再上采样能省大量带宽。另外移动端的 Blend 状态切换偶发不生效常见原因是设备对 RenderTarget 的 Load/Store 行为不一致。实测时如果发现合成 pass 结果不稳定试试在合成前先拷贝背景到一个临时 RT再在临时 RT 上做混合绕开目标 RT 的未知状态。5. RenderStateBlock 在后处理之外的延展NPR、The Book of Shaders 与 Mesh Shader 趋势5.1 NPR/二次元描边里的状态覆盖思路NPR/二次元渲染中很常见的描边做法是背面膨胀第一个 Pass 用 Cull Front 只画背面把顶点沿法线外扩形成一个轮廓第二个 Pass 正常绘制正面。传统做法是每个角色 Shader 里写两个 Pass参数一多变体数量迅速膨胀。如果这个描边发生在后处理辅助 pass 阶段你就可以用 RenderStateBlock 在同一个材质上动态切换 Cull、深度和模板。美术只需要暴露“轮廓宽度”“深度偏移”“模板参考值”这几个参数脚本侧负责在绘制时塞一个状态块。这样角色 Shader 保持干净渲染状态统一收口效果也更容易批量调整。5.2 The Book of Shaders 对后处理直觉的帮助经常有人问练 shader 只看 ShaderLab 够不够。我的看法是ShaderLab 是语法外壳真正的数学直觉来自对 uv 坐标、噪声、距离场、颜色空间的理解。The Book of Shaders 虽然基于 GLSL/WebGL但它对坐标、渐变、噪声、分形这些基础讲得非常形象对写后处理采样逻辑有直接帮助。状态配置这种东西不太需要背 API靠的是调试直觉。你从 The Book of Shaders 里得到的不是可以在 URP 里直接搬的代码而是“我大概知道这个效果该从哪个坐标入手”的判断力。等你在后处理里踩过几次状态坑再回头看那些 shader 练习感觉会完全不一样。5.3 Mesh Shader 时代光栅化状态不会消失有朋友问过 PS5 支持不支持 Mesh Shader答案是支持PS5 的 GPU 架构里确实有对应的几何处理流程。Mesh Shader 改变的是几何生成和剔除方式后处理最终仍然需要把三角形光栅化到目标 RT 上深度、模板、混合、剔除这些光栅状态依然是管线的一部分。URP 14.x 本质上还是传统光栅管线的封装短期内后处理状态管理逻辑不会失效。读懂 RenderStateBlock 和它的掩码机制相当于掌握了 SRP 里最底层的状态覆盖规则。就算未来管线走得再底层这些概念都能迁移。5.4 我习惯的配置顺序和排错节奏最后分享一个我自己的习惯。写多 pass 后处理之前先在纸上画一张状态表明确每个 pass 的输入 RT、输出 RT、混合模式、深度写入、剔除模式。全屏 pass 的状态写死在 Shader 里场景辅助 pass 的状态用 RenderStateBlock 在 C# 侧覆盖。然后调试时先关掉所有混合确定每个 pass 的结果都是对的再逐个打开混合和模板。每次只改一个 mask 位不要同时调 Blend、Depth、Stencil 三个变量不然出了问题你根本不知道是谁干的。这套思路帮我解决了很多项目里的后处理状态问题希望也能让你少走几天弯路。