Encode linear-float flips to sRGB at present (#448)

PS5 float VideoOut buffers (A16B16G16R16F flips) hold linear scRGB
light where 1.0 is SDR white; hardware scan-out applies the display
transfer function. vkCmdBlitImage converts numerically only, so
raw-blitting a linear-float guest frame into a UNORM swapchain crushes
dim scenes to near-black.

Blit float flip sources through a cached swapchain-sized sRGB
intermediate (the sRGB store performs the linear->sRGB encode), then
raw vkCmdCopyImage the encoded bytes into the same-compatibility-class
UNORM swapchain image. Swapchains that are already sRGB keep the
direct blit (their store encodes), and swapchain formats without an
sRGB counterpart keep today's raw blit unchanged.
This commit is contained in:
kuba
2026-07-20 00:29:38 +02:00
committed by GitHub
parent 04557fd250
commit 327018e80a
2 changed files with 241 additions and 3 deletions
@@ -1690,6 +1690,21 @@ internal static unsafe class VulkanVideoPresenter
return checked((ulong)width * height * bytesPerPixel); return checked((ulong)width * height * bytesPerPixel);
} }
// Maps a UNORM swapchain format to the sRGB view of the same bit layout,
// or Undefined when no counterpart exists. Used to encode linear-float
// guest flips on their way into a UNORM swapchain.
internal static Format GetSrgbCounterpart(Format format) => format switch
{
Format.B8G8R8A8Unorm => Format.B8G8R8A8Srgb,
Format.R8G8B8A8Unorm => Format.R8G8B8A8Srgb,
_ => Format.Undefined,
};
// Float VideoOut flip buffers hold linear scRGB light; presenting them
// requires a linear->sRGB encode that a plain blit does not perform.
internal static bool IsLinearFloatPresentSource(Format format) =>
format is Format.R16G16B16A16Sfloat or Format.R32G32B32A32Sfloat;
private static byte[]? TakeGuestImageInitialData(ulong address) private static byte[]? TakeGuestImageInitialData(ulong address)
{ {
lock (_gate) lock (_gate)
@@ -14652,6 +14667,99 @@ internal static unsafe class VulkanVideoPresenter
&toPresent); &toPresent);
} }
// PS5 float VideoOut buffers (A16B16G16R16F flips) hold linear scRGB
// light where 1.0 is SDR white; hardware scan-out applies the display
// transfer. vkCmdBlitImage converts numerically only, so presenting a
// linear-float guest frame into a UNORM swapchain shows near-black
// 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;
private Extent2D _presentEncodeExtent;
private bool TryGetPresentEncodeImage(out Image encodeImage)
{
encodeImage = default;
var encodeFormat = GetSrgbCounterpart(_swapchainFormat);
if (encodeFormat == Format.Undefined)
{
return false;
}
if (_presentEncodeImage.Handle != 0 &&
(_presentEncodeExtent.Width != _extent.Width ||
_presentEncodeExtent.Height != _extent.Height))
{
DestroyPresentEncodeImage();
}
if (_presentEncodeImage.Handle == 0)
{
var imageInfo = new ImageCreateInfo
{
SType = StructureType.ImageCreateInfo,
ImageType = ImageType.Type2D,
Format = encodeFormat,
Extent = new Extent3D(_extent.Width, _extent.Height, 1),
MipLevels = 1,
ArrayLayers = 1,
Samples = SampleCountFlags.Count1Bit,
Tiling = ImageTiling.Optimal,
Usage = ImageUsageFlags.TransferDstBit |
ImageUsageFlags.TransferSrcBit,
SharingMode = SharingMode.Exclusive,
InitialLayout = ImageLayout.Undefined,
};
Check(
_vk.CreateImage(_device, &imageInfo, null, out _presentEncodeImage),
"vkCreateImage(present encode)");
_vk.GetImageMemoryRequirements(
_device,
_presentEncodeImage,
out var requirements);
var allocationInfo = new MemoryAllocateInfo
{
SType = StructureType.MemoryAllocateInfo,
AllocationSize = requirements.Size,
MemoryTypeIndex = FindMemoryType(
requirements.MemoryTypeBits,
MemoryPropertyFlags.DeviceLocalBit),
};
Check(
_vk.AllocateMemory(_device, &allocationInfo, null, out _presentEncodeMemory),
"vkAllocateMemory(present encode)");
Check(
_vk.BindImageMemory(_device, _presentEncodeImage, _presentEncodeMemory, 0),
"vkBindImageMemory(present encode)");
_presentEncodeExtent = new Extent2D(_extent.Width, _extent.Height);
SetDebugName(
ObjectType.Image,
_presentEncodeImage.Handle,
"SharpEmu present sRGB-encode image");
}
encodeImage = _presentEncodeImage;
return true;
}
private void DestroyPresentEncodeImage()
{
if (_presentEncodeImage.Handle != 0)
{
_vk.DestroyImage(_device, _presentEncodeImage, null);
_presentEncodeImage = default;
}
if (_presentEncodeMemory.Handle != 0)
{
_vk.FreeMemory(_device, _presentEncodeMemory, null);
_presentEncodeMemory = default;
}
_presentEncodeExtent = default;
}
private void RecordGuestImageBlit( private void RecordGuestImageBlit(
uint imageIndex, uint imageIndex,
GuestImageResource source) GuestImageResource source)
@@ -14703,9 +14811,33 @@ internal static unsafe class VulkanVideoPresenter
Image = _swapchainImages[imageIndex], Image = _swapchainImages[imageIndex],
SubresourceRange = ColorSubresourceRange(), SubresourceRange = ColorSubresourceRange(),
}; };
var barriers = stackalloc ImageMemoryBarrier[2]; // Linear-float flips need a linear->sRGB encode on the way to a
// UNORM swapchain; sRGB (or unknown-counterpart) swapchains keep
// the direct blit.
var encodeForPresent = false;
Image encodeImage = default;
if (IsLinearFloatPresentSource(source.Format) &&
GetSrgbCounterpart(_swapchainFormat) != Format.Undefined)
{
encodeForPresent = TryGetPresentEncodeImage(out encodeImage);
}
var encodeToTransferDst = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.TransferReadBit,
DstAccessMask = AccessFlags.TransferWriteBit,
OldLayout = ImageLayout.Undefined,
NewLayout = ImageLayout.TransferDstOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = _presentEncodeImage,
SubresourceRange = ColorSubresourceRange(),
};
var barriers = stackalloc ImageMemoryBarrier[3];
barriers[0] = sourceToTransfer; barriers[0] = sourceToTransfer;
barriers[1] = destinationToTransfer; barriers[1] = destinationToTransfer;
barriers[2] = encodeToTransferDst;
_vk.CmdPipelineBarrier( _vk.CmdPipelineBarrier(
_commandBuffer, _commandBuffer,
PipelineStageFlags.AllCommandsBit, PipelineStageFlags.AllCommandsBit,
@@ -14715,7 +14847,7 @@ internal static unsafe class VulkanVideoPresenter
null, null,
0, 0,
null, null,
2, encodeForPresent ? 3u : 2u,
barriers); barriers);
var sourceX = 0u; var sourceX = 0u;
@@ -14784,12 +14916,58 @@ internal static unsafe class VulkanVideoPresenter
_commandBuffer, _commandBuffer,
source.Image, source.Image,
ImageLayout.TransferSrcOptimal, ImageLayout.TransferSrcOptimal,
_swapchainImages[imageIndex], encodeForPresent ? encodeImage : _swapchainImages[imageIndex],
ImageLayout.TransferDstOptimal, ImageLayout.TransferDstOptimal,
1, 1,
&region, &region,
isIntegerUpscale ? Filter.Nearest : Filter.Linear); isIntegerUpscale ? Filter.Nearest : Filter.Linear);
if (encodeForPresent)
{
var encodeToTransferSrc = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.TransferWriteBit,
DstAccessMask = AccessFlags.TransferReadBit,
OldLayout = ImageLayout.TransferDstOptimal,
NewLayout = ImageLayout.TransferSrcOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = encodeImage,
SubresourceRange = ColorSubresourceRange(),
};
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.TransferBit,
PipelineStageFlags.TransferBit,
0,
0,
null,
0,
null,
1,
&encodeToTransferSrc);
// Raw same-class copy keeps the sRGB-encoded bytes unchanged
// while landing them in the UNORM swapchain image.
var encodedCopy = new ImageCopy
{
SrcSubresource = new ImageSubresourceLayers(
ImageAspectFlags.ColorBit, 0, 0, 1),
DstSubresource = new ImageSubresourceLayers(
ImageAspectFlags.ColorBit, 0, 0, 1),
Extent = new Extent3D(_extent.Width, _extent.Height, 1),
};
_vk.CmdCopyImage(
_commandBuffer,
encodeImage,
ImageLayout.TransferSrcOptimal,
_swapchainImages[imageIndex],
ImageLayout.TransferDstOptimal,
1,
&encodedCopy);
}
if (traceDestination) if (traceDestination)
{ {
var destinationToReadback = new ImageMemoryBarrier var destinationToReadback = new ImageMemoryBarrier
@@ -15325,6 +15503,7 @@ internal static unsafe class VulkanVideoPresenter
private void DestroySwapchainResources() private void DestroySwapchainResources()
{ {
DestroyPresentEncodeImage();
if (_stagingBuffer.Handle != 0) if (_stagingBuffer.Handle != 0)
{ {
_vk.DestroyBuffer(_device, _stagingBuffer, null); _vk.DestroyBuffer(_device, _stagingBuffer, null);
@@ -0,0 +1,59 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.VideoOut;
using Silk.NET.Vulkan;
using Xunit;
namespace SharpEmu.Libs.Tests.VideoOut;
public sealed class VulkanPresentEncodeFormatTests
{
[Theory]
[InlineData(Format.B8G8R8A8Unorm, Format.B8G8R8A8Srgb)]
[InlineData(Format.R8G8B8A8Unorm, Format.R8G8B8A8Srgb)]
public void UnormSwapchainFormatsHaveSrgbCounterparts(
Format swapchainFormat,
Format expected)
{
Assert.Equal(
expected,
VulkanVideoPresenter.GetSrgbCounterpart(swapchainFormat));
}
[Theory]
// Already-sRGB swapchains encode on store; the direct blit stays.
[InlineData(Format.B8G8R8A8Srgb)]
[InlineData(Format.R8G8B8A8Srgb)]
// No same-class sRGB counterpart exists; the raw blit must remain.
[InlineData(Format.A2B10G10R10UnormPack32)]
[InlineData(Format.R16G16B16A16Sfloat)]
[InlineData(Format.R5G6B5UnormPack16)]
[InlineData(Format.Undefined)]
public void OtherSwapchainFormatsKeepTheDirectBlit(Format swapchainFormat)
{
Assert.Equal(
Format.Undefined,
VulkanVideoPresenter.GetSrgbCounterpart(swapchainFormat));
}
[Theory]
[InlineData(Format.R16G16B16A16Sfloat)]
[InlineData(Format.R32G32B32A32Sfloat)]
public void FloatFlipSourcesNeedLinearToSrgbEncode(Format sourceFormat)
{
Assert.True(VulkanVideoPresenter.IsLinearFloatPresentSource(sourceFormat));
}
[Theory]
[InlineData(Format.B8G8R8A8Unorm)]
[InlineData(Format.R8G8B8A8Unorm)]
[InlineData(Format.B8G8R8A8Srgb)]
[InlineData(Format.A2B10G10R10UnormPack32)]
[InlineData(Format.B10G11R11UfloatPack32)]
[InlineData(Format.Undefined)]
public void NonFloatFlipSourcesKeepTheDirectBlit(Format sourceFormat)
{
Assert.False(VulkanVideoPresenter.IsLinearFloatPresentSource(sourceFormat));
}
}