mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-22 19:06:15 +08:00
[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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user