mcpField
A
axiom-display-performance

axiom-display-performance

github.com/charleswiltgen開発1.2k172 回インストール今日更新
リポジトリ

axiom-display-performance について

Appleプラットフォームの開発者向けに、可変リフレッシュレート画面の表示性能を診断するリファレンスです。レンダリングループ、フレームペーシング、ヒッチ、運用時の計測を扱い、60 fps制限の調査から実際の表示計測まで解説します。

主な機能

  1. 01ProMotionのフレームレート制限を診断
  2. 02MTKViewとCADisplayLinkの設定
  3. 03CAMetalDisplayLinkによるタイミング制御
  4. 04フレームペーシング改善とヒッチ分析
  5. 05実際のフレーム表示を計測
  6. 06MetricKitでヒッチを監視

活用シーン

  1. 01ProMotion端末が60 fpsに留まる原因を調査
  2. 02Metalアプリの描画タイミングを調整
  3. 03アニメーションやスクロールの微細なカクつきを改善
  4. 04本番環境でアニメーションのヒッチを監視

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/charleswiltgen/axiom --skill axiom-display-performance

インストール後、Claude Code で/axiom-display-performanceを入力して読み込まれたか確認します。

詳細

ディスプレイのパフォーマンス

可変リフレッシュレートディスプレイ(ProMotion、iPad Pro、将来のデバイス)におけるフレームレートの問題を体系的に診断します。レンダーループの設定、フレームペーシング、ヒッチの仕組み、本番環境のテレメトリーを扱います。

重要なポイント:「ProMotion に対応している」からといって、アプリが自動的に 120Hz で動作するわけではありません。適切に設定し、システムによる上限を考慮し、正しいフレームペーシングを確保する必要があります。


パート 1:60fps で止まってしまう理由

診断の順序

ProMotion で 60fps のままになる場合は、次の順に確認してください。

  1. Info.plist のキーが不足している?(iPhone のみ)→ パート 2
  2. レンダーループが 60 に設定されている?(MTKView のデフォルト設定、CADisplayLink)→ パート 3
  3. システムによる上限が有効になっている?(低電力モード、フレームレートを制限、熱)→ パート 5
  4. フレーム時間が 8.33ms を超えている?(120fps を維持できない)→ パート 6
  5. フレームペーシングに問題がある?(FPS は良好なのにマイクロスタッタリングが発生する)→ パート 7
  6. 測定対象を間違えている?(UIScreen と実際の表示)→ パート 9

パート 2:iPhone で ProMotion を有効にする

重要:このキーを追加しない限り、iPhone の Core Animation は 60Hz を超えるフレームレートにアクセスできません。


<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>

このキーがない場合:

  • preferredFrameRateRange のヒントは 60Hz を超えると無視されます
  • ほかのアニメーションが CADisplayLink のコールバックレートに影響することがあります
  • iPad Pro ではこのキーは不要です

追加するタイミング:ゲーム、アニメーション、またはスムーズなスクロールで 60Hz を超える必要がある iPhone アプリ。


パート 3:レンダーループの設定

MTKView のデフォルトは 60fps

これが最も一般的な原因です。 MTKView の preferredFramesPerSecond はデフォルトで 60 です。

// ❌ WRONG: Implicit 60fps (default)
let mtkView = MTKView(frame: frame, device: device)
mtkView.delegate = self
// Running at 60fps even on ProMotion!

// ✅ CORRECT: Explicit 120fps request
let mtkView = MTKView(frame: frame, device: device)
mtkView.preferredFramesPerSecond = 120
mtkView.isPaused = false
mtkView.enableSetNeedsDisplay = false  // Continuous, not on-demand
mtkView.delegate = self

高レートの連続レンダリングに必要な設定:

プロパティ値理由
preferredFramesPerSecond120最大レートを要求する
isPausedfalseレンダーループを一時停止しない
enableSetNeedsDisplayfalseオンデマンドではなく連続モードにする
CADisplayLink の設定(iOS 15 以降)

Apple は、カスタムのレンダーループにはタイマーではなく CADisplayLink を明示的に推奨しています。

// ❌ WRONG: Timer-based render loop (drifts, wastes frame time)
Timer.scheduledTimer(withTimeInterval: 1.0/120.0, repeats: true) { _ in
    self.render()
}

// ❌ WRONG: Default CADisplayLink (may hint 60)
let displayLink = CADisplayLink(target: self, selector: #selector(render))
displayLink.add(to: .main, forMode: .common)

// ✅ CORRECT: Explicit frame rate range
let displayLink = CADisplayLink(target: self, selector: #selector(render))
displayLink.preferredFrameRateRange = CAFrameRateRange(
    minimum: 80,      // Minimum acceptable
    maximum: 120,     // Preferred maximum
    preferred: 120    // What you want
)
displayLink.add(to: .main, forMode: .common)

ゲーム向けの特別な優先レート:iOS 15 以降では、30Hz と 60Hz に特別な優先度が与えられます。これらのレートを対象とする場合:

// 30Hz and 60Hz get priority scheduling
let prioritizedRange = CAFrameRateRange(
    minimum: 30,
    maximum: 60,
    preferred: 60
)
displayLink.preferredFrameRateRange = prioritizedRange
コンテンツの種類別の推奨フレームレート
コンテンツの種類推奨レート備考
動画の再生24-30 Hzコンテンツのフレームレートに合わせる
UI のスクロール60-120 Hz高いほど滑らか
高速なゲーム60-120 Hzレンダリング能力に合わせる
ゆっくりしたアニメーション30-60 Hz電力を節約する
静的コンテンツ10-24 Hz更新を最小限に抑える

パート 4:CAMetalDisplayLink(iOS 17 以降)

正確なタイミング制御が必要な Metal アプリでは、CAMetalDisplayLink を使うと CADisplayLink よりも細かく制御できます。

class MetalRenderer: NSObject, CAMetalDisplayLinkDelegate {
    var displayLink: CAMetalDisplayLink?
    var metalLayer: CAMetalLayer!

    func setupDisplayLink() {
        displayLink = CAMetalDisplayLink(metalLayer: metalLayer)
        displayLink?.delegate = self
        displayLink?.preferredFrameRateRange = CAFrameRateRange(
            minimum: 60,
            maximum: 120,
            preferred: 120
        )
        // Control render latency (in frames)
        displayLink?.preferredFrameLatency = 2
        displayLink?.add(to: .main, forMode: .common)
    }

    func metalDisplayLink(_ link: CAMetalDisplayLink, needsUpdate update: CAMetalDisplayLink.Update) {
        // update.drawable - The drawable to render to
        // update.targetTimestamp - Deadline to finish rendering
        // update.targetPresentationTimestamp - When frame will display

        guard let drawable = update.drawable else { return }

        let workingTime = update.targetTimestamp - CACurrentMediaTime()
        // workingTime = seconds available before deadline

        // Render to drawable...
        renderFrame(to: drawable)
    }
}

CADisplayLink との主な違い:

機能CADisplayLinkCAMetalDisplayLink
Drawable へのアクセスレイヤー経由で手動コールバックで提供
レイテンシー制御なしpreferredFrameLatency
目標タイミングtimestamp/targetTimestamp+ targetPresentationTimestamp
用途一般的なアニメーションMetal 専用のレンダリング

CAMetalDisplayLink を使う場合:

  • レンダリングのタイミングウィンドウを正確に制御する必要がある
  • 入力レイテンシーを最小限に抑えたい
  • ゲームや負荷の高い Metal アプリを開発する
  • iOS 17 以降のみを対象にする

パート 5:システムによる上限

コードで 120 を要求していても、システムの状態によって 60fps に制限されることがあります。

低電力モード

ProMotion デバイスを 60fps に制限します。

// Check programmatically
if ProcessInfo.processInfo.isLowPowerModeEnabled {
    // System caps display to 60Hz
}

// Observe changes
NotificationCenter.default.addObserver(
    forName: .NSProcessInfoPowerStateDidChange,
    object: nil,
    queue: .main
) { _ in
    let isLowPower = ProcessInfo.processInfo.isLowPowerModeEnabled
    self.adjustRenderingForPowerState(isLowPower)
}
フレームレートを制限(アクセシビリティ)

設定 → アクセシビリティ → モーション → フレームレートを制限を有効にすると、60fps に制限されます。

検出する API はありません。設定しているのに 60fps になるとユーザーから報告された場合は、この設定を確認してもらってください。

熱によるスロットリング

デバイスが過熱すると、システムによって 120Hz に制限がかかります。

// Check thermal state
switch ProcessInfo.processInfo.thermalState {
case .nominal, .fair:
    preferredFramesPerSecond = 120
case .serious, .critical:
    preferredFramesPerSecond = 60  // Reduce proactively
@unknown default:
    break
}

// Observe thermal changes
NotificationCenter.default.addObserver(
    forName: ProcessInfo.thermalStateDidChangeNotification,
    object: nil,
    queue: .main
) { _ in
    self.adjustForThermalState()
}
適応型電力(iOS 26 以降、iPhone 17)

iOS 26 の新機能:iPhone 17/17 Pro では、適応型電力がデフォルトでオンになっています。バッテリー残量が 60% でもスロットリングが発生することがあります。

テスト時のユーザー操作:設定 → バッテリー → 電力モード → 適応型電力をオフにします。

適応型電力の状態を検出する公開 API はありません。


パート 6:パフォーマンス予算

フレーム時間の予算
目標 FPSフレーム予算Vsync 間隔
1208.33msVsync ごと
9011.11ms—
6016.67ms2 回目の Vsync ごと
3033.33ms4 回目の Vsync ごと

予算を継続的に超過すると、システムは維持可能な次のレートに低下します。

GPU フレーム時間の測定
func draw(in view: MTKView) {
    guard let commandBuffer = commandQueue.makeCommandBuffer() else { return }

    // Your rendering code...

    commandBuffer.addCompletedHandler { buffer in
        let gpuTime = buffer.gpuEndTime - buffer.gpuStartTime
        let gpuMs = gpuTime * 1000

        if gpuMs > 8.33 {
            print("⚠️ GPU: \(String(format: "%.2f", gpuMs))ms exceeds 120Hz budget")
        }
    }

    commandBuffer.commit()
}
120 を維持できない場合は、より低いレートを均等に目指す

重要:フレームペーシングが不均一だと、安定した低いレートよりも見た目が悪くなります。

// If you can't sustain 8.33ms, explicitly target 60 for smooth cadence
if averageGpuTime > 8.33 && averageGpuTime <= 16.67 {
    mtkView.preferredFramesPerSecond = 60
}

パート 7:フレームペーシング

マイクロスタッタリングの問題

平均 FPS が良好でも、フレームタイミングが不安定だと目に見える揺れが発生します。

// BAD: Inconsistent intervals despite ~40 FPS average
Frame 1: 25ms
Frame 2: 40ms  ← stutter
Frame 3: 25ms
Frame 4: 40ms  ← stutter

// GOOD: Consistent intervals at 30 FPS
Frame 1: 33ms
Frame 2: 33ms
Frame 3: 33ms
Frame 4: 33ms

レンダリング直後に表示すると、この問題が発生します。 明示的なタイミング制御を使用してください。

フレームペーシング API
present(afterMinimumDuration:) — 推奨

フレーム間隔を一定に保ちます:

func draw(in view: MTKView) {
    guard let commandBuffer = commandQueue.makeCommandBuffer(),
          let drawable = view.currentDrawable else { return }

    // Render to drawable...

    // Present with minimum 33ms between frames (30 FPS target)
    commandBuffer.present(drawable, afterMinimumDuration: 0.033)
    commandBuffer.commit()
}
present(at:) — 精密なタイミング

指定した時刻に表示をスケジュールします:

// Present at specific Mach absolute time
let presentTime = CACurrentMediaTime() + 0.033
commandBuffer.present(drawable, atTime: presentTime)
presentedTime — 実際の表示時刻を確認

フレームが実際に表示された時刻を確認します:

drawable.addPresentedHandler { drawable in
    let actualTime = drawable.presentedTime
    if actualTime == 0.0 {
        // Frame was dropped!
        print("⚠️ Frame dropped")
    } else {
        print("Frame presented at: \(actualTime)")
    }
}
フレームペーシングのパターン
class SmoothRenderer: NSObject, MTKViewDelegate {
    private var targetFrameDuration: CFTimeInterval = 1.0 / 60.0  // 60 FPS target

    func draw(in view: MTKView) {
        guard let commandBuffer = commandQueue.makeCommandBuffer(),
              let drawable = view.currentDrawable else { return }

        renderScene(to: drawable)

        // Use frame pacing to ensure consistent intervals
        commandBuffer.present(drawable, afterMinimumDuration: targetFrameDuration)
        commandBuffer.commit()
    }

    func adjustTargetFrameRate(canSustain fps: Int) {
        switch fps {
        case 90...:
            targetFrameDuration = 1.0 / 120.0
        case 50...:
            targetFrameDuration = 1.0 / 60.0
        default:
            targetFrameDuration = 1.0 / 30.0
        }
    }
}

パート 8:ヒッチについて

レンダーループの各フェーズ

フレームのライフサイクル:開始時刻 → コミット期限 → 表示時刻

  1. アプリプロセス(CPU):イベントを処理し、UI 更新を計算し、Core Animation のコミットを実行します
  2. レンダーサーバー(CPU+GPU):UI をビットマップに変換し、バッファーにレンダリングします
  3. ディスプレイドライバー:Vsync 時にバッファーを画面に切り替えます

120Hz では、各フェーズに約 8.33ms あります。いずれかの期限を逃すと、ヒッチが発生します。

コミットヒッチとレンダーヒッチ

コミットヒッチ:アプリプロセスがコミット期限を逃す

  • 原因:メインスレッドの処理に時間がかかりすぎます
  • 対処法:処理をメインスレッド以外に移し、ビューの複雑さを軽減します

レンダーヒッチ:レンダーサーバーが表示期限を逃す

  • 原因:GPU の処理が複雑すぎます(ぼかし、シャドウ、レイヤー)
  • 対処法:視覚効果をシンプルにし、オーバードローを減らします
ダブルバッファリングとトリプルバッファリング

ダブルバッファー(デフォルト):

  • フレームのライフタイム:2 回の Vsync 間隔
  • 厳しい期限
  • 低レイテンシ

トリプルバッファー(システムが有効にする場合があります):

  • フレームのライフタイム:3 回の Vsync 間隔
  • レンダーサーバーに 2 回の Vsync 間隔が与えられます
  • レイテンシは高くなりますが、余裕が増します

システムはレンダーヒッチから回復するため、トリプルバッファリングに自動的に切り替えます。

ヒッチの継続時間
Expected Frame Lifetime = Begin Time → Presentation Time
Actual Frame Lifetime = Begin Time → Actual Vsync

Hitch Duration = Actual - Expected

ヒッチの継続時間が 0 より大きい場合、フレームの表示が遅れ、前のフレームが画面に長く表示されていたことを示します。


パート 9:測定

UIScreen は真実を示さず、実際の表示が真実を示す
// ❌ This says 120 even when system caps you to 60
let maxFPS = UIScreen.main.maximumFramesPerSecond
// Reports capability, not actual rate!

// ✅ Measure from CADisplayLink timing
@objc func displayLinkCallback(_ link: CADisplayLink) {
    // Time available to prepare next frame
    let workingTime = link.targetTimestamp - CACurrentMediaTime()

    // Actual interval since last callback
    if lastTimestamp > 0 {
        let interval = link.timestamp - lastTimestamp
        let actualFPS = 1.0 / interval
    }
    lastTimestamp = link.timestamp
}
Metal Performance HUD

デバイス上でリアルタイムのパフォーマンスオーバーレイを有効にします:

Xcode スキームから設定する場合:

  1. スキームを編集 → 実行 → 診断
  2. 「グラフィックスの概要を表示」を有効にします
  3. 必要に応じて「グラフィックスの概要を記録」も有効にします

環境変数から設定する場合:

MTL_HUD_ENABLED=1

デバイスの設定から設定する場合: 設定 → デベロッパ → グラフィックス HUD → グラフィックス HUD を表示

HUD に表示される情報:

  • FPS(平均)
  • フレームあたりの GPU 時間
  • フレーム間隔のグラフ(直近 120 フレーム)
  • メモリ使用量
MetricKit による本番環境のテレメトリ

本番環境でヒッチを監視します:

import MetricKit

class MetricsManager: NSObject, MXMetricManagerSubscriber {
    func didReceive(_ payloads: [MXMetricPayload]) {
        for payload in payloads {
            if let animationMetrics = payload.animationMetrics {
                // Ratio of time spent hitching during scroll
                let scrollHitchRatio = animationMetrics.scrollHitchTimeRatio

                // Ratio of time spent hitching in all animations
                if #available(iOS 17.0, *) {
                    let hitchRatio = animationMetrics.hitchTimeRatio
                }

                analyzeHitchMetrics(scrollHitchRatio: scrollHitchRatio)
            }
        }
    }
}

// Register for metrics
MXMetricManager.shared.add(metricsManager)

追跡する項目:

  • scrollHitchTimeRatio:スクロール中にヒッチが発生していた時間(UIScrollView のみ)
  • hitchTimeRatio(iOS 17 以降):追跡対象のすべてのアニメーションでヒッチが発生していた時間

パート 10:簡易診断チェックリスト

フレームレートの問題をデバッグする際は、次を確認します:

手順確認項目対処法
1Info.plist キーが存在するか(iPhone)?CADisableMinimumFrameDurationOnPhone を追加します
2フレームレート制限がオフか?設定 → アクセシビリティ → 動作
3低電力モードがオフか?設定 → バッテリー
4適応型電力がオフか(iPhone 17 以降)?設定 → バッテリー → 電力モード
5preferredFramesPerSecond = 120 か?MTKView に明示的に設定します
6preferredFrameRateRange が設定されているか?CADisplayLink に設定します
7GPU フレーム時間が 8.33ms 未満か?Metal HUD または Instruments でプロファイリングします
8フレームペーシングが安定しているか?present(afterMinimumDuration:) を使用します
9本番環境でヒッチが発生しているか?MetricKit で監視します

パート 11:よくあるパターン

パターン:熱状態を考慮した適応型フレームレート
class AdaptiveRenderer: NSObject, MTKViewDelegate {
    private var recentFrameTimes: [Double] = []
    private let sampleCount = 30
    private var targetFrameDuration: CFTimeInterval = 1.0 / 60.0

    func draw(in view: MTKView) {
        guard let commandBuffer = commandQueue.makeCommandBuffer(),
              let drawable = view.currentDrawable else { return }

        let startTime = CACurrentMediaTime()
        renderScene(to: drawable)
        let frameTime = (CACurrentMediaTime() - startTime) * 1000

        updateTargetRate(frameTime: frameTime, view: view)

        commandBuffer.present(drawable, afterMinimumDuration: targetFrameDuration)
        commandBuffer.commit()
    }

    private func updateTargetRate(frameTime: Double, view: MTKView) {
        recentFrameTimes.append(frameTime)
        if recentFrameTimes.count > sampleCount {
            recentFrameTimes.removeFirst()
        }

        let avgFrameTime = recentFrameTimes.reduce(0, +) / Double(recentFrameTimes.count)
        let thermal = ProcessInfo.processInfo.thermalState
        let lowPower = ProcessInfo.processInfo.isLowPowerModeEnabled

        // Constrain based on what we can sustain AND system state
        if lowPower || thermal >= .serious {
            view.preferredFramesPerSecond = 30
            targetFrameDuration = 1.0 / 30.0
        } else if avgFrameTime < 7.0 && thermal == .nominal {
            view.preferredFramesPerSecond = 120
            targetFrameDuration = 1.0 / 120.0
        } else if avgFrameTime < 14.0 {
            view.preferredFramesPerSecond = 60
            targetFrameDuration = 1.0 / 60.0
        } else {
            view.preferredFramesPerSecond = 30
            targetFrameDuration = 1.0 / 30.0
        }
    }
}
パターン:フレーム落ちの検出
class FrameDropMonitor {
    private var expectedPresentTime: CFTimeInterval = 0
    private var dropCount = 0

    func trackFrame(drawable: MTLDrawable, expectedInterval: CFTimeInterval) {
        drawable.addPresentedHandler { [weak self] drawable in
            guard let self = self else { return }

            if drawable.presentedTime == 0.0 {
                self.dropCount += 1
                print("⚠️ Frame dropped (total: \(self.dropCount))")
            } else if self.expectedPresentTime > 0 {
                let actualInterval = drawable.presentedTime - self.expectedPresentTime
                let variance = abs(actualInterval - expectedInterval)

                if variance > expectedInterval * 0.5 {
                    print("⚠️ Frame timing variance: \(variance * 1000)ms")
                }
            }

            self.expectedPresentTime = drawable.presentedTime
        }
    }
}

リソース

WWDC:2021-10147, 2018-612, 2022-10083, 2023-10123

Tech Talks:10855, 10856, 10857(Hitch の詳細解説)

ドキュメント:/quartzcore/cadisplaylink, /quartzcore/cametaldisplaylink, /quartzcore/optimizing-iphone-and-ipad-apps-to-support-promotion-displays, /xcode/understanding-hitches-in-your-app, /metal/mtldrawable/present(afterminimumduration:), /metrickit/mxanimationmetric

スキル:axiom-energy, axiom-ios-graphics, axiom-metal-migration-ref, axiom-performance-profiling

コメント