Banner

「Custom SRP」:后处理栈

系列第 7 篇。前六篇笔记把场景从几何提交一路走到 GI 接入——到这一步,相机的 color attachment 中存储的是一张线性空间的 HDR 图像:物理光度学单位下的真实辐射度,可能远超 [0, 1],包含了所有直接光、间接光、阴影信息。本篇关注的是从这张 HDR 图像到最终显示器输出的完整加工链——Bloom 模拟相机镜头光晕、Tone Mapping 把 HDR 映射到 LDR、Color Grading 进行风格化调色、Render Scale 处理动态分辨率、FXAA 收尾抗锯齿。

TL;DR

  • 后处理是 Render Graph 中的三个独立 Pass:BloomPass(光晕金字塔)→ ColorLUTPass(生成 3D 调色查找表)→ PostFXPass(最终合成 + FXAA + 输出)。Render Graph 自动复用格式相同、生命周期不重叠的中间 RT,显著减少分配。
  • HDR 的核心 RT 格式是 R11G11B10F:32 位/像素、近似 RGBA16F 的视觉质量、显著节省带宽。这是后处理性能与质量的甜区。
  • Bloom 用 Dual Filter Pyramid:下采样 + 上采样的双向金字塔,每级用低成本 Box Filter 模拟高斯模糊。Threshold 用 soft knee 曲线避免硬截断造成的 Mach Band。
  • Tone Mapping 三模式各有适用场景:Reinhard 简单稳定、Neutral 中性自然、ACES 电影级胶片质感(计算量稍高)。所有模式都在 Log-C 空间外完成。
  • Color LUT 是性能优化的灵魂:把所有逐像素调色(白平衡、对比度、饱和度、Channel Mixer、SMH)烘焙到一张 32³ 的 3D 纹理,全屏只需一次三线性查找——把每像素几十次浮点计算压缩为一次纹理采样。
  • 6.2.0 起使用真 3D Texture + Compute Shader 生成 LUT:替代旧版 2D 模拟方案,提升采样精度与编程清晰度。

1. 后处理栈架构

1.1 整体数据流

flowchart TD
    A[Camera Color Attachment
HDR Linear · R11G11B10F] --> B[BloomPass] B --> C[Bloom Pyramid
多级 RT] C --> D[Bloom Result
HDR · 与原图同尺寸] E[PostFXSettings · 调色参数] --> F[ColorLUTPass · Compute Shader] F --> G[Color LUT 3D Texture
32³ · Log-C 空间] A --> H[PostFXPass] D --> H G --> H H --> I[最终输出
LDR sRGB · Camera Target] style B fill:#e3f2fd,stroke:#1976d2,stroke-width:2px style F fill:#fff3e0,stroke:#f57c00,stroke-width:2px style H fill:#e8f5e9,stroke:#388e3c,stroke-width:2px

三个 Pass 各司其职:

  • BloomPass — 提取 HDR 高亮部分、生成模糊金字塔、合成回原图
  • ColorLUTPass — 用 Compute Shader 生成 3D 颜色查找表(不依赖输入图像、只依赖参数)
  • PostFXPass — 最终合成阶段:从原图 + Bloom 结果 + LUT 一次性产出 LDR 输出,期间完成 Tone Mapping、Color Grading 查表、FXAA、Render Scale 缩放

1.2 Render Graph 资源复用

后处理的中间 RT 数量很多——Bloom 金字塔可能有 5-7 级、加上 Color Copy、Color Grading Result 等中间副本。如果每个都独立分配,是显著的显存负担。

Render Graph 会自动检测格式相同 + 生命周期不重叠的资源,在底层复用同一张物理 RT。Frame Debugger 中会显示”Color Copy”和”Color Grading Result”使用了同名的物理 RT——这就是复用生效的标志。

1
2
3
4
5
6
7
// 业务代码声明的逻辑资源
var colorCopy = builder.UseTexture(...);
var colorGradingResult = builder.UseTexture(...);

// 引擎实际分配可能只有一张物理 RT
// 因为 colorCopy 在 PostFXPass 入口被消费完
// colorGradingResult 才开始写入

这是 Render Graph 相对手动 RT 管理的核心收益之一——资源声明的颗粒度可以做得很细,物理分配由引擎统一优化

1.3 PostFX 启用判断

不是每个相机都需要后处理。Custom SRP 在 CameraRenderer 中根据相机配置与 PostFX 设置决定走完整 PostFXPass 还是直接 FinalPass:

1
2
3
4
5
6
7
8
bool hasActivePostFX = postFXSettings != null
&& cameraSettings.allowPostFX
&& camera.cameraType <= CameraType.SceneView;

if (hasActivePostFX)
PostFXPass.Record(renderGraph, postFXSettings, textures, ...);
else
FinalPass.Record(renderGraph, copier, textures);

camera.cameraType <= CameraType.SceneView 跳过预览相机和反射 Probe 相机——对它们做后处理通常无意义且会拖慢编辑器响应。


2. HDR 与中间 RT 格式

2.1 为什么需要 HDR

直接用 LDR(每通道 8 位 [0, 1])做后处理会撞上两个问题:

  • 过曝信息丢失:场景中超过 1.0 的辐射度(直接光、霓虹灯、阳光反射)在 LDR 中被截断为纯白——后续 Bloom 无法区分”白纸”和”灯泡”
  • 精度链式损失:每次 Blit 都会产生 8 位量化误差,链式后处理多次累积后出现可见 banding

HDR 用浮点格式存储辐射度,保留 [0, ∞) 全范围。Bloom 提取时能精确区分高光程度,Tone Mapping 阶段才把 HDR 映射到 LDR。

2.2 R11G11B10F:性能甜区

HDR 中间 RT 的格式选择直接影响带宽与质量。三个候选:

格式 位深/像素 范围 精度 适用
RGBA16F 64 bit [-65504, 65504] 11 位尾数 桌面端、对精度极敏感的场景
R11G11B10F 32 bit [0, 65000] 6-7 位尾数 后处理标准
RGBA8 (LDR) 32 bit [0, 1] 8 位定点 最终输出

R11G11B10F 的精度安排:R 与 G 通道 11 位(5 位指数 + 6 位尾数)、B 通道 10 位(5 位指数 + 5 位尾数)——B 通道精度略低是因为人眼对蓝色亮度变化最不敏感。

视觉对比 RGBA16F 时几乎无差,但带宽减半——这在移动端 TBR 架构下意味着片上内存占用减半、Pass 间 store-load 也减半。这是 R11G11B10F 成为 URP / HDRP / Custom SRP 后处理标准格式的根本原因

2.3 Pre-Exposure:避免精度浪费

R11G11B10F 的 6-7 位尾数虽然够用,但当场景辐射度数值范围跨度大(暗部 0.001、阳光 1000)时仍会出现暗部精度不足。

Pre-Exposure 是 HDR 管线的前置技巧:在 Tone Mapping 之前,把整个 HDR buffer 乘以一个全局曝光值,让”中等亮度”的辐射度落到 1.0 附近——浮点精度在 1.0 附近最高。

1
2
float exposureValue = Mathf.Pow(2f, colorAdjustments.postExposure);
// 整个 HDR 缓冲乘以 exposureValue 后再做后续处理

postExposure 暴露给美术的单位是 EV(Exposure Value,stops)——每 +1 EV 等同曝光时间翻倍或光圈大一档。这与摄影师的工作语言一致,在场景照明调整时直观。

2.4 KeepAlpha:透明度保留判断

PostFX 链路是否需要保留 alpha 通道是个常被忽视的选项。Custom SRP 暴露 keepAlpha 配置:

  • keepAlpha = false(默认):Alpha 通道在 PostFX 阶段被覆盖。此时 Bloom 可以借用 Alpha 通道存储中间数据
  • keepAlpha = true:Alpha 通道严格保留——用于需要把渲染结果合成到外部图层的情况(比如视频会议背景虚化、AR 应用、UI 层叠加)

这个开关影响 Bloom 实现细节——如果允许使用 Alpha,可以把”Bloom 强度”打包到 alpha 中省一张 RT。


3. Bloom:HDR 高光泛光

3.1 物理原理与设计目标

真实相机镜头不是完美的——亮光源在感光元件上产生散射光晕,强度越大、扩散越广。Bloom 模拟这个效应,让 HDR 高光部分自然”溢出”到周围区域。

Bloom 的关键参数:

  • Threshold — 高光提取阈值。低于此值的像素不参与 Bloom
  • Knee — Threshold 的软过渡范围。避免硬截断造成的 Mach Band
  • Intensity — Bloom 总强度
  • Scatter — 散射程度。决定金字塔上采样的混合比例
  • Iterations — 金字塔层级数。越多越柔和但也越糊

3.2 Threshold 的 Soft Knee 曲线

朴素 threshold 用 max(color - threshold, 0) 截断——但这会让略高于阈值的像素突然出现,亮度与阈值之差很小时容易出现可见的边界。

Soft Knee 曲线让 threshold 周围有一段平滑过渡:

其中 。结果是阈值附近的 quadratic 平滑、远离阈值后线性。

1
2
3
4
5
6
7
8
9
10
float3 ApplyBloomThreshold(float3 color)
{
float brightness = Max3(color.r, color.g, color.b);
float soft = brightness + _BloomThreshold.y; // y = -knee
soft = clamp(soft, 0.0, _BloomThreshold.z); // z = 2 × knee
soft = soft * soft * _BloomThreshold.w; // w = 1 / (4 × knee + epsilon)
float contribution = max(soft, brightness - _BloomThreshold.x);
contribution /= max(brightness, 0.00001);
return color * contribution;
}

_BloomThreshold.xyzw 在 CPU 端预计算好四个系数,Shader 端只用 4 次乘法 + 几次比较即可完成 soft threshold——比展开公式直接计算快得多。

3.3 Dual Filter Pyramid

Bloom 的核心算法是多级模糊金字塔。直接对全屏做大半径高斯模糊成本极高(半径 R 的高斯需要 R² 次采样),分层金字塔把这个成本压到对数级:

1
2
3
4
5
6
7
8
Level 0 (full size)
↓ Downsample with box filter
Level 1 (1/2 size)
↓ Downsample
Level 2 (1/4 size)
↓ Downsample
...
Level N (very small)

下采样阶段每像素只需要 4 次采样(box filter),总成本是 O(N × tap),远低于单级大核高斯。

更精妙的是上采样阶段反向走金字塔,在每级把当前模糊结果与上一级原始结果按 Scatter 权重混合:

1
2
3
4
5
6
7
8
Level N (modeled blur) ──┐
├─→ Upsample to Level N-1
Level N-1 (downsampled) ──┘

├─→ Upsample to Level N-2
Level N-2 (downsampled) ──────┘
...
→ Final Bloom Result

这种 dual filter(下采样 + 上采样的两阶段对称结构)的视觉效果接近高质量高斯模糊,但成本只有单次大核模糊的几分之一——Kawase 在 CryEngine 时代提出的优化思路,至今仍是现代游戏引擎 Bloom 的事实标准。

3.4 BloomPass 的 Render Graph 资源声明

Bloom 金字塔的每一级都是独立 RT。BloomPass 需要在录制阶段批量声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public static void Record(RenderGraph renderGraph, /*...*/, in CameraRendererTextures textures)
{
using var builder = renderGraph.AddRasterRenderPass(
"Bloom", out BloomPass pass, sampler);

// 输入:HDR color
builder.UseTexture(textures.colorAttachment, AccessFlags.Read);

// 创建金字塔级别(动态决定数量)
int width = bufferSize.x / 2;
int height = bufferSize.y / 2;
for (int i = 0; i < maxBloomIterations; i++)
{
if (width < 2 || height < 2) break;
var desc = new TextureDesc(width, height) {
format = colorFormat,
name = $"Bloom Pyramid {i}"
};
pass.bloomPyramid[i] = renderGraph.CreateTexture(desc);
builder.SetRenderAttachment(pass.bloomPyramid[i], 0); // 写入

width /= 2;
height /= 2;
}

builder.SetRenderFunc<BloomPass>(Render);
}

每级金字塔都是独立 attachment——Render Graph 会自动判断这些 RT 中能否复用物理资源。注意 Bloom 各级 attachment 的尺寸不同,无法跨级合并为同一原生 render pass——这是 Bloom 必然打断 attachment 合并链的原因,也是为什么 Note 1 §7 强调”PostFX 必然打断合并链”。


4. Tone Mapping

4.1 设计动机

人眼的动态范围(暗适应到亮适应)跨越约 14 个数量级,单次场景中可感知约 3-4 个数量级;显示器只能显示约 2 个数量级(典型 250 nits 标准显示器);HDR 渲染输出可能跨越 6-8 个数量级。

Tone Mapping 的核心任务是把 HDR 辐射度智能地压缩到 LDR 范围,保留视觉重要的细节、舍弃眼睛不敏感的部分。这不是简单的 min(color, 1.0) 截断——而是模拟人眼或胶片对亮度的非线性响应。

4.2 三种 Tone Mapping 模式

Custom SRP 实现三种典型 Tone Mapper:

Reinhard

最简单的 Tone Mapper,公式:

1
2
3
4
5
float3 ToneMappingReinhard(float3 color)
{
color.rgb /= color.rgb + 1.0;
return color;
}

特点:对 HDR 任意值都收敛到 [0, 1]、没有截断、计算极廉。但视觉上偏淡——所有高亮区域都被均匀压缩,缺少电影感。

适合:debug 用、性能极致受限的场景、追求中性风格的项目。

Neutral

Unity 内置的 Neutral Tone Mapper 采用 Hable / Lottes 风格的多项式拟合,特点是中等亮度区域几乎线性(保持准确色彩)、高光部分柔和压缩

1
2
3
4
5
float3 ToneMappingNeutral(float3 color)
{
color = NeutralTonemap(color);
return color;
}

NeutralTonemap 是 RP Core Library 提供的标准实现,参数已经调好(white point、shoulder、toe 等)。视觉上比 Reinhard 自然许多——高光不那么平淡、暗部也保持细节。

适合:写实游戏、户外场景、对色彩准确性要求高的项目。

ACES

ACES(Academy Color Encoding System)是电影工业的色彩管线标准,包含完整的 Input → Reference Rendering Transform → Output 链路。游戏中常用的是简化版的 ACES Filmic Tone Mapping 曲线:

1
2
3
4
5
float3 ToneMappingACES(float3 color)
{
color = unity_to_ACES(color);
return AcesTonemap(color);
}

unity_to_ACES 把 Unity 的工作色彩空间(线性 sRGB)转换到 ACES2065-1 / ACEScg;AcesTonemap 应用 RRT + ODT 简化曲线后输出。

ACES 的特点:胶片质感的高光卷曲(roll-off)暗部的丰富深度、整体色调偏暖。这是当代 3A 游戏的事实标准(《最后生还者》《荒野大镖客 2》《赛博朋克 2077》等都使用 ACES 路径)。

代价:计算量稍高(多几次矩阵乘法),但移动端高端机型完全能承担。

4.3 模式选择决策

flowchart TD
    A[项目风格] --> B{追求什么?}
    B -->|纯净中性| C[Reinhard / Neutral]
    B -->|电影质感| D[ACES]
    B -->|风格化| E[Reinhard + Color Grading]

    C --> F[移动端低端: Reinhard
桌面端: Neutral] D --> G[全平台 ACES
性能预算允许] E --> H[依赖 Color LUT 风格化] style D fill:#e8f5e9,stroke:#388e3c style G fill:#e8f5e9,stroke:#388e3c

实践中 ACES 是默认选择——除非性能极度紧张或风格上明确不要电影感。


5. Color Grading

Color Grading 是后处理中最复杂的子系统——涉及多个独立的调色阶段串联,每阶段都有自己的参数。但 Custom SRP 通过 LUT 烘焙巧妙地把整个链路成本固化为”一次 3D 纹理采样”。

5.1 调色管线

调色阶段按顺序串联:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Input HDR

Post Exposure (EV 调整)

White Balance (色温 / 色调)

Contrast (在 Log-C 空间内)

Color Filter (整体 tint)

Hue Shift (色相旋转)

Saturation (饱和度)

Channel Mixer (通道交叉混合)

Split Toning (高光/阴影分别染色)

Shadows / Midtones / Highlights (三段独立调色)

Tone Mapping

Output LDR

每个阶段都有独立的参数集,对应美术界面上的多个分区。完整链路如果逐像素计算,每像素需要执行整套数学操作——典型 50-100 ALU。

5.2 LUT 烘焙的核心思想

观察一下整个调色链路的特性:

  • 它是一个纯函数 f(rgb_in) = rgb_out——输入 RGB 决定输出 RGB,不依赖屏幕位置、不依赖时间、不依赖其他像素
  • 参数变化频率低——美术调好一组参数后,每一帧都用同一组参数

既然是纯函数且参数稳定,可以预计算所有可能的输入对应的输出,存在 3D 纹理里——这就是 Color LUT(Lookup Table)。

LUT 的尺寸常见 16³ / 32³ / 64³。32³ 是甜区——压缩 4096 像素长的 1D LUT 为可三线性插值的 3D 纹理,覆盖整个 RGB 空间且精度足够。

每像素的成本从”50-100 ALU 整套调色计算”压缩为”一次 3D 纹理三线性查找”——后处理的核心性能优化。

5.3 ColorLUTPass 的 Compute Shader 实现

Custom SRP 6.2.0 起使用真正的 3D 纹理 + Compute Shader 生成 LUT。Compute Shader 比 fragment shader 直接写入更适合 3D 纹理(fragment 无法直接渲染到 3D RT):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// colorLUT.compute
#pragma kernel ColorGradingNonePassFragment
#pragma kernel ColorGradingReinhardPassFragment
#pragma kernel ColorGradingNeutralPassFragment
#pragma kernel ColorGradingACESPassFragment

RWTexture3D<float4> _ColorGradingLUT;
float4 _ColorGradingLUTParameters;
// ... 调色参数

float3 GetColorGradedLUT(uint3 id)
{
float scale = _ColorGradingLUTParameters.w;
float3 color = id * scale; // 把 thread id 映射回 RGB

if (_ColorGradingLUTParameters.x > 0.0)
color = LogCToLinear(color); // Log-C 空间转回线性

return ColorGrade(color); // 应用所有调色阶段
}

[numthreads(4, 4, 4)]
void ColorGradingACESPassFragment(uint3 id : SV_DispatchThreadID)
{
float3 color = GetColorGradedLUT(id);
color = ColorGradingACES(color);
_ColorGradingLUT[id] = float4(color, 1.0);
}

numthreads(4, 4, 4) 是个看似保守的选择——64 thread/group 在所有支持 Compute Shader 的硬件上都能运行(Mali、Adreno、Apple、Nvidia、AMD)。32³ LUT 用 8×8×8 = 512 个 thread group 完成填充——一次 dispatch 即可。

💡 64 线程的硬件哲学:Wavefront 与 Warp 的对齐4 × 4 × 4 = 64 不是随意的魔法数字,而是对 GPU 硬件执行单元(SIMD)的精确对齐。64 个线程恰好填满 AMD GPU 的一个完整 Wavefront(Wave64),同时完美等分为 NVIDIA GPU 的两个 Warp(Warp32)——也对得上 Apple GPU 的 32 SIMD-group、Mali 的 16/8 量子。这种”最大公约对齐”保证了:(1) 寄存器分配在硬件层最优化,没有任何线程组内的部分填充浪费;(2) 完全规避 Divergence——同一 dispatch 内所有线程走相同代码路径(都是 LUT 填充计算),没有 if-else 分歧导致的空转;(3) 跨厂商兼容——大于 64 的 thread group 可能在某些移动 GPU 上被强制拆分降低效率,小于 32 的会让 NVIDIA Warp 没填满。64 线程是 Compute Shader 跨平台的兼容性甜区,也是性能调优的基石。当你看到现代引擎的 Compute Shader 反复出现 numthreads(8, 8, 1)(64)、numthreads(64, 1, 1)numthreads(4, 4, 4)(64)时,背后是同一条硬件对齐准则。

1
2
3
4
5
// CPU 端 dispatch
buffer.SetComputeTextureParam(shader, kernel, colorGradingLUTId, colorLUT);
int groups = colorLUTResolution / 4;
buffer.DispatchCompute(shader, kernel, groups, groups, groups);
buffer.SetGlobalTexture(colorGradingLUTId, colorLUT);

SetGlobalTexture 让 LUT 对所有后续 PostFXPass 可见——这是 Compute → Raster Pass 之间最常见的资源共享方式。

5.4 Log-C 空间的工作意义

调色操作在不同色彩空间中表现差异巨大。一些操作(contrast、white balance)在 Log-C 空间 中比在线性 RGB 中更接近人眼感知:

  • 线性空间下的对比度调整:高光区域被剧烈拉开(数值大、变化大)、阴影区域被压扁(数值小、变化小)。视觉上不均匀
  • Log-C 空间下的对比度调整:所有亮度区间均匀缩放——这正是胶片摄影师调对比度时的预期效果

Log-C 是 ARRI Log-C 的简称,类似的还有 Sony S-Log、RED Log3G10 等——所有都是为电影级调色优化的对数曲线。

ColorLUTPass 的工作流程:

  1. 将 LUT 输入坐标 id 视作 LDR RGB([0, 1])
  2. 如果 inLogC 标志启用:用 LogCToLinear(rgb) 把它解读为 Log-C 空间的坐标,转换为线性 RGB(这样输入范围扩展到 HDR 的等效区域)
  3. 应用所有调色阶段
  4. 应用 Tone Mapping
  5. 写入 LUT

PostFXPass 采样时反向使用:把每像素的 HDR 颜色 LinearToLogC() 转换为 Log-C 编码后查找 LUT——这样一张 32³ LUT 能覆盖 HDR 全范围。

5.5 LUT 采样

PostFXPass 中应用 LUT:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
TEXTURE3D(_ColorGradingLUT);
SAMPLER(sampler_LinearClamp);

float3 ApplyColorGradingLUT(float3 color)
{
float scale = _ColorGradingLUTParameters.y;
float offset = _ColorGradingLUTParameters.z;

if (_ColorGradingLUTParameters.x > 0.0)
color = LinearToLogC(color); // HDR 输入转 Log-C

color = saturate(color);

return SAMPLE_TEXTURE3D(
_ColorGradingLUT,
sampler_LinearClamp,
color * scale + offset).rgb;
}

scaleoffset 是 32³ LUT 与 [0, 1] 输入坐标空间的转换系数(避免边缘 voxel 的精度损失):scale = (resolution - 1) / resolutionoffset = 1 / (2 × resolution)

整个采样只占 1 次 ALU 运算 + 1 次 3D 纹理三线性插值(GPU 硬件原生支持)。

5.6 LUT 调试

Color LUT 的内容是个 3D 纹理,无法直接观察。Custom SRP 6.1.0 起提供 LUT 调试可视化——把 LUT 作为渐变色块覆盖在屏幕角落,让美术能直观确认调色参数是否产生预期效果。这对调试”为什么调色看起来不对”极有帮助——美术可以对照标准的渐变色块判断当前 LUT 把哪些颜色区域怎么变换了。


6. Render Scale 与中间分辨率

6.1 设计目标

Render Scale 让渲染分辨率与显示分辨率解耦:

  • Render Scale = 0.5:以一半分辨率渲染,最后放大到屏幕——大幅省 GPU
  • Render Scale = 1.5:超采样渲染,缩小到屏幕——抗锯齿质量极高(SSAA 等效)
  • Render Scale = 1.0:原生分辨率(默认)

这是动态分辨率(DRS)的基础——在性能压力大时降低 Render Scale 维持帧率,性能宽裕时回升保持画质。移动端项目中是必备特性。

6.2 中间 RT 与最终 Blit

启用 Render Scale 时,整个渲染流程都在中间 RT 上完成:

1
2
3
4
5
所有渲染 Pass → Intermediate RT (renderScale × screenSize)

FinalPass / PostFXPass

Bicubic Blit → Camera Target (screenSize)

中间 RT 的尺寸:

1
2
3
Vector2Int bufferSize;
bufferSize.x = (int)(camera.pixelWidth * renderScale);
bufferSize.y = (int)(camera.pixelHeight * renderScale);

整个 PostFX 链路(Bloom 金字塔、Color Grading 输出)都在 bufferSize 尺度下进行——这意味着 Bloom pyramid 的级数、PCF 的相对像素尺寸等都自适应缩放。

6.3 Bicubic Filtering:上采样质量

最终 Blit 把中间 RT 拉伸到屏幕尺寸时,简单 bilinear 会造成可见模糊。Bicubic 上采样使用 16 个邻域 texel(4×4)做高阶插值,效果显著优于 bilinear:

1
2
3
4
5
6
7
float4 GetSourceBicubic(float2 screenUV)
{
return SampleTexture2DBicubic(
TEXTURE2D_ARGS(_PostFXSource, sampler_linear_clamp),
screenUV, _PostFXSource_TexelSize.zwxy,
1.0, 0.0);
}

Bicubic 的代价是约 5-7 倍的采样开销(16 tap vs bilinear 的 4 tap 实质成本)。Custom SRP 通过 _CopyBicubic 关键字让美术按需启用——大多数场景下 Render Scale > 0.7 时 bilinear 已经足够,更激进的下采样才需要 bicubic。

6.4 与后处理的协同

Render Scale 与 Bloom 的协同需要注意:Bloom pyramid 的级数应该基于中间 RT 尺寸计算,而不是屏幕尺寸。否则在 Render Scale = 0.5 时 pyramid 会少一级,Bloom 范围意外变小。

Catlike 实现已经处理这个细节——bufferSize 是所有 PostFX 阶段的统一尺寸基准。


7. FXAA 抗锯齿

7.1 抗锯齿方案对比

主流抗锯齿技术:

方案 原理 成本 质量
MSAA 多采样几何边缘 高(带宽 × 4) 最高
SSAA 全屏超采样 极高 最高
TAA 时间累积抗锯齿 高(动态画面有 ghosting)
FXAA 单帧屏幕空间分析 极低
SMAA 形态学 + 多采样混合 中低

FXAA(Fast Approximate Anti-Aliasing)由 Nvidia 的 Timothy Lottes 在 2009 年提出,核心是在 LDR 图像上通过 luma 检测边缘并对边缘做单方向模糊。计算量极低、不依赖 MSAA、不依赖时间累积——是移动端项目的现代默认选择。

7.2 FXAA 算法概要

简化的 FXAA 流程:

  1. 采样中心像素与四个邻居(上下左右)的 luma
  2. 检测对比度:max - min 是否超过 threshold?低于 → 不是边缘、跳过
  3. 判定边缘方向:水平边缘(左右 luma 差大)还是垂直边缘?
  4. 沿边缘方向迭代:步进探测边缘端点位置
  5. 混合样本:根据探测结果在边缘垂直方向 lerp 模糊

Custom SRP 集成 RP Core Library 的 FXAA 实现,分三个质量等级:

1
2
3
static readonly GlobalKeyword
fxaaLowKeyword = GlobalKeyword.Create("FXAA_QUALITY_LOW"),
fxaaMediumKeyword = GlobalKeyword.Create("FXAA_QUALITY_MEDIUM");
等级 EXTRA_EDGE_STEPS 边缘探测能力
Low 3 中等长度边缘
Medium 8 大多数边缘
High 12 (默认) 极长边缘

EXTRA_EDGE_STEPS 越多越能处理倾斜的长边(屋顶天际线、远处地平线),代价是每像素几次额外采样。移动端通常用 Medium,桌面端用 High。

7.3 Luma 计算

FXAA 需要 luma(亮度)输入。两种获取路径:

  • 从 RGB 现算luma = sqrt(dot(rgb, float3(0.299, 0.587, 0.114)))——最常见
  • 使用 alpha 通道:在最终 Blit 时把 luma 写入 alpha——节省 FXAA Pass 内的运算

第二种是 Custom SRP 的现代实现选择——前一个 Pass 写入 alpha = sqrt(luma),FXAA Pass 直接读 alpha。这是 keepAlpha = false 路径下的优化。

1
2
3
4
5
6
7
8
9
float Luminance(float3 rgb)
{
return dot(rgb, float3(0.2126729, 0.7151522, 0.0721750));
}

float GetLuma(float2 uv)
{
return SAMPLE_TEXTURE2D(_PostFXSource, sampler_linear_clamp, uv).a;
}

注意:BT.709(HD)的 luma 系数是 (0.2126, 0.7152, 0.0722),比 BT.601(SD)的 (0.299, 0.587, 0.114) 略有不同。现代项目应使用 BT.709。


8. PostFXPass 总集成

最终的 PostFXPass 是所有上述阶段的集成器。它的任务是从中间 HDR RT、Bloom 结果、Color LUT 三个输入,一次产出最终 LDR 输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
float4 PostFXFragment(Varyings input) : SV_TARGET
{
// 1. 采样原始 HDR 颜色
float3 color = SAMPLE_TEXTURE2D(_PostFXSource, sampler_linear_clamp, input.uv).rgb;

// 2. 加上 Bloom 贡献
float3 bloom = SAMPLE_TEXTURE2D(_PostFXSource2, sampler_linear_clamp, input.uv).rgb;
color = lerp(color, bloom, _BloomIntensity);

// 3. Color LUT 应用(包含 Tone Mapping)
color = ApplyColorGradingLUT(color);

// 4. 屏幕空间抖动(消除 8-bit 量化色带)
color += ScreenSpaceDither(input.positionCS.xy);

// 5. 写入 alpha = sqrt(luma) 供 FXAA Pass 用
return float4(color, sqrt(Luminance(color)));
}

后续 FXAA Pass 在此输出上应用边缘检测与模糊。最终输出到 Camera Target——也就是屏幕本身或外部 RT。

8.1 屏幕空间抖动(Dithering):消除 8-bit 色带的最后一道工序

即使中间 RT 全程使用 R11G11B10F、LUT 是 RGBA16F、计算全在浮点空间——最终输出给显示器的依然是 8-bit RGBA8 图像。Tone Mapping 的输出曲线在暗部(接近 0)和高光(接近 1)的斜率较小,意味着 HDR 输入的相邻浮点值映射到 LDR 后落在同一个 256 级量化档位上。

这种量化截断在以下场景产生明显的可见色带(Color Banding)

  • 暗室墙壁的渐变阴影
  • 夜晚天空盒的颜色过渡
  • 平缓的雾效和大气散射
  • 渐变 UI 背景

屏幕空间抖动通过给每像素引入微小的、空间分布合理的随机扰动,让”相邻的两档量化值在屏幕上随机分布”——人眼的视觉暂留效应会把这种分布感知为更平滑的中间色,色带被彻底打散。

1
2
3
4
5
6
7
float3 ScreenSpaceDither(float2 positionSS)
{
// Bayer Matrix 或 Interleaved Gradient Noise
float dither = InterleavedGradientNoise(positionSS, 0.0);
// 1/255 量级的扰动 = 一档 8-bit 量化精度
return (dither - 0.5) / 255.0;
}

InterleavedGradientNoise(Jorge Jimenez 提出)是 Custom SRP 中已经使用的稳定屏幕空间噪声函数(Note 5 §2.4 LOD Cross-fade 和 Note 2 也用过它)。它的特点是同一像素位置每帧给出相同噪声值——如果用 frame-varying 的随机数会产生闪烁,反而比色带更糟糕。

更高质量的实现使用预计算的 蓝噪声(Blue Noise) 贴图——蓝噪声的频谱特性在视觉上比白噪声更不可见,是当代 3A 项目的标准选择:

1
2
3
4
5
6
7
8
TEXTURE2D(_BlueNoiseTexture);
float3 ScreenSpaceDither(float2 positionSS)
{
float2 noiseUV = positionSS * _BlueNoiseTexture_TexelSize.xy;
float dither = SAMPLE_TEXTURE2D(_BlueNoiseTexture,
sampler_PointRepeat, noiseUV).r;
return (dither - 0.5) / 255.0;
}

sampler_PointRepeat 让蓝噪声在屏幕上周期性平铺,64×64 或 128×128 的蓝噪声贴图就足够全屏使用。

💡 抖动是 3A 后处理栈的隐形收尾工序:成本极低(一次纹理采样 + 一次 lerp),收益显著(彻底消除 8-bit banding)。不加抖动的 PostFX 链路在中等画质需求下就能撞墙——美术经常发现”暗部就是有色带,调什么都调不掉”的根源就是这一步缺失。Custom SRP 教程没有展开这一步,但 production 项目应该把它视为 PostFXPass 的必备最后一公里。HDRP 的 _DitherTexture 和 UE5 的 DitherFinalOutput 都对应这个工作。

需要注意:抖动应用在输出 sRGB 之前——即在线性空间内施加 1/255 的扰动。如果在 sRGB 编码后施加,扰动量就需要根据 sRGB gamma 曲线非线性调整,复杂得多。


9. TA Takeaway

9.1 后处理的总成本图谱

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Bloom (主要成本):
├─ Threshold extract: 1 sample + Soft Knee
├─ Downsample × N levels: N × 4 sample
├─ Upsample × N levels: N × 8 sample (双源混合)
└─ 总成本: ~30-50 samples per pixel (相对原图)

Color LUT 生成 (一次性):
└─ Compute Shader 32³ × 50 ALU = 一次 dispatch

PostFXPass (每像素):
├─ HDR sample: 1
├─ Bloom sample: 1
├─ LUT sample (3D 三线性): 1
└─ Total: ~3 samples + 5-10 ALU

FXAA:
├─ Luma sample × 9 (3×3 邻域): 9
├─ 边缘检测: 5-10 ALU
└─ 总成本: ~9-12 samples + 10-20 ALU

整个 PostFX 链路 per frame (1080p 60Hz):
├─ Bloom: ~3-6 ms (移动端)
├─ Color LUT 生成: < 0.5 ms (一次性)
├─ PostFX 主合成: ~1-2 ms
└─ FXAA: ~0.5-1 ms

后处理通常占整个 GPU 帧时间的 20-30%——这是移动端帧率最容易撞墙的阶段。

📱 Bloom 在 TBR 架构下的带宽真相:上述成本图谱的 ALU 数字是桌面级 GPU 的视角。在移动端 TBR 架构下,Bloom 的真实成本根本不在 ALU 上——而在带宽。Dual Filter Pyramid 在算法层面虽然是 的优雅设计,但每一次 Downsample 与 Upsample 都意味着切换 RT——从 Note 1 §7 已经确立的 TBR 行为推论:每次 RT 切换都触发一次完整的 attachment store-load 往返。一个 6 级 Bloom Pyramid 包含 6 次下采样 + 6 次上采样 = 12 次 store-load 往返。1080p × R11G11B10F 单次往返约 8MB,6 级 Pyramid 加上原图采样约 30-50MB 带宽——这在移动端 25-50 GB/s 的总带宽预算下是个惊人的数字,足以让单帧温度上升一个台阶。这也是为什么在重度移动端项目优化中,宁愿用更复杂的单 Pass 算法(Compute Shader 实现的合并下/上采样)减少 RT 切换次数,也要极力压缩 Bloom 的迭代层级——maxBloomIterations 在移动端通常配置为 4 而非默认的 5-6。同样的逻辑适用于所有多级 Pass 后处理:DOF、SSAO、Volumetric 等。在 TBR 架构上,带宽是比 ALU 更紧迫的预算,这是后处理优化的核心硬件认知。

9.2 LUT 烘焙是性能优化的范式案例

Color Grading 的 LUT 化是一个值得反复学习的优化范式:

问题特征

  • 计算成本高(50-100 ALU)
  • 是纯函数(无状态依赖)
  • 输入是有限的(RGB 三维空间)
  • 参数变化频率低(美术调好后稳定)

优化思路

把”逐像素计算”换成”一次性预计算 + 全屏查找表”。当问题满足上述四个特征时,LUT 化几乎总是巨大胜利

类似可以 LUT 化的场景:

  • BRDF 预积分(Karis BRDF LUT,HDRP/UE 标准实现)
  • Atmospheric Scattering 预积分
  • 复杂的颜色空间转换(如 PQ → SDR / HLG → SDR)
  • 噪声模式生成

理解 LUT 化的背后哲学——用空间换时间、用一次预计算换每像素的运行时计算——是 TA 优化思路的核心工具之一。

9.3 中间分辨率与 PostFX 的协同设计

Render Scale 是当代移动端的必备特性,但与 PostFX 协同有几个隐性约束:

  • Bloom 阈值是绝对值:Render Scale 改变后场景”亮”的认知不变,Bloom Threshold 不需要随之调整
  • FXAA 在中间 RT 上工作:Render Scale = 0.5 时 FXAA 处理的是低分辨率图像,最终 Blit 时小尺度边缘已经被 bicubic 平滑——可以考虑跳过 FXAA 或用 Low 等级
  • LUT 与 Render Scale 完全解耦:LUT 是颜色空间映射,与分辨率无关,无需任何调整

实践推论:移动端项目推荐 Render Scale 在 0.7-1.0 之间动态调整,配合 ACES + Color LUT + FXAA Medium——这是当前移动端 PBR 项目的画质/性能甜区组合。

9.4 实践原则

  • HDR 中间 RT 用 R11G11B10F:除非碰到精度问题再上 RGBA16F
  • Pre-Exposure 先于 Tone Mapping:让暗部精度集中在 1.0 附近
  • Bloom 用 Soft Knee 而不是硬截断:Mach Band 是常被忽视的视觉瑕疵
  • Bloom 移动端层级 ≤ 4:每多一级 = 多一次 store-load 往返,带宽收益负优化点比 ALU 来得早
  • Color Grading 必走 LUT 路径:逐像素调色在中等画质需求下就能撞墙
  • 3D LUT > 2D 模拟:Compute Shader 路径的精度与可读性都更好
  • ACES Tone Mapping 是默认选择:除非有明确的非电影感需求
  • Dithering 是 PostFX 必备最后一公里:1/255 量级的蓝噪声扰动,彻底消除 8-bit 色带
  • FXAA 优于不抗锯齿:成本 < 1ms,效果显著
  • Render Scale 而不是降低 PostFX 质量:先降分辨率再削 PostFX

关键 API 速查

CSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 三个 PostFX Pass 的注册
BloomPass.Record(renderGraph, postFXSettings, ...);
ColorLUTPass.Record(renderGraph, postFXSettings, lutResolution, ...);
PostFXPass.Record(renderGraph, postFXSettings, lutResolution, fxaaQuality, ...);

// HDR RT 格式
GraphicsFormat hdrFormat = GraphicsFormat.B10G11R11_UFloatPack32; // R11G11B10F

// Color LUT 资源(3D Texture)
new TextureDesc {
width = lutResolution,
height = lutResolution,
slices = lutResolution, // 3D 维度
dimension = TextureDimension.Tex3D,
format = GraphicsFormat.R16G16B16A16_SFloat,
enableRandomWrite = true, // Compute Shader 写入
name = "Color Grading LUT"
};

// Compute Shader Dispatch
buffer.SetComputeTextureParam(shader, kernel, lutId, lutTexture);
buffer.DispatchCompute(shader, kernel, groups, groups, groups);
buffer.SetGlobalTexture(lutId, lutTexture);

// FXAA 关键字
GlobalKeyword.Create("FXAA_QUALITY_LOW");
GlobalKeyword.Create("FXAA_QUALITY_MEDIUM");
// (无关键字时为 High)

// Render Scale 配置
float renderScale = settings.renderScale;
Vector2Int bufferSize = new(
(int)(camera.pixelWidth * renderScale),
(int)(camera.pixelHeight * renderScale));

// PostFXSettings 关键参数
public enum ToneMappingMode { None, Reinhard, Neutral, ACES }
public enum ColorLUTResolution { _16 = 16, _32 = 32, _64 = 64 }
public enum BloomMode { Additive, Scattering }
public enum FXAAQuality { Low, Medium, High }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 3D LUT 采样(PostFX Pass 内)
TEXTURE3D(_ColorGradingLUT);
float3 ApplyColorGradingLUT(float3 color);

// HDR / LDR 转换
LinearToLogC(float3 color);
LogCToLinear(float3 color);
unity_to_ACES(float3 color);
AcesTonemap(float3 color);
NeutralTonemap(float3 color);

// Bloom Soft Threshold
float3 ApplyBloomThreshold(float3 color); // 用 _BloomThreshold.xyzw 预计算

// Bicubic 上采样
SampleTexture2DBicubic(...);

// Luma
float Luminance(float3 rgb); // BT.709

// Compute Shader 入口
[numthreads(4, 4, 4)]
void ColorGradingACESPassFragment(uint3 id : SV_DispatchThreadID);