axiom-display-performance について
Appleプラットフォームの開発者向けに、可変リフレッシュレート画面の表示性能を診断するリファレンスです。レンダリングループ、フレームペーシング、ヒッチ、運用時の計測を扱い、60 fps制限の調査から実際の表示計測まで解説します。
主な機能
- 01ProMotionのフレームレート制限を診断
- 02MTKViewとCADisplayLinkの設定
- 03CAMetalDisplayLinkによるタイミング制御
- 04フレームペーシング改善とヒッチ分析
- 05実際のフレーム表示を計測
- 06MetricKitでヒッチを監視
活用シーン
- 01ProMotion端末が60 fpsに留まる原因を調査
- 02Metalアプリの描画タイミングを調整
- 03アニメーションやスクロールの微細なカクつきを改善
- 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 のままになる場合は、次の順に確認してください。
- Info.plist のキーが不足している?(iPhone のみ)→ パート 2
- レンダーループが 60 に設定されている?(MTKView のデフォルト設定、CADisplayLink)→ パート 3
- システムによる上限が有効になっている?(低電力モード、フレームレートを制限、熱)→ パート 5
- フレーム時間が 8.33ms を超えている?(120fps を維持できない)→ パート 6
- フレームペーシングに問題がある?(FPS は良好なのにマイクロスタッタリングが発生する)→ パート 7
- 測定対象を間違えている?(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
高レートの連続レンダリングに必要な設定:
| プロパティ | 値 | 理由 |
|---|---|---|
preferredFramesPerSecond | 120 | 最大レートを要求する |
isPaused | false | レンダーループを一時停止しない |
enableSetNeedsDisplay | false | オンデマンドではなく連続モードにする |
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 との主な違い:
| 機能 | CADisplayLink | CAMetalDisplayLink |
|---|---|---|
| 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 間隔 |
|---|---|---|
| 120 | 8.33ms | Vsync ごと |
| 90 | 11.11ms | — |
| 60 | 16.67ms | 2 回目の Vsync ごと |
| 30 | 33.33ms | 4 回目の 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:ヒッチについて
レンダーループの各フェーズ
フレームのライフサイクル:開始時刻 → コミット期限 → 表示時刻
- アプリプロセス(CPU):イベントを処理し、UI 更新を計算し、Core Animation のコミットを実行します
- レンダーサーバー(CPU+GPU):UI をビットマップに変換し、バッファーにレンダリングします
- ディスプレイドライバー: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 スキームから設定する場合:
- スキームを編集 → 実行 → 診断
- 「グラフィックスの概要を表示」を有効にします
- 必要に応じて「グラフィックスの概要を記録」も有効にします
環境変数から設定する場合:
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:簡易診断チェックリスト
フレームレートの問題をデバッグする際は、次を確認します:
| 手順 | 確認項目 | 対処法 |
|---|---|---|
| 1 | Info.plist キーが存在するか(iPhone)? | CADisableMinimumFrameDurationOnPhone を追加します |
| 2 | フレームレート制限がオフか? | 設定 → アクセシビリティ → 動作 |
| 3 | 低電力モードがオフか? | 設定 → バッテリー |
| 4 | 適応型電力がオフか(iPhone 17 以降)? | 設定 → バッテリー → 電力モード |
| 5 | preferredFramesPerSecond = 120 か? | MTKView に明示的に設定します |
| 6 | preferredFrameRateRange が設定されているか? | CADisplayLink に設定します |
| 7 | GPU フレーム時間が 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