1a5e860cc7722f3cea319aea3dfe7e5f
35- 实现优化的 Metal 应用和游戏

ARKit系列文章目录

2019年WWDC的《 Session 606 - Delivering Optimized Metal Apps and Games
主要内容速览:

  • 通用性能优化
  • 内存带宽
  • 内存占用

这个 session 的主要内容是Metal Best Practices,不是OK,不是Acceptable,也不是Trick。而是为大家提供 Metal 程序优化的最佳实践

主要有三大方面(即通用性能,内存带宽,内存占用),共 18 个具体措施和建议

通用性能优化,包括

  • 选择正确的分辨率
  • 最小化透明的过度绘制(overdraw)
  • 尽可能早的向 GPU 提交任务
  • 有效的传输资料
  • 做好持续性能的设计

下面逐一讲解这五条。

选择正确的分辨率

为知道,游戏中的每个效果,可能有不同的分辨率。最佳策略就是:

  • 考虑图像质量和性能之间的平衡
  • 以原生分辨率(至少应尽量接近原生分辨率)混合游戏中的 UI

我们可以用 Metal Frame Debugger 工具来检查分辨率的问题,其中的 Dependency Viewer(依赖关系查看器)最常用,它能展示每个渲染通道中的顺序图。


上图中,各个不同的效果使用了不同的分辨率,但最终生成的 UI 则是原生分辨率的。

最小化透明的过度绘制(overdraw)

处理多个逐像素的片段就会引起过度绘制。iOS 的 GPU 在减少不透明的过度绘制时非常高效,无需我们过多插手,但是那些透明的就需要我们程序员来帮忙处理了。最佳策略有:

  • 首先渲染不透明网格(mesh),然后再渲染透明的网格(mesh)
  • 不要渲染不可见的(即完全透明的)网格

检查工具还是 Metal Frame Debugger ,不过这次我们用 GPU Counters 仪表来检验指定通道的过度绘制情况


上图中,我们只关注主光照渲染通道。为了计算过度绘制情况,我们需要关注片段着色器的调用数量,除以储存像素数量。上面例子中,是几乎不存在透明场景的,所以看到没有过度绘制。

尽可能早的向 GPU 提交任务

尽早提交任务非常重要,因为它可以:

  • 改善延迟和响应性
  • 允许系统根据负载进行调整

最佳策略有:

  • 尽早向 GPU 提交所有离屏任务
  • 尽可能晚的拿到帧中的 drawable

这样可能不太好理解,我们可以看下面的 game performance template 的例子,里面有非常多的stutter(口吃,即掉帧),按住Option键选中,可以将其放大观看。

放大后,再点击左侧的A11可以展开查看详情。我们可以看到 Main Thread 中的少量任务(Main Thread 行的黄色部分)完成后,由于 Fragment 中的任务(蓝色部分)处理时间过长,黄色任务提交太晚,错过了当前帧的显示时机,所以掉帧了。

修复上面的问题后,再看一下。我们可以看到,GPU 已经提前拿到了任务并开始处理,Wait for Next Drawable 的时间也缩短了。

修复这个问题的主要代码如下:

// Off-screen command buffer
let offscreenCb = commandQueue.makeCommandBuffer()!
 // ... Encode off-screen work ...
offscreenCb.commit() //这里提交了离屏任务
let drawable = caMetalLayer.nextDrawable()! //等待 drawable,会阻塞
// On-screen command buffer
let onscreenCb = commandQueue.makeCommandBuffer()!
 // .. Encode on-screen work ...
onscreenCb.present(drawable, afterMinimumDuration: 33.0 / 1000)
onscreenCb.commit() //提交其他 on-screen 任务

有效的传输资料

我们知道,分配资源需要花费时间。将资源素材从渲染线程传输过去可能会引起卡顿。最佳策略有:

  • 考虑内存和性能的平衡
  • 在启动时就分配并加载 GPU 素材资源
  • 在专用的线程分配并传输新的素材资源

这个问题可以用 Metal System Trace 来追踪解决,Allocation行显示了相关信息

做好持续性能的设计

持续性能,主要就是指游戏运行一段时间后,手机发热或低电量等情况下的性能稳定问题。包括改善热状态,改善稳定性和响应性。
最佳策略有:

  • serious热状态下测试你的游戏
  • 考虑将你的游戏在运行中切换为serious热状态

现在,可以在 xcode 中进行设置,强制开启热节流状态

整体性能则可以在 Xcode Energy Gauge 中查看

内存带宽

为什么要优化内存?因为内存的转移代价昂贵。
iOS 设备上有

  • CPU 和 GPU 之间的共享内存(Shared memory)
  • GPU 的专用内存(Dedicated memory)


Metal 可以帮助你同时提升这两者的效果

主要措施有:

  • 压缩纹理素材
  • 优化,以便 GPU 能更快访问
  • 选择正确的像素格式
  • 优化加载和储存动作
  • 优化多重采样纹理(用于 MSAA 的纹理)
  • 提升块内存(tile memory)

下面来逐一讲解

压缩纹理素材

采样很大的纹理有可能非常低效率。最佳策略:

  • 压缩所有纹理素材----ASTC,PVRTC,等
  • 为纹理产生可缩小的 mipmap

比如下面的图,完整的纹理需要 16MB,而改用 PVRTC 压缩并启用 mipmap 后,只需要 2.7MB。这里之所有使用了 PVRTC 是因为我们的 Demo游戏需要支持 A7(iPhone 5s) 及以上设备,如果你需要支持的设备更新,则可以使用 STC 压缩格式,可以有更高压缩的同时保留更好的质量。

如何检查游戏中的纹理格式呢?我们可以使用 Metal Memory Viewer,只需双击就能查看是否压缩,是否启用 mipmap。

但是,如果是那些不能预先压缩的纹理呢?比如 render target 或者运行时产生纹理呢?

最新的 iOS GPU 支持无损的纹理压缩,它允许 GPU 压缩纹理以供快速访问。就是下一条优化措施。

优化纹理,以便 GPU 快速访问

正确的配置纹理,可以让 GPU 能更快访问。最佳策略:

  • 使用private存储模式
  • 不要设置unknown使用标识
  • 不要设置不必要的使用标识(例如,shaderWrite或者pixelView
  • 对于shared纹理,在 CPU 更新后再明确优化

private:只能被 GPU 访问;shared:可以被 CPU 和 GPU 访问

比如下面的代码,该纹理只需要被 GPU 访问,所以存储模式设置为private,使用标识设置为shaderReadrenderTarget

// Create a texture with optimal GPU access
// ...
textureDescriptor.storageMode = .private
textureDescriptor.usage       = [ .shaderRead, .renderTarget ]
let texture = device.makeTexture(descriptor: textureDescriptor)

但是,对于那些需要被 CPU 和 GPU 共同使用的纹理来说,就稍微复杂一些

// Optimize Shared texture after CPU update
// ...
textureDescriptor.storageMode = .shared
textureDescriptor.usage       = .shaderRead
let texture = device.makeTexture(descriptor: textureDescriptor)!
// ... CPU 处理后,再优化纹理,以供 GPU 快速访问
texture.replace(region: region, mipmapLevel: 0, withBytes: bytes, bytesPerRow: bytesPerRow)
let blitCommandEncoder = commandBuffer.makeBlitCommandEncoder()!
blitCommandEncoder.optimizeContentsForGPUAccess(texture: texture)
blitCommandEncoder.endEncoding()

纹理的查看和优化工具,仍然是 Metal Memory Viewer

选择正确的像素格式

纹理优化还有最后一步,就是选择正确的像素格式。大的像素格式会使用更多的内存带宽。同时采样率也是和像素格式有关。最佳策略:

  • 避免像素格式带有不必要的通道
    • 例如,使用 RGBA16 来存储 2-分量的数据
  • 尽可能的使用低精度数据

下图中我们可以看到在 A12 及更新设备上,常规的 32-bit 格式速度最快,而 128-bit 格式(如 RGBA 32-bit float)则只有四分之一的速度。

通常,这些高精度格式被用于噪声纹理或者后置处理效果(post-process effect)的查找表(lookup table)。仍然是用 Metal Memory Viewer 来查看。下图中的示例 demo 中,SSAO 效果用到的是 16-bit 格式数据。

还有就是,本例中的大部分纹理其实是 render target。当游戏变得越来越复杂时,这些 render target 会消耗大量带宽。

所以,下面要讲讲渲染通道的加载和储存动作,仔细了解一下 MSAA,并讲一点关于 Tile Memory 的知识

优化加载和储存动作

加载或储存 render target 会消耗带宽。不合适的配置项可能会创建假性依赖关系。最佳策略:

  • 只在必须时,再加载或储存 render target

代码如下:

// Configure color attachment 1 as transient 只需瞬态使用的纹理,并不需要从中加载或向其储存任何东西
// ...
renderPassDescriptor.colorAttachments[1].texture     = texture
renderPassDescriptor.colorAttachments[1].loadAction  = .clear
renderPassDescriptor.colorAttachments[1].storeAction = .dontCare

当 loadAction 是clear时,GPU 就不会在加载后纹理进行转换操作。当 storeAction 为dontCare时,意味着在渲染通道的最后不会写入任何数据。

对加载和储存的查看,可以在 Dependency Viewer 中进行

优化多重采样纹理(用于 MSAA 的纹理)

iOS 设备有非常高效的 MSAA

  • 从 tile memory 中解析(无需消耗内存带宽)
  • 明确的颜色覆盖控制,自定义解析等

最佳策略有:

  • 考虑在原生分辨率下进行 MSAA
  • 不要加载或储存多重采样纹理
  • 设置多重采样纹理的存储模式为memoryless

关键代码如下:
```swift
// Create a multisample texture
// ...
textureDescriptor.textureType = .type2DMultisample
textureDescriptor.sampleCount = 4
textureDescriptor.storageMode = .memoryless
let msaaTexture = device.makeTexture(descriptor: textureDescriptor)!
// Configure MSAA render pass descriptor
// ...
renderPassDescriptor.colorAttachments[0].texture = msaaTexture
renderPassDescriptor.colorAttachments[0].loadAction = .clear
renderPassDescriptor.colorAttachments[0].storeAction = .multisampleResolve

top Created with Sketch.