feat: add proper CADisplayLink handling for iOS15 where commandBuffer presentDrawable:drawable afterMinimumDuration doesn't seem to work well

This commit is contained in:
Acaki
2025-08-18 19:37:14 +08:00
parent ea1a2f3cd0
commit ee67ff56c7
5 changed files with 126 additions and 9 deletions
+9
View File
@@ -22,12 +22,21 @@
@property (nonatomic) id<CAMetalDrawable> _Nullable nextDrawable;
@property (atomic) BOOL isStopping;
// iOS 15.0 and below frame timing properties
@property (nonatomic) BOOL useLegacyFrameTiming;
@property (nonatomic) CFTimeInterval lastDisplayLinkTime;
@property (nonatomic) CFTimeInterval displayLinkFrametime;
- (nonnull instancetype)initWithMetalDevice:(nonnull id<MTLDevice>)device drawablePixelFormat:(MTLPixelFormat)drawablePixelFormat framerate:(float)framerate;
- (void)renderFrame:(nonnull Frame *)frame toLayer:(nonnull CAMetalLayer *)layer API_AVAILABLE(ios(13.0));
- (void)waitToRenderTo:(nonnull CAMetalLayer *)layer API_AVAILABLE(ios(13.0));
- (void)drawableResize:(CGSize)drawableSize;
- (void)shutdown;
// iOS 15.0 and below legacy timing support
- (void)updateLegacyFrameTiming:(CFTimeInterval)displayLinkTimestamp;
- (void)resetFrameTiming;
+ (NSString *_Nullable)currentColorSpace;
@end
+60 -5
View File
@@ -157,6 +157,17 @@ CFStringRef __currentColorSpace;
_lastPresented = 0.0f;
_inFlightSemaphore = dispatch_semaphore_create(MaxFramesInFlight);
_isStopping = NO;
// Enable legacy frame timing for iOS 15.0 and below to fix timing issues
if (@available(iOS 16.0, *)) {
_useLegacyFrameTiming = NO;
Log(LOG_I, @"MetalVideoRenderer: Using iOS 16+ timing (Metal presentedTime)");
} else {
_useLegacyFrameTiming = YES;
_lastDisplayLinkTime = 0.0f;
_displayLinkFrametime = 1.0f / framerate * 1000.0f; // Expected frametime in ms
Log(LOG_I, @"MetalVideoRenderer: Using legacy timing for iOS 15.0 and below (CADisplayLink)");
}
CFStringRef keys[1] = {kCVMetalTextureUsage};
NSUInteger values[1] = {MTLTextureUsageShaderRead};
@@ -713,14 +724,21 @@ CFStringRef __currentColorSpace;
[renderEncoder drawPrimitives:MTLPrimitiveTypeTriangleStrip vertexStart:0 vertexCount:4];
[renderEncoder endEncoding];
__block MetalVideoRenderer *strongSelf = self;
#if !TARGET_OS_SIMULATOR
__block MetalVideoRenderer *strongSelf = self;
[drawable addPresentedHandler:^(id<MTLDrawable> d) {
if (strongSelf.lastPresented > 0.0f) {
CFTimeInterval frametime = d.presentedTime - strongSelf.lastPresented;
[[ImGuiPlots sharedInstance] observeFloat:PLOT_FRAMETIME value:(frametime * 1000.0)];
// Only use Metal's presentedTime on iOS 16+ due to timing bugs on iOS 15.0
if (!strongSelf.useLegacyFrameTiming) {
if (strongSelf.lastPresented > 0.0f) {
CFTimeInterval frametime = d.presentedTime - strongSelf.lastPresented;
// Sanity check: reject negative or impossibly large frametimes
if (frametime > 0 && frametime < 0.1) { // Less than 100ms
[[ImGuiPlots sharedInstance] observeFloat:PLOT_FRAMETIME value:(frametime * 1000.0)];
}
}
strongSelf.lastPresented = d.presentedTime;
}
strongSelf.lastPresented = d.presentedTime;
// For iOS 15.0 and below, frametime is calculated in updateLegacyFrameTiming
}];
#endif
@@ -777,6 +795,43 @@ CFStringRef __currentColorSpace;
}
}
#pragma mark - iOS 15.0 and below legacy timing support
- (void)updateLegacyFrameTiming:(CFTimeInterval)displayLinkTimestamp {
if (!_useLegacyFrameTiming) {
return; // Only for iOS 15.0 and below
}
if (_lastDisplayLinkTime > 0.0f) {
CFTimeInterval actualFrametime = (displayLinkTimestamp - _lastDisplayLinkTime) * 1000.0; // Convert to ms
// Sanity check: reject negative or impossibly large frametimes
if (actualFrametime > 0 && actualFrametime < 100.0) { // Less than 100ms
[[ImGuiPlots sharedInstance] observeFloat:PLOT_FRAMETIME value:actualFrametime];
_displayLinkFrametime = actualFrametime;
} else {
// Use expected frametime if actual is unreliable
Log(LOG_W, @"MetalVideoRenderer: Rejecting invalid frametime %.2f ms, using expected %.2f ms", actualFrametime, _displayLinkFrametime);
[[ImGuiPlots sharedInstance] observeFloat:PLOT_FRAMETIME value:_displayLinkFrametime];
}
}
_lastDisplayLinkTime = displayLinkTimestamp;
}
- (void)resetFrameTiming {
if (_useLegacyFrameTiming) {
// Reset legacy timing state for iOS 15.0 and below
_lastDisplayLinkTime = 0.0f;
_displayLinkFrametime = 1.0f / _framerate * 1000.0f;
Log(LOG_I, @"MetalVideoRenderer: Reset legacy frame timing for iOS 15.0 and below");
} else {
// Reset Metal timing state for iOS 16+
_lastPresented = 0.0f;
Log(LOG_I, @"MetalVideoRenderer: Reset Metal frame timing for iOS 16+");
}
}
/// Responds to the drawable's size or orientation changes.
- (void)drawableResize:(CGSize)drawableSize {
[self resize:drawableSize];
+3
View File
@@ -22,4 +22,7 @@
enableHdr:(BOOL)enableHdr
metricsHandler:(MetricsHandler _Nonnull)metricsHandler;
// Reset frame timing for iOS 15.0 and below compatibility
- (void)resetFrameTiming;
@end
+48 -4
View File
@@ -92,19 +92,35 @@
view.metalLayer.pixelFormat = renderer.colorPixelFormat;
view.metalLayer.maximumDrawableCount = 3;
// We need a no-op displaylink timer or iOS can decide to run at 60fps
// The overhead from this should be minimal.
// We need a displaylink timer for iOS 15.0 and below to provide accurate frame timing,
// or just as a no-op for iOS 16+ to prevent iOS from dropping to 60fps
_displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(displayLinkHandler:)];
if (@available(iOS 15.0, tvOS 15.0, *)) {
if (@available(iOS 16.0, tvOS 16.0, *)) {
// iOS 16+: Use CAFrameRateRange, displaylink is just a no-op
_displayLink.preferredFrameRateRange = CAFrameRateRangeMake(_framerate, _framerate, _framerate);
Log(LOG_I, @"MetalViewController: DisplayLink using iOS 16+ CAFrameRateRange");
} else if (@available(iOS 15.0, tvOS 15.0, *)) {
// iOS 15.0: Use legacy preferredFramesPerSecond to avoid CAFrameRateRange bugs
_displayLink.preferredFramesPerSecond = _framerate;
Log(LOG_I, @"MetalViewController: DisplayLink using iOS 15.0 legacy preferredFramesPerSecond");
} else {
// iOS 14 and below
_displayLink.preferredFramesPerSecond = _framerate;
}
[_displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
}
- (void)displayLinkHandler:(CADisplayLink *)link {
// Rendering does not use DisplayLink, this exists to fool iOS into keeping us running at the desired framerate
// For iOS 16+: This is just a no-op to fool iOS into keeping us running at the desired framerate
// For iOS 15.0 and below: Update renderer with accurate frame timing to fix negative framerates
if (@available(iOS 16.0, *)) {
// No-op for iOS 16+, Metal's presentedTime is reliable
} else {
// iOS 15.0 and below: Use DisplayLink timing since Metal's presentedTime is unreliable
if (_renderer) {
[_renderer updateLegacyFrameTiming:link.timestamp];
}
}
}
- (void)waitToRenderTo:(nonnull CAMetalLayer *)layer {
@@ -154,4 +170,32 @@
}
#endif
- (void)resetFrameTiming {
// Reset the renderer's frame timing state for iOS 15.0 and below compatibility
if (_renderer) {
[_renderer resetFrameTiming];
Log(LOG_I, @"MetalViewController: Reset frame timing called");
}
// Reset DisplayLink timing for iOS 15.0 and below
if (@available(iOS 16.0, *)) {
// No additional DisplayLink reset needed for iOS 16+
} else {
// For iOS 15.0 and below, pause and resume DisplayLink to re-engage timing
if (_displayLink) {
_displayLink.paused = YES;
// Re-apply frame rate (avoid CAFrameRateRange on iOS 15.0)
if (@available(iOS 15.0, *)) {
_displayLink.preferredFramesPerSecond = _framerate;
} else {
_displayLink.preferredFramesPerSecond = _framerate;
}
_displayLink.paused = NO;
Log(LOG_I, @"MetalViewController: Reset DisplayLink timing for iOS 15.0 and below");
}
}
}
@end
@@ -1043,6 +1043,12 @@
}
[self->_streamMan.videoRenderer resetFramePacing];
// Also reset Metal renderer timing for iOS 15.0 and below compatibility
if (self.metalViewController) {
[self.metalViewController resetFrameTiming];
}
_isRestoringFromPiP = NO;
}