这篇笔记主要写一下后处理:在屏幕空间对渲染结果做的那一系列操作,以及在 Metal 里怎么把它们串起来。
HDR 与色调映射
后处理的前提是在 HDR 中间纹理里渲染,而不是直接渲染到 8 位的 drawable。
Swiftlet d = MTLTextureDescriptor.texture2DDescriptor(
pixelFormat: .rgba16Float, width: w, height: h, mipmapped: false)
d.usage = [.renderTarget, .shaderRead, .shaderWrite]
理由很简单:真实场景的亮度范围远超 [0, 1]。太阳比白纸亮好几个数量级。如果在渲染时就把一切截断到 1,泛光就无从谈起(所有过曝区域都变成同一个 1.0),色调映射也没有意义。
色调映射把 HDR 范围压回显示器能表现的 [0, 1]。最简单的是 Reinhard:
MSLfloat3 reinhard(float3 c) { return c / (1.0 + c); }
它数学上很干净,但高光会显得灰扑扑的,因为它把所有亮度都压缩了,缺少胶片那种在高光处的柔和滚降。现在常用 ACES 的近似拟合:
MSLfloat3 ACESFilm(float3 x) {
const float a = 2.51, b = 0.03, c = 2.43, d = 0.59, e = 0.14;
return saturate((x * (a * x + b)) / (x * (c * x + d) + e));
}
它保留了暗部对比,在高光处平滑滚降,并且有轻微的色相偏移来模拟胶片。这几乎是当前的行业默认。
色调映射之前要先乘曝光值。自动曝光可以通过对亮度图做 mip 链、读最后一级得到平均亮度来实现,再用一个时间上的低通滤波避免忽明忽暗。
泛光
泛光(bloom)模拟真实镜头里明亮光源产生的光晕。三步:提亮部、模糊、加回去。这个流程在《Unity Shader #13 屏幕后处理》里详细讲过,这里只说 Metal 特有的部分。
现在通用的做法不是单一半径的高斯模糊,而是渐进降采样再渐进升采样:
Plain Text1/2 ──> 1/4 ──> 1/8 ──> 1/16
│
1/2 <── 1/4 <── 1/8 <──────┘ (每一级与降采样时的同尺寸结果相加)
每一级用一个小的 13 tap 核,降采样和升采样各一次。这样得到的泛光有很宽的范围(相当于很大半径的模糊),代价却只是几次小核采样,而且不同尺度的光晕叠加在一起,看起来比单一高斯自然得多。
降采样时要注意萤火虫问题:一个孤立的超亮像素在降采样后会变成一个明显的方块。解法是在最初的降采样里用亮度加权平均(Karis average):
MSLfloat weight(float3 c) { return 1.0 / (1.0 + luminance(c)); }
// 加权平均而不是算术平均
SSAO
屏幕空间环境光遮蔽通过深度缓冲估算每个像素被周围几何遮挡的程度。
基本算法:在像素周围的法线半球内取若干采样点,把它们投影回屏幕,比较采样点的深度和深度缓冲里记录的深度。如果记录的深度更近,说明这个方向被遮挡了。
MSLkernel void ssao(texture2d<float> depthTex [[texture(0)]],
texture2d<float> normalTex [[texture(1)]],
texture2d<float, access::write> output [[texture(2)]],
constant SSAOParams ¶ms [[buffer(0)]],
uint2 gid [[thread_position_in_grid]])
{
float3 posVS = reconstructViewPos(gid, depthTex, params);
float3 n = normalTex.read(gid).xyz * 2.0 - 1.0;
float occlusion = 0.0;
for (uint i = 0; i < params.sampleCount; i++) {
float3 samplePos = posVS + orientedHemisphereSample(i, n, gid) * params.radius;
float2 sampleUV = projectToUV(samplePos, params.projection);
float sceneZ = linearDepth(depthTex.sample(s, sampleUV).r, params);
// 范围检查:远处的几何不应该遮挡近处的像素
float rangeCheck = smoothstep(0.0, 1.0, params.radius / abs(posVS.z - sceneZ));
occlusion += (sceneZ >= samplePos.z + params.bias ? 1.0 : 0.0) * rangeCheck;
}
output.write(1.0 - occlusion / params.sampleCount, gid);
}
那个 rangeCheck 很关键。没有它的话,一个近处的物体边缘会在远处的墙上投出一圈假的暗边——因为深度差很大的采样点也被算成了遮挡。
SSAO 的采样数不可能开很大,所以结果一定是噪声的。标准做法是用一个 4×4 的随机旋转纹理让噪声呈高频分布,再用一个 4×4 的模糊滤掉。这样 16 个采样点就能得到相当于 256 个的效果。
用 compute 还是全屏三角形
后处理传统上用全屏三角形加片元着色器。但 compute shader 在这里通常更好:
可以用线程组共享内存。 一个模糊 kernel 里,同一个线程组的线程需要的采样数据大量重叠。先协作把一块数据读进 threadgroup 内存,再从共享内存里读,能把纹理带宽降低好几倍:
MSLkernel void blur_h(texture2d<float> src [[texture(0)]],
texture2d<float, access::write> dst [[texture(1)]],
uint2 gid [[thread_position_in_grid]],
uint2 lid [[thread_position_in_threadgroup]])
{
threadgroup float4 cache[64 + 2 * RADIUS];
// 协作加载,包含两侧的 halo
cache[lid.x + RADIUS] = src.read(gid);
if (lid.x < RADIUS) {
cache[lid.x] = src.read(uint2(gid.x - RADIUS, gid.y));
cache[lid.x + 64 + RADIUS] = src.read(uint2(gid.x + 64, gid.y));
}
threadgroup_barrier(mem_flags::mem_threadgroup);
float4 sum = 0;
for (int i = -RADIUS; i <= RADIUS; i++)
sum += cache[lid.x + RADIUS + i] * kernelWeights[i + RADIUS];
dst.write(sum, gid);
}
可以一次写多个输出。 一个 kernel 可以同时写几张纹理,不受 render target 数量和格式一致性的约束。
可以做真正的 scatter。 片元着色器只能写自己那个像素;compute 可以写任意位置,这让直方图、亮度归约这类操作成为可能。
不需要 render pass 的 load/store 开销。 compute encoder 没有附件,也就没有 tile 的加载和写回。
代价是失去了固定功能的混合和光栅化优化。对于纯 gather 的简单效果(色调映射、色彩分级),全屏三角形仍然可能更快。
组织后处理链
一条典型的链:
Plain TextHDR scene ─> SSAO ─> bloom (down/up) ─> motion blur ─> tonemap + grading ─> FXAA/TAA ─> drawable
用《Metal #9 渲染通道》里说的 MTLHeap 管理中间纹理,让不重叠的中间结果共享内存。另外,把色调映射和色彩分级合并成一个 pass(用 3D LUT 表达整条色彩变换),能省下一次全屏读写——在 4K 下这不是小数目。
下一篇讲反射与折射。