Add scan flow MVP and local Axiom skill workspace
This snapshot establishes the camera-to-result recognition flow and related tests while checking in the project skill/docs assets required for the configured local tooling.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"source": "CharlesWiltgen/Axiom",
|
||||
"sourceType": "git",
|
||||
"repoUrl": "https://github.com/CharlesWiltgen/Axiom",
|
||||
"subpath": "axiom-codex/skills/axiom-display-performance",
|
||||
"installedAt": "2026-04-12T08:06:12.352Z"
|
||||
}
|
||||
633
.claude/skills/axiom-display-performance/SKILL.md
Normal file
633
.claude/skills/axiom-display-performance/SKILL.md
Normal file
@@ -0,0 +1,633 @@
|
||||
---
|
||||
name: axiom-display-performance
|
||||
description: Use when app runs at unexpected frame rate, stuck at 60fps on ProMotion, frame pacing issues, or configuring render loops. Covers MTKView, CADisplayLink, CAMetalDisplayLink, frame pacing, hitches, system caps.
|
||||
license: MIT
|
||||
metadata:
|
||||
version: "1.0.0"
|
||||
---
|
||||
|
||||
# Display Performance
|
||||
|
||||
Systematic diagnosis for frame rate issues on variable refresh rate displays (ProMotion, iPad Pro, future devices). Covers render loop configuration, frame pacing, hitch mechanics, and production telemetry.
|
||||
|
||||
**Key insight**: "ProMotion available" does NOT mean your app automatically runs at 120Hz. You must configure it correctly, account for system caps, and ensure proper frame pacing.
|
||||
|
||||
---
|
||||
|
||||
## Part 1: Why You're Stuck at 60fps
|
||||
|
||||
### Diagnostic Order
|
||||
|
||||
Check these in order when stuck at 60fps on ProMotion:
|
||||
|
||||
1. **Info.plist key missing?** (iPhone only) → Part 2
|
||||
2. **Render loop configured for 60?** (MTKView defaults, CADisplayLink) → Part 3
|
||||
3. **System caps enabled?** (Low Power Mode, Limit Frame Rate, Thermal) → Part 5
|
||||
4. **Frame time > 8.33ms?** (Can't sustain 120fps) → Part 6
|
||||
5. **Frame pacing issues?** (Micro-stuttering despite good FPS) → Part 7
|
||||
6. **Measuring wrong thing?** (UIScreen vs actual presentation) → Part 9
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Enabling ProMotion on iPhone
|
||||
|
||||
**Critical**: Core Animation won't access frame rates above 60Hz on iPhone unless you add this key.
|
||||
|
||||
```xml
|
||||
<!-- Info.plist -->
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
```
|
||||
|
||||
Without this key:
|
||||
- Your `preferredFrameRateRange` hints are ignored above 60Hz
|
||||
- Other animations may affect your CADisplayLink callback rate
|
||||
- iPad Pro does NOT require this key
|
||||
|
||||
**When to add**: Any iPhone app that needs >60Hz for games, animations, or smooth scrolling.
|
||||
|
||||
---
|
||||
|
||||
## Part 3: Render Loop Configuration
|
||||
|
||||
### MTKView Defaults to 60fps
|
||||
|
||||
**This is the most common cause.** MTKView's `preferredFramesPerSecond` defaults to 60.
|
||||
|
||||
```swift
|
||||
// ❌ 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
|
||||
```
|
||||
|
||||
**Critical settings for continuous high-rate rendering:**
|
||||
|
||||
| Property | Value | Why |
|
||||
|----------|-------|-----|
|
||||
| `preferredFramesPerSecond` | `120` | Request max rate |
|
||||
| `isPaused` | `false` | Don't pause the render loop |
|
||||
| `enableSetNeedsDisplay` | `false` | Continuous mode, not on-demand |
|
||||
|
||||
### CADisplayLink Configuration (iOS 15+)
|
||||
|
||||
Apple explicitly recommends CADisplayLink (not timers) for custom render loops.
|
||||
|
||||
```swift
|
||||
// ❌ 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)
|
||||
```
|
||||
|
||||
**Special priority for games**: iOS 15+ gives 30Hz and 60Hz special priority. If targeting these rates:
|
||||
|
||||
```swift
|
||||
// 30Hz and 60Hz get priority scheduling
|
||||
let prioritizedRange = CAFrameRateRange(
|
||||
minimum: 30,
|
||||
maximum: 60,
|
||||
preferred: 60
|
||||
)
|
||||
displayLink.preferredFrameRateRange = prioritizedRange
|
||||
```
|
||||
|
||||
### Suggested Frame Rates by Content Type
|
||||
|
||||
| Content Type | Suggested Rate | Notes |
|
||||
|--------------|----------------|-------|
|
||||
| Video playback | 24-30 Hz | Match content frame rate |
|
||||
| Scrolling UI | 60-120 Hz | Higher = smoother |
|
||||
| Fast games | 60-120 Hz | Match rendering capability |
|
||||
| Slow animations | 30-60 Hz | Save power |
|
||||
| Static content | 10-24 Hz | Minimal updates needed |
|
||||
|
||||
---
|
||||
|
||||
## Part 4: CAMetalDisplayLink (iOS 17+)
|
||||
|
||||
For Metal apps needing precise timing control, `CAMetalDisplayLink` provides more control than CADisplayLink.
|
||||
|
||||
```swift
|
||||
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)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key differences from CADisplayLink:**
|
||||
|
||||
| Feature | CADisplayLink | CAMetalDisplayLink |
|
||||
|---------|---------------|-------------------|
|
||||
| Drawable access | Manual via layer | Provided in callback |
|
||||
| Latency control | None | `preferredFrameLatency` |
|
||||
| Target timing | timestamp/targetTimestamp | + targetPresentationTimestamp |
|
||||
| Use case | General animation | Metal-specific rendering |
|
||||
|
||||
**When to use CAMetalDisplayLink:**
|
||||
- Need precise control over render timing window
|
||||
- Want to minimize input latency
|
||||
- Building games or intensive Metal apps
|
||||
- iOS 17+ only deployment
|
||||
|
||||
---
|
||||
|
||||
## Part 5: System Caps
|
||||
|
||||
System states can force 60fps even when your code requests 120:
|
||||
|
||||
### Low Power Mode
|
||||
|
||||
**Caps ProMotion devices to 60fps.**
|
||||
|
||||
```swift
|
||||
// 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)
|
||||
}
|
||||
```
|
||||
|
||||
### Limit Frame Rate (Accessibility)
|
||||
|
||||
**Settings → Accessibility → Motion → Limit Frame Rate** caps to 60fps.
|
||||
|
||||
No API to detect. If user reports 60fps despite configuration, have them check this setting.
|
||||
|
||||
### Thermal Throttling
|
||||
|
||||
System restricts 120Hz when device overheats.
|
||||
|
||||
```swift
|
||||
// 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()
|
||||
}
|
||||
```
|
||||
|
||||
### Adaptive Power (iOS 26+, iPhone 17)
|
||||
|
||||
**New in iOS 26**: Adaptive Power is ON by default on iPhone 17/17 Pro. Can throttle even at 60% battery.
|
||||
|
||||
**User action for testing**: Settings → Battery → Power Mode → disable **Adaptive Power**.
|
||||
|
||||
No public API to detect Adaptive Power state.
|
||||
|
||||
---
|
||||
|
||||
## Part 6: Performance Budget
|
||||
|
||||
### Frame Time Budgets
|
||||
|
||||
| Target FPS | Frame Budget | Vsync Interval |
|
||||
|------------|--------------|----------------|
|
||||
| 120 | 8.33ms | Every vsync |
|
||||
| 90 | 11.11ms | — |
|
||||
| 60 | 16.67ms | Every 2nd vsync |
|
||||
| 30 | 33.33ms | Every 4th vsync |
|
||||
|
||||
**If you consistently exceed budget, system drops to next sustainable rate.**
|
||||
|
||||
### Measuring GPU Frame Time
|
||||
|
||||
```swift
|
||||
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()
|
||||
}
|
||||
```
|
||||
|
||||
### Can't Sustain 120? Target Lower Rate Evenly
|
||||
|
||||
**Critical**: Uneven frame pacing looks worse than consistent lower rate.
|
||||
|
||||
```swift
|
||||
// If you can't sustain 8.33ms, explicitly target 60 for smooth cadence
|
||||
if averageGpuTime > 8.33 && averageGpuTime <= 16.67 {
|
||||
mtkView.preferredFramesPerSecond = 60
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 7: Frame Pacing
|
||||
|
||||
### The Micro-Stuttering Problem
|
||||
|
||||
Even with good average FPS, inconsistent frame timing causes visible jitter.
|
||||
|
||||
```
|
||||
// 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
|
||||
```
|
||||
|
||||
**Presenting immediately after rendering causes this.** Use explicit timing control.
|
||||
|
||||
### Frame Pacing APIs
|
||||
|
||||
#### present(afterMinimumDuration:) — Recommended
|
||||
|
||||
Ensures consistent spacing between frames:
|
||||
|
||||
```swift
|
||||
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:) — Precise Timing
|
||||
|
||||
Schedule presentation at specific time:
|
||||
|
||||
```swift
|
||||
// Present at specific Mach absolute time
|
||||
let presentTime = CACurrentMediaTime() + 0.033
|
||||
commandBuffer.present(drawable, atTime: presentTime)
|
||||
```
|
||||
|
||||
#### presentedTime — Verify Actual Presentation
|
||||
|
||||
Check when frames actually appeared:
|
||||
|
||||
```swift
|
||||
drawable.addPresentedHandler { drawable in
|
||||
let actualTime = drawable.presentedTime
|
||||
if actualTime == 0.0 {
|
||||
// Frame was dropped!
|
||||
print("⚠️ Frame dropped")
|
||||
} else {
|
||||
print("Frame presented at: \(actualTime)")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Frame Pacing Pattern
|
||||
|
||||
```swift
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 8: Understanding Hitches
|
||||
|
||||
### Render Loop Phases
|
||||
|
||||
Frame lifecycle: **Begin Time → Commit Deadline → Presentation Time**
|
||||
|
||||
1. **App Process (CPU)**: Handle events, compute UI updates, Core Animation commit
|
||||
2. **Render Server (CPU+GPU)**: Transform UI to bitmap, render to buffer
|
||||
3. **Display Driver**: Swap buffer to screen at vsync
|
||||
|
||||
At 120Hz, each phase has ~8.33ms. Miss any deadline = hitch.
|
||||
|
||||
### Commit Hitch vs Render Hitch
|
||||
|
||||
**Commit Hitch**: App process misses commit deadline
|
||||
- Cause: Main thread work takes too long
|
||||
- Fix: Move work off main thread, reduce view complexity
|
||||
|
||||
**Render Hitch**: Render server misses presentation deadline
|
||||
- Cause: GPU work too complex (blur, shadows, layers)
|
||||
- Fix: Simplify visual effects, reduce overdraw
|
||||
|
||||
### Double vs Triple Buffering
|
||||
|
||||
**Double Buffer (default)**:
|
||||
- Frame lifetime: 2 vsync intervals
|
||||
- Tighter deadlines
|
||||
- Lower latency
|
||||
|
||||
**Triple Buffer (system may enable)**:
|
||||
- Frame lifetime: 3 vsync intervals
|
||||
- Render server gets 2 vsync intervals
|
||||
- Higher latency but more headroom
|
||||
|
||||
The system automatically switches to triple buffering to recover from render hitches.
|
||||
|
||||
### Hitch Duration
|
||||
|
||||
```
|
||||
Expected Frame Lifetime = Begin Time → Presentation Time
|
||||
Actual Frame Lifetime = Begin Time → Actual Vsync
|
||||
|
||||
Hitch Duration = Actual - Expected
|
||||
```
|
||||
|
||||
If hitch duration > 0, the frame was late and previous frame stayed onscreen longer.
|
||||
|
||||
---
|
||||
|
||||
## Part 9: Measurement
|
||||
|
||||
### UIScreen Lies, Actual Presentation Tells Truth
|
||||
|
||||
```swift
|
||||
// ❌ 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
|
||||
|
||||
Enable on-device real-time performance overlay:
|
||||
|
||||
**Via Xcode scheme:**
|
||||
1. Edit Scheme → Run → Diagnostics
|
||||
2. Enable "Show Graphics Overview"
|
||||
3. Optionally enable "Log Graphics Overview"
|
||||
|
||||
**Via environment variable:**
|
||||
```bash
|
||||
MTL_HUD_ENABLED=1
|
||||
```
|
||||
|
||||
**Via device settings:**
|
||||
Settings → Developer → Graphics HUD → Show Graphics HUD
|
||||
|
||||
**HUD shows:**
|
||||
- FPS (average)
|
||||
- GPU time per frame
|
||||
- Frame interval chart (last 120 frames)
|
||||
- Memory usage
|
||||
|
||||
### Production Telemetry with MetricKit
|
||||
|
||||
Monitor hitches in production:
|
||||
|
||||
```swift
|
||||
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)
|
||||
```
|
||||
|
||||
**What to track:**
|
||||
- `scrollHitchTimeRatio`: Time spent hitching while scrolling (UIScrollView only)
|
||||
- `hitchTimeRatio` (iOS 17+): Time spent hitching in all tracked animations
|
||||
|
||||
---
|
||||
|
||||
## Part 10: Quick Diagnostic Checklist
|
||||
|
||||
When debugging frame rate issues:
|
||||
|
||||
| Step | Check | Fix |
|
||||
|------|-------|-----|
|
||||
| 1 | Info.plist key present? (iPhone) | Add `CADisableMinimumFrameDurationOnPhone` |
|
||||
| 2 | Limit Frame Rate off? | Settings → Accessibility → Motion |
|
||||
| 3 | Low Power Mode off? | Settings → Battery |
|
||||
| 4 | Adaptive Power off? (iPhone 17+) | Settings → Battery → Power Mode |
|
||||
| 5 | preferredFramesPerSecond = 120? | Set explicitly on MTKView |
|
||||
| 6 | preferredFrameRateRange set? | Configure on CADisplayLink |
|
||||
| 7 | GPU frame time < 8.33ms? | Profile with Metal HUD or Instruments |
|
||||
| 8 | Frame pacing consistent? | Use present(afterMinimumDuration:) |
|
||||
| 9 | Hitches in production? | Monitor with MetricKit |
|
||||
|
||||
---
|
||||
|
||||
## Part 11: Common Patterns
|
||||
|
||||
### Pattern: Adaptive Frame Rate with Thermal Awareness
|
||||
|
||||
```swift
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Frame Drop Detection
|
||||
|
||||
```swift
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
**WWDC**: 2021-10147, 2018-612, 2022-10083, 2023-10123
|
||||
|
||||
**Tech Talks**: 10855, 10856, 10857 (Hitch deep dives)
|
||||
|
||||
**Docs**: /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
|
||||
|
||||
**Skills**: axiom-energy, axiom-ios-graphics, axiom-metal-migration-ref, axiom-performance-profiling
|
||||
@@ -0,0 +1,3 @@
|
||||
interface:
|
||||
display_name: "Display Performance"
|
||||
short_description: "App runs at unexpected frame rate, stuck at 60fps on ProMotion, frame pacing issues, or configuring render loops"
|
||||
Reference in New Issue
Block a user