[GUI] Add native Vulkan host surface support and more (#337)

* [GUI] Add native Vulkan host surface support and more

* reuse

* [GUI] set width session menu
This commit is contained in:
Berk
2026-07-17 18:57:10 +03:00
committed by GitHub
parent aa25f6e978
commit fbafd3f429
25 changed files with 3661 additions and 585 deletions
@@ -100,8 +100,10 @@ internal readonly record struct VulkanGuestQueueIdentity(
internal static unsafe class VulkanVideoPresenter
{
private const uint DefaultWindowWidth = 1280;
private const uint DefaultWindowHeight = 720;
// Standalone CLI launches use a desktop-sized surface. The embedded GUI
// always takes its dimensions from the native child control instead.
private const uint DefaultWindowWidth = 1920;
private const uint DefaultWindowHeight = 1080;
// Vulkan's portable upper bound for minStorageBufferOffsetAlignment is
// 256 bytes. Using that fixed power of two (instead of racing the render
// thread's physical-device query) gives shader translation and descriptor
@@ -123,7 +125,16 @@ internal static unsafe class VulkanVideoPresenter
// of draws per frame to a fraction of the display rate. The pending cap
// stays tighter than the drain budget because queued draws pin their
// pooled guest-data arrays until the render thread uploads them.
private const int MaxPendingGuestWork = 64;
// The Cocoa event loop must stay responsive while guest work is pending,
// but Windows and Linux render on a dedicated host thread. Keeping the
// macOS item limit everywhere throttles draw-heavy games well below their
// display rate before the byte budget is remotely close to full.
private static readonly int _maxPendingGuestWorkItems =
int.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_PENDING_GUEST_WORK_ITEMS"),
out var pendingGuestWorkItems) && pendingGuestWorkItems > 0
? pendingGuestWorkItems
: OperatingSystem.IsMacOS() ? 64 : 512;
private const ulong MaximumCachedHostBufferBytes = 128UL * 1024 * 1024;
// A captured 4K flip can consume tens of MiB of device-local memory.
// Retain only a short presentation queue while always preserving the
@@ -140,30 +151,40 @@ internal static unsafe class VulkanVideoPresenter
out var pendingGuestWorkMb) && pendingGuestWorkMb > 0
? pendingGuestWorkMb
: 256UL) * 1024UL * 1024UL;
private const int MaxGuestWorkPerRender = 256;
private static readonly int _maxGuestWorkPerRender =
int.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_MAX_GUEST_WORK_PER_RENDER"),
out var guestWorkPerRender) && guestWorkPerRender > 0
? guestWorkPerRender
: OperatingSystem.IsMacOS() ? 256 : 1024;
// On macOS the whole window loop — including Render() and its guest-work
// drain — runs on the process main thread, so draining a large backlog of
// slow guest work (heavy compute) blocks the Cocoa event pump and marks the
// window "Not Responding" while starving the swapchain present. Cap the
// wall-clock time spent draining per Render() call; leftover work stays
// queued for the next frame. SHARPEMU_RENDER_WORK_BUDGET_MS overrides
// (0 disables the cap); default 12ms keeps the window interactive at ~60Hz.
// (0 disables the cap); default 12ms keeps the macOS window interactive at
// ~60Hz. Windows and Linux use a dedicated render thread, so they drain
// without a time budget by default.
private static readonly long _renderWorkBudgetTicks =
(long.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_RENDER_WORK_BUDGET_MS"),
out var renderBudgetMs) && renderBudgetMs >= 0
? renderBudgetMs
: 12L) * System.Diagnostics.Stopwatch.Frequency / 1000L;
: OperatingSystem.IsMacOS() ? 12L : 0L) *
System.Diagnostics.Stopwatch.Frequency / 1000L;
// Max time the main-thread Render() will block waiting for a frame slot's
// GPU fence before skipping the frame and returning to the event pump.
// Prevents the window freezing behind a slow-compute GPU backlog.
// SHARPEMU_FRAME_WAIT_BUDGET_MS overrides; default 8ms.
// SHARPEMU_FRAME_WAIT_BUDGET_MS overrides; default 8ms on macOS. The
// dedicated Windows/Linux render thread may wait for its frame slot so it
// does not drop guest-work drain opportunities under normal GPU load.
private static readonly ulong _frameSlotWaitBudgetNs =
(ulong.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_FRAME_WAIT_BUDGET_MS"),
out var frameWaitMs) && frameWaitMs > 0
? frameWaitMs
: 8UL) * 1_000_000UL;
ulong.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_FRAME_WAIT_BUDGET_MS"),
out var frameWaitMs) && frameWaitMs > 0
? frameWaitMs * 1_000_000UL
: OperatingSystem.IsMacOS() ? 8_000_000UL : ulong.MaxValue;
// Cap the guest-submission fence wait so a GPU submission whose fence never
// signals (a mistranslated compute shader that hangs the Metal queue) cannot
// freeze the render thread forever and starve the swapchain present.
@@ -263,6 +284,9 @@ internal static unsafe class VulkanVideoPresenter
private static readonly HashSet<(ulong Address, uint Width, uint Height)>
_tracedGuestImageSubmissions = [];
private static Thread? _thread;
private static VulkanHostSurface? _hostSurface;
private static VulkanHostSurface? _hostSurfacePendingDetach;
internal static event Action<VulkanHostSurface>? FirstHostFramePresented;
private static Presentation? _latestPresentation;
private static byte[]? _copyFragmentSpirv;
private static uint _windowWidth;
@@ -463,10 +487,124 @@ internal static unsafe class VulkanVideoPresenter
TranslatedDraw: null,
RequiredGuestWorkSequence: 0,
IsSplash: false);
if (_hostSurface is not null && _latestPresentation is { IsSplash: true })
{
// The GUI keeps its native child hidden while the launcher is
// loading. Reveal it for the real VideoOut splash rather than
// substituting a launcher-side image.
Console.Error.WriteLine("[VIDEOOUT][INFO] Hosted splash ready.");
}
StartPresenterLocked();
}
}
/// <summary>
/// Selects a same-process native surface for the next VideoOut session.
/// This is intentionally rejected once the presenter has started: Vulkan
/// surface ownership cannot change under an active swapchain.
/// </summary>
public static bool TryAttachHostSurface(VulkanHostSurface surface)
{
ArgumentNullException.ThrowIfNull(surface);
lock (_gate)
{
if (_thread is not null)
{
return false;
}
ResetHostSessionStateLocked();
_hostSurface = surface;
_closed = false;
_presenterCloseRequested = false;
return true;
}
}
/// <summary>
/// Releases the host surface after the guest session has stopped.
/// </summary>
public static void DetachHostSurface(VulkanHostSurface surface)
{
ArgumentNullException.ThrowIfNull(surface);
lock (_gate)
{
if (!ReferenceEquals(_hostSurface, surface))
{
return;
}
if (_thread is null)
{
_hostSurface = null;
}
else
{
_hostSurfacePendingDetach = surface;
}
}
}
internal static bool UsesHostSurface
{
get
{
lock (_gate)
{
return _hostSurface is not null;
}
}
}
private static void NotifyFirstHostFramePresented(VulkanHostSurface surface)
{
// This marker crosses the GUI child-process pipe. The in-process
// event remains for hosts that embed the renderer directly.
Console.Error.WriteLine("[VIDEOOUT][INFO] Hosted first frame presented.");
var callback = FirstHostFramePresented;
if (callback is null)
{
return;
}
try
{
callback(surface);
}
catch (Exception exception)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Embedded first-frame notification failed: {exception.Message}");
}
}
private static void ResetHostSessionStateLocked()
{
_latestPresentation = null;
_splashHidden = false;
_pendingGuestWorkByQueue.Clear();
_pendingGuestQueueSchedule.Clear();
_pendingGuestQueueCursor = 0;
_pendingGuestWorkCount = 0;
_pendingGuestWorkBytes = 0;
_pendingGuestImagePresentations.Clear();
_guestImageWorkSequences.Clear();
_availableGuestImages.Clear();
_lastOrderedGuestFlipVersions.Clear();
_orderedGuestFlipVersionSequence = 0;
_pendingGuestImageUploads.Clear();
_pendingGuestImageInitialData.Clear();
_guestImageExtents.Clear();
_enqueuedGuestWorkSequence = 0;
_completedGuestWorkSequence = 0;
_completedGuestWorkOutOfOrder.Clear();
_lastEnqueuedGuestWorkByQueue.Clear();
_executingGuestWorkSequence = 0;
_hostSurfacePendingDetach = null;
}
public static void HideSplashScreen()
{
lock (_gate)
@@ -1192,7 +1330,7 @@ internal static unsafe class VulkanVideoPresenter
GuestImageAddress: address);
_latestPresentation = presentation;
_pendingGuestImagePresentations.Enqueue(presentation);
while (_pendingGuestImagePresentations.Count > MaxPendingGuestWork)
while (_pendingGuestImagePresentations.Count > MaxPendingGuestFlipVersions)
{
_pendingGuestImagePresentations.Dequeue();
}
@@ -1588,7 +1726,7 @@ internal static unsafe class VulkanVideoPresenter
private static void StartPresenterLocked()
{
if (HostMainThread.IsAvailable)
if (_hostSurface is null && HostMainThread.IsAvailable)
{
// AppKit (and therefore GLFW) traps when touched off the process
// main thread on macOS, so hand the whole window loop to the
@@ -1756,18 +1894,23 @@ internal static unsafe class VulkanVideoPresenter
{
uint width;
uint height;
VulkanHostSurface? hostSurface;
lock (_gate)
{
width = _windowWidth == 0 ? _latestPresentation?.Width ?? 1280 : _windowWidth;
height = _windowHeight == 0 ? _latestPresentation?.Height ?? 720 : _windowHeight;
hostSurface = _hostSurface;
}
PreferX11OnLinuxWayland();
InitializeMacVulkanLoader();
if (hostSurface is null)
{
PreferX11OnLinuxWayland();
InitializeMacVulkanLoader();
}
try
{
using var presenter = new Presenter(width, height);
using var presenter = new Presenter(width, height, hostSurface);
presenter.Run();
}
catch (Exception exception)
@@ -1780,6 +1923,12 @@ internal static unsafe class VulkanVideoPresenter
{
_closed = true;
_thread = null;
if (_hostSurfacePendingDetach is not null &&
ReferenceEquals(_hostSurface, _hostSurfacePendingDetach))
{
_hostSurface = null;
_hostSurfacePendingDetach = null;
}
System.Threading.Monitor.PulseAll(_gate);
}
}
@@ -1885,7 +2034,7 @@ internal static unsafe class VulkanVideoPresenter
while (!_enqueueAsImmediateQueueFollowup &&
!_closed &&
_thread is not null &&
(_pendingGuestWorkCount >= MaxPendingGuestWork ||
(_pendingGuestWorkCount >= _maxPendingGuestWorkItems ||
// Always admit one item when no payload is outstanding, even
// when that single item exceeds the configured budget. This
// avoids an impossible wait while still bounding the normal
@@ -1965,6 +2114,9 @@ internal static unsafe class VulkanVideoPresenter
RecordGuestImageWritersLocked(work, sequence);
_pendingGuestWorkCount++;
_pendingGuestWorkBytes = SaturatingAdd(_pendingGuestWorkBytes, payloadBytes);
// Wake the embedded render loop parked in WaitForRenderWork; without a
// pulse it only notices new work when its timed wait expires.
System.Threading.Monitor.PulseAll(_gate);
return sequence;
}
@@ -2287,7 +2439,10 @@ internal static unsafe class VulkanVideoPresenter
private const string FullscreenBarycentricFragmentSpirv =
"AwIjBwAAAQALAAgAEgAAAAAAAAARAAIAAQAAAAsABgABAAAAR0xTTC5zdGQuNDUwAAAAAA4AAwAAAAAAAQAAAA8ABwAEAAAABAAAAG1haW4AAAAACQAAAAwAAAAQAAMABAAAAAcAAAADAAMAAgAAAMIBAAAFAAQABAAAAG1haW4AAAAABQAFAAkAAABvdXRDb2xvcgAAAAAFAAUADAAAAGJhcnljZW50cmljAEcABAAJAAAAHgAAAAAAAABHAAQADAAAAB4AAAAAAAAAEwACAAIAAAAhAAMAAwAAAAIAAAAWAAMABgAAACAAAAAXAAQABwAAAAYAAAAEAAAAIAAEAAgAAAADAAAABwAAADsABAAIAAAACQAAAAMAAAAXAAQACgAAAAYAAAACAAAAIAAEAAsAAAABAAAACgAAADsABAALAAAADAAAAAEAAAArAAQABgAAAA4AAAAAAAAANgAFAAIAAAAEAAAAAAAAAAMAAAD4AAIABQAAAD0ABAAKAAAADQAAAAwAAABRAAUABgAAAA8AAAANAAAAAAAAAFEABQAGAAAAEAAAAA0AAAABAAAAUAAHAAcAAAARAAAADwAAABAAAAAOAAAADgAAAD4AAwAJAAAAEQAAAP0AAQA4AAEA";
private readonly IWindow _window;
private readonly IWindow? _window;
private readonly VulkanHostSurface? _hostSurface;
private int _lastHostResizeGeneration;
private bool _embeddedLoopClosed;
private const int MaxInFlightGuestSubmissions = 8;
private Vk _vk = null!;
private KhrSurface _surfaceApi = null!;
@@ -2380,8 +2535,11 @@ internal static unsafe class VulkanVideoPresenter
private long _presentNotTakenLoggedSequence = long.MinValue;
private bool _vulkanReady;
private bool _firstFramePresented;
private bool _firstHostFramePresented;
private bool _firstGuestDrawPresented;
private bool _splashPresented;
private Presentation? _lastHostSplashPresentation;
private Presentation? _pendingHostSplashReplay;
private bool _swapchainRecreateDeferred;
private bool _tracedPresentedSwapchain;
private bool _swapchainReadbackPending;
@@ -2668,11 +2826,22 @@ internal static unsafe class VulkanVideoPresenter
VulkanGuestQueueIdentity Queue,
long WorkSequence);
public Presenter(uint width, uint height)
public Presenter(uint width, uint height, VulkanHostSurface? hostSurface)
{
_hostSurface = hostSurface;
_hostBufferPool = new VulkanHostBufferPool(
MaximumCachedHostBufferBytes,
DestroyHostBufferAllocation);
if (_hostSurface is not null)
{
_hostSurface.UpdatePixelSize(
_hostSurface.PixelWidth > 0 ? _hostSurface.PixelWidth : (int)width,
_hostSurface.PixelHeight > 0 ? _hostSurface.PixelHeight : (int)height);
_lastHostResizeGeneration = _hostSurface.ResizeGeneration;
return;
}
var options = WindowOptions.DefaultVulkan;
options.Size = new Vector2D<int>((int)DefaultWindowWidth, (int)DefaultWindowHeight);
options.Title = VideoOutExports.GetWindowTitle();
@@ -2694,14 +2863,32 @@ internal static unsafe class VulkanVideoPresenter
};
}
public void Run() => _window.Run();
public void Run()
{
if (_window is not null)
{
_window.Run();
return;
}
Initialize();
while (!_embeddedLoopClosed)
{
Render(0);
// Guest draws and flips execute on this thread. An unconditional
// Thread.Sleep here is quantized to the Windows timer period
// (~15.6ms) and throttles every hosted game far below the GLFW
// path. Wait only while there is genuinely no render work.
WaitForRenderWork();
}
}
public void Dispose()
{
DisposeVulkan();
try
{
_window.Dispose();
_window?.Dispose();
}
catch (InvalidOperationException exception)
when (exception.Message.Contains("render loop", StringComparison.OrdinalIgnoreCase))
@@ -2713,24 +2900,27 @@ internal static unsafe class VulkanVideoPresenter
private void Initialize()
{
LogGlfwPlatformInUse();
if (!OperatingSystem.IsWindows())
if (_window is not null)
{
try
LogGlfwPlatformInUse();
if (!OperatingSystem.IsWindows())
{
Pad.HostWindowInput.Attach(_window.CreateInput());
Console.Error.WriteLine("[LOADER][INFO] Window keyboard input attached for pad emulation.");
try
{
Pad.HostWindowInput.Attach(_window.CreateInput());
Console.Error.WriteLine("[LOADER][INFO] Window keyboard input attached for pad emulation.");
}
catch (Exception exception)
{
Console.Error.WriteLine($"[LOADER][WARN] Window keyboard input unavailable: {exception.Message}");
}
}
catch (Exception exception)
{
Console.Error.WriteLine($"[LOADER][WARN] Window keyboard input unavailable: {exception.Message}");
}
}
if (PngSplashLoader.TryLoadIcon(out var iconPixels, out var iconWidth, out var iconHeight))
{
var icon = new RawImage((int)iconWidth, (int)iconHeight, iconPixels);
_window.SetWindowIcon(ref icon);
if (PngSplashLoader.TryLoadIcon(out var iconPixels, out var iconWidth, out var iconHeight))
{
var icon = new RawImage((int)iconWidth, (int)iconHeight, iconPixels);
_window.SetWindowIcon(ref icon);
}
}
WaitForRenderDocAttachIfRequested();
@@ -2986,15 +3176,45 @@ internal static unsafe class VulkanVideoPresenter
ApiVersion = Vk.Version12,
};
var extensions = _window.VkSurface!.GetRequiredExtensions(out var extensionCount);
var hostExtensionNames = _hostSurface is null
? null
: GetHostSurfaceExtensions(_hostSurface.Kind);
byte** extensions;
uint extensionCount;
if (hostExtensionNames is not null)
{
extensions = null;
extensionCount = (uint)hostExtensionNames.Length;
}
else if (_window?.VkSurface is { } glfwSurface)
{
extensions = glfwSurface.GetRequiredExtensions(out extensionCount);
}
else
{
throw new InvalidOperationException("GLFW did not provide Vulkan surface extensions.");
}
byte* debugUtilsExtension = null;
byte* portabilityExtension = null;
var instanceCreateFlags = InstanceCreateFlags.None;
var enabledExtensionCount = (int)extensionCount;
var enabledExtensions = stackalloc byte*[(int)extensionCount + 2];
var allocatedHostExtensions = hostExtensionNames is null
? null
: new nint[hostExtensionNames.Length];
for (var index = 0; index < (int)extensionCount; index++)
{
enabledExtensions[index] = extensions[index];
if (hostExtensionNames is null)
{
enabledExtensions[index] = extensions[index];
}
else
{
var extension = (byte*)SilkMarshal.StringToPtr(hostExtensionNames[index]);
allocatedHostExtensions![index] = (nint)extension;
enabledExtensions[index] = extension;
}
}
if (_vulkanDebugUtilsEnabled &&
@@ -3065,6 +3285,13 @@ internal static unsafe class VulkanVideoPresenter
{
SilkMarshal.Free((nint)portabilityExtension);
}
if (allocatedHostExtensions is not null)
{
foreach (var extension in allocatedHostExtensions)
{
SilkMarshal.Free(extension);
}
}
}
}
finally
@@ -3174,11 +3401,99 @@ internal static unsafe class VulkanVideoPresenter
}
private void CreateSurface()
{
if (_hostSurface is not null)
{
CreateHostSurface(_hostSurface);
return;
}
var instanceHandle = new VkHandle(_instance.Handle);
var surfaceHandle = _window.VkSurface!.Create<AllocationCallbacks>(instanceHandle, null);
var surfaceHandle = _window!.VkSurface!.Create<AllocationCallbacks>(instanceHandle, null);
_surface = new SurfaceKHR(surfaceHandle.Handle);
}
private static string[] GetHostSurfaceExtensions(VulkanHostSurfaceKind kind) => kind switch
{
VulkanHostSurfaceKind.Win32 => ["VK_KHR_surface", "VK_KHR_win32_surface"],
VulkanHostSurfaceKind.Xlib => ["VK_KHR_surface", "VK_KHR_xlib_surface"],
VulkanHostSurfaceKind.Metal => ["VK_KHR_surface", "VK_EXT_metal_surface"],
_ => throw new PlatformNotSupportedException($"Unsupported Vulkan host surface: {kind}."),
};
private void CreateHostSurface(VulkanHostSurface hostSurface)
{
switch (hostSurface.Kind)
{
case VulkanHostSurfaceKind.Win32:
CreateWin32HostSurface(hostSurface);
return;
case VulkanHostSurfaceKind.Xlib:
CreateXlibHostSurface(hostSurface);
return;
case VulkanHostSurfaceKind.Metal:
CreateMetalHostSurface(hostSurface);
return;
default:
throw new PlatformNotSupportedException($"Unsupported Vulkan host surface: {hostSurface.Kind}.");
}
}
private void CreateWin32HostSurface(VulkanHostSurface hostSurface)
{
if (!_vk.TryGetInstanceExtension(_instance, out KhrWin32Surface win32Surface))
{
throw new InvalidOperationException("VK_KHR_win32_surface is unavailable.");
}
var createInfo = new Win32SurfaceCreateInfoKHR
{
SType = StructureType.Win32SurfaceCreateInfoKhr,
Hinstance = hostSurface.DisplayHandle != 0
? hostSurface.DisplayHandle
: GetModuleHandleW(null),
Hwnd = hostSurface.WindowHandle,
};
Check(win32Surface.CreateWin32Surface(_instance, &createInfo, null, out _surface), "vkCreateWin32SurfaceKHR");
}
private void CreateXlibHostSurface(VulkanHostSurface hostSurface)
{
if (!_vk.TryGetInstanceExtension(_instance, out KhrXlibSurface xlibSurface))
{
throw new InvalidOperationException("VK_KHR_xlib_surface is unavailable.");
}
var createInfo = new XlibSurfaceCreateInfoKHR
{
SType = StructureType.XlibSurfaceCreateInfoKhr,
Dpy = (nint*)hostSurface.DisplayHandle,
Window = hostSurface.WindowHandle,
};
Check(xlibSurface.CreateXlibSurface(_instance, &createInfo, null, out _surface), "vkCreateXlibSurfaceKHR");
}
private void CreateMetalHostSurface(VulkanHostSurface hostSurface)
{
var proc = _vk.GetInstanceProcAddr(_instance, "vkCreateMetalSurfaceEXT");
if (proc == 0)
{
throw new InvalidOperationException("VK_EXT_metal_surface is unavailable.");
}
var createInfo = new MetalSurfaceCreateInfoEXT
{
SType = StructureType.MetalSurfaceCreateInfoExt,
PLayer = (nint*)hostSurface.MetalLayerHandle,
};
var createSurface = (delegate* unmanaged<Instance, MetalSurfaceCreateInfoEXT*, AllocationCallbacks*, SurfaceKHR*, Result>)proc.Handle;
SurfaceKHR surface;
Check(createSurface(_instance, &createInfo, null, &surface), "vkCreateMetalSurfaceEXT");
_surface = surface;
}
[DllImport("kernel32.dll", EntryPoint = "GetModuleHandleW", CharSet = CharSet.Unicode)]
private static extern nint GetModuleHandleW(string? moduleName);
private void SelectPhysicalDevice()
{
uint deviceCount = 0;
@@ -3245,7 +3560,10 @@ internal static unsafe class VulkanVideoPresenter
Console.Error.WriteLine(
$"[LOADER][INFO] Vulkan device: {selectedName} ({selected.DeviceType})");
VideoOutExports.SetSelectedGpuName(selectedName);
_window.Title = VideoOutExports.GetWindowTitle();
if (_window is not null)
{
_window.Title = VideoOutExports.GetWindowTitle();
}
}
private void LoadComputeDeviceLimits()
@@ -7674,15 +7992,19 @@ internal static unsafe class VulkanVideoPresenter
WaitForActiveGuestQueueSubmissionsForCpuVisibility();
WriteBackAllDirtyGuestBuffers(_activeGuestQueue.Name);
}
var mapped = new Span<byte>(
(void*)(allocation.Mapped + checked((nint)guestOffset)),
guestBuffer.Length);
if (_guestMemory?.TryRead(guestBuffer.BaseAddress, mapped) != true)
// Populate the cached shadow copy first and write it out to the
// mapped allocation in one pass. The mapped memory is
// HOST_VISIBLE|HOST_COHERENT (write-combined on most drivers),
// so CPU reads from it are uncached and orders of magnitude
// slower than heap reads — never use it as a copy source.
if (_guestMemory?.TryRead(guestBuffer.BaseAddress, shadow) != true)
{
source.CopyTo(mapped);
source.CopyTo(shadow);
}
mapped.CopyTo(shadow);
shadow.CopyTo(new Span<byte>(
(void*)(allocation.Mapped + checked((nint)guestOffset)),
guestBuffer.Length));
}
if (ShouldTraceVulkanResources() &&
@@ -11219,6 +11541,30 @@ internal static unsafe class VulkanVideoPresenter
}
}
private bool _overlayHotkeyWasDown;
private void PollPerfOverlayHotkey()
{
// POSIX hosts route F1 through the GLFW window's keyboard events
// (HostWindowInput.Attach). Windows never attaches that path — its
// input comes from user32 polling — so sample the hotkey here for
// both the standalone window and the embedded host surface.
if (!OperatingSystem.IsWindows())
{
return;
}
const int VkF1 = 0x70;
var input = SharpEmu.HLE.Host.HostPlatform.Current.Input;
var down = input.IsKeyDown(VkF1) && input.IsHostWindowFocused();
if (down && !_overlayHotkeyWasDown)
{
PerfOverlay.Toggle();
}
_overlayHotkeyWasDown = down;
}
private void Render(double _)
{
try
@@ -11241,10 +11587,30 @@ internal static unsafe class VulkanVideoPresenter
if (Volatile.Read(ref _presenterCloseRequested))
{
Console.Error.WriteLine("[LOADER][WARN] Vulkan VideoOut closing on host shutdown request.");
_window.Close();
if (_window is not null)
{
_window.Close();
}
else
{
_embeddedLoopClosed = true;
}
return;
}
PollPerfOverlayHotkey();
if (_hostSurface is not null)
{
_hostSurface.RefreshChildProcessPixelSize();
if (_lastHostResizeGeneration != _hostSurface.ResizeGeneration)
{
_lastHostResizeGeneration = _hostSurface.ResizeGeneration;
RecreateSwapchainResources("embedded host resize", Result.SuboptimalKhr);
_pendingHostSplashReplay = _lastHostSplashPresentation;
}
}
if (!_vulkanReady)
{
return;
@@ -11287,7 +11653,7 @@ internal static unsafe class VulkanVideoPresenter
var renderWorkDeadline = _renderWorkBudgetTicks > 0
? System.Diagnostics.Stopwatch.GetTimestamp() + _renderWorkBudgetTicks
: long.MaxValue;
while (completedWork < MaxGuestWorkPerRender)
while (completedWork < _maxGuestWorkPerRender)
{
// Never block the macOS main thread waiting for in-flight GPU
// work to drain. If submission is at capacity (a slow-compute
@@ -11295,7 +11661,8 @@ internal static unsafe class VulkanVideoPresenter
// remaining queued work is picked up on later frames as the GPU
// completions free up capacity (collected non-blockingly here).
CollectCompletedGuestSubmissions(waitForOldest: false);
if (_pendingGuestSubmissions.Count >= MaxInFlightGuestSubmissions)
if (OperatingSystem.IsMacOS() &&
_pendingGuestSubmissions.Count >= MaxInFlightGuestSubmissions)
{
break;
}
@@ -11412,6 +11779,13 @@ internal static unsafe class VulkanVideoPresenter
if (!TryTakePresentation(_presentedSequence, out var presentation))
{
if (_pendingHostSplashReplay is { } splash)
{
presentation = splash;
_pendingHostSplashReplay = null;
}
else
{
// A render-loop tick with no newer flip is normal. Warn only when
// an actual queued presentation is waiting on unfinished guest work.
if (ShouldTracePresentedGuestImageContentsForDiagnostics() &&
@@ -11425,6 +11799,20 @@ internal static unsafe class VulkanVideoPresenter
}
return;
}
}
if (_hostSurface is not null)
{
if (presentation.IsSplash && presentation.Pixels is not null)
{
_lastHostSplashPresentation = presentation;
}
else
{
_lastHostSplashPresentation = null;
_pendingHostSplashReplay = null;
}
}
if (ShouldTracePresentedGuestImageContentsForDiagnostics())
@@ -11449,6 +11837,13 @@ internal static unsafe class VulkanVideoPresenter
{
pixels = presentation.Width == _extent.Width && presentation.Height == _extent.Height
? sourcePixels
: _hostSurface is not null
? ScaleBgraCoverBilinear(
sourcePixels,
presentation.Width,
presentation.Height,
_extent.Width,
_extent.Height)
: ScaleBgra(
sourcePixels,
presentation.Width,
@@ -11715,6 +12110,11 @@ internal static unsafe class VulkanVideoPresenter
recreateAfterPresent |= presentResult == Result.SuboptimalKhr;
VideoOutExports.ReportPresentedFrame();
PerfOverlay.RecordPresent();
if (_hostSurface is not null && !_firstHostFramePresented)
{
_firstHostFramePresented = true;
NotifyFirstHostFramePresented(_hostSurface);
}
if (_swapchainReadbackPending || !_pendingAliasImageDumps.IsEmpty)
{
// Diagnostics read back GPU memory and need this frame done.
@@ -13551,12 +13951,35 @@ internal static unsafe class VulkanVideoPresenter
2,
barriers);
var sourceX = 0u;
var sourceY = 0u;
var sourceWidth = source.Width;
var sourceHeight = source.Height;
if (_hostSurface is not null)
{
// The embedded GUI fills its game surface like CSS
// object-fit: cover. Preserve the guest aspect ratio and crop
// only the excess instead of distorting every video frame.
var sourceIsWider = (ulong)sourceWidth * _extent.Height >
(ulong)_extent.Width * sourceHeight;
if (sourceIsWider)
{
sourceWidth = Math.Max(1u, (uint)((ulong)sourceHeight * _extent.Width / _extent.Height));
sourceX = (source.Width - sourceWidth) / 2;
}
else
{
sourceHeight = Math.Max(1u, (uint)((ulong)sourceWidth * _extent.Height / _extent.Width));
sourceY = (source.Height - sourceHeight) / 2;
}
}
var sourceOffsets = new ImageBlit.SrcOffsetsBuffer
{
Element0 = new Offset3D(0, 0, 0),
Element0 = new Offset3D(checked((int)sourceX), checked((int)sourceY), 0),
Element1 = new Offset3D(
checked((int)source.Width),
checked((int)source.Height),
checked((int)(sourceX + sourceWidth)),
checked((int)(sourceY + sourceHeight)),
1),
};
var destinationOffsets = new ImageBlit.DstOffsetsBuffer
@@ -13587,9 +14010,9 @@ internal static unsafe class VulkanVideoPresenter
// must blend neighbours or it silently drops every Nth source
// row/column, which shreds 1-2px features in the guest frame.
var isIntegerUpscale =
source.Width != 0 && source.Height != 0 &&
_extent.Width >= source.Width && _extent.Height >= source.Height &&
_extent.Width % source.Width == 0 && _extent.Height % source.Height == 0;
sourceWidth != 0 && sourceHeight != 0 &&
_extent.Width >= sourceWidth && _extent.Height >= sourceHeight &&
_extent.Width % sourceWidth == 0 && _extent.Height % sourceHeight == 0;
_vk.CmdBlitImage(
_commandBuffer,
source.Image,
@@ -13786,7 +14209,7 @@ internal static unsafe class VulkanVideoPresenter
capabilities.MaxImageExtent.Height));
}
var size = _window.FramebufferSize;
var size = GetFramebufferSize();
return new Extent2D(
ClampSurfaceExtent(
(uint)Math.Max(size.X, 1),
@@ -13800,6 +14223,21 @@ internal static unsafe class VulkanVideoPresenter
capabilities.MaxImageExtent.Height));
}
private Vector2D<int> GetFramebufferSize()
{
if (_window is not null)
{
return _window.FramebufferSize;
}
if (_hostSurface is not null)
{
return new Vector2D<int>(_hostSurface.PixelWidth, _hostSurface.PixelHeight);
}
return new Vector2D<int>((int)DefaultWindowWidth, (int)DefaultWindowHeight);
}
private static uint ClampSurfaceExtent(
uint value,
uint fallback,
@@ -13876,6 +14314,88 @@ internal static unsafe class VulkanVideoPresenter
return destination;
}
private static byte[] ScaleBgraCoverBilinear(
byte[] source,
uint sourceWidth,
uint sourceHeight,
uint width,
uint height)
{
var destination = new byte[checked((int)(width * height * 4))];
var sourceIsWider = (ulong)sourceWidth * height > (ulong)width * sourceHeight;
var cropWidth = sourceIsWider
? Math.Max(1u, (uint)((ulong)sourceHeight * width / height))
: sourceWidth;
var cropHeight = sourceIsWider
? sourceHeight
: Math.Max(1u, (uint)((ulong)sourceWidth * height / width));
var offsetX = (sourceWidth - cropWidth) / 2;
var offsetY = (sourceHeight - cropHeight) / 2;
var maxSourceX = offsetX + cropWidth - 1;
var maxSourceY = offsetY + cropHeight - 1;
for (uint y = 0; y < height; y++)
{
for (uint x = 0; x < width; x++)
{
var destinationOffset = checked((int)(((ulong)y * width + x) * 4));
float blue = 0;
float green = 0;
float red = 0;
float alpha = 0;
for (var sampleY = 0; sampleY < 2; sampleY++)
{
var scaledY = offsetY + (((y + ((sampleY + 0.5f) / 2)) * cropHeight) / height) - 0.5f;
var sourceY0 = (uint)Math.Clamp((int)MathF.Floor(scaledY), (int)offsetY, (int)maxSourceY);
var sourceY1 = Math.Min(sourceY0 + 1, maxSourceY);
var fractionY = scaledY - MathF.Floor(scaledY);
for (var sampleX = 0; sampleX < 2; sampleX++)
{
var scaledX = offsetX + (((x + ((sampleX + 0.5f) / 2)) * cropWidth) / width) - 0.5f;
var sourceX0 = (uint)Math.Clamp((int)MathF.Floor(scaledX), (int)offsetX, (int)maxSourceX);
var sourceX1 = Math.Min(sourceX0 + 1, maxSourceX);
var fractionX = scaledX - MathF.Floor(scaledX);
var sourceOffset00 = checked((int)(((ulong)sourceY0 * sourceWidth + sourceX0) * 4));
var sourceOffset10 = checked((int)(((ulong)sourceY0 * sourceWidth + sourceX1) * 4));
var sourceOffset01 = checked((int)(((ulong)sourceY1 * sourceWidth + sourceX0) * 4));
var sourceOffset11 = checked((int)(((ulong)sourceY1 * sourceWidth + sourceX1) * 4));
var topBlue = source[sourceOffset00] +
((source[sourceOffset10] - source[sourceOffset00]) * fractionX);
var bottomBlue = source[sourceOffset01] +
((source[sourceOffset11] - source[sourceOffset01]) * fractionX);
blue += topBlue + ((bottomBlue - topBlue) * fractionY);
var topGreen = source[sourceOffset00 + 1] +
((source[sourceOffset10 + 1] - source[sourceOffset00 + 1]) * fractionX);
var bottomGreen = source[sourceOffset01 + 1] +
((source[sourceOffset11 + 1] - source[sourceOffset01 + 1]) * fractionX);
green += topGreen + ((bottomGreen - topGreen) * fractionY);
var topRed = source[sourceOffset00 + 2] +
((source[sourceOffset10 + 2] - source[sourceOffset00 + 2]) * fractionX);
var bottomRed = source[sourceOffset01 + 2] +
((source[sourceOffset11 + 2] - source[sourceOffset01 + 2]) * fractionX);
red += topRed + ((bottomRed - topRed) * fractionY);
var topAlpha = source[sourceOffset00 + 3] +
((source[sourceOffset10 + 3] - source[sourceOffset00 + 3]) * fractionX);
var bottomAlpha = source[sourceOffset01 + 3] +
((source[sourceOffset11 + 3] - source[sourceOffset01 + 3]) * fractionX);
alpha += topAlpha + ((bottomAlpha - topAlpha) * fractionY);
}
}
destination[destinationOffset] = (byte)MathF.Round(blue * 0.25f);
destination[destinationOffset + 1] = (byte)MathF.Round(green * 0.25f);
destination[destinationOffset + 2] = (byte)MathF.Round(red * 0.25f);
destination[destinationOffset + 3] = (byte)MathF.Round(alpha * 0.25f);
}
}
return destination;
}
private void DisposeVulkan()
{
if (!_vulkanReady)
@@ -13994,7 +14514,7 @@ internal static unsafe class VulkanVideoPresenter
_surface,
out var capabilities),
"vkGetPhysicalDeviceSurfaceCapabilitiesKHR");
var framebufferSize = _window.FramebufferSize;
var framebufferSize = GetFramebufferSize();
var hasFixedExtent = capabilities.CurrentExtent.Width != uint.MaxValue;
var surfaceWidth = hasFixedExtent
? capabilities.CurrentExtent.Width