WWDC Quick Look 💓 By SwiftGGTeam
Load resources faster with Metal 3

Load resources faster with Metal 3

观看原视频

Highlight

GPU 软件工程师 Jaideep Joshi 介绍了 Metal 3 的 Fast Resource Loading 功能,这是一个专门为游戏和图形密集型 App 设计的异步资源加载 API。


核心内容

大型游戏的资源加载有一个很直接的问题:资源越精细,玩家等待越久。关卡开始前先把纹理、音频、几何数据全部读进内存,可以避免运行中卡顿,但会拉长 loading screen,也会占用更多内存。

Metal 3 的 Fast Resource Loading 解决的是这条链路。它把资源加载变成异步 IO 命令,App 发出加载请求后不必阻塞当前线程,可以继续编码渲染或计算工作。IO 命令队列还可以并发执行多个 command buffer,更适合高速 SSD 和 Apple Silicon 的统一内存架构。

这场 session 的核心场景是流式加载。玩家靠近黑板时,游戏再加载更高分辨率的纹理;开放世界地图按区域预加载;音效比大纹理更紧急时,音频资源走高优先级队列。共同目标是让游戏只加载当前需要的数据,并尽量在玩家察觉前完成。

另一个重点是粒度。Apple 建议把加载请求拆小,例如用 sparse texture 按 tile 加载可见区域,而不是一次加载完整 mip level。请求数量会变多,但现代存储硬件可以并发处理,Metal 3 的 IO 队列正是为这种模式设计的。

详细内容

打开资源文件

05:19)Fast Resource Loading 的第一步是创建文件句柄。这个句柄来自 MTLDevice,后续所有 load 命令都通过它读取文件内容。

// Create an Metal File IO Handle

// Create handle to an existing file
var fileIOHandle: MTLIOFileHandle!
do {
    try fileHandle = device.makeIOHandle(url: filePath)
} catch {
    print(error)
}

关键点:

  • MTLIOFileHandle 表示 Metal IO 系统可以读取的文件句柄。
  • device.makeIOHandle(url:) 用 Metal device 打开已有文件。
  • catch 分支处理文件打开失败,例如路径错误或资源不可访问。
  • 官方片段里声明名是 fileIOHandle,赋值处写成 fileHandle;实际项目中应保持变量名一致。

创建 IO 命令队列

06:49)加载命令不会直接提交到渲染队列,而是提交到 MTLIOCommandQueue。默认队列是 concurrent,也可以改成 serial,让 command buffer 按顺序执行。

// Create a Metal IO command queue

let commandQueueDescriptor = MTLIOCommandQueueDescriptor()

commandQueueDescriptor.type = MTLIOCommandQueueType.concurrent // or serial

var ioCommandQueue: MTLIOCommandQueue!
do {
    try ioCommandQueue = device.makeIOCommandQueue(descriptor: commandQueueDescriptor)
} catch {
    print(error)
}

关键点:

  • MTLIOCommandQueueDescriptor 用来描述 IO 队列的行为。
  • type = .concurrent 允许多个 IO command buffer 并发执行并乱序完成。
  • 如果资源有严格顺序要求,可以使用 serial 队列。
  • device.makeIOCommandQueue(descriptor:) 根据描述符创建 Metal IO 队列。

提交纹理和缓冲区加载

07:17)IO 队列创建后,App 从队列生成 command buffer,再向里面编码加载命令。Metal 3 支持三类加载:加载到 texture、加载到 buffer、加载到 CPU 可访问内存。

// Create Metal IO Command Buffer

let ioCommandBuffer = ioCommandQueue.makeCommandBuffer()

// Encode load commands
// Encode load texture and load buffer commands
ioCommandBuffer.load(texture, slice: 0, level: 0, size: size, 
                     sourceBytesPerRow:bytesPerRow, sourceBytesPerImage: bytesPerImage,  
                     destinationOrigin: destOrigin,
                     sourceHandle: fileHandle, sourceHandleOffset: 0)
ioCommandBuffer.load(buffer, offset: 0, size: size,
                     sourceHandle: fileHandle, sourceHandleOffset: 0)


// Commit command buffer for execution
ioCommandBuffer.commit()

关键点:

  • makeCommandBuffer() 从 IO 队列创建一个加载批次。
  • 第一个 load 把文件数据加载到 Metal texture,可用于纹理流式加载。
  • sliceleveldestinationOrigin 指定写入纹理的区域。
  • 第二个 load 把文件数据加载到 Metal buffer,可用于几何数据或场景数据。
  • sourceHandleOffset 指定从文件的哪个偏移开始读取。
  • commit() 提交加载工作,调用线程不需要等待加载完成。

用 shared event 同步加载和渲染

08:51)异步加载带来一个新问题:渲染不能在资源就绪前读取它。Metal 3 使用 MTLSharedEvent 在 IO command buffer 和图形 command buffer 之间建立同步。

var sharedEvent: MTLSharedEvent!
sharedEvent = device.makeSharedEvent()

// Create Metal IO command buffer
let ioCommandBuffer = ioCommandQueue.makeCommandBuffer()

ioCommandBuffer.waitForEvent(sharedEvent, value: waitVal)

// Encode load commands

ioCommandBuffer.signalEvent(sharedEvent, value: signalVal)
ioCommandBuffer.commit()

// Graphics work waits for the IO command buffer to signal

关键点:

  • device.makeSharedEvent() 创建一个可以跨队列使用的同步对象。
  • waitForEvent(_:value:) 让 IO command buffer 等待某个事件值后再执行。
  • 加载命令编码在 wait 和 signal 之间。
  • signalEvent(_:value:) 会在 IO command buffer 内所有加载命令完成后发出信号。
  • 对应的图形 command buffer 等待同一个事件值,就能避免读取未加载完成的资源。

取消不再需要的加载

10:29)开放世界游戏常会预加载玩家可能前往的区域。玩家改变方向后,某些预加载就失去价值。Metal 3 允许在 command buffer 粒度尝试取消这些 IO 工作。

// Player in the center region
// Encode loads for the North-West region
ioCommandBufferNW.commit()
// Encode loads for the West region
ioCommandBufferW.commit()
// Encode loads for the South-West region
ioCommandBufferSW.commit()

// Player in the western region and heading south
// tryCancel NW command buffer
ioCommandBufferNW.tryCancel()

// ..
// ..
func regionNWCancelled() -> Bool {
    return ioCommandBufferNW.status == MTLIOStatus.cancelled
}

关键点:

  • 每个区域的预加载可以放进独立 IO command buffer。
  • 玩家还在中心区域时,游戏同时提交西北、西、西南三个方向的加载。
  • 玩家向南移动后,tryCancel() 尝试取消西北区域的加载。
  • status == .cancelled 用来判断 command buffer 是否确实被取消。
  • 取消粒度是 command buffer,不是单条 load 命令。

给紧急资源更高优先级

12:28)纹理和音频都可以通过 Fast Resource Loading 读取,但它们的延迟要求不同。玩家传送到新场景时,大量纹理可以稍后完成,传送音效必须马上播放。解决方法是为音频创建高优先级 IO 队列。

// Create a Metal IO command queue

let commandQueueDescriptor = MTLIOCommandQueueDescriptor()
commandQueueDescriptor.type = MTLIOCommandQueueType.concurrent // or serial

// Set Metal IO command queue Priority
commandQueueDescriptor.priority = MTLIOPriority.high // or normal or low

var ioCommandQueue: MTLIOCommandQueue!
do {
    try ioCommandQueue = device.makeIOCommandQueue(descriptor: commandQueueDescriptor)
} catch {
    print(error)
}

关键点:

  • priority 可以设为 high、normal 或 low。
  • 高优先级队列适合音频这类对延迟敏感的资源。
  • 纹理加载可以继续走普通优先级队列。
  • 队列创建后不能再修改优先级,因此需要提前按资源类型设计队列。

离线压缩资源包

14:04)Fast Resource Loading 还支持压缩文件。Apple 建议在离线阶段生成压缩包,运行时由 Metal 3 根据偏移找到需要解压的 chunk,并把解压后的数据加载到资源中。

// Create a compressed file

// Create compression context
let chunkSize = 64 * 1024
let compressionMethod = MTLIOCompressionMethod.zlib
let compressionContext = MTLIOCreateCompressionContext(compressedFilePath, compressionMethod, chunkSize)

// Append uncompressed file data to the compression context
// Get uncompressed file data 
MTLIOCompressionContextAppendData(compressionContext, filedata.bytes, filedata.length)


// Write the compressed file
MTLIOFlushAndDestroyCompressionContext(compressionContext)

关键点:

  • chunkSize 决定压缩包内部的分块大小,session 示例使用 64K。
  • MTLIOCompressionMethod.zlib 选择 ZLib 压缩方法。
  • MTLIOCreateCompressionContext 创建离线压缩上下文。
  • MTLIOCompressionContextAppendData 可以多次调用,把不同文件的数据追加进同一个压缩上下文。
  • MTLIOFlushAndDestroyCompressionContext 写出压缩文件并销毁上下文。

读取压缩文件

15:05)运行时打开压缩文件时,需要把创建压缩包时使用的压缩方法传给 makeIOHandle。Metal 3 后续会在 load 命令执行时做 inline decompression。

// Create an Metal File IO Handle

// Create handle to a compressed file
var compressedFileIOHandle : MTLIOFileHandle!
do {
    try compressedFileHandle = device.makeIOHandle(url: compressedFilePath, compressionMethod: MTLIOCompressionMethod.zlib)
} catch {
    print(error)
}

关键点:

  • 压缩文件同样通过 MTLIOFileHandle 进入加载管线。
  • compressionMethod 必须匹配离线压缩时使用的方法。
  • Metal 3 会把文件偏移转换成需要解压的 chunk 列表。
  • 官方片段里声明名是 compressedFileIOHandle,赋值处写成 compressedFileHandle;实际项目中应保持变量名一致。

核心启发

  • 做一个按视距加载的贴图系统。 远处只加载低分辨率 mip,玩家靠近时再用 load(texture:slice:level:size:...) 加载更高分辨率数据。这样可以缩短初始加载时间,并减少一开始占用的显存。

  • 做开放世界区域预加载。 把地图切成区域,每个区域对应一个 IO command buffer。玩家移动方向变化时,对不再需要的区域调用 tryCancel(),把存储带宽留给新的目标区域。

  • 给音频建立高优先级 IO 队列。 传送、战斗提示、关键 UI 音效可以用 loadBytes 加载到 App 分配的内存,并放到 MTLIOPriority.high 队列中,降低被大纹理加载拖慢的风险。

  • 把资源打成 Metal IO 压缩包。 构建阶段用 MTLIOCreateCompressionContext 生成压缩文件,运行时用带 compressionMethodmakeIOHandle 打开。适合纹理、几何数据和音频都很多的游戏项目。

  • 用 Metal System Trace 检查加载是否足够早。 session 提到 Xcode 14 的 Metal System Trace 可以观察 load 操作的编码和执行时间。把红色未加载 tile 的出现时间和 IO 队列活动对齐,就能判断加载请求是否发得太晚。

关联 Session

评论

GitHub Issues · utterances