WWDC Quick Look 💓 By SwiftGGTeam
Maximize your Metal ray tracing performance

Maximize your Metal ray tracing performance

观看原视频

Highlight

这场 Session 深入讲解了 Metal 3 中光线追踪相关的性能优化手段。如果你去年已经在用 Metal ray tracing,今年这几项改进可以让你的渲染管线跑得更快、支持更大的场景。

核心内容

光线追踪的成本主要来自两件事:发射大量光线,以及在场景里反复查找光线命中的几何体。Metal 的 ray tracing API 已经把这条路径放进 shader 函数里,开发者通常在 compute 或 fragment function 里生成 ray,再用 intersector 查询 acceleration structure(加速结构)。

00:57)这套流程能做反射、阴影和全局光照,但每一帧会执行大量相交测试。一个场景越大,intersection function(相交函数)和 acceleration structure build(加速结构构建)越容易成为热路径。

Metal 3 的改动集中在这些热路径上。第一类改动减少 shader 里的内存访问。第二类改动加快 acceleration structure 的 build、refit 和并行调度。第三类改动让 Xcode 14 能检查每个 primitive、每行 shader 代码和 shader 栈溢出。

这场 session 的主线很明确:把每次光线命中时必须读的数据放到更近的位置,把每帧必须构建的数据拆成更适合 GPU 执行的形式,再用工具确认瓶颈是否真的消失。

详细内容

用 per-primitive data 减少相交函数的间接访问

04:04)演讲从 alpha testing 开始。透明贴图常见于树叶、栅栏、头发等模型。相交函数需要根据纹理 alpha 决定这次 triangle hit 是否成立。

最小逻辑很简单。

float alpha = texture.sample(sampler, UV).w;
return alpha >= 0.5f;

关键点:

  • texture.sample(sampler, UV) 用当前 UV 对贴图采样。
  • .w 取 alpha 通道。
  • alpha >= 0.5f 决定这次命中是否通过 alpha test。

05:46)问题出在真实相交函数需要先找到纹理和 UV。旧写法会从 instance data 和 material buffer 里绕几层。

[[intersection(triangle, raytracing::triangle_data, raytracing::instancing)]]
bool alphaTestIntersection(float2               coordinates    [[barycentric_coord]],
                           unsigned int         primitiveIndex [[primitive_id]],
                           unsigned int         instanceIndex  [[instance_id]],
                           device GlobalData   *globalData     [[buffer(1)]],
                           device InstanceData *instanceData   [[buffer(0)]])
{
    device Material *materials = globalData->materials;
    InstanceData instance = instanceData[instanceIndex];
    float2 UV = calculateSamplingCoords(coordinates,
                                        instance.uvs[primitiveIndex * 3 + 0],
                                        instance.uvs[primitiveIndex * 3 + 1],
                                        instance.uvs[primitiveIndex * 3 + 2]);
    int materialIndex = instance.materialIndices[primitiveIndex];
    float alpha = materials[materialIndex].texture.sample(sam, UV).w;
    return alpha >= 0.5f;
}

关键点:

  • [[intersection(... )]] 声明这是 triangle ray tracing 使用的相交函数。
  • coordinates 提供 barycentric coordinates,用来插值三角形顶点属性。
  • primitiveIndexinstanceIndex 用来定位当前 primitive 与 instance。
  • globalData->materials 先拿到全局材质数组。
  • instanceData[instanceIndex] 再取当前 instance 的 UV 和材质索引。
  • calculateSamplingCoords 用 primitive 的三个 UV 插值得到采样坐标。
  • instance.materialIndices[primitiveIndex] 查到材质索引。
  • materials[materialIndex].texture.sample(...) 继续跳到材质,再读纹理 alpha。

06:48)Metal 3 增加了 per-primitive data。开发者可以把相交函数真正需要的数据直接存进 acceleration structure。演讲的例子只保存 texture 和三个顶点的 UV。

struct PrimitiveData
{
    texture2d<float> texture;
    float2 uvs[3];
};

关键点:

  • PrimitiveData 是每个 primitive 关联的一小块数据。
  • texture2d<float> texture 把 alpha test 需要的贴图句柄放在 primitive 上。
  • float2 uvs[3] 保存三角形三个顶点的 UV。
  • 演讲提醒,这里可以存任意数据,但保持尺寸较小有助于获得更好性能。

07:08)相交函数因此变短。它不再接收 global data、instance data、primitive id 和 instance id,只读取一个 primitive data 指针。

// Alpha testing intersection function
[[intersection(triangle, raytracing::triangle_data, raytracing::instancing)]]
bool alphaTestIntersection(float2               coordinates    [[barycentric_coord]],
                     const device PrimitiveData *primitiveData [[primitive_data]])
{
    PrimitiveData ppd = *primitiveData;
    float2 UV = calculateSamplingCoords(coordinates,
                                        ppd.uvs[0],
                                        ppd.uvs[1],
                                        ppd.uvs[2]);
    float alpha = ppd.texture.sample(sam, UV).w;
    return alpha >= 0.5f;
}

关键点:

  • [[primitive_data]] 让相交函数直接接收当前 primitive 的数据指针。
  • PrimitiveData ppd = *primitiveData 是这个函数唯一需要的 device memory load。
  • ppd.uvs[0...2] 直接提供三角形 UV,避免从 instance data 继续解引用。
  • ppd.texture.sample(sam, UV).w 直接使用 primitive 绑定的纹理采样 alpha。
  • 返回值仍然只表达一件事:这次 hit 是否通过 alpha test。

08:54)要把这块数据放进 acceleration structure,需要在 geometry descriptor 上设置 buffer、element size、stride 和 offset。

geometryDescriptor.primitiveDataBuffer = primitiveDataBuffer
geometryDescriptor.primitiveDataElementSize = MemoryLayout<PrimitiveData>.size
geometryDescriptor.primitiveDataStride = MemoryLayout<PrimitiveData>.stride
geometryDescriptor.primitiveDataBufferOffset = primitiveDataOffset

关键点:

  • primitiveDataBuffer 指向保存所有 primitive data 的 Metal buffer。
  • primitiveDataElementSize 说明每个 primitive 存多少字节。
  • primitiveDataStride 处理 buffer 内数据未紧密排列的情况。
  • primitiveDataBufferOffset 处理 primitive data 不从 buffer 开头存放的情况。

09:18)per-primitive data 不只给相交函数用。它也可以从 intersection result 和 intersection query 中取到,用于 shading。

// Intersection function argument:
const device void *primitiveData [[primitive_data]]

// Intersection result:
primitiveData = intersection.primitive_data;

// Intersection query:
primitiveData = query.get_candidate_primitive_data();
primitiveData = query.get_committed_primitive_data();

关键点:

  • [[primitive_data]] 是相交函数参数的入口。
  • intersection.primitive_data 从最终 intersection result 读取同一份数据。
  • get_candidate_primitive_data() 读取候选命中的 primitive data。
  • get_committed_primitive_data() 读取已确认命中的 primitive data。

Apple 在自己的测试应用中看到 per-primitive data 带来 10% 到 16% 的性能提升。这个数字来自减少内存访问和指针间接引用;alpha test 的判定逻辑没有变化。

复用 intersection function table 上的 buffer

10:18)ray tracing kernel 和 intersection function 经常需要同一批资源。旧流程里,应用会把资源绑定到 intersection function table,同时再把同一份资源绑定给主 ray tracing kernel。

Metal 3 的 Metal Shading Language 允许 shader 从 intersection function table 直接取 buffer 和 visible function table。

device int *buffer = intersectionFunctionTable.get_buffer<device int *>(index);

visible_function_table<uint(uint)> table =
    intersectionFunctionTable.get_visible_function_table<uint(uint)>(index);

uint result = table[0](parameter);

关键点:

  • get_buffer<device int *>(index) 从 intersection function table 上按索引读取已绑定 buffer。
  • get_visible_function_table<uint(uint)>(index) 按函数类型读取 visible function table。
  • table[0](parameter) 调用 table 中的 visible function。
  • 这个能力减少重复绑定,适合 intersection function 与主 kernel 共用资源的场景。

从 indirect command buffer 发起光线追踪

11:15)Indirect command buffer(间接命令缓冲)用于 GPU-driven pipeline。Metal 3 让它支持 ray tracing。开启方式只有一个 descriptor flag。

let icbDescriptor = MTLIndirectCommandBufferDescriptor()

icbDescriptor.supportRayTracing = true

关键点:

  • MTLIndirectCommandBufferDescriptor() 创建 ICB 描述对象。
  • supportRayTracing = true 声明这个 ICB 会调度使用 ray tracing 的 graphics 或 compute function。
  • ray tracing 仍然在这些函数里按常规方式使用。

这个改动对 GPU-driven renderer 有用。应用可以让 GPU 编码后续工作,再在被调度的 shader 中执行 ray tracing 查询。

加快 acceleration structure 的 build、refit 和并行执行

13:16)游戏加载新关卡时,通常会加载模型和纹理,也会为模型构建 primitive acceleration structures。进入主循环后,动态角色和动画模型常用 refit 更新;场景物体新增、移除或大幅移动时,instance acceleration structure 通常需要完整 rebuild。

14:39)Metal 3 在 Apple Silicon 上把 acceleration structure build 提升到最高 2.3 倍,refit 提升到最高 38%。对很多小型 primitive acceleration structures,Metal 还会尽量并行执行 build、compact 和 refit,最高可达到 2.8 倍 build 提升。

15:43)要得到并行收益,多个 build 需要使用同一个 acceleration structure command encoder,并且不能共用同一个 scratch buffer。演讲给出的模式是使用一小组 scratch buffers 轮换。

for (index, accelerationStructure) in accelerationStructures.enumerated() {
    encoder.build(accelerationStructure: accelerationStructure,
                  descriptor: descriptors[index],
                  scratchBuffer: scratchBuffers[index % numScratchBuffers],
                  scratchBufferOffset: 0)
}

关键点:

  • accelerationStructures.enumerated() 遍历一组需要构建的 acceleration structures。
  • encoder.build(...) 在同一个 encoder 上连续编码多个 build。
  • descriptors[index] 为每个 acceleration structure 提供对应描述。
  • scratchBuffers[index % numScratchBuffers] 从 scratch buffer 池中轮换,避免所有 build 抢同一块 scratch buffer。
  • scratchBufferOffset: 0 表示每个 scratch buffer 从起始位置使用。

直接使用更紧凑的 vertex format

16:29)以前 acceleration structure 要求三分量、全精度浮点顶点数据。使用 half precision、normalized integer 或二维平面几何时,应用可能要先解包并复制到临时 buffer,只为了构建 acceleration structure。

Metal 3 允许 acceleration structure build 直接消费更多 vertex formats。设置入口在 triangle geometry descriptor 上。

let geometryDescriptor = MTLAccelerationStructureTriangleGeometryDescriptor()

geometryDescriptor.vertexFormat = .uint1010102Normalized

关键点:

  • MTLAccelerationStructureTriangleGeometryDescriptor() 描述一组三角形几何。
  • vertexFormat 声明 vertex buffer 中顶点的实际格式。
  • .uint1010102Normalized 是演讲示例中的紧凑 normalized integer 格式。
  • build 阶段可以直接读取该格式,避免为 acceleration structure 额外准备全精度临时副本。

给 geometry descriptor 设置 transformation matrix

17:37)新的 transformation matrix 能和紧凑 vertex format 配合使用。举例来说,模型可以把坐标归一化到 0 到 1 范围,用 normalized integer 存储;运行时再提供 scale 和 offset 矩阵,把顶点变回原始位置。

var scaleTransform =
    MTLPackedFloat4x3(columns: (
        MTLPackedFloat3Make( scale.x,      0.0,      0.0),
        MTLPackedFloat3Make(     0.0,  scale.y,      0.0),
        MTLPackedFloat3Make(     0.0,      0.0,  scale.z),
        MTLPackedFloat3Make(offset.x, offset.y, offset.z))

let transformBuffer = device.makeBuffer(length: MemoryLayout<MTLPackedFloat4x3>.size,
                                        options: .storageModeShared)!

transformBuffer.contents().copyMemory(from: &scaleTransform,
                                      byteCount: MemoryLayout<MTLPackedFloat4x3>.size)

关键点:

  • MTLPackedFloat4x3 保存 build acceleration structure 时使用的 4x3 变换矩阵。
  • 前三列保存 scale.xscale.yscale.z
  • 第四列保存 offset.xoffset.yoffset.z
  • makeBuffer 创建能容纳该矩阵的 Metal buffer。
  • copyMemory 把 CPU 侧矩阵复制到 buffer 中。

18:51)然后把这个 buffer 交给 geometry descriptor。

let geometryDescriptor = MTLAccelerationStructureTriangleGeometryDescriptor()

geometryDescriptor.transformationMatrixBuffer = transformBuffer
geometryDescriptor.transformationMatrixBufferOffset = 0

关键点:

  • transformationMatrixBuffer 指向保存矩阵的 buffer。
  • transformationMatrixBufferOffset 指明矩阵在 buffer 内的起始位置。
  • build acceleration structure 时,Metal 会用该矩阵预变换 vertex data。

19:05)同一个能力还能减少 instance 数量。对于一组简单且重叠的 boxes 和 sphere,可以给每个 geometry descriptor 设置自己的 transformation matrix,再合并到一个 primitive acceleration structure。演讲说明,这样生成的 primitive acceleration structure build 时间更少,intersect 也更快。

从 heap 分配 acceleration structure

21:04)Metal 3 支持从 heap 分配 acceleration structure。这样可以复用 heap memory,避免昂贵的 buffer allocations,并减少 useResource: 调用。

在大型场景里,instance acceleration structure 会间接引用很多 primitive acceleration structures。使用 command encoder 时,旧方式需要为每个 primitive acceleration structure 调用 useResource:。如果它们都来自同一个 heap,可以用一次 useHeap: 覆盖这些资源。

22:33)分配方式有两种。第一种直接让 heap 按 descriptor 创建 acceleration structure。第二种先向 device 查询 size 和 alignment,再按 size 创建。

let heap = device.makeHeap(descriptor: heapDescriptor)!

let accelerationStructure = heap.makeAccelerationStructure(descriptor: descriptor)

let sizeAndAlign = device.heapAccelerationStructureSizeAndAlign(descriptor: descriptor)

let accelerationStructure = heap.makeAccelerationStructure(size: sizeAndAlign.size)

关键点:

  • makeHeap(descriptor:) 创建用于资源分配的 heap。
  • heap.makeAccelerationStructure(descriptor:) 用 descriptor 直接在 heap 中创建 acceleration structure。
  • heapAccelerationStructureSizeAndAlign(descriptor:) 查询该 descriptor 对应的 size 和 alignment 需求。
  • heap.makeAccelerationStructure(size:) 使用最终 size 从 heap 分配 acceleration structure。

使用 heap 时还要处理 residency 和同步。演讲提醒:ray tracing pass 期间要调用 useHeap:;heap 资源默认不由 Metal 跟踪 hazard,可以选择开启 resource hazard tracking,也可以手动用 MTLFencesMTLEvents 同步。

用 Xcode 14 工具验证性能和正确性

24:16)Xcode 14 的 Metal Debugger 为 ray tracing 增加了几项工具支持。

Acceleration Structure Viewer 可以查看 primitive 或 instanced motion。它还增加了 primitive highlight mode,能展开每个 primitive 的数据。这直接对应本场 session 前半段的 per-primitive data。

25:55)Shader Profiler 支持 intersection functions、visible functions 和 dynamic libraries。开发者可以看到 ray tracing pipeline 中每行代码的执行成本,并按指令类别拆解。

27:02)Shader Debugger 也能进入 linked functions 和 dynamic libraries。使用 visible function table 或动态库时,调试器可以跟进实际执行的函数。

28:05)Runtime Shader Validation 能诊断 GPU 运行时错误,例如越界内存访问和空 texture 读取。Metal 3 还增加了 stack overflow detection。如果 shader 里有 intersection function、visible function、dynamic library 或函数指针调用,开发者需要给 pipeline descriptor 设置足够的 maximum call stack depth。设置过低时,Shader Validation 会指出 stack overflow 发生的位置。

核心启发

  • 做什么:为使用 alpha-tested geometry 的 renderer 改造相交函数。 为什么值得做:per-primitive data 把 texture 和 UV 放到 acceleration structure 中,演讲中的测试应用获得 10% 到 16% 的性能提升。 怎么开始:定义较小的 PrimitiveData,在 MTLAccelerationStructureTriangleGeometryDescriptor 上设置 primitiveDataBufferprimitiveDataElementSizeprimitiveDataStrideprimitiveDataBufferOffset

  • 做什么:把关卡加载阶段的大量 primitive acceleration structure build 合并到同一个 encoder 中。 为什么值得做:Metal 3 会尽量并行执行 build、compact 和 refit,小 build 数量多时能提高 GPU 利用率。 怎么开始:用同一个 acceleration structure command encoder 编码多个 build,并准备一个 scratch buffer 池,避免所有 build 共用同一块 scratch buffer。

  • 做什么:让压缩顶点格式直接参与 acceleration structure build。 为什么值得做:新 vertex format 支持减少临时全精度顶点副本,降低内存占用和准备成本。 怎么开始:在 MTLAccelerationStructureTriangleGeometryDescriptor 上设置 vertexFormat,必要时配合 transformationMatrixBuffer 还原模型坐标。

  • 做什么:把多个简单重叠实例合并成一个 primitive acceleration structure。 为什么值得做:演讲指出减少 instance count 可以减少 ray 命中 instance 时的 transform 和切换开销。 怎么开始:为每个对象创建独立 geometry descriptor,设置各自的 transformation matrix,再把这些 descriptors 放进同一个 MTLPrimitiveAccelerationStructureDescriptor

  • 做什么:把大型场景的 primitive acceleration structures 放进同一个 heap。 为什么值得做:一次 useHeap: 可以替代大量 useResource: 调用,并复用 heap memory。 怎么开始:用 device.makeHeap(descriptor:) 创建 heap,通过 heap.makeAccelerationStructure(...) 分配 acceleration structure,在 ray tracing pass 前调用 useHeap:

关联 Session

评论

GitHub Issues · utterances