[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
@@ -77,7 +77,10 @@ public static class KernelSemaphoreCompatExports
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
TraceSemaphore($"create handle=0x{handle:X8} name='{name}' attr=0x{attr:X} init={initialCount} max={maxCount}");
if (_traceSema)
{
TraceSemaphore($"create handle=0x{handle:X8} name='{name}' attr=0x{attr:X} init={initialCount} max={maxCount}");
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
@@ -118,7 +121,10 @@ public static class KernelSemaphoreCompatExports
_ = TryWriteUInt32(ctx, timeoutAddress, timeoutUsec);
}
TraceSemaphore($"wait handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)}");
if (_traceSema)
{
TraceSemaphore($"wait handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)}");
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
@@ -158,7 +164,10 @@ public static class KernelSemaphoreCompatExports
if (acquired)
{
TraceSemaphore($"wait-wake handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
if (_traceSema)
{
TraceSemaphore($"wait-wake handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
}
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -167,7 +176,10 @@ public static class KernelSemaphoreCompatExports
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
}
TraceSemaphore($"wait-timeout handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
if (_traceSema)
{
TraceSemaphore($"wait-timeout handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
}
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT;
}
@@ -179,7 +191,10 @@ public static class KernelSemaphoreCompatExports
WakePredicate,
deadline))
{
TraceSemaphore($"wait-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} waiters={semaphore.WaitingThreads} {FormatCallSite(ctx)}");
if (_traceSema)
{
TraceSemaphore($"wait-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} waiters={semaphore.WaitingThreads} {FormatCallSite(ctx)}");
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
@@ -201,9 +216,12 @@ public static class KernelSemaphoreCompatExports
: long.MaxValue;
lock (semaphore.Gate)
{
TraceSemaphore(
$"wait-host-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} " +
$"count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} {FormatCallSite(ctx)}");
if (_traceSema)
{
TraceSemaphore(
$"wait-host-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} " +
$"count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} {FormatCallSite(ctx)}");
}
while (semaphore.Count < needCount)
{
var remaining = deadlineMs - Environment.TickCount64;
@@ -219,8 +237,11 @@ public static class KernelSemaphoreCompatExports
semaphore.Count -= needCount;
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
TraceSemaphore(
$"wait-host-wake handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} {FormatCallSite(ctx)}");
if (_traceSema)
{
TraceSemaphore(
$"wait-host-wake handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} {FormatCallSite(ctx)}");
}
if (timeoutAddress != 0)
{
_ = TryWriteUInt32(ctx, timeoutAddress, 0);
@@ -253,12 +274,18 @@ public static class KernelSemaphoreCompatExports
{
if (semaphore.Count < needCount)
{
TraceSemaphore($"poll-busy handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
if (_traceSema)
{
TraceSemaphore($"poll-busy handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY);
}
semaphore.Count -= needCount;
TraceSemaphore($"poll handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
if (_traceSema)
{
TraceSemaphore($"poll handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
}
@@ -290,7 +317,10 @@ public static class KernelSemaphoreCompatExports
semaphore.Count += signalCount;
// Wake host-thread waiters parked in the fallback path.
Monitor.PulseAll(semaphore.Gate);
TraceSemaphore($"signal handle=0x{handle:X8} name='{semaphore.Name}' signal={signalCount} count={semaphore.Count} waiters={semaphore.WaitingThreads} {FormatCallSite(ctx)}");
if (_traceSema)
{
TraceSemaphore($"signal handle=0x{handle:X8} name='{semaphore.Name}' signal={signalCount} count={semaphore.Count} waiters={semaphore.WaitingThreads} {FormatCallSite(ctx)}");
}
}
// Wake cooperatively-blocked guest threads; their wake predicate
@@ -326,7 +356,10 @@ public static class KernelSemaphoreCompatExports
semaphore.Count = setCount < 0 ? semaphore.InitialCount : setCount;
semaphore.WaitingThreads = 0;
Monitor.PulseAll(semaphore.Gate);
TraceSemaphore($"cancel handle=0x{handle:X8} name='{semaphore.Name}' set={setCount} count={semaphore.Count}");
if (_traceSema)
{
TraceSemaphore($"cancel handle=0x{handle:X8} name='{semaphore.Name}' set={setCount} count={semaphore.Count}");
}
}
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(GetSemaphoreWakeKey(handle));
@@ -346,7 +379,10 @@ public static class KernelSemaphoreCompatExports
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND);
}
TraceSemaphore($"delete handle=0x{handle:X8} name='{semaphore.Name}'");
if (_traceSema)
{
TraceSemaphore($"delete handle=0x{handle:X8} name='{semaphore.Name}'");
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
@@ -385,7 +421,10 @@ public static class KernelSemaphoreCompatExports
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
TraceSemaphore($"posix-init address=0x{semaphoreAddress:X16} handle=0x{handle:X8} count={initialCount}");
if (_traceSema)
{
TraceSemaphore($"posix-init address=0x{semaphoreAddress:X16} handle=0x{handle:X8} count={initialCount}");
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
@@ -582,6 +621,11 @@ public static class KernelSemaphoreCompatExports
private static void TraceSemaphore(string message)
{
if (!_traceSema)
{
return;
}
Console.Error.WriteLine($"[LOADER][TRACE] sema.{message}");
}
+4 -1
View File
@@ -154,7 +154,10 @@ public static class PerfOverlay
_lastGen2 = gen2;
var cpuTime = GetProcessCpuTime();
_cpuPercent = (cpuTime - _lastCpuTime).TotalSeconds / seconds * 100.0;
// Normalize across logical processors (Task Manager convention):
// raw process time / wall time reads 100% per fully busy core.
_cpuPercent = (cpuTime - _lastCpuTime).TotalSeconds / seconds * 100.0 /
Environment.ProcessorCount;
_lastCpuTime = cpuTime;
var drawsPerFrame = _fps > 0.5 ? _drawsPerSecond / _fps : 0;
+19 -5
View File
@@ -156,15 +156,29 @@ public static class VideoOutExports
private static void RequestHostShutdown(string reason)
{
Console.Error.WriteLine($"[LOADER][INFO] Host shutdown requested: {reason}");
VulkanVideoPresenter.RequestClose();
var embedded = VulkanVideoHost.IsEmbedded;
AudioOutExports.ShutdownAllPorts();
Interlocked.Exchange(ref _vblankStopRequested, 1);
HostSessionControl.RequestShutdown(reason);
ThreadPool.QueueUserWorkItem(static _ =>
// A hosted game can still be issuing AGC work after it requests its
// own shutdown. Keep the Vulkan resources alive until the GUI session
// reaches its guest-safe exit path and disposes the host surface.
if (!embedded)
{
Thread.Sleep(2000);
Environment.Exit(0);
});
VulkanVideoPresenter.RequestClose();
}
// The embedded GUI owns the process lifetime. A guest shutdown should
// end only that session rather than terminating the launcher itself.
if (!embedded)
{
ThreadPool.QueueUserWorkItem(static _ =>
{
Thread.Sleep(2000);
Environment.Exit(0);
});
}
}
private sealed class VideoOutPortState
@@ -0,0 +1,270 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.VideoOut;
/// <summary>
/// A native child surface owned by a host UI. The presenter consumes this
/// directly, avoiding a second top-level GLFW window when the GUI is active.
/// </summary>
public enum VulkanHostSurfaceKind
{
Win32,
Xlib,
Metal,
}
/// <summary>
/// Platform-native handles required to create a Vulkan presentation surface.
/// The GUI owns their lifetime and updates the physical pixel size on resize.
/// </summary>
public sealed class VulkanHostSurface : IDisposable
{
private int _pixelWidth;
private int _pixelHeight;
private int _resizeGeneration;
private readonly bool _ownsDisplay;
private readonly bool _pollNativeSize;
private long _nextNativeSizePoll;
public VulkanHostSurface(
VulkanHostSurfaceKind kind,
nint windowHandle,
nint displayHandle = 0,
nint metalLayerHandle = 0,
bool ownsDisplay = false,
bool pollNativeSize = false)
{
Kind = kind;
WindowHandle = windowHandle;
DisplayHandle = displayHandle;
MetalLayerHandle = metalLayerHandle;
_ownsDisplay = ownsDisplay;
_pollNativeSize = pollNativeSize;
}
public VulkanHostSurfaceKind Kind { get; }
public nint WindowHandle { get; }
/// <summary>X11 Display* when <see cref="Kind"/> is <see cref="VulkanHostSurfaceKind.Xlib"/>.</summary>
public nint DisplayHandle { get; }
/// <summary>CAMetalLayer* when <see cref="Kind"/> is <see cref="VulkanHostSurfaceKind.Metal"/>.</summary>
public nint MetalLayerHandle { get; }
public int PixelWidth => Volatile.Read(ref _pixelWidth);
public int PixelHeight => Volatile.Read(ref _pixelHeight);
internal int ResizeGeneration => Volatile.Read(ref _resizeGeneration);
public void UpdatePixelSize(int width, int height)
{
width = Math.Max(width, 1);
height = Math.Max(height, 1);
if (Volatile.Read(ref _pixelWidth) == width && Volatile.Read(ref _pixelHeight) == height)
{
return;
}
Volatile.Write(ref _pixelWidth, width);
Volatile.Write(ref _pixelHeight, height);
Interlocked.Increment(ref _resizeGeneration);
}
/// <summary>
/// The child emulator cannot receive Avalonia resize notifications. Poll
/// the native host at a bounded rate so embedded child swapchains still
/// follow normal resize and F11 transitions.
/// </summary>
internal void RefreshChildProcessPixelSize()
{
if (!_pollNativeSize)
{
return;
}
var now = System.Diagnostics.Stopwatch.GetTimestamp();
var due = Volatile.Read(ref _nextNativeSizePoll);
if (now < due || Interlocked.CompareExchange(
ref _nextNativeSizePoll,
now + (System.Diagnostics.Stopwatch.Frequency / 8),
due) != due)
{
return;
}
if (Kind == VulkanHostSurfaceKind.Win32 && GetClientRect(WindowHandle, out var rect))
{
UpdatePixelSize(rect.Right - rect.Left, rect.Bottom - rect.Top);
return;
}
if (Kind == VulkanHostSurfaceKind.Xlib && DisplayHandle != 0 &&
XGetGeometry(
DisplayHandle,
WindowHandle,
out _,
out _,
out _,
out var width,
out var height,
out _,
out _) != 0)
{
UpdatePixelSize(unchecked((int)width), unchecked((int)height));
}
}
/// <summary>
/// Serializes a native child handle for a separately hosted emulator
/// process. Metal object pointers are process-local, so macOS falls back
/// to a standalone child window until an IPC Metal host is implemented.
/// </summary>
public bool TryGetChildProcessDescriptor(out string descriptor)
{
descriptor = string.Empty;
if (Kind == VulkanHostSurfaceKind.Metal || WindowHandle == 0)
{
return false;
}
var kind = Kind == VulkanHostSurfaceKind.Win32 ? "win32" : "xlib";
descriptor = string.Create(
System.Globalization.CultureInfo.InvariantCulture,
$"{kind}:{unchecked((ulong)WindowHandle):X}:{Math.Max(PixelWidth, 1)}:{Math.Max(PixelHeight, 1)}:{unchecked((ulong)DisplayHandle):X}");
return true;
}
/// <summary>
/// Reconstructs a surface in the isolated emulator process. X11 clients
/// must open their own Display connection; Display* values cannot cross a
/// process boundary.
/// </summary>
public static bool TryCreateChildProcessSurface(
string descriptor,
out VulkanHostSurface? surface,
out string? error)
{
surface = null;
error = null;
var parts = descriptor.Split(':');
if (parts.Length != 5 ||
!ulong.TryParse(parts[1], System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture, out var window) ||
!int.TryParse(parts[2], System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var width) ||
!int.TryParse(parts[3], System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var height) ||
!ulong.TryParse(parts[4], System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture, out var nativeDisplay))
{
error = "invalid host-surface descriptor";
return false;
}
if (window == 0 || width <= 0 || height <= 0)
{
error = "host-surface descriptor has an invalid size or handle";
return false;
}
if (string.Equals(parts[0], "win32", StringComparison.OrdinalIgnoreCase))
{
surface = new VulkanHostSurface(
VulkanHostSurfaceKind.Win32,
unchecked((nint)window),
unchecked((nint)nativeDisplay),
pollNativeSize: true);
}
else if (string.Equals(parts[0], "xlib", StringComparison.OrdinalIgnoreCase))
{
var display = XOpenDisplay(0);
if (display == 0)
{
error = "could not open an X11 display for the host surface";
return false;
}
surface = new VulkanHostSurface(
VulkanHostSurfaceKind.Xlib,
unchecked((nint)window),
display,
ownsDisplay: true,
pollNativeSize: true);
}
else
{
error = $"unsupported host-surface kind '{parts[0]}'";
return false;
}
surface.UpdatePixelSize(width, height);
return true;
}
public void Dispose()
{
if (_ownsDisplay && DisplayHandle != 0 && OperatingSystem.IsLinux())
{
_ = XCloseDisplay(DisplayHandle);
}
}
[System.Runtime.InteropServices.DllImport("libX11.so.6", EntryPoint = "XOpenDisplay")]
private static extern nint XOpenDisplay(nint displayName);
[System.Runtime.InteropServices.DllImport("libX11.so.6", EntryPoint = "XCloseDisplay")]
private static extern int XCloseDisplay(nint display);
[System.Runtime.InteropServices.DllImport("user32.dll", EntryPoint = "GetClientRect", SetLastError = true)]
[return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
private static extern bool GetClientRect(nint window, out Rect rect);
[System.Runtime.InteropServices.DllImport("libX11.so.6", EntryPoint = "XGetGeometry")]
private static extern int XGetGeometry(
nint display,
nint drawable,
out nint root,
out int x,
out int y,
out uint width,
out uint height,
out uint borderWidth,
out uint depth);
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
private struct Rect
{
public int Left;
public int Top;
public int Right;
public int Bottom;
}
}
/// <summary>
/// Small public bridge between a desktop UI and the internal Vulkan
/// presenter. Launchers can host a surface without depending on renderer
/// submission internals.
/// </summary>
public static class VulkanVideoHost
{
/// <summary>
/// Raised after the first successful Vulkan present to an embedded host
/// surface. UI hosts use this to retire their launch affordance only once
/// a real frame can be seen.
/// </summary>
public static event Action<VulkanHostSurface>? FirstFramePresented
{
add => VulkanVideoPresenter.FirstHostFramePresented += value;
remove => VulkanVideoPresenter.FirstHostFramePresented -= value;
}
public static bool TryAttachSurface(VulkanHostSurface surface) =>
VulkanVideoPresenter.TryAttachHostSurface(surface);
public static void DetachSurface(VulkanHostSurface surface) =>
VulkanVideoPresenter.DetachHostSurface(surface);
public static void RequestClose() => VulkanVideoPresenter.RequestClose();
public static bool IsEmbedded => VulkanVideoPresenter.UsesHostSurface;
}
@@ -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