diff --git a/src/Ryujinx.Graphics.GAL/IWindow.cs b/src/Ryujinx.Graphics.GAL/IWindow.cs index 48144f0b0..68e847f82 100644 --- a/src/Ryujinx.Graphics.GAL/IWindow.cs +++ b/src/Ryujinx.Graphics.GAL/IWindow.cs @@ -15,5 +15,6 @@ namespace Ryujinx.Graphics.GAL void SetScalingFilter(ScalingFilter type); void SetScalingFilterLevel(float level); void SetColorSpacePassthrough(bool colorSpacePassThroughEnabled); + void SetHdrMode(bool enabled, float paperWhite, float peak, float curve); } } diff --git a/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedWindow.cs b/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedWindow.cs index 78c39c493..05d9cc4a8 100644 --- a/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedWindow.cs +++ b/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedWindow.cs @@ -41,5 +41,7 @@ namespace Ryujinx.Graphics.GAL.Multithreading public void SetScalingFilterLevel(float level) { } public void SetColorSpacePassthrough(bool colorSpacePassthroughEnabled) { } + + public void SetHdrMode(bool enabled, float paperWhite, float peak, float curve) { } } } diff --git a/src/Ryujinx.Graphics.OpenGL/Window.cs b/src/Ryujinx.Graphics.OpenGL/Window.cs index fb3208f00..b1b68d809 100644 --- a/src/Ryujinx.Graphics.OpenGL/Window.cs +++ b/src/Ryujinx.Graphics.OpenGL/Window.cs @@ -423,5 +423,10 @@ namespace Ryujinx.Graphics.OpenGL _scalingFilterLevel = level; _updateScalingFilter = true; } + + public void SetHdrMode(bool enabled, float paperWhite, float peak, float curve) + { + // Native HDR output is only implemented on the Vulkan backend. + } } } diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/AreaScalingFilter.cs b/src/Ryujinx.Graphics.Vulkan/Effects/AreaScalingFilter.cs index b8992c652..bfd21bb35 100644 --- a/src/Ryujinx.Graphics.Vulkan/Effects/AreaScalingFilter.cs +++ b/src/Ryujinx.Graphics.Vulkan/Effects/AreaScalingFilter.cs @@ -63,7 +63,11 @@ namespace Ryujinx.Graphics.Vulkan.Effects int width, int height, Extent2D source, - Extent2D destination) + Extent2D destination, + bool hdr = false, + float paperWhite = 2.5f, + float peak = 12.5f, + float curve = 4.0f) { _pipeline.SetCommandBuffer(cbs); _pipeline.SetProgram(_scalingProgram); diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/FsrScalingFilter.cs b/src/Ryujinx.Graphics.Vulkan/Effects/FsrScalingFilter.cs index 64b6c3b37..104871fcb 100644 --- a/src/Ryujinx.Graphics.Vulkan/Effects/FsrScalingFilter.cs +++ b/src/Ryujinx.Graphics.Vulkan/Effects/FsrScalingFilter.cs @@ -17,6 +17,7 @@ namespace Ryujinx.Graphics.Vulkan.Effects private ISampler _sampler; private ShaderCollection _scalingProgram; private ShaderCollection _sharpeningProgram; + private ShaderCollection _sharpeningProgramHdr; private float _sharpeningLevel = 1; private Device _device; private TextureView _intermediaryTexture; @@ -43,6 +44,7 @@ namespace Ryujinx.Graphics.Vulkan.Effects _pipeline.Dispose(); _scalingProgram.Dispose(); _sharpeningProgram.Dispose(); + _sharpeningProgramHdr.Dispose(); _sampler.Dispose(); _intermediaryTexture?.Dispose(); } @@ -77,6 +79,12 @@ namespace Ryujinx.Graphics.Vulkan.Effects _sharpeningProgram = _renderer.CreateProgramWithMinimalLayout([ new ShaderSource(sharpeningShader, ShaderStage.Compute, TargetLanguage.Spirv) ], sharpeningResourceLayout); + + byte[] sharpeningShaderHdr = EmbeddedResources.Read("Ryujinx.Graphics.Vulkan/Effects/Shaders/FsrSharpeningHdr.spv"); + + _sharpeningProgramHdr = _renderer.CreateProgramWithMinimalLayout([ + new ShaderSource(sharpeningShaderHdr, ShaderStage.Compute, TargetLanguage.Spirv) + ], sharpeningResourceLayout); } public void Run( @@ -87,7 +95,11 @@ namespace Ryujinx.Graphics.Vulkan.Effects int width, int height, Extent2D source, - Extent2D destination) + Extent2D destination, + bool hdr = false, + float paperWhite = 2.5f, + float peak = 12.5f, + float curve = 4.0f) { if (_intermediaryTexture == null || _intermediaryTexture.Info.Width != width @@ -143,9 +155,16 @@ namespace Ryujinx.Graphics.Vulkan.Effects using ScopedTemporaryBuffer buffer = _renderer.BufferManager.ReserveOrCreate(_renderer, cbs, rangeSize); buffer.Holder.SetDataUnchecked(buffer.Offset, dimensionsBuffer); - ReadOnlySpan sharpeningBufferData = [1.5f - (Level * 0.01f * 1.5f)]; - using ScopedTemporaryBuffer sharpeningBuffer = _renderer.BufferManager.ReserveOrCreate(_renderer, cbs, sizeof(float)); - sharpeningBuffer.Holder.SetDataUnchecked(sharpeningBuffer.Offset, sharpeningBufferData); + // Non-HDR: [sharpening]. HDR: [sharpening, paperWhite, peak, curve] (the HDR sharpening + // shader reads the extra floats from the same uniform block to tone-map to scRGB). + int sharpeningCount = hdr ? 4 : 1; + Span sharpeningBufferData = stackalloc float[4]; + sharpeningBufferData[0] = 1.5f - (Level * 0.01f * 1.5f); + sharpeningBufferData[1] = paperWhite; + sharpeningBufferData[2] = peak; + sharpeningBufferData[3] = curve; + using ScopedTemporaryBuffer sharpeningBuffer = _renderer.BufferManager.ReserveOrCreate(_renderer, cbs, sharpeningCount * sizeof(float)); + sharpeningBuffer.Holder.SetDataUnchecked(sharpeningBuffer.Offset, sharpeningBufferData[..sharpeningCount]); int threadGroupWorkRegionDim = 16; int dispatchX = (width + (threadGroupWorkRegionDim - 1)) / threadGroupWorkRegionDim; @@ -157,7 +176,7 @@ namespace Ryujinx.Graphics.Vulkan.Effects _pipeline.ComputeBarrier(); // Sharpening pass - _pipeline.SetProgram(_sharpeningProgram); + _pipeline.SetProgram(hdr ? _sharpeningProgramHdr : _sharpeningProgram); _pipeline.SetTextureAndSampler(ShaderStage.Compute, 1, _intermediaryTexture, _sampler); _pipeline.SetUniformBuffers([new BufferAssignment(4, sharpeningBuffer.Range)]); _pipeline.SetImage(0, destinationTexture); diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/IScalingFilter.cs b/src/Ryujinx.Graphics.Vulkan/Effects/IScalingFilter.cs index 50fc3710c..898908a7f 100644 --- a/src/Ryujinx.Graphics.Vulkan/Effects/IScalingFilter.cs +++ b/src/Ryujinx.Graphics.Vulkan/Effects/IScalingFilter.cs @@ -15,6 +15,10 @@ namespace Ryujinx.Graphics.Vulkan.Effects int width, int height, Extent2D source, - Extent2D destination); + Extent2D destination, + bool hdr = false, + float paperWhite = 2.5f, + float peak = 12.5f, + float curve = 4.0f); } } diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/FsrSharpening.glsl b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/FsrSharpening.glsl index 785bc0c83..72239cef1 100644 --- a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/FsrSharpening.glsl +++ b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/FsrSharpening.glsl @@ -1,7 +1,11 @@ // Sharpening #version 430 core layout (local_size_x = 64) in; +#ifdef HDR_OUTPUT +layout( rgba16f, binding = 0, set = 3) uniform image2D imgOutput; +#else layout( rgba8, binding = 0, set = 3) uniform image2D imgOutput; +#endif layout( binding = 2 ) uniform invResolution { vec2 invResolution_data; @@ -14,6 +18,11 @@ layout( binding = 1, set = 2) uniform sampler2D source; layout( binding = 4 ) uniform sharpening { float sharpening_data; +#ifdef HDR_OUTPUT + float paperWhite_data; + float peak_data; + float curve_data; +#endif }; #define A_GPU 1 @@ -3883,10 +3892,33 @@ AF1 sharpness){ #endif +#ifdef HDR_OUTPUT +// sRGB encoded -> linear, for the scRGB (extended sRGB linear) HDR swapchain. +vec3 srgbToLinear(vec3 c) +{ + return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), c)); +} +// First-pass SDR->HDR: scale to paper white + expand ONLY the highlights (blacks stay black). +// scRGB: 1.0 == 80 nits, paperWhite 2.0 ~= 160 nits, peak 8.0 ~= 640 nits. +vec3 sdrToHdr(vec3 srgb) +{ + vec3 lin = srgbToLinear(srgb); + float paperWhite = paperWhite_data; + float peak = peak_data; + float curve = curve_data; + float luma = dot(lin, vec3(0.2126, 0.7152, 0.0722)); + float boost = mix(1.0, peak / paperWhite, pow(clamp(luma, 0.0, 1.0), curve)); + return lin * paperWhite * boost; +} +#endif + void CurrFilter(AU2 pos) { AF3 c; FsrRcasF(c.r, c.g, c.b, pos, con0); +#ifdef HDR_OUTPUT + c = sdrToHdr(c); +#endif imageStore(imgOutput, ASU2(pos), AF4(c, 1)); } diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/FsrSharpeningHdr.spv b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/FsrSharpeningHdr.spv new file mode 100644 index 000000000..23d9b8f99 Binary files /dev/null and b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/FsrSharpeningHdr.spv differ diff --git a/src/Ryujinx.Graphics.Vulkan/HelperShader.cs b/src/Ryujinx.Graphics.Vulkan/HelperShader.cs index dbb5ee224..5c20e0746 100644 --- a/src/Ryujinx.Graphics.Vulkan/HelperShader.cs +++ b/src/Ryujinx.Graphics.Vulkan/HelperShader.cs @@ -33,6 +33,7 @@ namespace Ryujinx.Graphics.Vulkan private readonly ISampler _samplerLinear; private readonly ISampler _samplerNearest; private readonly ShaderCollection _programColorBlit; + private readonly ShaderCollection _programColorBlitHdr; private readonly ShaderCollection _programColorBlitMs; private readonly ShaderCollection _programColorBlitClearAlpha; private readonly ShaderCollection _programColorClearF; @@ -73,6 +74,16 @@ namespace Ryujinx.Graphics.Vulkan new ShaderSource(ReadSpirv("ColorBlitFragment.spv"), ShaderStage.Fragment, TargetLanguage.Spirv) ], blitResourceLayout); + ResourceLayout blitHdrResourceLayout = new ResourceLayoutBuilder() + .Add(ResourceStages.Vertex, ResourceType.UniformBuffer, 1) + .Add(ResourceStages.Fragment, ResourceType.TextureAndSampler, 0) + .Add(ResourceStages.Fragment, ResourceType.UniformBuffer, 2).Build(); + + _programColorBlitHdr = gd.CreateProgramWithMinimalLayout([ + new ShaderSource(ReadSpirv("ColorBlitVertex.spv"), ShaderStage.Vertex, TargetLanguage.Spirv), + new ShaderSource(ReadSpirv("ColorBlitHdrFragment.spv"), ShaderStage.Fragment, TargetLanguage.Spirv) + ], blitHdrResourceLayout); + _programColorBlitMs = gd.CreateProgramWithMinimalLayout([ new ShaderSource(ReadSpirv("ColorBlitVertex.spv"), ShaderStage.Vertex, TargetLanguage.Spirv), new ShaderSource(ReadSpirv("ColorBlitMsFragment.spv"), ShaderStage.Fragment, TargetLanguage.Spirv) @@ -352,7 +363,11 @@ namespace Ryujinx.Graphics.Vulkan Extents2D srcRegion, Extents2D dstRegion, bool linearFilter, - bool clearAlpha = false) + bool clearAlpha = false, + bool hdr = false, + float paperWhite = 2.5f, + float peak = 12.5f, + float curve = 4.0f) { _pipeline.SetCommandBuffer(cbs); @@ -383,7 +398,24 @@ namespace Ryujinx.Graphics.Vulkan buffer.Holder.SetDataUnchecked(buffer.Offset, region); - _pipeline.SetUniformBuffers([new BufferAssignment(1, buffer.Range)]); + // Reserved at method scope (must stay alive until the draw). Only filled/bound for HDR; + // the HDR blit program's layout has a fragment uniform buffer at binding 2. + using ScopedTemporaryBuffer hdrBuffer = gd.BufferManager.ReserveOrCreate(gd, cbs, 16); + + if (hdr) + { + Span hdrParams = stackalloc float[4]; + hdrParams[0] = paperWhite; + hdrParams[1] = peak; + hdrParams[2] = curve; + hdrBuffer.Holder.SetDataUnchecked(hdrBuffer.Offset, hdrParams); + + _pipeline.SetUniformBuffers([new BufferAssignment(1, buffer.Range), new BufferAssignment(2, hdrBuffer.Range)]); + } + else + { + _pipeline.SetUniformBuffers([new BufferAssignment(1, buffer.Range)]); + } Span viewports = stackalloc Viewport[1]; @@ -415,7 +447,7 @@ namespace Ryujinx.Graphics.Vulkan } else if (clearAlpha) { - _pipeline.SetProgram(_programColorBlitClearAlpha); + _pipeline.SetProgram(hdr ? _programColorBlitHdr : _programColorBlitClearAlpha); } else { @@ -1672,6 +1704,7 @@ namespace Ryujinx.Graphics.Vulkan { _programColorBlitClearAlpha.Dispose(); _programColorBlit.Dispose(); + _programColorBlitHdr.Dispose(); _programColorBlitMs.Dispose(); _programColorClearF.Dispose(); _programColorClearSI.Dispose(); diff --git a/src/Ryujinx.Graphics.Vulkan/Ryujinx.Graphics.Vulkan.csproj b/src/Ryujinx.Graphics.Vulkan/Ryujinx.Graphics.Vulkan.csproj index 897bf8d09..e59823648 100644 --- a/src/Ryujinx.Graphics.Vulkan/Ryujinx.Graphics.Vulkan.csproj +++ b/src/Ryujinx.Graphics.Vulkan/Ryujinx.Graphics.Vulkan.csproj @@ -18,6 +18,7 @@ + @@ -25,6 +26,7 @@ + diff --git a/src/Ryujinx.Graphics.Vulkan/Shaders/ColorBlitHdrFragmentShaderSource.frag b/src/Ryujinx.Graphics.Vulkan/Shaders/ColorBlitHdrFragmentShaderSource.frag new file mode 100644 index 000000000..c5c5c5f32 --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/Shaders/ColorBlitHdrFragmentShaderSource.frag @@ -0,0 +1,38 @@ +#version 450 core + +layout (binding = 0, set = 2) uniform sampler2D tex; + +layout (std140, binding = 2) uniform hdr_params +{ + // x = paper white (scRGB multiplier), y = peak (scRGB multiplier), z = highlight curve exponent + vec4 hdr_params_data; +}; + +layout (location = 0) in vec2 tex_coord; +layout (location = 0) out vec4 colour; + +// sRGB encoded -> linear, for the scRGB (extended sRGB linear) HDR swapchain. +vec3 srgbToLinear(vec3 c) +{ + return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), c)); +} + +// First-pass SDR->HDR "inverse tone map": scale to paper white, then expand ONLY the +// highlights toward the peak. Blacks stay black (no constant lift) to avoid a washed look. +// scRGB: 1.0 == 80 nits, so paperWhite 2.0 ~= 160 nits, peak 8.0 ~= 640 nits. +vec3 sdrToHdr(vec3 srgb) +{ + vec3 lin = srgbToLinear(srgb); + float paperWhite = hdr_params_data.x; + float peak = hdr_params_data.y; + float curve = hdr_params_data.z; + float luma = dot(lin, vec3(0.2126, 0.7152, 0.0722)); + float boost = mix(1.0, peak / paperWhite, pow(clamp(luma, 0.0, 1.0), curve)); + return lin * paperWhite * boost; +} + +void main() +{ + vec3 c = texture(tex, tex_coord).rgb; + colour = vec4(sdrToHdr(c), 1.0f); +} diff --git a/src/Ryujinx.Graphics.Vulkan/Shaders/SpirvBinaries/ColorBlitHdrFragment.spv b/src/Ryujinx.Graphics.Vulkan/Shaders/SpirvBinaries/ColorBlitHdrFragment.spv new file mode 100644 index 000000000..4298f3110 Binary files /dev/null and b/src/Ryujinx.Graphics.Vulkan/Shaders/SpirvBinaries/ColorBlitHdrFragment.spv differ diff --git a/src/Ryujinx.Graphics.Vulkan/Window.cs b/src/Ryujinx.Graphics.Vulkan/Window.cs index 8b2a53fc0..5889fa85f 100644 --- a/src/Ryujinx.Graphics.Vulkan/Window.cs +++ b/src/Ryujinx.Graphics.Vulkan/Window.cs @@ -42,6 +42,10 @@ namespace Ryujinx.Graphics.Vulkan private bool _updateScalingFilter; private ScalingFilter _currentScalingFilter; private bool _colorSpacePassthroughEnabled; + private bool _hdrEnabled; + private float _hdrPaperWhite = 200f; + private float _hdrPeak = 1100f; + private float _hdrCurve = 2.8f; public unsafe Window(VulkanRenderer gd, SurfaceKHR surface, PhysicalDevice physicalDevice, Device device) { @@ -117,7 +121,7 @@ namespace Ryujinx.Graphics.Vulkan imageCount = capabilities.MaxImageCount; } - SurfaceFormatKHR surfaceFormat = ChooseSwapSurfaceFormat(surfaceFormats, _colorSpacePassthroughEnabled); + SurfaceFormatKHR surfaceFormat = ChooseSwapSurfaceFormat(surfaceFormats, _colorSpacePassthroughEnabled, _hdrEnabled); Extent2D extent = ChooseSwapExtent(capabilities); @@ -226,8 +230,24 @@ namespace Ryujinx.Graphics.Vulkan return new TextureView(_gd, _device, new DisposableImageView(_gd.Api, _device, imageView), info, format); } - private static SurfaceFormatKHR ChooseSwapSurfaceFormat(SurfaceFormatKHR[] availableFormats, bool colorSpacePassthroughEnabled) + private static SurfaceFormatKHR ChooseSwapSurfaceFormat(SurfaceFormatKHR[] availableFormats, bool colorSpacePassthroughEnabled, bool hdrEnabled) { + // Native HDR output: when HDR is enabled and the surface advertises the scRGB + // (extended sRGB linear) HDR colorspace, select the 16-bit float swapchain. That + // colorspace is only enumerated on a genuinely HDR-capable display/OS, so on an SDR + // display this branch never triggers and the normal SDR format is used unchanged. + if (hdrEnabled) + { + foreach (SurfaceFormatKHR hdrFormat in availableFormats) + { + if (hdrFormat.Format == VkFormat.R16G16B16A16Sfloat && + hdrFormat.ColorSpace == ColorSpaceKHR.ColorSpaceExtendedSrgbLinearExt) + { + return hdrFormat; + } + } + } + if (availableFormats.Length == 1 && availableFormats[0].Format == VkFormat.Undefined) { return new SurfaceFormatKHR(VkFormat.B8G8R8A8Unorm, ColorSpaceKHR.PaceSrgbNonlinearKhr); @@ -438,7 +458,11 @@ namespace Ryujinx.Graphics.Vulkan _width, _height, new Extents2D(srcX0, srcY0, srcX1, srcY1), - new Extents2D(dstX0, dstY0, dstX1, dstY1) + new Extents2D(dstX0, dstY0, dstX1, dstY1), + _format == VkFormat.R16G16B16A16Sfloat, + _hdrPaperWhite / 80f, + _hdrPeak / 80f, + _hdrCurve ); } else @@ -451,7 +475,11 @@ namespace Ryujinx.Graphics.Vulkan new Extents2D(srcX0, srcY0, srcX1, srcY1), new Extents2D(dstX0, dstY1, dstX1, dstY0), _isLinear, - true); + true, + _format == VkFormat.R16G16B16A16Sfloat, + _hdrPaperWhite / 80f, + _hdrPeak / 80f, + _hdrCurve); } Transition( @@ -653,6 +681,20 @@ namespace Ryujinx.Graphics.Vulkan _swapchainIsDirty = true; } + public override void SetHdrMode(bool enabled, float paperWhite, float peak, float curve) + { + _hdrPaperWhite = paperWhite; + _hdrPeak = peak; + _hdrCurve = curve; + + if (_hdrEnabled != enabled) + { + _hdrEnabled = enabled; + // The swapchain surface format (SDR vs scRGB float) changes, so recreate it. + _swapchainIsDirty = true; + } + } + protected virtual void Dispose(bool disposing) { if (disposing) diff --git a/src/Ryujinx.Graphics.Vulkan/WindowBase.cs b/src/Ryujinx.Graphics.Vulkan/WindowBase.cs index 34ac63a83..0e330cc61 100644 --- a/src/Ryujinx.Graphics.Vulkan/WindowBase.cs +++ b/src/Ryujinx.Graphics.Vulkan/WindowBase.cs @@ -16,5 +16,6 @@ namespace Ryujinx.Graphics.Vulkan public abstract void SetScalingFilter(ScalingFilter scalerType); public abstract void SetScalingFilterLevel(float scale); public abstract void SetColorSpacePassthrough(bool colorSpacePassthroughEnabled); + public abstract void SetHdrMode(bool enabled, float paperWhite, float peak, float curve); } } diff --git a/src/Ryujinx/Systems/AppHost.cs b/src/Ryujinx/Systems/AppHost.cs index 23f6d6b47..21ecd7f26 100644 --- a/src/Ryujinx/Systems/AppHost.cs +++ b/src/Ryujinx/Systems/AppHost.cs @@ -206,6 +206,10 @@ namespace Ryujinx.Ava.Systems ConfigurationState.Instance.Graphics.ScalingFilter.Event += UpdateScalingFilter; ConfigurationState.Instance.Graphics.ScalingFilterLevel.Event += UpdateScalingFilterLevel; ConfigurationState.Instance.Graphics.EnableColorSpacePassthrough.Event += UpdateColorSpacePassthrough; + ConfigurationState.Instance.Graphics.EnableHdr.Event += UpdateHdrEnabled; + ConfigurationState.Instance.Graphics.HdrPaperWhite.Event += UpdateHdrLevel; + ConfigurationState.Instance.Graphics.HdrPeakBrightness.Event += UpdateHdrLevel; + ConfigurationState.Instance.Graphics.HdrIntensity.Event += UpdateHdrLevel; ConfigurationState.Instance.Graphics.VSyncMode.Event += UpdateVSyncMode; ConfigurationState.Instance.Graphics.CustomVSyncInterval.Event += UpdateCustomVSyncIntervalValue; ConfigurationState.Instance.Graphics.EnableCustomVSyncInterval.Event += UpdateCustomVSyncIntervalEnabled; @@ -305,6 +309,26 @@ namespace Ryujinx.Ava.Systems _renderer.Window?.SetColorSpacePassthrough(ConfigurationState.Instance.Graphics.EnableColorSpacePassthrough); } + private void UpdateHdrEnabled(object sender, ReactiveEventArgs e) => ApplyHdrMode(); + + private void UpdateHdrLevel(object sender, ReactiveEventArgs e) => ApplyHdrMode(); + + private void ApplyHdrMode() + { + _renderer.Window?.SetHdrMode( + ConfigurationState.Instance.Graphics.EnableHdr, + ConfigurationState.Instance.Graphics.HdrPaperWhite, + ConfigurationState.Instance.Graphics.HdrPeakBrightness, + HdrIntensityToCurve(ConfigurationState.Instance.Graphics.HdrIntensity)); + } + + // Maps the user-facing HDR intensity (0-100, higher = more pop) to the highlight curve + // exponent: 0 -> 6.0 (only near-white pops), 50 -> 4.0 (default), 100 -> 2.0 (whole scene pops). + private static float HdrIntensityToCurve(int intensity) + { + return 6.0f - (Math.Clamp(intensity, 0, 100) * 0.04f); + } + public void UpdateVSyncMode(object sender, ReactiveEventArgs e) { if (Device != null) @@ -661,6 +685,10 @@ namespace Ryujinx.Ava.Systems ConfigurationState.Instance.Graphics.ScalingFilterLevel.Event -= UpdateScalingFilterLevel; ConfigurationState.Instance.Graphics.AntiAliasing.Event -= UpdateAntiAliasing; ConfigurationState.Instance.Graphics.EnableColorSpacePassthrough.Event -= UpdateColorSpacePassthrough; + ConfigurationState.Instance.Graphics.EnableHdr.Event -= UpdateHdrEnabled; + ConfigurationState.Instance.Graphics.HdrPaperWhite.Event -= UpdateHdrLevel; + ConfigurationState.Instance.Graphics.HdrPeakBrightness.Event -= UpdateHdrLevel; + ConfigurationState.Instance.Graphics.HdrIntensity.Event -= UpdateHdrLevel; _topLevel.PointerMoved -= TopLevel_PointerEnteredOrMoved; _topLevel.PointerEntered -= TopLevel_PointerEnteredOrMoved; @@ -1116,6 +1144,11 @@ namespace Ryujinx.Ava.Systems _renderer?.Window?.SetScalingFilter(ConfigurationState.Instance.Graphics.ScalingFilter); _renderer?.Window?.SetScalingFilterLevel(ConfigurationState.Instance.Graphics.ScalingFilterLevel); _renderer?.Window?.SetColorSpacePassthrough(ConfigurationState.Instance.Graphics.EnableColorSpacePassthrough); + _renderer?.Window?.SetHdrMode( + ConfigurationState.Instance.Graphics.EnableHdr, + ConfigurationState.Instance.Graphics.HdrPaperWhite, + ConfigurationState.Instance.Graphics.HdrPeakBrightness, + HdrIntensityToCurve(ConfigurationState.Instance.Graphics.HdrIntensity)); Width = (int)RendererHost.Bounds.Width; Height = (int)RendererHost.Bounds.Height; diff --git a/src/Ryujinx/Systems/Configuration/ConfigurationFileFormat.cs b/src/Ryujinx/Systems/Configuration/ConfigurationFileFormat.cs index 55b2d2cac..5e5c0721e 100644 --- a/src/Ryujinx/Systems/Configuration/ConfigurationFileFormat.cs +++ b/src/Ryujinx/Systems/Configuration/ConfigurationFileFormat.cs @@ -17,7 +17,7 @@ namespace Ryujinx.Ava.Systems.Configuration /// /// The current version of the file format /// - public const int CurrentVersion = 73; + public const int CurrentVersion = 75; /// /// Version of the configuration file format @@ -69,6 +69,26 @@ namespace Ryujinx.Ava.Systems.Configuration /// public int ScalingFilterLevel { get; set; } + /// + /// Enables or disables native HDR output (scRGB) with SDR->HDR tone mapping. + /// + public bool EnableHdr { get; set; } + + /// + /// HDR paper white (reference white) brightness, in nits. + /// + public int HdrPaperWhite { get; set; } + + /// + /// HDR peak (highlight) brightness target, in nits. + /// + public int HdrPeakBrightness { get; set; } + + /// + /// HDR highlight intensity (0-100): how far down the brightness range the highlight expansion reaches. + /// + public int HdrIntensity { get; set; } + /// /// Dumps shaders in this local directory /// diff --git a/src/Ryujinx/Systems/Configuration/ConfigurationState.Migration.cs b/src/Ryujinx/Systems/Configuration/ConfigurationState.Migration.cs index 3fd132cff..e2cf1af19 100644 --- a/src/Ryujinx/Systems/Configuration/ConfigurationState.Migration.cs +++ b/src/Ryujinx/Systems/Configuration/ConfigurationState.Migration.cs @@ -85,6 +85,10 @@ namespace Ryujinx.Ava.Systems.Configuration Graphics.AntiAliasing.Value = cff.AntiAliasing; Graphics.ScalingFilter.Value = cff.ScalingFilter; Graphics.ScalingFilterLevel.Value = cff.ScalingFilterLevel; + Graphics.EnableHdr.Value = cff.EnableHdr; + Graphics.HdrPaperWhite.Value = cff.HdrPaperWhite; + Graphics.HdrPeakBrightness.Value = cff.HdrPeakBrightness; + Graphics.HdrIntensity.Value = cff.HdrIntensity; Graphics.VSyncMode.Value = cff.VSyncMode; Graphics.EnableCustomVSyncInterval.Value = cff.EnableCustomVSyncInterval; Graphics.CustomVSyncInterval.Value = cff.CustomVSyncInterval; @@ -539,7 +543,14 @@ namespace Ryujinx.Ava.Systems.Configuration if (cff.AudioBackend is AudioBackend.SDL2) cff.AudioBackend = AudioBackend.SDL3; }), - (72, static cff => cff.GCLowLatency = false) + (72, static cff => cff.GCLowLatency = false), + (74, static cff => + { + cff.EnableHdr = false; + cff.HdrPaperWhite = 200; + cff.HdrPeakBrightness = 1100; + }), + (75, static cff => cff.HdrIntensity = 80) ); } } diff --git a/src/Ryujinx/Systems/Configuration/ConfigurationState.Model.cs b/src/Ryujinx/Systems/Configuration/ConfigurationState.Model.cs index c66f74ed5..c4d6ce273 100644 --- a/src/Ryujinx/Systems/Configuration/ConfigurationState.Model.cs +++ b/src/Ryujinx/Systems/Configuration/ConfigurationState.Model.cs @@ -620,6 +620,27 @@ namespace Ryujinx.Ava.Systems.Configuration /// public ReactiveObject ScalingFilterLevel { get; private set; } + /// + /// Enables or disables native HDR output (scRGB) with SDR->HDR tone mapping. + /// + public ReactiveObject EnableHdr { get; private set; } + + /// + /// HDR paper white (reference white) brightness, in nits. + /// + public ReactiveObject HdrPaperWhite { get; private set; } + + /// + /// HDR peak (highlight) brightness target, in nits. + /// + public ReactiveObject HdrPeakBrightness { get; private set; } + + /// + /// HDR highlight intensity (0-100): how far down the brightness range the highlight + /// expansion reaches. Higher = more of the scene pops, lower = only near-white pops. + /// + public ReactiveObject HdrIntensity { get; private set; } + /// /// Preferred GPU /// @@ -662,6 +683,14 @@ namespace Ryujinx.Ava.Systems.Configuration ScalingFilter.LogChangesToValue(nameof(ScalingFilter)); ScalingFilterLevel = new ReactiveObject(); ScalingFilterLevel.LogChangesToValue(nameof(ScalingFilterLevel)); + EnableHdr = new ReactiveObject(); + EnableHdr.LogChangesToValue(nameof(EnableHdr)); + HdrPaperWhite = new ReactiveObject(); + HdrPaperWhite.LogChangesToValue(nameof(HdrPaperWhite)); + HdrPeakBrightness = new ReactiveObject(); + HdrPeakBrightness.LogChangesToValue(nameof(HdrPeakBrightness)); + HdrIntensity = new ReactiveObject(); + HdrIntensity.LogChangesToValue(nameof(HdrIntensity)); } } diff --git a/src/Ryujinx/Systems/Configuration/ConfigurationState.cs b/src/Ryujinx/Systems/Configuration/ConfigurationState.cs index acf8a6793..00e94ba34 100644 --- a/src/Ryujinx/Systems/Configuration/ConfigurationState.cs +++ b/src/Ryujinx/Systems/Configuration/ConfigurationState.cs @@ -40,6 +40,10 @@ namespace Ryujinx.Ava.Systems.Configuration AntiAliasing = Graphics.AntiAliasing, ScalingFilter = Graphics.ScalingFilter, ScalingFilterLevel = Graphics.ScalingFilterLevel, + EnableHdr = Graphics.EnableHdr, + HdrPaperWhite = Graphics.HdrPaperWhite, + HdrPeakBrightness = Graphics.HdrPeakBrightness, + HdrIntensity = Graphics.HdrIntensity, GraphicsShadersDumpPath = Graphics.ShadersDumpPath, LoggingEnableDebug = Logger.EnableDebug, LoggingEnableStub = Logger.EnableStub, @@ -208,6 +212,10 @@ namespace Ryujinx.Ava.Systems.Configuration Graphics.AntiAliasing.Value = AntiAliasing.None; Graphics.ScalingFilter.Value = ScalingFilter.Bilinear; Graphics.ScalingFilterLevel.Value = 80; + Graphics.EnableHdr.Value = false; + Graphics.HdrPaperWhite.Value = 200; + Graphics.HdrPeakBrightness.Value = 1100; + Graphics.HdrIntensity.Value = 80; System.EnablePtc.Value = true; System.EnableInternetAccess.Value = false; System.EnableFsIntegrityChecks.Value = true; diff --git a/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs b/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs index 44caffb8d..0f6e473e3 100644 --- a/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs @@ -287,6 +287,7 @@ namespace Ryujinx.Ava.UI.ViewModels public MemoryConfiguration DramSize { get; set; } public bool EnableShaderCache { get; set; } public bool EnableTextureRecompression { get; set; } + public bool EnableHdr { get; set; } public bool EnableMacroHLE { get; set; } public bool EnableColorSpacePassthrough { get; set; } public bool ColorSpacePassthroughAvailable => RunningPlatform.IsMacOS; @@ -352,6 +353,45 @@ namespace Ryujinx.Ava.UI.ViewModels } } + public string HdrPaperWhiteText => $"{HdrPaperWhite} nits"; + + public int HdrPaperWhite + { + get; + set + { + field = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(HdrPaperWhiteText)); + } + } + + public string HdrPeakBrightnessText => $"{HdrPeakBrightness} nits"; + + public int HdrPeakBrightness + { + get; + set + { + field = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(HdrPeakBrightnessText)); + } + } + + public string HdrIntensityText => $"{HdrIntensity}"; + + public int HdrIntensity + { + get; + set + { + field = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(HdrIntensityText)); + } + } + public int OpenglDebugLevel { get; set; } public int MemoryMode { get; set; } public int BaseStyleIndex { get; set; } @@ -725,6 +765,10 @@ namespace Ryujinx.Ava.UI.ViewModels // Physical devices are queried asynchronously hence the preferred index config value is loaded in LoadAvailableGpus(). EnableShaderCache = config.Graphics.EnableShaderCache; EnableTextureRecompression = config.Graphics.EnableTextureRecompression; + EnableHdr = config.Graphics.EnableHdr; + HdrPaperWhite = config.Graphics.HdrPaperWhite.Value; + HdrPeakBrightness = config.Graphics.HdrPeakBrightness.Value; + HdrIntensity = config.Graphics.HdrIntensity.Value; EnableMacroHLE = config.Graphics.EnableMacroHLE; EnableColorSpacePassthrough = config.Graphics.EnableColorSpacePassthrough; ResolutionScale = config.Graphics.ResScale == -1 ? 4 : config.Graphics.ResScale - 1; @@ -842,6 +886,10 @@ namespace Ryujinx.Ava.UI.ViewModels config.Graphics.PreferredGpu.Value = _gpuIds.ElementAtOrDefault(PreferredGpuIndex); config.Graphics.EnableShaderCache.Value = EnableShaderCache; config.Graphics.EnableTextureRecompression.Value = EnableTextureRecompression; + config.Graphics.EnableHdr.Value = EnableHdr; + config.Graphics.HdrPaperWhite.Value = HdrPaperWhite; + config.Graphics.HdrPeakBrightness.Value = HdrPeakBrightness; + config.Graphics.HdrIntensity.Value = HdrIntensity; config.Graphics.EnableMacroHLE.Value = EnableMacroHLE; config.Graphics.EnableColorSpacePassthrough.Value = EnableColorSpacePassthrough; config.Graphics.ResScale.Value = ResolutionScale == 4 ? -1 : ResolutionScale + 1; diff --git a/src/Ryujinx/UI/Views/Settings/SettingsGraphicsView.axaml b/src/Ryujinx/UI/Views/Settings/SettingsGraphicsView.axaml index 22c7acfa2..9e5b17fd7 100644 --- a/src/Ryujinx/UI/Views/Settings/SettingsGraphicsView.axaml +++ b/src/Ryujinx/UI/Views/Settings/SettingsGraphicsView.axaml @@ -95,6 +95,76 @@ ToolTip.Tip="{ext:Locale SettingsEnableColorSpacePassthroughTooltip}"> + + + + + + + + + + + + + + + + + +