技术博客

后处理链源码:FPostProcessing 的 Pass 顺序与设计逻辑

第 42 / 100 篇 · 2026-08-12 · ue5 · 后处理渲染源码分析

BasePass 画完的场景渲染结果是一张"原始"的 HDR 缓冲——没有泛光、没有色调映射、没有抗锯齿。把这些变成最终画面的整条 pass 链在 Engine/Source/Runtime/Renderer/Private/PostProcess/PostProcessing.cpp(UE5.3)。这篇按执行顺序过一遍关键 pass,重点讲顺序为什么是这样——顺序不是习惯,每一步都依赖前面步骤的输出特性。

总览:从 HDR 线性到显示输出

完整链(默认配置、全特性开启的近似序列):

BasePass 输出 (HDR linear)
  → Depth of Field / MotionBlur(视需求)
  → TSR(时序超分+抗锯齿,UE5 默认)
  → Bloom(降采样链 + 累加)
  → Exposure(自动曝光,可前移至 TSR 前)
  → Tonemapping(ACES 或自定义曲线)
  → Color Grading(LUT 合成)
  → sRGB 编码 + 输出到 backbuffer

UE5.3 里这条链全部走 RDG(RDG 那篇讲过 pass 声明与资源生命周期),PostProcessing.cpp 的 FPostProcessing::Process 是调度核心——它按"当前启用了哪些效果"动态拼接 pass 图,不是一条写死的流水线。关掉 Bloom 就没有 Bloom 的 pass,链自动缩短。

为什么 Exposure 的位置有讲究

自动曝光的采样点在链上的位置是个容易忽略的设计。曝光量要回答"画面平均有多亮",而 Bloom 会在画面上叠加亮斑、tonemapping 会压缩动态范围——在它们之后测曝光,读数被效果本身污染(亮斑把整体曝光拉低,画面变暗,负反馈循环)。

所以 Exposure 的默认采样点在 Bloom 之前、原始 HDR 上:测的是场景真实亮度分布。5.x 版本允许把 exposure 计算前移到 TSR 之前(r.EyeAdaptation.PreExposure),理由相同但更激进——TSR 之后图像是 jittered 累积的结果,统计特性仍然干净,但前移可以省一个全屏 pass 的带宽。这个前移有个前提:TSR 是线性 HDR 操作,不引入曝光相关的非线性,所以前移数学上无损。

PreExposure(预曝光)机制顺带一提:UE 在渲染早期就乘上一个 2 的幂缩放,把 HDR 值压到 float 精度舒服的范围,tonemapping 阶段再除回来。浮点数的精度在指数分布上均匀——预曝光就是手动重定指数基线,大动态范围场景(直射太阳 + 室内暗角)下能明显减 banding。

Bloom:降采样链的工程

Bloom 的高斯模糊不是一次大核卷积——$n \times n$ 核的采样数是 $O(n^2)$,GPU 上不可行。UE 的做法是可分离滤波 + 多级降采样:先在半分辨率做横向模糊,再纵向($O(n)$ 每方向),然后降到 1/4、1/8…逐级重复,最后逐级累加回全分辨率。

用降采样链近似大核模糊的数学依据:高斯核在反复降采样(本身近似小高斯模糊)和逐级滤波的复合下,等效于一个标准差随级数增长的高斯。第 $k$ 级的模糊半径大约是 $2^k$ 像素量级,5 级链就能覆盖上百像素的泛光半径,总采样数只有几十个像素——对大核模糊是 $O(\log n)$ 的复杂度。这是实时渲染里"用金字塔换核宽"的标准手法(mip chain bloom)。

BloomConvolution(高品质模式)是另一条路:把模糊核傅里叶变换后在频域乘——卷积变逐点乘法,但要多一次 FFT 和逆 FFT,小核场景反而贵。默认的降采样链在核宽和开销的权衡上更平衡。

后处理链的顺序依赖:每一步依赖前一步的输出特性 HDR Linear TSR Auto Exposure Bloom Tonemap (ACES) sRGB 输出 Exposure 必须在 Bloom 前 在 Bloom 后测光 = 被亮斑污染读数 负反馈:亮斑拉低曝光 → 画面变暗 r.EyeAdaptation.PreExposure 可前移省带宽 Tonemap 必须在 Color Grading 时 ACES 曲线压缩 HDR → 可显示范围 LUT 在 LDR 域合成,避免 HDR 域溢出 sRGB 编码最后一步,之前全线性

Tonemapping 与 Color Grading 的合并

UE5.3 里 tonemap 和 color grading 是同一个 pass(PostProcessTonemap),不是两个。原因:color grading 的大部分操作(对比度、饱和度、白平衡、LUT 应用)是逐像素色变换,和 tonemapping 的曲线求值可以合并成一次 ALU 序列——分开做就是两次全屏读写的带宽浪费。

LUT(颜色查找表)的处理也在这里:材质编辑器里做的 grading LUT 是 3D LUT($32^3$ 或 $16^3$),tonemap pass 里做三线性插值。自定义 LUT 要注意 UE 的 LUT 是在 tonemap 之后、sRGB 编码之前的 LDR 域——美术在 DCC 里调的 LUT 如果预期作用于 HDR 域,结果会偏。

Motion Blur 和 DoF 的位置

这两个是链上位置最灵活的效果。默认 DoF 和 MotionBlur 在 TSR 之前(r.DepthOfField.Before.TAA 一类开关),理由:TSR 的时序累积依赖清晰的像素对应关系,先模糊再超分会污染 motion vector 的有效性(模糊是空间操作,motion vector 是逐像素的)。但某些管线(尤其关了 TSR 走 MSAA 的项目)会把它们放 TSR 之后省一个全屏 pass——代价是 blur 不会被时序累积稳定化,噪点可见度上升。r.MotionBlurQuality 和 DoF 的 r.DepthOfField.Quality 档位变化时,pass 结构也会跟着变(低档位用更少的降采样级数)。

排查与调优

  • 画面发灰、对比度上不去:先怀疑 tonemap 曲线的 shoulder 参数(r.Tonemapper.Sharpen 和 filmic 参数族),再看 color grading LUT 是否双重应用(材质里和 volume 里各挂了一次);
  • Bloom 溢光范围不够:调 r.Bloom.MaxConvolutionBufferSize(默认 64)决定链最深层级,也就是最大模糊半径上限;
  • 曝光抽动:histogram 模式的曝光统计窗口(r.EyeAdaptation.ExposureHighPercent / LowPercent)决定亮部权重,过窄的窗口在强点光源入画时会把曝光拉飞;
  • 整条链的开销分析:Stat GPU 的 PostProcessing 大类下每个 pass 单独计时,RDG 的 pass 图可视化(r.RDG.Graph 1 之类调试手段)能直接看到实际拼出来的链长什么样——很多"我开了 X 效果怎么没生效"的谜题,看一眼实际 pass 图就知道是开关没生效还是被合并掉了。

源码入口

  • 调度核心:PostProcessing.cpp 的 FPostProcessing::Process,看它按 feature flag 条件分支拼 pass 的方式;
  • Bloom 降采样链:PostProcessBloom.cpp 的 AddBloomPass,看级数和各级核宽;
  • Tonemap:PostProcessTonemap.cpp,ACES 曲线参数和 LUT 插值都在这个 pass 的 shader(PostProcessTonemap.usf)里。

后处理链的设计哲学可以用一句话概括:顺序即语义。曝光依赖原始亮度、tonemap 依赖曝光归一、grading 依赖 LDR 域——每个 pass 的位置都是它的输入语义决定的,调优时想动顺序,先想清楚输入语义还成不成立。