From 4512688301985f5cbdbffdcdb784cd933c3b8fa3 Mon Sep 17 00:00:00 2001 From: kuba Date: Sun, 27 Sep 2026 00:36:14 +0200 Subject: [PATCH 1/3] [VideoOut] Give every swapchain image its own sRGB-encode intermediate A float guest flip is blitted through an sRGB image before it is copied into the UNORM swapchain image, and one such intermediate served every frame. Each frame discards its contents by transitioning it Undefined -> TransferDst before blitting, so with two frames in flight the next frame's blit raced the previous frame's copy-out and the presented image came out fully zeroed. The guest's own display buffers held the correct picture the whole time, so the window simply went black for long stretches. Each swapchain image now owns its own intermediate, created and destroyed with the swapchain, so a frame can only ever discard the intermediate belonging to the image it is about to present. On Superliminal the same run went from 60 of 84 presented-swapchain readbacks black to 10 of 187, all of them from before the menu appears, and the menu no longer turns black after a couple of minutes. --- .../VulkanVideoPresenter.Present.Frames.cs | 74 +++++++++++++------ 1 file changed, 52 insertions(+), 22 deletions(-) diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Present.Frames.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Present.Frames.cs index 5a275ec..826e3a7 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Present.Frames.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Present.Frames.cs @@ -145,11 +145,16 @@ internal static unsafe partial class VulkanVideoPresenter // for any dim scene. Encode linear->sRGB by blitting through an sRGB // intermediate (sRGB stores encode), then raw-copying the encoded // bytes into the same-class UNORM swapchain image. - private Image _presentEncodeImage; - private DeviceMemory _presentEncodeMemory; + // One intermediate per swapchain image: every frame discards the encode + // image's contents (Undefined -> TransferDst) before blitting into it, + // so a single shared image lets the next frame in flight overwrite the + // one the previous frame has not copied out yet, which presents as a + // fully zeroed (black) swapchain image. + private Image[] _presentEncodeImages = []; + private DeviceMemory[] _presentEncodeMemories = []; private Extent2D _presentEncodeExtent; - private bool TryGetPresentEncodeImage(out Image encodeImage) + private bool TryGetPresentEncodeImage(uint imageIndex, out Image encodeImage) { encodeImage = default; var encodeFormat = GetSrgbCounterpart(_swapchainFormat); @@ -158,14 +163,29 @@ internal static unsafe partial class VulkanVideoPresenter return false; } - if (_presentEncodeImage.Handle != 0 && + if (_presentEncodeImages.Length != 0 && (_presentEncodeExtent.Width != _extent.Width || _presentEncodeExtent.Height != _extent.Height)) { DestroyPresentEncodeImage(); } - if (_presentEncodeImage.Handle == 0) + if (_presentEncodeImages.Length <= imageIndex) + { + var grown = new Image[_swapchainImages.Length]; + var grownMemory = new DeviceMemory[_swapchainImages.Length]; + _presentEncodeImages.CopyTo(grown, 0); + _presentEncodeMemories.CopyTo(grownMemory, 0); + _presentEncodeImages = grown; + _presentEncodeMemories = grownMemory; + } + + if (imageIndex >= _presentEncodeImages.Length) + { + return false; + } + + if (_presentEncodeImages[imageIndex].Handle == 0) { var imageInfo = new ImageCreateInfo { @@ -183,11 +203,11 @@ internal static unsafe partial class VulkanVideoPresenter InitialLayout = ImageLayout.Undefined, }; Check( - _vk.CreateImage(_device, &imageInfo, null, out _presentEncodeImage), + _vk.CreateImage(_device, &imageInfo, null, out _presentEncodeImages[imageIndex]), "vkCreateImage(present encode)"); _vk.GetImageMemoryRequirements( _device, - _presentEncodeImage, + _presentEncodeImages[imageIndex], out var requirements); var allocationInfo = new MemoryAllocateInfo { @@ -198,35 +218,45 @@ internal static unsafe partial class VulkanVideoPresenter MemoryPropertyFlags.DeviceLocalBit), }; Check( - _deviceInfo.AllocateMemory(allocationInfo, out _presentEncodeMemory), + _deviceInfo.AllocateMemory(allocationInfo, out _presentEncodeMemories[imageIndex]), "vkAllocateMemory(present encode)"); Check( - _vk.BindImageMemory(_device, _presentEncodeImage, _presentEncodeMemory, 0), + _vk.BindImageMemory( + _device, + _presentEncodeImages[imageIndex], + _presentEncodeMemories[imageIndex], + 0), "vkBindImageMemory(present encode)"); _presentEncodeExtent = new Extent2D(_extent.Width, _extent.Height); SetDebugName( ObjectType.Image, - _presentEncodeImage.Handle, - "SharpEmu present sRGB-encode image"); + _presentEncodeImages[imageIndex].Handle, + $"SharpEmu present sRGB-encode image {imageIndex}"); } - encodeImage = _presentEncodeImage; + encodeImage = _presentEncodeImages[imageIndex]; return true; } private void DestroyPresentEncodeImage() { - if (_presentEncodeImage.Handle != 0) + for (var index = 0; index < _presentEncodeImages.Length; index++) { - _vk.DestroyImage(_device, _presentEncodeImage, null); - _presentEncodeImage = default; + if (_presentEncodeImages[index].Handle != 0) + { + _vk.DestroyImage(_device, _presentEncodeImages[index], null); + _presentEncodeImages[index] = default; + } + + if (_presentEncodeMemories[index].Handle != 0) + { + _deviceInfo.FreeMemory(_presentEncodeMemories[index]); + _presentEncodeMemories[index] = default; + } } - if (_presentEncodeMemory.Handle != 0) - { - _deviceInfo.FreeMemory(_presentEncodeMemory); - _presentEncodeMemory = default; - } + _presentEncodeImages = []; + _presentEncodeMemories = []; _presentEncodeExtent = default; } @@ -291,7 +321,7 @@ internal static unsafe partial class VulkanVideoPresenter if (IsLinearFloatPresentSource(source.Format) && GetSrgbCounterpart(PresentationTargetFormat) != Format.Undefined) { - encodeForPresent = TryGetPresentEncodeImage(out encodeImage); + encodeForPresent = TryGetPresentEncodeImage(imageIndex, out encodeImage); } var encodeToTransferDst = new ImageMemoryBarrier2 @@ -303,7 +333,7 @@ internal static unsafe partial class VulkanVideoPresenter NewLayout = ImageLayout.TransferDstOptimal, SrcQueueFamilyIndex = Vk.QueueFamilyIgnored, DstQueueFamilyIndex = Vk.QueueFamilyIgnored, - Image = _presentEncodeImage, + Image = encodeImage, SubresourceRange = ColorSubresourceRange(), }; var barriers = stackalloc ImageMemoryBarrier2[3]; From 133dfb48575d1315bef03ddaf590dcce862494f9 Mon Sep 17 00:00:00 2001 From: kuba Date: Sun, 27 Sep 2026 00:13:28 +0200 Subject: [PATCH 2/3] [VideoOut] Present a flip only after the tick that copied it CaptureFlip recorded the display-surface copy into the guest batch, flushed it, and then read the shared _submitTimeline field for the presentation's RequiredTick. The presenter thread writes that same field from its own submission, so the flip could be tagged with an older tick that was already complete. The render loop then blitted the freshly created (never written) flip snapshot, which presents as fully zeroed RGBA - a black frame with an alpha of 0. Take the tick that this flush returned instead. --- .../VideoOut/VulkanVideoPresenter.CommandStream.cs | 7 +++++-- .../VideoOut/VulkanVideoPresenter.GuestSubmissions.cs | 9 ++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CommandStream.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CommandStream.cs index 07eec6e..3e5d5b1 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CommandStream.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CommandStream.cs @@ -585,7 +585,10 @@ internal static unsafe partial class VulkanVideoPresenter VulkanSynchronization.PipelineBarrier(_vk, commandBuffer, PipelineStageFlags.TransferBit, PipelineStageFlags.AllCommandsBit, 0, 0, null, 0, null, 1, &toShaderRead); - FlushBatchedGuestCommands(); + // The tick of this flush, not the shared field: the presenter thread also + // writes _submitTimeline, so reading it here can name an older tick that is + // already complete and let the blit run before the copy above. + var captureTick = FlushBatchedGuestCommands(); submitted = true; _guestImageVersions.Add(version, snapshot); @@ -602,7 +605,7 @@ internal static unsafe partial class VulkanVideoPresenter GuestImageAddress: displayBuffer.Address, GuestImageVersion: version, IsHdr: VideoOutExports.IsHdrPixelFormat(displayBuffer.PixelFormat), - RequiredTick: _submitTimeline, + RequiredTick: captureTick, FlipRequestId: requestId); _latestPresentation = presentation; _pendingGuestImagePresentations.Enqueue(presentation); diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.GuestSubmissions.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.GuestSubmissions.cs index 44ba1d5..6267ddd 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.GuestSubmissions.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.GuestSubmissions.cs @@ -51,15 +51,18 @@ internal static unsafe partial class VulkanVideoPresenter } // Submits the current recording buffer with everything the batch lists own. - private void FlushBatchedGuestCommands() + // Returns the timeline tick that contains the commands this call submitted, so a + // caller that must wait for its own recording does not read the shared + // _submitTimeline field, which the presenter thread also writes. + private ulong FlushBatchedGuestCommands() { if (!_batchOpen) { - return; + return _submitTimeline; } using var profile = RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.QueueSubmit); - _scheduler.Flush(); + return _scheduler.Flush(); } private void PrepareGuestSubmission(SubmitBundle bundle) From 2f4dcba99c0e88799c15aa02b461b96065d93217 Mon Sep 17 00:00:00 2001 From: kuba Date: Sun, 27 Sep 2026 10:35:58 +0200 Subject: [PATCH 3/3] [VideoOut] Pool the per-flip display-surface snapshot images CaptureFlip created a 1920x1080 RGBA16F VkImage with its own VkDeviceMemory for every flip and destroyed both when the flip retired: around twenty vkCreateImage plus vkAllocateMemory pairs a second for the same extent, and on MoltenVK as many Metal texture allocations. Retired snapshots are now kept in a small per-format, per-extent pool and handed back instead. A snapshot is fully overwritten by the copy that follows it and its barrier takes it from Undefined, so neither its previous contents nor its layout matter. The pool holds at most eight images, is guarded because the command-stream thread takes snapshots while the render thread retires them, and is drained at teardown. SHARPEMU_FLIP_SNAPSHOT_POOL=0 turns it off. --- .../VulkanVideoPresenter.CachedImages.cs | 5 + .../VulkanVideoPresenter.Device.Teardown.cs | 2 + .../VulkanVideoPresenter.Present.Frames.cs | 100 ++++++++++++++++++ 3 files changed, 107 insertions(+) diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CachedImages.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CachedImages.cs index ae527a9..1897c94 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CachedImages.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CachedImages.cs @@ -1109,6 +1109,11 @@ internal static unsafe partial class VulkanVideoPresenter private void DestroyGuestImage(GuestImageResource resource) { + if (TryPoolFlipSnapshot(resource)) + { + return; + } + if (resource.Image.Handle != 0) { _vk.DestroyImage(_device, resource.Image, null); diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Teardown.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Teardown.cs index cd3fa85..49f2967 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Teardown.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Teardown.cs @@ -46,6 +46,8 @@ internal static unsafe partial class VulkanVideoPresenter { DestroyGuestImage(deferredVersion.Image); } + + DrainFlipSnapshotPool(); DestroySwapchainResources(); Console.Error.WriteLine( $"[LOADER][INFO] vk.device_memory live_allocations={_deviceInfo.LiveAllocations} " + diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Present.Frames.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Present.Frames.cs index 826e3a7..922fe54 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Present.Frames.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Present.Frames.cs @@ -739,8 +739,108 @@ internal static unsafe partial class VulkanVideoPresenter return destination; } // Captures the display surface through the store in queue order; presentation reads the copy. + // A flip snapshot is created and retired for every flip; on macOS that is a + // vkCreateImage plus a vkAllocateMemory (and a Metal texture) about twenty + // times a second for the same 1920x1080 target. Keep the retired ones and + // hand them back: a snapshot is fully overwritten by the copy that follows + // (its barrier takes it from Undefined), so its old contents and layout do + // not matter. + // The command-stream thread takes snapshots and the render thread retires + // them, so the pool is guarded. + private readonly Dictionary<(Format Format, uint Width, uint Height), Stack> + _flipSnapshotPool = new(); + private readonly object _flipSnapshotPoolGate = new(); + private int _pooledFlipSnapshots; + // SHARPEMU_FLIP_SNAPSHOT_POOL=0 turns the pool off; it is on otherwise. + private bool _flipSnapshotPoolOpen = + !string.Equals( + Environment.GetEnvironmentVariable("SHARPEMU_FLIP_SNAPSHOT_POOL"), + "0", + StringComparison.Ordinal); + private const int MaxPooledFlipSnapshots = 8; + + private bool TryTakePooledFlipSnapshot( + Format format, + uint width, + uint height, + ulong address, + long version, + out GuestImageResource snapshot) + { + lock (_flipSnapshotPoolGate) + { + snapshot = null!; + if (!_flipSnapshotPoolOpen || + !_flipSnapshotPool.TryGetValue((format, width, height), out var available) || + available.Count == 0) + { + return false; + } + + snapshot = available.Pop(); + _pooledFlipSnapshots--; + } + + snapshot.Address = address; + snapshot.FlipVersion = version; + return true; + } + + private bool TryPoolFlipSnapshot(GuestImageResource resource) + { + lock (_flipSnapshotPoolGate) + { + if (!_flipSnapshotPoolOpen || resource.Image.Handle == 0 || resource.Memory.Handle == 0 || + _pooledFlipSnapshots >= MaxPooledFlipSnapshots) + { + return false; + } + + var key = (resource.Format, resource.Width, resource.Height); + if (!_flipSnapshotPool.TryGetValue(key, out var available)) + { + available = new Stack(); + _flipSnapshotPool[key] = available; + } + + resource.Address = 0; + resource.FlipVersion = 0; + available.Push(resource); + _pooledFlipSnapshots++; + return true; + } + } + + // Closes the pool and hands every kept snapshot back for a real destroy. + private void DrainFlipSnapshotPool() + { + List kept = []; + lock (_flipSnapshotPoolGate) + { + _flipSnapshotPoolOpen = false; + foreach (var available in _flipSnapshotPool.Values) + { + kept.AddRange(available); + } + + _flipSnapshotPool.Clear(); + _pooledFlipSnapshots = 0; + } + + foreach (var resource in kept) + { + DestroyGuestImage(resource); + } + } + private GuestImageResource CreateGuestFlipSnapshot(Format format, uint width, uint height, ulong address, long version) { + if (TryTakePooledFlipSnapshot(format, width, height, address, version, out var pooled)) + { + SetDebugName(ObjectType.Image, pooled.Image.Handle, $"guest flip v{version} source 0x{address:X16}"); + return pooled; + } + var imageInfo = new ImageCreateInfo { SType = StructureType.ImageCreateInfo,