Compare commits

...

3 Commits

Author SHA1 Message Date
kuba 2f4dcba99c [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.
2026-09-29 23:18:07 +02:00
kuba 133dfb4857 [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.
2026-09-29 23:18:07 +02:00
kuba 4512688301 [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.
2026-09-29 23:18:07 +02:00
5 changed files with 170 additions and 27 deletions

View File

@ -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);

View File

@ -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);

View File

@ -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} " +

View File

@ -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)

View File

@ -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];
@ -709,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<GuestImageResource>>
_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<GuestImageResource>();
_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<GuestImageResource> 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,