diff --git a/VoidLink/Database/TemporarySettings.m b/VoidLink/Database/TemporarySettings.m index 499608c0..f7446b48 100644 --- a/VoidLink/Database/TemporarySettings.m +++ b/VoidLink/Database/TemporarySettings.m @@ -52,15 +52,7 @@ self.statsOverlay = [[NSUserDefaults standardUserDefaults] boolForKey:@"statsOverlay"]; self.enableGraphs = [[NSUserDefaults standardUserDefaults] boolForKey:@"enableGraphs"]; self.graphOpacity = [NSNumber numberWithInteger:[[NSUserDefaults standardUserDefaults] integerForKey:@"graphOpacity"]]; - // Set rendering backend based on iOS version and user preference - NSInteger savedBackend = [[NSUserDefaults standardUserDefaults] integerForKey:@"renderingBackend"]; - if (@available(iOS 17.0, *)) { - // iOS 17+ can use Performance mode (Metal renderer) - self.renderingBackend = [NSNumber numberWithInteger:savedBackend]; - } else { - // iOS < 17 must use Balanced mode (AVSB renderer) - self.renderingBackend = [NSNumber numberWithInteger:RENDER_AVSB]; - } + self.renderingBackend = [NSNumber numberWithInteger:[[NSUserDefaults standardUserDefaults] integerForKey:@"renderingBackend"]]; self.framePacingMode = [NSNumber numberWithInteger:[[NSUserDefaults standardUserDefaults] integerForKey:@"framePacingMode"]]; NSInteger _screenSize = [[NSUserDefaults standardUserDefaults] integerForKey:@"streamResolution"]; diff --git a/VoidLink/Metal/MetalVideoRenderer.h b/VoidLink/Metal/MetalVideoRenderer.h index 9392480f..954374bc 100644 --- a/VoidLink/Metal/MetalVideoRenderer.h +++ b/VoidLink/Metal/MetalVideoRenderer.h @@ -24,9 +24,8 @@ @property (nonatomic) BOOL hdrEnabled; - (nonnull instancetype)initWithMetalDevice:(nonnull id)device drawablePixelFormat:(MTLPixelFormat)drawablePixelFormat framerate:(float)framerate hdrEnabled:(BOOL)hdrEnabled; -- (void)renderFrame:(nonnull Frame *)frame withDrawable:(nonnull id)drawable API_AVAILABLE(ios(17.0)); -- (void)renderFrame:(nonnull Frame *)frame withDrawable:(nonnull id)drawable targetPresentationTimestamp:(CFTimeInterval)targetPresentationTimestamp API_AVAILABLE(ios(17.0)); -- (void)waitToRenderTo:(nonnull CAMetalLayer *)layer; +- (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; diff --git a/VoidLink/Metal/MetalVideoRenderer.m b/VoidLink/Metal/MetalVideoRenderer.m index 3194ef03..d32e8448 100644 --- a/VoidLink/Metal/MetalVideoRenderer.m +++ b/VoidLink/Metal/MetalVideoRenderer.m @@ -557,29 +557,21 @@ CFStringRef __currentColorSpace; return YES; } - -- (void)renderFrame:(Frame *)frame withDrawable:(id)drawable targetPresentationTimestamp:(CFTimeInterval)targetPresentationTimestamp API_AVAILABLE(ios(17.0)) { +- (void)renderFrame:(Frame *)frame toLayer:(CAMetalLayer *)layer { @autoreleasepool { if (self.isStopping) { Log(LOG_I, @"[MetalVideoRenderer] isStopping"); return; } - // Calculate accurate frametime using CAMetalDisplayLink timing - if (self.lastPresented > 0.0f) { - CFTimeInterval frametime = targetPresentationTimestamp - self.lastPresented; - [[ImGuiPlots sharedInstance] observeFloat:PLOT_FRAMETIME value:(frametime * 1000.0)]; - } - self.lastPresented = targetPresentationTimestamp; - // Handle changes to the frame's colorspace from last time we rendered BOOL layerDidChange = NO; - if (![self updateColorSpaceForFrame:frame toLayer:(CAMetalLayer *)drawable.layer layerDidChange:&layerDidChange]) { + if (![self updateColorSpaceForFrame:frame toLayer:layer layerDidChange:&layerDidChange]) { return; } // Handle changes to the video size or drawable size - if (![self updateVideoRegionSizeForFrame:frame toLayer:(CAMetalLayer *)drawable.layer]) { + if (![self updateVideoRegionSizeForFrame:frame toLayer:layer]) { return; } @@ -604,6 +596,13 @@ CFStringRef __currentColorSpace; } } + // Get the next drawable early to get its pixel format + id drawable = [layer nextDrawable]; + if (!drawable) { + Log(LOG_E, @"Failed to get nextDrawable"); + return; + } + // Get the framebuffer pixel format for pipeline creation MTLPixelFormat framebufferPixelFormat = drawable.texture.pixelFormat; @@ -710,7 +709,7 @@ CFStringRef __currentColorSpace; [renderEncoder setVertexBuffer:_VideoVertexBuffer offset:0 atIndex:0]; [renderEncoder setFragmentBuffer:_CscParamsBuffer offset:0 atIndex:0]; #if !TARGET_OS_TV - if (((CAMetalLayer *)drawable.layer).pixelFormat == MTLPixelFormatRGBA16Float) { + if (layer.pixelFormat == MTLPixelFormatRGBA16Float) { [self pollCurrentEDRHeadroom]; [renderEncoder setFragmentBytes:&_currentEDRHeadroom length:sizeof(float) atIndex:1]; } @@ -718,30 +717,25 @@ CFStringRef __currentColorSpace; [renderEncoder drawPrimitives:MTLPrimitiveTypeTriangleStrip vertexStart:0 vertexCount:4]; [renderEncoder endEncoding]; + __block MetalVideoRenderer *strongSelf = self; +#if !TARGET_OS_SIMULATOR + [drawable addPresentedHandler:^(id d) { + if (strongSelf.lastPresented > 0.0f) { + CFTimeInterval frametime = d.presentedTime - strongSelf.lastPresented; + [[ImGuiPlots sharedInstance] observeFloat:PLOT_FRAMETIME value:(frametime * 1000.0)]; + } + strongSelf.lastPresented = d.presentedTime; + }]; +#endif + // signal semaphore, compute GPU time average, and clear textures __block dispatch_semaphore_t block_semaphore = _inFlightSemaphore; [commandBuffer addCompletedHandler:^(id cb) { dispatch_semaphore_signal(block_semaphore); - if (cb.error) { - Log(LOG_E, @"Command buffer error: %@", cb.error); - // Request IDR frame to recover from rendering error - LiRequestIdrFrame(); - - // Clear the command queue to reset GPU state - if (cb.status == MTLCommandBufferStatusError) { - NSError *error = cb.error; - if (error.code == 2 || error.code == 4) { // GPU timeout or submissions ignored - Log(LOG_W, @"GPU timeout detected, flushing command queue"); - // Force texture cache flush - CVMetalTextureCacheFlush(self->_textureCache, 0); - } - } - } else { - const CFTimeInterval GPUTime = cb.GPUEndTime - cb.GPUStartTime; - const double alpha = 0.25f; - self->_averageGPUTime = (GPUTime * alpha) + (self->_averageGPUTime * (1.0 - alpha)); - } + const CFTimeInterval GPUTime = cb.GPUEndTime - cb.GPUStartTime; + const double alpha = 0.25f; + self->_averageGPUTime = (GPUTime * alpha) + (self->_averageGPUTime * (1.0 - alpha)); // Free textures after completion of rendering for (size_t i = 0; i < planes; i++) { @@ -754,10 +748,15 @@ CFStringRef __currentColorSpace; CVMetalTextureCacheFlush(self->_textureCache, 0); }]; - // CAMetalDisplayLink handles timing automatically, so we don't use afterMinimumDuration +#if TARGET_OS_SIMULATOR [commandBuffer presentDrawable:drawable]; +#else + // present for a minimum duration for best frame pacing + [commandBuffer presentDrawable:drawable afterMinimumDuration:1.0f / _framerate]; +#endif [commandBuffer commit]; + [commandBuffer waitUntilCompleted]; } } @@ -766,14 +765,7 @@ CFStringRef __currentColorSpace; // by any stage in the Metal pipeline (CPU, GPU, Metal, Drivers, etc.). if (!self.isStopping) { dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1f * NSEC_PER_SEC)); // 100ms - long result = dispatch_semaphore_wait(_inFlightSemaphore, timeout); - if (result != 0) { - Log(LOG_W, @"MetalVideoRenderer semaphore timeout - requesting IDR frame"); - LiRequestIdrFrame(); // Request recovery - - // Force flush the texture cache when we timeout - CVMetalTextureCacheFlush(_textureCache, 0); - } + dispatch_semaphore_wait(_inFlightSemaphore, timeout); } } diff --git a/VoidLink/Metal/MetalView.h b/VoidLink/Metal/MetalView.h index 304be491..4564f51d 100644 --- a/VoidLink/Metal/MetalView.h +++ b/VoidLink/Metal/MetalView.h @@ -15,14 +15,13 @@ @protocol MetalViewDelegate - (void)drawableResize:(CGSize)size; -- (void)renderWithDrawable:(nonnull id)drawable toLayer:(nonnull CAMetalLayer *)layer API_AVAILABLE(ios(17.0)); -- (void)renderWithDrawable:(nonnull id)drawable toLayer:(nonnull CAMetalLayer *)layer targetPresentationTimestamp:(CFTimeInterval)targetPresentationTimestamp API_AVAILABLE(ios(17.0)); +- (void)renderTo:(nonnull CAMetalLayer *)layer; - (void)waitToRenderTo:(nonnull CAMetalLayer *)layer; @end // The Metal game view base class. -@interface MetalView : UIView +@interface MetalView : UIView @property (nonatomic, nonnull, readonly) CAMetalLayer *metalLayer; @property (nonatomic, nullable) id delegate; diff --git a/VoidLink/Metal/MetalView.m b/VoidLink/Metal/MetalView.m index 3835be44..ca967738 100644 --- a/VoidLink/Metal/MetalView.m +++ b/VoidLink/Metal/MetalView.m @@ -11,12 +11,10 @@ #import "MetalView.h" #import "MetalConfig.h" -#import @implementation MetalView { // The secondary thread containing the render loop. NSThread *_renderThread; - CAMetalDisplayLink *_metalDisplayLink API_AVAILABLE(ios(17.0)); } #pragma mark - Initialization and Setup. @@ -42,43 +40,15 @@ self.layer.delegate = self; } -- (void)setFramerate:(float)framerate { - _framerate = framerate; - - if (@available(iOS 17.0, *)) { - if (_metalDisplayLink) { - _metalDisplayLink.preferredFrameRateRange = CAFrameRateRangeMake(framerate, framerate, framerate); - } - } -} - - (void)shutdown { - // First cancel the thread to stop the run loop if (_renderThread) { Log(LOG_I, @"[MetalView] sending renderThread a cancel message"); [_renderThread cancel]; - - // Invalidate metal display link to stop callbacks - if (@available(iOS 17.0, *)) { - if (_metalDisplayLink) { - Log(LOG_I, @"[MetalView] invalidating metal display link"); - [_metalDisplayLink invalidate]; - _metalDisplayLink = nil; - } - } - - // Now wait for thread to finish Log(LOG_I, @"[MetalView] waiting on renderThread to finish"); - NSDate *timeout = [NSDate dateWithTimeIntervalSinceNow:2.0]; // 2 second timeout - while (!_renderThread.isFinished && [timeout timeIntervalSinceNow] > 0) { - usleep(1000); - } - - if (_renderThread.isFinished) { - Log(LOG_I, @"[MetalView] renderThread has finished"); - } else { - Log(LOG_E, @"[MetalView] renderThread failed to finish in time"); + while (!_renderThread.isFinished) { + usleep(100); } + Log(LOG_I, @"[MetalView] renderThread has finished"); _renderThread = nil; } } @@ -91,26 +61,6 @@ [self movedToWindow]; } -- (void)metalDisplayLink:(CAMetalDisplayLink *)link needsUpdate:(CAMetalDisplayLinkUpdate *)update API_AVAILABLE(ios(17.0)) { - // Skip rendering if we're shutting down - if ([NSThread currentThread].isCancelled) { - return; - } - @autoreleasepool { - [self.delegate waitToRenderTo:self.metalLayer]; - - id drawable = update.drawable; - if (drawable) { - // Pass timing information to the renderer - if ([self.delegate respondsToSelector:@selector(renderWithDrawable:toLayer:targetPresentationTimestamp:)]) { - [self.delegate renderWithDrawable:drawable toLayer:self.metalLayer targetPresentationTimestamp:update.targetPresentationTimestamp]; - } else { - [self.delegate renderWithDrawable:drawable toLayer:self.metalLayer]; - } - } - } -} - - (void)movedToWindow { if (!self.window) { Log(LOG_I, @"[MetalView] movedToWindow(nil): shutting down..."); @@ -118,32 +68,12 @@ return; } - if (@available(iOS 17.0, *)) { - _metalDisplayLink = [[CAMetalDisplayLink alloc] initWithMetalLayer:_metalLayer]; - _metalDisplayLink.delegate = self; - _metalDisplayLink.preferredFrameRateRange = CAFrameRateRangeMake(_framerate, _framerate, _framerate); - Log(LOG_I, @"[MetalView] Using CAMetalDisplayLink for optimal Metal rendering"); - } - - // Start the display link on a background thread + // Render on a new thread _renderThread = [[NSThread alloc] initWithBlock:^{ - // Add metal display link to this thread's run loop - if (@available(iOS 17.0, *)) { - [self->_metalDisplayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode]; - } - - // Keep the run loop alive, checking for cancellation regularly - while (![NSThread currentThread].isCancelled && self->_metalDisplayLink) { + while (![NSThread currentThread].isCancelled) { @autoreleasepool { - // Run the run loop for a short time to allow checking cancellation - [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]]; - } - } - - // Remove from run loop if still valid - if (@available(iOS 17.0, *)) { - if (self->_metalDisplayLink) { - [self->_metalDisplayLink removeFromRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode]; + [self.delegate waitToRenderTo:self.metalLayer]; + [self.delegate renderTo:self.metalLayer]; } } Log(LOG_I, @"[MetalView] renderThread is exiting"); @@ -151,7 +81,7 @@ _renderThread.name = @"MetalVideoRenderer"; _renderThread.qualityOfService = NSQualityOfServiceUserInteractive; [_renderThread start]; - Log(LOG_I, @"[MetalView] started renderThread with CAMetalDisplayLink at %f fps", _framerate); + Log(LOG_I, @"[MetalView] started renderThread %@", _renderThread); // Perform any actions that need to know the size and scale of the drawable. When UIKit calls // didMoveToWindow after the view initialization, this is the first opportunity to notify diff --git a/VoidLink/Metal/MetalViewController.m b/VoidLink/Metal/MetalViewController.m index 96d4342d..46868bcd 100644 --- a/VoidLink/Metal/MetalViewController.m +++ b/VoidLink/Metal/MetalViewController.m @@ -18,6 +18,7 @@ MetalView *_metalView; MetalVideoRenderer *_renderer; MetricsHandler _metricsHandler; + CADisplayLink *_displayLink; } - (nonnull instancetype)initWithFrame:(CGRect)bounds framerate:(float)framerate enableHdr:(BOOL)enableHdr metricsHandler:(MetricsHandler)metricsHandler { @@ -73,6 +74,20 @@ // Initialize the renderer-dependent view properties. 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. + _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(displayLinkHandler:)]; + if (@available(iOS 15.0, tvOS 15.0, *)) { + _displayLink.preferredFrameRateRange = CAFrameRateRangeMake(_framerate, _framerate, _framerate); + } else { + _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 } - (void)waitToRenderTo:(nonnull CAMetalLayer *)layer { @@ -90,23 +105,17 @@ [_frameQueue waitForEnqueue]; } -- (void)renderWithDrawable:(nonnull id)drawable toLayer:(nonnull CAMetalLayer *)layer API_AVAILABLE(ios(17.0)) { +/// Draw frame (used by manual loop) +- (void)renderTo:(nonnull CAMetalLayer *)layer { CFTimeInterval timeout = (1.0f / _framerate) - _renderer.averageGPUTime; Frame *frame = [_frameQueue dequeueWithTimeout:timeout]; + if (!_renderer.isStopping) { // Only render if not paused if (frame) { - [_renderer renderFrame:frame withDrawable:drawable]; - } - } -} - -- (void)renderWithDrawable:(nonnull id)drawable toLayer:(nonnull CAMetalLayer *)layer targetPresentationTimestamp:(CFTimeInterval)targetPresentationTimestamp API_AVAILABLE(ios(17.0)) { - if (!_renderer.isStopping) { - CFTimeInterval timeout = (1.0f / _framerate) - _renderer.averageGPUTime; - Frame *frame = [_frameQueue dequeueWithTimeout:timeout]; - if (frame) { - [_renderer renderFrame:frame withDrawable:drawable targetPresentationTimestamp:targetPresentationTimestamp]; + if (@available(iOS 13.0, *)) { + [_renderer renderFrame:frame toLayer:layer]; + } } } else { // When paused, we still dequeue frames to prevent accumulation @@ -120,6 +129,9 @@ } - (void)pauseRendering { + if (_displayLink) { + _displayLink.paused = YES; + } if (_renderer) { _renderer.isStopping = YES; } @@ -130,6 +142,9 @@ if (_renderer) { _renderer.isStopping = NO; } + if (_displayLink) { + _displayLink.paused = NO; + } Log(LOG_I, @"[MetalViewController] Rendering resumed"); } @@ -138,13 +153,16 @@ Log(LOG_I, @"[MetalViewController] viewDidDisappear"); - // Shutdown the renderer first + if (_displayLink) { + [_displayLink invalidate]; + _displayLink = nil; + } + if (_renderer) { [_renderer shutdown]; _renderer = nil; } - // Then shutdown the view's display link if (_metalView) { _metalView.delegate = nil; [_metalView shutdown]; diff --git a/VoidLink/ViewControllers/SettingsViewController.m b/VoidLink/ViewControllers/SettingsViewController.m index d48f091a..35b2250c 100644 --- a/VoidLink/ViewControllers/SettingsViewController.m +++ b/VoidLink/ViewControllers/SettingsViewController.m @@ -320,7 +320,7 @@ BOOL isCustomResolution(int resolutionSelected) { selector:@selector(deviceOrientationDidChange:) // handle orientation change since i made portrait mode available name:UIDeviceOrientationDidChangeNotification object:nil]; - + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ if(self.mainFrameViewController.settingsExpandedInStreamView){ NSInteger responseCode = [self.mainFrameViewController requestForBitrate:self->_bitrate]; @@ -570,11 +570,7 @@ BOOL isCustomResolution(int resolutionSelected) { [self addSetting:self.pipStack ofId:@"pipStack" withInfoTag:YES withDynamicLabel:NO to:videoSection]; [self addSetting:self.framePacingStack ofId:@"framePacingStack" withInfoTag:YES withDynamicLabel:NO to:videoSection]; [self addSetting:self.frameQueueSizeStack ofId:@"frameQueueSizeStack" withInfoTag:NO withDynamicLabel:YES to:videoSection]; - - // Only show Metal renderer option on iOS 17+ where CAMetalDisplayLink is available - if (@available(iOS 17.0, *)) { - [self addSetting:self.renderingBackendStack ofId:@"renderingBackendStack" withInfoTag:YES withDynamicLabel:NO to:videoSection]; - } + [self addSetting:self.renderingBackendStack ofId:@"renderingBackendStack" withInfoTag:YES withDynamicLabel:NO to:videoSection]; [videoSection addToParentStack:_parentStack]; [videoSection setExpanded:YES]; @@ -653,10 +649,10 @@ BOOL isCustomResolution(int resolutionSelected) { [self addSetting:self.optimizeGamesStack ofId:@"optimizeGamesStack" withInfoTag:YES withDynamicLabel:NO to:otherSection]; [self addSetting:self.multiControllerStack ofId:@"multiControllerStack" withInfoTag:NO withDynamicLabel:NO to:otherSection]; [self addSetting:self.softKeyboardToolbarStack ofId:@"softKeyboardToolbarStack" withInfoTag:NO withDynamicLabel:NO to:otherSection]; - + [self addSetting:self.performanceGraphStack ofId:@"performanceGraphStack" withInfoTag:YES withDynamicLabel:NO to:otherSection]; [self addDynamicLabelForStack:self.graphOpacityStack]; - + [otherSection addToParentStack:_parentStack]; [otherSection setExpanded:YES]; @@ -1095,7 +1091,7 @@ BOOL isCustomResolution(int resolutionSelected) { } - + UIAlertController *tipsAlertController = [UIAlertController alertControllerWithTitle: [LocalizationHelper localizedStringForKey:@"Tips"] message:tipText preferredStyle:UIAlertControllerStyleAlert]; @@ -1751,7 +1747,7 @@ BOOL isCustomResolution(int resolutionSelected) { - (void)framePacingModeChanged:(UISegmentedControl *)sender { [self setHidden:sender.selectedSegmentIndex == FramePacingModeLegacy forStack:self.frameQueueSizeStack]; [videoSection updateViewForFoldState]; - + if(sender.selectedSegmentIndex == FramePacingModeLegacy){ [self.enableGraphsSwitch setOn:NO]; [self findDynamicLabelFromStack:_graphOpacityStack].hidden = YES; @@ -1760,20 +1756,20 @@ BOOL isCustomResolution(int resolutionSelected) { [self.graphOpacityStepper setEnabled:self.enableGraphsSwitch.isOn]; [self setHidden:sender.selectedSegmentIndex == FramePacingModeLegacy forStack:self.performanceGraphStack]; [otherSection updateViewForFoldState]; - + /* if (sender.selectedSegmentIndex == FramePacingModeLegacy) { // Legacy mode selected - disable frames to buffer and graph settings [self.frameQueueSizeSlider setEnabled:NO]; [self.enableGraphsSwitch setOn:NO animated:YES]; - + [self.enableGraphsSwitch setEnabled:NO]; [self.graphOpacityStepper setEnabled:NO]; } else { // Queue mode selected - enable frames to buffer and graph settings [self.frameQueueSizeSlider setEnabled:YES]; [self.enableGraphsSwitch setEnabled:YES]; - + if (self.enableGraphsSwitch.isOn) { [self.graphOpacityStepper setEnabled:YES]; } @@ -2304,11 +2300,11 @@ BOOL isCustomResolution(int resolutionSelected) { } - (void) saveSettings { - + if(self.mainFrameViewController.settingsExpandedInStreamView){ [self.mainFrameViewController requestForBitrate:(uint32_t)_bitrate]; } - + DataManager* dataMan = [[DataManager alloc] init]; Settings* currentSettings = [dataMan retrieveSettings];