Files
VoidLink/VoidLink/Metal/MetalViewController.m
T
AcakiandClaude Fable 5 6879bf6379 Allow switching rendering backend without restarting the app
The renderer setting previously forced an app quit (exit(0)) because
switching backends between sessions crashed or leaked. Fix the
underlying lifecycle bugs and remove the restart requirement:

- FrameQueue: really release frames in clear(), clear on session
  start/stop, and guard start/stop with an owner so a stale session's
  async cleanup can't pause a new session's queue
- MetalView: weak delegate (breaks retain cycle leaking the whole Metal
  stack), never start two render threads, bound the shutdown wait to
  avoid deadlocking against the render thread's dispatch_sync to main
- MetalViewController: explicit idempotent shutdown called from stream
  teardown (viewDidDisappear isn't guaranteed); snapshot the renderer
  in render-thread callbacks to avoid use-after-free during shutdown
- MetalVideoRenderer: make the shared colorspace name thread-safe and
  ARC-managed instead of a CFStringRef over-released in dealloc; guard
  NULL format description extensions
- Connection: drop the static renderer reference in DrCleanup
- VideoDecoderRenderer: cache the application background state via
  notifications instead of calling UIKit from the decode queue
  (Main Thread Checker violation)
- Frame: don't CFRetain(NULL) when a format desc has no extensions
- StreamFrameViewController: fix _streamView cleanup ordering
- Settings: apply the backend change in place (with the Metal caveats
  alert) and update localized tips; takes effect on next stream

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 17:14:01 +08:00

222 lines
6.6 KiB
Objective-C

//
// MetalViewController.m
//
// Created by Andy Grundman.
// Ported to VoidLink by Acaki.
// Copyright (c) 2025 Moonlight Stream. All rights reserved.
//
#import "MetalViewController.h"
#import "FrameQueue.h"
#import "ImGuiRenderer.h"
#import "MetalVideoRenderer.h"
@implementation MetalViewController {
FrameQueue *_frameQueue;
float _framerate;
TemporarySettings* _currentSettings;
MetalView *_metalView;
MetalVideoRenderer *_renderer;
MetricsHandler _metricsHandler;
CADisplayLink *_displayLink;
BOOL _frameSlotAcquired;
}
- (nonnull instancetype)initWithFrame:(CGRect)bounds framerate:(float)framerate settings:(TemporarySettings* )settings metricsHandler:(MetricsHandler)metricsHandler {
self = [super init];
if (self) {
_bounds = bounds;
_frameQueue = [FrameQueue sharedInstance];
_framerate = framerate;
_currentSettings = settings;
_metricsHandler = metricsHandler;
}
return self;
}
- (void)loadView {
self.view = [[MetalView alloc] initWithFrame:_bounds];
Log(LOG_I, @"[MetalViewController] created MetalView %@", (MetalView *)self.view);
}
- (void)viewDidLoad {
[super viewDidLoad];
__block MetalView *view = (MetalView *)self.view;
if (!view) {
Log(LOG_E, @"The view attached to MetalViewController isn't a MetalView.");
return;
}
_metalView = view;
_metalView.delegate = self;
_metalView.framerate = _framerate;
// Select the device to render with.
id<MTLDevice> device = MTLCreateSystemDefaultDevice();
if (!device) {
Log(LOG_E, @"Metal isn't supported on this device.");
self.view = [[UIView alloc] initWithFrame:self.view.frame];
return;
}
view.metalLayer.device = device;
// Determine supported pixel format before initializing renderer
// Use TARGET_OS_SIMULATOR to detect simulator environment
MTLPixelFormat pixelFormat;
#if TARGET_OS_SIMULATOR
// iOS Simulator doesn't support BGRA10_XR
pixelFormat = MTLPixelFormatBGRA8Unorm;
Log(LOG_W, @"Running on iOS Simulator, using BGRA8Unorm pixel format");
#else
// On real devices, check if we should enable HDR
if (_currentSettings.enableHdr) {
pixelFormat = MTLPixelFormatBGRA10_XR;
Log(LOG_I, @"HDR enabled, using BGRA10_XR pixel format");
} else {
pixelFormat = MTLPixelFormatBGRA8Unorm;
Log(LOG_I, @"HDR disabled, using BGRA8Unorm pixel format");
}
#endif
// Initialize the renderer.
MetalVideoRenderer *renderer = [[MetalVideoRenderer alloc] initWithMetalDevice:device
drawablePixelFormat:pixelFormat
settings:_currentSettings];
if (!renderer) {
Log(LOG_E, @"The renderer couldn't be initialized.");
return;
}
self->_renderer = renderer;
Log(LOG_I, @"[MetalViewController] viewDidLoad, created renderer: %@", renderer);
// 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 {
// Snapshot into a strong local: shutdown (main thread) can nil _renderer while
// the render thread is inside this method.
MetalVideoRenderer *renderer = _renderer;
// Skip waiting when renderer is paused or gone
if (!renderer || renderer.isStopping) {
_frameSlotAcquired = NO;
return;
}
if (@available(iOS 13.0, *)) {
_frameSlotAcquired = [renderer waitToRenderTo:layer];
}
if (_frameSlotAcquired) {
[_frameQueue waitForEnqueue];
}
}
/// Draw frame (used by manual loop)
- (void)renderTo:(nonnull CAMetalLayer *)layer {
if (!_frameSlotAcquired) {
return;
}
// Snapshot for the same reason as in waitToRenderTo
MetalVideoRenderer *renderer = _renderer;
if (!renderer) {
return;
}
CFTimeInterval timeout = (1.0f / _framerate) - renderer.averageGPUTime;
Frame *frame = [_frameQueue dequeueWithTimeoutSync:timeout];
if (!renderer.isStopping) {
// Only render if not paused
if (frame) {
//if (@available(iOS 13.0, *)) {
[renderer renderFrame:frame toLayer:layer];
//}
} else {
dispatch_semaphore_signal([renderer inFlightSemaphore]);
}
} else {
// When paused, we still dequeue frames to prevent accumulation
// but don't render them. Also sleep a bit to reduce CPU usage
dispatch_semaphore_signal([renderer inFlightSemaphore]);
usleep(100000);
}
}
- (void)drawableResize:(CGSize)size {
[_renderer drawableResize:size];
}
- (void)pauseRendering {
if (_displayLink) {
_displayLink.paused = YES;
}
if (_renderer) {
_renderer.isStopping = YES;
}
Log(LOG_I, @"[MetalViewController] Rendering paused");
}
- (void)resumeRendering {
if (_renderer) {
_renderer.isStopping = NO;
}
if (_displayLink) {
_displayLink.paused = NO;
}
Log(LOG_I, @"[MetalViewController] Rendering resumed");
}
- (void)viewDidDisappear:(BOOL)animated {
[super viewDidDisappear:animated];
Log(LOG_I, @"[MetalViewController] viewDidDisappear");
[self shutdown];
}
- (void)shutdown {
if (_displayLink) {
[_displayLink invalidate];
_displayLink = nil;
}
// Stop the renderer before the render thread: isStopping makes the thread's
// renderFrame return early, so it can't enter the dispatch_sync-to-main path
// while we wait for it below.
if (_renderer) {
[_renderer shutdown];
_renderer = nil;
}
if (_metalView) {
_metalView.delegate = nil;
[_metalView shutdown];
_metalView = nil;
}
}
#if TARGET_OS_IOS
// Hides the Home indicator button automatically.
- (BOOL)prefersHomeIndicatorAutoHidden {
return YES;
}
#endif
@end