WWDC Quick Look 💓 By SwiftGGTeam
Optimize for variable refresh rate displays

Optimize for variable refresh rate displays

观看原视频

Highlight

macOS Monterey 引入 Adaptive-Sync 显示器支持,Metal 游戏可通过 presentAfterMinimumDuration 实现自适应帧率;iPad Pro 的 ProMotion 显示器配合 CADisplayLinktargetTimestamp 可在帧率变化时保持动画平滑。

核心内容

你的游戏在 120Hz 显示器上运行,大部分时候能稳定在 120fps。但偶尔场景复杂度飙升,某一帧花了 9ms 才渲染完。在固定刷新率显示器上,这帧会被延迟到下一个 16.6ms 的刷新周期,用户感受到明显的卡顿。在 Adaptive-Sync 显示器上,这帧只延迟 1ms,几乎无感知。

这就是可变刷新率显示器的价值。但要用好它,需要改变传统的帧率控制思路。

详细内容

Adaptive-Sync 显示器在 macOS 上的工作原理

01:27

固定刷新率显示器(60Hz 或 120Hz)每 16.6ms 或 8.3ms 刷新一次。如果 GPU 没准备好新帧,就重复显示上一帧。

Adaptive-Sync 显示器的工作方式不同。每帧在屏幕上停留的时间是一个区间,而不是固定值。比如一台 40-120Hz 的 Adaptive-Sync 显示器,每帧可以在屏幕上停留 8ms 到 25ms。

这意味着:

  • 如果你的帧在 9ms 完成,它会在 9ms 后立刻显示,不需要等到 16.6ms
  • 卡顿从 8ms 降到了 1ms,用户几乎感知不到

02:44

关键点:

  • Adaptive-Sync 显示器的刷新区间是动态的
  • 帧完成后立即显示,不需要等待下一个固定刷新点
  • 超出最大停留时间后,显示器必须刷新,会短暂不可用

Adaptive-Sync 最佳实践

04:18

在固定刷新率显示器上,如果 GPU 工作持续超过刷新间隔,Apple 建议把帧率降到下一个因数(比如从 60fps 降到 30fps)。

在 Adaptive-Sync 显示器上,这个建议变了:你应该以应用能稳定达到的最高均匀帧率呈现帧。

// 检测 Adaptive-Sync 是否可用
let screen = window.screen
let isAdaptiveSync = screen.minimumRefreshInterval != screen.maximumRefreshInterval
let isFullScreen = window.styleMask.contains(.fullScreen)

if isAdaptiveSync && isFullScreen {
    // 启用 Adaptive-Sync 帧率控制
}

05:51

关键点:

  • minimumRefreshIntervalmaximumRefreshInterval 是 NSScreen 的新属性
  • 两个值不相等表示 Adaptive-Sync 模式
  • 必须同时检查窗口是否全屏
  • 只在全屏模式下才能使用 Adaptive-Sync 调度

用 presentAfterMinimumDuration 控制帧率

06:51

最简单的方式是用 presentAfterMinimumDuration 固定帧率:

let desiredInterval: CFTimeInterval = 1.0 / 78.0 // 目标 78Hz

// 在渲染循环中
drawable.present(afterMinimumDuration: desiredInterval)

07:11

关键点:

  • presentAfterMinimumDuration 确保帧在指定间隔后才呈现
  • 适合实现用户可调节的 FPS 滑块
  • 帧率稳定但可能不是最优

更高级的做法是动态计算帧率:

var averageGPUTime: CFTimeInterval = 1.0 / 120.0 // 初始乐观估计

// 第一帧:用乐观估计
let firstDrawable = getDrawable()
let commandBuffer = encodeWork()
commandBuffer.addCompletedHandler { _ in
    let gpuTime = CACurrentMediaTime() - frameStartTime
    // 滚动平均
    averageGPUTime = 0.9 * averageGPUTime + 0.1 * gpuTime
}
commandBuffer.commit()
firstDrawable.present(afterMinimumDuration: averageGPUTime)

08:44

关键点:

  • CommandBuffer 的 completed handler 测量 GPU 时间
  • 滚动平均平滑帧率变化
  • 同一套代码在不同性能的 Mac 上自动适配帧率
  • 在弱性能 Mac 上可能跑到 48Hz,在强性能 Mac 上跑到 78Hz

10:22

iPad Pro 的 ProMotion 显示器支持最高 120Hz,但以下情况可能不可用:

  • 用户开启了低电量模式(iPadOS 15 新增)
  • 用户在辅助功能中设置了限制帧率(上限 60Hz)
  • 设备过热,系统限制 120Hz

CADisplayLink 是驱动自定义绘制的推荐工具。它会在每次 vsync 时唤醒,比普通 NSTimer 更精确。

// 设置 CADisplayLink
displayLink = CADisplayLink(target: self, selector: #selector(draw))
displayLink.preferredFramesPerSecond = 40
displayLink.add(to: .current, forMode: .common)

// 在回调中
@objc func draw() {
    let interval = displayLink.targetTimestamp - displayLink.timestamp
    // interval 是实际可用的渲染时间
}

15:37

关键点:

  • preferredFramesPerSecond 只是建议,实际帧率由系统决定
  • 在 60Hz 显示器上请求 40Hz,系统会自动调整到 30Hz
  • duration 属性反映当前实际帧间隔
  • targetTimestamp 而不是 timestamp 准备动画

为什么用 targetTimestamp 而不是 timestamp

17:46

当帧率从 120Hz 降到 60Hz 时,如果用 timestamp 采样动画进度,会出现跳变:

// 用 timestamp:帧率切换时动画进度跳变
// 帧1 (120Hz): timestamp=0, progress=0
// 帧2 (120Hz): timestamp=8ms, progress=0.05
// 帧3 (60Hz):  timestamp=16ms, progress=0.10  <- 这里跳变!

targetTimestamp 则平滑:

// 用 targetTimestamp:帧率切换时动画平滑
// 帧1 (120Hz): targetTimestamp=8ms, progress=0.05
// 帧2 (120Hz): targetTimestamp=16ms, progress=0.10
// 帧3 (60Hz):  targetTimestamp=32ms, progress=0.20  <- 平滑过渡

19:26

关键点:

  • timestamp 是回调被调用的时间
  • targetTimestamp 是下一帧将被合成的时间
  • targetTimestamp 采样动画,帧率变化时不会出现跳变
  • 代码改动很简单:把 timestamp 替换为 targetTimestamp

处理错过的回调

20:16

高优先级线程或繁忙的 runloop 可能导致 CADisplayLink 回调被延迟或跳过。

@objc func draw() {
    let currentTime = CACurrentMediaTime()
    let timeRemaining = displayLink.targetTimestamp - currentTime

    if timeRemaining < minimumWorkTime {
        // 时间不够,减少工作量或跳过这帧
        return
    }

    // 用上一帧的 targetTimestamp 计算正确的时间增量
    let delta = displayLink.targetTimestamp - previousTargetTimestamp
    previousTargetTimestamp = displayLink.targetTimestamp

    // 用 delta 更新动画状态
    updateAnimation(delta: delta)
}

21:48

关键点:

  • CACurrentMediaTime() 检查剩余时间
  • 保留 previousTargetTimestamp 计算正确的时间增量
  • 回调被跳过时,delta 自动变为两倍的帧间隔
  • 动画不会减速或加速

核心启发

  1. 为 Metal 游戏添加 Adaptive-Sync 支持。检测 minimumRefreshInterval != maximumRefreshInterval 且窗口全屏时,用滚动平均 GPU 时间动态控制帧率。入口 API:NSScreen.minimumRefreshInterval + MTLDrawable.present(afterMinimumDuration:)

  2. 把 CADisplayLink 的 timestamp 全部替换为 targetTimestamp。这是避免帧率切换时动画跳变的唯一正确做法。入口 API:CADisplayLink.targetTimestamp

  3. 为自定义动画添加帧率降级逻辑。当 timeRemaining < minimumWorkTime 时减少工作量或跳过帧,保证不丢帧。入口 API:CACurrentMediaTime() + displayLink.targetTimestamp

  4. 在 iPad 应用中检测 ProMotion 可用性。用 CADisplayLink.duration 获取实际帧间隔,不要用 UIScreen.maximumFramesPerSecond(它始终返回 120)。入口 API:CADisplayLink.duration

  5. 为游戏添加用户可调节的 FPS 上限。用 presentAfterMinimumDuration 固定帧率,降低 GPU 功耗和发热。入口 API:MTLDrawable.present(afterMinimumDuration:)

关联 Session

评论

GitHub Issues · utterances