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,小核场景反而贵。默认的降采样链在核宽和开销的权衡上更平衡。
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 的位置都是它的输入语义决定的,调优时想动顺序,先想清楚输入语义还成不成立。