☰
Post Processing Stack v2源码解析:架构、自定义与性能优化
2026/10/15 7:07:21 网站建设 项目流程

打开 Post Processing Stack v2 的源码时,第一反应往往是“没用之前觉得是个黑盒,用起来才发现是个半透明的壳”——参数面板一眼看不懂,但只要一打开脚本目录,整个工程的骨架就摊在面前了。很多人用 PPv2 只是拖动 Bloom、Color Grading 的滑块调到“好看”,却从没想过它在管线里从哪一步开始介入、每个效果为什么拆成两个类。

这篇代码结构分析完全围绕 PPv2 源码本身展开,我默认你已经装了插件、至少跑通过一个最简单的 Bloom 效果。适合两类人:一类是正准备把自定义后处理塞进自己的渲染流程,却被官方文档有限的接口说明卡住的人;另一类是突然遇到黑屏、参数无效、多相机叠加异常,想从原理层面定位而不是靠“删掉重来”解决问题的人。我会从目录分层讲到类职责,再从渲染路径切到自定义效果注册方式,最后给出一份能跑的扩展示例,以及我实际排查问题时的经验。

1. 代码库整体结构与模块划分

1.1 从文件夹看懂分层思想

先看包体解压后的根目录,核心代码集中在Runtime/PostProcessing下,不会像 Unity 内置后处理那样只有“看不见的 native 层”。这个目录的结构本身就透露了 PPv2 的架构理念——数据、执行、工具全部分开。

Runtime/PostProcessing ├── Components │ ├── AmbientOcclusion.cs │ ├── Bloom.cs │ ├── ChromaticAberration.cs │ ├── ColorGrading.cs │ ├── DepthOfField.cs │ ├── Grain.cs │ ├── LensDistortion.cs │ ├── MotionBlur.cs │ ├── ScreenSpaceReflections.cs │ └── Vignette.cs ├── Effects │ ├── AmbientOcclusionRenderer.cs │ ├── BloomRenderer.cs │ ├── ChromaticAberrationRenderer.cs │ ├── ColorGradingRenderer.cs │ ├── DepthOfFieldRenderer.cs │ ├── GrainRenderer.cs │ ├── LensDistortionRenderer.cs │ ├── MotionBlurRenderer.cs │ ├── ScreenSpaceReflectionsRenderer.cs │ └── VignetteRenderer.cs ├── Volume.cs ├── PostProcessProfile.cs ├── PostProcessSettings.cs ├── PostProcessLayer.cs ├── PostProcessResources.cs ├── RenderContext.cs ├── PostProcessEffectRenderer.cs ├── PostProcessEffectSettings.cs ├── PostProcessFactory.cs ├── PostProcessVolumeStack.cs ├── Utilities │ ├── BitArrayUtilities.cs │ ├── ButterflyUtilities.cs │ ├── ColorUtilities.cs │ ├── DirectRenderTexture.cs │ ├── GaussianUtilities.cs │ ├── HaltonSeq.cs │ ├── ImageProcessingUtilities.cs │ ├── OpenGLUtilities.cs │ ├── PropertySheet.cs │ ├── PropertySheetFactory.cs │ ├── RenderTextureFactory.cs │ ├── RuntimeUtilities.cs │ └── TextureFormatUtilities.cs └── Renderers ├── ScreenSpaceReflectionRenderer.cs ? ├── TemporalAntialiasingRenderer.cs ? ├── FXAA.cs ? └── ...

规律很明显:每一个后处理效果都由两个文件组成——Components下的类负责参数声明,Effects下的类负责实际 Shader 执行。Renderers目录则是抗锯齿相关渲染器的集合,Utilities是通用工具,如果你要手写自定义后处理,这里才是需要吃透的地方。

Editor目录下还有一套自绘 Inspector 逻辑,像PostProcessEffectInspector、PostProcessProfileEditor这类文件,决定了你在面板上看到的参数控件的样式。这段代码里有一个老生常谈的“GUID 丢失”坑,后面我在第 5 节专门展开。

1.2 核心模块依赖关系

把类与类之间的引用画成依赖链,顺序大概是这样的:

Camera 上的 PostProcessLayer └── 持有 PostProcessResources(Shader/纹理等运行时资产) └── 持有 PostProcessVolumeStack(场景里所有 Volume 的引用集合) └── PostProcessProfile(ScriptableObject,保存一组 EffectSettings 资产) └── PostProcessEffectSettings 列表 每个 Settings 对应一个 PostProcessingEffectRenderer 执行器 └── 从资源中获取 Material,对临时 RenderTexture 做 Blit

PostProcessLayer 是“入口组件”,它挂了 Camera 后才会触发整个后处理管线;PostProcessResources 是“运行资产仓库”,所有 Shader、LUT、噪声纹理都集中在这里,而不是散落在各个效果类里;PostProcessVolumeStack 负责收集场景中所有 Volume 组件,并在每一帧排序混合。这个链路的终点是各个Renderer,它们真正执行Material.SetXxx和Graphics.Blit。

我最开始读源码时犯过一个错误:试图从 PostProcessLayer 开始逐行往下读,结果被巨大信息量冲晕。正确顺序应当是:先读数据层(Profile/Volume/EffectsSettings),再读执行层(Renderer/PropertySheet),最后回到 PostProcessLayer 看调度。这个顺序跟渲染流程恰好是反的,但对理解“参数如何流到 GPU”来说效率最高。

2. 关键类与其职责拆解

2.1 PostProcessLayer:相机入口与调度核心

放在 Camera 上的PostProcessLayer是 PPv2 的启动开关。它最重要的字段包括:

  • VolumeTrigger:哪个 Transform 代表“玩家所在位置”,用于体积计算。
  • VolumeLayer:LayerMask,决定哪些 Volume 会参与混合。
  • AntialiasingMode:TAA、FXAA 还是 SMAA,对应底层走 FSR 还是专用渲染器。
  • StopNaN:会把 NaN 像素替换成黑色,排查 Shader 时很有用。
  • Dithering:输出抖动,减少色带。
  • Init到OnDestroy阶段会创建CommandBuffer,并在OnRenderImage里决定是直接执行拿到的中间 RT,还是交给 CommandBuffer 统一提交。

这个类还有一个很多人没注意的职责:维护m_InternalRTs临时纹理池。PPv2 的每一次效果处理都在若干张临时 RT 之间来回 Blit,不会直接写到屏幕。使用手机端时如果看到内存暴涨或帧率抖动,往往要回来看这儿的分配与释放策略。

我没有逐行贴PostProcessLayer.cs的完整代码,因为它在不同版本里差异比较大。但阅读时要抓住三个关键方法:Awake里的资源定位、LateUpdate里的BuildCommandBuffer、和OnRenderImage里的执行分支。理解这三个方法,就相当于把握了整个调度主循环。

2.2 Volume 框架:Profile 与 Volume 组件协作

PostProcessProfile是一个ScriptableObject,保存了某种“预设”或“调色风格”。它内部维护一个List<PostProcessEffectSettings>,并且有两个自动查找方法:GetSettings<T>()和TryGetSettings<T>(out T),供运行时按类型抓取对应参数。

场景里的Volume组件则负责定义“这个预设作用在哪个范围”。它持有Profile引用、isGlobal开关、weight权重、priority优先级、blendDistance融合距离。当玩家位置落在某个 Volume 的碰撞体范围内时,PostProcessVolumeStack 会根据距离和权重计算最终混合系数:

finalWeight = volume.weight * (distanceBlendFactor)

不同 Volume 之间依靠priority决定谁压在谁上面。这跟音频系统的 AudioMixer 有点像——逻辑相似,但实现完全是定制化的。

注意一个容易混淆的点:PostProcessSettings不是PostProcessProfile的子类,而是挂在场景某种管理物体上的“配置集合”,内部持有PostProcessVolume引用。实际混合计算的数据源头是PostProcessProfile,PostProcessSettings只是提供了另一种访问入口和编辑便捷性。源码里它们俩名称太像,我见过不少新人把这两个类当成同一个东西用,然后抱怨“为什么存档里的 Profile 没生效”,其实因为改错了对象。

2.3 Effect 双结构:Settings 与 Renderer 拆分的原因

PPv2 最核心的设计之一是“效果双类结构”。每一个效果在Components下有一个PostProcessEffectSettings子类,在Effects下有一个PostProcessingEffectRenderer子类。例如ChromaticAberration.cs与ChromaticAberrationRenderer.cs。

为什么强制拆开?因为参数数据要与执行代码解耦。Profile 作为资产要被多个场景复用,假如把 Shader、Material 引用塞进 Profile,整个资产就会变得笨重且难以跨平台;同时执行器只关心“拿到参数后怎么渲染”,不必知道 UI 面板怎么展示数据。对于自定义效果来说,这个结构意味着你只需要继承PostProcessEffectSettings声明字段,再写一个PostProcessingEffectRenderer处理 Blit,系统就能自动完成从 Inspector 到 GPU 的参数流转。

PostProcessEffectSettings底层用大量[Range]、[Header]、[PostProcess]特性控制参数面板的生成方式,编辑器会自动反射并绘制。你写的类只要声明公开字段,面板就自动出现对应控件,不需要手写 Editor 逻辑,这就是双类结构带来的效率红利。

3. 渲染入口与命令缓冲拼接过程

3.1 两套运行路径:OnRenderImage 与 CommandBuffer

PPv2 在运行时根据平台与管线配置选择不同执行路径:

  • 简化路径:编辑器下或某些移动端,使用OnRenderImage(RenderTexture src, RenderTexture dest),把src当作输入,一系列 Blit 后写入dest。
  • 命令缓冲路径:支持 CommandBuffer 的平台,将所有后处理操作放进一个CommandBuffer,在相机的某个渲染事件点执行,减少脚本层调度开销。

两种路径的差异集中体现在源码中两个方法里:PostProcessLayer.BuildCommandBuffer与PostProcessLayer.OnRenderImage。前者把一帧的渲染命令堆进m_CommandBuffer,后者则是“实时同步”式地执行。自定义渲染器时不必关心选哪条路径,因为系统会在渲染前统一把你的Render方法挂到执行列表里——你只负责“这一次效果做什么”,由框架决定“在哪个时机做”。

但有一点必须清楚:自己写的 Renderer 在OnDisable或Invalidate时必须释放自己创建的临时 RT、CommandBuffer 或 Material,否则切场景时会导致资源泄漏。我见过项目里 UI 上大量红字报错,最后定位到某个自定义效果只在移动端设备上出现,原因就是临时纹理没有按平台条件释放。

3.2 中间纹理与 RT 释放策略

PPv2 用RenderTextureFactory维护临时纹理池。RenderTextureFactory.GetTemporaryRT(width, height, depthBits, format, readWrite, filter)会根据请求参数查找池中可用纹理,而不是每次new RenderTexture。满帧都执行的分配操作如果走垃圾回收,GC 抖动能直接把帧率打掉 30%。

阅读Utility.cs时重点看这三个功能:

  • GetTemporaryRT:尝试复用池内纹理,没有则创建。
  • ReleaseTemporaryRT:归还纹理到池,而不是销毁。
  • GetCopy:分配一张与给定纹理同参数的 RT 并拷贝内容。

实际执行中,像 Bloom 这种多级降采样效果,会同时占用 3~5 张不同分辨率的 RT。如果你在自定义 Renderer 中直接RenderTexture.GetTemporary而不归还,随着运行时间推移内存会只增不减。这就是我经常向团队强调的:自定义效果写完后必须开 Memory Profiler 跑一段游戏流程,确认 RT 数量不会持续上涨。

3.3 渲染循环大致流程

理解 PPv2 全链路,可以把每一帧执行拆成以下几个阶段:

  1. 相机渲染完场景后,后处理管线被触发。
  2. PostProcessLayer把中间目标绑定到临时 RT,开始逐效果执行。
  3. 每个效果在自己对应的PostProcessingEffectRenderer.Render()中完成从输入 RT 到输出 RT 的 Blit。
  4. 所有效果完成后,进行最后合并:将结果拷贝到相机目标,并顺便处理 Dithering/StopNaN 等收尾操作。
  5. 释放本帧使用过但没有被标记为“跨帧重用”的 RT。

框架之所以能保持效果顺序稳定,是因为PostProcessLayer在初始化时把全部 Effects 的执行顺序写死在一个列表里。比如Bloom → ColorGrading → Vignette是常见顺序。你自定义的效果如果想插在某两个内置效果之间,需要在PostProcessEffectiveSettings上加[PostProcess(typeof(你的Renderer), PostProcessEvent.BeforeStack)]之类的特性,然后由框架把新的执行器位置计算进依赖排序。

我实际测试下来的顺序感受是:效果顺序比参数更影响观感。同样一组锐化参数,放在 Bloom 前和 Bloom 后,最终画面差异极大。所以自定义效果时,我建议先在 Frame Debugger 里确认执行顺序是否符合预期,再调具体参数。

4. 自定义后处理效果的完整接入流程

4.1 写一个数据类:PostProcessEffectSettings 子类

我拿一个非常简单的“像素化”效果做例子,它会按设定的 Cell Size 把画面采样网格放大,形成马赛克风格。第一步是声明参数类:

using UnityEngine; using UnityEngine.Rendering.PostProcessing; [System.Serializable] [PostProcess(typeof(PixelateRenderer), PostProcessEvent.BeforeStack, "Custom/Pixelate")] public sealed class PixelateSettings : PostProcessEffectSettings { [Header("Pixelation")] [Range(2f, 256f)] public IntParameter cellSize = new IntParameter { value = 16 }; }

继承PostProcessEffectSettings后,字段使用IntParameter、FloatParameter、ColorParameter、BoolParameter等封装类型。这些类型重写了==和interpolation,是为了支持 Volume 之间的平滑融合。比如两个 Volume 的 cellSize 一个是 8,一个是 32,玩家经过时系统会基于 weight 自动插值。

注意特性里PostProcessEvent.BeforeStack表示这个效果放在内置效果之前执行;如果你要在所有效果之后叠加滤镜,可以用AfterStack。常见误区是忘了在类上写这个特性,结果效果的 Renderer 永远没有被调用,面板也不会出现它。

4.2 写执行类:PostProcessingEffectRenderer 子类

然后实现 Renderer:

using UnityEngine; using UnityEngine.Rendering.PostProcessing; public sealed class PixelateRenderer : PostProcessingEffectRenderer<PixelateSettings> { private Shader shader; private Material material; public override void Init() { base.Init(); shader = Shader.Find("Hidden/Custom/Pixelate"); if (shader == null) { Debug.LogError("Pixelate shader not found!"); return; } material = new Material(shader); } public override void Render(PostProcessRenderContext context) { if (material == null) return; int cell = Mathf.Max(1, settings.cellSize.value); material.SetInt("_CellSize", cell); var sheet = context.propertySheets.Get(material); // 从输入RT拷贝到临时RT,然后执行一次全屏Pass var source = context.source; var dest = context.GetScreenSpaceTemporaryRT(); Graphics.Blit(source, dest, sheet.material, 0); context.command.Blit(dest, context.destination); context.ReleaseScreenSpaceTemporaryRT(dest); } public override void Release() { if (material != null) { Object.Destroy(material); material = null; } base.Release(); } }

Init在效果被激活时调用,主要做 Shader 查找和 Material 创建;Render是每一帧的执行核心;Release用于清理。这个模式非常固定,几乎任何自定义后处理都可以套用。

context.propertySheets.Get(material)返回的是一个PropertySheet,它封装了材质属性设置和绘制调用。直接使用material也可以,但PropertySheet可以在命令缓冲路径下更高效地批处理。无特殊需求时不建议绕过它。

4.3 Shader 与注册细节

对应 Shader 如下:

Shader "Hidden/Custom/Pixelate" { Properties { _MainTex ("Texture", 2D) = "white" {} _CellSize ("Cell Size", Int) = 16 } 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 _CellSize; float4 Frag(VaryingsDefault i) : SV_Target { float2 uv = i.texcoord; float2 cell = _CellSize; float2 snappedUV = floor(uv * cell) / cell; return SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, snappedUV); } ENDHLSL } } }

内置的StdLib.hlsl提供了VertDefault、VaryingsDefault、SAMPLE_TEXTURE2D等基础定义,自定义后处理 Shader 应该以它作为起点,避免自己写全屏三角形。

最后一步:在PostProcessProfile上把设置资产实例化出来。最简单的方式是在PostProcessVolume的 Inspector 面板点击“Add Effect”,选择Custom/Pixelate,然后调整参数。如果你的效果没有出现在列表里,重新编译脚本后关闭再打开该面板,一般就能刷新。

我建议把这个效果单独做成一个 Package 目录,而不是直接改PostProcessing/Runtime下的代码。独立目录的好处是升级插件时不会被覆盖,代码隔离也便于团队协同。

5. 源码阅读经验与实战避坑指南

5.1 按什么顺序读源码收益最大

如果你现在准备深入看 PPv2 源码,我推荐的阅读顺序是:

  • 先读PostProcessEffectSettings.cs和PostProcessingEffectRenderer.cs,理解“参数+执行”接口的约定。
  • 再读Volume.cs和PostProcessProfile.cs,明白场景中的配置如何被序列化。
  • 接着进入RenderContext.cs,看系统给你提供了哪些屏幕 RT、临时 RT、属性表。
  • 然后啃PostProcessLayer.cs的调度逻辑,尤其是 BuildCommandBuffer 与 OnRenderImage 分支。
  • 最后把PropertySheetFactory.cs与RuntimeUtilities.cs通读一遍,这两段代码是手写自定义效果时最常用的工具。

不要从Bloom这类具体效果入手,它们的 Shader 复杂度和常量配置会淹没主线。先把框架骨架建立起来,再逐点啃细节效率高得多。

5.2 高频踩坑与排查方法

我整理了实际项目中遇到的高频问题,按发生频率排序:

现象可能原因检查位置
效果完全不显示没有挂 PostProcessLayer,或 Volume 没有勾选 Is GlobalCamera、Volume 面板
某些参数改了没反应Volume 的 weight 为 0,或 Profile 与 Volume 引用断开Inspector 权重曲线
自定义效果不出现缺少[PostProcess(...)]特性,或类不是 public sealed代码里的类声明
切场景后画面突然黑某些临时 RT 在 Release 中被错误销毁Renderer 的 Release
Shader 找不到Shader 名称拼写错误,或没放进 Always Included ShadersProject Settings
多相机叠加后颜色异常下层相机也挂了 PostProcessLayer,且没有排除层级LayerMask 设置

“黑屏”是我见过最多的现象。多数情况下不是 Shader 写错,而是后处理在中间 RT 上执行完成后,最终 Blit 到屏幕时context.destination为空或没被正确写入。定位方法是把自定义效果里最后一个 Blit 的 Shader 临时替换成Graphics.Blit(source, dest)纯拷贝,如果画面恢复,说明问题出在 Shader 本身而非 RT 管理。

5.3 调试后处理效果的实用技巧

调试 PPv2 最重要的工具是 Frame Debugger 和 RenderDoc。Frame Debugger 能直观看到每一张 RT 里的中间结果,RenderDoc 则能截获所有 DrawCall 和资源状态。

如果要把某个中间 RT 吐到屏幕,可以在 Renderer 里临时加上:

context.command.Blit(tempRT, context.destination);

然后注释掉其余效果。这种方式很粗暴但有效。另一个细节是:如果你的自定义效果使用了MaterialPropertyBlock,每次改动参数后要确认 Shader 关键字没有残留。用material.EnableKeyword/DisableKeyword时,一旦忘记恢复,GPU 上会一直应用旧的代码路径,表现为参数波动或结果异常。

对于颜色类效果,把 Color Grading 的 Output Color Space 设为 Linear 测试很关键。很多自定义效果在 Gamma 空间的颜色结果与 Linear 不同,导致同一套参数在不同项目里观感差异巨大。

6. 从源码层面看性能优化

6.1 临时 RT 分配频率

PPv2 最耗性能的操作不是 Blit 本身,而是纹理分配。一个完整帧中,Bloom 要 5 级降采样 + 4 级合成,Motion Blur 要 2 次临时拷贝 + 1 次混合,Color Grading 要 1 次 LUT 计算 + 1 次 AB 缓冲切换。每个操作都牵涉临时 RT,如果代码反复GetTemporaryRT/ReleaseTemporaryRT,驱动层会比较吃力。

源码里RenderTextureFactory用了一个简单的“池”策略:释放后的 RT 不立即销毁,而是写入池供后续复用。但前提是请求参数(分辨率、格式、深度)完全一致。所以自定义效果要尽量固定 RT 格式,不要每帧换RenderTextureFormat,否则会导致池循环重建。

6.2 Shader 复杂度控制

PPv2 源码中每个效果的 Shader 都尽量控制在 1~2 个 Pass 以内。一个效果如果有 3 个以上 Pass,每帧全屏绘制次数就会同步上升,移动端 GPU 的 fillrate 会最先扛不住。

自定义效果时,优先把多个操作合并到同一个 Shader 的多个 Pass 里,而不是写多个一次性效果串联。例如“像素化+颜色偏移”可以放同一个 Shader,一次 Blit 完成,效率远高于做成两个效果。参数写入用material.SetFloat合并成一次SetPass调用比较现实,但还是要尽可能减少全屏绘制次数。

6.3 与 SRP 的配合

PPv2 是前向管线时代的产物,Unity 官方表示它不会支持 SRP 的 Runtime 模式。所以如果你的项目已经切到 HDRP 或 URP,PPv2 的源码只能当参考,不能直接挂到新管线上。URP 里有自己的Volume框架,API 设计借鉴了 PPv2,但执行机制完全不同。

这意味着源码分析的最大价值在于“理解设计思路”,而不是“无缝迁移”。对于仍在使用内置渲染管线的项目,PPv2 仍然是目前功能最完整、社区案例最多的后处理方案。

收尾的个人体会

把 PPv2 代码从头到尾拆过一遍之后,最直接影响我工作习惯的是:所有后处理参数都必须走 Volume 的 Profile 保存,绝不把 Material 或 CommandBuffer 直接挂在相机上。这样做的理由很简单——可回滚、可对比、可多人协作。团队里每个人提交的调参结果都沉淀成 Profile 资产,而不是散落在场景的组件参数里。

最后分享一个小技巧:分析这种插件型代码时,先搜OnDestroy和Release,把资源释放路径搞清楚,再看渲染路径。因为渲染路径最多让你写对功能,资源释放路径决定你在线上能不能活过十分钟。做自定义后处理效果也一样,先写 Release,再写 Render,这个顺序能帮你避开一半的运行时崩溃问题。

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

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

立即咨询