WWDC Quick Look 💓 By SwiftGGTeam
Optimize high-end games for Apple GPUs

Optimize high-end games for Apple GPUs

观看原视频

Highlight

Apple 与 Larian Studios(《神界:原罪2》)和 4A Games(《地铁:离去》)合作,总结出 Apple GPU 上高端游戏的四步优化法:分析瓶颈、优化 shader、减少内存带宽、增加 GPU 工作负载重叠。

核心内容

你把一款高端 PC 游戏移植到 Apple Silicon。画面看起来不错,但帧率不稳定,某些场景突然掉到 20fps。问题出在哪?

Apple GPU 采用 TBDR(Tile-Based Deferred Rendering)架构,和 PC 上的 IMR(Immediate Mode Rendering)完全不同。在 PC 上有效的优化策略,在 Apple GPU 上可能适得其反。

Apple 工程师用一年时间分析了多款游戏,总结出一套系统化的优化方法。

详细内容

四步优化流程

00:52

Apple 推荐按这个顺序优化:

  1. 分析瓶颈 — 用 GPU 工具找出性能限制因素
  2. 优化 shader — 减少 ALU 和寄存器压力
  3. 减少内存带宽 — 降低 tile 内存溢出和纹理带宽
  4. 增加工作负载重叠 — 让 GPU 同时处理多个任务

这个顺序很重要。如果瓶颈在内存带宽,优化 shader 不会有明显效果。

案例分析:《神界:原罪2》

04:37

Larian Studios 的《神界:原罪2》在 iPad 上运行时,某些场景性能很差。Apple 用 Xcode 13 的新 GPU Timeline 工具分析后发现:

问题 1:Uber-shader 寄存器压力

游戏用一个巨大的 uber-shader 处理所有材质。这个 shader 包含大量条件分支,但实际每帧只用到其中一小部分。由于 shader 太大,寄存器分配压力高,导致 GPU 无法充分利用并行性。

解决方案:用 function constants 替代运行时条件分支。

// 优化前:运行时条件分支,所有代码都占用寄存器
fragment float4 uberShader(...) {
    float4 color;
    if (materialType == 0) {
        color = computeMetal(...);
    } else if (materialType == 1) {
        color = computeFabric(...);
    } else if (materialType == 2) {
        color = computeStone(...);
    }
    // ... 更多分支
    return color;
}

// 优化后:编译期 specialization,只保留用到的代码
[[function_constant(0)]] const bool hasMetal = false;
[[function_constant(1)]] const bool hasFabric = false;

fragment float4 specializedShader(...) {
    float4 color;
    if (hasMetal) {
        color = computeMetal(...);
    } else if (hasFabric) {
        color = computeFabric(...);
    }
    return color;
}

12:25

关键点:

  • [[function_constant]] 在编译期确定分支
  • 未使用的代码被编译器剔除
  • 寄存器分配更紧凑
  • 需要为每种组合创建 pipeline state,但运行时性能更好

问题 2:Tile 内存溢出

TBDR 架构在 on-chip tile 内存中完成渲染。如果每帧的中间数据太多,tile 内存装不下,就会溢出到主内存,性能断崖式下降。

解决方案:减少 render target 的 bit depth,合并 render pass。

// 优化前:多个 render pass,每个都写主内存
Pass 1: G-Buffer (albedo + normal + depth) → 主内存
Pass 2: Lighting → 主内存
Pass 3: Post-processing → 主内存

// 优化后:合并到一个 render pass,数据留在 tile 内存
Pass 1: G-Buffer → Lighting → Post-processing (tile 内存内完成)

15:24

关键点:

  • TBDR 的优势是 tile 内存带宽远高于主内存
  • 每次 render pass 切换都可能导致 tile 数据写回主内存
  • 合并 render pass 让中间数据留在 on-chip
  • 使用 memoryless attachment 避免写回

问题 3:冗余资源绑定

每帧重复绑定相同的 buffer 和 texture,CPU 开销大。

解决方案:用 argument buffer 缓存绑定状态。

// 优化前:每帧逐个绑定
[encoder setVertexBuffer:buffer1 offset:0 atIndex:0];
[encoder setVertexBuffer:buffer2 offset:0 atIndex:1];
[encoder setFragmentTexture:texture1 atIndex:0];
[encoder setFragmentTexture:texture2 atIndex:1];

// 优化后:用 argument buffer 一次绑定
[encoder setVertexBuffer:argumentBuffer offset:0 atIndex:0];
[encoder useResources:resources usage:MTLResourceUsageRead];

21:40

关键点:

  • argument buffer 把资源引用打包到一个 buffer 中
  • 减少 encoder 的 API 调用次数
  • 适合资源数量多的场景
  • 配合 useResources:usage: 告诉驱动哪些资源会被访问

案例分析:《地铁:离去》

16:30

4A Games 的《地铁:离去》在 Apple GPU 上的主要问题是 compute shader 的内存访问模式。

问题:非合并内存访问

compute shader 中线程访问的内存地址分散,导致缓存效率低。

解决方案:重新组织数据布局,让相邻线程访问相邻内存地址。

// 优化前:线程 (x,y) 访问内存位置不连续
uint index = y * width + x;
float value = input[index];

// 优化后:利用 threadgroup memory 合并访问
threadgroup float sharedData[256];
uint localIndex = thread_position_in_threadgroup.x;
sharedData[localIndex] = input[globalIndex];
threadgroup_barrier(mem_flags::mem_threadgroup);
float value = sharedData[localIndex];

18:45

关键点:

  • threadgroup memory 比 device memory 快得多
  • threadgroup_barrier 确保所有线程完成写入后再读取
  • 重新组织数据布局让访问模式更规则
  • 减少 cache miss 是带宽优化的核心

GPU Timeline 工具

22:30

Xcode 13 新增的 GPU Timeline 是分析性能瓶颈的核心工具。它以时间线方式展示:

  • 每个 render pass 和 compute dispatch 的 GPU 耗时
  • 内存带宽使用情况
  • tile 内存压力
  • 各阶段的重叠程度

通过时间线可以直观看到:哪个 pass 耗时最长?是否有空闲周期可以重叠更多工作?带宽是否成为瓶颈?

核心启发

  1. 先用 GPU Timeline 定位瓶颈再优化。不要凭感觉优化,可能优化了半天发现瓶颈在另一个地方。入口 API:Xcode 13 GPU Timeline。

  2. 用 function constants 替代 uber-shader 的运行时分支。编译期剔除未用代码,大幅降低寄存器压力。入口 API:[[function_constant(N)]]

  3. 合并 render pass,让中间数据留在 tile 内存。TBDR 架构下,一次大 render pass 比多次小 render pass 高效得多。入口 API:MTLLoadAction + MTLStoreAction + memoryless attachment。

  4. 检查 compute shader 的内存访问模式。用 threadgroup memory 缓存频繁访问的数据,确保相邻线程访问相邻地址。入口 API:threadgroup + threadgroup_barrier

  5. 用 argument buffer 减少每帧的资源绑定开销。特别适合资源数量多的场景。入口 API:MTLArgumentEncoder + useResources:usage:

关联 Session

评论

GitHub Issues · utterances