しおメモ

雑多な技術系ブログです。ニッチな内容が多いです。

CADisplayLinkを用いたアニメーション実装

複雑なアニメーションを実装する場合に、画面の更新(垂直同期)に合わせて、アニメーションの更新処理をしたいケースがあります。
単純にTimerなどで実装した場合、アニメーションの処理が呼ばれるタイミングが画面の更新と合わないため、次の垂直同期までに間に合わない可能性があり、 結果としてanimation hitchを生じさせる原因となります。

そのような問題を回避するために、垂直同期の直後のタイミングでの呼び出しが行われるCADisplayLinkを利用することができます。

Timerを利用した場合に困るケース

iOSに限らずですが、大体のモバイルアプリやゲームでは、ダブルバッファリングや、トリプルバッファリングを用いて描画をしています。
ダブルバッファリングでは、表裏の2枚のスクリーンがあり、今見えている画面の裏で次のフレームの準備をしつつ、垂直同期のタイミングに合わせて表裏を入れ替えています。

例えば、60Hzのリフレッシュレートのディスプレイで、最大のパフォーマンスを出す場合、 1フレームあたりおよそ16ms以内に次のフレームの描画の準備を終える必要があります。
仮に処理が重すぎて、次の垂直同期までに間に合わない場合は、以前のフレームがそのまま描画される状態なります。
(iOSの文脈では、animation hitchと呼ばれています)

developer.apple.com

Timerの場合は特に垂直同期のタイミングとは関係なく、startしたタイミングから一定の間隔でcallbackが呼ばれます。 アニメーションの処理の開始が垂直同期のタイミングとずれることで、次の垂直同期までに動ける時間が短くなるため、後述のCADisplayLinkよりも不利になります。
(これについては、以下のWWDCの発表がわかりやすいです)

developer.apple.com

1秒に1回くらいの更新ではTimerでもほとんどのケースで困らないのですが、 高頻度になるほど準備の時間がより短くなるので、60Hz、120Hzのリフレッシュレートに合わせようとした場合は不適切と言えます。

CADisplayLinkの使い方

リフレッシュレートと同調させるような、高頻度なアニメーションの更新をしたい場合には、TimerよりもCADisplayLinkが適しています。 CADisplayLinkRunLoopに紐付けて使用します。

let displayLink = CADisplayLink(target: self, selector: #selector(update(_:)))
displayLink.add(to: .main, forMode: .default)

@objc func update(_ displayLink: CADisplayLink) { ... }

上記の場合は、垂直同期のすぐ後にupdateが呼ばれ、効率よく次のフレームの準備を進めることができます。

現行のiPhoneやiPadのリフレッシュレートは60Hzや120Hzですが、 アプリの処理の重さや端末の状態、設定によって、実際のフレームレートは30fpsであったり、60fpsであったりと異なります。

そのため、Timerで一定間隔に固定にしても、期待通りのフレームレートで動くとは限らず、過剰に呼び出されることもあり、効率の面でもあまり良い書き方とは言えません。
(特にiPhone 13 Proなどの端末では、最大120Hzの可変リフレッシュレートとなっているため、さらに配慮が必要です。)

また、CADisplayLinkの場合はpreferredFramesPerSecondというプロパティで、callbackが呼ばれる頻度を大まかに設定することができます。

displayLink.preferredFramesPerSecond = 60

ただし、iOS15でdeprecatedになっていて、代わりにiOS15+では拡張されたpreferredFrameRateRangeが使えます。

displayLink.preferredFrameRateRange = .init(minimum: 15, maximum: 60, preferred: 60)

例えば、60fpsに設定しておくと、リフレッシュレートが120Hzの端末でも毎秒60回の更新に抑えられますが、 あくまでpreferredなので、元々30fpsしか出ていないようなケースでは、callbackが呼ばれる回数はおおよそ毎秒30回となります。

何も設定しなければ、自動で最大のfpsを取るようになるので、更新の頻度を抑える必要がない場合は設定しないでもOKです。

フレーム毎の時間の計算

先ほども書いたように、端末の種類や、設定、アプリによって実際のフレームレートは異なるため、 アニメーションがどの環境でも同じように見えるようにするためには、フレーム毎の時間を計測するのが適切です。

例えば、1回のcallbackで6度回転するようなアニメーションを実装した場合、 60fpsでは1秒に360度回転しますが、30fpsでは1秒に180度しか回転しません。
このように環境によってアニメーションの速度が変わってしまうので、意図した速さでアニメーションを動かすには、 実際に1フレームあたりどれくらいの時間がかかっているかを知ることが必要になります。

CADisplayLinkにはtimestamptargetTimestampの2種類のプロパティがありますが、timestampは今準備しているフレームの開始時刻で、targetTimestampは今準備しているフレームが表示される予定時刻なので、アニメーションに時刻を利用する場合はtargetTimestampが適切になります。

Always use targetTimestamp to drive any animation, physics, or other time-related content provided in your CADisplayLink callback.

実際には、高負荷などで処理が間に合わない際に、callbackがスキップされるケースがあるので、 今のフレームのtargetTimestampが次のフレームのtimestampに一致するとは限りません。
したがって、厳密にアニメーションの計算のために時間の差分を取るには、targetTimestamp - timestampよりも、前回のtargetTimestampと比較をするのがより正確な手段になります。

var previousTimeStamp: CFTimeInterval = .zero

func start() {
    let displayLink = CADisplayLink(target: self, selector: #selector(update(_:)))
    displayLink.add(to: .main, forMode: .default)
    previousTimeStamp = CACurrentMediaTime()
}

@objc func update(_ displayLink: CADisplayLink) {
    let delta = displayLink.targetTimestamp - previousTimeStamp
    previousTimeStamp = displayLink.targetTimestamp

    // ...
}

先ほどの例のように、1秒に360度回転させるようなアニメーションをしたい場合には、以下のようになります。

@objc func update(_ displayLink: CADisplayLink) {
    let delta = displayLink.targetTimestamp - previousTimeStamp
    previousTimeStamp = displayLink.targetTimestamp

    angle += CGFloat.pi * 2 * delta
}

CADisplayLinkTimerとは異なり、呼び出しの間隔が一定でない場合がありますが、 経過時間を考慮することで、環境によらず意図したスピードでアニメーションを動かすことができます。

リンク

書いている途中で試聴したのですが、以下のWWDC21の発表の後半でわかりやすくまとまっているので、そちらが参考になります。
iPhone13 Proなどの可変リフレッシュレートのディスプレイ対応についてもAppleのドキュメントがあります。