fix: build

This commit is contained in:
Acaki committed 2025-07-12 02:00:33 +08:00
1 parent df2b67ac99
commit d69688678d
36 files changed
+565 -836

No files matched your search

+5 -2
View File
@@ -78,14 +78,17 @@ typedef NS_ENUM(NSInteger, SettingsMenuMode) {
useFramePacing:(BOOL)useFramePacing
enableHdr:(BOOL)enableHdr
btMouseSupport:(BOOL)btMouseSupport
// absoluteTouchMode:(BOOL)absoluteTouchMode
touchMode:(NSInteger)touchMode
statsOverlayLevel:(NSInteger)statsOverlayLevel
statsOverlayEnabled:(BOOL)statsOverlayEnabled
unlockDisplayOrientation:(BOOL)unlockDisplayOrientation
resolutionSelected:(NSInteger)resolutionSelected
externalDisplayMode:(NSInteger)externalDisplayMode
localMousePointerMode:(NSInteger)localMousePointerMode;
localMousePointerMode:(NSInteger)localMousePointerMode
frameQueueSize:(NSInteger)frameQueueSize
enableGraphs:(BOOL)enableGraphs
graphOpacity:(NSInteger)graphOpacity
renderingBackend:(NSInteger)renderingBackend;
- (NSArray*) getHosts;
- (void) updateHost:(TemporaryHost*)host;
+4 -9
View File
@@ -82,20 +82,16 @@
preferredCodec:(uint32_t)preferredCodec
enableYUV444:(BOOL)enableYUV444
useFramePacing:(BOOL)useFramePacing
frameQueueSize:(NSInteger)frameQueueSize
enableHdr:(BOOL)enableHdr
btMouseSupport:(BOOL)btMouseSupport
// absoluteTouchMode:(BOOL)absoluteTouchMode
touchMode:(NSInteger)touchMode
statsOverlayLevel:(NSInteger)statsOverlayLevel
statsOverlayEnabled:(BOOL)statsOverlayEnabled
unlockDisplayOrientation:(BOOL)unlockDisplayOrientation
resolutionSelected:(NSInteger)resolutionSelected
externalDisplayMode:(NSInteger)externalDisplayMode
localMousePointerMode:(NSInteger)localMousePointerMode{
absoluteTouchMode:(BOOL)absoluteTouchMode
statsOverlay:(BOOL)statsOverlay
localMousePointerMode:(NSInteger)localMousePointerMode
frameQueueSize:(NSInteger)frameQueueSize
enableGraphs:(BOOL)enableGraphs
graphOpacity:(NSInteger)graphOpacity
renderingBackend:(NSInteger)renderingBackend
@@ -128,7 +124,6 @@
settingsToSave.playAudioOnPC = audioOnPC;
settingsToSave.preferredCodec = preferredCodec;
settingsToSave.enableYUV444 = enableYUV444;
settingsToSave.frameQueueSize = [NSNumber numberWithInteger:frameQueueSize];
settingsToSave.enableHdr = enableHdr;
settingsToSave.btMouseSupport = btMouseSupport;
// settingsToSave.absoluteTouchMode = absoluteTouchMode;
@@ -139,8 +134,8 @@
settingsToSave.resolutionSelected = [NSNumber numberWithInteger:resolutionSelected];
settingsToSave.externalDisplayMode = [NSNumber numberWithInteger:externalDisplayMode];
settingsToSave.localMousePointerMode = [NSNumber numberWithInteger:localMousePointerMode];
settingsToSave.absoluteTouchMode = absoluteTouchMode;
settingsToSave.statsOverlay = statsOverlay;
settingsToSave.frameQueueSize = [NSNumber numberWithInteger:frameQueueSize];
settingsToSave.enableGraphs = enableGraphs;
settingsToSave.graphOpacity = [NSNumber numberWithInteger:graphOpacity];
settingsToSave.renderingBackend = [NSNumber numberWithInteger:renderingBackend];
+4
View File
@@ -62,6 +62,10 @@
@property (nonatomic) BOOL liftStreamViewForKeyboard;
@property (nonatomic) BOOL showKeyboardToolbar;
@property (nonatomic) BOOL unlockDisplayOrientation;
@property (nonatomic) BOOL enableGraphs;
@property (nonatomic, retain) NSNumber * frameQueueSize;
@property (nonatomic, retain) NSNumber * graphOpacity;
@property (nonatomic, retain) NSNumber * renderingBackend;
- (id) initFromSettings:(Settings*)settings;
+45
View File
@@ -0,0 +1,45 @@
//
// ImGuiRenderer 2.h
// VoidLink
//
// Created by Mason Chen on 2025/7/11.
// Copyright © 2025 Moonlight Game Streaming Project. All rights reserved.
//
#import <Foundation/Foundation.h>
#import <Metal/Metal.h>
#import <MetalKit/MetalKit.h>
#import <UIKit/UIKit.h>
#import "FloatBuffer.h"
#import "Plot.h"
typedef void (^MetricsHandler)(int plotId, CFTimeInterval value);
@interface ImGuiRenderer : UIViewController
@end
@interface ImGuiRenderer () <MTKViewDelegate>
@property (nonatomic) CGRect bounds;
@property (nonatomic, readonly) MTKView * _Nonnull mtkView;
@property (nonatomic, strong) id <MTLDevice> _Nonnull device;
@property (nonatomic, strong) id <MTLCommandQueue> _Nonnull commandQueue;
@property (nonatomic) struct PlotDef * _Nonnull plots;
@property (nonatomic) FloatBuffer * _Nonnull frametimes;
@property (nonatomic) BOOL enableGraphs;
@property (nonatomic) float graphOpacity;
@property (nonatomic) BOOL imguiRunning;
@property (nonatomic) MetricsHandler _Nonnull metricsHandler;
-(nonnull instancetype) initWithFrame:(CGRect)bounds
streamFps:(int)streamFps
enableGraphs:(BOOL)enableGraphs
graphOpacity:(int)graphOpacity;
-(void) start;
-(void) show;
-(void) hide;
-(void) stop;
-(void) observeFloat:(int)plotId value:(CFTimeInterval)value;
-(void) observeFloatReturnMetrics:(int)plotId value:(CFTimeInterval)value plotMetrics:(PlotMetrics * _Nullable)plotMetrics;
@end
+36
View File
@@ -0,0 +1,36 @@
#import <Foundation/Foundation.h>
#import <Metal/Metal.h>
#import <MetalKit/MetalKit.h>
#import <UIKit/UIKit.h>
#import "FloatBuffer.h"
#import "Plot.h"
typedef void (^MetricsHandler)(int plotId, CFTimeInterval value);
@interface ImGuiRenderer : UIViewController
@end
@interface ImGuiRenderer () <MTKViewDelegate>
@property (nonatomic) CGRect bounds;
@property (nonatomic, readonly) MTKView * _Nonnull mtkView;
@property (nonatomic, strong) id <MTLDevice> _Nonnull device;
@property (nonatomic, strong) id <MTLCommandQueue> _Nonnull commandQueue;
@property (nonatomic) struct PlotDef * _Nonnull plots;
@property (nonatomic) FloatBuffer * _Nonnull frametimes;
@property (nonatomic) BOOL enableGraphs;
@property (nonatomic) float graphOpacity;
@property (nonatomic) BOOL imguiRunning;
@property (nonatomic) MetricsHandler _Nonnull metricsHandler;
-(nonnull instancetype) initWithFrame:(CGRect)bounds
streamFps:(int)streamFps
enableGraphs:(BOOL)enableGraphs
graphOpacity:(int)graphOpacity;
-(void) start;
-(void) show;
-(void) hide;
-(void) stop;
-(void) observeFloat:(int)plotId value:(CFTimeInterval)value;
-(void) observeFloatReturnMetrics:(int)plotId value:(CFTimeInterval)value plotMetrics:(PlotMetrics * _Nullable)plotMetrics;
@end
+434
View File
@@ -0,0 +1,434 @@
//
// ImGuiRenderer.m
// VoidLink
//
// Created by Mason Chen on 2025/7/11.
// Copyright © 2025 Moonlight Game Streaming Project. All rights reserved.
//
#import <AVFoundation/AVFoundation.h>
#import <Metal/Metal.h>
#import "ImGuiRenderer.h"
#import "ImGuiPlots.h"
// This will fully disable ImGui by compiling it out. The in-app setting enableGraphs
// will also remove all ImGui overhead.
//#define IMGUI_DISABLE
// If this is defined, ImGui takes over all touch control while using the app.
// This is only needed if you want to enable and interact with the demo. I'm sure it's possible
// to pass-through touch events when touching non-ImGui portions of the screen
// but I couldn't figure it out. I did not try to get controller support working.
//#define IMGUI_STEALS_TOUCH
#import "imgui.h"
#import "imgui_impl_metal.h"
// ImGui code needs to live in this file because it's an Objective-C++ class, and can call C++ code.
@implementation ImGuiRenderer
-(nonnull instancetype)initWithFrame:(CGRect)bounds
streamFps:(int)streamFps
enableGraphs:(BOOL)enableGraphs
graphOpacity:(int)graphOpacity
{
self = [super init];
_enableGraphs = enableGraphs;
_graphOpacity = (float)(graphOpacity / 100.0);
_bounds = bounds;
_device = MTLCreateSystemDefaultDevice();
_commandQueue = [_device newCommandQueue];
_plots = [ImGuiPlots sharedInstance].plots;
return self;
}
-(void)ImGui_Init {
// self-contained startup for ImGui so it can be dynamically toggled easily
#if !defined(IMGUI_DISABLE)
if (_enableGraphs && !_imguiRunning) {
IMGUI_CHECKVERSION();
ImGui::CreateContext();
ImGuiIO& io = ImGui::GetIO(); (void)io;
// io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; // Enable Keyboard Controls
// io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad; // Enable Gamepad Controls
ImGui::StyleColorsDark();
ImGui_ImplMetal_Init(_device);
_imguiRunning = YES;
}
#endif
}
-(void)ImGui_Deinit {
if (_imguiRunning) {
#if !defined(IMGUI_DISABLE)
ImGui_ImplMetal_Shutdown();
ImGui::DestroyContext();
#endif
_imguiRunning = NO;
}
}
-(MTKView *)mtkView
{
return (MTKView *)self.view;
}
-(void)loadView
{
self.view = [[MTKView alloc] initWithFrame:self.bounds];
}
-(void)viewDidLoad
{
[super viewDidLoad];
self.mtkView.device = self.device;
self.mtkView.delegate = self;
self.mtkView.preferredFramesPerSecond = 60; // ImGui overlay will always render at this rate
self.mtkView.opaque = NO;
self.mtkView.enableSetNeedsDisplay = NO;
if (_enableGraphs) {
[self ImGui_Init];
self.mtkView.paused = NO;
}
else {
self.mtkView.paused = YES;
}
}
// start & stop are used to show/hide graphs when swiping stats in or out
-(void)start {
[self ImGui_Init];
if (_enableGraphs) {
self.mtkView.paused = NO;
}
}
-(void)show {
[self.mtkView setHidden:NO];
}
-(void)hide {
[self.mtkView setHidden:YES];
}
-(void)stop {
self.mtkView.paused = YES;
[self ImGui_Deinit];
}
// Only called when mtkView.paused is false
- (void)drawInMTKView:(MTKView *)view
{
#if !defined(IMGUI_DISABLE)
ImGuiIO &io = ImGui::GetIO();
io.DisplaySize.x = view.bounds.size.width;
io.DisplaySize.y = view.bounds.size.height;
CGFloat framebufferScale = view.window.screen.scale ?: UIScreen.mainScreen.scale;
io.DisplayFramebufferScale = ImVec2(framebufferScale, framebufferScale);
id<MTLCommandBuffer> commandBuffer = [self.commandQueue commandBuffer];
MTLRenderPassDescriptor* renderPassDescriptor = view.currentRenderPassDescriptor;
if (renderPassDescriptor == nil) {
[commandBuffer commit];
return;
}
// Start the Dear ImGui frame
ImGui_ImplMetal_NewFrame(renderPassDescriptor);
ImGui::NewFrame();
// 1. Show the big demo window (Most of the sample code is in ImGui::ShowDemoWindow()! You can browse its code to learn more about Dear ImGui!).
static bool show_demo_window = false;
if (show_demo_window)
ImGui::ShowDemoWindow(&show_demo_window);
// Custom Moonlight stuff goes here
[self drawStatsGraphs];
// Rendering
ImGui::Render();
ImDrawData* draw_data = ImGui::GetDrawData();
// This looks silly when clear_color is all zeros but the original example uses this method to tint or make transparent the rest of the viewport
static ImVec4 clear_color = ImVec4(0, 0, 0, 0);
renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColorMake(clear_color.x * clear_color.w, clear_color.y * clear_color.w, clear_color.z * clear_color.w, clear_color.w);
id <MTLRenderCommandEncoder> renderEncoder = [commandBuffer renderCommandEncoderWithDescriptor:renderPassDescriptor];
[renderEncoder pushDebugGroup:@"Dear ImGui rendering"];
ImGui_ImplMetal_RenderDrawData(draw_data, commandBuffer, renderEncoder);
[renderEncoder popDebugGroup];
[renderEncoder endEncoding];
// Present
#if TARGET_OS_SIMULATOR
[commandBuffer presentDrawable:view.currentDrawable];
#else
[commandBuffer presentDrawable:view.currentDrawable afterMinimumDuration:1.0 / view.preferredFramesPerSecond];
#endif
[commandBuffer commit];
#endif
}
- (void)mtkView:(MTKView *)view drawableSizeWillChange:(CGSize)size
{
}
- (void)viewDidDisappear:(BOOL)animated
{
[super viewDidDisappear:animated];
[self ImGui_Deinit];
}
//-----------------------------------------------------------------------------------
// Input processing
//-----------------------------------------------------------------------------------
// This touch mapping is super cheesy/hacky. We treat any touch on the screen
// as if it were a depressed left mouse button, and we don't bother handling
// multitouch correctly at all. This causes the "cursor" to behave very erratically
// when there are multiple active touches. But for demo purposes, single-touch
// interaction actually works surprisingly well.
#if !defined(IMGUI_DISABLE)
# if defined(IMGUI_STEALS_TOUCH)
-(BOOL)updateIOWithTouchEvent:(UIEvent *)event
{
UITouch *anyTouch = event.allTouches.anyObject;
CGPoint touchLocation = [anyTouch locationInView:self.view];
ImGuiIO &io = ImGui::GetIO();
io.AddMouseSourceEvent(ImGuiMouseSource_TouchScreen);
io.AddMousePosEvent(touchLocation.x, touchLocation.y);
BOOL hasActiveTouch = NO;
for (UITouch *touch in event.allTouches)
{
if (touch.phase != UITouchPhaseEnded && touch.phase != UITouchPhaseCancelled)
{
hasActiveTouch = YES;
break;
}
}
io.AddMouseButtonEvent(0, hasActiveTouch);
return YES;
}
# endif
#endif
#if !defined(IMGUI_DISABLE)
# if defined(IMGUI_STEALS_TOUCH)
-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { [self updateIOWithTouchEvent:event]; }
-(void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { [self updateIOWithTouchEvent:event]; }
-(void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { [self updateIOWithTouchEvent:event]; }
-(void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { [self updateIOWithTouchEvent:event]; }
# endif
#endif
/// Stats Graphs, we can still track data this way even with ImGui disabled
- (void) observeFloat:(int)plotId value:(CFTimeInterval)value {
[self.plots[plotId].buffer addValue:(float)value];
}
- (void) observeFloatReturnMetrics:(int)plotId value:(CFTimeInterval)value plotMetrics:(PlotMetrics *)plotMetrics {
[self.plots[plotId].buffer addValue:(float)value];
if (plotMetrics != nil) {
[self.plots[plotId].buffer copyMetrics:plotMetrics];
}
}
#if !defined(IMGUI_DISABLE)
inline static float getValue(void *buffer, int idx) {
float *fbuffer = (float *)buffer;
float v = fbuffer[idx];
// clip the top of frametime graphs so they're less ugly
if (v > 50)
v = 49.9;
return v;
}
- (void) drawStatsGraphs {
// we malloc a buffer for frametimes once and reuse it
static float * buffers[8] = {
(float *)malloc(sizeof(float) * 512),
(float *)malloc(sizeof(float) * 512),
(float *)malloc(sizeof(float) * 512),
(float *)malloc(sizeof(float) * 512),
(float *)malloc(sizeof(float) * 512),
(float *)malloc(sizeof(float) * 512),
(float *)malloc(sizeof(float) * 512),
(float *)malloc(sizeof(float) * 512)
};
ImGuiIO &io = ImGui::GetIO();
// Try to make the graphs suck less on common device sizes
float graphW = 0.0f;
float graphH = 0.0f;
switch ((int)io.DisplaySize.x) {
case 1920: // ATV 4K 1920x1080 2x
graphW = 525.0f; graphH = 80.0f; break;
case 1376: // iPad Pro 1376x1032 2x
graphW = 450.0f; graphH = 45.0f; break;
case 1366: // iPad Air 1366x1024 2x
graphW = 446.0f; graphW = 44.0f; break;
case 1194: // Vision Pro (iPad mode) 1194x834 2x
graphW = 379.0f; graphH = 45.0f; break;
case 1133: // iPad Mini 1133x744 2x
graphW = 360.0f; graphH = 45.0f; break;
case 874: // iPhone 16 Pro 874x402 3x
graphW = 275.0f; graphH = 45.0f; break;
// TODO:
default:
float x = io.DisplaySize.x;
float y = io.DisplaySize.y;
if (io.DisplayFramebufferScale.x > 2.0f) {
x = x * io.DisplayFramebufferScale.x / 2.0f;
y = y * io.DisplayFramebufferScale.y / 2.0f;
}
graphW = x * 0.327f;
graphH = y * 0.044f;
}
LogOnce(LOG_I, @"Drawing graphs of size %.1f x %.1f in viewport %.1f x %.1f scale %.0f,%.0f using opacity %.2f",
graphW, graphH,
io.DisplaySize.x, io.DisplaySize.y,
io.DisplayFramebufferScale.x, io.DisplayFramebufferScale.y,
_graphOpacity);
// Left side - 2 graphs
ImVec2 windowSize(graphW, (graphH * 4));
ImVec2 windowPos(10.0f, 10.0f);
ImGui::SetNextWindowPos(windowPos, ImGuiCond_Always, ImVec2(0.0f, 0.0f)); // pivot (0,0) = top-left
ImGui::SetNextWindowSize(windowSize, ImGuiCond_Always);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoNavFocus | ImGuiWindowFlags_NoBackground |
ImGuiWindowFlags_NoSavedSettings;
ImGui::Begin("##StatsLeft", nullptr, flags);
// Dimensions of each graph
ImVec2 avail = ImGui::GetContentRegionAvail();
float fullW = avail.x;
// First 2 on left
for (int i = 0; i < PlotCount; i++) {
if (self.plots[i].side != PLOT_LEFT) continue;
float minY, maxY;
int countF = [self.plots[i].buffer copyValuesIntoBuffer:buffers[i] min:&minY max:&maxY];
float avgF = [self.plots[i].buffer averageValue];
if (!countF) {
continue;
}
// Ugly, but can't get ImPlot to build for iOS
char label[64];
switch (self.plots[i].labelType) {
case PLOT_LABEL_MIN_MAX_AVG:
sprintf(label, "%s %.1f/%.1f/%.1f %s", self.plots[i].title, minY, maxY, avgF, self.plots[i].unit);
break;
case PLOT_LABEL_MIN_MAX_AVG_INT:
sprintf(label, "%s %d/%d/%.1f %s", self.plots[i].title, (int)minY, (int)maxY, avgF, self.plots[i].unit);
break;
case PLOT_LABEL_TOTAL_INT:
sprintf(label, "%s %d %s", self.plots[i].title, (int)[self.plots[i].buffer total], self.plots[i].unit);
break;
}
float scaleMin = FLT_MAX;
float scaleMax = FLT_MAX;
if (self.plots[i].scaleTarget) {
// optionally center the graph on a target such as the ideal frametime
float ideal = (float)self.plots[i].scaleTarget;
scaleMin = ideal - (2 * ideal);
scaleMax = ideal + (2 * ideal);
}
if (self.plots[i].scaleMin)
scaleMin = self.plots[i].scaleMin;
if (self.plots[i].scaleMax)
scaleMax = self.plots[i].scaleMax;
ImGui::PushID(i);
ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.16f, 0.29f, 0.48f, _graphOpacity)); // dark
//ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.43f, 0.43f, 0.43f, 0.39f)); // classic
ImGui::PushStyleColor(ImGuiCol_PlotLines, ImVec4(0.0f, 1.0f, 0.0f, 1.0f)); // green
ImGui::PlotLines("##xx", buffers[i], countF, 0, (countF > 0 ? label : "no data"), scaleMin, scaleMax, ImVec2(fullW, graphH));
ImGui::PopStyleColor(2);
ImGui::PopID();
}
ImGui::End();
// Right side - 2 graphs
windowPos = ImVec2(io.DisplaySize.x - 10.0f, 10.0f); // 10px margin
ImGui::SetNextWindowPos(windowPos, ImGuiCond_Always, ImVec2(1.0f, 0.0f)); // pivot (1,0) = top-right
ImGui::SetNextWindowSize(windowSize, ImGuiCond_Always);
flags = ImGuiWindowFlags_NoDecoration |
ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoNavFocus |
ImGuiWindowFlags_NoBackground |
ImGuiWindowFlags_NoSavedSettings;
ImGui::Begin("##StatsRight", nullptr, flags);
for (int i = 2; i < PlotCount; i++) {
if (self.plots[i].side != PLOT_RIGHT) continue;
float minY, maxY;
int countF = [self.plots[i].buffer copyValuesIntoBuffer:buffers[i] min:&minY max:&maxY];
float avgF = [self.plots[i].buffer averageValue];
if (!countF) {
continue;
}
// Ugly, but can't get ImPlot to build for iOS
char label[64];
switch (self.plots[i].labelType) {
case PLOT_LABEL_MIN_MAX_AVG:
sprintf(label, "%s %.1f/%.1f/%.1f %s", self.plots[i].title, minY, maxY, avgF, self.plots[i].unit);
break;
case PLOT_LABEL_MIN_MAX_AVG_INT:
sprintf(label, "%s %d/%d/%.1f %s", self.plots[i].title, (int)minY, (int)maxY, avgF, self.plots[i].unit);
break;
case PLOT_LABEL_TOTAL_INT:
sprintf(label, "%s %d %s", self.plots[i].title, (int)[self.plots[i].buffer total], self.plots[i].unit);
break;
}
float scaleMin = FLT_MAX;
float scaleMax = FLT_MAX;
if (self.plots[i].scaleTarget) {
// optionally center the graph on a target such as the ideal frametime
float ideal = (float)self.plots[i].scaleTarget;
scaleMin = ideal - (2 * ideal);
scaleMax = ideal + (2 * ideal);
}
if (self.plots[i].scaleMin)
scaleMin = self.plots[i].scaleMin;
if (self.plots[i].scaleMax)
scaleMax = self.plots[i].scaleMax;
ImGui::PushID(i);
ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.16f, 0.29f, 0.48f, _graphOpacity)); // dark
ImGui::PushStyleColor(ImGuiCol_PlotLines, ImVec4(0.0f, 1.0f, 0.0f, 1.0f)); // green
if (i == PLOT_FRAMETIME || i == PLOT_HOST_FRAMETIME) {
// getValue() clips at max 50
ImGui::PlotLines("##xx", getValue, buffers[i], countF, 0, (countF > 0 ? label : "no data"), scaleMin, scaleMax, ImVec2(fullW, graphH));
} else {
ImGui::PlotLines("##xx", buffers[i], countF, 0, (countF > 0 ? label : "no data"), scaleMin, scaleMax, ImVec2(fullW, graphH));
}
ImGui::PopStyleColor(2);
ImGui::PopID();
}
ImGui::End();
#endif
}
@end
@@ -54,8 +54,9 @@
<attribute name="showKeyboardToolbar" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES" syncable="YES"/>
<attribute name="slideToSettingsDistance" optional="YES" attributeType="Float" defaultValueString="0.25" usesScalarValueType="NO" syncable="YES"/>
<attribute name="slideToSettingsScreenEdge" optional="YES" attributeType="Integer 32" defaultValueString="2" usesScalarValueType="NO" syncable="YES"/>
<attribute name="statsOverlayEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES" syncable="YES"/>
<attribute name="statsOverlayLevel" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="NO" syncable="YES"/>
<attribute name="renderingBackend" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="NO" syncable="YES"/>
<attribute name="statsOverlay" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES" syncable="YES"/>
<attribute name="swapABXYButtons" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES" syncable="YES"/>
<attribute name="touchMode" optional="YES" attributeType="Integer 16" defaultValueString="1" usesScalarValueType="NO" syncable="YES"/>
<attribute name="touchMoveEventInterval" optional="YES" attributeType="Integer 16" defaultValueString="320" usesScalarValueType="NO" syncable="YES"/>
+22
View File
@@ -0,0 +1,22 @@
//
// MetalConfig.h
// VoidLink
//
// Created by Mason Chen on 2025/7/11.
// Copyright © 2025 Moonlight Game Streaming Project. All rights reserved.
//
// When enabled, rendering occurs on the main application thread.
// This can make responding to UI events during redraw simpler
// to manage because UI calls usually need to occur on the main thread.
// When disabled, rendering occurs on a background thread, allowing
// the UI to respond more quickly in some cases because events can
// process asynchronously from potentially CPU-intensive rendering code.
#define RENDER_ON_MAIN_THREAD 0
// When enabled, the drawable's size updates automatically whenever
// the view resizes. When disabled, you can update the drawable's
// size explicitly outside the view class.
#define AUTOMATICALLY_RESIZE 1
+32
View File
@@ -0,0 +1,32 @@
//
// MetalVideoRenderer.h
// VoidLink
//
// Created by Mason Chen on 2025/7/11.
// Copyright © 2025 Moonlight Game Streaming Project. All rights reserved.
//
#import <QuartzCore/CAMetalLayer.h>
#import <QuartzCore/QuartzCore.h>
#import "ConnectionCallbacks.h"
#import "Frame.h"
#import "Plot.h"
@interface MetalVideoRenderer : NSObject
@property (atomic) CFTimeInterval averageGPUTime;
@property (nonatomic) NSUInteger sampleCount;
@property (nonatomic) MTLPixelFormat colorPixelFormat;
@property (nonatomic, nonnull) CGColorSpaceRef colorspace;
@property (nonatomic) id<CAMetalDrawable> _Nullable nextDrawable;
- (nonnull instancetype)initWithMetalDevice:(nonnull id<MTLDevice>)device
drawablePixelFormat:(MTLPixelFormat)drawablePixelFormat
framerate:(float)framerate;
- (void)renderFrame:(nonnull Frame *)frame toLayer:(nonnull CAMetalLayer *)layer;
- (void)waitToRenderTo:(nonnull CAMetalLayer *)layer;
- (void)drawableResize:(CGSize)drawableSize;
- (void)plotFrametime:(CFTimeInterval)presentedTime;
@end
+533
View File
@@ -0,0 +1,533 @@
//
// MetalVideoRenderer.m
// VoidLink
//
// Created by Mason Chen on 2025/7/11.
// Copyright © 2025 Moonlight Game Streaming Project. All rights reserved.
//
#import "MetalVideoRenderer.h"
#import <CoreVideo/CoreVideo.h>
#import <Metal/Metal.h>
#import <MetalKit/MetalKit.h>
#import "ImGuiPlots.h"
#include <Limelight.h>
#define MAX_VIDEO_PLANES 3
struct CscParams {
vector_float3 matrix[3];
vector_float3 offsets;
};
struct ParamBuffer {
struct CscParams cscParams;
};
static const struct CscParams k_CscParams_Bt601Lim = {
// CSC Matrix
{{1.1644f, 0.0f, 1.5960f}, {1.1644f, -0.3917f, -0.8129f}, {1.1644f, 2.0172f, 0.0f}},
// Offsets
{16.0f / 255.0f, 128.0f / 255.0f, 128.0f / 255.0f},
};
static const struct CscParams k_CscParams_Bt601Full = {
{
{1.0f, 0.0f, 1.4020f},
{1.0f, -0.3441f, -0.7141f},
{1.0f, 1.7720f, 0.0f},
},
{0.0f, 128.0f / 255.0f, 128.0f / 255.0f},
};
static const struct CscParams k_CscParams_Bt709Lim = {
{
{1.1644f, 0.0f, 1.7927f},
{1.1644f, -0.2132f, -0.5329f},
{1.1644f, 2.1124f, 0.0f},
},
{16.0f / 255.0f, 128.0f / 255.0f, 128.0f / 255.0f},
};
static const struct CscParams k_CscParams_Bt709Full = {
{
{1.0f, 0.0f, 1.5748f},
{1.0f, -0.1873f, -0.4681f},
{1.0f, 1.8556f, 0.0f},
},
{0.0f, 128.0f / 255.0f, 128.0f / 255.0f},
};
static const struct CscParams k_CscParams_Bt2020Lim = {
{
{1.1644f, 0.0f, 1.6781f},
{1.1644f, -0.1874f, -0.6505f},
{1.1644f, 2.1418f, 0.0f},
},
{16.0f / 255.0f, 128.0f / 255.0f, 128.0f / 255.0f},
};
static const struct CscParams k_CscParams_Bt2020Full = {
{
{1.0f, 0.0f, 1.4746f},
{1.0f, -0.1646f, -0.5714f},
{1.0f, 1.8814f, 0.0f},
},
{0.0f, 128.0f / 255.0f, 128.0f / 255.0f},
};
struct Vertex {
vector_float4 position;
vector_float2 texCoord;
};
static const NSUInteger MaxFramesInFlight = 3;
@implementation MetalVideoRenderer {
dispatch_queue_t _sq;
id<MTLDevice> _device;
float _framerate;
id<ConnectionCallbacks> _callbacks;
id<MTLCommandQueue> _commandQueue;
id<MTLLibrary> _shaderLibrary;
id<MTLRenderPipelineState> _videoPipelineState;
MTLRenderPassDescriptor *_renderPassDescriptor;
id<MTLTexture> _videoTexture;
CVMetalTextureCacheRef _textureCache;
CVMetalTextureRef _cvMetalTextures[MAX_VIDEO_PLANES];
int _lastColorSpace;
BOOL _lastFullRange;
size_t _lastFrameWidth;
size_t _lastFrameHeight;
size_t _lastDrawableWidth;
size_t _lastDrawableHeight;
id<MTLBuffer> _CscParamsBuffer;
id<MTLBuffer> _VideoVertexBuffer;
CFTimeInterval _lastPresented;
// https://developer.apple.com/documentation/metal/synchronizing-cpu-and-gpu-work?language=objc
dispatch_semaphore_t _inFlightSemaphore;
}
- (instancetype)initWithMetalDevice:(id<MTLDevice>)device drawablePixelFormat:(MTLPixelFormat)drawablePixelFormat framerate:(float)framerate {
self = [super init];
if (self) {
_sq = dispatch_queue_create("com.moonlight.MetalVideoRenderer",
dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_USER_INTERACTIVE, 0));
_averageGPUTime = (1.0f / framerate) / 2;
_device = device;
_nextDrawable = nil;
_colorPixelFormat = MTLPixelFormatBGR10A2Unorm;
_colorspace = CGColorSpaceCreateWithName(kCGColorSpaceITUR_2100_PQ);
_framerate = framerate;
_commandQueue = [_device newCommandQueue];
_lastColorSpace = -1;
_lastFullRange = NO;
_lastPresented = 0;
_inFlightSemaphore = dispatch_semaphore_create(MaxFramesInFlight);
CFStringRef keys[1] = {kCVMetalTextureUsage};
NSUInteger values[1] = {MTLTextureUsageShaderRead};
CFDictionaryRef cacheAttributes = CFDictionaryCreate(kCFAllocatorDefault, (const void **)keys, (const void **)values, 1, NULL, NULL);
CVMetalTextureCacheCreate(kCFAllocatorDefault, cacheAttributes, _device, NULL, &_textureCache);
CFRelease(cacheAttributes);
_renderPassDescriptor = [MTLRenderPassDescriptor new];
_renderPassDescriptor.colorAttachments[0].loadAction = MTLLoadActionClear;
_renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColorMake(0, 0, 0, 0);
_renderPassDescriptor.colorAttachments[0].storeAction = MTLStoreActionStore;
}
return self;
}
#if !TARGET_OS_TV
- (void)applyEDRFromFrame:(Frame *)frame toLayer:(CAMetalLayer *)layer {
CFDictionaryRef ext = [frame getFormatDescExtensions];
Log(LOG_I, @"ext: %@", ext);
CFDataRef masteringData = CFDictionaryGetValue(ext, kCMFormatDescriptionExtension_MasteringDisplayColorVolume);
CFDataRef contentDataRef = CFDictionaryGetValue(ext, kCMFormatDescriptionExtension_ContentLightLevelInfo);
if (masteringData) {
Log(LOG_I, @"ext MDCV %@", masteringData);
}
if (contentDataRef) {
Log(LOG_I, @"ext CLLI %@", contentDataRef);
}
if (masteringData && CFDataGetLength(masteringData) == 24 && contentDataRef && CFDataGetLength(contentDataRef) == 4) {
NSData *displayData = (__bridge NSData *)masteringData;
NSData *contentData = (__bridge NSData *)contentDataRef;
layer.wantsExtendedDynamicRangeContent = YES;
layer.pixelFormat = MTLPixelFormatRGBA16Float;
CFStringRef name = kCGColorSpaceExtendedLinearITUR_2020;
CGColorSpaceRef colorspace = CGColorSpaceCreateWithName(name);
layer.colorspace = colorspace;
layer.EDRMetadata = [CAEDRMetadata HDR10MetadataWithDisplayInfo:displayData contentInfo:contentData opticalOutputScale:100.0f];
Log(LOG_I, @"EDRMetadata set from MDCV %@ and CLLI %@", displayData, contentData);
} else {
layer.wantsExtendedDynamicRangeContent = YES;
layer.pixelFormat = MTLPixelFormatRGBA16Float;
CFStringRef name = kCGColorSpaceExtendedLinearITUR_2020;
CGColorSpaceRef colorspace = CGColorSpaceCreateWithName(name);
layer.colorspace = colorspace;
layer.EDRMetadata = [CAEDRMetadata HDR10MetadataWithMinLuminance:0.0005f maxLuminance:1000.0f opticalOutputScale:100.0f];
Log(LOG_I, @"EDRMetadata set for 1000 nits");
}
}
#endif
- (int)getFrameColorspaceAndRange:(Frame *)frame isFullRange:(BOOL *)isFullRange {
CFDictionaryRef ext = [frame getFormatDescExtensions];
// FQLog(LOG_I, @"%@", ext);
// Full Range boolean
CFBooleanRef fullRangeRef = CFDictionaryGetValue(ext, kCMFormatDescriptionExtension_FullRangeVideo);
*isFullRange = NO;
if (fullRangeRef && CFGetTypeID(fullRangeRef) == CFBooleanGetTypeID()) {
*isFullRange = CFBooleanGetValue(fullRangeRef);
}
// Colorspace
CFStringRef frame_color = CFDictionaryGetValue(ext, kCVImageBufferColorPrimariesKey);
if (CFEqual(frame_color, kCVImageBufferColorPrimaries_ITU_R_709_2)) {
return COLORSPACE_REC_709;
} else if (CFEqual(frame_color, kCVImageBufferColorPrimaries_ITU_R_2020)) {
return COLORSPACE_REC_2020;
}
return COLORSPACE_REC_601;
}
- (BOOL)updateColorSpaceForFrame:(Frame *)frame toLayer:(CAMetalLayer *)layer layerDidChange:(BOOL *)layerDidChange {
BOOL fullRange = NO;
int colorspace = [self getFrameColorspaceAndRange:frame isFullRange:&fullRange];
if (colorspace != _lastColorSpace || fullRange != _lastFullRange) {
CGColorSpaceRef newColorSpace = nil;
MTLPixelFormat newPixelFormat = layer.pixelFormat;
BOOL isHDR = NO;
struct ParamBuffer paramBuffer;
switch (colorspace) {
case COLORSPACE_REC_709:
newColorSpace = CGColorSpaceCreateWithName(kCGColorSpaceITUR_709);
newPixelFormat = MTLPixelFormatBGRA8Unorm;
paramBuffer.cscParams = (fullRange ? k_CscParams_Bt709Full : k_CscParams_Bt709Lim);
break;
case COLORSPACE_REC_2020: {
CFDictionaryRef ext = [frame getFormatDescExtensions];
CFStringRef frame_trc = CFDictionaryGetValue(ext, kCVImageBufferTransferFunctionKey);
if (CFEqual(frame_trc, kCVImageBufferTransferFunction_SMPTE_ST_2084_PQ)) {
isHDR = YES;
newColorSpace = CGColorSpaceCreateWithName(kCGColorSpaceITUR_2100_PQ);
newPixelFormat = MTLPixelFormatBGR10A2Unorm;
} else {
// SDR 2020
newColorSpace = CGColorSpaceCreateWithName(kCGColorSpaceITUR_2020);
newPixelFormat = MTLPixelFormatBGR10A2Unorm;
}
paramBuffer.cscParams = (fullRange ? k_CscParams_Bt2020Full : k_CscParams_Bt2020Lim);
break;
}
case COLORSPACE_REC_601:
newColorSpace = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
newPixelFormat = MTLPixelFormatBGRA8Unorm;
paramBuffer.cscParams = (fullRange ? k_CscParams_Bt601Full : k_CscParams_Bt601Lim);
}
// The CAMetalLayer retains the CGColorSpace
if (newColorSpace || newPixelFormat != layer.pixelFormat) {
*layerDidChange = YES;
if (newColorSpace) {
Log(LOG_I,
@"Frame colorspace %@ - changing MetalLayer's colorspace to %@",
colorspace == COLORSPACE_REC_709 ? @"REC_709"
: colorspace == COLORSPACE_REC_2020 ? @"REC_2020"
: colorspace == COLORSPACE_REC_601 ? @"REC_601 (sRGB)"
: [NSString stringWithFormat:@"Unknown: %d", colorspace],
newColorSpace);
}
if (newPixelFormat != layer.pixelFormat) {
Log(LOG_I,
@"Frame pixel format %@ - changing MetalLayer's pixel format to %@",
layer.pixelFormat == MTLPixelFormatBGRA8Unorm ? @"MTLPixelFormatBGRA8Unorm"
: layer.pixelFormat == MTLPixelFormatBGR10A2Unorm ? @"MTLPixelFormatBGR10A2Unorm"
: [NSString stringWithFormat:@"Unknown: %lu", layer.pixelFormat],
newPixelFormat == MTLPixelFormatBGRA8Unorm ? @"MTLPixelFormatBGRA8Unorm"
: newPixelFormat == MTLPixelFormatBGR10A2Unorm ? @"MTLPixelFormatBGR10A2Unorm"
: [NSString stringWithFormat:@"Unknown: %lu", (unsigned long)layer.pixelFormat]);
}
#if !RENDER_ON_MAIN_THREAD
// These can only be changed on the main thread
dispatch_sync(dispatch_get_main_queue(), ^{
#endif
#if !TARGET_OS_TV
if (isHDR) {
layer.wantsExtendedDynamicRangeContent = YES;
}
#endif
layer.colorspace = newColorSpace;
layer.pixelFormat = newPixelFormat;
#if !RENDER_ON_MAIN_THREAD
});
#endif
CGColorSpaceRelease(newColorSpace);
}
// Create the new colorspace parameter buffer for our fragment shader
MTLResourceOptions bufferOptions = MTLResourceStorageModeShared;
_CscParamsBuffer = [_device newBufferWithBytes:(void *)&paramBuffer length:sizeof(paramBuffer) options:bufferOptions];
if (!_CscParamsBuffer) {
Log(LOG_E, @"Failed to create CSC parameters buffer");
return NO;
}
_lastColorSpace = colorspace;
_lastFullRange = fullRange;
}
return YES;
}
- (void)scaleSource:(CGRect *)src toDest:(CGRect *)dst {
int dstH = ceilf((float)dst->size.width * src->size.height / src->size.width);
int dstW = ceilf((float)dst->size.height * src->size.width / src->size.height);
if (dstH > dst->size.height) {
dst->origin.x += (dst->size.width - dstW) / 2;
dst->size.width = dstW;
} else {
dst->origin.y += (dst->size.height - dstH) / 2;
dst->size.height = dstH;
}
}
- (void)screenSpace:(CGRect *)src toNormalizedDeviceCoords:(CGRect *)dst withDrawableWidth:(int)viewportWidth drawableHeight:(int)viewportHeight {
dst->origin.x = ((float)src->origin.x / (viewportWidth / 2.0f)) - 1.0f;
dst->origin.y = ((float)src->origin.y / (viewportHeight / 2.0f)) - 1.0f;
dst->size.width = (float)src->size.width / (viewportWidth / 2.0f);
dst->size.height = (float)src->size.height / (viewportHeight / 2.0f);
}
- (BOOL)updateVideoRegionSizeForFrame:(Frame *)frame toLayer:(CAMetalLayer *)layer {
int drawableWidth = layer.drawableSize.width;
int drawableHeight = layer.drawableSize.height;
// Check if anything has changed since the last vertex buffer upload
if (_VideoVertexBuffer && [frame width] == _lastFrameWidth && [frame height] == _lastFrameHeight && drawableWidth == _lastDrawableWidth &&
drawableHeight == _lastDrawableHeight) {
// Nothing to do
return YES;
}
// Determine the correct scaled size for the video region
CGRect src = CGRectMake(0.0, 0.0, [frame width], [frame height]);
CGRect dst = CGRectMake(0.0, 0.0, drawableWidth, drawableHeight);
[self scaleSource:&src toDest:&dst];
// Convert screen space to normalized device coordinates
CGRect renderRect;
[self screenSpace:&dst toNormalizedDeviceCoords:&renderRect withDrawableWidth:drawableWidth drawableHeight:drawableHeight];
struct Vertex verts[] = {
{{renderRect.origin.x, renderRect.origin.y, 0.0f, 1.0f}, {0.0f, 1.0f}},
{{renderRect.origin.x, renderRect.origin.y + renderRect.size.height, 0.0f, 1.0f}, {0.0f, 0}},
{{renderRect.origin.x + renderRect.size.width, renderRect.origin.y, 0.0f, 1.0f}, {1.0f, 1.0f}},
{{renderRect.origin.x + renderRect.size.width, renderRect.origin.y + renderRect.size.height, 0.0f, 1.0f}, {1.0f, 0}},
};
MTLResourceOptions bufferOptions = MTLResourceStorageModeShared;
_VideoVertexBuffer = [_device newBufferWithBytes:verts length:sizeof(verts) options:bufferOptions];
if (!_VideoVertexBuffer) {
Log(LOG_E, @"Failed to create video vertex buffer");
return NO;
}
_lastFrameWidth = [frame width];
_lastFrameHeight = [frame height];
_lastDrawableWidth = drawableWidth;
_lastDrawableHeight = drawableHeight;
return YES;
}
- (void)discardNextDrawable {
_nextDrawable = nil;
}
- (void)renderFrame:(Frame *)frame toLayer:(CAMetalLayer *)layer {
// Handle changes to the frame's colorspace from last time we rendered
BOOL layerDidChange = NO;
if (![self updateColorSpaceForFrame:frame toLayer:layer layerDidChange:&layerDidChange]) {
return;
}
if (layerDidChange && frame.frameNumber > 1) {
Log(LOG_I, @"Metal frame changed layer's colorspace and/or pixel format, returning for new drawable");
[self discardNextDrawable];
return;
}
// Handle changes to the video size or drawable size
if (![self updateVideoRegionSizeForFrame:frame toLayer:layer]) {
return;
}
FQLog(LOG_I, @"[%d / %.3f ms] Metal frame rendering", frame.frameNumber, frame.pts);
#if !TARGET_OS_TV
// Experimental EDR handling based on frame metadata
//[self applyEDRFromFrame:frame toLayer:layer];
#endif
size_t planes = CVPixelBufferGetPlaneCount(frame.pixelBuffer);
assert(planes == 2 || planes == 3);
MTLRenderPipelineDescriptor *pipelineDesc = [MTLRenderPipelineDescriptor new];
id<MTLLibrary> defaultLibrary = [_device newDefaultLibrary];
pipelineDesc.vertexFunction = [defaultLibrary newFunctionWithName:@"vs_draw"];
pipelineDesc.fragmentFunction = [defaultLibrary newFunctionWithName:planes == 2 ? @"ps_draw_biplanar" : @"ps_draw_triplanar"];
pipelineDesc.colorAttachments[0].pixelFormat = layer.pixelFormat;
pipelineDesc.vertexBuffers[0].mutability = MTLMutabilityImmutable;
NSError *error = nil;
_videoPipelineState = [_device newRenderPipelineStateWithDescriptor:pipelineDesc error:&error];
if (!_videoPipelineState) {
Log(LOG_E, @"Failed to create video pipeline state: %@", error);
return;
}
for (size_t i = 0; i < planes; i++) {
MTLPixelFormat fmt;
switch (CVPixelBufferGetPixelFormatType(frame.pixelBuffer)) {
case kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange:
case kCVPixelFormatType_444YpCbCr8BiPlanarVideoRange:
case kCVPixelFormatType_420YpCbCr8BiPlanarFullRange:
case kCVPixelFormatType_444YpCbCr8BiPlanarFullRange:
fmt = (i == 0) ? MTLPixelFormatR8Unorm : MTLPixelFormatRG8Unorm;
break;
case kCVPixelFormatType_420YpCbCr10BiPlanarFullRange:
case kCVPixelFormatType_444YpCbCr10BiPlanarFullRange:
case kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange:
case kCVPixelFormatType_444YpCbCr10BiPlanarVideoRange:
fmt = (i == 0) ? MTLPixelFormatR16Unorm : MTLPixelFormatRG16Unorm;
break;
default:
Log(LOG_E, @"Unknown pixel format: %@", CVPixelBufferGetPixelFormatType(frame.pixelBuffer));
return;
}
if (_cvMetalTextures[i]) {
CVBufferRelease(_cvMetalTextures[i]);
}
CVReturn err = CVMetalTextureCacheCreateTextureFromImage(kCFAllocatorDefault,
_textureCache,
frame.pixelBuffer,
NULL,
fmt,
CVPixelBufferGetWidthOfPlane(frame.pixelBuffer, i),
CVPixelBufferGetHeightOfPlane(frame.pixelBuffer, i),
i,
&_cvMetalTextures[i]);
if (err != kCVReturnSuccess) {
Log(LOG_E, @"CVMetalTextureCacheCreateTextureFromImage() failed: %d", err);
return;
}
}
_renderPassDescriptor.colorAttachments[0].texture = _nextDrawable.texture;
id<MTLCommandBuffer> commandBuffer = [_commandQueue commandBuffer];
id<MTLRenderCommandEncoder> renderEncoder = [commandBuffer renderCommandEncoderWithDescriptor:_renderPassDescriptor];
[renderEncoder setRenderPipelineState:_videoPipelineState];
for (size_t i = 0; i < planes; i++) {
[renderEncoder setFragmentTexture:CVMetalTextureGetTexture(_cvMetalTextures[i]) atIndex:i];
}
[commandBuffer addCompletedHandler:^(id<MTLCommandBuffer> cb) {
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 per CVMetalTextureCache requirements
for (size_t i = 0; i < planes; i++) {
CVBufferRelease(self->_cvMetalTextures[i]);
self->_cvMetalTextures[i] = nil;
}
CVMetalTextureCacheFlush(self->_textureCache, 0);
}];
[renderEncoder setFragmentBuffer:_CscParamsBuffer offset:0 atIndex:0];
[renderEncoder setVertexBuffer:_VideoVertexBuffer offset:0 atIndex:0];
[renderEncoder drawPrimitives:MTLPrimitiveTypeTriangleStrip vertexStart:0 vertexCount:4];
[renderEncoder endEncoding];
__block dispatch_semaphore_t block_semaphore = _inFlightSemaphore;
[commandBuffer addCompletedHandler:^(id<MTLCommandBuffer> buffer) {
dispatch_semaphore_signal(block_semaphore);
}];
__weak typeof(self) self_ = self;
[_nextDrawable addPresentedHandler:^(id<MTLDrawable> d) {
if (self_) {
[self_ plotFrametime:d.presentedTime];
}
}];
#if TARGET_OS_SIMULATOR
[commandBuffer presentDrawable:_nextDrawable];
#else
// present for a minimum duration for best frame pacing
[commandBuffer presentDrawable:_nextDrawable afterMinimumDuration:1.0f / _framerate];
#endif
[commandBuffer commit];
// Wait for the command buffer to complete and free our CVMetalTextureCache references
[commandBuffer waitUntilCompleted];
_nextDrawable = nil;
}
- (void)plotFrametime:(CFTimeInterval)presentedTime {
if (_lastPresented > 0) {
CFTimeInterval frametime = presentedTime - _lastPresented;
[[ImGuiPlots sharedInstance] observeFloat:PLOT_FRAMETIME value:(frametime * 1000.0)];
}
_lastPresented = presentedTime;
}
- (void)waitToRenderTo:(nonnull CAMetalLayer *)layer {
if (!_nextDrawable) {
// Wait for the next available drawable
_nextDrawable = [layer nextDrawable];
if (!_nextDrawable) {
Log(LOG_E, @"Error getting nextDrawable from CAMetalLayer");
return;
}
// Wait to ensure only `MaxFramesInFlight` number of frames are getting processed
// by any stage in the Metal pipeline (CPU, GPU, Metal, Drivers, etc.).
dispatch_semaphore_wait(_inFlightSemaphore, DISPATCH_TIME_FOREVER);
}
}
/// Responds to the drawable's size or orientation changes.
- (void)drawableResize:(CGSize)drawableSize {
[self resize:drawableSize];
}
- (void)resize:(CGSize)size {
// TODO?
}
@end
+45
View File
@@ -0,0 +1,45 @@
//
// MetalView.h
// VoidLink
//
// Created by Mason Chen on 2025/7/11.
// Copyright © 2025 Moonlight Game Streaming Project. All rights reserved.
//
#import <Metal/Metal.h>
#import <QuartzCore/CAMetalDisplayLink.h>
#import <QuartzCore/CAMetalLayer.h>
#import "MetalConfig.h"
#if TARGET_OS_IOS || TARGET_OS_TV
#import <UIKit/UIKit.h>
#define PlatformView UIView
#else
#import <AppKit/AppKit.h>
#define PlatformView NSView
#endif
// The protocol to provide resize and redraw callbacks to a delegate.
@protocol MetalViewDelegate <NSObject>
- (void)drawableResize:(CGSize)size;
- (void)renderTo:(nonnull CAMetalLayer *)layer;
- (void)waitToRenderTo:(nonnull CAMetalLayer *)layer;
@end
// The Metal game view base class.
@interface MetalView : PlatformView <CALayerDelegate>
@property (nonatomic, nonnull, readonly) CAMetalLayer *metalLayer;
@property (nonatomic, getter=isPaused) BOOL paused;
@property (nonatomic, nullable) id<MetalViewDelegate> delegate;
@property (nonatomic) float framerate;
- (void)initCommon;
#if AUTOMATICALLY_RESIZE
- (void)resizeDrawable:(CGFloat)scaleFactor;
#endif
@end
+214
View File
@@ -0,0 +1,214 @@
//
// MetalView.m
// VoidLink
//
// Created by Mason Chen on 2025/7/11.
// Copyright © 2025 Moonlight Game Streaming Project. All rights reserved.
//
// This is based on the following Apple example:
// https://developer.apple.com/documentation/metal/achieving-smooth-frame-rates-with-a-metal-display-link?language=objc
// https://developer.apple.com/wwdc23/10123/
#import "MetalView.h"
#import "MetalConfig.h"
@implementation MetalView {
#if !RENDER_ON_MAIN_THREAD
// The secondary thread containing the render loop.
NSThread *_renderThread;
// The flag to indicate that rendering needs to cease on the main thread.
BOOL _continueRunLoop;
#endif
}
#pragma mark - Initialization and Setup.
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
[self initCommon];
}
return self;
}
- (instancetype)initWithCoder:(NSCoder *)aDecoder {
self = [super initWithCoder:aDecoder];
if (self) {
[self initCommon];
}
return self;
}
- (void)initCommon {
#if TARGET_OS_OSX
self.wantsLayer = YES;
self.layerContentsRedrawPolicy = NSViewLayerContentsRedrawDuringViewResize;
#endif
_metalLayer = (CAMetalLayer *)self.layer;
self.layer.delegate = self;
}
#if TARGET_OS_IOS || TARGET_OS_TV
+ (Class)layerClass {
return [CAMetalLayer class];
}
- (void)didMoveToWindow {
[self movedToWindow];
}
#else
- (CALayer *)makeBackingLayer {
return [CAMetalLayer layer];
}
- (void)viewDidMoveToWindow {
[self movedToWindow];
}
#endif // END TARGET_OS_IOS || TARGET_OS_TV
- (void)movedToWindow {
#if !RENDER_ON_MAIN_THREAD
// Protect _continueRunLoop with a `@synchronized` block because it's accessed by the separate
// animation thread.
@synchronized(self) {
// Stop the animation loop, allowing it to complete if it's in progress.
_continueRunLoop = NO;
}
// Create and start a secondary NSThread that has another run runloop. The NSThread
// class calls the 'runThread' method at the start of the secondary thread's execution.
_renderThread = [[NSThread alloc] initWithTarget:self selector:@selector(runThread) object:nil];
_continueRunLoop = YES;
_renderThread.qualityOfService = NSQualityOfServiceUserInteractive;
[_renderThread start];
#endif // END !RENDER_ON_MAIN_THREAD
// 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
// components of the drawable's size.
#if AUTOMATICALLY_RESIZE
#if TARGET_OS_IOS || TARGET_OS_TV
[self resizeDrawable:self.window.screen.nativeScale];
#else
[self resizeDrawable:self.window.screen.backingScaleFactor];
#endif
#else
// Notify the delegate of the default drawable size when the system can calculate it.
CGSize defaultDrawableSize = self.bounds.size;
defaultDrawableSize.width *= self.layer.contentsScale;
defaultDrawableSize.height *= self.layer.contentsScale;
[self.delegate drawableResize:defaultDrawableSize];
#endif
}
#if !RENDER_ON_MAIN_THREAD
- (void)runThread {
// The system sets the '_continueRunLoop' ivar outside this thread, so it needs to synchronize. Create a
// 'continueRunLoop' local var that the system can set from the _continueRunLoop ivar in a @synchronized block.
BOOL continueRunLoop = YES;
// Begin the run loop.
while (continueRunLoop) {
@autoreleasepool {
[_delegate waitToRenderTo:_metalLayer];
@synchronized(self) {
continueRunLoop = _continueRunLoop;
}
if (!continueRunLoop) {
break;
}
[_delegate renderTo:_metalLayer];
@synchronized(self) {
continueRunLoop = _continueRunLoop;
}
}
}
}
#endif // END !RENDER_ON_MAIN_THREAD
#pragma mark - Resizing
#if AUTOMATICALLY_RESIZE
// Override all methods that indicate the view's size has changed.
#if TARGET_OS_IOS || TARGET_OS_TV
- (void)setContentScaleFactor:(CGFloat)contentScaleFactor {
[super setContentScaleFactor:contentScaleFactor];
[self resizeDrawable:self.window.screen.nativeScale];
}
- (void)layoutSubviews {
[super layoutSubviews];
[self resizeDrawable:self.window.screen.nativeScale];
}
- (void)setFrame:(CGRect)frame {
[super setFrame:frame];
[self resizeDrawable:self.window.screen.nativeScale];
}
- (void)setBounds:(CGRect)bounds {
[super setBounds:bounds];
[self resizeDrawable:self.window.screen.nativeScale];
}
#else
- (void)viewDidChangeBackingProperties {
[super viewDidChangeBackingProperties];
[self resizeDrawable:self.window.screen.backingScaleFactor];
}
- (void)setFrameSize:(NSSize)size {
[super setFrameSize:size];
[self resizeDrawable:self.window.screen.backingScaleFactor];
}
- (void)setBoundsSize:(NSSize)size {
[super setBoundsSize:size];
[self resizeDrawable:self.window.screen.backingScaleFactor];
}
#endif
- (void)resizeDrawable:(CGFloat)scaleFactor {
CGSize newSize = self.bounds.size;
newSize.width *= scaleFactor;
newSize.height *= scaleFactor;
if (newSize.width <= 0 || newSize.width <= 0) {
return;
}
#if RENDER_ON_MAIN_THREAD
if (newSize.width == _metalLayer.drawableSize.width && newSize.height == _metalLayer.drawableSize.height) {
return;
}
_metalLayer.drawableSize = newSize;
[_delegate drawableResize:newSize];
#else
// The system calls all AppKit and UIKit calls that notify of a resize on the main thread. Use
// a synchronized block to ensure that resize notifications on the delegate are atomic.
@synchronized(_metalLayer) {
if (newSize.width == _metalLayer.drawableSize.width && newSize.height == _metalLayer.drawableSize.height) {
return;
}
_metalLayer.drawableSize = newSize;
[_delegate drawableResize:newSize];
}
#endif
}
#endif // END AUTOMATICALLY_RESIZE
@end
+33
View File
@@ -0,0 +1,33 @@
//
// MetalViewController.h
// VoidLink
//
// Created by Mason Chen on 2025/7/11.
// Copyright © 2025 Moonlight Game Streaming Project. All rights reserved.
//
#import <Metal/Metal.h>
#import "FrameQueue.h"
#import "ImGuiRenderer.h"
#import "MetalVideoRenderer.h"
#import "MetalView.h"
#if TARGET_OS_IOS || TARGET_OS_TV
#import <UIKit/UIKit.h>
#define PlatformViewController UIViewController
#else
#import <AppKit/AppKit.h>
#define PlatformViewController NSViewController
#endif
@interface MetalViewController : PlatformViewController <MetalViewDelegate>
@property (nonatomic) CGRect bounds;
- (nonnull instancetype)initWithFrame:(CGRect)bounds
framerate:(float)framerate
enableHdr:(BOOL)enableHdr
metricsHandler:(MetricsHandler _Nonnull)metricsHandler;
@end
+153
View File
@@ -0,0 +1,153 @@
//
// MetalViewController.m
// VoidLink
//
// Created by Mason Chen on 2025/7/11.
// Copyright © 2025 Moonlight Game Streaming Project. All rights reserved.
//
/*
See the LICENSE.txt file for this sample’s licensing information.
Abstract:
The implementation of the cross-platform game view controller.
*/
#import "MetalViewController.h"
#import "FrameQueue.h"
#import "ImGuiRenderer.h"
#import "MetalVideoRenderer.h"
@implementation MetalViewController {
/// A queue to initialize the renderer asynchronously from the main thread.
dispatch_queue_t _dispatch_queue;
FrameQueue *_frameQueue;
float _framerate;
BOOL _enableHdr;
MetalView *_metalView;
MetalVideoRenderer *_renderer;
MetricsHandler _metricsHandler;
BOOL _stopping;
}
- (nonnull instancetype)initWithFrame:(CGRect)bounds framerate:(float)framerate enableHdr:(BOOL)enableHdr metricsHandler:(MetricsHandler)metricsHandler {
self = [super init];
if (self) {
_bounds = bounds;
_frameQueue = [FrameQueue sharedInstance];
_framerate = framerate;
_enableHdr = enableHdr;
_metricsHandler = metricsHandler;
_stopping = NO;
}
return self;
}
- (void)loadView {
self.view = [[MetalView alloc] initWithFrame:_bounds];
}
- (void)viewDidLoad {
[super viewDidLoad];
/// A queue to initialize the renderer asynchronously from the main thread.
_dispatch_queue = dispatch_queue_create("com.moonlight.Metal", DISPATCH_QUEUE_CONCURRENT);
__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 = [[PlatformView alloc] initWithFrame:self.view.frame];
return;
}
view.metalLayer.device = device;
// Initialize the renderer.
MetalVideoRenderer *renderer = [[MetalVideoRenderer alloc] initWithMetalDevice:device
drawablePixelFormat:MTLPixelFormatBGR10A2Unorm
framerate:self->_framerate];
if (!renderer) {
Log(LOG_E, @"The renderer couldn't be initialized.");
return;
}
// Initialize the renderer-dependent view properties.
view.metalLayer.pixelFormat = renderer.colorPixelFormat;
view.metalLayer.colorspace = renderer.colorspace;
view.metalLayer.maximumDrawableCount = 3;
self->_renderer = renderer;
}
- (void)waitToRenderTo:(nonnull CAMetalLayer *)layer {
if (!_stopping) {
// Renderer obtains a nextDrawable, waiting if necessary
FQLog(LOG_I, @"[MetalViewController] caling [_renderer waitToRenderTo:layer]");
[_renderer waitToRenderTo:layer];
// If we don't have a frame yet, wait on that too
FQLog(LOG_I, @"[MetalViewController] caling [_renderer waitForEnqueue]");
[_frameQueue waitForEnqueue];
}
}
/// Draw frame (used by manual loop)
- (void)renderTo:(nonnull CAMetalLayer *)layer {
if (!_renderer) {
return;
}
CFTimeInterval timeout = (1.0f / _framerate) - _renderer.averageGPUTime;
FQLog(LOG_I, @"[MetalViewController] caling [_frameQueue dequeueWithTimeout:%f]", timeout);
Frame *frame = [_frameQueue dequeueWithTimeout:timeout];
if (frame) {
FQLog(LOG_I, @"[MetalViewController] calling [_renderer renderFrame] %@", frame);
[_renderer renderFrame:frame toLayer:layer];
}
}
- (void)drawableResize:(CGSize)size {
[_renderer drawableResize:size];
}
#if TARGET_OS_IOS
/// Hides the Home indicator button automatically.
- (BOOL)prefersHomeIndicatorAutoHidden {
return YES;
}
#endif
#if TARGET_OS_OSX
/// Makes the view controller the first responder to receive keyboard events.
- (void)viewDidAppear {
[_metalView.window makeFirstResponder:self];
}
/// Receives the keydown events to avoid system beeps.
///
/// The `GameInputKeyboardMouse` class handles keyboard events.
- (void)keyDown:(NSEvent *)event {
// Reference the parameter to avoid an unused parameter warning.
(void)(event);
}
/// Receives the keyup events to avoid system beeps.
///
/// The `GameInputKeyboardMouse` class handles keyboard events.
- (void)keyUp:(NSEvent *)event {
// Reference the parameter to avoid an unused parameter warning.
(void)(event);
}
#endif
@end
+72
View File
@@ -0,0 +1,72 @@
//
// Vertex.swift
// VoidLink
//
// Created by Mason Chen on 2025/7/11.
// Copyright © 2025 Moonlight Game Streaming Project. All rights reserved.
//
#include <metal_stdlib>
#include <simd/simd.h>
using namespace metal;
struct Vertex
{
float4 position [[ position ]];
float2 texCoords;
};
struct CscParams
{
float3 matrix[3];
float3 offsets;
};
constexpr sampler s(coord::normalized, address::clamp_to_edge, filter::linear);
vertex Vertex vs_draw(constant Vertex *vertices [[ buffer(0) ]], uint id [[ vertex_id ]])
{
return vertices[id];
}
fragment float4 ps_draw_biplanar(Vertex v [[ stage_in ]],
constant CscParams &cscParams [[ buffer(0) ]],
texture2d<float> luminancePlane [[ texture(0) ]],
texture2d<float> chrominancePlane [[ texture(1) ]])
{
float3 yuv = float3(luminancePlane.sample(s, v.texCoords).r,
chrominancePlane.sample(s, v.texCoords).rg);
yuv -= cscParams.offsets;
float3 rgb;
rgb.r = dot(yuv, cscParams.matrix[0]);
rgb.g = dot(yuv, cscParams.matrix[1]);
rgb.b = dot(yuv, cscParams.matrix[2]);
return float4(rgb, 1.0f);
}
fragment float4 ps_draw_triplanar(Vertex v [[ stage_in ]],
constant CscParams &cscParams [[ buffer(0) ]],
texture2d<float> luminancePlane [[ texture(0) ]],
texture2d<float> chrominancePlaneU [[ texture(1) ]],
texture2d<float> chrominancePlaneV [[ texture(2) ]])
{
float3 yuv = float3(luminancePlane.sample(s, v.texCoords).r,
chrominancePlaneU.sample(s, v.texCoords).r,
chrominancePlaneV.sample(s, v.texCoords).r);
yuv -= cscParams.offsets;
float3 rgb;
rgb.r = dot(yuv, cscParams.matrix[0]);
rgb.g = dot(yuv, cscParams.matrix[1]);
rgb.b = dot(yuv, cscParams.matrix[2]);
return float4(rgb, 1.0f);
}
fragment float4 ps_draw_rgb(Vertex v [[ stage_in ]],
texture2d<float> rgbTexture [[ texture(0) ]])
{
return rgbTexture.sample(s, v.texCoords);
}
+12
View File
@@ -0,0 +1,12 @@
#import <UIKit/UIKit.h>
@interface SceneDelegate : UIResponder <UIWindowSceneDelegate>
@property (strong, nonatomic) UIWindow * window;
+ (void)setExternalDisplayRenderView:(UIView *)renderView;
+ (void)clearExternalDisplayRenderView;
- (void)updatePreferredDisplayMode:(BOOL)streamActive withRenderView:(UIView *)renderView;
@end
+91
View File
@@ -0,0 +1,91 @@
#import "SceneDelegate.h"
#import "StreamFrameViewController.h"
@implementation SceneDelegate
static UIView *_sharedStreamVideoRenderView = nil;
static UIWindow *_externalSceneWindow = nil;
- (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session options:(UISceneConnectionOptions *)connectionOptions {
if (![scene isKindOfClass:[UIWindowScene class]]) {
return;
}
UIWindowScene *windowScene = (UIWindowScene *)scene;
if ([session.role isEqualToString:UIWindowSceneSessionRoleApplication]) {
self.window = [[UIWindow alloc] initWithWindowScene:windowScene];
NSString *storyboardName;
if (UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPad) {
storyboardName = @"iPad";
} else {
storyboardName = @"iPhone";
}
UIStoryboard *storyboard = [UIStoryboard storyboardWithName:storyboardName bundle:nil];
UIViewController *initialViewController = [storyboard instantiateInitialViewController];
self.window.rootViewController = initialViewController;
[self.window makeKeyAndVisible];
Log(LOG_I, @"SceneDelegate: Main app scene connected.");
} else if ([session.role isEqualToString:UIWindowSceneSessionRoleExternalDisplay]) {
Log(LOG_I, @"SceneDelegate: External display scene connecting for screen: %@", ((UIWindowScene *)scene).screen.description);
UIWindowScene *windowScene = (UIWindowScene *)scene;
_externalSceneWindow = [[UIWindow alloc] initWithWindowScene:windowScene];
UIViewController *externalVC = [[UIViewController alloc] init];
externalVC.view.backgroundColor = [UIColor blackColor]; // Set a default background
_externalSceneWindow.rootViewController = externalVC;
if (_sharedStreamVideoRenderView) {
_sharedStreamVideoRenderView.frame = _externalSceneWindow.bounds;
[_externalSceneWindow.rootViewController.view addSubview:_sharedStreamVideoRenderView];
Log(LOG_I, @"SceneDelegate: External display scene connected.");
}
}
}
// Method for StreamFrameViewController to provide its render view
+ (void)setExternalDisplayRenderView:(UIView *)renderView {
_sharedStreamVideoRenderView = renderView;
if (_externalSceneWindow && _externalSceneWindow.rootViewController && _sharedStreamVideoRenderView) {
dispatch_async(dispatch_get_main_queue(), ^{
// Ensure it's removed from any previous parent (should have been done by StreamFrameVC)
[_sharedStreamVideoRenderView removeFromSuperview];
_sharedStreamVideoRenderView.frame = _externalSceneWindow.bounds; // Set frame for external window
[_externalSceneWindow.rootViewController.view addSubview:_sharedStreamVideoRenderView];
_externalSceneWindow.hidden = NO;
Log(LOG_I, @"SceneDelegate: Added render view to external window's root view.");
});
} else {
Log(LOG_E, @"SceneDelegate: External display window or root view controller not available.");
}
}
+ (void)clearExternalDisplayRenderView {
if (_sharedStreamVideoRenderView) {
dispatch_async(dispatch_get_main_queue(), ^{
[_sharedStreamVideoRenderView removeFromSuperview];
Log(LOG_I, @"SceneDelegate: Removed render view from external display.");
});
}
_sharedStreamVideoRenderView = nil;
}
- (void)sceneDidDisconnect:(UIScene *)scene {
Log(LOG_I, @"SceneDelegate: Scene disconnected: %@, role: %@", scene.title, scene.session.role);
if ([scene.session.role isEqualToString:UIWindowSceneSessionRoleExternalDisplay]) {
if ([scene isKindOfClass:[UIWindowScene class]]) {
UIWindowScene *windowScene = (UIWindowScene *)scene;
if (_externalSceneWindow == windowScene.windows.firstObject) { // Compare with the window from the disconnecting scene
[SceneDelegate clearExternalDisplayRenderView]; // Clears the shared view
_externalSceneWindow = nil;
Log(LOG_I, @"SceneDelegate: External display scene fully disconnected and cleaned up.");
} else {
Log(LOG_W, @"SceneDelegate: Disconnecting scene is not the one holding our _externalSceneWindow.");
}
} else {
Log(LOG_W, @"SceneDelegate: Disconnecting scene is not a UIWindowScene.");
}
}
}
@end
+39
View File
@@ -0,0 +1,39 @@
//
// ImGuiPlots.h
// VoidLink
//
// Created by Mason Chen on 2025/7/11.
// Copyright © 2025 Moonlight Game Streaming Project. All rights reserved.
//
#import <Foundation/Foundation.h>
#import <VideoToolbox/VideoToolbox.h>
#import "FloatBuffer.h"
#import "Plot.h"
@interface ImGuiPlots : NSObject
struct PlotDef {
FloatBuffer * _Nonnull buffer;
const char * _Nonnull title;
PlotSide side;
PlotLabelType labelType;
const char * _Nonnull unit;
double scaleMin, scaleMax, scaleTarget;
float minY;
float maxY;
};
@property (nonatomic) struct PlotDef * _Nonnull plots;
+ (instancetype _Nonnull)sharedInstance;
- (instancetype _Nonnull)init NS_UNAVAILABLE;
+ (instancetype _Nonnull)new NS_UNAVAILABLE;
- (void)observeFloat:(int)plotId value:(CFTimeInterval)value;
- (void)observeFloatReturnMetrics:(int)plotId value:(CFTimeInterval)value plotMetrics:(PlotMetrics * _Nullable)plotMetrics;
@end
+95
View File
@@ -0,0 +1,95 @@
#import "ImGuiPlots.h"
@implementation ImGuiPlots
+ (instancetype)sharedInstance {
static ImGuiPlots *sharedInstance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
sharedInstance = [[super allocWithZone:NULL] _initSingleton];
});
return sharedInstance;
}
// Prevent others from using alloc/init directly
+ (instancetype)allocWithZone:(struct _NSZone *)zone {
return [self sharedInstance];
}
// If someone tries to copy it, just return the same instance
- (id)copyWithZone:(NSZone *)zone {
return self;
}
- (id)mutableCopyWithZone:(NSZone *)zone {
return self;
}
-(nonnull instancetype)_initSingleton
{
self = [super init];
if (self) {
_plots = (PlotDef *)calloc(PlotCount, sizeof(PlotDef));
_plots[PLOT_FRAMETIME] = {
.title = "Frametime",
.side = PLOT_LEFT,
.unit = "ms",
.scaleMin = (1000.0 / 120) - 1,
.scaleMax = 50.0f, // (1000.0 / streamFps) * 3,
.buffer = [[FloatBuffer alloc] initWithCapacity:512]
};
_plots[PLOT_HOST_FRAMETIME] = {
.title = "Host Frametime",
.side = PLOT_LEFT,
.unit = "ms",
.scaleMin = (1000.0 / 120) - 1,
.scaleMax = 50.0f, // (1000.0 / streamFps) * 3,
.buffer = [[FloatBuffer alloc] initWithCapacity:512]
};
_plots[PLOT_QUEUED_FRAMES] = {
.title = "Frame queue",
.side = PLOT_RIGHT,
.labelType = PLOT_LABEL_MIN_MAX_AVG_INT,
.unit = "",
.scaleMin = -0.5,
.scaleMax = 15,
.buffer = [[FloatBuffer alloc] initWithCapacity:512]
};
_plots[PLOT_DROPPED] = {
.title = "Frames dropped",
.side = PLOT_RIGHT,
.labelType = PLOT_LABEL_TOTAL_INT,
.unit = "",
.scaleTarget = 2,
.buffer = [[FloatBuffer alloc] initWithCapacity:512]
};
// not graphed, but used for stats
_plots[PLOT_DECODE] = {
.title = "Decode time",
.labelType = PLOT_LABEL_MIN_MAX_AVG,
.unit = "ms",
.buffer = [[FloatBuffer alloc] initWithCapacity:512]
};
}
return self;
}
- (void) observeFloat:(int)plotId value:(CFTimeInterval)value {
[_plots[plotId].buffer addValue:(float)value];
}
- (void) observeFloatReturnMetrics:(int)plotId value:(CFTimeInterval)value plotMetrics:(PlotMetrics *)plotMetrics {
[_plots[plotId].buffer addValue:(float)value];
if (plotMetrics != nil) {
[_plots[plotId].buffer copyMetrics:plotMetrics];
}
}
@end
+1 -1
View File
@@ -18,6 +18,6 @@
- (void) stopStream;
- (NSString*) getStatsOverlayText: (uint16_t) overlayLevel;
- (NSString*) getStatsOverlayText;
@end
+5 -14
View File
@@ -152,11 +152,9 @@
return TRUE;
}
- (NSString*) getStatsOverlayText: (uint16_t) overlayLevel {
- (NSString*) getStatsOverlayText {
video_stats_t stats;
// NSLog(@"overlayLevel: %d", overlayLevel);
if (!_connection) {
return nil;
}
@@ -168,16 +166,15 @@
uint32_t rtt, variance;
NSString* latencyString;
if (LiGetEstimatedRttInfo(&rtt, &variance)) {
latencyString = [LocalizationHelper localizedStringForKey:@"%u ms (variance: %u ms)", rtt, variance];
latencyString = [NSString stringWithFormat:@"%u ms (variance: %u ms)", rtt, variance];
}
else {
latencyString = @"N/A";
}
NSString* hostProcessingString;
// overlayLevel 2: detailed
if (stats.framesWithHostProcessingLatency != 0) {
hostProcessingString = [LocalizationHelper localizedStringForKey:@"\nHost processing latency min/max/avg: %.1f/%.1f/%.1f ms",
hostProcessingString = [NSString stringWithFormat:@"Host processing latency min/max/avg: %.1f/%.1f/%.1f ms\n",
stats.minHostProcessingLatency / 10.f,
stats.maxHostProcessingLatency / 10.f,
(float)stats.totalHostProcessingLatency / stats.framesWithHostProcessingLatency / 10.f];
@@ -188,13 +185,12 @@
}
float interval = stats.endTime - stats.startTime;
if(overlayLevel == 2) return [LocalizationHelper localizedStringForKey:@"Video stream: %dx%d %.2f FPS (Codec: %@)\nNetwork dropped frames: %.2f%%\nAverage network latency: %@%@",
float scalePlotMetrics = stats.frameDropMetrics.nsamples > 0 ? ((float)stats.frameDropMetrics.nsamples / stats.totalFrames) : 1.0f;
float fps = (stats.totalFrames - stats.networkDroppedFrames - (stats.frameDropMetrics.total / scalePlotMetrics)) / interval;
double avgVideoMbps = [_connection getBwTracker].averageMbps;
double peakVideoMbps = [_connection getBwTracker].peakMbps;
return [NSString stringWithFormat:@"Video stream: %dx%d %.2f FPS (Codec: %@)\n"
"Bitrate: %.1f Mbps, Peak: %.1f, Renderer: %@\n"
"%@"
@@ -212,11 +208,6 @@
(stats.networkDroppedFrames / stats.totalFrames) * 100.0,
stats.frameDropMetrics.nsamples > 0 ? (stats.frameDropMetrics.total / stats.frameDropMetrics.nsamples) * 100.0 : 0.0f,
latencyString,
hostProcessingString];
else return [LocalizationHelper localizedStringForKey:@"FPS: %5.2f Network dropped frames: %.2f%% Network latency: %@",
stats.totalFrames / interval,
stats.networkDroppedFrames / interval,
latencyString];
stats.decodeMetrics.min, stats.decodeMetrics.max, stats.decodeMetrics.avg];
}
@@ -106,6 +106,14 @@
@property (strong, nonatomic) IBOutlet UIStackView *audioConfigStack;
@property (strong, nonatomic) IBOutlet UISegmentedControl *audioConfigSelector;
@property (strong, nonatomic) IBOutlet UILabel *frameQueueSizeLabel;
@property (strong, nonatomic) IBOutlet UISlider *frameQueueSizeSlider;
@property (strong, nonatomic) IBOutlet UILabel *enableGraphsLabel;
@property (strong, nonatomic) IBOutlet UISwitch *enableGraphsSwitch;
@property (strong, nonatomic) IBOutlet UIStepper *graphOpacityStepper;
@property (strong, nonatomic) IBOutlet UISegmentedControl *renderingBackendSelector;
#pragma clang diagnostic push
@@ -37,6 +37,8 @@
CGFloat _currentRefreshRate;
MenuSectionView *touchControlSection;
NSMutableSet* hiddenStacks;
NSInteger _frameQueueSize;
NSInteger _graphOpacity;
}
@dynamic overrideUserInterfaceStyle;
@@ -147,7 +149,7 @@ static const int bitrateTable[] = {
500000,
};
const int RESOLUTION_TABLE_SIZE = 6;
const int RESOLUTION_TABLE_SIZE = 7;
const int RESOLUTION_TABLE_CUSTOM_INDEX = RESOLUTION_TABLE_SIZE - 1;
CGSize resolutionTable[RESOLUTION_TABLE_SIZE];
@@ -1149,6 +1151,8 @@ BOOL isCustomResolution(CGSize res) {
// Ensure we pick a bitrate that falls exactly onto a slider notch
_bitrate = bitrateTable[[self getSliderValueForBitrate:[currentSettings.bitrate intValue]]];
_frameQueueSize = [currentSettings.frameQueueSize intValue];
_graphOpacity = [currentSettings.graphOpacity intValue];
// Get the size of the screen with and without safe area insets
UIWindow *window = UIApplication.sharedApplication.windows.firstObject;
@@ -1162,10 +1166,11 @@ BOOL isCustomResolution(CGSize res) {
resolutionTable[0] = CGSizeMake(1280, 720);
resolutionTable[1] = CGSizeMake(1920, 1080);
resolutionTable[2] = CGSizeMake(3840, 2160);
resolutionTable[3] = CGSizeMake(safeAreaWidth, fullScreenHeight);
resolutionTable[4] = CGSizeMake(fullScreenWidth, fullScreenHeight);
resolutionTable[5] = CGSizeMake([currentSettings.width integerValue], [currentSettings.height integerValue]); // custom initial value
resolutionTable[2] = CGSizeMake(2560, 1440);
resolutionTable[3] = CGSizeMake(3840, 2160);
resolutionTable[4] = CGSizeMake(safeAreaWidth, fullScreenHeight);
resolutionTable[5] = CGSizeMake(fullScreenWidth, fullScreenHeight);
resolutionTable[6] = CGSizeMake([currentSettings.width integerValue], [currentSettings.height integerValue]); // custom initial value
[self updateResolutionTable];
// Don't populate the custom entry unless we have a custom resolution
@@ -1183,9 +1188,12 @@ BOOL isCustomResolution(CGSize res) {
case 60:
framerate = 1;
break;
case 120:
case 90:
framerate = 2;
break;
case 120:
framerate = 3;
break;
}
NSInteger resolution = currentSettings.resolutionSelected.integerValue;
@@ -1246,6 +1254,10 @@ BOOL isCustomResolution(CGSize res) {
[self.yuv444Switch setOn:currentSettings.enableYUV444];
[self.statsOverlaySelector setSelectedSegmentIndex:currentSettings.statsOverlayLevel.intValue];
NSInteger renderingBackend = [currentSettings.renderingBackend integerValue];
[self.renderingBackendSelector setSelectedSegmentIndex:renderingBackend];
[self.citrixX1MouseSwitch setOn:currentSettings.btMouseSupport];
[self.optimizeGamesSwitch setOn: currentSettings.optimizeGames];
[self.framePacingSelector setSelectedSegmentIndex:currentSettings.useFramePacing ? 1 : 0];
@@ -1274,6 +1286,21 @@ BOOL isCustomResolution(CGSize res) {
[self.bitrateSlider addTarget:self action:@selector(bitrateSliderMoved) forControlEvents:UIControlEventValueChanged];
[self updateBitrateText];
[self updateResolutionDisplayLabel];
[self.frameQueueSizeSlider setMinimumValue:1];
[self.frameQueueSizeSlider setMaximumValue:5];
[self.frameQueueSizeSlider setValue:_frameQueueSize];
[self.frameQueueSizeSlider addTarget:self action:@selector(frameQueueSizeSliderMoved) forControlEvents:UIControlEventValueChanged];
[self updateFrameQueueSizeText];
[self.enableGraphsSwitch addTarget:self action:@selector(enableGraphsChanged) forControlEvents:UIControlEventValueChanged];
[self enableGraphsChanged];
[self.graphOpacityStepper setMinimumValue:0];
[self.graphOpacityStepper setMaximumValue:100];
[self.graphOpacityStepper setValue:_graphOpacity];
[self.graphOpacityStepper addTarget:self action:@selector(graphOpacityStepperMoved) forControlEvents:UIControlEventValueChanged];
[self updateGraphOpacityText];
if (@available(iOS 18.0, tvOS 18.0, *)) {}else{
[self.audioConfigSelector removeSegmentAtIndex:1 animated:false];
[self.audioConfigSelector removeSegmentAtIndex:1 animated:false]; // segment 2 goes away when you remove index 2
@@ -1808,6 +1835,8 @@ BOOL isCustomResolution(CGSize res) {
case 1:
return 60;
case 2:
return 90;
case 3:
return 120;
default:
abort();
@@ -1926,25 +1955,50 @@ BOOL isCustomResolution(CGSize res) {
}
}
- (void) frameQueueSizeSliderMoved {
assert(self.frameQueueSizeSlider.value >= 0 && self.frameQueueSizeSlider.value <= 5);
_frameQueueSize = (int)self.frameQueueSizeSlider.value;
[self updateFrameQueueSizeText];
}
- (void) updateFrameQueueSizeText {
[self.frameQueueSizeLabel setText:[NSString stringWithFormat:@"Frames to buffer: %ld", _frameQueueSize ]];
}
- (void) enableGraphsChanged {
[self.graphOpacityStepper setEnabled:[self.enableGraphsSwitch isOn]];
}
- (void) graphOpacityStepperMoved {
assert(self.graphOpacityStepper.value >= 0 && self.graphOpacityStepper.value <= 100);
_graphOpacity = (int)self.graphOpacityStepper.value;
[self updateGraphOpacityText];
}
- (void) updateGraphOpacityText {
[self.enableGraphsLabel setText:[NSString stringWithFormat:@"Performance Graphs - Opacity: %ld%%", _graphOpacity ]];
}
- (void) saveSettings {
DataManager* dataMan = [[DataManager alloc] init];
NSInteger framerate = [self getChosenFrameRate];
NSInteger height = [self getChosenStreamHeight];
NSInteger width = [self getChosenStreamWidth];
NSInteger audioConfig = [@[@2, @6, @8][[self.audioConfigSelector selectedSegmentIndex]] integerValue];
NSInteger renderingBackend = [self.renderingBackendSelector selectedSegmentIndex];
NSInteger onscreenControls = [self.onScreenWidgetSelector selectedSegmentIndex];
NSInteger keyboardToggleFingers = self.softKeyboardGestureSelector.selectedSegmentIndex == 3 ? 20 : self.softKeyboardGestureSelector.selectedSegmentIndex+3;
NSInteger oscLayoutToolFingers = (uint16_t)self->oswLayoutFingers;
CGFloat slideToSettingsDistance = self.slideToMenuDistanceSlider.value;
uint32_t slideToSettingsScreenEdge = [self getScreenEdgeFromSelector];
CGFloat pointerVelocityModeDivider = (CGFloat)(uint8_t)self.pointerVelocityModeDividerSlider.value/100;
CGFloat touchPointerVelocityFactor = (CGFloat)(uint16_t)[self map_velocFactorDisplay_fromSliderValue:self.touchPointerVelocityFactorSlider.value]/100;
CGFloat mousePointerVelocityFactor = (CGFloat)(uint16_t)[self map_velocFactorDisplay_fromSliderValue:self.mousePointerVelocityFactorSlider.value]/100;
CGFloat gyroSensitivity = (CGFloat)(uint16_t)self.gyroSensitivitySlider.value/100;
uint16_t touchMoveEventInterval = (uint16_t)self.touchMoveEventIntervalSlider.value;
BOOL reverseMouseWheelDirection = [self.reverseMouseWheelDirectionSelector selectedSegmentIndex] == 1;
NSInteger asyncNativeTouchPriority = 1;
//BOOL liftStreamViewForKeyboard = [self.liftStreamViewForKeyboardSelector selectedSegmentIndex] == 1;
@@ -1964,6 +2018,7 @@ BOOL isCustomResolution(CGSize res) {
BOOL statsOverlayEnabled = statsOverlayLevel != 0;
BOOL enableHdr = self.hdrSwitch.isOn;
BOOL unlockDisplayOrientation = [self.unlockDisplayOrientationSelector selectedSegmentIndex] == 1;
BOOL enableGraphs = self.enableGraphsSwitch.isOn;
NSInteger resolutionSelected = [self.resolutionSelector selectedSegmentIndex];
NSInteger externalDisplayMode = [self.externalDisplayModeSelector selectedSegmentIndex];
NSInteger localMousePointerMode = [self.localMousePointerModeSelector selectedSegmentIndex];
@@ -2002,7 +2057,11 @@ BOOL isCustomResolution(CGSize res) {
unlockDisplayOrientation:unlockDisplayOrientation
resolutionSelected:resolutionSelected
externalDisplayMode:externalDisplayMode
localMousePointerMode:localMousePointerMode];
localMousePointerMode:localMousePointerMode
frameQueueSize:_frameQueueSize
enableGraphs:enableGraphs
graphOpacity:_graphOpacity
renderingBackend:renderingBackend];
}
- (void)didReceiveMemoryWarning {
@@ -50,7 +50,6 @@
UITapGestureRecognizer *_menuDoubleTapGestureRecognizer;
UITapGestureRecognizer *_playPauseTapGestureRecognizer;
UITextView *_overlayView;
uint16_t overlayLevel;
UILabel *_stageLabel;
UILabel *_tipLabel;
UIActivityIndicatorView *_spinner;
@@ -189,7 +188,6 @@
}
_settings = [[[DataManager alloc] init] getSettings]; //StreamFrameViewController retrieve the settings here.
overlayLevel = _settings.statsOverlayLevel.intValue;
[self configOscLayoutTool];
[self configSwipeGestures];
[self configZoomGestureAndAddStreamView];
@@ -583,7 +581,7 @@
}
else [self.view addSubview:_overlayView]; // don't know why but this is necessary for reactivating overlay.
NSString* overlayText = [self->_streamMan getStatsOverlayText:overlayLevel];
NSString* overlayText = [self->_streamMan getStatsOverlayText];
dispatch_async(dispatch_get_main_queue(), ^{
[self updateOverlayText:overlayText];
});