探索 Metal 中的无绑定渲染
Explore bindless rendering in Metal
2021年6月10日
一句话判断
Bindless rendering 是 Metal 的性能终极武器——如果你在做大型 3D 场景(开放世界游戏、CAD),不用它你就永远在跟 draw call 的数量做斗争。
这场 Session 讲了什么
传统 Metal 渲染管线中,每次 draw call 之前你都需要「绑定」资源——把 texture、buffer、sampler 绑定到特定的 binding point 上。当场景中有成千上万个物体时,频繁的绑定操作成为 GPU 管线的瓶颈。Bindless rendering 的思路是:把所有资源一次性放进一个大的资源堆(resource heap),然后在 shader 中通过索引直接访问,不再需要逐个绑定。
Session 详细介绍了三种实现 bindless 的 Metal 特性:Argument Buffers(参数缓冲区)、Heap Resources(堆资源)和 Metal 3 的新特性。Argument Buffers 允许你把多个 texture、buffer 和 sampler 打包到一个 buffer 对象中,通过一个 binding point 传递给 shader。Heap Resources 让你在 GPU 堆上分配和管理资源,支持在同一个堆中混合放置 texture 和 buffer。结合起来,你可以把整个场景的所有资源放进几个大 heap 中,用极少的 draw call 渲染整个场景。
苹果在 A14/M1 及更新的 GPU 上对 Argument Buffers 做了硬件级优化,支持在 shader 中通过 device 地址直接索引资源,性能开销几乎为零。
值得深挖的点
Argument Buffers Tier 2 的硬件差异。 Metal 的 Argument Buffers 有两个 tier:Tier 1 支持有限数量的资源引用,Tier 2 支持几乎无限制的资源数量和更灵活的嵌套结构。A14/M1 及更新的芯片支持 Tier 2,这意味着你可以在一个 Argument Buffer 中引用数千个 texture——足够装下整个游戏场景的所有贴图。通过 MTLDevice.argumentBuffersSupport 查询设备支持的 tier。在 Tier 2 设备上,你可以实现真正的「一次 bind,全场景渲染」:把所有材质、贴图、变换矩阵放进 Argument Buffer,每个物体只需要在 draw call 中传一个索引号。
Sparse Textures 与 Bindless 的配合。 iOS 15 / macOS Monterey 引入了 Sparse Textures(稀疏纹理)支持——你可以创建超大尺寸的 texture(比如 65536x65536),但只在实际使用的区域分配物理内存。这对 bindless 渲染非常有用:你不再需要为每种贴图创建单独的 texture 对象,而是把所有贴图「拼贴」到一个巨大的 sparse texture 中的不同区域,shader 通过坐标偏移来访问。这大幅减少了 texture 对象的数量和管理开销。
代码片段
import Metal
// 创建 Argument Buffer 来存储多个纹理引用
let device = MTLCreateSystemDefaultDevice()!
let commandBuffer = commandQueue.makeCommandBuffer()!
let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor)!
// 假设我们已经创建了多个 texture
let textures: [MTLTexture] = [texture0, texture1, texture2, texture3]
// 创建 Argument Buffer
let argBufferLength = textures.count * MemoryLayout<MTLTexture>.size
let argBuffer = device.makeBuffer(length: argBufferLength, options: .storagePrivate)!
// 使用 Argument Encoder 将纹理编码到 buffer 中
let argEncoder = renderPipelineState.makeArgumentEncoder(bufferIndex: 0)
argEncoder.setArgumentBuffer(argBuffer, offset: 0)
for (i, texture) in textures.enumerated() {
argEncoder.setTexture(texture, index: i)
}
// Metal Shader 中使用 Argument Buffer
// Shader.metal
#include <metal_stdlib>
using namespace metal;
// 定义 Argument Buffer 结构体
struct MaterialData {
texture2d<float> albedoMap [[id(0)]];
texture2d<float> normalMap [[id(1)]];
texture2d<float> roughnessMap [[id(2)]];
sampler bilinearSampler [[id(3)]];
};
fragment float4 fragmentShader(
vertex_out in [[stage_in]],
constant MaterialData &material [[buffer(0)]],
constant uint &materialIndex [[buffer(1)]]
) {
// 通过索引直接访问材质,无需绑定切换
// 每个 draw call 只需传入 materialIndex
float4 albedo = material.albedoMap.sample(material.bilinearSampler, in.uv);
return albedo;
}
// 在堆上分配资源——bindless 的关键步骤
let heapDescriptor = MTLHeapDescriptor()
heapDescriptor.storageMode = .private
heapDescriptor.size = calculateTotalResourceSize()
let heap = device.makeHeap(descriptor: heapDescriptor)!
// 在堆上创建 texture——它们共享同一块内存空间
var heapTextures: [MTLTexture] = []
for textureDescriptor in textureDescriptors {
let texture = heap.makeTexture(descriptor: textureDescriptor)
heapTextures.append(texture!)
}
// 现在可以通过 Argument Buffer 一次性传递所有 texture
最佳实践
- 在开始 bindless 重构前,先用 Metal System Trace 确认你的瓶颈确实是 binding overhead,而不是 fill rate 或 bandwidth
- Argument Buffers 的嵌套不要超过 2 层——太深的嵌套会让调试变得非常困难,而且某些 GPU 上性能收益递减
- 对于移动端(A 系列 GPU),注意 Argument Buffer 的内存占用,大量 texture 引用会增加常量内存压力
- 使用
MTLResourceStatePass来管理 sparse texture 的内存映射,按需加载和卸载贴图区域
还有什么值得关注
- Metal Performance Shaders 已经在内部使用了 bindless 技术,可以作为参考实现
- Xcode 的 Metal Debugger 可以可视化 Argument Buffer 的内容,帮助排查资源索引错误
MTLHeap支持makeAliasable()方法,允许不同资源复用同一块内存(前提是它们不会同时被使用)