What's new in Metal
Graphics & Games 高级 45m

Metal 新特性

What's new in Metal

2025年6月10日

在 Apple 官方观看视频

一句话判断

Metal 终于开始帮你管那些你一直在手动管的东西了——渲染状态、Shader 编译、分辨率折中——这次更新的信号是:别自己造轮子了,用框架的。

这场 Session 讲了什么

Metal 这次的更新覆盖了四个方向,但逻辑其实是一条线:把渲染流水线上那些开发者不得不自己处理的脏活累活收进框架层。渲染状态切换的 CPU 开销、着色器编译的冷启动卡顿、低分辨率渲染的画面折损——这些问题存在好几年了,大家各自写了各种 workaround,现在 Apple 给了官方解法。

MetalFX 的时间放大(Temporal Upscaling)做了比较大的质量提升,特别是高速运动和密集几何体场景下的时序稳定性。运动向量精度更高,重投影算法对遮挡区域的处理更智能,这意味着”低内部分辨率渲染 + 放大输出”这个策略终于可以在正式项目里放心用了。以前提 2x 放大倍率,美术团队总会抱怨高速运镜时的拖影和闪烁,现在这些问题基本被压住了。着色器编译方面,增量编译、异步 PSO 创建、SwiftUI Shader 的构建时预编译三管齐下,基本上把开发迭代中最让人头疼的编译等待砍掉了大半。特别是增量编译——你改了一行着色器代码,不再需要等几分钟的全量重编,这个体验差距是数量级的。

另外一块容易被忽略的变化是 Indirect Command Buffer(ICB)的扩展,以及 visionOS 立体渲染的深度集成。ICB 支持更多命令类型后,GPU 驱动的剔除和延迟渲染管线终于有了比较完整的编程模型。visionOS 那边,Remote Immersive Space 让 Mac 应用也能驱动 Vision Pro 的立体内容,跨设备渲染的门槛降了不少。

值得深挖的点

渲染状态管理:从”每帧几百次切换”到”批量提交”

传统 Metal 渲染里,每个 draw call 前面都跟着一串状态设置——管线状态、顶点缓冲区、纹理绑定。场景里有 500 个物体,CPU 就要执行 500 次状态切换,每次切换都包含验证逻辑。这个开销在简单场景里感知不明显,但一旦进入开放世界、粒子系统这类高频 draw call 的场景,CPU 端就先扛不住了。

新的状态管理 API 做了两件事:批量提交和惰性验证。批量提交让你把一帧的状态变更打包成一个批次,一次性送给 GPU;惰性验证则把验证逻辑推迟到真正需要结果的时候才执行,而不是每次切换都跑一遍。这两个改动叠加起来,CPU 渲染开销能砍掉 20-40%,对于那些 CPU-bound 的场景来说是质变。

但这里有个 trade-off:批量提交意味着你需要重新组织渲染循环的代码结构。如果你之前是按材质排序 draw call 的,切换到新 API 比较自然;如果是按空间位置排序的,可能需要额外的抽象层来管理状态差异。另外,惰性验证虽然省了 CPU 时间,但也意味着错误发现的时间点会延后——之前每次状态切换都能立刻报错,现在要等到真正渲染时才会暴露问题。调试的时候需要适应这个变化。迁移成本不低,但收益是实打实的,尤其对 CPU-bound 的场景。

着色器编译:三招解决冷启动噩梦

着色器编译延迟是 Metal 开发中一个长期痛点。应用冷启动时用户看到几秒黑屏、场景切换时帧率骤降到个位数——根源都是 Pipeline State Object(PSO)创建时的同步编译。开发者能做的就是提前预热,但预热本身也要时间,而且很难覆盖所有分支。

这次更新从三个层面各砍一刀。增量编译(Incremental Shader Compilation)只重建着色器中真正变化的部分,而不是每次都全量重编。异步 PSO 创建把编译任务扔到后台线程,主线程不再被阻塞。SwiftUI 的 compiled(as:) 方法则更激进——直接在 Xcode 构建阶段完成编译,运行时拿到的是编译好的产物。

三招的效果是叠加的:开发阶段用增量编译加速迭代,启动阶段用异步创建避免卡顿,SwiftUI 项目直接消灭运行时编译。不过有个限制:compiled(as:) 要求着色器参数在编译时已知,运行时动态传参的场景还是得走老路。所以实际项目里往往是两种方式并存——静态效果走构建时编译,动态效果走异步运行时编译。还有一个容易踩的坑:增量编译的缓存失效策略比较保守,有时候你只改了无关的配置文件,它也会触发重编。建议在 CI 流程里加上着色器编译时间的监控,一旦发现异常膨胀就手动清理缓存。

代码片段

1. MetalFX 时间放大——低分辨率渲染的正确姿势

场景:在 iPhone 15 Pro 上以 720p 渲染,输出到 1440p 屏幕。

import MetalFX

let descriptor = MTLFXTemporalScalerDescriptor()
descriptor.inputWidth = 1280
descriptor.inputHeight = 720
descriptor.outputWidth = 2560
descriptor.outputHeight = 1440
descriptor.colorTextureFormat = .bgra8Unorm
descriptor.depthTextureFormat = .depth32Float
descriptor.motionTextureFormat = .rg16Float

let scaler = descriptor.makeTemporalScaler(device: device)!
// 每帧渲染末尾调用 scaler.encode()

坑:运动向量纹理必须用 .rg16Float,低精度格式会导致运动边缘出现明显撕裂。

2. SwiftUI 着色器构建时预编译

场景:用 SwiftUI Layer Effect 做自定义视觉效果,不想忍受首次使用的编译卡顿。

struct ShaderBackground: View {
    let shader = ShaderLibrary.myEffect.compiled(as: .fragment)

    var body: some View {
        Rectangle()
            .layerEffect(shader, maxSampleOffset: .zero)
    }
}

坑:参数必须编译时已知,需要运行时动态传参的场景走不通,得用传统的 Shader 初始化。

3. ICB 实现 GPU 驱动剔除

场景:开放世界游戏里要剔除几万个不可见物体,CPU 逐个判断太慢。

let icbDescriptor = MTLIndirectCommandBufferDescriptor()
icbDescriptor.commandTypes = [.drawIndexed]
icbDescriptor.inheritBuffers = false
icbDescriptor.maxVertexBufferBindCount = 3

let icb = device.makeIndirectCommandBuffer(
    descriptor: icbDescriptor,
    maxCommandCount: 10000,
    options: []
)!
// 流程:计算着色器写可见对象到 ICB -> 渲染编码器执行 ICB

坑:maxCommandCount 直接决定显存分配,设太大浪费,设太小限制场景规模,建议配合 GPU 计数器动态调整。

最佳实践

优先从 MetalFX 的时间放大开始集成。这是投入产出比最高的改动——不需要重构渲染管线,只需要在渲染末尾加一个 pass,就能把内部分辨率降下来换帧率。先在项目里跑起来,验证 2x 放大倍率下画质能否通过 QA,再考虑是否更激进。

渲染状态管理的迁移建议放第二优先级。这个改动收益大,但需要重构渲染循环,风险也高。先在新功能或新场景里用新 API,老代码逐步迁移,不要一次性全改。

Shader 编译方面,SwiftUI 项目立刻用 compiled(as:),纯 Metal 项目先把异步 PSO 创建加上——改动最小,效果最直接。增量编译是框架自动生效的,不需要额外操作。ICB 和 GPU 驱动管线,除非在做大型 3D 场景渲染,否则可以先观望。它的编程模型复杂度高,调试工具链还不够成熟,投入产出比不如前几项。

还有什么值得关注

  • visionOS 的 Remote Immersive Space:让 Mac 应用驱动 Vision Pro 立体渲染,跨设备 XR 应用的开发门槛大幅降低。
  • Indirect Command Buffer 命令类型扩展:除了 draw 调用,现在还支持更多计算命令,GPU 驱动管线的适用范围更广了。
  • MetalFX 空间放大改进:虽然不如时间放大抢眼,但在不需要历史帧信息的场景(如 UI 叠加层)下,空间放大的性能开销更低。
Metal GPU MetalFX 着色器 visionOS