503 lines
20 KiB
C#
503 lines
20 KiB
C#
// Copyright (C) 2026 SharpEmu Emulator Project
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
namespace SharpEmu.Libs.VideoOut;
|
|
|
|
using SharpEmu.HLE;
|
|
using SharpEmu.Libs.Gpu;
|
|
using SharpEmu.ShaderCompiler;
|
|
using Silk.NET.Vulkan;
|
|
|
|
internal static unsafe partial class VulkanVideoPresenter
|
|
{
|
|
private sealed partial class Presenter
|
|
{
|
|
// This partial drives the Vulkan presenter render loop.
|
|
private void ProcessGuestCacheReadbacks()
|
|
{
|
|
using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.BufferFaults))
|
|
{
|
|
_bufferCache.ProcessPendingFaultBuffer();
|
|
}
|
|
|
|
using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.ImageReadback))
|
|
{
|
|
_imageCache.FlushScheduledReadbacks();
|
|
}
|
|
}
|
|
|
|
private void RunGuestCacheCollection(bool endsFrame)
|
|
{
|
|
ProcessGuestCacheReadbacks();
|
|
|
|
using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.ImageCollect))
|
|
{
|
|
_imageCache.RunGarbageCollector(endsFrame);
|
|
}
|
|
|
|
using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.BufferCollect))
|
|
{
|
|
_bufferCache.RunGarbageCollector();
|
|
}
|
|
}
|
|
|
|
private void WaitForRenderWork()
|
|
{
|
|
using var profileScope = RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Idle);
|
|
var gpuWorkInFlight = _pendingGuestSubmissions.Count > 0 ||
|
|
Array.Exists(_frameInFlight, static pending => pending);
|
|
var blockedRetryWait = BlockedRetryWaitMilliseconds();
|
|
lock (_gate)
|
|
{
|
|
if (_closed ||
|
|
Volatile.Read(ref _presenterCloseRequested) ||
|
|
_relay.HasPendingCommands ||
|
|
(_vulkanReady && _commandStream.HasUnblockedPending) ||
|
|
blockedRetryWait == 0 ||
|
|
HasReadyPresentationLocked())
|
|
{
|
|
return;
|
|
}
|
|
|
|
var waitMilliseconds = gpuWorkInFlight ? 1 : 8;
|
|
if (blockedRetryWait is { } retryWait)
|
|
{
|
|
waitMilliseconds = Math.Min(waitMilliseconds, retryWait);
|
|
}
|
|
|
|
var waitPhase = _commandStream.HasPending
|
|
? RenderPhaseProfile.Phase.IdleBlockedCommands
|
|
: _pendingGuestImagePresentations.Count > 0 || _pendingVideoPresentations.Count > 0 ||
|
|
(_latestPresentation is { } latest && latest.Sequence != _presentedSequence)
|
|
? RenderPhaseProfile.Phase.IdlePendingPresentation
|
|
: RenderPhaseProfile.Phase.IdleNoQueuedWork;
|
|
using var waitProfile = RenderPhaseProfile.MeasureDetail(waitPhase);
|
|
SubmissionFlowProfile.Record(SubmissionFlowProfile.EventKind.WaitStarted,
|
|
detail: waitMilliseconds);
|
|
var signaled = System.Threading.Monitor.Wait(_gate, waitMilliseconds);
|
|
// The result describes the monitor wait, not the arrival of runnable guest work.
|
|
SubmissionFlowProfile.Record(signaled ? SubmissionFlowProfile.EventKind.WaitSignaled
|
|
: SubmissionFlowProfile.EventKind.WaitTimedOut, detail: (int)waitPhase);
|
|
}
|
|
}
|
|
|
|
private void Render(double _)
|
|
{
|
|
try
|
|
{
|
|
RenderCore();
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
RenderDocCapture.DiscardFrame();
|
|
// Device loss can strike between any two Vulkan calls in the frame;
|
|
// keep the window loop pumping instead of tearing the presenter down.
|
|
if (!TryMarkDeviceLost(exception))
|
|
{
|
|
throw;
|
|
}
|
|
}
|
|
}
|
|
|
|
private void RenderCore()
|
|
{
|
|
using var profileScope = RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Unattributed);
|
|
RenderDocCapture.DiscardTimedOutFrame();
|
|
|
|
if (Volatile.Read(ref _presenterCloseRequested))
|
|
{
|
|
RenderDocCapture.DiscardFrame();
|
|
Console.Error.WriteLine("[LOADER][WARN] Vulkan VideoOut closing on host shutdown request.");
|
|
_window.Close();
|
|
return;
|
|
}
|
|
|
|
if (!_vulkanReady)
|
|
{
|
|
return;
|
|
}
|
|
|
|
using (RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.QueueRelay))
|
|
{
|
|
_relay.RunPendingCommands();
|
|
}
|
|
if (_deviceLost)
|
|
{
|
|
RenderDocCapture.DiscardFrame();
|
|
_commandStream.DiscardAll();
|
|
return;
|
|
}
|
|
|
|
// Reuse of a frame slot waits only on that slot's tick, keeping
|
|
// up to MaxFramesInFlight frames pipelined between CPU and GPU.
|
|
var frameSlot = _currentFrameSlot;
|
|
using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.FrameSlotWait))
|
|
{
|
|
WaitFrameSlot(frameSlot);
|
|
}
|
|
|
|
if (!_deviceLost)
|
|
{
|
|
using var collectScope = RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Collect);
|
|
CollectCompletedGuestSubmissions(waitForOldest: false);
|
|
}
|
|
var renderWorkDeadline = _renderWorkBudgetTicks > 0
|
|
? System.Diagnostics.Stopwatch.GetTimestamp() + _renderWorkBudgetTicks
|
|
: long.MaxValue;
|
|
RunCommandStreamSlices(renderWorkDeadline);
|
|
|
|
using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Flush))
|
|
{
|
|
FlushBatchedGuestCommands();
|
|
}
|
|
|
|
PerfOverlay.SetGuestCacheStatistics(
|
|
_bufferCache.TotalUsedMemory, _imageCache.TotalUsedMemory, _deviceInfo.LiveAllocations, _deviceInfo.PeakAllocations);
|
|
CollectAbandonedGuestImageVersions();
|
|
|
|
using var preparationScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.PresentationPreparation);
|
|
Presentation presentation;
|
|
if (_window.IsMinimized)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var framebufferSize = GetFramebufferSize();
|
|
var drawableSizeChanged =
|
|
(uint)Math.Max(framebufferSize.X, 1) != _extent.Width ||
|
|
(uint)Math.Max(framebufferSize.Y, 1) != _extent.Height;
|
|
var hdrStateChanged = _window.ConsumeHdrStateChange();
|
|
var guestHdrRequestChanged =
|
|
_videoOptions.HdrMode == HostHdrMode.Auto &&
|
|
_hdrRequestedForSwapchain !=
|
|
(_window.HdrState.Enabled && VideoOutExports.IsHdrOutputRequested);
|
|
if (_window.ConsumeSurfaceRestore() ||
|
|
drawableSizeChanged ||
|
|
_swapchainRecreateDeferred ||
|
|
hdrStateChanged && _videoOptions.HdrMode != HostHdrMode.Off ||
|
|
guestHdrRequestChanged)
|
|
{
|
|
RecreateSwapchainResources(
|
|
guestHdrRequestChanged
|
|
? "guest HDR output change"
|
|
: hdrStateChanged
|
|
? "SDL HDR state change"
|
|
: drawableSizeChanged
|
|
? "SDL drawable resize"
|
|
: "restored SDL window",
|
|
Result.SuboptimalKhr);
|
|
return;
|
|
}
|
|
|
|
bool tookPresentation;
|
|
using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.TakePresentation))
|
|
{
|
|
tookPresentation = TryTakePresentation(out presentation);
|
|
}
|
|
|
|
if (!tookPresentation &&
|
|
TryTakeHostMovieOnlyPresentation(_presentedSequence, out presentation))
|
|
{
|
|
tookPresentation = true;
|
|
}
|
|
|
|
if (!tookPresentation)
|
|
{
|
|
// A render-loop tick with no newer flip is normal.
|
|
if (SharpEmu.Libs.Diagnostics.LoadProgressDiagnostics.IsActive ||
|
|
ShouldTracePresentedGuestImageContentsForDiagnostics())
|
|
{
|
|
var hasPendingPresentation =
|
|
HasPendingGuestPresentation(_presentedSequence);
|
|
SharpEmu.Libs.Diagnostics.LoadProgressDiagnostics.TracePresentNotTaken(
|
|
_presentedSequence,
|
|
hasPendingPresentation);
|
|
SharpEmu.Libs.Diagnostics.LoadProgressDiagnostics.TraceGpuWaitSnapshot();
|
|
if (ShouldTracePresentedGuestImageContentsForDiagnostics() &&
|
|
hasPendingPresentation &&
|
|
_presentNotTakenLoggedSequence != _presentedSequence)
|
|
{
|
|
_presentNotTakenLoggedSequence = _presentedSequence;
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][WARN] vk.present_not_taken seq={_presentedSequence} " +
|
|
"— presentation submitted but its required GPU tick is incomplete; nothing shown.");
|
|
}
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
SharpEmu.Libs.Diagnostics.LoadProgressDiagnostics.TracePresentTaken(
|
|
presentation.Sequence,
|
|
presentation.GuestImageAddress,
|
|
presentation.GuestImageVersion);
|
|
if (ShouldTracePresentedGuestImageContentsForDiagnostics())
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][TRACE] vk.present_taken addr=0x{presentation.GuestImageAddress:X16} " +
|
|
$"version={presentation.GuestImageVersion} " +
|
|
$"drawKind={presentation.DrawKind} hasPixels={presentation.Pixels is not null}");
|
|
}
|
|
|
|
if (presentation.Pixels is null &&
|
|
presentation.DrawKind != GuestDrawKind.FullscreenBarycentric &&
|
|
presentation.GuestImageAddress == 0)
|
|
{
|
|
CompletePresentation(in presentation, presented: false);
|
|
return;
|
|
}
|
|
|
|
byte[]? pixels = null;
|
|
if (presentation.Pixels is { } sourcePixels)
|
|
{
|
|
pixels = presentation.Width == _extent.Width && presentation.Height == _extent.Height
|
|
? sourcePixels
|
|
: ScaleBgra(
|
|
sourcePixels,
|
|
presentation.Width,
|
|
presentation.Height,
|
|
_extent.Width,
|
|
_extent.Height);
|
|
if ((ulong)pixels.Length > _stagingSize)
|
|
{
|
|
CompletePresentation(in presentation, presented: false);
|
|
return;
|
|
}
|
|
|
|
}
|
|
|
|
GuestImageResource? presentedGuestImage = null;
|
|
var ownsPresentedGuestImageVersion = false;
|
|
if (presentation.GuestImageVersion != 0)
|
|
{
|
|
ownsPresentedGuestImageVersion = _guestImageVersions.Remove(
|
|
presentation.GuestImageVersion,
|
|
out presentedGuestImage);
|
|
}
|
|
|
|
if (presentation.GuestImageAddress != 0 && presentedGuestImage is null)
|
|
{
|
|
if (ShouldTracePresentedGuestImageContentsForDiagnostics())
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][WARN] vk.present_dropped addr=0x{presentation.GuestImageAddress:X16} " +
|
|
$"version={presentation.GuestImageVersion} " +
|
|
"found=False " +
|
|
$"— no swapchain present this frame (black).");
|
|
}
|
|
|
|
CompletePresentation(in presentation, presented: false);
|
|
return;
|
|
}
|
|
if (ownsPresentedGuestImageVersion)
|
|
{
|
|
System.Diagnostics.Debug.Assert(
|
|
_frameGuestImageVersions[frameSlot] is null,
|
|
"A reusable frame slot cannot still own a flip version.");
|
|
_frameGuestImageVersions[frameSlot] = presentedGuestImage;
|
|
}
|
|
if (presentedGuestImage is not null)
|
|
{
|
|
_directPresentationCount++;
|
|
var traceAddressedPresentation =
|
|
ShouldTraceAddressedPresentedGuestImage(presentedGuestImage);
|
|
if (traceAddressedPresentation ||
|
|
ShouldTracePresentedGuestImageContentsForDiagnostics() &&
|
|
(_directPresentationCount is 1 or 30 or 120 ||
|
|
_directPresentationCount % 600 == 0))
|
|
{
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][TRACE] vk.present_sample frame={_directPresentationCount} " +
|
|
$"addr=0x{presentedGuestImage.Address:X16}");
|
|
}
|
|
}
|
|
|
|
|
|
uint imageIndex;
|
|
Result acquireResult;
|
|
using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Acquire))
|
|
{
|
|
acquireResult = _swapchainApi.AcquireNextImage(
|
|
_device,
|
|
_swapchain,
|
|
SwapchainAcquireTimeoutNs,
|
|
_frameImageAvailable[frameSlot],
|
|
default,
|
|
&imageIndex);
|
|
}
|
|
if (acquireResult == Result.Timeout)
|
|
{
|
|
ReleaseUnsubmittedPresentationResources(
|
|
frameSlot,
|
|
ownsPresentedGuestImageVersion,
|
|
presentedGuestImage);
|
|
return;
|
|
}
|
|
|
|
if (acquireResult == Result.ErrorOutOfDateKhr)
|
|
{
|
|
RecreateSwapchainResources("vkAcquireNextImageKHR", acquireResult);
|
|
ReleaseUnsubmittedPresentationResources(
|
|
frameSlot,
|
|
ownsPresentedGuestImageVersion,
|
|
presentedGuestImage);
|
|
|
|
CompletePresentation(in presentation, presented: false);
|
|
return;
|
|
}
|
|
|
|
CheckSwapchainResult(acquireResult, "vkAcquireNextImageKHR");
|
|
var recreateAfterPresent = acquireResult == Result.SuboptimalKhr;
|
|
|
|
if (pixels is not null)
|
|
{
|
|
var mapped = (void*)_frameUploadMapped[frameSlot];
|
|
fixed (byte* source = pixels)
|
|
{
|
|
System.Buffer.MemoryCopy(source, mapped, pixels.Length, pixels.Length);
|
|
}
|
|
}
|
|
|
|
using var presentScope = RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Present);
|
|
FlushBatchedGuestCommands();
|
|
_commandBuffer = CurrentRecordingBuffer();
|
|
|
|
PipelineStageFlags waitStage;
|
|
if (pixels is not null)
|
|
{
|
|
RecordUpload(imageIndex, frameSlot);
|
|
waitStage = PipelineStageFlags.TransferBit;
|
|
}
|
|
else if (presentation.DrawKind == GuestDrawKind.FullscreenBarycentric)
|
|
{
|
|
var clearValue = default(ClearValue);
|
|
var renderPassInfo = new RenderPassBeginInfo
|
|
{
|
|
SType = StructureType.RenderPassBeginInfo,
|
|
RenderPass = _renderPass,
|
|
Framebuffer = _framebuffers[imageIndex],
|
|
RenderArea = new Rect2D(new Offset2D(0, 0), _extent),
|
|
ClearValueCount = 1,
|
|
PClearValues = &clearValue,
|
|
};
|
|
_vk.CmdBeginRenderPass(
|
|
_commandBuffer,
|
|
&renderPassInfo,
|
|
SubpassContents.Inline);
|
|
_vk.CmdBindPipeline(
|
|
_commandBuffer,
|
|
PipelineBindPoint.Graphics,
|
|
_barycentricPipeline);
|
|
_vk.CmdDraw(_commandBuffer, 3, 1, 0, 0);
|
|
_vk.CmdEndRenderPass(_commandBuffer);
|
|
waitStage = PipelineStageFlags.ColorAttachmentOutputBit;
|
|
}
|
|
else if (presentedGuestImage is not null)
|
|
{
|
|
RecordGuestImageBlit(imageIndex, presentedGuestImage);
|
|
waitStage = PipelineStageFlags.TransferBit;
|
|
}
|
|
else
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Unsupported translated guest draw: {presentation.DrawKind}.");
|
|
}
|
|
|
|
if (PerfOverlay.DrawOnScreen)
|
|
{
|
|
RecordOverlayBlit(imageIndex, frameSlot);
|
|
}
|
|
|
|
if (_hdrOutputActive)
|
|
{
|
|
RecordHdrPresentation(imageIndex, presentation.IsHdr);
|
|
waitStage = PipelineStageFlags.ColorAttachmentOutputBit;
|
|
}
|
|
|
|
var renderFinished = _renderFinishedPerImage[imageIndex];
|
|
_submitTimeline = SubmitPresentation(
|
|
_frameImageAvailable[frameSlot],
|
|
waitStage,
|
|
renderFinished);
|
|
_commandBuffer = default;
|
|
_frameTimelines[frameSlot] = _submitTimeline;
|
|
_frameInFlight[frameSlot] = true;
|
|
|
|
var swapchain = _swapchain;
|
|
var presentInfo = new PresentInfoKHR
|
|
{
|
|
SType = StructureType.PresentInfoKhr,
|
|
WaitSemaphoreCount = 1,
|
|
PWaitSemaphores = &renderFinished,
|
|
SwapchainCount = 1,
|
|
PSwapchains = &swapchain,
|
|
PImageIndices = &imageIndex,
|
|
};
|
|
Result presentResult;
|
|
using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.QueuePresent))
|
|
{
|
|
lock (_queueGate)
|
|
{
|
|
presentResult = _swapchainApi.QueuePresent(_queue, &presentInfo);
|
|
}
|
|
}
|
|
|
|
if (presentResult == Result.ErrorOutOfDateKhr)
|
|
{
|
|
// The submitted frame still executes; RecreateSwapchainResources
|
|
// drains it (and every frame slot) before destroying anything.
|
|
RecreateSwapchainResources("vkQueuePresentKHR", presentResult);
|
|
CompletePresentation(in presentation, presented: false);
|
|
return;
|
|
}
|
|
|
|
CheckSwapchainResult(presentResult, "vkQueuePresentKHR");
|
|
recreateAfterPresent |= presentResult == Result.SuboptimalKhr;
|
|
VideoOutExports.ReportPresentedFrame();
|
|
PerfOverlay.RecordPresent();
|
|
RenderPhaseProfile.RecordFrame();
|
|
if (_swapchainReadbackPending)
|
|
{
|
|
// Diagnostics read back GPU memory and need this frame done.
|
|
WaitFrameSlot(frameSlot);
|
|
TraceSwapchainReadback();
|
|
}
|
|
|
|
CollectCompletedGuestSubmissions(waitForOldest: false);
|
|
_imageInitialized[imageIndex] = true;
|
|
_currentFrameSlot = (frameSlot + 1) % MaxFramesInFlight;
|
|
CompletePresentation(in presentation, presented: true);
|
|
if (presentation.IsSplash && !_splashPresented)
|
|
{
|
|
_splashPresented = true;
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][INFO] Vulkan VideoOut presented splash: " +
|
|
$"{presentation.Width}x{presentation.Height}");
|
|
}
|
|
else if (!presentation.IsSplash && !_firstFramePresented)
|
|
{
|
|
_firstFramePresented = true;
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][INFO] Vulkan VideoOut presented first frame: " +
|
|
$"{presentation.Width}x{presentation.Height}");
|
|
}
|
|
|
|
if (pixels is null && !_firstGuestDrawPresented)
|
|
{
|
|
_firstGuestDrawPresented = true;
|
|
Console.Error.WriteLine(
|
|
$"[LOADER][INFO] Vulkan VideoOut presented guest frame: " +
|
|
(presentedGuestImage is not null
|
|
? $"image=0x{presentedGuestImage.Address:X16} " +
|
|
$"{presentedGuestImage.Width}x{presentedGuestImage.Height}"
|
|
: $"{presentation.DrawKind}"));
|
|
}
|
|
|
|
if (recreateAfterPresent)
|
|
{
|
|
RecreateSwapchainResources("present suboptimal", Result.SuboptimalKhr);
|
|
}
|
|
}
|
|
}
|
|
}
|