fix: build
This commit is contained in:
1 parent
df2b67ac99
commit
d69688678d
36 files changed
+565
-836
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 *)¶mBuffer 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -18,6 +18,6 @@
|
||||
|
||||
- (void) stopStream;
|
||||
|
||||
- (NSString*) getStatsOverlayText: (uint16_t) overlayLevel;
|
||||
- (NSString*) getStatsOverlayText;
|
||||
|
||||
@end
|
||||
@@ -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];
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user