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

Metal #9 渲染通道

#ComputerGraphics#GraphicsEngine#Metal

这篇笔记主要写一下 render pass:怎么渲染到纹理而不是屏幕,以及 Metal 里那组看起来不起眼、实际决定性能的 action 设置。

一个 render pass 是什么

一个 MTLRenderCommandEncoder 对应一个 render pass,它绑定一组附件(color、depth、stencil),在这些附件上执行一系列绘制,然后结束。想渲染到不同的目标,就得开一个新的 encoder。

Swiftlet descriptor = MTLRenderPassDescriptor()
descriptor.colorAttachments[0].texture     = offscreenTexture
descriptor.colorAttachments[0].loadAction  = .clear
descriptor.colorAttachments[0].storeAction = .store
descriptor.colorAttachments[0].clearColor  = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)

descriptor.depthAttachment.texture     = depthTexture
descriptor.depthAttachment.loadAction  = .clear
descriptor.depthAttachment.storeAction = .dontCare      // 后面不需要深度了
descriptor.depthAttachment.clearDepth  = 1.0

let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor)!
// ... 绘制 ...
encoder.endEncoding()

load / store action:TBDR 上最重要的开关

这两个设置在桌面 GPU 上影响不大,但在 Apple 的分块延迟渲染(TBDR)架构上,它们直接决定了显存带宽。

Apple GPU 把屏幕切成小块(tile),每块的附件数据在渲染期间完全放在片上内存里——那是一块很小但极快的 SRAM。一个 pass 的流程是:

  1. load:把附件从显存读进 tile memory;
  2. 在 tile memory 里执行所有绘制;
  3. store:把 tile memory 写回显存。

load 和 store action 控制的正是第 1 步和第 3 步:

loadAction含义
.clear不读显存,直接把 tile memory 填成清除值。最快
.load从显存读入已有内容。有带宽成本
.dontCare内容未定义。当你保证会写满每个像素时最快
storeAction含义
.store写回显存
.dontCare丢弃。零带宽
.multisampleResolve解析 MSAA 后只写回单采样结果

关键结论:深度缓冲几乎总是应该用 .dontCare 作为 storeAction。 深度只在本 pass 内部用于可见性判断,除非后续 pass 要读它(SSAO、软粒子),否则把它写回显存是纯浪费。一个 4K 的 depth32 缓冲是 32 MB,每帧多写一遍就是 2 GB/s 的带宽。

同理,MSAA 的颜色附件应该用 .multisampleResolve 而不是 .store:4× MSAA 的原始附件是解析后的四倍大,把它写回显存毫无意义。

Swiftdescriptor.colorAttachments[0].texture              = msaaTexture
descriptor.colorAttachments[0].resolveTexture       = drawable.texture
descriptor.colorAttachments[0].storeAction          = .multisampleResolve

这些设置改起来只有几行,收益却经常有百分之十几。写 Metal 的时候养成习惯:每开一个 render pass,先想清楚每个附件的这两个 action 该填什么。

离屏渲染

渲染到纹理是阴影贴图、后处理、反射探针的基础。目标纹理必须声明 .renderTarget 用途:

Swiftfunc makeRenderTarget(size: CGSize, format: MTLPixelFormat) -> MTLTexture {
    let d = MTLTextureDescriptor.texture2DDescriptor(
        pixelFormat: format,
        width: Int(size.width), height: Int(size.height),
        mipmapped: false)
    d.usage       = [.renderTarget, .shaderRead]
    d.storageMode = .private
    return device.makeTexture(descriptor: d)!
}

storageMode = .private 很重要:这张纹理只被 GPU 使用,CPU 不需要看到它,驱动因此可以用最优的内存布局(比如压缩的 tile 格式)。

在 iOS 和 Apple Silicon 上还有一个更强的选项:.memoryless。这种纹理完全不占显存,只存在于 tile memory 中。深度缓冲、MSAA 中间附件、G-Buffer 的中间层都可以是 memoryless,前提是它们只在同一个 pass 内被使用。

Swiftd.storageMode = .memoryless
d.usage = .renderTarget        // memoryless 不能被 shaderRead(跨 pass)

一个 4K 的深度缓冲从 32 MB 变成 0 MB,这在移动设备上是实打实的收益。

一帧的组织

一个典型的帧是若干个 pass 串起来:

Swiftfunc draw(in view: MTKView) {
    guard let commandBuffer = commandQueue.makeCommandBuffer(),
          let drawable = view.currentDrawable else { return }

    // Pass 1: 阴影贴图
    encodeShadowPass(commandBuffer)

    // Pass 2: 主渲染,输出到 HDR 离屏纹理
    encodeMainPass(commandBuffer, target: hdrTexture)

    // Pass 3: 泛光(可能内含多个子 pass)
    encodeBloomPass(commandBuffer)

    // Pass 4: 色调映射 + 输出到 drawable
    encodeTonemapPass(commandBuffer, target: drawable.texture)

    commandBuffer.present(drawable)
    commandBuffer.commit()
}

几点值得注意:

所有 pass 共用一个 command buffer。 没必要为每个 pass 建一个——一个 command buffer 的提交有固定开销,而且同一个 buffer 里的 pass 之间,Metal 会自动插入需要的同步。

pass 之间的依赖是隐式的。 Metal 会追踪资源的读写关系,自动插入屏障。这和 Vulkan 需要手写 barrier 形成对比——写起来轻松很多,但也意味着你不太容易看出同步开销在哪里,需要靠 GPU 帧捕获来分析。

currentDrawable 要尽可能晚地获取。 它可能会阻塞等待一个可用的 drawable。所有不依赖它的 pass 都应该在获取之前编码好。

HDR 中间纹理用 .rgba16Float 直接渲染到 8 位的 drawable 会让泛光和色调映射失去意义,因为超过 1 的亮度值已经被截断了。

用 MTLHeap 管理瞬态资源

后处理链里有很多"用一下就扔"的中间纹理。每帧创建销毁它们开销不小,全都长期持有又浪费显存。MTLHeap 是这个问题的答案——一块预分配的内存,纹理可以在里面别名(alias):

Swiftlet heapDescriptor = MTLHeapDescriptor()
heapDescriptor.size = 64 * 1024 * 1024
heapDescriptor.storageMode = .private
let heap = device.makeHeap(descriptor: heapDescriptor)!

let tempA = heap.makeTexture(descriptor: descA)!
// tempA 用完之后
tempA.makeAliasable()
// 现在这段内存可以被新纹理复用
let tempB = heap.makeTexture(descriptor: descB)!

生命周期不重叠的两张纹理可以共用同一块物理内存。在一条复杂的后处理链上,这经常能把中间纹理的显存占用砍掉一半以上。这也正是现代引擎里 render graph 系统所做的事——分析 pass 之间的依赖关系,自动做资源别名和 action 推导。

下一篇用阴影贴图来实际走一遍多 pass 流程。

本系列文章

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