【iOS】Metal Best Practicesの解説(8) フレームレート


Metal Best Practicesは、iOS/MacOS/tvOSのAPIであるMetalを用いた設計のベストプラクティスガイドです。

本稿では、何回かに分けてこのガイドを読み解き、コード上での実験を交えて解説していきます。
読んでそのまま理解できそうなところは飛ばしますので、原文を読みながら原文のガイドとしてご利用下さい。
また、iOSの記事なので他のOS(MacOS, tvOS)についての記載は割愛します。

他の記事の一覧は、初回記事よりご覧下さい。

Frame Rate (iOS and tvOS)(フレームレート)

ベストプラクティス:一貫した安定したフレームレートでドローアブルを表示します。

アプリは一定したフレームレートを維持できるようにしましょう、という話。フレームレートが一定でない場合Jitterと呼ばれる現象が起きます。Jitterは遅延の変動のことで、これが起きると画面のモーションにカクつきが出るためユーザーに不快な印象を与えます。シューティングゲームでこれがあると操作しづらいでしょうね😅 

アプリが、60FPSを維持できるような処理速度であれば何も問題はありません。ただ、それが難しいときはフレームレートを下げる必要があります。

iOSのフレームレートはUIScreenのmaximumFramesPerSecondプロパティで参照でき、通常60FPSです。

MTKViewは、フレームレートの調整がしやすくなっていて、preferredFramesPerSecondプロパティを使うことで、指定したFPSにすることができます。

ドローアブルの表示時間の固定

preferredFramesPerSecondに30FPSなど低いフレームレートを指定して、アプリのレンダリングループを遅くしても、ディスプレイのリフレッシュレートは60FPSのままなので、タイミングによってはアプリの処理が60FPSの間隔(16.67ms)に間に合ってしまうことがります。これによりフレームレートが一定せずJitterが起きます。

そこで、presentDrawableのafterMinimumDuration引数を指定すると、ドローアブルの最小表示時間を指定できます。この引数に、1.0/preferredFramesPerSecondを指定すると、preferredFramesPerSecondのフレームレートにあわせたドロアーブルの表示間隔を保ってくれるので、アプリのレンダリングループとドローアブルの表示間隔が同期し、Jitterの問題を回避できます。

コードで検証してみる

何も指定しない場合と、フレームレートを固定した場合、ドロアーブルの表示間隔をフレームレートにあわせた場合をコードで比較してみます。

サンプルコードはこちらにあるコードを改変して作ります。

実行すると、パーティクルが上から下に流れてきます。

(実行イメージ)

検証のためにパーティクルの数を10万に増やします。

TripleBufferingMetalView.swift
class Coordinator : NSObject, MTKViewDelegate {
    static let numberOfParticles = 100000

そのまま実行した場合

サンプルコードをそのまま実行してみます。

FPSは46〜48FPSの間で変動していました。パーティクルの流れは滑らかさに欠ける感じがあります。
(スクリーンキャプチャしてGIFアニメするとGIFのカクつきの方が大きかったので、キャプチャは省略します)

Metal System Traceを使って実行中の状態を取得してみます。
このツールの使い方はこちらの記事をご覧ください。

(実行中の状態)

このコードは、パーティクルを動かす処理をCPU側でやっているので、遅延の原因の殆どがCPUの処理によるものです。

ディスプレイのリフレッシュ間隔が49msになったり、16msになったりと一定していないようです。平均すると21ms(=46FPS)ぐらいです。

MTKViewDelegateのdrawメソッドは内部タイマーに基づいて呼び出されますが、呼び出しの間隔は21ms〜28msの間で変化していました。

60FPSに間に合っていても間に合わなくても、準備ができたらすぐに描画、といった感じでしょうか。

フレームレートを固定した場合

MTKViewのpreferredFramesPerSecondを使ってフレームレートを30FPSに固定してみます。

次のようにコードを変更します。

TripleBufferingMetalView.swift
func makeUIView(context: Context) -> MTKView {
    // 〜中略〜
    mtkView.drawableSize = mtkView.frame.size
    mtkView.colorPixelFormat = .bgra8Unorm_srgb
    mtkView.preferredFramesPerSecond = 30 // 👈これを追加
    return mtkView
}

実行するとFPSは30で一定のままになりました。パーティクルのカクつきはほとんどなくなったように見えます。

(実行中の状態)

drawメソッドはの呼び出しの間隔も33msで一定でしたが、たまに処理がディスプレイのリフレッシュ間隔(16.67ms)に間に合うことがあるようで、その場合は16ms間隔でリフレッシュされ、その後33msに戻ったりと一定のリフレッシュ間隔にはなっていません。

この結果からの推測ですが、preferredFramesPerSecondは、おそらくMTKViewDelegateのdrawメソッドの更新間隔を決定しているものと思われます。preferredFramesPerSecondで30FPSを指定すると、33msの間隔でdrawメソッドは呼ばれますが、ディスプレイのリフレッシュレートは60FSPのままなので、タイミングによっては、16msの間隔で画面が更新されるようです。

フレームレートを固定 + ドロアーブルの表示間隔を固定した場合

フレームレートの固定に加え、ドローアブルの表示間隔も固定してみます。

TripleBufferingMetalView.swift
func draw(in view: MTKView) {
        renderEncoder.drawPrimitives(type: .point, vertexStart: 0, vertexCount: Coordinator.numberOfParticles)        
        renderEncoder.endEncoding()        
//      👇引数afterMinimumDurationを追加
        commandBuffer.present(drawable, afterMinimumDuration: 1.0/Double(parent.mtkView.preferredFramesPerSecond))
        commandBuffer.addCompletedHandler {[weak self] _ in
            self?.semaphore.signal()
        }
        commandBuffer.commit()
    }
}

こちらも実行すると30FPSで一定になります。パーティクルのカクつきは全くなく、とても滑らかになったように見えます。(目視なので気のせいかもしれませんが。。)

(実行中の状態)

drawメソッドの呼び出しの間隔は33msで一定しており、ディスプレイのリフレッシュ間隔も33msで一定したまま続いているのがわかります。

結論

今回は、アプリが60FPSを守れない場合の対策、のような感じになりましたが、30FPSなどフレームレートを下げても滑らかな動きを実現するためには、preferredFramesPerSecondでフレームレートを固定し、presentDrawableのafterMinimumDurationでドローアブルの表示間隔を同期させる必要がありそうです。

最後に

iOSを使った3D処理やAR、ML、音声処理などの作品やサンプル、技術情報を発信しています。
作品ができたらTwitterで発信していきますのでフォローをお願いします🙏

Twitterは作品や記事のリンクを貼っています。
https://twitter.com/jugemjugemjugem

Qiitaは、iOS開発、とくにARや機械学習、グラフィックス処理、音声処理について発信しています。
https://qiita.com/TokyoYoshida

Noteでは、連載記事を書いています。
https://note.com/tokyoyoshida

Zennは機械学習が多めです。
https://zenn.dev/tokyoyoshida