说实话,把 Post Processing Stack V2 的源码翻出来做代码结构分析,这个动作放在今天多少有点“考古”的味道,但真去扒一遍的开发者并不少。尤其是那些还留在内置渲染管线上的老项目,以及想弄明白“Unity 的后处理框架到底是怎么把一堆效果串起来”的人,V2 的代码依然是一个绕不开的参考样板。
如果你只是用过 PostProcessing Volume、加过 Bloom 和 Color Grading,那看到的东西只是冰山一角。真正有意思的部分在框架内部:PostProcessLayer、Volume、Profile、EffectSettings、EffectRenderer 这几个类之间是怎么互相配合的?一帧画面里的后处理命令是在哪个环节被组装的?为什么新增一个自定义效果需要动四个不同的地方?这篇文章就围绕这些问题,把 V2 的代码结构按我的理解完整拆一遍。
1. V2 这套框架解决的现实问题:从 OnRenderImage 到 CommandBuffer
1.1 V1 时代后处理代码的痛点
在 V2 出现之前,Unity 内置渲染管线的后处理基本靠每个效果各写一个 MonoBehaviour,然后在OnRenderImage里拿Graphics.Blit来回拷贝纹理。单个效果这么写没什么问题,一旦堆五六个效果,你就得自己排顺序、自己管临时 RT、自己关心哪些效果在当前平台能不能跑。
那个阶段我踩过的典型坑有三个。第一,多个效果的执行顺序完全靠脚本执行顺序或组件挂载顺序控制,改一个效果的优先级可能要拖半天组件。第二,每个效果各自RenderTexture.GetTemporary,没人统一归还,移动端内存经常莫名其妙涨上去。第三,想做一个“靠近某个区域时画面慢慢变冷色调”的过渡效果,V1 几乎做不了,因为所有参数都是一次性设置到材质上的,没有混合权重、没有优先级、没有体积插值。
V2 就是冲着这些痛点去的。它把“效果参数”和“效果渲染”彻底拆开,用一套统一调度机制来驱动,同时引入了 Volume 体系来处理空间上的参数混合。所以分析它的代码结构,本质上就是在看一套成熟的后处理框架如何解决这些工程问题。
1.2 V2 的设计目标决定了代码骨架
V2 的设计目标可以从它的命名上看出来:PostProcessLayer 管调度,PostProcessVolume 管空间影响,PostProcessProfile 管参数集合,PostProcessEffectSettings 管单个效果的参数,PostProcessEffectRenderer 管单帧渲染。
这套命名不是随便起的,每个类都对应一个明确的职责边界。Layer 是唯一的入口,挂在相机上;Volume 是场景中可选的触发容器;Profile 是可复用的参数资产;Settings 是某个效果(比如 Bloom)的参数集合;Renderer 是真正往 CommandBuffer 里写渲染命令的执行器。
弄懂这个边界之后,再去看源码就不会迷路。比如你看到一个加了[PostProcess(typeof(...))]特性的类,它一定是 Settings;看到继承PostProcessEffectRenderer的类,它一定是 Renderer;看到 ScriptableObject 后缀的资源,它大概率是 Profile 或者 Settings 的实例。
| 类名 | 类型 | 运行时职责 | 编辑器职责 |
|---|---|---|---|
| PostProcessLayer | MonoBehaviour | 挂在相机上,组装并提交命令缓冲 | 提供后处理开关与调试入口 |
| PostProcessVolume | MonoBehaviour | 根据相机位置计算权重与混合 | 提供全局/局部体积的可视化调节 |
| PostProcessProfile | ScriptableObject | 保存一组 EffectSettings 实例 | 作为可复用的参数资产保存到磁盘 |
| PostProcessEffectSettings | ScriptableObject | 定义单个效果的全部参数与默认值 | 在 Inspector 中显示参数面板 |
| PostProcessEffectRenderer | 普通类 | 每帧向 CommandBuffer 提交渲染命令 | 不参与编辑器显示 |
1.3 为什么这套结构值得分析
我的看法是,V2 的价值不在于某个效果写得多惊艳,而在于它把一个“可插拔的后处理管线”用最小成本的代码组织出来了。你往框架里加一个新效果,不需要改 Layer,不需要改 Volume,不需要改调度逻辑,只需要按约定提供 Settings、Renderer 和 Shader,再完成资源注册。这个扩展性设计的思路,放到今天写自定义渲染管线、写编辑器工具、写技能系统都依然适用。
2. 核心骨架:Volume、Profile、Settings、Renderer 四个物件各自的职责
2.1 PostProcessVolume:场景里的“影响范围”
PostProcessVolume 本质上是一个挂在 GameObject 上的 MonoBehaviour,它可以配合 Collider 使用。isGlobal为 true 时影响全场景;为 false 时,只有相机进入 Collider 范围内才会受到效果影响。此外它还有priority、blendDistance、weight等属性,这些属性共同决定当前相机接收到多少强度的效果参数。
源码里PostProcessVolume做的事情并不复杂,每帧在Update里把自己的存在登记到全局的 VolumeManager,同时根据 trigger 位置算出 blend 权重。真正关键的是它持有的Profile引用。一个 Volume 可以引用同一个 Profile,也可以各自实例化一份,这取决于你勾选的是sharedProfile还是直接改 Profile 资源。
这里有个容易忽略的点:Profile 是 ScriptableObject,默认情况下多个 Volume 共享它是安全的,只要你不打算在运行期修改参数。如果需要在运行时动态改某个 Volume 的参数而不影响其它 Volume,就得先Instantiate(profile)再赋给它。源码虽然没强制你做这一步,但理解了这个机制,使用上会稳妥很多。
2.2 PostProcessProfile:一组参数的集合
PostProcessProfile 的内容很简单,本质就是一个List<PostProcessEffectSettings>。它把多个效果的参数打包成一个资产文件,方便你在不同场景间复用。比如一个“暖色调电影感”的 Profile 里,可能同时包含了 Bloom、Color Grading、Vignette 三组 Settings。
值得说的是,Profile 本身不关心这些 Settings 是否处于激活状态,真正的激活判断在渲染阶段由 Renderer 的IsActiveAndSupported完成。这也是数据与逻辑分离的体现:Profile 只是“有哪些参数”,而不负责“这些参数怎么用”。
2.3 PostProcessEffectSettings:单个效果的参数容器
所有具体效果的参数类,比如Bloom、ColorGrading、Vignette,都继承自PostProcessEffectSettings。你在 Inspector 里看到的那些 Slider、Toggle、ColorField,都是由 Settings 类里的字段声明驱动的。
这里有一件至关重要的事:Settings 里的字段不能直接用float、Color这些原生类型,而是要包一层FloatParameter、ColorParameter、BoolParameter等。这不是为了炫技,而是因为 V2 的参数系统需要支持两件事:第一,在 Volume 面板里可以单独覆盖某个参数而不是整个效果;第二,Volume 混合时需要能够对两个实例的同一字段做插值。
ParameterOverride这个泛型基类,源码里核心就两个东西:overrideState表示这个参数是否被当前 Volume 显式覆盖;Interp(a, b, t)用于两个 Volume 之间做插值。所有标量参数类型的插值逻辑都集中在这些 Parameter 类里,你要扩展自定义参数类型的时候,同样要走这条路径。
2.4 PostProcessEffectRenderer:真正干活的人
Settings 只管“参数是什么”,Renderer 管“参数怎么用”。比如 BloomRenderer,每帧会根据当前 Bloom 参数的阈值、强度、散射等数值,决定执行哪些 Blit 命令、用 Shader 的哪个 Pass、中间需要几张临时 RT。
Renderer 是普通类,不是组件,也没有生命周期托管的压力,它的入口方法就是Render(PostProcessRenderContext context)。你需要的一切都塞在context里:当前相机、CommandBuffer、源纹理、目标纹理、宽高、时间、属性表。写自定义效果时 90% 的代码都发生在 Render 方法里。
2.5 PostProcessBundle:把 Settings 和 Renderer 绑在一起的中间层
除了上面四个核心类,还有一个容易被忽略的PostProcessBundle。在PostProcessLayer内部,所有效果都不是直接持有 Settings 或 Renderer 对象,而是先创建一个 Bundle,把同一个效果的 Settings 和 Renderer 实例打包在一起。这样做的好处是,调度层只需要维护一个字典即可,不用分别检索 Settings 和 Renderer。
从代码组织上看,这个 Bundle 是对“参数与逻辑配对的映射关系”的显式表达,也避免了你把 Settings 类 A 和 Renderer 类 B 错误配对的隐患。
3. 从相机到屏幕:一帧画面里后处理命令是怎么被组装的
3.1 PostProcessLayer 在相机上的初始化流程
PostProcessLayer 挂到相机后,OnEnable时会做几件事:根据PostProcessResources资产里的效果清单,为每个效果创建对应的 Bundle,并存入一个Type -> Bundle的字典;然后创建一个CommandBuffer,通过camera.AddCommandBuffer(CameraEvent.BeforeImageEffects, cmd)挂到相机上;最后注册相机事件,确保每帧渲染前都有机会重建命令。
这一步是理解整个框架的关键:V2 没有在脚本侧去主动调用某个效果的 Blit,而是把所有渲染命令预先写进一个 CommandBuffer,由原生渲染管线在正确时机执行。这样 CPU 到 GPU 的数据传递更高效,也避免了很多OnRenderImage时代的 RT 拷贝开销。
3.2 BuildCommandBuffer 的组装顺序
每帧渲染前,PostProcessLayer会重建命令缓冲,核心流程可以概括为四步。
第一步,重置渲染上下文。PostProcessRenderContext会把当前相机、CommandBuffer、源纹理、目标纹理这些信息带进来,后续所有 Renderer 都通过这个上下文获取资源。第二步,让 VolumeManager 根据相机位置和所有 Volume 的状态算出当前生效的参数快照。第三步,遍历之前创建的 Bundle 字典,逐个调用IsActiveAndSupported判断这个效果是否需要进入本帧渲染。第四步,把通过判断的效果的 Renderer 按事件顺序依次执行,最终完成一次全屏 Blit。
这个顺序看起来简单,但它保证了每个效果在同一帧内拿到的是同一份 Volume 混合结果,不会出现先执行的效果用的是旧参数、后执行的效果用的是新参数这种不一致。
3.3 RenderContext 里到底塞了什么
PostProcessRenderContext是源码里出现频率最高的“快递员”。具体字段包括camera、source、destination、command、width、height、stereoRendering、propertySheets、resources、settings等。
其中source是当前后处理栈的输入纹理,destination是最终输出目标。每个效果执行时,都可以把context.source作为输入,通过 Blit 写出到自己的临时 RT,再把临时 RT 赋给context.source,从而把结果传递给下一个效果。这是整个后处理链最基本的数据流模型。
3.4 PropertySheet 和临时 RT 管理的巧思
V2 源码里大量使用PropertySheet,它内部其实是Material加MaterialPropertyBlock。为什么不用普通 Material?因为后处理效果往往只需要改几个参数,而普通材质每次SetFloat都可能触发材质变体切换或实例化开销。用MaterialPropertyBlock可以做到更轻量地传递属性,同时仍保留材质本身的可复用性。
临时 RT 的管理同样有套路。你会看到GetTemporaryRT/ReleaseTemporaryRT在 Renderer 里高频出现,它们直接跑在 CommandBuffer 上,由引擎在原生侧统一分配内存池。写自定义效果时我也建议遵循同样的模式:申请的 RT 一定要在同一个命令缓冲周期内释放,不要持有跨帧的 RT 句柄,否则内存峰值会很难看。
3.5 全屏三角形与 Blit 的底层差异
V2 源码中有一个BlitFullscreenTriangle的扩展方法,它代替了经典的Graphics.Blit。原因是Graphics.Blit走的是内部绘制全屏 Quad 的路径,涉及额外的 Mesh 数据;而直接绘制一个覆盖裁剪空间的大三角形,更贴合现代 GPU 的全屏后处理绘制方式。
这个细节对性能敏感项目很重要。哪怕只是做一次全屏拷贝,如果每帧都走Graphics.Blit,移动端上也会有可感知的提交开销。V2 通过 CommandBuffer 扩展统一了这一层,让所有效果都以相同的全屏三角形方式绘制。
4. 效果注册:Attribute 扫描与 PostProcessResources 资源清单
4.1 一个自定义效果要出现在面板里,靠的是 Attribute
先看一个典型的自定义效果声明:
[Serializable] [PostProcess(typeof(FoxGrayscaleRenderer), PostProcessEvent.AfterStack, "Custom/FoxGrayscale")] public sealed class FoxGrayscale : PostProcessEffectSettings { [Range(0f, 1f)] public FloatParameter blend = new FloatParameter { value = 1f }; }PostProcessAttribute的第一个参数指定渲染器类型,第二个参数指定触发事件,第三个参数是它在 Volume 组件菜单里显示的路径。当你在场景里调出 PostProcess Volume 的 Add Override 面板时,能看到 “Custom/FoxGrayscale” 这一项,靠的就是这个 Attribute。
事件参数PostProcessEvent一共有三个可选值:BeforeTransparent、BeforeStack、AfterStack。默认自定义效果一般用AfterStack,表示在整个内建后处理栈结束之后追加;少数特殊效果会用到BeforeTransparent,比如需要在透明物体渲染之前做一次深度相关的处理。
4.2 PostProcessResources:框架的“资源户口本”
除了写代码,新增效果还要完成资源注册。PostProcessResources 是一个 ScriptableObject,里面有两个重要列表:settings和shaders。前者把可参与后处理栈的 Settings 类型全部登记进去,后者集中管理 Shader 资源。
为什么不能只靠 Attribute 扫描,还要手动在 Resources 里登记?因为反射扫描所有程序集类型的开销很大,而且运行时不一定准确。V2 干脆让开发者在资源面板里显式声明“有哪些效果参与这个项目”,调度层启动时直接遍历这份声明列表,生成 Bundle 字典。这是一种典型的“显式优于隐式”的工程取舍。
4.3 启动时创建 Bundle 的反射过程
PostProcessLayer 初始化时拿到 Resources 里的 settings 列表,对每个 Settings 类型做一次反射,读取它身上的PostProcessAttribute,从而拿到对应的 Renderer 类型。随后通过Activator.CreateInstance创建 Renderer 实例,再一起封装进PostProcessBundle。
这意味着你新增一个效果后,不需要改动任何调度逻辑。框架只要知道 Settings 类型和 Renderer 类型之间的映射关系,就能自动把它纳入渲染栈。代价是每次启动时有一点反射开销,但对大多数项目来说完全可以忽略。
4.4 新效果最常见的注册遗漏
基于我自己的踩坑经验,新增自定义效果时最容易漏三件事。
第一,只写了 Settings 和 Renderer,忘了把 Settings 类型加进 PostProcessResources 的 settings 列表,结果 Add Override 菜单里怎么都找不到。第二,Shader 写了也放进工程了,但没拖进 PostProcessResources 的 shaders 列表,运行时效果直接不显示或者报错。第三,Attribute 的菜单路径和其他效果冲突,导致右键菜单重复显示同名项。
5. 自定义效果实战:从 Settings 到 Renderer 一共要动四个地方
5.1 完整示例:做一个带混合强度的灰度效果
下面我拿一个“FoxGrayscale”示例完整走一遍,你可以直接照着在自己的工程里试。目标效果:控制灰度的混合强度,数值 0 是原图,1 是完全灰度。
先建FoxGrayscale.cs:
using System; using UnityEngine; using UnityEngine.Rendering.PostProcessing; [Serializable] [PostProcess(typeof(FoxGrayscaleRenderer), PostProcessEvent.AfterStack, "Custom/FoxGrayscale")] public sealed class FoxGrayscale : PostProcessEffectSettings { [Range(0f, 1f), Tooltip("灰度混合强度")] public FloatParameter blend = new FloatParameter { value = 1f }; }再建FoxGrayscaleRenderer.cs:
using UnityEngine; using UnityEngine.Rendering.PostProcessing; public sealed class FoxGrayscaleRenderer : PostProcessEffectRenderer { public override void Render(PostProcessRenderContext context) { var sheet = context.propertySheets.Get(context.resources.shaders.foxGrayscale); sheet.properties.SetFloat("_Blend", context.settings.Get<FoxGrayscale>().blend); context.command.BlitFullscreenTriangle(context.source, context.destination, sheet, 0); } }接着是 ShaderFoxGrayscale.shader:
Shader "Hidden/Custom/FoxGrayscale" { SubShader { Cull Off ZWrite Off ZTest Always Pass { HLSLPROGRAM #pragma vertex VertDefault #pragma fragment Frag #include "Packages/com.unity.postprocessing/PostProcessing/Shaders/StdLib.hlsl" TEXTURE2D_SAMPLER2D(_MainTex, sampler_MainTex); float _Blend; float4 Frag(VaryingsDefault i) : SV_Target { float4 color = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.texcoord); float gray = dot(color.rgb, float3(0.2126, 0.7152, 0.0722)); return lerp(color, float4(gray.xxx, color.a), _Blend); } ENDHLSL } } }最后一步是资源注册。打开 PostProcessResources 资产,把FoxGrayscale(Settings)加入 settings 列表,把FoxGrayscale.shader加入 shaders 列表。然后在场景里放一个 PostProcess Volume,添加 Override,找到Custom/FoxGrayscale,勾选 blend 并拖动数值,就能看到效果实时变化。
5.2 参数从中转到大屏:context 里发生的事
上面代码里有一行容易被新手看懵:context.settings.Get<FoxGrayscale>()。这个context.settings并不是你在 Volume 面板上看到的那份原始 Settings,而是经过 VolumeManager 混合后的“当前生效快照”。也就是说,多个 Volume 之间的优先级、权重、blendDistance 计算已经完成了,你在 Renderer 里拿到的就是一帧最终参数。
这个设计很关键。如果你的自定义 Renderer 不小心直接去读PostProcessVolume上的 Profile 数据,而没走context.settings,那么一旦场景中有多个 Volume,你的效果参数就会混乱。所以自定义效果一定要遵守“Renderer 只从 context 拿参数”这个约定。
5.3 自定义效果的四步法清单
| 步骤 | 要动的文件 | 要点 |
|---|---|---|
| 定义参数 | 新建 Settings 类 | 继承 PostProcessEffectSettings,字段用 XxxParameter |
| 实现渲染 | 新建 Renderer 类 | 继承 PostProcessEffectRenderer,重写 Render |
| 编写 Shader | 新建 Shader 资产 | Pass 与 Renderer 中传入的 pass 编号对齐 |
| 注册资源 | PostProcessResources | settings 列表加类型,shaders 列表加 Shader |
5.4 几个调试时的常见问题与判断方法
如果自定义效果没生效,我的排查顺序一般是:先看 Add Override 菜单里有没有出现效果名,没有就是 Attribute 或 resources 注册问题;再打开后处理调试面板看效果是否处于激活状态,没激活就看IsActiveAndSupported是否误判;最后在 Renderer 里临时加Debug.Log确认Render方法到底有没有被调用。
Shader 变紫或者效果黑屏,重点检查 Shader 的 Pass 编号、_MainTex的采样声明,以及 RGBA 输出格式。灰度效果你甚至可以先把_Blend强制设为 1,排除参数传递环节的问题。
5.5 一个值得借鉴的扩展思路
V2 自带的 ColorGrading 效果并不只是简单地对_MainTex做逐像素调整,它会在 Renderer 内先烘焙一张 LUT,再最终采样查表。这个思路很有参考价值:像 Bloom、ColorGrading 这种重计算效果,可以先通过低分辨率预处理生成一张“中间数据贴图”,再在最终全屏 Pass 里使用。如果你自己做更复杂的效果,比如局部色调映射、扫描线抖动、风格化描边,都可以采用这种“预处理 + 最终合成”的多 Pass 结构。
6. 读完 V2 源码后,我对这套设计的取舍反思
6.1 设计里做得好的地方
我最大的收获是它的“数据与渲染分离”原则。Settings 里全是纯数据字段和 Attribute,Renderer 里全是渲染命令,二者通过 PostProcessBundle 关联,互不污染。这个模式适合直接借鉴到技能编辑器、任务系统、美术资源合批工具里——只要你的场景里同样存在“数据定义”和“数据使用”两块逻辑,都可以拆成两个模块,再让一个中间层去配对。
另一个值得学习的是 ParameterOverride 带来的可插值参数系统。它本质上是一套“每个字段支持覆盖状态 + 双实例插值”的通用容器。如果你做过角色动画混合或者红点提示系统,就会发现这套参数容器的思路完全通用。
6.2 设计里不够舒服的地方
V2 也不是没有槽点。新增效果要同时动代码和资源资产,属于典型的多点同步操作。一旦漏掉一处,错误信息指向又不够明确,排起来很费时间。另外,它和后来 URP/HDRP 自带的 Volume 体系在命名上撞车,迁移时会产生不少认知成本。源码里大量使用反射和字典查找,在启动阶段问题不大,但如果你的项目里塞了几百个效果设置,每次启动的初始化耗时还是能感知到的。
还有一个更现实的问题:V2 已经不再跟随内置渲染管线以外的技术演进,很多新特性比如屏幕空间全局光照、路径追踪、自定义渲染节点都围绕 URP/HDRP 展开。选择学习 V2 的价值不在于继续扩展,而在于理解一套经过大规模项目验证的后处理框架是怎么组织代码的。
6.3 给现在还在用 V2 的项目的建议
如果你确实还在维护内置渲染管线的老项目,我的建议是把 V2 当成稳定的黑盒来用,不要做深度定制。需要新效果时,优先用上述自定义效果的方式外挂,而不是去改框架自带效果源码。这样后续无论迁移 URP 还是替换第三方后处理方案,都更容易解耦。
另外,在 Build 设置里多测几种 GPU 平台的回退路径。有些平台的 Shader 方言不一致,V2 的 Shader 可能走 Stripping 或 fallback,出现“编辑器里正常、真机上效果缺失”的情况。这种情况建议通过 PostProcessing Debug 面板检测每个效果在当前平台的支持状态,而不是盲目调参数。
6.4 我最后的个人体会
扒完这套代码的最深感受:一个后处理框架最难的部分,不是某一个效果公式,而是“参数声明、混合计算、渲染调度、资源加载”这四件事如何被干净地串在一起。V2 把这四件事全部分离,再用一套显式约定组合起来,这种层次感就是我反复翻它的原因。如果你正在写自己的渲染工具或后处理管线,完全可以拿它的代码组织结构当模板。
把 V2 的源码通读一遍后,建议你再去看一眼 URP 里 Volume 框架的实现方式,会明显感觉到 V2 的影子还在:同样的 ParameterOverride、同样的 Settings 与 Renderer 分离、同样的 Profile 资产概念。V2 确实退出了新项目的舞台,但它留下的这一套组织代码的方法,比任何单个效果都值钱。