T.TAO
返回博客
/8 min read/Graphics Engine

Metal #11 延迟渲染

#ComputerGraphics#GraphicsEngine#Metal

这篇笔记主要写一下延迟渲染,以及 Metal 在 Apple Silicon 上能做而其它 API 做不到的那个版本。

为什么要延迟

前向渲染的代价是 O(物体数 × 光源数)。更糟的是,被遮挡的片元也要跑完整的光照计算,然后被深度测试丢掉——过绘制越严重,浪费越多。

延迟渲染把着色拆成两步:

  1. 几何 pass:把每个像素的表面属性(albedo、法线、粗糙度、深度)写进一组纹理,统称 G-Buffer。这一步只写属性,不算光照。
  2. 光照 pass:对屏幕上的每个像素,从 G-Buffer 读回属性,算一次光照。

这样每个像素只着色一次,与场景复杂度无关。复杂度变成 O(物体数 + 光源数 × 覆盖像素)。几百个动态光源忽然变得可行。

G-Buffer 的组织

G-Buffer 的设计是延迟渲染最核心的权衡:存得越多越灵活,但带宽开销线性增长。

一个务实的布局:

附件格式内容
0.rgba8Unorm_srgbalbedo.rgb, AO
1.rgba16Floatnormal.xy(八面体编码), roughness, metallic
2.depth32Float深度

有几个值得注意的决定。

不存世界坐标。 位置可以从深度和屏幕坐标反推出来,存它是纯粹浪费 12 字节/像素。

MSLfloat3 reconstructWorldPos(float2 uv, float depth, float4x4 invViewProj) {
    float4 ndc = float4(uv * 2.0 - 1.0, depth, 1.0);
    ndc.y = -ndc.y;
    float4 world = invViewProj * ndc;
    return world.xyz / world.w;
}

法线用八面体编码。 单位向量只有两个自由度,存三个分量是冗余的。八面体映射把单位球面映射到一个正方形,用两个 16 位分量就能得到比三个 8 位分量好得多的精度:

MSLfloat2 octEncode(float3 n) {
    n /= (abs(n.x) + abs(n.y) + abs(n.z));
    float2 e = n.xy;
    if (n.z < 0) e = (1.0 - abs(e.yx)) * sign(e);
    return e * 0.5 + 0.5;
}

G-Buffer 的 storeAction。 如果光照 pass 在同一帧、同一个 encoder 之后立刻消费它们,那么在 Apple GPU 上可以设为 .dontCare——这正是下面要说的那件事。

光照 pass

光照 pass 画一个全屏三角形,读 G-Buffer,对每个光源累加。

用一个三角形而不是两个组成的四边形,是因为四边形的对角线上那些 quad 会被两个三角形各执行一次,产生一条浪费的接缝。一个覆盖整个屏幕的超大三角形没有这个问题,而且不需要顶点缓冲:

MSLvertex VertexOut fullscreen_vertex(uint vid [[vertex_id]]) {
    float2 uv = float2((vid << 1) & 2, vid & 2);
    VertexOut out;
    out.position = float4(uv * float2(2, -2) + float2(-1, 1), 0, 1);
    out.uv = uv;
    return out;
}

对于有 range 的点光源和聚光灯,还可以进一步优化:不画全屏,而是画光源的包围球/包围锥,只有球内的像素才会被着色。配合模板测试可以避免对球外和球后的像素做无用功。

延迟渲染的代价

延迟渲染不是免费的:

  • 半透明物体无法处理。 G-Buffer 每个像素只能存一个表面。透明物体必须在光照 pass 之后用前向渲染单独画一遍,于是引擎里要维护两套着色代码路径。
  • MSAA 变得非常昂贵。 硬件 MSAA 在几何阶段工作,延迟渲染的着色发生在之后,要正确做 MSAA 就得在光照 pass 里对每个采样点着色。这也是延迟渲染的引擎普遍改用 FXAA 或 TAA 的原因。
  • 带宽消耗大。 写 G-Buffer 再读回来,是一笔实打实的显存流量。在移动设备上这往往是决定性的劣势。
  • 材质模型受限。 G-Buffer 的布局是固定的,很难支持需要额外参数的特殊材质(皮肤、头发、布料),通常要靠额外的 material ID 加分支。

Apple Silicon:单 pass 延迟渲染

前面那条"带宽消耗大"的劣势,在 Apple GPU 上可以几乎完全消除。

Apple 的 TBDR 架构里,每个 tile 的附件数据在渲染期间都在片上内存中。Metal 暴露了这一点:同一个 render pass 内的后续绘制可以直接读取 tile memory 里的附件值,不需要把 G-Buffer 写回显存再读回来。

这需要三件事配合:

1. G-Buffer 纹理声明为 memoryless。

Swiftd.storageMode = .memoryless
d.usage = .renderTarget

它们完全不占显存。

2. 几何 pass 和光照 pass 放在同一个 render pass descriptor 里,作为两组不同的绘制,中间不 endEncoding()

3. 光照 pass 的片元函数用 [[color(n)]] 输入读回附件值,而不是用纹理采样:

MSLstruct GBufferData {
    float4 albedo   [[color(0)]];
    float4 normal   [[color(1)]];
    float  depth    [[color(2)]];
};

fragment float4 deferred_lighting(VertexOut in [[stage_in]],
                                  GBufferData gbuffer,          // 直接从 tile memory 读
                                  constant Light *lights [[buffer(13)]])
{
    float3 n = octDecode(gbuffer.normal.xy);
    // ... 光照计算 ...
}

结果是 G-Buffer 从来没有离开过芯片。带宽成本降到接近零,而且那 32 MB 的显存也省下来了。这是 Metal 相对其它 API 的一个真实优势——Vulkan 的 subpass 机制有类似意图,但在桌面 GPU 上并不会真的把数据留在片上。

更进一步,还可以用 tile shading:在同一个 pass 里插入 compute dispatch,直接对 tile memory 操作。分块光源剔除就可以这样实现——一个 tile 的光源列表算好之后立即被同一个 tile 的光照绘制消费,全程不碰显存。

什么时候不用延迟渲染

现在的趋势其实是往回走。Forward+(分块前向)保留了前向渲染对半透明和 MSAA 的支持,又用 compute 的光源剔除解决了多光源问题。在 Apple Silicon 上,因为硬件本来就按 tile 工作,Forward+ 的实现相当自然,往往是比延迟渲染更好的默认选择。

延迟渲染依然值得用的场景:光源数量极多且大部分是小范围的动态光(城市夜景),或者需要大量屏幕空间效果(SSAO、SSR、屏幕空间阴影)——这些效果本来就需要一份深度和法线缓冲,G-Buffer 顺手就有了。

下一篇讲粒子系统,会第一次用到 compute shader 做模拟。

本系列文章

Metal
  1. 01Metal #0 Swift 回顾
  2. 02Metal #1 初始化
  3. 03Metal #2 渲染管线
  4. 04Metal #3 顶点函数
  5. 05Metal #4 片元函数
  6. 06Metal #5 纹理
  7. 07Metal #6 摄像机与交互
  8. 08Metal #7 光照
  9. 09Metal #8 材质
  10. 10Metal #9 渲染通道
  11. 11Metal #10 阴影
  12. 12Metal #11 延迟渲染
  13. 13Metal #12 粒子系统
  14. 14Metal #13 曲面细分
  15. 15Metal #14 后处理
  16. 16Metal #15 反射与折射
  17. 17Metal #16 动画
  18. 18Metal #17 光线追踪(一)渲染算法
  19. 19Metal #18 光线追踪(二)阴影与光照
  20. 20Metal #19 光线追踪(三)性能优化
  21. 21Metal #21 [附录] 计算着色器
  22. 22Metal #22 [附录] SwiftUI 中的 Metal