【iOS】Metal Best Practicesの解説（8） フレームレート

[Metal Best Practices](https://developer.apple.com/library/archive/documentation/3DDrawing/Conceptual/MTLBestPracticesGuide/index.html#//apple_ref/doc/uid/TP40016642-CH27-SW1)は、iOS／MacOS／tvOSのAPIであるMetalを用いた設計のベストプラクティスガイドです。

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

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

https://qiita.com/TokyoYoshida/items/521619d6d8dd8d8ef9e4

### [Frame Rate (iOS and tvOS)(フレームレート)](https://developer.apple.com/library/archive/documentation/3DDrawing/Conceptual/MTLBestPracticesGuide/FrameRate.html#//apple_ref/doc/uid/TP40016642-CH23-SW1)

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

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

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

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

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

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

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

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

### コードで検証してみる

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

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

https://github.com/TokyoYoshida/MetalExamples/tree/main/MetalExamples/Samples/Triple%20Buffering

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

(実行イメージ)

![20210829_frame_rate.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/116010/41930e09-bc9a-91d9-40a1-e7eb7c81de0f.gif)


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

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

**そのまま実行した場合**

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

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

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

https://qiita.com/TokyoYoshida/items/c904e79daf0a1a72375a

（実行中の状態）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/116010/5d80d338-6b3d-a91e-2818-21873693ff5b.png)

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

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

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

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

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

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

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

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

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

（実行中の状態）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/116010/260dc42b-fcce-7549-4a19-90f916405d39.png)

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

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

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

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

```swift: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で一定になります。パーティクルのカクつきは全くなく、とても滑らかになったように見えます。（目視なので気のせいかもしれませんが。。）

（実行中の状態）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/116010/3d5e4c87-2909-6f78-43b2-f3ac4810147d.png)

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