// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using SharpEmu.HLE;
using SharpEmu.Libs.VideoOut;
using SharpEmu.ShaderCompiler;
using SharpEmu.ShaderCompiler.Metal;
namespace SharpEmu.Libs.Gpu.Metal;
///
/// The Metal presenter: an AppKit window hosting a CAMetalLayer. A CADisplayLink
/// requested from the content view drives in sync with the
/// display refresh, on the main run loop — the loop whose Core Animation observer
/// commits presented drawables to the window server, so it must be a real running
/// run loop (a hand-pumped event drain never fires that observer and the window
/// stays black). Everything AppKit runs on the process main thread via
/// (AppKit traps off-main), which the CLI parks for us.
///
internal static partial class MetalVideoPresenter
{
private const uint DefaultWindowWidth = 1280;
private const uint DefaultWindowHeight = 720;
// NSWindow style: Titled | Closable | Miniaturizable | Resizable. Resizable
// both lets the user drag the window edges and turns the green zoom button
// into the full-screen toggle (paired with the collection behavior below).
private const nuint WindowStyleMask = 1 | 2 | 4 | 8;
// NSWindowCollectionBehaviorFullScreenPrimary: opt this window into native
// full-screen, so the green button enters full-screen rather than zooming.
private const nuint FullScreenPrimaryBehavior = 1 << 7;
private const nuint BackingStoreBuffered = 2;
private const nuint PixelFormatBgra8Unorm = (nuint)MtlPixelFormat.Bgra8Unorm;
private const nuint LoadActionLoad = 1;
private const nuint LoadActionClear = 2;
private const nuint StoreActionStore = 1;
private const nuint PrimitiveTypeTriangle = 3;
private const nuint SamplerMinMagFilterLinear = 1;
private sealed record Presentation(
byte[]? Pixels,
uint Width,
uint Height,
long Sequence,
bool IsSplash,
ulong GuestImageAddress = 0,
long GuestImageVersion = 0,
uint GuestImagePitch = 0,
long RequiredGuestWorkSequence = 0,
TranslatedGuestDraw? TranslatedDraw = null,
GuestDrawKind DrawKind = GuestDrawKind.None);
private static readonly object _gate = new();
private static Thread? _thread;
private static bool _closed;
private static bool _splashHidden;
private static bool _closeRequested;
private static Presentation? _latestPresentation;
private static bool _loggedFirstPresentedFrame;
private static int _titleRefreshCounter;
private static string? _lastWindowTitle;
// CPU-rasterized perf HUD (F1), blitted over the frame like the Vulkan
// presenter does; the panel texture lives for the window's lifetime.
private static nint _overlayTexture;
private static readonly byte[] _overlayPixels =
new byte[PerfOverlay.PanelWidth * PerfOverlay.PanelHeight * 4];
// Presenter objects and per-frame present state, shared between window setup
// and the display-link RenderFrame callback (both on the main thread).
private static nint _device;
private static nint _commandQueue;
private static nint _metalLayer;
private static nint _presentPipeline;
private static nint _presentSampler;
private static nint _window;
private static nint _application;
private static nint _renderTimer;
private static nint _renderTimerTarget;
private static double _drawableWidth;
private static double _drawableHeight;
private static nint _frameTexture;
private static uint _frameTextureWidth;
private static uint _frameTextureHeight;
private static nint _presentTexture;
private static uint _presentTextureWidth;
private static uint _presentTextureHeight;
private static nint _ownedVersionTexture;
private static ulong _presentGuestAddress;
private static long _presentedSequence = -1;
private static bool _userClosed;
private static uint _windowWidth;
private static uint _windowHeight;
public static void EnsureStarted(uint width, uint height)
{
if (width == 0 || height == 0)
{
return;
}
lock (_gate)
{
if (_closed || _thread is not null)
{
return;
}
}
var hasSplash = PngSplashLoader.TryLoad(
out var splashPixels,
out var splashWidth,
out var splashHeight);
lock (_gate)
{
if (_closed || _thread is not null)
{
return;
}
_windowWidth = width;
_windowHeight = height;
_latestPresentation ??= _splashHidden
? new Presentation(CreateBlackFrame(width, height), width, height, 1, IsSplash: false)
: hasSplash
? new Presentation(splashPixels, splashWidth, splashHeight, 1, IsSplash: true)
: new Presentation(null, width, height, 0, IsSplash: false);
StartPresenterLocked();
}
}
public static void HideSplashScreen()
{
lock (_gate)
{
_splashHidden = true;
if (_closed || _latestPresentation is not { IsSplash: true } latest)
{
return;
}
_latestPresentation = new Presentation(
CreateBlackFrame(latest.Width, latest.Height),
latest.Width,
latest.Height,
latest.Sequence + 1,
IsSplash: false);
Console.Error.WriteLine("[LOADER][INFO] Metal VideoOut hid splash");
}
}
public static void Submit(byte[] bgraFrame, uint width, uint height)
{
if (bgraFrame.Length != checked((int)(width * height * 4)))
{
return;
}
lock (_gate)
{
if (_closed)
{
return;
}
var sequence = (_latestPresentation?.Sequence ?? 0) + 1;
_latestPresentation = new Presentation(bgraFrame, width, height, sequence, IsSplash: false);
if (_thread is not null)
{
return;
}
_windowWidth = width;
_windowHeight = height;
StartPresenterLocked();
}
}
/// Asks a running presenter loop to close its window and return.
public static void RequestClose()
{
Volatile.Write(ref _closeRequested, true);
}
private static void StartPresenterLocked()
{
if (HostMainThread.IsAvailable)
{
_thread = Thread.CurrentThread;
HostMainThread.SetShutdownRequestHandler(RequestClose);
HostMainThread.Post(Run);
return;
}
_thread = new Thread(Run)
{
IsBackground = true,
Name = "SharpEmu Metal VideoOut",
};
_thread.Start();
}
private static void Run()
{
try
{
RunWindowLoop();
}
catch (Exception exception)
{
Console.Error.WriteLine($"[LOADER][ERROR] Metal VideoOut presenter failed: {exception}");
}
finally
{
lock (_gate)
{
_closed = true;
_thread = null;
// Wake guest-work waiters and backpressured producers so close
// never strands a blocked guest thread.
Monitor.PulseAll(_gate);
}
}
}
private static void RunWindowLoop()
{
MetalNative.EnsureFrameworksLoaded();
_device = MetalNative.MTLCreateSystemDefaultDevice();
if (_device == 0)
{
Console.Error.WriteLine("[LOADER][ERROR] No Metal device available.");
return;
}
// Mirror the Vulkan presenter: fold the selected GPU's name into the
// window title. Without this the Metal title never gains the "· "
// suffix the Vulkan path shows.
var deviceName = MetalNative.ReadNsString(
MetalNative.Send(_device, MetalNative.Selector("name")));
if (!string.IsNullOrEmpty(deviceName))
{
VideoOutExports.SetSelectedGpuName(deviceName);
}
// Fixed window like the Vulkan presenter: guest frames letterbox into
// it. Sizing the window from the guest's display mode (4K) exceeds the
// screen — macOS clamps the window while the layer keeps the requested
// geometry, leaving the visible region showing nothing but clear.
const uint width = DefaultWindowWidth;
const uint height = DefaultWindowHeight;
var setupPool = MetalNative.objc_autoreleasePoolPush();
try
{
_application = MetalNative.Send(
MetalNative.Class("NSApplication"), MetalNative.Selector("sharedApplication"));
// NSApplicationActivationPolicyRegular: dock icon + key window like any app.
MetalNative.Send(_application, MetalNative.Selector("setActivationPolicy:"), 0);
MetalNative.SendVoid(_application, MetalNative.Selector("finishLaunching"));
_window = CreateWindow(width, height);
// Swap in the key-capturing view before the metal layer attaches so
// the layer lands on the input-aware content view.
var keyView = MetalNative.SendInitFrame(
MetalNative.Send(CreateKeyViewClass(), MetalNative.Selector("alloc")),
MetalNative.Selector("initWithFrame:"),
new CGRect { X = 0, Y = 0, Width = width, Height = height });
MetalNative.SendVoid(_window, MetalNative.Selector("setContentView:"), keyView);
_metalLayer = CreateLayer(_device, _window, out _drawableWidth, out _drawableHeight);
_commandQueue = MetalNative.Send(_device, MetalNative.Selector("newCommandQueue"));
if (!TryCreatePresentPipeline(_device, out _presentPipeline, out var pipelineError))
{
Console.Error.WriteLine($"[LOADER][ERROR] Metal present pipeline failed: {pipelineError}");
return;
}
_presentSampler = CreateLinearSampler(_device);
MetalNative.SendVoid(_window, MetalNative.Selector("makeKeyAndOrderFront:"), 0);
MetalNative.SendVoidBool(
_application, MetalNative.Selector("activateIgnoringOtherApps:"), true);
MetalNative.SendVoid(_window, MetalNative.Selector("makeFirstResponder:"), keyView);
MetalHostInput.Attach();
// A repeating NSTimer on this (main) run loop fires onFrame: at the
// display rate. CADisplayLink (NSView.displayLinkWithTarget:selector:)
// is the natural choice but its callback never fires under the x86-64
// Rosetta process this emulator runs as — proven in isolation against
// a bare AppKit harness, where a timer fires and composites and the
// display link does not. nextDrawable still throttles presentation to
// the display, so the timer only needs to keep up, not pace precisely.
_renderTimerTarget = CreateRenderTimerTarget();
_renderTimer = MetalNative.Send(
MetalNative.SendTimer(
MetalNative.Class("NSTimer"),
MetalNative.Selector("scheduledTimerWithTimeInterval:target:selector:userInfo:repeats:"),
1.0 / 60.0,
_renderTimerTarget,
MetalNative.Selector("onFrame:"),
0,
repeats: true),
MetalNative.Selector("retain"));
}
finally
{
MetalNative.objc_autoreleasePoolPop(setupPool);
}
Console.Error.WriteLine("[LOADER][INFO] Metal VideoOut presenter started.");
// [NSApp run] runs the main run loop (its Core Animation observer commits
// presented drawables to the window server) AND fully activates the app,
// which a bare CFRunLoopRun does not — the CADisplayLink is only serviced
// once the app is running, and NSApp dispatches window events itself.
// Returns once RenderFrame stops it.
MetalNative.SendVoid(_application, MetalNative.Selector("run"));
var closePool = MetalNative.objc_autoreleasePoolPush();
try
{
MetalNative.SendVoid(_window, MetalNative.Selector("close"));
}
finally
{
MetalNative.objc_autoreleasePoolPop(closePool);
}
if (_userClosed)
{
Console.Error.WriteLine(
"[LOADER][WARN] Metal VideoOut window closed; requesting emulator shutdown.");
VideoOutExports.NotifyPresentationWindowClosed();
}
}
///
/// An NSView subclass that records key events for pad emulation. Overriding
/// keyDown:/keyUp: (instead of an event monitor) needs no ObjC blocks, and
/// swallowing the events also silences the system alert beep AppKit plays
/// for unhandled keys. Registered once per process.
///
private static unsafe nint CreateKeyViewClass()
{
var cls = MetalNative.objc_allocateClassPair(
MetalNative.Class("NSView"), "SharpEmuMetalView", 0);
if (cls == 0)
{
return MetalNative.Class("SharpEmuMetalView");
}
var keyDown = (nint)(delegate* unmanaged[Cdecl])&OnKeyDown;
MetalNative.class_addMethod(cls, MetalNative.Selector("keyDown:"), keyDown, "v@:@");
var keyUp = (nint)(delegate* unmanaged[Cdecl])&OnKeyUp;
MetalNative.class_addMethod(cls, MetalNative.Selector("keyUp:"), keyUp, "v@:@");
// Command-modified keys never reach keyDown: — AppKit routes them through
// performKeyEquivalent:, so Cmd+F1 (Metal Performance HUD) hooks in here.
var keyEquivalent = (nint)(delegate* unmanaged[Cdecl])&OnPerformKeyEquivalent;
MetalNative.class_addMethod(
cls, MetalNative.Selector("performKeyEquivalent:"), keyEquivalent, "c@:@");
// First responder status is what routes key events to this view.
var accepts = (nint)(delegate* unmanaged[Cdecl])&AcceptsFirstResponder;
MetalNative.class_addMethod(
cls, MetalNative.Selector("acceptsFirstResponder"), accepts, "c@:");
MetalNative.objc_registerClassPair(cls);
return cls;
}
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
private static void OnKeyDown(nint self, nint cmd, nint nsEvent)
{
try
{
var keyCode = (ushort)(MetalNative.Send(nsEvent, MetalNative.Selector("keyCode")) & 0xFFFF);
var isRepeat = MetalNative.SendBool(nsEvent, MetalNative.Selector("isARepeat"));
// Function keys can arrive here even with Command held (AppKit only
// reroutes some chords through the key-equivalent path), so catch
// Cmd+F1 in both places — and keep it away from MetalHostInput so it
// never toggles the plain-F1 perf overlay.
var modifiers = (ulong)MetalNative.Send(nsEvent, MetalNative.Selector("modifierFlags"));
if (keyCode == KeyCodeF1 && (modifiers & NsEventModifierFlagCommand) != 0)
{
if (!isRepeat)
{
ToggleMetalPerformanceHud();
}
return;
}
MetalHostInput.KeyDown(keyCode, isRepeat);
}
catch (Exception exception)
{
Console.Error.WriteLine($"[LOADER][WARN] Metal key-down handler failed: {exception.Message}");
}
}
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
private static void OnKeyUp(nint self, nint cmd, nint nsEvent)
{
try
{
var keyCode = (ushort)(MetalNative.Send(nsEvent, MetalNative.Selector("keyCode")) & 0xFFFF);
MetalHostInput.KeyUp(keyCode);
}
catch (Exception exception)
{
Console.Error.WriteLine($"[LOADER][WARN] Metal key-up handler failed: {exception.Message}");
}
}
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
private static byte AcceptsFirstResponder(nint self, nint cmd) => 1;
private const ushort KeyCodeF1 = 0x7A;
private const ulong NsEventModifierFlagCommand = 1UL << 20;
private static bool _metalHudVisible;
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
private static byte OnPerformKeyEquivalent(nint self, nint cmd, nint nsEvent)
{
try
{
var keyCode = (ushort)(MetalNative.Send(nsEvent, MetalNative.Selector("keyCode")) & 0xFFFF);
var modifiers = (ulong)MetalNative.Send(nsEvent, MetalNative.Selector("modifierFlags"));
if (keyCode == KeyCodeF1 && (modifiers & NsEventModifierFlagCommand) != 0)
{
if (!MetalNative.SendBool(nsEvent, MetalNative.Selector("isARepeat")))
{
ToggleMetalPerformanceHud();
}
return 1; // handled: no system beep, no further routing
}
}
catch (Exception exception)
{
Console.Error.WriteLine($"[LOADER][WARN] Metal key-equivalent handler failed: {exception.Message}");
}
return 0;
}
///
/// Cmd+F1: Apple's Metal Performance HUD on the CAMetalLayer (plain F1 keeps
/// the built-in CPU-rasterized perf overlay). Configured per Apple's
/// "Customizing Metal Performance HUD": developerHUDProperties takes
/// mode=default|disabled and logging=default, plus any MTL_HUD_* environment
/// keys directly in the dictionary — all three HUD flags (enabled, per-frame
/// logging, shader-compile logging) ride in one property set. Runs on the
/// AppKit main thread (the key-equivalent path), same thread as the render loop.
///
private static void ToggleMetalPerformanceHud()
{
var layer = _metalLayer;
if (layer == 0)
{
return;
}
var setProperties = MetalNative.Selector("setDeveloperHUDProperties:");
if (!MetalNative.SendBool(layer, MetalNative.Selector("respondsToSelector:"), setProperties))
{
Console.Error.WriteLine("[LOADER][WARN] Metal Performance HUD unavailable on this macOS.");
return;
}
_metalHudVisible = !_metalHudVisible;
var pool = MetalNative.objc_autoreleasePoolPush();
try
{
var properties = MetalNative.Send(
MetalNative.Class("NSMutableDictionary"), MetalNative.Selector("dictionary"));
var setObjectForKey = MetalNative.Selector("setObject:forKey:");
if (_metalHudVisible)
{
var defaultValue = MetalNative.NsString("default");
MetalNative.SendVoid(properties, setObjectForKey, defaultValue, MetalNative.NsString("mode"));
MetalNative.SendVoid(properties, setObjectForKey, defaultValue, MetalNative.NsString("logging"));
MetalNative.SendVoid(
properties,
setObjectForKey,
MetalNative.NsString("1"),
MetalNative.NsString("MTL_HUD_LOG_SHADER_ENABLED"));
}
else
{
MetalNative.SendVoid(
properties, setObjectForKey, MetalNative.NsString("disabled"), MetalNative.NsString("mode"));
}
MetalNative.SendVoid(layer, setProperties, properties);
}
finally
{
MetalNative.objc_autoreleasePoolPop(pool);
}
Console.Error.WriteLine(
$"[LOADER][INFO] Metal Performance HUD {(_metalHudVisible ? "shown" : "hidden")} (Cmd+F1).");
}
private static unsafe nint CreateRenderTimerTarget()
{
// A minimal NSObject subclass whose onFrame: is our unmanaged callback —
// the dependency-free way to hand a target/selector to NSTimer without a
// binding library. Registered once per process.
var cls = MetalNative.objc_allocateClassPair(
MetalNative.Class("NSObject"), "SharpEmuRenderTimerTarget", 0);
if (cls != 0)
{
var imp = (nint)(delegate* unmanaged[Cdecl])&OnRenderTimer;
// "v@:@": void return, self, _cmd, one object argument (the timer).
MetalNative.class_addMethod(cls, MetalNative.Selector("onFrame:"), imp, "v@:@");
var wakeImp = (nint)(delegate* unmanaged[Cdecl])&OnGuestWorkWake;
MetalNative.class_addMethod(cls, MetalNative.Selector("onGuestWork:"), wakeImp, "v@:@");
MetalNative.objc_registerClassPair(cls);
}
else
{
cls = MetalNative.Class("SharpEmuRenderTimerTarget");
}
return MetalNative.Send(
MetalNative.Send(cls, MetalNative.Selector("alloc")), MetalNative.Selector("init"));
}
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
private static void OnRenderTimer(nint self, nint cmd, nint timer)
{
try
{
RenderFrame();
}
catch (Exception exception)
{
Console.Error.WriteLine($"[LOADER][ERROR] Metal render frame failed: {exception}");
}
}
/// Set while an onGuestWork: wake is scheduled on the main run
/// loop; coalesces enqueue-side wake requests to one in-flight message.
private static int _guestWorkWakeScheduled;
/// Wakes the main run loop to drain guest work now instead of at
/// the next render tick. Guest submit→wait round-trips (release-mem labels,
/// CPU-visible write-backs) otherwise cost a full frame interval each —
/// games that chain several per frame crawl at a fraction of the display
/// rate. Safe from any thread; no-op until the presenter starts.
internal static void ScheduleGuestWorkDrain()
{
if (Interlocked.CompareExchange(ref _guestWorkWakeScheduled, 1, 0) != 0)
{
return;
}
var target = _renderTimerTarget;
if (target == 0)
{
Volatile.Write(ref _guestWorkWakeScheduled, 0);
return;
}
MetalNative.SendVoidPerformSelector(
target,
MetalNative.Selector("performSelectorOnMainThread:withObject:waitUntilDone:"),
MetalNative.Selector("onGuestWork:"),
0,
waitUntilDone: false);
}
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
private static void OnGuestWorkWake(nint self, nint cmd, nint argument)
{
Volatile.Write(ref _guestWorkWakeScheduled, 0);
if (_device == 0 || _commandQueue == 0 || Volatile.Read(ref _closeRequested))
{
return;
}
var pool = MetalNative.objc_autoreleasePoolPush();
try
{
DrainGuestWork(_device, _commandQueue);
}
catch (Exception exception)
{
Console.Error.WriteLine($"[LOADER][ERROR] Metal guest work wake failed: {exception}");
}
finally
{
MetalNative.objc_autoreleasePoolPop(pool);
}
}
private static void RenderFrame()
{
MetalHostInput.PumpAutoKeys();
var pool = MetalNative.objc_autoreleasePoolPush();
try
{
// NSApp.run dispatches window events itself, so there is no manual
// event drain here.
var visible = MetalNative.SendBool(_window, MetalNative.Selector("isVisible"));
if (Volatile.Read(ref _closeRequested) || !visible)
{
_userClosed = !visible && !Volatile.Read(ref _closeRequested);
MetalNative.SendVoid(_renderTimer, MetalNative.Selector("invalidate"));
// Stop both the AppKit loop and the underlying CFRunLoop so
// [NSApp run] returns.
MetalNative.SendVoid(_application, MetalNative.Selector("stop:"), 0);
MetalNative.CFRunLoopStop(MetalNative.CFRunLoopGetMain());
return;
}
DrainGuestWork(_device, _commandQueue);
if (TryTakePresentation(_presentedSequence, out var presentation))
{
_presentedSequence = presentation.Sequence;
if (presentation.Pixels is not null)
{
UploadFrame(
_device,
presentation,
ref _frameTexture,
ref _frameTextureWidth,
ref _frameTextureHeight);
SwitchPresentSource(
_frameTexture,
_frameTextureWidth,
_frameTextureHeight,
ownsTexture: false,
ref _presentTexture,
ref _presentTextureWidth,
ref _presentTextureHeight,
ref _ownedVersionTexture);
_presentGuestAddress = 0;
}
else if (presentation.TranslatedDraw is not null ||
presentation.DrawKind != GuestDrawKind.None)
{
var drawTarget = ExecutePresentationDraw(_device, _commandQueue, presentation);
if (drawTarget != 0)
{
// Transient targets are pooled by the presenter; the
// present source borrows them.
SwitchPresentSource(
drawTarget,
presentation.Width,
presentation.Height,
ownsTexture: false,
ref _presentTexture,
ref _presentTextureWidth,
ref _presentTextureHeight,
ref _ownedVersionTexture);
_presentGuestAddress = 0;
}
}
else if (TryResolveGuestPresentation(
_device,
presentation,
out var guestTexture,
out var guestWidth,
out var guestHeight,
out var ownsGuestTexture))
{
// Captured versions are immutable and owned here; mutable
// address-keyed images are re-resolved at encode time so a
// write swapping the texture never leaves a stale handle.
SwitchPresentSource(
ownsGuestTexture ? guestTexture : 0,
guestWidth,
guestHeight,
ownsGuestTexture,
ref _presentTexture,
ref _presentTextureWidth,
ref _presentTextureHeight,
ref _ownedVersionTexture);
_presentGuestAddress = ownsGuestTexture ? 0 : presentation.GuestImageAddress;
}
}
if (_presentGuestAddress != 0)
{
// Re-resolve every frame: a guest-image write swaps the texture
// behind the address.
_presentTexture = 0;
lock (_gate)
{
if (_guestImages.TryGetValue(_presentGuestAddress, out var borrowed) &&
borrowed.Initialized)
{
_presentTexture = borrowed.Texture;
_presentTextureWidth = borrowed.Width;
_presentTextureHeight = borrowed.Height;
}
}
}
// The window title reflects late guest state (the game registers its
// application name after boot) plus the GPU suffix; the Vulkan
// presenter re-reads it, so refresh periodically here for parity.
if ((++_titleRefreshCounter & 0x3F) == 0)
{
var title = VideoOutExports.GetWindowTitle();
if (!string.Equals(title, _lastWindowTitle, StringComparison.Ordinal))
{
_lastWindowTitle = title;
MetalNative.SendVoid(
_window, MetalNative.Selector("setTitle:"), MetalNative.NsString(title));
}
}
// The window is resizable, so the backing layer's bounds follow the
// window while its drawable size does not — match them before asking
// for a drawable, or nextDrawable keeps handing back the original
// resolution and Core Animation stretches it (blurry, mis-scaled
// overlay). No-op when the size is unchanged, i.e. almost every tick.
SyncDrawableSizeToLayer();
var drawable = MetalNative.Send(_metalLayer, MetalNative.Selector("nextDrawable"));
if (drawable == 0)
{
// No free drawable this tick; the next timer fire retries.
return;
}
if (_presentTexture != 0 && !_loggedFirstPresentedFrame)
{
_loggedFirstPresentedFrame = true;
Console.Error.WriteLine(
$"[LOADER][INFO] Metal VideoOut presenting {_presentTextureWidth}x{_presentTextureHeight}.");
}
var drawableTexture = MetalNative.Send(drawable, MetalNative.Selector("texture"));
var commandBuffer = MetalNative.Send(_commandQueue, MetalNative.Selector("commandBuffer"));
var pass = CreateClearPass(
drawableTexture,
new MtlClearColor { Red = 0, Green = 0, Blue = 0, Alpha = 1 });
var encoder = MetalNative.Send(
commandBuffer, MetalNative.Selector("renderCommandEncoderWithDescriptor:"), pass);
if (_presentTexture != 0)
{
EncodePresent(
encoder,
_presentPipeline,
_presentSampler,
_presentTexture,
_presentTextureWidth,
_presentTextureHeight,
_drawableWidth,
_drawableHeight);
}
if (PerfOverlay.Enabled)
{
EncodeOverlay(encoder);
}
MetalNative.SendVoid(encoder, MetalNative.Selector("endEncoding"));
MetalNative.SendVoid(commandBuffer, MetalNative.Selector("presentDrawable:"), drawable);
MetalNative.SendVoid(commandBuffer, MetalNative.Selector("commit"));
PerfOverlay.RecordPresent();
}
finally
{
MetalNative.objc_autoreleasePoolPop(pool);
}
}
/// Draws the CPU-rasterized perf panel over the frame's top-left
/// corner, reusing the present pipeline with a panel-sized viewport.
private static void EncodeOverlay(nint encoder)
{
if (_overlayTexture == 0)
{
var descriptor = MetalNative.SendTextureDescriptor(
MetalNative.Class("MTLTextureDescriptor"),
MetalNative.Selector("texture2DDescriptorWithPixelFormat:width:height:mipmapped:"),
PixelFormatBgra8Unorm,
PerfOverlay.PanelWidth,
PerfOverlay.PanelHeight,
mipmapped: false);
_overlayTexture = MetalNative.Send(
_device, MetalNative.Selector("newTextureWithDescriptor:"), descriptor);
if (_overlayTexture == 0)
{
return;
}
}
int pendingWork;
lock (_gate)
{
pendingWork = _pendingGuestWorkCount;
}
PerfOverlay.Fill(_overlayPixels, pendingWork, 0);
ReplaceTextureContents(
_overlayTexture,
PerfOverlay.PanelWidth,
PerfOverlay.PanelHeight,
_overlayPixels,
PerfOverlay.PanelWidth,
bytesPerPixel: 4);
const double margin = 16;
var panelWidth = Math.Min(PerfOverlay.PanelWidth, _drawableWidth - margin);
var panelHeight = Math.Min(PerfOverlay.PanelHeight, _drawableHeight - margin);
if (panelWidth <= 0 || panelHeight <= 0)
{
return;
}
MetalNative.SendVoid(encoder, MetalNative.Selector("setRenderPipelineState:"), _presentPipeline);
MetalNative.SendVoidViewport(
encoder,
MetalNative.Selector("setViewport:"),
new MtlViewport
{
OriginX = margin,
OriginY = margin,
Width = panelWidth,
Height = panelHeight,
ZNear = 0,
ZFar = 1,
});
MetalNative.SendSetAtIndex(
encoder, MetalNative.Selector("setFragmentTexture:atIndex:"), _overlayTexture, 0);
MetalNative.SendSetAtIndex(
encoder, MetalNative.Selector("setFragmentSamplerState:atIndex:"), _presentSampler, 0);
MetalNative.SendDrawPrimitives(
encoder,
MetalNative.Selector("drawPrimitives:vertexStart:vertexCount:"),
PrimitiveTypeTriangle,
0,
3);
}
private static nint CreateWindow(uint width, uint height)
{
var window = MetalNative.SendInitWindow(
MetalNative.Send(MetalNative.Class("NSWindow"), MetalNative.Selector("alloc")),
MetalNative.Selector("initWithContentRect:styleMask:backing:defer:"),
new CGRect { X = 0, Y = 0, Width = width, Height = height },
WindowStyleMask,
BackingStoreBuffered,
defer: false);
// The presenter owns the handle; AppKit must not free it on user close.
MetalNative.SendVoidBool(window, MetalNative.Selector("setReleasedWhenClosed:"), false);
MetalNative.Send(
window, MetalNative.Selector("setCollectionBehavior:"), (nint)FullScreenPrimaryBehavior);
MetalNative.SendVoid(
window,
MetalNative.Selector("setTitle:"),
MetalNative.NsString(VideoOutExports.GetWindowTitle()));
MetalNative.SendVoid(window, MetalNative.Selector("center"));
// makeKeyAndOrderFront happens after the metal layer is attached.
return window;
}
/// Keeps the CAMetalLayer's drawable size (pixels) matched to its
/// current bounds (points) × scale as the window resizes or moves between
/// displays. CAMetalLayer never updates drawableSize on its own, even as a
/// view's backing layer, so the render loop drives it.
private static void SyncDrawableSizeToLayer()
{
MetalNative.SendStretRect(out var bounds, _metalLayer, MetalNative.Selector("bounds"));
var scale = MetalNative.SendDouble(_metalLayer, MetalNative.Selector("contentsScale"));
if (scale <= 0)
{
scale = 1;
}
var width = Math.Max(1, Math.Round(bounds.Width * scale));
var height = Math.Max(1, Math.Round(bounds.Height * scale));
if (width == _drawableWidth && height == _drawableHeight)
{
return;
}
_drawableWidth = width;
_drawableHeight = height;
MetalNative.SendVoidSize(
_metalLayer,
MetalNative.Selector("setDrawableSize:"),
new CGSize { Width = width, Height = height });
}
private static nint CreateLayer(nint device, nint window, out double drawableWidth, out double drawableHeight)
{
var contentView = MetalNative.Send(window, MetalNative.Selector("contentView"));
var scale = MetalNative.SendDouble(window, MetalNative.Selector("backingScaleFactor"));
if (scale <= 0)
{
scale = 1;
}
const uint width = DefaultWindowWidth;
const uint height = DefaultWindowHeight;
drawableWidth = width * scale;
drawableHeight = height * scale;
var layer = MetalNative.Send(
MetalNative.Send(MetalNative.Class("CAMetalLayer"), MetalNative.Selector("alloc")),
MetalNative.Selector("init"));
MetalNative.SendVoid(layer, MetalNative.Selector("setDevice:"), device);
MetalNative.Send(layer, MetalNative.Selector("setPixelFormat:"), (nint)PixelFormatBgra8Unorm);
// A Core Animation layer composites with its alpha channel by default, so
// a presented frame whose guest alpha is zero would show through as the
// window background (black). The presenter output is a finished opaque
// frame; mark the layer opaque so alpha never reaches the compositor.
MetalNative.SendVoidBool(layer, MetalNative.Selector("setOpaque:"), true);
MetalNative.SendVoidBool(layer, MetalNative.Selector("setFramebufferOnly:"), true);
MetalNative.SendVoidDouble(layer, MetalNative.Selector("setContentsScale:"), scale);
MetalNative.SendVoidSize(
layer,
MetalNative.Selector("setDrawableSize:"),
new CGSize { Width = drawableWidth, Height = drawableHeight });
// A manually created layer defaults to a zero-size frame, and a hosted
// layer's geometry is the caller's job: without this the presenter
// happily presents every drawable into a layer with no on-screen
// extent — a permanently black window.
MetalNative.SendVoidRect(
layer,
MetalNative.Selector("setFrame:"),
new CGRect { X = 0, Y = 0, Width = width, Height = height });
// wantsLayer FIRST, then the layer: that makes the metal layer the
// view's AppKit-managed BACKING layer (geometry and window-server
// commits handled by AppKit) — the SDL/GLFW pattern. The reverse order
// creates a layer-hosting view whose tree the app must commit itself,
// which never composites under a manually pumped run loop.
MetalNative.SendVoidBool(contentView, MetalNative.Selector("setWantsLayer:"), true);
MetalNative.SendVoid(contentView, MetalNative.Selector("setLayer:"), layer);
MetalNative.SendVoid(MetalNative.Class("CATransaction"), MetalNative.Selector("flush"));
return layer;
}
private static bool TryCreatePresentPipeline(nint device, out nint pipeline, out string error)
{
pipeline = 0;
var dbg = Environment.GetEnvironmentVariable("SHARPEMU_METAL_DBG");
var fragmentSource = dbg switch
{
"solid" => MslFixedShaders.CreateSolidFragment(0f, 1f, 0f, 1f),
"uv" => MslFixedShaders.CreateAttributeFragment(0),
_ => MslFixedShaders.CreatePresentFragment(),
};
if (!TryCompileLibrary(device, MslFixedShaders.CreateFullscreenVertex(1), out var vertexLibrary, out error) ||
!TryCompileLibrary(device, fragmentSource, out var fragmentLibrary, out error))
{
return false;
}
var selNewFunction = MetalNative.Selector("newFunctionWithName:");
var fragmentEntry = dbg switch { "solid" => "solid_fs", "uv" => "attribute_fs", _ => "present_fs" };
var vertexFunction = MetalNative.Send(vertexLibrary, selNewFunction, MetalNative.NsString("fullscreen_vs"));
var fragmentFunction = MetalNative.Send(fragmentLibrary, selNewFunction, MetalNative.NsString(fragmentEntry));
if (vertexFunction == 0 || fragmentFunction == 0)
{
error = "present shader entry points missing from the compiled libraries";
return false;
}
var descriptor = MetalNative.Send(
MetalNative.Send(MetalNative.Class("MTLRenderPipelineDescriptor"), MetalNative.Selector("alloc")),
MetalNative.Selector("init"));
MetalNative.SendVoid(descriptor, MetalNative.Selector("setVertexFunction:"), vertexFunction);
MetalNative.SendVoid(descriptor, MetalNative.Selector("setFragmentFunction:"), fragmentFunction);
var colorAttachment = MetalNative.SendAtIndex(
MetalNative.Send(descriptor, MetalNative.Selector("colorAttachments")),
MetalNative.Selector("objectAtIndexedSubscript:"),
0);
MetalNative.Send(colorAttachment, MetalNative.Selector("setPixelFormat:"), (nint)PixelFormatBgra8Unorm);
nint nsError = 0;
pipeline = MetalNative.Send(
device,
MetalNative.Selector("newRenderPipelineStateWithDescriptor:error:"),
descriptor,
ref nsError);
if (pipeline == 0)
{
error = MetalNative.DescribeError(nsError);
return false;
}
return true;
}
private static bool TryCompileLibrary(nint device, string source, out nint library, out string error)
{
error = string.Empty;
// Fast-math off everywhere for parity with translated guest shaders,
// whose GCN float semantics do not survive it.
var options = MetalNative.Send(
MetalNative.Send(MetalNative.Class("MTLCompileOptions"), MetalNative.Selector("alloc")),
MetalNative.Selector("init"));
MetalNative.SendVoidBool(options, MetalNative.Selector("setFastMathEnabled:"), false);
nint nsError = 0;
library = MetalNative.Send(
device,
MetalNative.Selector("newLibraryWithSource:options:error:"),
MetalNative.NsString(source),
options,
ref nsError);
if (library == 0)
{
error = MetalNative.DescribeError(nsError);
return false;
}
return true;
}
private static nint CreateLinearSampler(nint device)
{
var descriptor = MetalNative.Send(
MetalNative.Send(MetalNative.Class("MTLSamplerDescriptor"), MetalNative.Selector("alloc")),
MetalNative.Selector("init"));
MetalNative.Send(descriptor, MetalNative.Selector("setMinFilter:"), (nint)SamplerMinMagFilterLinear);
MetalNative.Send(descriptor, MetalNative.Selector("setMagFilter:"), (nint)SamplerMinMagFilterLinear);
return MetalNative.Send(device, MetalNative.Selector("newSamplerStateWithDescriptor:"), descriptor);
}
private static void UploadFrame(
nint device,
Presentation presentation,
ref nint frameTexture,
ref uint textureWidth,
ref uint textureHeight)
{
if (frameTexture == 0 || textureWidth != presentation.Width || textureHeight != presentation.Height)
{
if (frameTexture != 0)
{
MetalNative.SendVoid(frameTexture, MetalNative.Selector("release"));
}
var descriptor = MetalNative.SendTextureDescriptor(
MetalNative.Class("MTLTextureDescriptor"),
MetalNative.Selector("texture2DDescriptorWithPixelFormat:width:height:mipmapped:"),
PixelFormatBgra8Unorm,
presentation.Width,
presentation.Height,
mipmapped: false);
// Shared (0): CPU-uploaded frame, GPU-sampled by the present pass;
// the Managed default reads stale on unified memory.
MetalNative.Send(descriptor, MetalNative.Selector("setStorageMode:"), (nint)0);
frameTexture = MetalNative.Send(device, MetalNative.Selector("newTextureWithDescriptor:"), descriptor);
textureWidth = presentation.Width;
textureHeight = presentation.Height;
}
ReplaceTextureContents(
frameTexture,
presentation.Width,
presentation.Height,
presentation.Pixels!,
presentation.Width,
bytesPerPixel: 4);
}
private static nint CreateClearPass(nint targetTexture, MtlClearColor clearColor)
{
var pass = MetalNative.Send(
MetalNative.Class("MTLRenderPassDescriptor"),
MetalNative.Selector("renderPassDescriptor"));
var colorAttachment = MetalNative.SendAtIndex(
MetalNative.Send(pass, MetalNative.Selector("colorAttachments")),
MetalNative.Selector("objectAtIndexedSubscript:"),
0);
MetalNative.SendVoid(colorAttachment, MetalNative.Selector("setTexture:"), targetTexture);
MetalNative.Send(colorAttachment, MetalNative.Selector("setLoadAction:"), (nint)LoadActionClear);
MetalNative.Send(colorAttachment, MetalNative.Selector("setStoreAction:"), (nint)StoreActionStore);
MetalNative.SendVoidClearColor(
colorAttachment,
MetalNative.Selector("setClearColor:"),
clearColor);
return pass;
}
private static void EncodePresent(
nint encoder,
nint pipeline,
nint sampler,
nint frameTexture,
uint frameWidth,
uint frameHeight,
double drawableWidth,
double drawableHeight)
{
MetalNative.SendVoid(encoder, MetalNative.Selector("setRenderPipelineState:"), pipeline);
// Aspect-fit letterbox: scale the frame into the drawable via the viewport.
var scale = Math.Min(drawableWidth / frameWidth, drawableHeight / frameHeight);
var viewportWidth = frameWidth * scale;
var viewportHeight = frameHeight * scale;
MetalNative.SendVoidViewport(
encoder,
MetalNative.Selector("setViewport:"),
new MtlViewport
{
OriginX = (drawableWidth - viewportWidth) * 0.5,
OriginY = (drawableHeight - viewportHeight) * 0.5,
Width = viewportWidth,
Height = viewportHeight,
ZNear = 0,
ZFar = 1,
});
MetalNative.SendSetAtIndex(
encoder, MetalNative.Selector("setFragmentTexture:atIndex:"), frameTexture, 0);
MetalNative.SendSetAtIndex(
encoder, MetalNative.Selector("setFragmentSamplerState:atIndex:"), sampler, 0);
MetalNative.SendDrawPrimitives(
encoder,
MetalNative.Selector("drawPrimitives:vertexStart:vertexCount:"),
PrimitiveTypeTriangle,
0,
3);
}
private static byte[] CreateBlackFrame(uint width, uint height)
{
if (width == 0 || height == 0 || width > 8192 || height > 8192)
{
width = 1;
height = 1;
}
var pixels = GC.AllocateUninitializedArray(checked((int)(width * height * 4)));
pixels.AsSpan().Clear();
for (var offset = 3; offset < pixels.Length; offset += 4)
{
pixels[offset] = 0xFF;
}
return pixels;
}
}