From 213da8085e8ac7b0b3822b1fced720127e432917 Mon Sep 17 00:00:00 2001 From: The Roofer Dev Date: Fri, 26 Jun 2026 17:57:04 -0400 Subject: [PATCH 01/20] snapshot WIP: NIS + HDR whitening + DLSS experimental (LOCAL ONLY - do not push) --- .../Configuration/ScalingFilter.cs | 1 + src/Ryujinx.Graphics.GAL/DlssJitterState.cs | 61 + src/Ryujinx.Graphics.GAL/IWindow.cs | 2 +- .../Multithreading/ThreadedWindow.cs | 2 +- .../Engine/Threed/StateUpdater.cs | 27 + src/Ryujinx.Graphics.OpenGL/Window.cs | 2 +- .../Dlss/DlssBinaries.cs | 225 ++++ .../Dlss/DlssIntegration.cs | 165 +++ .../Dlss/DlssJitter.cs | 63 + .../Dlss/DlssUpscaler.cs | 644 +++++++++++ .../Dlss/Streamline.cs | 372 ++++++ .../Dlss/StreamlineDlss.cs | 510 ++++++++ .../Effects/AreaScalingFilter.cs | 5 +- .../Effects/FsrScalingFilter.cs | 10 +- .../Effects/IScalingFilter.cs | 4 +- .../Effects/NisScalingFilter.cs | 231 ++++ .../Effects/Shaders/FsrSharpening.glsl | 11 +- .../Effects/Shaders/FsrSharpeningHdr.spv | Bin 22388 -> 22952 bytes .../Effects/Shaders/FsrSharpeningHdr.spv.bak | Bin 0 -> 22388 bytes .../Effects/Shaders/MotionFilter.glsl | 96 ++ .../Effects/Shaders/MotionFilter.spv | Bin 0 -> 6308 bytes .../Effects/Shaders/MotionVectors.glsl | 159 +++ .../Effects/Shaders/MotionVectors.spv | Bin 0 -> 10456 bytes .../Effects/Shaders/NisScaler.h | 1023 +++++++++++++++++ .../Effects/Shaders/NisScaling.glsl | 88 ++ .../Effects/Shaders/NisScaling.spv | Bin 0 -> 56760 bytes .../Effects/Shaders/NisScalingHdr.glsl | 125 ++ .../Effects/Shaders/NisScalingHdr.spv | Bin 0 -> 59420 bytes .../Effects/Shaders/nis_coef.glsl | 157 +++ src/Ryujinx.Graphics.Vulkan/HelperShader.cs | 4 +- .../Ryujinx.Graphics.Vulkan.csproj | 4 + .../ColorBlitHdrFragmentShaderSource.frag | 13 +- .../SpirvBinaries/ColorBlitHdrFragment.spv | Bin 2952 -> 3520 bytes .../ColorBlitHdrFragment.spv.bak | Bin 0 -> 2952 bytes .../VulkanInitialization.cs | 16 + src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs | 55 + src/Ryujinx.Graphics.Vulkan/Window.cs | 51 +- src/Ryujinx.Graphics.Vulkan/WindowBase.cs | 2 +- src/Ryujinx/Systems/AppHost.cs | 8 +- .../Configuration/ConfigurationFileFormat.cs | 7 +- .../ConfigurationState.Migration.cs | 4 +- .../Configuration/ConfigurationState.Model.cs | 8 + .../Configuration/ConfigurationState.cs | 2 + .../UI/ViewModels/SettingsViewModel.cs | 15 + .../Views/Settings/SettingsGraphicsView.axaml | 24 + src/Ryujinx/UI/Windows/MainWindow.axaml.cs | 8 + 46 files changed, 4182 insertions(+), 22 deletions(-) create mode 100644 src/Ryujinx.Graphics.GAL/DlssJitterState.cs create mode 100644 src/Ryujinx.Graphics.Vulkan/Dlss/DlssBinaries.cs create mode 100644 src/Ryujinx.Graphics.Vulkan/Dlss/DlssIntegration.cs create mode 100644 src/Ryujinx.Graphics.Vulkan/Dlss/DlssJitter.cs create mode 100644 src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs create mode 100644 src/Ryujinx.Graphics.Vulkan/Dlss/Streamline.cs create mode 100644 src/Ryujinx.Graphics.Vulkan/Dlss/StreamlineDlss.cs create mode 100644 src/Ryujinx.Graphics.Vulkan/Effects/NisScalingFilter.cs create mode 100644 src/Ryujinx.Graphics.Vulkan/Effects/Shaders/FsrSharpeningHdr.spv.bak create mode 100644 src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MotionFilter.glsl create mode 100644 src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MotionFilter.spv create mode 100644 src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MotionVectors.glsl create mode 100644 src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MotionVectors.spv create mode 100644 src/Ryujinx.Graphics.Vulkan/Effects/Shaders/NisScaler.h create mode 100644 src/Ryujinx.Graphics.Vulkan/Effects/Shaders/NisScaling.glsl create mode 100644 src/Ryujinx.Graphics.Vulkan/Effects/Shaders/NisScaling.spv create mode 100644 src/Ryujinx.Graphics.Vulkan/Effects/Shaders/NisScalingHdr.glsl create mode 100644 src/Ryujinx.Graphics.Vulkan/Effects/Shaders/NisScalingHdr.spv create mode 100644 src/Ryujinx.Graphics.Vulkan/Effects/Shaders/nis_coef.glsl create mode 100644 src/Ryujinx.Graphics.Vulkan/Shaders/SpirvBinaries/ColorBlitHdrFragment.spv.bak diff --git a/src/Ryujinx.Common/Configuration/ScalingFilter.cs b/src/Ryujinx.Common/Configuration/ScalingFilter.cs index 9040b1be0..c04a18a51 100644 --- a/src/Ryujinx.Common/Configuration/ScalingFilter.cs +++ b/src/Ryujinx.Common/Configuration/ScalingFilter.cs @@ -9,5 +9,6 @@ namespace Ryujinx.Common.Configuration Nearest, Fsr, Area, + Nis, } } diff --git a/src/Ryujinx.Graphics.GAL/DlssJitterState.cs b/src/Ryujinx.Graphics.GAL/DlssJitterState.cs new file mode 100644 index 000000000..4aa666afb --- /dev/null +++ b/src/Ryujinx.Graphics.GAL/DlssJitterState.cs @@ -0,0 +1,61 @@ +using System.Collections.Concurrent; + +namespace Ryujinx.Graphics.GAL +{ + /// + /// Cross-layer hand-off for DLSS "Mode B" sub-pixel jitter. The DLSS backend generates the Halton + /// offset and publishes the next frame's value in /; the + /// GPU layer (StateUpdater) reads it to shift the resolution-scaled viewport, and tags the rendered + /// color target with the exact offset it used (). At present, the DLSS backend + /// looks the offset back up by the presented texture (), so the value handed to + /// DLSS always matches the jitter baked into that specific frame -- independent of frame-queue depth + /// or frames-in-flight. This lives in GAL because the GPU layer cannot reference the Vulkan backend. + /// + /// When is false the GPU layer skips all of this, so the path is byte-identical. + /// + public static class DlssJitterState + { + public static bool Enabled; + + /// The next frame's jitter offset, in internal-render pixels, published by the DLSS backend. + public static float OffsetX; + public static float OffsetY; + + private static readonly ConcurrentDictionary _tags = new(); + + /// Records the jitter offset a color target was actually rendered with. + public static void Tag(ITexture target, float x, float y) + { + if (target == null) + { + return; + } + + // The few live framebuffers cycle through a handful of textures; bound the map so recreated + // textures can't leak it without an eviction policy (this is an experimental, gated feature). + if (_tags.Count > 64) + { + _tags.Clear(); + } + + _tags[target] = (x, y); + } + + /// Reads back the offset a presented texture was rendered with (0 if untagged). + public static bool TryGet(ITexture target, out float x, out float y) + { + if (target != null && _tags.TryGetValue(target, out (float X, float Y) value)) + { + x = value.X; + y = value.Y; + + return true; + } + + x = 0f; + y = 0f; + + return false; + } + } +} diff --git a/src/Ryujinx.Graphics.GAL/IWindow.cs b/src/Ryujinx.Graphics.GAL/IWindow.cs index d5c048784..a364867d1 100644 --- a/src/Ryujinx.Graphics.GAL/IWindow.cs +++ b/src/Ryujinx.Graphics.GAL/IWindow.cs @@ -15,6 +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, float gamma, float blend); + void SetHdrMode(bool enabled, float paperWhite, float peak, float curve, float gamma, float blend, float whiten); } } diff --git a/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedWindow.cs b/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedWindow.cs index 220ab8a90..9d1d23a33 100644 --- a/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedWindow.cs +++ b/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedWindow.cs @@ -42,6 +42,6 @@ namespace Ryujinx.Graphics.GAL.Multithreading public void SetColorSpacePassthrough(bool colorSpacePassthroughEnabled) { } - public void SetHdrMode(bool enabled, float paperWhite, float peak, float curve, float gamma, float blend) { } + public void SetHdrMode(bool enabled, float paperWhite, float peak, float curve, float gamma, float blend, float whiten) { } } } diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Threed/StateUpdater.cs b/src/Ryujinx.Graphics.Gpu/Engine/Threed/StateUpdater.cs index 4f2f01606..f2d1b2c41 100644 --- a/src/Ryujinx.Graphics.Gpu/Engine/Threed/StateUpdater.cs +++ b/src/Ryujinx.Graphics.Gpu/Engine/Threed/StateUpdater.cs @@ -453,6 +453,10 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed /// /// Updates render targets (color and depth-stencil buffers) based on current render target state. /// + // DLSS Mode B jitter: the main (index 0) color target bound for the current frame, captured so + // the scaled-viewport jitter offset can be tagged onto exactly this frame for the present. + private Image.Texture _mainColorTarget; + private void UpdateRenderTargetState() { UpdateRenderTargetState(RenderTargetUpdateFlags.UpdateAll); @@ -489,6 +493,8 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed bool changedScale = false; uint rtNoAlphaMask = 0; + _mainColorTarget = null; + Span rtColorStateSpan = _state.State.RtColorState.AsSpan(); for (int index = 0; index < Constants.TotalRenderTargets; index++) @@ -520,6 +526,11 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed changedScale |= _channel.TextureManager.SetRenderTargetColor(index, color); + if (index == 0) + { + _mainColorTarget = color; + } + if (color != null) { if (clipRegionWidth > color.Width / samplesInX) @@ -739,6 +750,18 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed Span viewportTransformSpan = _state.State.ViewportTransform.AsSpan(); Span viewportExtentsSpan = _state.State.ViewportExtents.AsSpan(); + // DLSS Mode B: shift the resolution-scaled viewport by this frame's sub-pixel jitter, and + // tag the main color target with the exact offset so the present can hand it to DLSS. Only + // the scaled (main) pass; native passes (UI) keep RenderTargetScale 1 and stay untouched. + // No-op (offset 0, no tag) unless jitter is enabled, so the default path is unchanged. + float jitterX = 0f, jitterY = 0f; + if (DlssJitterState.Enabled && _channel.TextureManager.RenderTargetScale != 1f) + { + jitterX = DlssJitterState.OffsetX; + jitterY = DlssJitterState.OffsetY; + DlssJitterState.Tag(_mainColorTarget?.HostTexture, jitterX, jitterY); + } + for (int index = 0; index < Constants.TotalViewports; index++) { if (disableTransform) @@ -781,6 +804,10 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed y *= scale; width *= scale; height *= scale; + + // Sub-pixel jitter is in scaled-render pixels, same space as x/y here (0 unless Mode B). + x += jitterX; + y += jitterY; } Rectangle region = new(x, y, width, height); diff --git a/src/Ryujinx.Graphics.OpenGL/Window.cs b/src/Ryujinx.Graphics.OpenGL/Window.cs index 2518c7d33..ab4e17c62 100644 --- a/src/Ryujinx.Graphics.OpenGL/Window.cs +++ b/src/Ryujinx.Graphics.OpenGL/Window.cs @@ -424,7 +424,7 @@ namespace Ryujinx.Graphics.OpenGL _updateScalingFilter = true; } - public void SetHdrMode(bool enabled, float paperWhite, float peak, float curve, float gamma, float blend) + public void SetHdrMode(bool enabled, float paperWhite, float peak, float curve, float gamma, float blend, float whiten) { // HDR output (scRGB inverse tone mapping) is only implemented on the Vulkan backend. } diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssBinaries.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssBinaries.cs new file mode 100644 index 000000000..4c18de96c --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssBinaries.cs @@ -0,0 +1,225 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; + +namespace Ryujinx.Graphics.Vulkan.Dlss +{ + /// + /// Locates the "bring your own" (BYO) DLSS runtime DLLs. + /// + /// Ryujinx never ships the proprietary nvngx_dlss.dll: the user supplies their own copy, + /// which they already own via any DLSS game or their NVIDIA driver. This locator first + /// checks an explicit "dlss" folder (where the user can drop the DLL), and if nothing is + /// there it auto-scans common game/launcher locations and returns the newest copy found. + /// The NVIDIA Streamline DLLs (MIT) may be bundled, but are looked up the same way so they + /// can also be supplied externally. + /// + /// Nothing here downloads or redistributes the proprietary DLL - it only points at a copy + /// the user already legitimately has on their machine. + /// + public static class DlssBinaries + { + public const string NgxDlssDll = "nvngx_dlss.dll"; + public const string StreamlineInterposer = "sl.interposer.dll"; + + /// + /// Returns the path to a usable nvngx_dlss.dll, or null if none could be found. + /// + /// Explicit folder the user can drop the DLL into (highest priority). + /// When true, scan common game/driver locations if the BYO folder is empty. + public static string LocateNgxDlss(string byoFolder, bool autoScan = true) + { + // 1. Explicit BYO folder always wins. + if (!string.IsNullOrEmpty(byoFolder)) + { + string explicitPath = Path.Combine(byoFolder, NgxDlssDll); + if (File.Exists(explicitPath)) + { + return explicitPath; + } + } + + if (!autoScan) + { + return null; + } + + // 2. Auto-scan known locations and pick the newest version found. + string best = null; + Version bestVersion = null; + + foreach (string root in GetScanRoots()) + { + foreach (string candidate in SafeEnumerate(root, NgxDlssDll)) + { + Version version = GetFileVersion(candidate); + + if (best == null || (version != null && (bestVersion == null || version > bestVersion))) + { + best = candidate; + bestVersion = version; + } + } + } + + return best; + } + + /// + /// Returns the folder containing sl.interposer.dll (bundled or in the BYO folder), or null. + /// + public static string LocateStreamlineFolder(string byoFolder, string bundledFolder) + { + foreach (string folder in new[] { byoFolder, bundledFolder }) + { + if (!string.IsNullOrEmpty(folder) && File.Exists(Path.Combine(folder, StreamlineInterposer))) + { + return folder; + } + } + + return null; + } + + private static IEnumerable GetScanRoots() + { + // Common launcher install roots, per drive. Kept curated so the scan stays fast + // instead of walking entire drives. + string[] relativeRoots = + { + @"SteamLibrary\steamapps\common", + @"Program Files (x86)\Steam\steamapps\common", + @"XboxGames", + @"Program Files\Epic Games", + @"Epic Games", + @"Program Files\EA Games", + @"Program Files\Ubisoft\Ubisoft Game Launcher\games", + }; + + foreach (DriveInfo drive in GetReadyDrives()) + { + foreach (string relative in relativeRoots) + { + string root = Path.Combine(drive.RootDirectory.FullName, relative); + if (Directory.Exists(root)) + { + yield return root; + } + } + } + + // The NVIDIA driver ships NGX DLLs here (usually nvngx_dlssg/_dlssd; base dlss may + // be present depending on driver), so include it as a fallback. + string driverStore = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Windows), + @"System32\DriverStore\FileRepository"); + + if (Directory.Exists(driverStore)) + { + yield return driverStore; + } + } + + private static IEnumerable GetReadyDrives() + { + DriveInfo[] drives; + + try + { + drives = DriveInfo.GetDrives(); + } + catch + { + yield break; + } + + foreach (DriveInfo drive in drives) + { + bool ready; + + try + { + ready = drive.IsReady && drive.DriveType == DriveType.Fixed; + } + catch + { + ready = false; + } + + if (ready) + { + yield return drive; + } + } + } + + // Recursive enumeration that swallows access errors on individual subtrees instead of + // aborting the whole scan (game folders often contain locked/protected directories). + private static IEnumerable SafeEnumerate(string root, string fileName) + { + Queue pending = new(); + pending.Enqueue(root); + + while (pending.Count > 0) + { + string dir = pending.Dequeue(); + + string[] matches = null; + try + { + matches = Directory.GetFiles(dir, fileName); + } + catch + { + // ignored - unreadable directory + } + + if (matches != null) + { + foreach (string match in matches) + { + yield return match; + } + } + + string[] subDirs = null; + try + { + subDirs = Directory.GetDirectories(dir); + } + catch + { + // ignored + } + + if (subDirs != null) + { + foreach (string subDir in subDirs) + { + pending.Enqueue(subDir); + } + } + } + } + + private static Version GetFileVersion(string path) + { + try + { + FileVersionInfo info = FileVersionInfo.GetVersionInfo(path); + + if (info.FileMajorPart != 0 || info.FileMinorPart != 0 || info.FileBuildPart != 0 || info.FilePrivatePart != 0) + { + return new Version(info.FileMajorPart, info.FileMinorPart, info.FileBuildPart, info.FilePrivatePart); + } + } + catch + { + // ignored - treat as unknown version + } + + return null; + } + } +} diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssIntegration.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssIntegration.cs new file mode 100644 index 000000000..99d4b3758 --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssIntegration.cs @@ -0,0 +1,165 @@ +using Ryujinx.Common.Logging; +using System; + +namespace Ryujinx.Graphics.Vulkan.Dlss +{ + /// + /// Central on/off switch and shared constants for the experimental DLSS integration. + /// + /// Everything DLSS-related (loading Streamline, the extra Vulkan device extensions/features, + /// slSetVulkanInfo, the present-time evaluate) is gated on the RYUJINX_DLSS environment + /// variable. When it is unset the default code path is byte-identical to upstream, so a normal + /// launch never loads Streamline nor changes how the Vulkan device is created. + /// + public static class DlssIntegration + { + private static readonly bool _enabled = IsTruthy(Environment.GetEnvironmentVariable("RYUJINX_DLSS")); + + /// True when the user opted into DLSS via RYUJINX_DLSS=1. + public static bool IsEnabled => _enabled; + + /// + /// Whether DLSS is fed the median-filtered motion field (outliers removed) instead of the raw + /// optical-flow field. On by default; set RYUJINX_DLSS_MV_FILTER=0 to A/B against the raw field. + /// The instrumentation logs both raw and filtered metrics regardless of which one is used. + /// + public static readonly bool MvFilterEnabled = + Environment.GetEnvironmentVariable("RYUJINX_DLSS_MV_FILTER") is not ("0" or "false" or "off"); + + /// + /// Whether the per-frame motion-field metrics (RMS/outliers) are accumulated and logged. OFF by + /// default: the stats use hundreds of thousands of atomic adds per frame, which is fine for a + /// measurement run but costs frame time, so it is a dev tool gated on RYUJINX_DLSS_METRICS=1. + /// + public static readonly bool MetricsEnabled = + Environment.GetEnvironmentVariable("RYUJINX_DLSS_METRICS") is "1" or "true" or "on"; + + /// + /// Diagnostic flag (RYUJINX_DLSS_FORCE_DLSS=1): force pure DLSS every frame — never hand off to + /// the spatial (NIS) fallback and never reset history mid-scene. Lets a camera pan be judged on + /// DLSS's motion reconstruction in isolation, with no DLSS<->NIS pumping or history churn. + /// OFF by default (the build is byte-identical); intended only for A/B measurement runs. + /// + public static readonly bool ForceDlss = + Environment.GetEnvironmentVariable("RYUJINX_DLSS_FORCE_DLSS") is "1" or "true" or "on"; + + /// + /// Extra Vulkan device extensions NGX/DLSS needs, on top of what Ryujinx already enables + /// (VK_KHR_push_descriptor is already in the desirable list). Enabled only when supported. + /// + public static readonly string[] DeviceExtensions = + { + "VK_NVX_binary_import", + "VK_NVX_image_view_handle", + "VK_KHR_buffer_device_address", + "VK_EXT_buffer_device_address", + }; + + /// + /// Injected by the host (which owns GraphicsConfig.ResScale) so the Vulkan backend can read + /// and drive the guest resolution scale without referencing the GPU project. Used to make the + /// internal render resolution match the chosen DLSS mode. + /// + public static Func ResolutionScaleGetter; + public static Action ResolutionScaleSetter; + + /// The user's configured resolution scale, captured before a mode preset is applied. + public static float UserBaseResScale = 1f; + + private static bool _modeLogged; + + /// + /// The selected DLSS quality mode (RYUJINX_DLSS_MODE), defaulting to Native. Under the + /// "no automatic piloting" design this only chooses the one-shot Resolution Scale preset + /// applied by ; DLSS itself picks the matching preset + /// from whatever internal resolution results, and never steers the resolution at runtime. + /// + public static readonly DlssQualityMode Mode = ParseQualityMode(Environment.GetEnvironmentVariable("RYUJINX_DLSS_MODE")) ?? DlssQualityMode.Native; + + /// + /// Render-resolution factor for a quality mode, relative to the user's configured scale. + /// Native = 1 (untouched); the upscale modes render below it so DLSS reconstructs upward. + /// + public static float RenderScaleFactor(DlssQualityMode mode) + { + return mode switch + { + DlssQualityMode.Quality => 0.667f, // 1 / 1.5 + DlssQualityMode.Balanced => 0.581f, // 1 / 1.72 + DlssQualityMode.Performance => 0.5f, // 1 / 2 + _ => 1f, // Native: leave the user's resolution alone. + }; + } + + /// + /// Applies the selected quality mode's render-scale preset to the guest resolution by writing + /// GraphicsConfig.ResScale through the host-injected setter -- equivalent to the user nudging + /// their Resolution Scale slider. The host calls this at config time (not per frame), right + /// after it resets the scale to the configured value, so it always derives from a clean base + /// and never compounds. This deliberately avoids the runtime resolution-driving that previously + /// destabilized the renderer. No-op for Native or when DLSS is off. + /// + public static void ApplyModeResolutionScale() + { + if (!_enabled || ResolutionScaleGetter == null || ResolutionScaleSetter == null) + { + return; + } + + float baseScale = ResolutionScaleGetter(); + if (baseScale <= 0f) + { + baseScale = 1f; + } + + UserBaseResScale = baseScale; + + float factor = RenderScaleFactor(Mode); + if (factor == 1f) + { + return; + } + + float scaled = baseScale * factor; + ResolutionScaleSetter(scaled); + + if (!_modeLogged) + { + _modeLogged = true; + Logger.Info?.Print(LogClass.Gpu, + $"DLSS: quality mode {Mode} -> resolution scale {baseScale:0.00} x {factor:0.00} = {scaled:0.00}."); + } + } + + private static DlssQualityMode? ParseQualityMode(string value) + { + return value?.Trim().ToLowerInvariant() switch + { + "native" or "dlaa" => DlssQualityMode.Native, + "quality" => DlssQualityMode.Quality, + "balanced" => DlssQualityMode.Balanced, + "performance" or "perf" => DlssQualityMode.Performance, + _ => null, + }; + } + + private static bool IsTruthy(string value) + { + return value is "1" or "true" or "TRUE" or "True"; + } + } + + /// + /// User-facing DLSS quality mode. Under the "no automatic piloting" design these act only as a + /// one-shot Resolution Scale preset (see ): + /// Native leaves the user's resolution alone (DLSS just anti-aliases it), the others render below + /// it so DLSS upscales for more FPS. DLSS never drives the resolution at runtime. + /// + public enum DlssQualityMode + { + Native, + Quality, + Balanced, + Performance, + } +} diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssJitter.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssJitter.cs new file mode 100644 index 000000000..c40fd8e8a --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssJitter.cs @@ -0,0 +1,63 @@ +using Ryujinx.Graphics.GAL; +using System; + +namespace Ryujinx.Graphics.Vulkan.Dlss +{ + /// + /// Generates the deterministic Halton(2,3) sub-pixel jitter for DLSS "Mode B" and publishes the + /// NEXT frame's offset to the cross-layer , where the GPU layer reads + /// it to shift the resolution-scaled viewport. The value the present actually hands to DLSS is read + /// back from the frame's tag (), not from here, so it always + /// matches the jitter baked into that exact frame regardless of frame-queue depth. + /// + /// Gated on RYUJINX_DLSS_JITTER=1; off => DlssJitterState stays disabled and the path is identical. + /// + public static class DlssJitter + { + private static readonly bool _flag = + Environment.GetEnvironmentVariable("RYUJINX_DLSS_JITTER") is "1" or "true" or "on"; + + public static bool Enabled => _flag && DlssIntegration.IsEnabled; + + private const uint SequenceLength = 16; // a short Halton phase set, classic for DLSS + + private static uint _index; + + /// + /// Publishes the next frame's jitter offset to the GPU layer. Called once per present; the + /// offset takes effect on the next guest frame's scaled viewport. No-op (disabled) when off. + /// + public static void Advance() + { + if (!Enabled) + { + DlssJitterState.Enabled = false; + DlssJitterState.OffsetX = 0f; + DlssJitterState.OffsetY = 0f; + + return; + } + + _index = _index % SequenceLength + 1; // 1..N (Halton is undefined at 0) + DlssJitterState.Enabled = true; + DlssJitterState.OffsetX = Halton(_index, 2) - 0.5f; + DlssJitterState.OffsetY = Halton(_index, 3) - 0.5f; + } + + private static float Halton(uint index, uint radix) + { + float result = 0f; + float fraction = 1f; + uint i = index; + + while (i > 0) + { + fraction /= radix; + result += fraction * (i % radix); + i /= radix; + } + + return result; + } + } +} diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs new file mode 100644 index 000000000..28fc4303a --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs @@ -0,0 +1,644 @@ +using Ryujinx.Common; +using Ryujinx.Common.Logging; +using Ryujinx.Graphics.GAL; +using Ryujinx.Graphics.Shader; +using Ryujinx.Graphics.Shader.Translation; +using Silk.NET.Vulkan; +using System; +using Extents2D = Ryujinx.Graphics.GAL.Extents2D; +using Format = Ryujinx.Graphics.GAL.Format; +using SamplerCreateInfo = Ryujinx.Graphics.GAL.SamplerCreateInfo; +using VkFormat = Silk.NET.Vulkan.Format; + +namespace Ryujinx.Graphics.Vulkan.Dlss +{ + /// + /// Drives DLSS-SR at present time: upscales the game's framebuffer into a full-resolution target + /// via slEvaluateFeature, then blits that target to the swapchain with the usual scRGB/HDR + /// tone-map. Motion vectors are estimated with a compute-shader optical flow between the previous + /// and current frame (no dedicated optical-flow queue needed); depth is still a zeroed dummy. + /// Everything is gated by ; on any failure TryRun returns + /// false so the caller falls back to the normal blit/scaling path. + /// + internal sealed class DlssUpscaler : IDisposable + { + private const uint ViewportId = 0; + private const float MaxMotion = 32f; // motion-vector clamp, in render-resolution pixels + private const float SceneChangeHighFraction = 0.85f; // arm a reset only on a near-total change (real scene cut), not fast camera motion + private const float SceneChangeLowFraction = 0.55f; // ... and disarm (hysteresis) once it drops back below this + private const int ResetCooldownFrames = 45; // min frames between resets (~0.75s), so fast-pan blips don't reset repeatedly + private const float MotionFullScale = 0.15f; // motion fraction treated as "fully dynamic" (staticScore -> 0) + private const float StaticEnterRate = 0.05f; // per-frame easing toward static (~0.3s); exit is instant + private const float StaticThreshold = 0.6f; // blended static score above which we hand off to the spatial filter + private const int ModeSwitchHoldFrames = 20; // min frames to hold a DLSS<->spatial state before flipping (kills stop-and-go pumping) + private const int CounterCount = 9; // uints in the motion-pass SSBO: 2 scene-change + 4 raw stats + 3 filtered stats + private const int StatLogInterval = 120; // frames between motion-field metric log lines + + private readonly VulkanRenderer _gd; + private readonly Device _device; + + private TextureView _output; + private TextureView _depth; + private TextureView _motion; + private TextureView _motionFiltered; + private TextureView _prevColor; + private bool _hasPrev; + + // Two host-mapped uints (unexplained-change + confident-motion pixel counts), read back one + // frame late to drive a DLSS history reset on hard full-screen cuts. _wasChanging and + // _framesSinceReset make that reset edge-triggered + rate-limited, so a fade or a fast pan + // resets once instead of every frame (which would leave the image permanently aliased). + private BufferHandle _sceneChangeBuffer; + private bool _wasChanging; + private int _framesSinceReset; + + // Phase 1 instrumentation: a batch of per-frame motion-field RMS/outlier samples, summarised + // to the log every StatLogInterval frames so a held-still scene yields a stable measurement. + private int _statFrames; + private float _statRmsSum; + private float _statRmsMin = float.MaxValue; + private float _statRmsMax; + private float _statOutlierSum; + private float _statRmsSumF; // filtered-field batch accumulators (A/B vs raw) + private float _statOutlierSumF; + + // DLSS Mode B jitter: the offset the just-presented frame was rendered with, and the delta vs + // the previous presented frame, used to de-jitter the motion field so DLSS receives M_real. + private float _lastJitterX; + private float _lastJitterY; + private float _dejitterX; + private float _dejitterY; + private int _jitterLogCount; + + // Hybrid static/dynamic mode: DLSS reconstructs moving content, but degrades to a soft/aliased + // upscale on static menus (no temporal signal). _modeBlend eases toward "static" and, past a + // threshold, TryRun bails so the configured spatial scaling filter (e.g. NIS) draws instead. + private float _modeBlend; + private bool _spatialActive; + private int _framesSinceModeSwitch = ModeSwitchHoldFrames; + private bool _bailedLastFrame; + + private readonly PipelineHelperShader _motionPipeline; + private readonly ShaderCollection _motionProgram; + private readonly ShaderCollection _motionFilterProgram; + private readonly ISampler _sampler; + + private int _inW, _inH, _outW, _outH; + private uint _frame; + private bool _modeLogged; + private bool _modeWarned; + + // DLSS modes from highest quality (smallest upscale) to most aggressive (largest upscale). + private static readonly StreamlineDlss.DlssMode[] ModesByQuality = + { + StreamlineDlss.DlssMode.Dlaa, + StreamlineDlss.DlssMode.MaxQuality, + StreamlineDlss.DlssMode.Balanced, + StreamlineDlss.DlssMode.MaxPerformance, + StreamlineDlss.DlssMode.UltraPerformance, + }; + + public DlssUpscaler(VulkanRenderer gd, Device device) + { + _gd = gd; + _device = device; + + _motionPipeline = new PipelineHelperShader(gd, device); + _motionPipeline.Initialize(); + + byte[] shader = EmbeddedResources.Read("Ryujinx.Graphics.Vulkan/Effects/Shaders/MotionVectors.spv"); + + // current color (b1), previous color (b3), params UBO (b2), output MV image (b0/set3). + ResourceLayout layout = new ResourceLayoutBuilder() + .Add(ResourceStages.Compute, ResourceType.UniformBuffer, 2) + .Add(ResourceStages.Compute, ResourceType.StorageBuffer, 0) + .Add(ResourceStages.Compute, ResourceType.TextureAndSampler, 1) + .Add(ResourceStages.Compute, ResourceType.TextureAndSampler, 3) + .Add(ResourceStages.Compute, ResourceType.Image, 0, true).Build(); + + _sampler = gd.CreateSampler(SamplerCreateInfo.Create(MinFilter.Linear, MagFilter.Linear)); + + _motionProgram = gd.CreateProgramWithMinimalLayout([ + new ShaderSource(shader, ShaderStage.Compute, TargetLanguage.Spirv) + ], layout); + + // Second pass: 3x3 median outlier filter. Two storage images (filtered out b0/set3, raw in + // b1/set3), params UBO (b2), stats SSBO (b0/set1). + byte[] filterShader = EmbeddedResources.Read("Ryujinx.Graphics.Vulkan/Effects/Shaders/MotionFilter.spv"); + ResourceLayout filterLayout = new ResourceLayoutBuilder() + .Add(ResourceStages.Compute, ResourceType.UniformBuffer, 2) + .Add(ResourceStages.Compute, ResourceType.StorageBuffer, 0) + .Add(ResourceStages.Compute, ResourceType.Image, 0, true) + .Add(ResourceStages.Compute, ResourceType.Image, 1, true).Build(); + + _motionFilterProgram = gd.CreateProgramWithMinimalLayout([ + new ShaderSource(filterShader, ShaderStage.Compute, TargetLanguage.Spirv) + ], filterLayout); + + _sceneChangeBuffer = gd.BufferManager.CreateWithHandle(gd, CounterCount * sizeof(uint)); + } + + public bool TryRun( + TextureView input, + CommandBufferScoped cbs, + TextureView dst, + int outW, + int outH, + Extents2D dstRegion, + bool hdr, + float paperWhite, + float peak, + float curve, + float gamma, + float blend, + float whiten) + { + if (!DlssIntegration.IsEnabled || !Streamline.IsInitialized) + { + return false; + } + + if (input.Width == 0 || input.Height == 0) + { + return false; + } + + // Publish the NEXT frame's jitter to the GPU layer, then recover THIS frame's actual offset + // from its tag (set when the guest rendered it). Reading the tag by the presented texture is + // the queue-depth-independent sync: the offset always matches the image. The delta versus the + // previous presented frame de-jitters the motion field so DLSS receives M = M_real. + DlssJitter.Advance(); + bool jitterTagHit = DlssJitterState.TryGet(input, out float frameJitterX, out float frameJitterY); + _dejitterX = frameJitterX - _lastJitterX; + _dejitterY = frameJitterY - _lastJitterY; + _lastJitterX = frameJitterX; + _lastJitterY = frameJitterY; + + // Diagnostic probe (first few frames): tagHit=false means the carrier did not match this + // texture (DLSS got jitter 0); tagHit=true with a non-zero frame value means the offset + // reached DLSS and any remaining shimmer is a sign/convention issue, not a plumbing one. + if (DlssJitter.Enabled && _jitterLogCount < 5) + { + _jitterLogCount++; + Logger.Info?.Print(LogClass.Gpu, + $"DLSS jitter probe: tagHit={jitterTagHit} frame=({frameJitterX:0.000},{frameJitterY:0.000}) " + + $"galNext=({DlssJitterState.OffsetX:0.000},{DlssJitterState.OffsetY:0.000}) dejitter=({_dejitterX:0.000},{_dejitterY:0.000})."); + } + + // The swapchain (outW x outH) can be letterboxed/pillarboxed relative to the guest's aspect + // ratio. Feeding that raw size to DLSS as the output makes a different-aspect internal exceed + // DLSS's max render rect, so slEvaluateFeature fails every frame -- and NGX leaks on each + // failed evaluate, eventually losing the device. Derive a content size at the input's aspect + // instead; the final blit still positions it into dstRegion (which carries the letterbox). + double inAspect = (double)input.Width / input.Height; + int contentW = outW; + int contentH = (int)Math.Round(outW / inAspect); + if (contentH > outH) + { + contentH = outH; + contentW = (int)Math.Round(outH * inAspect); + } + contentW = Math.Max(contentW, 1); + contentH = Math.Max(contentH, 1); + + // DLSS works at the resolution the user chose (their Resolution Scale, optionally preset + // once by the quality mode); we never drive it from here. DLSS-SR only makes sense when + // upscaling (internal smaller than the on-screen content), and DLSS rejects the evaluate + // unless the input is within the chosen preset's dynamic render range, so pick the preset + // that fits this internal resolution and fall back otherwise. + if (input.Width >= contentW || input.Height >= contentH) + { + return false; + } + + if (!TryPickMode(input.Width, input.Height, contentW, contentH, out StreamlineDlss.DlssMode mode)) + { + if (!_modeWarned) + { + _modeWarned = true; + Logger.Warning?.Print(LogClass.Gpu, + $"DLSS: no preset fits internal {input.Width}x{input.Height} -> {contentW}x{contentH} " + + "(a global NVIDIA DLSS override may be forcing a fixed mode); falling back to normal scaling."); + } + + return false; + } + + int dlssOutW = contentW; + int dlssOutH = contentH; + + EnsureResources(input, dlssOutW, dlssOutH, cbs); + + if (!StreamlineDlss.SetOptions(ViewportId, mode, (uint)dlssOutW, (uint)dlssOutH, hdr)) + { + return false; + } + + if (!_modeLogged) + { + _modeLogged = true; + Logger.Info?.Print(LogClass.Gpu, $"DLSS: using mode {mode} for internal {input.Width}x{input.Height} -> {dlssOutW}x{dlssOutH} (content {contentW}x{contentH})."); + } + + // Estimate this frame's motion vectors (previous vs current color) into _motion. + // On the very first frame there is no previous frame, so reset DLSS history. We also + // reset on a detected hard scene cut (page/menu swap): last frame's motion pass counts + // the pixels it could not explain, and if most of the screen changed in place we clear + // DLSS history so the stale-history ghosting on the cut resolves in ~1 frame. + ReadCounters(out uint unexplained, out uint motion, + out uint statSamples, out _, out uint sumMagSqFx, out uint outliers, + out _, out uint sumMagSqFxF, out uint outliersF); + int total = input.Width * input.Height; + float changedFraction = total != 0 ? (float)unexplained / total : 0f; + float motionFraction = total != 0 ? (float)motion / total : 0f; + + // Phase 1 instrumentation: fold this frame's motion-field statistics into a batch and log a + // summary every StatLogInterval frames. RMS is the noise floor of the vectors handed to + // DLSS: held still (static), the true motion is 0, so RMS < ~0.5px means the field is clean + // enough for temporal reconstruction (Mode B viable); higher means it is too noisy. + if (statSamples > 0) + { + float rms = MathF.Sqrt(MathF.Max((sumMagSqFx / 1024f) / statSamples, 0f)); + float outlierPct = 100f * outliers / statSamples; + float rmsF = MathF.Sqrt(MathF.Max((sumMagSqFxF / 1024f) / statSamples, 0f)); + float outlierPctF = 100f * outliersF / statSamples; + + _statFrames++; + _statRmsSum += rms; + _statRmsMin = MathF.Min(_statRmsMin, rms); + _statRmsMax = MathF.Max(_statRmsMax, rms); + _statOutlierSum += outlierPct; + _statRmsSumF += rmsF; + _statOutlierSumF += outlierPctF; + + if (_statFrames >= StatLogInterval) + { + Logger.Info?.Print(LogClass.Gpu, + $"DLSS motion-field [{_statFrames}f]: raw RMS={_statRmsSum / _statFrames:0.000}px outliers={_statOutlierSum / _statFrames:0.0}% " + + $"| filtered RMS={_statRmsSumF / _statFrames:0.000}px outliers={_statOutlierSumF / _statFrames:0.0}% " + + $"(raw min {_statRmsMin:0.000}/max {_statRmsMax:0.000}, {statSamples} samples/f, filter {(DlssIntegration.MvFilterEnabled ? "ON" : "OFF")})."); + + _statFrames = 0; + _statRmsSum = 0f; + _statRmsMin = float.MaxValue; + _statRmsMax = 0f; + _statOutlierSum = 0f; + _statRmsSumF = 0f; + _statOutlierSumF = 0f; + } + } + + // Hybrid static/dynamic decision: DLSS has no temporal signal to reconstruct a static scene + // (a menu) and degrades to a soft/aliased upscale, so above a smoothed static score we bail + // to the configured spatial scaling filter (set it to NIS). Ease into static slowly (~0.3s) + // but leave it instantly, so any motion returns to DLSS without lag. + float staticScore = Math.Clamp(1f - motionFraction / MotionFullScale, 0f, 1f); + _modeBlend = staticScore < _modeBlend + ? staticScore + : _modeBlend + (staticScore - _modeBlend) * StaticEnterRate; + // Debounce the DLSS<->spatial switch: hold the current state for a minimum number of frames + // so stop-and-go camera motion can't make it flip-flop every few frames (which pumps the + // image between DLSS and the NIS fallback). A brief blip in either direction is ignored. + // Diagnostic: RYUJINX_DLSS_FORCE_DLSS pins pure DLSS (never hand off to the spatial filter), + // which also kills the resume-reset, so a camera pan reflects DLSS's motion reconstruction + // alone -- no DLSS<->NIS pumping, no history churn. + bool wantSpatial = !DlssIntegration.ForceDlss && _hasPrev && _modeBlend >= StaticThreshold; + _framesSinceModeSwitch++; + if (wantSpatial != _spatialActive && _framesSinceModeSwitch >= ModeSwitchHoldFrames) + { + _spatialActive = wantSpatial; + _framesSinceModeSwitch = 0; + } + + bool spatial = _spatialActive; + bool resuming = _bailedLastFrame && !spatial; + + if (spatial && !_bailedLastFrame) + { + Logger.Info?.Print(LogClass.Gpu, $"DLSS: -> spatial fallback (static, modeBlend={_modeBlend:0.00})."); + } + else if (resuming) + { + Logger.Info?.Print(LogClass.Gpu, "DLSS: -> DLSS (motion resumed, history reset)."); + } + + // Reset DLSS history only on the rising edge of a *full-screen* change (loading->game, an + // area transition, opening a full menu), with hysteresis + a cooldown. A localized UI change + // over a paused scene (an inventory tab, or ambient animation while standing still) is left + // alone on purpose: it cannot be told apart from real content, and resetting on every such + // frame stops DLSS ever accumulating, leaving the image permanently aliased (pixelated menu). + bool changing = _wasChanging + ? changedFraction >= SceneChangeLowFraction + : changedFraction >= SceneChangeHighFraction; + + _framesSinceReset++; + bool sceneCut = !DlssIntegration.ForceDlss && _hasPrev && changing && !_wasChanging && _framesSinceReset >= ResetCooldownFrames; + _wasChanging = changing; + + // ... and when resuming DLSS after a spatial stretch, since its history is stale by then. + bool reset = !_hasPrev || sceneCut || resuming; + if (reset) + { + _framesSinceReset = 0; + } + + if (sceneCut) + { + Logger.Info?.Print(LogClass.Gpu, + $"DLSS: history reset (changed={100f * unexplained / total:0.#}% motion={100f * motion / total:0.#}%)."); + } + + // Clear the counters for this frame's pass. The buffer is host-mapped, so this CPU write + // lands before the command buffer is submitted, i.e. before the compute pass runs. + Span zero = stackalloc uint[] { 0u, 0u, 0u, 0u, 0u, 0u, 0u, 0u, 0u }; + _gd.BufferManager.SetData(_sceneChangeBuffer, 0, zero); + + if (_hasPrev) + { + RunMotionPass(input, cbs); + RunMotionFilterPass(input, cbs); + } + + // Static scene: the motion pass above kept the counters and previous-frame color live; + // now bail so Window.Present falls back to the configured spatial scaling filter (NIS). + if (spatial) + { + CopyToPrev(input, cbs); + _hasPrev = true; + _bailedLastFrame = true; + + return false; + } + + _bailedLastFrame = false; + + StreamlineDlss.DlssTexture inTex = Describe(input, cbs); + StreamlineDlss.DlssTexture outTex = Describe(_output, cbs); + StreamlineDlss.DlssTexture depthTex = Describe(_depth, cbs); + TextureView mvSource = DlssIntegration.MvFilterEnabled ? _motionFiltered : _motion; + StreamlineDlss.DlssTexture mvTex = Describe(mvSource, cbs); + + bool ok = StreamlineDlss.Evaluate( + (IntPtr)cbs.CommandBuffer.Handle, + ViewportId, + _frame++, + reset, + frameJitterX, + frameJitterY, + in inTex, + in outTex, + in depthTex, + in mvTex); + + if (!ok) + { + return false; + } + + // DLSS output is already at the final resolution; blit it to the swapchain applying the + // same scRGB/HDR tone-map as the normal present path. + _gd.HelperShader.BlitColor( + _gd, + cbs, + _output, + dst, + new Extents2D(0, 0, _output.Width, _output.Height), + dstRegion, + true, + true, + hdr, + paperWhite, + peak, + curve, + gamma, + blend, + whiten); + + // Keep this frame's color as the "previous" frame for next time's motion estimate. + CopyToPrev(input, cbs); + _hasPrev = true; + + return true; + } + + private void RunMotionPass(TextureView input, CommandBufferScoped cbs) + { + _motionPipeline.SetCommandBuffer(cbs); + _motionPipeline.SetProgram(_motionProgram); + _motionPipeline.SetTextureAndSampler(ShaderStage.Compute, 1, input, _sampler); + _motionPipeline.SetTextureAndSampler(ShaderStage.Compute, 3, _prevColor, _sampler); + + // 8 floats = a full std140 2x vec4 block (width/height/maxMotion/metrics + dejitterX/Y + 2 pad). + ReadOnlySpan p = [input.Width, input.Height, MaxMotion, DlssIntegration.MetricsEnabled ? 1f : 0f, _dejitterX, _dejitterY, 0f, 0f]; + using ScopedTemporaryBuffer buffer = _gd.BufferManager.ReserveOrCreate(_gd, cbs, p.Length * sizeof(float)); + buffer.Holder.SetDataUnchecked(buffer.Offset, p); + + _motionPipeline.SetUniformBuffers([new BufferAssignment(2, buffer.Range)]); + _motionPipeline.SetStorageBuffers([new BufferAssignment(0, new BufferRange(_sceneChangeBuffer, 0, CounterCount * sizeof(uint), true))]); + _motionPipeline.SetImage(0, _motion.GetImageView()); + + _motionPipeline.DispatchCompute((input.Width + 15) / 16, (input.Height + 15) / 16, 1); + _motionPipeline.ComputeBarrier(); + _motionPipeline.Finish(); + } + + private void RunMotionFilterPass(TextureView input, CommandBufferScoped cbs) + { + // Second compute pass: 3x3 median outlier filter on the raw motion field. Reads _motion + // (made visible by the previous pass's ComputeBarrier) and writes _motionFiltered, then + // re-measures the field statistics on the filtered output for the raw-vs-filtered A/B log. + _motionPipeline.SetCommandBuffer(cbs); + _motionPipeline.SetProgram(_motionFilterProgram); + + // 8 floats = a full std140 2x vec4 block (width/height/maxMotion/metrics + dejitterX/Y + 2 pad). + ReadOnlySpan p = [input.Width, input.Height, MaxMotion, DlssIntegration.MetricsEnabled ? 1f : 0f, _dejitterX, _dejitterY, 0f, 0f]; + using ScopedTemporaryBuffer buffer = _gd.BufferManager.ReserveOrCreate(_gd, cbs, p.Length * sizeof(float)); + buffer.Holder.SetDataUnchecked(buffer.Offset, p); + + _motionPipeline.SetUniformBuffers([new BufferAssignment(2, buffer.Range)]); + _motionPipeline.SetStorageBuffers([new BufferAssignment(0, new BufferRange(_sceneChangeBuffer, 0, CounterCount * sizeof(uint), true))]); + _motionPipeline.SetImage(0, _motionFiltered.GetImageView()); + _motionPipeline.SetImage(1, _motion.GetImageView()); + + _motionPipeline.DispatchCompute((input.Width + 15) / 16, (input.Height + 15) / 16, 1); + _motionPipeline.ComputeBarrier(); + _motionPipeline.Finish(); + } + + /// + /// Reads back the previous frame's scene-change counters (unexplained-change and confident-motion + /// pixel counts) from the host-mapped buffer. One frame of latency is fine: scene-cut ghosting + /// spans several frames, so resetting one frame late still clears it, and reading last frame's + /// value avoids a GPU stall. + /// + private void ReadCounters(out uint changed, out uint motion, + out uint statSamples, out uint sumMagFx, out uint sumMagSqFx, out uint outliers, + out uint sumMagFxF, out uint sumMagSqFxF, out uint outliersF) + { + using PinnedSpan data = _gd.BufferManager.GetData(_sceneChangeBuffer, 0, CounterCount * sizeof(uint)); + ReadOnlySpan bytes = data.Get(); + + changed = bytes.Length >= 1 * sizeof(uint) ? BitConverter.ToUInt32(bytes[(0 * sizeof(uint))..]) : 0u; + motion = bytes.Length >= 2 * sizeof(uint) ? BitConverter.ToUInt32(bytes[(1 * sizeof(uint))..]) : 0u; + statSamples = bytes.Length >= 3 * sizeof(uint) ? BitConverter.ToUInt32(bytes[(2 * sizeof(uint))..]) : 0u; + sumMagFx = bytes.Length >= 4 * sizeof(uint) ? BitConverter.ToUInt32(bytes[(3 * sizeof(uint))..]) : 0u; + sumMagSqFx = bytes.Length >= 5 * sizeof(uint) ? BitConverter.ToUInt32(bytes[(4 * sizeof(uint))..]) : 0u; + outliers = bytes.Length >= 6 * sizeof(uint) ? BitConverter.ToUInt32(bytes[(5 * sizeof(uint))..]) : 0u; + sumMagFxF = bytes.Length >= 7 * sizeof(uint) ? BitConverter.ToUInt32(bytes[(6 * sizeof(uint))..]) : 0u; + sumMagSqFxF = bytes.Length >= 8 * sizeof(uint) ? BitConverter.ToUInt32(bytes[(7 * sizeof(uint))..]) : 0u; + outliersF = bytes.Length >= 9 * sizeof(uint) ? BitConverter.ToUInt32(bytes[(8 * sizeof(uint))..]) : 0u; + } + + private void CopyToPrev(TextureView input, CommandBufferScoped cbs) + { + ImageSubresourceLayers layers = new() + { + AspectMask = ImageAspectFlags.ColorBit, + MipLevel = 0, + BaseArrayLayer = 0, + LayerCount = 1, + }; + + ImageCopy region = new() + { + SrcSubresource = layers, + DstSubresource = layers, + Extent = new Extent3D((uint)input.Width, (uint)input.Height, 1), + }; + + _gd.Api.CmdCopyImage( + cbs.CommandBuffer, + input.GetImage().Get(cbs).Value, + ImageLayout.General, + _prevColor.GetImage().Get(cbs).Value, + ImageLayout.General, + 1, + in region); + } + + private StreamlineDlss.DlssTexture Describe(TextureView tex, CommandBufferScoped cbs) + { + return new StreamlineDlss.DlssTexture + { + Image = (IntPtr)tex.GetImage().Get(cbs).Value.Handle, + View = (IntPtr)tex.GetImageView().Get(cbs).Value.Handle, + NativeFormat = (uint)tex.VkFormat, + Layout = (uint)ImageLayout.General, // Ryujinx keeps its textures in General. + Width = (uint)tex.Width, + Height = (uint)tex.Height, + }; + } + + private static bool TryPickMode(int inW, int inH, int outW, int outH, out StreamlineDlss.DlssMode mode) + { + foreach (StreamlineDlss.DlssMode candidate in ModesByQuality) + { + if (StreamlineDlss.GetRenderRange(candidate, (uint)outW, (uint)outH, out uint minW, out uint minH, out uint maxW, out uint maxH) && + inW >= minW && inW <= maxW && inH >= minH && inH <= maxH) + { + mode = candidate; + + return true; + } + } + + mode = StreamlineDlss.DlssMode.MaxPerformance; + + return false; + } + + private void EnsureResources(TextureView input, int outW, int outH, CommandBufferScoped cbs) + { + if (_output != null && _inW == input.Width && _inH == input.Height && _outW == outW && _outH == outH) + { + return; + } + + _output?.Dispose(); + _depth?.Dispose(); + _motion?.Dispose(); + _motionFiltered?.Dispose(); + _prevColor?.Dispose(); // was leaked on every reallocation (dynamic-resolution games churn this) + + _inW = input.Width; + _inH = input.Height; + _outW = outW; + _outH = outH; + + _output = _gd.CreateTexture(MakeInfo(input.Info, outW, outH, input.Info.Format, input.Info.BytesPerPixel)) as TextureView; + _depth = _gd.CreateTexture(MakeInfo(input.Info, input.Width, input.Height, Format.D32Float, 4)) as TextureView; + _motion = _gd.CreateTexture(MakeInfo(input.Info, input.Width, input.Height, Format.R16G16Float, 4)) as TextureView; + _motionFiltered = _gd.CreateTexture(MakeInfo(input.Info, input.Width, input.Height, Format.R16G16Float, 4)) as TextureView; + _prevColor = _gd.CreateTexture(MakeInfo(input.Info, input.Width, input.Height, input.Info.Format, input.Info.BytesPerPixel)) as TextureView; + _hasPrev = false; + + ClearResources(cbs); + } + + private static TextureCreateInfo MakeInfo(TextureCreateInfo b, int w, int h, Format format, int bytesPerPixel) + { + return new TextureCreateInfo( + w, + h, + 1, + 1, + 1, + 1, + 1, + bytesPerPixel, + format, + b.DepthStencilMode, + Target.Texture2D, + b.SwizzleR, + b.SwizzleG, + b.SwizzleB, + b.SwizzleA); + } + + private void ClearResources(CommandBufferScoped cbs) + { + // Zero the output, the previous-frame color and the depth/motion buffers so the first + // frame reads defined data (depth stays zeroed - no real depth in this integration). + ImageSubresourceRange colorRange = new() + { + AspectMask = ImageAspectFlags.ColorBit, + BaseMipLevel = 0, + LevelCount = 1, + BaseArrayLayer = 0, + LayerCount = 1, + }; + + ImageSubresourceRange depthRange = new() + { + AspectMask = ImageAspectFlags.DepthBit, + BaseMipLevel = 0, + LevelCount = 1, + BaseArrayLayer = 0, + LayerCount = 1, + }; + + ClearColorValue zeroColor = default; + _gd.Api.CmdClearColorImage(cbs.CommandBuffer, _output.GetImage().Get(cbs).Value, ImageLayout.General, in zeroColor, 1, in colorRange); + _gd.Api.CmdClearColorImage(cbs.CommandBuffer, _motion.GetImage().Get(cbs).Value, ImageLayout.General, in zeroColor, 1, in colorRange); + _gd.Api.CmdClearColorImage(cbs.CommandBuffer, _motionFiltered.GetImage().Get(cbs).Value, ImageLayout.General, in zeroColor, 1, in colorRange); + _gd.Api.CmdClearColorImage(cbs.CommandBuffer, _prevColor.GetImage().Get(cbs).Value, ImageLayout.General, in zeroColor, 1, in colorRange); + + ClearDepthStencilValue zeroDepth = new() { Depth = 0f, Stencil = 0 }; + _gd.Api.CmdClearDepthStencilImage(cbs.CommandBuffer, _depth.GetImage().Get(cbs).Value, ImageLayout.General, in zeroDepth, 1, in depthRange); + } + + public void Dispose() + { + _output?.Dispose(); + _depth?.Dispose(); + _motion?.Dispose(); + _motionFiltered?.Dispose(); + _prevColor?.Dispose(); + _motionProgram?.Dispose(); + _motionFilterProgram?.Dispose(); + _motionPipeline?.Dispose(); + _sampler?.Dispose(); + _gd.BufferManager.Delete(_sceneChangeBuffer); + } + } +} diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/Streamline.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/Streamline.cs new file mode 100644 index 000000000..0567d7329 --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/Streamline.cs @@ -0,0 +1,372 @@ +using Ryujinx.Common.Logging; +using System; +using System.IO; +using System.Runtime.InteropServices; + +namespace Ryujinx.Graphics.Vulkan.Dlss +{ + /// + /// Minimal managed interop for NVIDIA Streamline (MIT) - just enough to initialize the + /// SDK and ask "is DLSS supported on this machine?". + /// + /// Clean-room note: every type and signature in this file is OUR code. The struct layouts + /// and enum values are re-declared from the public MIT-licensed Streamline headers + /// (sl_struct.h, sl_core_types.h, sl_result.h, sl_appidentity.h, sl_device_wrappers.h, + /// sl_version.h). No proprietary NVIDIA SDK is copied. The proprietary nvngx_dlss.dll is + /// never shipped - it is located on the user's machine by . + /// + /// ABI: the structs below mirror the x64 C++ layout exactly (MSVC default packing). C# blittable + /// fields use the same natural alignment, so offsets match field-for-field: + /// - C++ `bool` -> (1 byte, NOT marshalled bool which is 4) + /// - C++ enums -> (underlying type uint32_t) + /// - C++ `size_t` -> (8 bytes on x64) + /// - C++ pointers -> + /// + public static class Streamline + { + private const string InterposerName = "sl.interposer"; + private const string InterposerDll = "sl.interposer.dll"; + + // sl::kSDKVersion for the v2.12.0 SDK: (major<<48)|(minor<<32)|(patch<<16)|0xfedc. + // Expressed as the formula (not a transcribed literal) so it stays self-evidently correct. + private const ulong KSdkVersion = ((ulong)2 << 48) | ((ulong)12 << 32) | ((ulong)0 << 16) | 0xfedc; + + // sl::Feature (uint32_t) - sl_consts.h + public const uint FeatureDLSS = 0; + public const uint FeatureNIS = 2; + public const uint FeatureDLSS_G = 1000; + public const uint FeatureDLSS_RR = 1001; + + // sl::RenderAPI (uint32_t) - sl_device_wrappers.h + private const uint RenderApiVulkan = 2; + + // sl::EngineType (uint32_t) - sl_appidentity.h + private const uint EngineTypeCustom = 0; + + // sl::LogLevel (uint32_t) - sl_core_types.h + private const uint LogLevelDefault = 1; + private const uint LogLevelVerbose = 2; + + // sl::kStructVersion* - sl_struct.h + private const uint StructVersion1 = 1; + private const uint StructVersion3 = 3; + + /// sl::Result - sl_result.h. eOk == 0; everything else is an error/warning. + public enum Result + { + Ok = 0, + ErrorIO, + ErrorDriverOutOfDate, + ErrorOSOutOfDate, + ErrorOSDisabledHWS, + ErrorDeviceNotCreated, + ErrorNoSupportedAdapterFound, + ErrorAdapterNotSupported, + ErrorNoPlugins, + ErrorVulkanAPI, + ErrorDXGIAPI, + ErrorD3DAPI, + ErrorNRDAPI, + ErrorNVAPI, + ErrorReflexAPI, + ErrorNGXFailed, + ErrorJSONParsing, + ErrorMissingProxy, + ErrorMissingResourceState, + ErrorInvalidIntegration, + ErrorMissingInputParameter, + ErrorNotInitialized, + ErrorComputeFailed, + ErrorInitNotCalled, + ErrorExceptionHandler, + ErrorInvalidParameter, + ErrorMissingConstants, + ErrorDuplicatedConstants, + ErrorMissingOrInvalidAPI, + ErrorCommonConstantsMissing, + ErrorUnsupportedInterface, + ErrorFeatureMissing, + ErrorFeatureNotSupported, + ErrorFeatureMissingHooks, + ErrorFeatureFailedToLoad, + ErrorFeatureWrongPriority, + ErrorFeatureMissingDependency, + ErrorFeatureManagerInvalidState, + ErrorInvalidState, + WarnOutOfVRAM, + } + + // sl::StructType - a 16-byte GUID. Alignment 4. + [StructLayout(LayoutKind.Sequential)] + private struct SlStructType + { + public uint Data1; + public ushort Data2; + public ushort Data3; + public byte B0, B1, B2, B3, B4, B5, B6, B7; + } + + // sl::Preferences (derives sl::BaseStructure) - sl_core_types.h. sizeof == 144 on x64. + [StructLayout(LayoutKind.Sequential)] + private struct Preferences + { + // --- BaseStructure header (32 bytes) --- + public IntPtr Next; // 0 + public SlStructType StructType; // 8 + public nuint StructVersion; // 24 + // --- Preferences body --- + public byte ShowConsole; // 32 (C++ bool) + public uint LogLevel; // 36 + public IntPtr PathsToPlugins; // 40 (const wchar_t**) + public uint NumPathsToPlugins; // 48 + public IntPtr PathToLogsAndData; // 56 (const wchar_t*) + public IntPtr AllocateCallback; // 64 + public IntPtr ReleaseCallback; // 72 + public IntPtr LogMessageCallback; // 80 + public ulong Flags; // 88 (PreferenceFlags : uint64_t) + public IntPtr FeaturesToLoad; // 96 (const Feature*) + public uint NumFeaturesToLoad; // 104 + public uint ApplicationId; // 108 + public uint Engine; // 112 (EngineType) + public IntPtr EngineVersion; // 120 (const char*) + public IntPtr ProjectId; // 128 (const char*) + public uint RenderApi; // 136 (RenderAPI) + } + + // sl::AdapterInfo (derives sl::BaseStructure) - sl_core_types.h. sizeof == 56 on x64. + // Kept for the per-device check (Phase 0 uses the null/general check first). + [StructLayout(LayoutKind.Sequential)] + private struct AdapterInfo + { + public IntPtr Next; // 0 + public SlStructType StructType; // 8 + public nuint StructVersion; // 24 + public IntPtr DeviceLuid; // 32 (uint8_t*) + public uint DeviceLuidSizeInBytes; // 40 + public IntPtr VkPhysicalDevice; // 48 (void*) + } + + // sl::VulkanInfo (derives sl::BaseStructure, kStructVersion3) - sl_helpers_vk.h. sizeof == 96 on x64. + // Tells SL about a device/instance created WITHOUT SL's vkCreate* proxies (our case). + [StructLayout(LayoutKind.Sequential)] + private struct VulkanInfo + { + public IntPtr Next; // 0 + public SlStructType StructType; // 8 + public nuint StructVersion; // 24 + public IntPtr Device; // 32 (VkDevice) + public IntPtr Instance; // 40 (VkInstance) + public IntPtr PhysicalDevice; // 48 (VkPhysicalDevice) + public uint ComputeQueueIndex; // 56 + public uint ComputeQueueFamily; // 60 + public uint GraphicsQueueIndex; // 64 + public uint GraphicsQueueFamily; // 68 + public uint OpticalFlowQueueIndex; // 72 + public uint OpticalFlowQueueFamily; // 76 + public byte UseNativeOpticalFlowMode; // 80 (C++ bool) + public uint ComputeQueueCreateFlags; // 84 + public uint GraphicsQueueCreateFlags; // 88 + public uint OpticalFlowQueueCreateFlags;// 92 + } + + // Streamline exports are `extern "C"` (no mangling); x64 has a single calling convention. + [DllImport(InterposerName, EntryPoint = "slInit", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)] + private static extern Result slInit(in Preferences pref, ulong sdkVersion); + + [DllImport(InterposerName, EntryPoint = "slShutdown", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)] + private static extern Result slShutdown(); + + // adapterInfo is a `const AdapterInfo&` at source level, i.e. a pointer at the ABI. + // On Vulkan the adapter MUST be provided (via VkPhysicalDevice): the DLSS plugin throws an + // internal exception on a null adapter when renderAPI is Vulkan, so we never pass null here. + [DllImport(InterposerName, EntryPoint = "slIsFeatureSupported", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)] + private static extern Result slIsFeatureSupported(uint feature, in AdapterInfo adapterInfo); + + [DllImport(InterposerName, EntryPoint = "slSetVulkanInfo", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)] + private static extern Result slSetVulkanInfo(in VulkanInfo info); + + private static IntPtr _interposerHandle; + private static IntPtr _logPathPtr; + private static bool _initialized; + + /// True once slInit has succeeded and the device has been registered. + public static bool IsInitialized => _initialized; + + /// + /// Loads sl.interposer.dll from and calls slInit, + /// requesting the DLSS plugin. Returns true only if slInit succeeds. Logs the outcome. + /// + public static unsafe bool Initialize(string streamlineFolder) + { + if (_initialized) + { + return true; + } + + if (!OperatingSystem.IsWindows()) + { + Logger.Info?.Print(LogClass.Gpu, "DLSS: Streamline is Windows-only; skipping."); + + return false; + } + + string interposerPath = Path.Combine(streamlineFolder ?? string.Empty, InterposerDll); + if (!File.Exists(interposerPath)) + { + Logger.Info?.Print(LogClass.Gpu, $"DLSS: {InterposerDll} not found at \"{interposerPath}\"; DLSS unavailable."); + + return false; + } + + // Preload by full path. Subsequent [DllImport("sl.interposer")] calls resolve to this + // already-loaded module (Windows matches the base name), so no DllImportResolver needed. + if (!NativeLibrary.TryLoad(interposerPath, out _interposerHandle)) + { + Logger.Warning?.Print(LogClass.Gpu, $"DLSS: failed to load \"{interposerPath}\"."); + + return false; + } + + Preferences pref = default; + pref.StructType = MakeGuid(0x1ca10965, 0xbf8e, 0x432b, 0x8d, 0xa1, 0x67, 0x16, 0xd8, 0x79, 0xfb, 0x14); + pref.StructVersion = StructVersion1; + pref.ShowConsole = 0; + // Dev bring-up: verbose log written to sl.log next to the interposer for diagnostics. + pref.LogLevel = LogLevelVerbose; + _logPathPtr = Marshal.StringToHGlobalUni(streamlineFolder); + pref.PathToLogsAndData = _logPathPtr; + pref.Engine = EngineTypeCustom; + pref.RenderApi = RenderApiVulkan; + // Keep SL's default flags (eDisableCLStateTracking | eAllowOTA | eLoadDownloadedPlugins). + pref.Flags = (1UL << 0) | (1UL << 3) | (1UL << 6); + + // featuresToLoad is required, otherwise no plugins are loaded. Address of a local is + // valid for the duration of this synchronous call (SL keeps its own copy). + uint feature = FeatureDLSS; + pref.FeaturesToLoad = (IntPtr)(&feature); + pref.NumFeaturesToLoad = 1; + + Result result; + try + { + result = slInit(in pref, KSdkVersion); + } + catch (DllNotFoundException ex) + { + Logger.Warning?.Print(LogClass.Gpu, $"DLSS: slInit could not be resolved: {ex.Message}"); + + return false; + } + + if (result != Result.Ok) + { + Logger.Info?.Print(LogClass.Gpu, $"DLSS: slInit failed ({result}); DLSS unavailable."); + + return false; + } + + _initialized = true; + Logger.Info?.Print(LogClass.Gpu, "DLSS: Streamline initialized."); + + return true; + } + + /// + /// Asks Streamline whether DLSS is supported on the given Vulkan physical device. + /// Logs "DLSS: available" / the failure reason. Requires first. + /// + /// Native VkPhysicalDevice handle of the device in use. + public static bool IsDlssSupported(IntPtr vkPhysicalDevice) + { + if (!_initialized) + { + return false; + } + + AdapterInfo adapter = default; + adapter.StructType = MakeGuid(0x0677315f, 0xa746, 0x4492, 0x9f, 0x42, 0xcb, 0x61, 0x42, 0xc9, 0xc3, 0xd4); + adapter.StructVersion = StructVersion1; + adapter.VkPhysicalDevice = vkPhysicalDevice; + + Result result = slIsFeatureSupported(FeatureDLSS, in adapter); + + if (result == Result.Ok) + { + Logger.Info?.Print(LogClass.Gpu, "DLSS: available."); + + return true; + } + + Logger.Info?.Print(LogClass.Gpu, $"DLSS: not available ({result})."); + + return false; + } + + /// + /// Registers our natively-created Vulkan device/instance with Streamline. Mandatory when not + /// using SL's vkCreate* proxies. Must be called after and after the + /// device is created, before any feature evaluation. + /// + public static bool SetVulkanInfo(IntPtr instance, IntPtr physicalDevice, IntPtr device, uint queueFamily, uint queueIndex) + { + if (!_initialized) + { + return false; + } + + VulkanInfo info = default; + info.StructType = MakeGuid(0x0eed6fd5, 0x82cd, 0x43a9, 0xbd, 0xb5, 0x47, 0xa5, 0xba, 0x2f, 0x45, 0xd6); + info.StructVersion = StructVersion3; + info.Device = device; + info.Instance = instance; + info.PhysicalDevice = physicalDevice; + // DLSS-SR requests no extra queues, so all of SL's queues map to Ryujinx's single queue. + info.GraphicsQueueFamily = queueFamily; + info.GraphicsQueueIndex = queueIndex; + info.ComputeQueueFamily = queueFamily; + info.ComputeQueueIndex = queueIndex; + info.OpticalFlowQueueFamily = queueFamily; + info.OpticalFlowQueueIndex = queueIndex; + + Result result = slSetVulkanInfo(in info); + + if (result == Result.Ok) + { + Logger.Info?.Print(LogClass.Gpu, "DLSS: Vulkan device registered with Streamline."); + + return true; + } + + Logger.Warning?.Print(LogClass.Gpu, $"DLSS: slSetVulkanInfo failed ({result})."); + + return false; + } + + /// Shuts Streamline down if it was initialized. + public static void Shutdown() + { + if (_initialized) + { + slShutdown(); + _initialized = false; + } + + if (_interposerHandle != IntPtr.Zero) + { + NativeLibrary.Free(_interposerHandle); + _interposerHandle = IntPtr.Zero; + } + } + + private static SlStructType MakeGuid(uint d1, ushort d2, ushort d3, byte b0, byte b1, byte b2, byte b3, byte b4, byte b5, byte b6, byte b7) + { + return new SlStructType + { + Data1 = d1, + Data2 = d2, + Data3 = d3, + B0 = b0, B1 = b1, B2 = b2, B3 = b3, B4 = b4, B5 = b5, B6 = b6, B7 = b7, + }; + } + } +} diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/StreamlineDlss.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/StreamlineDlss.cs new file mode 100644 index 000000000..73340e1a3 --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/StreamlineDlss.cs @@ -0,0 +1,510 @@ +using Ryujinx.Common.Logging; +using System; +using System.Runtime.InteropServices; + +namespace Ryujinx.Graphics.Vulkan.Dlss +{ + /// + /// DLSS-SR evaluation interop: the structs, exported functions and feature functions needed to + /// actually run DLSS via slEvaluateFeature. Clean-room re-declaration of the public MIT + /// Streamline headers (sl_core_types.h, sl_consts.h, sl_dlss.h). All blittable, exact x64 layout. + /// + public static unsafe class StreamlineDlss + { + private const string Interposer = "sl.interposer"; + private const uint FeatureDLSS = 0; + + // sl::BufferType (uint32) - the four tags DLSS-SR requires. + private const uint BufferTypeDepth = 0; + private const uint BufferTypeMotionVectors = 1; + private const uint BufferTypeScalingInputColor = 3; + private const uint BufferTypeScalingOutputColor = 4; + + // sl::ResourceLifecycle - tag stays valid until the evaluate returns. + private const uint LifecycleValidUntilEvaluate = 2; + + // sl::ResourceType::eTex2d + private const byte ResourceTypeTex2d = 0; + + // sl::Boolean (char) + private const byte BoolFalse = 0; + private const byte BoolTrue = 1; + + /// One image to hand to DLSS (input/output/depth/motion). + public struct DlssTexture + { + public IntPtr Image; // VkImage + public IntPtr View; // VkImageView + public uint NativeFormat; // VkFormat (Silk.NET value) + public uint Layout; // VkImageLayout the image is in when DLSS runs + public uint Width; + public uint Height; + } + + public enum DlssMode : uint + { + Off = 0, + MaxPerformance, + Balanced, + MaxQuality, + UltraPerformance, + UltraQuality, + Dlaa, + } + + [StructLayout(LayoutKind.Sequential)] + private struct StructType + { + public uint Data1; + public ushort Data2; + public ushort Data3; + public byte B0, B1, B2, B3, B4, B5, B6, B7; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ViewportHandle + { + public IntPtr Next; + public StructType Type; + public nuint Version; + public uint Value; + } + + // sl::Resource - sl_core_types.h. sizeof == 112 on x64. + [StructLayout(LayoutKind.Sequential)] + private struct Resource + { + public IntPtr Next; + public StructType Type; + public nuint Version; + public byte ResType; // ResourceType : char (32, padded to 40) + public IntPtr Native; // 40 VkImage + public IntPtr Memory; // 48 + public IntPtr View; // 56 VkImageView + public uint State; // 64 VkImageLayout + public uint Width; // 68 + public uint Height; // 72 + public uint NativeFormat; // 76 VkFormat + public uint MipLevels; // 80 + public uint ArrayLayers; // 84 + public ulong GpuVirtualAddress; // 88 + public uint Flags; // 96 + public uint Usage; // 100 + public uint Reserved; // 104 + } + + [StructLayout(LayoutKind.Sequential)] + private struct Extent + { + public uint Top, Left, Width, Height; + } + + // sl::ResourceTag - sizeof == 64. + [StructLayout(LayoutKind.Sequential)] + private struct ResourceTag + { + public IntPtr Next; + public StructType Type; + public nuint Version; + public IntPtr ResourcePtr; // Resource* + public uint BufferType; + public uint Lifecycle; + public Extent Extent; + } + + [StructLayout(LayoutKind.Sequential)] + private struct Mat4 + { + public float M00, M01, M02, M03; + public float M10, M11, M12, M13; + public float M20, M21, M22, M23; + public float M30, M31, M32, M33; + + public static Mat4 Identity() + { + Mat4 m = default; + m.M00 = m.M11 = m.M22 = m.M33 = 1f; + + return m; + } + } + + // sl::Constants (kStructVersion2) - sl_consts.h. sizeof == 456 on x64. + [StructLayout(LayoutKind.Sequential)] + private struct Constants + { + public IntPtr Next; + public StructType Type; + public nuint Version; + public Mat4 CameraViewToClip; + public Mat4 ClipToCameraView; + public Mat4 ClipToLensClip; + public Mat4 ClipToPrevClip; + public Mat4 PrevClipToClip; + public float JitterOffsetX, JitterOffsetY; + public float MvecScaleX, MvecScaleY; + public float CameraPinholeOffsetX, CameraPinholeOffsetY; + public float CameraPosX, CameraPosY, CameraPosZ; + public float CameraUpX, CameraUpY, CameraUpZ; + public float CameraRightX, CameraRightY, CameraRightZ; + public float CameraFwdX, CameraFwdY, CameraFwdZ; + public float CameraNear; + public float CameraFar; + public float CameraFOV; + public float CameraAspectRatio; + public float MotionVectorsInvalidValue; + public byte DepthInverted; + public byte CameraMotionIncluded; + public byte MotionVectors3D; + public byte Reset; + public byte OrthographicProjection; + public byte MotionVectorsDilated; + public byte MotionVectorsJittered; + public float MinRelativeLinearDepthObjectSeparation; + } + + // sl::DLSSOptions (kStructVersion3) - sl_dlss.h. sizeof == 88. + [StructLayout(LayoutKind.Sequential)] + private struct DlssOptions + { + public IntPtr Next; + public StructType Type; + public nuint Version; + public uint Mode; + public uint OutputWidth; + public uint OutputHeight; + public float Sharpness; + public float PreExposure; + public float ExposureScale; + public byte ColorBuffersHDR; + public byte IndicatorInvertAxisX; + public byte IndicatorInvertAxisY; + public uint DlaaPreset; + public uint QualityPreset; + public uint BalancedPreset; + public uint PerformancePreset; + public uint UltraPerformancePreset; + public uint UltraQualityPreset; + public byte UseAutoExposure; + public byte AlphaUpscalingEnabled; + } + + // sl::DLSSOptimalSettings (kStructVersion1) - sl_dlss.h. sizeof == 64. + [StructLayout(LayoutKind.Sequential)] + private struct DlssOptimalSettings + { + public IntPtr Next; + public StructType Type; + public nuint Version; + public uint OptimalRenderWidth; + public uint OptimalRenderHeight; + public float OptimalSharpness; + public uint RenderWidthMin; + public uint RenderHeightMin; + public uint RenderWidthMax; + public uint RenderHeightMax; + } + + // --- exported functions --- + [DllImport(Interposer, EntryPoint = "slGetNewFrameToken", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)] + private static extern int slGetNewFrameToken(out IntPtr token, in uint frameIndex); + + [DllImport(Interposer, EntryPoint = "slSetConstants", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)] + private static extern int slSetConstants(in Constants values, IntPtr frameToken, in ViewportHandle viewport); + + [DllImport(Interposer, EntryPoint = "slEvaluateFeature", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)] + private static extern int slEvaluateFeature(uint feature, IntPtr frameToken, IntPtr* inputs, uint numInputs, IntPtr cmdBuffer); + + [DllImport(Interposer, EntryPoint = "slGetFeatureFunction", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)] + private static extern int slGetFeatureFunction(uint feature, [MarshalAs(UnmanagedType.LPStr)] string functionName, out IntPtr function); + + // DLSS feature functions, bound lazily via slGetFeatureFunction (they are not direct exports). + private static delegate* unmanaged[Cdecl] _slDLSSSetOptions; + private static delegate* unmanaged[Cdecl] _slDLSSGetOptimalSettings; + private static bool _functionsBound; + private static bool _loggedSizes; + + private static StructType Guid(uint d1, ushort d2, ushort d3, byte b0, byte b1, byte b2, byte b3, byte b4, byte b5, byte b6, byte b7) + { + return new StructType + { + Data1 = d1, Data2 = d2, Data3 = d3, + B0 = b0, B1 = b1, B2 = b2, B3 = b3, B4 = b4, B5 = b5, B6 = b6, B7 = b7, + }; + } + + private static bool BindFunctions() + { + if (_functionsBound) + { + return true; + } + + if (slGetFeatureFunction(FeatureDLSS, "slDLSSSetOptions", out IntPtr setOptions) != 0 || + slGetFeatureFunction(FeatureDLSS, "slDLSSGetOptimalSettings", out IntPtr getOptimal) != 0 || + setOptions == IntPtr.Zero || getOptimal == IntPtr.Zero) + { + Logger.Warning?.Print(LogClass.Gpu, "DLSS: could not bind DLSS feature functions."); + + return false; + } + + _slDLSSSetOptions = (delegate* unmanaged[Cdecl])setOptions; + _slDLSSGetOptimalSettings = (delegate* unmanaged[Cdecl])getOptimal; + _functionsBound = true; + + if (!_loggedSizes) + { + _loggedSizes = true; + Logger.Info?.Print(LogClass.Gpu, $"DLSS: struct sizes Resource={Marshal.SizeOf()}(112) Constants={Marshal.SizeOf()}(456) DLSSOptions={Marshal.SizeOf()}(88) Tag={Marshal.SizeOf()}(64)"); + } + + return true; + } + + private static ViewportHandle MakeViewport(uint id) + { + ViewportHandle vp = default; + vp.Type = Guid(0x171b6435, 0x9b3c, 0x4fc8, 0x99, 0x94, 0xfb, 0xe5, 0x25, 0x69, 0xaa, 0xa4); + vp.Version = 1; + vp.Value = id; + + return vp; + } + + /// Sets DLSS options for the viewport (mode + final output size). Call when size/mode changes. + public static bool SetOptions(uint viewportId, DlssMode mode, uint outputWidth, uint outputHeight, bool hdr) + { + if (!BindFunctions()) + { + return false; + } + + DlssOptions opt = default; + opt.Type = Guid(0x6ac826e4, 0x4c61, 0x4101, 0xa9, 0x2d, 0x63, 0x8d, 0x42, 0x10, 0x57, 0xb8); + opt.Version = 3; + opt.Mode = (uint)mode; + opt.OutputWidth = outputWidth; + opt.OutputHeight = outputHeight; + opt.PreExposure = 1.0f; + opt.ExposureScale = 1.0f; + opt.ColorBuffersHDR = hdr ? BoolTrue : BoolFalse; + opt.UseAutoExposure = BoolTrue; + + ViewportHandle vp = MakeViewport(viewportId); + int r = _slDLSSSetOptions(in vp, in opt); + if (r != 0) + { + Logger.Warning?.Print(LogClass.Gpu, $"DLSS: slDLSSSetOptions failed ({r})."); + + return false; + } + + return true; + } + + /// Queries DLSS's optimal render resolution for a given output size + mode. + public static bool GetOptimalRenderSize(DlssMode mode, uint outputWidth, uint outputHeight, out uint renderWidth, out uint renderHeight) + { + renderWidth = outputWidth; + renderHeight = outputHeight; + + if (!BindFunctions()) + { + return false; + } + + DlssOptions opt = default; + opt.Type = Guid(0x6ac826e4, 0x4c61, 0x4101, 0xa9, 0x2d, 0x63, 0x8d, 0x42, 0x10, 0x57, 0xb8); + opt.Version = 3; + opt.Mode = (uint)mode; + opt.OutputWidth = outputWidth; + opt.OutputHeight = outputHeight; + + DlssOptimalSettings settings = default; + settings.Type = Guid(0xef1d0957, 0xfd58, 0x4df7, 0xb5, 0x04, 0x8b, 0x69, 0xd8, 0xaa, 0x6b, 0x76); + settings.Version = 1; + + if (_slDLSSGetOptimalSettings(in opt, ref settings) != 0 || settings.OptimalRenderWidth == 0) + { + return false; + } + + renderWidth = settings.OptimalRenderWidth; + renderHeight = settings.OptimalRenderHeight; + + return true; + } + + /// + /// Returns the dynamic render-resolution range DLSS accepts for a given mode + output size. + /// The actual input must fall within [min, max] or slEvaluateFeature rejects it. + /// + public static bool GetRenderRange(DlssMode mode, uint outputWidth, uint outputHeight, + out uint minWidth, out uint minHeight, out uint maxWidth, out uint maxHeight) + { + minWidth = minHeight = maxWidth = maxHeight = 0; + + if (!BindFunctions()) + { + return false; + } + + DlssOptions opt = default; + opt.Type = Guid(0x6ac826e4, 0x4c61, 0x4101, 0xa9, 0x2d, 0x63, 0x8d, 0x42, 0x10, 0x57, 0xb8); + opt.Version = 3; + opt.Mode = (uint)mode; + opt.OutputWidth = outputWidth; + opt.OutputHeight = outputHeight; + + DlssOptimalSettings settings = default; + settings.Type = Guid(0xef1d0957, 0xfd58, 0x4df7, 0xb5, 0x04, 0x8b, 0x69, 0xd8, 0xaa, 0x6b, 0x76); + settings.Version = 1; + + if (_slDLSSGetOptimalSettings(in opt, ref settings) != 0 || settings.OptimalRenderWidth == 0) + { + return false; + } + + minWidth = settings.RenderWidthMin; + minHeight = settings.RenderHeightMin; + maxWidth = settings.RenderWidthMax; + maxHeight = settings.RenderHeightMax; + + return true; + } + + private static Resource MakeResource(in DlssTexture tex) + { + Resource r = default; + r.Type = Guid(0x3a9d70cf, 0x2418, 0x4b72, 0x83, 0x91, 0x13, 0xf8, 0x72, 0x1c, 0x72, 0x61); + r.Version = 1; + r.ResType = ResourceTypeTex2d; + r.Native = tex.Image; + r.View = tex.View; + r.State = tex.Layout; + r.Width = tex.Width; + r.Height = tex.Height; + r.NativeFormat = tex.NativeFormat; + r.MipLevels = 1; + r.ArrayLayers = 1; + + return r; + } + + private static ResourceTag MakeTag(Resource* resource, uint bufferType, uint width, uint height) + { + ResourceTag t = default; + t.Type = Guid(0x4c6a5aad, 0xb445, 0x496c, 0x87, 0xff, 0x1a, 0xf3, 0x84, 0x5b, 0xe6, 0x53); + t.Version = 1; + t.ResourcePtr = (IntPtr)resource; + t.BufferType = bufferType; + t.Lifecycle = LifecycleValidUntilEvaluate; + t.Extent = new Extent { Top = 0, Left = 0, Width = width, Height = height }; + + return t; + } + + private static Constants BuildConstants(uint outW, uint outH, bool reset, float jitterX, float jitterY) + { + Constants c = default; + c.Type = Guid(0xdcd35ad7, 0x4e4a, 0x4bad, 0xa9, 0x0c, 0xe0, 0xc4, 0x9e, 0xb2, 0x3a, 0xfe); + c.Version = 2; + c.CameraViewToClip = Mat4.Identity(); + c.ClipToCameraView = Mat4.Identity(); + c.ClipToLensClip = Mat4.Identity(); + c.ClipToPrevClip = Mat4.Identity(); + c.PrevClipToClip = Mat4.Identity(); + c.JitterOffsetX = jitterX; // sub-pixel jitter applied to the image this frame (0 unless Mode B) + c.JitterOffsetY = jitterY; + c.MvecScaleX = 1f; + c.MvecScaleY = 1f; + c.CameraUpY = 1f; + c.CameraRightX = 1f; + c.CameraFwdZ = 1f; + c.CameraNear = 0.1f; + c.CameraFar = 10000f; + c.CameraFOV = 1.0f; + c.CameraAspectRatio = outH != 0 ? (float)outW / outH : 1.7777f; + c.MotionVectorsInvalidValue = 0f; + c.DepthInverted = BoolFalse; + c.CameraMotionIncluded = BoolTrue; + c.MotionVectors3D = BoolFalse; + c.Reset = reset ? BoolTrue : BoolFalse; // history off only on the first frame / scene cut + c.OrthographicProjection = BoolFalse; + c.MotionVectorsDilated = BoolFalse; + c.MotionVectorsJittered = BoolFalse; + c.MinRelativeLinearDepthObjectSeparation = 40f; + + return c; + } + + /// + /// Runs DLSS for one frame: sets constants, tags the four buffers and evaluates into the + /// output image, recording into . The caller submits that buffer. + /// + public static bool Evaluate( + IntPtr cmdBuffer, + uint viewportId, + uint frameIndex, + bool reset, + float jitterX, + float jitterY, + in DlssTexture input, + in DlssTexture output, + in DlssTexture depth, + in DlssTexture motion) + { + if (!_functionsBound) + { + return false; + } + + if (slGetNewFrameToken(out IntPtr token, in frameIndex) != 0 || token == IntPtr.Zero) + { + Logger.Warning?.Print(LogClass.Gpu, "DLSS: slGetNewFrameToken failed."); + + return false; + } + + ViewportHandle vp = MakeViewport(viewportId); + + Constants constants = BuildConstants(output.Width, output.Height, reset, jitterX, jitterY); + int rc = slSetConstants(in constants, token, in vp); + if (rc != 0) + { + Logger.Warning?.Print(LogClass.Gpu, $"DLSS: slSetConstants failed ({rc})."); + + return false; + } + + Resource rInput = MakeResource(input); + Resource rOutput = MakeResource(output); + Resource rDepth = MakeResource(depth); + Resource rMotion = MakeResource(motion); + + ResourceTag tInput = MakeTag(&rInput, BufferTypeScalingInputColor, input.Width, input.Height); + ResourceTag tOutput = MakeTag(&rOutput, BufferTypeScalingOutputColor, output.Width, output.Height); + ResourceTag tDepth = MakeTag(&rDepth, BufferTypeDepth, depth.Width, depth.Height); + ResourceTag tMotion = MakeTag(&rMotion, BufferTypeMotionVectors, motion.Width, motion.Height); + + // Inputs to slEvaluateFeature: the viewport plus the four local resource tags. + IntPtr* inputs = stackalloc IntPtr[5]; + inputs[0] = (IntPtr)(&vp); + inputs[1] = (IntPtr)(&tDepth); + inputs[2] = (IntPtr)(&tMotion); + inputs[3] = (IntPtr)(&tInput); + inputs[4] = (IntPtr)(&tOutput); + + int re = slEvaluateFeature(FeatureDLSS, token, inputs, 5, cmdBuffer); + if (re != 0) + { + Logger.Warning?.Print(LogClass.Gpu, $"DLSS: slEvaluateFeature failed ({re})."); + + return false; + } + + return true; + } + } +} diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/AreaScalingFilter.cs b/src/Ryujinx.Graphics.Vulkan/Effects/AreaScalingFilter.cs index fc6dc2a02..1e0f16686 100644 --- a/src/Ryujinx.Graphics.Vulkan/Effects/AreaScalingFilter.cs +++ b/src/Ryujinx.Graphics.Vulkan/Effects/AreaScalingFilter.cs @@ -55,6 +55,8 @@ namespace Ryujinx.Graphics.Vulkan.Effects ], scalingResourceLayout); } + public bool IsResolutionSupported(int srcWidth, int srcHeight, int dstWidth, int dstHeight) => true; + public void Run( TextureView view, CommandBufferScoped cbs, @@ -69,7 +71,8 @@ namespace Ryujinx.Graphics.Vulkan.Effects float peak = 12.5f, float curve = 4.0f, float gamma = 2.2f, - float blend = 0.5f) + float blend = 0.5f, + float whiten = 0.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 69d60e9d7..1fd14f1a2 100644 --- a/src/Ryujinx.Graphics.Vulkan/Effects/FsrScalingFilter.cs +++ b/src/Ryujinx.Graphics.Vulkan/Effects/FsrScalingFilter.cs @@ -87,6 +87,8 @@ namespace Ryujinx.Graphics.Vulkan.Effects ], sharpeningResourceLayout); } + public bool IsResolutionSupported(int srcWidth, int srcHeight, int dstWidth, int dstHeight) => true; + public void Run( TextureView view, CommandBufferScoped cbs, @@ -101,7 +103,8 @@ namespace Ryujinx.Graphics.Vulkan.Effects float peak = 12.5f, float curve = 4.0f, float gamma = 2.2f, - float blend = 0.5f) + float blend = 0.5f, + float whiten = 0.0f) { if (_intermediaryTexture == null || _intermediaryTexture.Info.Width != width @@ -159,14 +162,15 @@ namespace Ryujinx.Graphics.Vulkan.Effects // Non-HDR: [sharpening]. HDR: [sharpening, paperWhite, peak, curve, gamma, blend] (the HDR sharpening // shader reads the extra floats from the same uniform block to tone-map to scRGB). - int sharpeningCount = hdr ? 6 : 1; - Span sharpeningBufferData = stackalloc float[6]; + int sharpeningCount = hdr ? 7 : 1; + Span sharpeningBufferData = stackalloc float[7]; sharpeningBufferData[0] = 1.5f - (Level * 0.01f * 1.5f); sharpeningBufferData[1] = paperWhite; sharpeningBufferData[2] = peak; sharpeningBufferData[3] = curve; sharpeningBufferData[4] = gamma; sharpeningBufferData[5] = blend; + sharpeningBufferData[6] = whiten; using ScopedTemporaryBuffer sharpeningBuffer = _renderer.BufferManager.ReserveOrCreate(_renderer, cbs, sharpeningCount * sizeof(float)); sharpeningBuffer.Holder.SetDataUnchecked(sharpeningBuffer.Offset, sharpeningBufferData[..sharpeningCount]); diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/IScalingFilter.cs b/src/Ryujinx.Graphics.Vulkan/Effects/IScalingFilter.cs index a12fb0a2e..c68ad7f45 100644 --- a/src/Ryujinx.Graphics.Vulkan/Effects/IScalingFilter.cs +++ b/src/Ryujinx.Graphics.Vulkan/Effects/IScalingFilter.cs @@ -7,6 +7,7 @@ namespace Ryujinx.Graphics.Vulkan.Effects internal interface IScalingFilter : IDisposable { float Level { get; set; } + bool IsResolutionSupported(int srcWidth, int srcHeight, int dstWidth, int dstHeight); void Run( TextureView view, CommandBufferScoped cbs, @@ -21,6 +22,7 @@ namespace Ryujinx.Graphics.Vulkan.Effects float peak = 12.5f, float curve = 4.0f, float gamma = 2.2f, - float blend = 0.5f); + float blend = 0.5f, + float whiten = 0.0f); } } diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/NisScalingFilter.cs b/src/Ryujinx.Graphics.Vulkan/Effects/NisScalingFilter.cs new file mode 100644 index 000000000..21f7a58b2 --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/Effects/NisScalingFilter.cs @@ -0,0 +1,231 @@ +using Ryujinx.Common; +using Ryujinx.Graphics.GAL; +using Ryujinx.Graphics.Shader; +using Ryujinx.Graphics.Shader.Translation; +using Silk.NET.Vulkan; +using System; +using Extent2D = Ryujinx.Graphics.GAL.Extents2D; +using Format = Silk.NET.Vulkan.Format; +using SamplerCreateInfo = Ryujinx.Graphics.GAL.SamplerCreateInfo; + +namespace Ryujinx.Graphics.Vulkan.Effects +{ + /// + /// NVIDIA Image Scaling (NIS) upscaler. Wraps the MIT-licensed NIS NVScaler compute + /// shader (see Effects/Shaders/NisScaler.h, nis_coef.glsl, NisScaling.glsl). + /// NVScaler is only defined for upscale ratios in [1x, 2x]; outside that the input + /// is simply stretched without the NIS quality benefit. SDR (rgba8) path for now; + /// an scRGB/HDR variant is a follow-up. + /// + internal class NisScalingFilter : IScalingFilter + { + // NIS shader block dimensions (must match NisScaling.glsl: NIS_BLOCK_WIDTH/HEIGHT). + private const int BlockWidth = 32; + private const int BlockHeight = 24; + + private const int ConfigFloats = 30; // NISConfig: 28 named values + 2 reserved. + + private readonly VulkanRenderer _renderer; + private PipelineHelperShader _pipeline; + private ISampler _sampler; + private ShaderCollection _scalingProgram; + private ShaderCollection _scalingProgramHdr; + private Device _device; + + // Slider value (0..100). Mapped to NIS sharpness (0..1) in Run. 50 = neutral. + public float Level { get; set; } = 50f; + + public NisScalingFilter(VulkanRenderer renderer, Device device) + { + _device = device; + _renderer = renderer; + + Initialize(); + } + + public void Dispose() + { + _pipeline.Dispose(); + _scalingProgram.Dispose(); + _scalingProgramHdr.Dispose(); + _sampler.Dispose(); + } + + public void Initialize() + { + _pipeline = new PipelineHelperShader(_renderer, _device); + _pipeline.Initialize(); + + byte[] scalingShader = EmbeddedResources.Read("Ryujinx.Graphics.Vulkan/Effects/Shaders/NisScaling.spv"); + + // Same layout as the other scaling filters: config UBO (2), input+sampler (1), output image (0/set 3). + ResourceLayout scalingResourceLayout = new ResourceLayoutBuilder() + .Add(ResourceStages.Compute, ResourceType.UniformBuffer, 2) + .Add(ResourceStages.Compute, ResourceType.TextureAndSampler, 1) + .Add(ResourceStages.Compute, ResourceType.Image, 0, true).Build(); + + _sampler = _renderer.CreateSampler(SamplerCreateInfo.Create(MinFilter.Linear, MagFilter.Linear)); + + _scalingProgram = _renderer.CreateProgramWithMinimalLayout([ + new ShaderSource(scalingShader, ShaderStage.Compute, TargetLanguage.Spirv) + ], scalingResourceLayout); + + byte[] scalingShaderHdr = EmbeddedResources.Read("Ryujinx.Graphics.Vulkan/Effects/Shaders/NisScalingHdr.spv"); + + // HDR variant adds the tone-map params UBO at binding 3 and outputs to rgba16f (scRGB). + ResourceLayout scalingResourceLayoutHdr = new ResourceLayoutBuilder() + .Add(ResourceStages.Compute, ResourceType.UniformBuffer, 2) + .Add(ResourceStages.Compute, ResourceType.UniformBuffer, 3) + .Add(ResourceStages.Compute, ResourceType.TextureAndSampler, 1) + .Add(ResourceStages.Compute, ResourceType.Image, 0, true).Build(); + + _scalingProgramHdr = _renderer.CreateProgramWithMinimalLayout([ + new ShaderSource(scalingShaderHdr, ShaderStage.Compute, TargetLanguage.Spirv) + ], scalingResourceLayoutHdr); + } + + // NIS NVScaler is only defined for upscale ratios in [1x, 2x] (kScale in [0.5, 1.0]). + // Outside that range - notably when the internal resolution is at/above the output, as + // with high-res mods or texture packs - its block tile cache reads out of bounds and + // corrupts the image. Report unsupported so Window.Present falls back to a normal blit. + public bool IsResolutionSupported(int srcWidth, int srcHeight, int dstWidth, int dstHeight) + { + float scaleX = (float)srcWidth / dstWidth; + float scaleY = (float)srcHeight / dstHeight; + return scaleX >= 0.5f && scaleX <= 1.0f && scaleY >= 0.5f && scaleY <= 1.0f; + } + + public void Run( + TextureView view, + CommandBufferScoped cbs, + Auto destinationTexture, + Format format, + int width, + int height, + Extent2D source, + Extent2D destination, + bool hdr = false, + float paperWhite = 2.5f, + float peak = 12.5f, + float curve = 4.0f, + float gamma = 2.2f, + float blend = 0.5f, + float whiten = 0.0f) + { + _pipeline.SetCommandBuffer(cbs); + _pipeline.SetProgram(hdr ? _scalingProgramHdr : _scalingProgram); + _pipeline.SetTextureAndSampler(ShaderStage.Compute, 1, view, _sampler); + + // v1: stretch the full source frame to the full output (swapchain) surface. + // Letterboxing/flip via source/destination extents is a follow-up. + uint inW = (uint)view.Width; + uint inH = (uint)view.Height; + uint outW = (uint)width; + uint outH = (uint)height; + + float sharpness = Math.Clamp(Level * 0.01f, 0f, 1f); + + Span cb = stackalloc float[ConfigFloats]; + BuildConfig(cb, sharpness, inW, inH, outW, outH); + + int rangeSize = ConfigFloats * sizeof(float); + using ScopedTemporaryBuffer buffer = _renderer.BufferManager.ReserveOrCreate(_renderer, cbs, rangeSize); + buffer.Holder.SetDataUnchecked(buffer.Offset, cb); + + // HDR tone-map params (paperWhite, peak, curve, gamma, blend, whiten), matching NisScalingHdr.glsl. + Span hdrData = stackalloc float[] { paperWhite, peak, curve, gamma, blend, whiten }; + using ScopedTemporaryBuffer hdrBuffer = _renderer.BufferManager.ReserveOrCreate(_renderer, cbs, hdrData.Length * sizeof(float)); + hdrBuffer.Holder.SetDataUnchecked(hdrBuffer.Offset, hdrData); + + if (hdr) + { + _pipeline.SetUniformBuffers([new BufferAssignment(2, buffer.Range), new BufferAssignment(3, hdrBuffer.Range)]); + } + else + { + _pipeline.SetUniformBuffers([new BufferAssignment(2, buffer.Range)]); + } + + _pipeline.SetImage(0, destinationTexture); + + int dispatchX = ((int)outW + (BlockWidth - 1)) / BlockWidth; + int dispatchY = ((int)outH + (BlockHeight - 1)) / BlockHeight; + + _pipeline.DispatchCompute(dispatchX, dispatchY, 1); + _pipeline.ComputeBarrier(); + + _pipeline.Finish(); + } + + // Port of NVScalerUpdateConfig (NIS_Config.h, MIT) for the SDR path. Fills the + // NISConfig constant buffer in the exact field order expected by NisScaling.glsl. + private static void BuildConfig(Span cb, float sharpness, uint inW, uint inH, uint outW, uint outH) + { + sharpness = Math.Clamp(sharpness, 0f, 1f); + float sharpenSlider = sharpness - 0.5f; // map 0..1 to -0.5..+0.5 + + float maxScale = sharpenSlider >= 0.0f ? 1.25f : 1.75f; + float minScale = sharpenSlider >= 0.0f ? 1.25f : 1.0f; + float limitScale = sharpenSlider >= 0.0f ? 1.25f : 1.0f; + + float kDetectRatio = 2f * 1127f / 1024f; + + // SDR params (HDR variant handled separately later). + float kDetectThres = 64f / 1024f; + float kMinContrastRatio = 2.0f; + float kMaxContrastRatio = 10.0f; + + float kSharpStartY = 0.45f; + float kSharpEndY = 0.9f; + float kSharpStrengthMin = MathF.Max(0.0f, 0.4f + sharpenSlider * minScale * 1.2f); + float kSharpStrengthMax = 1.6f + sharpenSlider * maxScale * 1.8f; + float kSharpLimitMin = MathF.Max(0.1f, 0.14f + sharpenSlider * limitScale * 0.32f); + float kSharpLimitMax = 0.5f + sharpenSlider * limitScale * 0.6f; + + float kRatioNorm = 1.0f / (kMaxContrastRatio - kMinContrastRatio); + float kSharpScaleY = 1.0f / (kSharpEndY - kSharpStartY); + float kSharpStrengthScale = kSharpStrengthMax - kSharpStrengthMin; + float kSharpLimitScale = kSharpLimitMax - kSharpLimitMin; + + float kSrcNormX = 1f / inW; + float kSrcNormY = 1f / inH; + float kDstNormX = 1f / outW; + float kDstNormY = 1f / outH; + float kScaleX = inW / (float)outW; + float kScaleY = inH / (float)outH; + + int i = 0; + cb[i++] = kDetectRatio; + cb[i++] = kDetectThres; + cb[i++] = kMinContrastRatio; + cb[i++] = kRatioNorm; + cb[i++] = 1.0f; // kContrastBoost + cb[i++] = 1.0f / 255.0f; // kEps + cb[i++] = kSharpStartY; + cb[i++] = kSharpScaleY; + cb[i++] = kSharpStrengthMin; + cb[i++] = kSharpStrengthScale; + cb[i++] = kSharpLimitMin; + cb[i++] = kSharpLimitScale; + cb[i++] = kScaleX; + cb[i++] = kScaleY; + cb[i++] = kDstNormX; + cb[i++] = kDstNormY; + cb[i++] = kSrcNormX; + cb[i++] = kSrcNormY; + cb[i++] = AsFloat(0); // kInputViewportOriginX + cb[i++] = AsFloat(0); // kInputViewportOriginY + cb[i++] = AsFloat(inW); // kInputViewportWidth + cb[i++] = AsFloat(inH); // kInputViewportHeight + cb[i++] = AsFloat(0); // kOutputViewportOriginX + cb[i++] = AsFloat(0); // kOutputViewportOriginY + cb[i++] = AsFloat(outW); // kOutputViewportWidth + cb[i++] = AsFloat(outH); // kOutputViewportHeight + cb[i++] = 0f; // reserved0 + cb[i++] = 0f; // reserved1 + } + + // Reinterpret a uint's bits as a float so the UBO's uint members get the right bytes. + private static float AsFloat(uint value) => BitConverter.Int32BitsToSingle((int)value); + } +} diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/FsrSharpening.glsl b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/FsrSharpening.glsl index f69a20b88..249db6c50 100644 --- a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/FsrSharpening.glsl +++ b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/FsrSharpening.glsl @@ -24,6 +24,7 @@ layout( binding = 4 ) uniform sharpening float curve_data; float gamma_data; float blend_data; + float whiten_data; #endif }; @@ -3916,8 +3917,14 @@ vec3 sdrToHdr(vec3 srgb) float luma = dot(lin, vec3(0.2126, 0.7152, 0.0722)); float maxc = max(max(lin.r, lin.g), lin.b); float hl = mix(luma, maxc, blend_data); - float boost = mix(1.0, peak / paperWhite, pow(clamp(hl, 0.0, 1.0), curve)); - return lin * paperWhite * boost; + float hlAmount = pow(clamp(hl, 0.0, 1.0), curve); + float boost = mix(1.0, peak / paperWhite, hlAmount); + vec3 outc = lin * paperWhite * boost; + // Desaturate the most extreme highlights toward white (same change as the plain blit path): + // a blinding light reads as white, not a saturated colour. Luminance-preserving. + float t = whiten_data * hlAmount; + float lumaOut = dot(outc, vec3(0.2126, 0.7152, 0.0722)); + return mix(outc, vec3(lumaOut), t); } #endif diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/FsrSharpeningHdr.spv b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/FsrSharpeningHdr.spv index 8ca5e52bb098b9d1a216af42c86e9095460db136..3b106f7d7b465bc15a8d859cfa132cc057d472e6 100644 GIT binary patch delta 3236 zcmZ9N%}-oa7{<>q!$&Psir4~03v@)Rw1!$fMQW+Q6k7{a`lYtRbQodm41+L3i?-gj zSV|Y#G}p$pJCi0RhQGkXr7PX&PUA)s6Bp{jM2ydG=AL+&NiOGkp7-;-=iJMmUj#ni z2!w-WkxF9%X1fWSdk@0V^7BDsESmEL@u?QG$v%Ke156vu-(=|lrd4w z8@^ARRB*1-cHt(Evk&+lkKguqV}S?Fs<Y z+sv?=*&T64NUPtlR=nVhgkQb*S>bA7B(k0p2neI&4`*a(*x&FlG1krqvC}1bTs&0d zPC>XLp~~lQOZ=$#0X0h@W{&nq8qZ#}!>qp%rs#*lj1Y~YaK?;=FJra7cRd|#z*L3| z!n*sgzQf_x>)TRD!OE?-{De4{-tw?GSKe~$Tx~z+YWq1?+t0b$@F88DU&_?&q&w75 z=bV;2spp6~rv-SwCc$oA7ar3}tJns$TMl?i7>yVX8BSV`<4rvr(RcF0pCfq7)A45; z@P7FpGR83%MY`mK!6u0bbB_Kb)8gn~E0Cm@h0!=)8gB^d-{2ec{l2$*>~oQc%zS_H<$3XjgKZ1Wlp8!YJF4q z*zFV!XzkEbBYa=$T|Kmhy;j;Ex+2vo1b^J~=ZjA)cG~8@J~kWogz)c*vwWBMZ{q8w z+$kj5EdeJC)~Q~YM7FsGVVZkh4^0t+4+Ysfc4DVw+avs=uV9ln1wYogV1H5^pQfPm zf5E3DkjOgi6~+;rA`BFb8=#MnX%+riYl~Lf^gdy9%Ok>U;*MgN*we!Jpp&zimkiN% z{){k%KJfzDh0*>|Ap+Wj(X9Wo!uZ>Q`-SnDP&sz`oG{vcxAkn@5O=^@4hVPn9_CEM zL1}i*WG4f@|LWs#(A}KsbvniDO=CS=!n;a%w~zDn@A2^w@jf5t<_>wBJ@K*X6`$71 z{uS#K1dM#o`(@-ij*&$0e*M$dyv&Qjr?t``P0&%(Ih=Nx*skvArUEQS}Q3 z97u#n@UYgXo>7l8y5qvrTCdCAt948q%@%uAm>iSdbvq%9#?NXeJ&j9&Hq1qS%@Y~( dga+5I^^`Oc5P+SxG@-&YMPcllx?6pE{sT2AIg9`R delta 2692 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 literal 0 HcmV?d00001 diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MotionFilter.glsl b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MotionFilter.glsl new file mode 100644 index 000000000..7f1cb5d41 --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MotionFilter.glsl @@ -0,0 +1,96 @@ +// Motion-vector outlier filter: a 3x3 component-wise median over the raw optical-flow field. +// An isolated wrong vector is never the median of its 8 neighbours, so it is rejected while coherent +// motion is preserved. Parameter-free (no tunable threshold). It also re-measures the field +// statistics on the filtered output so the log can show the raw-vs-filtered outlier rate side by +// side -- the A/B proof that the filter actually removes the motion-induced outliers. + +#version 430 core +layout (local_size_x = 16, local_size_y = 16) in; + +layout (rg16f, binding = 0, set = 3) uniform writeonly image2D imgOut; // filtered motion +layout (rg16f, binding = 1, set = 3) uniform readonly image2D imgIn; // raw motion + +layout (binding = 2) uniform params { + float width; + float height; + float maxMotion; + float metrics; // >0.5 = accumulate the filtered-field instrumentation stats (dev only) + float dejitterX; // unused here; kept so the UBO layout matches the motion pass + float dejitterY; +}; + +layout (std430, set = 1, binding = 0) buffer SceneChange { + uint changedCount; + uint motionCount; + uint statSamples; + uint sumMagFx; + uint sumMagSqFx; + uint outlierCount; + uint sumMagFxF; // filtered: sum of clamped |mv|, x256 + uint sumMagSqFxF; // filtered: sum of clamped |mv|^2, x1024 + uint outlierCountF; // filtered: sampled pixels with |mv| > 2px +}; + +float median9(float a[9]) +{ + // Partial selection sort: only the first five passes are needed to expose the median at [4]. + for (int i = 0; i < 5; ++i) + { + int m = i; + for (int j = i + 1; j < 9; ++j) + { + if (a[j] < a[m]) + { + m = j; + } + } + float t = a[i]; + a[i] = a[m]; + a[m] = t; + } + return a[4]; +} + +void main() +{ + ivec2 p = ivec2(gl_GlobalInvocationID.xy); + int w = int(width); + int h = int(height); + if (p.x >= w || p.y >= h) + { + return; + } + + ivec2 maxc = ivec2(w - 1, h - 1); + + float xs[9]; + float ys[9]; + int k = 0; + for (int dy = -1; dy <= 1; ++dy) + { + for (int dx = -1; dx <= 1; ++dx) + { + vec2 m = imageLoad(imgIn, clamp(p + ivec2(dx, dy), ivec2(0), maxc)).rg; + xs[k] = m.x; + ys[k] = m.y; + ++k; + } + } + + vec2 mv = vec2(median9(xs), median9(ys)); + imageStore(imgOut, p, vec4(mv, 0.0, 0.0)); + + // Re-measure on the filtered field, same 1/16 grid as the raw pass, so the log shows the outlier + // rate before and after filtering. + if (metrics > 0.5 && (p.x & 3) == 0 && (p.y & 3) == 0) + { + float mag = length(mv); + if (mag > 2.0) + { + atomicAdd(outlierCountF, 1u); + } + float magStat = min(mag, 4.0); + atomicAdd(sumMagFxF, uint(magStat * 256.0 + 0.5)); + atomicAdd(sumMagSqFxF, uint(magStat * magStat * 1024.0 + 0.5)); + } +} diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MotionFilter.spv b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MotionFilter.spv new file mode 100644 index 0000000000000000000000000000000000000000..2572a5249b1bf89102169d32404a5a0994966fa2 GIT binary patch literal 6308 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 ALI3~& literal 0 HcmV?d00001 diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MotionVectors.glsl b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MotionVectors.glsl new file mode 100644 index 000000000..3d58453fb --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MotionVectors.glsl @@ -0,0 +1,159 @@ +// Motion-vector estimation (Lucas-Kanade optical flow) between the previous and current frame. +// Produces a per-pixel screen-space motion vector (in render-resolution pixels) for DLSS. +// This is a lightweight stand-in for hardware optical flow: it runs on the graphics/compute +// queue inside the present command buffer, so it needs no dedicated optical-flow queue. + +#version 430 core +layout (local_size_x = 16, local_size_y = 16) in; + +layout (rg16f, binding = 0, set = 3) uniform image2D imgMotion; +layout (binding = 1, set = 2) uniform sampler2D CurrentColor; +layout (binding = 3, set = 2) uniform sampler2D PrevColor; + +layout (binding = 2) uniform params { + float width; + float height; + float maxMotion; // clamp range in pixels + float metrics; // >0.5 = accumulate the instrumentation stats (dev only; heavy atomics) + float dejitterX; // (J_n - J_(n-1)): jitter's apparent shift, removed so DLSS gets M_real (0 unless Mode B) + float dejitterY; +}; + +// Scene-change accumulators, read back by the CPU one frame later to drive a DLSS history reset: +// - changedCount: pixels whose temporal change the optical flow cannot explain (new content). +// - motionCount : pixels in confident, real motion. Lets the CPU tell a menu/UI change over a +// paused scene (mostly static + a changed chunk) from active gameplay (motion everywhere). +layout (std430, set = 1, binding = 0) buffer SceneChange { + uint changedCount; + uint motionCount; + // Phase 1 motion-field statistics (sparse 1/16 grid), read back by the CPU to compute the + // RMS/variance/outlier-rate of the vectors handed to DLSS. Fixed-point so the atomic sums stay + // integer and overflow-safe. + uint statSamples; // sampled pixels + uint sumMagFx; // sum of clamped |mv|, x256 + uint sumMagSqFx; // sum of clamped |mv|^2, x1024 + uint outlierCount; // sampled pixels with |mv| > 2px +}; + +float luma(vec3 c) +{ + return dot(c, vec3(0.299, 0.587, 0.114)); +} + +float lumaAt(ivec2 p, ivec2 maxc) +{ + ivec2 q = clamp(p, ivec2(0), maxc); + return luma(texelFetch(CurrentColor, q, 0).rgb); +} + +void main() +{ + ivec2 p = ivec2(gl_GlobalInvocationID.xy); + int w = int(width); + int h = int(height); + if (p.x >= w || p.y >= h) + { + return; + } + + ivec2 maxc = ivec2(w - 1, h - 1); + + // Lucas-Kanade: accumulate the 2x2 normal-equation system over a small window. + // Stt (sum of squared temporal differences) is also accumulated for the goodness-of-fit + // confidence below. + float Sxx = 0.0, Sxy = 0.0, Syy = 0.0, Sxt = 0.0, Syt = 0.0, Stt = 0.0; + const int R = 3; // 7x7 window + + for (int dy = -R; dy <= R; ++dy) + { + for (int dx = -R; dx <= R; ++dx) + { + ivec2 q = clamp(p + ivec2(dx, dy), ivec2(0), maxc); + + // Spatial gradients (central difference) and temporal difference. + float ix = 0.5 * (lumaAt(q + ivec2(1, 0), maxc) - lumaAt(q - ivec2(1, 0), maxc)); + float iy = 0.5 * (lumaAt(q + ivec2(0, 1), maxc) - lumaAt(q - ivec2(0, 1), maxc)); + float it = luma(texelFetch(CurrentColor, q, 0).rgb) - luma(texelFetch(PrevColor, q, 0).rgb); + + Sxx += ix * ix; + Sxy += ix * iy; + Syy += iy * iy; + Sxt += ix * it; + Syt += iy * it; + Stt += it * it; + } + } + + // Regularized Lucas-Kanade solve. The structure tensor M = [[Sxx,Sxy],[Sxy,Syy]] is PSD + // (det >= 0) but rank-deficient on flat/edge regions; add a tiny trace-relative ridge so the + // solve never divides by ~0 (no branch, no NaN). + float trace = Sxx + Syy; + float reg = 1e-4 * trace + 1e-8; + float Sxxr = Sxx + reg; + float Syyr = Syy + reg; + float detr = Sxxr * Syyr - Sxy * Sxy; + + // Solve (M + reg*I) [u v]^T = -[Sxt Syt]^T (flow from previous to current). + float u = (-Sxt * Syyr + Syt * Sxy) / detr; + float v = (-Syt * Sxxr + Sxt * Sxy) / detr; + + // ---- Optical-flow confidence (CUT 1), in [0,1] ---- + // (a) Conditioning: the smaller eigenvalue of M relative to its trace (Shi-Tomasi). Near 0 + // on flat areas and on aperture-dominated edges (motion unobservable along the edge), + // saturating to 1 on well-textured corners. 0.15 = eigenvalue-ratio knee. + float det = Sxx * Syy - Sxy * Sxy; + float disc = max(trace * trace - 4.0 * det, 0.0); + float lambdaMin = 0.5 * (trace - sqrt(disc)); + float wStruct = clamp(lambdaMin / (0.15 * trace + 1e-6), 0.0, 1.0); + + // (b) Goodness-of-fit: fraction of the local temporal change explained by this single flow + // vector ( = (Stt - E_min) / Stt ). Collapses at occlusion / transparency / multi-motion. + float explained = -(u * Sxt + v * Syt); + float wFit = clamp(explained / (Stt + 1e-6), 0.0, 1.0); + + float conf = wStruct * wFit; + + // Scene-change detector: a pixel counts as "cut" when it has a sizeable temporal change + // (Stt above the local spatial energy, i.e. >~1px of apparent change) that the single-flow + // model fails to explain (wFit low). New content appearing in place - a page/menu swap or a + // hard camera cut - lights up most of the screen here; coherent camera motion does not + // (the flow explains it -> wFit high). The CPU turns a large fraction into a DLSS reset. + if (wFit < 0.4 && Stt > (Sxx + Syy) + 1e-5) + { + atomicAdd(changedCount, 1u); + } + + // DLSS wants the vector pointing to the pixel's previous-frame position (negate the flow). + // Continuous gating: scale by confidence (shrink unreliable motion toward zero), never a + // hard binary cut, so the motion field stays temporally stable. + // Subtract the jitter's apparent shift (J_n - J_(n-1)) so the field handed to DLSS is M_real, not + // M_real + dJ (the spec's fundamental rule). Zero unless Mode B; the sign is calibrated at test time. + vec2 mv = (clamp(-vec2(u, v), vec2(-maxMotion), vec2(maxMotion)) - vec2(dejitterX, dejitterY)) * conf; + + // Count confident, real motion (> ~1px) so the CPU can recognise a paused scene. + if (conf > 0.5 && dot(mv, mv) > 1.0) + { + atomicAdd(motionCount, 1u); + } + + // ---- Phase 1 instrumentation: motion-field statistics ---- + // Characterise the optical-flow noise floor. On a static (held-still) scene the true motion is 0, + // so |mv| here is pure noise; its RMS is the number that decides whether temporal upscaling + // (Mode B) is viable (target < 0.5px). Sampled on a 1/16 grid, accumulated in fixed point to keep + // the atomics cheap and overflow-safe; the inlier magnitude is clamped so the squared sum cannot + // overflow uint32, and outliers (>2px) are counted apart. This measures the exact mv fed to DLSS. + if (metrics > 0.5 && (p.x & 3) == 0 && (p.y & 3) == 0) + { + float mag = length(mv); + if (mag > 2.0) + { + atomicAdd(outlierCount, 1u); + } + float magStat = min(mag, 4.0); + atomicAdd(statSamples, 1u); + atomicAdd(sumMagFx, uint(magStat * 256.0 + 0.5)); + atomicAdd(sumMagSqFx, uint(magStat * magStat * 1024.0 + 0.5)); + } + + imageStore(imgMotion, p, vec4(mv, 0.0, 0.0)); +} diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MotionVectors.spv b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/MotionVectors.spv new file mode 100644 index 0000000000000000000000000000000000000000..9bd055d3829f598cb42839f3cb02f103f9cb8b31 GIT binary patch literal 10456 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 literal 0 HcmV?d00001 diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/NisScaler.h b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/NisScaler.h new file mode 100644 index 000000000..1ca7d4a04 --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/NisScaler.h @@ -0,0 +1,1023 @@ +// The MIT License(MIT) +// +// Copyright(c) 2022 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files(the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and / or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +//--------------------------------------------------------------------------------- +// NVIDIA Image Scaling SDK - v1.0.3 +//--------------------------------------------------------------------------------- +// The NVIDIA Image Scaling SDK provides a single spatial scaling and sharpening algorithm +// for cross-platform support. The scaling algorithm uses a 6-tap scaling filter combined +// with 4 directional scaling and adaptive sharpening filters, which creates nice smooth images +// and sharp edges. In addition, the SDK provides a state-of-the-art adaptive directional sharpening algorithm +// for use in applications where no scaling is required. +// +// The directional scaling and sharpening algorithm is named NVScaler while the adaptive-directional-sharpening-only +// algorithm is named NVSharpen. Both algorithms are provided as compute shaders and +// developers are free to integrate them in their applications. Note that if you integrate NVScaler, you +// should NOT integrate NVSharpen, as NVScaler already includes a sharpening pass +// +// Pipeline Placement +// ------------------ +// The call into the NVIDIA Image Scaling shaders must occur during the post-processing phase after tone-mapping. +// Applying the scaling in linear HDR in-game color-space may result in a sharpening effect that is +// either not visible or too strong. Since sharpening algorithms can enhance noisy or grainy regions, it is recommended +// that certain effects such as film grain should occur after NVScaler or NVSharpen. Low-pass filters such as motion blur or +// light bloom are recommended to be applied before NVScaler or NVSharpen to avoid sharpening attenuation. +// +// Color Space and Ranges +// ---------------------- +// NVIDIA Image Scaling shaders can process color textures stored as either LDR or HDR with the following +// restrictions: +// 1) LDR +// - The range of color values must be in the [0, 1] range +// - The input color texture must be in display-referred color-space after tone mapping and OETF (gamma-correction) +// has been applied +// 2) HDR PQ +// - The range of color values must be in the [0, 1] range +// - The input color texture must be in display-referred color-space after tone mapping with Rec.2020 PQ OETF applied +// 3) HDR Linear +// - The recommended range of color values is [0, 12.5], where luminance value (as per BT. 709) of +// 1.0 maps to brightness value of 80nits (sRGB peak) and 12.5 maps to 1000nits +// - The input color texture may have luminance values that are either linear and scene-referred or +// linear and display-referred (after tone mapping) +// +// If the input color texture sent to NVScaler/NVSharpen is in HDR format set NIS_HDR_MODE define to either +// NIS_HDR_MODE_LINEAR (1) or NIS_HDR_MODE_PQ (2). +// +// Supported Texture Formats +// ------------------------- +// Input and output formats: +// Input and output formats are expected to be in the rages defined in previous section and should be +// specified using non-integer data types such as DXGI_FORMAT_R8G8B8A8_UNORM. +// +// Coefficients formats: +// The scaler coefficients and USM coefficients format should be specified using float4 type such as +// DXGI_FORMAT_R32G32B32A32_FLOAT or DXGI_FORMAT_R16G16B16A16_FLOAT. +// +// Resource States, Buffers, and Sampler: +// The game or application calling NVIDIA Image Scaling SDK shaders must ensure that the textures are in +// the correct state. +// - Input color textures must be in pixel shader read state. Shader Resource View (SRV) in DirectX +// - The output texture must be in read/write state. Unordered Access View (UAV) in DirectX +// - The coefficients texture for NVScaler must be in read state. Shader Resource View (SRV) in DirectX +// - The configuration variables must be passed as constant buffer. Constant Buffer View (CBV) in DirectX +// - The sampler for texture pixel sampling. Linear clamp SamplerState in Direct +// +// Adding NVIDIA Image Scaling SDK to a Project +// -------------------------------------------- +// Include NIS_Scaler.h directly in your application or alternative use the provided NIS_Main.hlsl shader file. +// Use NIS_Config.h to get the ideal shader dispatch values for your platform, to configure the algorithm constant +// values (NVScalerUpdateConfig, and NVSharpenUpdateConfig), and to access the algorithm coefficients (coef_scale and coef_USM). +// +// Defines: +// NIS_SCALER: default (1) NVScaler, (0) fast NVSharpen only, no upscaling +// NIS_HDR_MODE: default (0) disabled, (1) Linear, (2) PQ +// NIS_BLOCK_WIDTH: pixels per block width. Use GetOptimalBlockWidth query for your platform +// NIS_BLOCK_HEIGHT: pixels per block height. Use GetOptimalBlockHeight query for your platform +// NIS_THREAD_GROUP_SIZE: number of threads per group. Use GetOptimalThreadGroupSize query for your platform +// NIS_USE_HALF_PRECISION: default (0) disabled, (1) enable half pression computation +// NIS_HLSL: (1) enabled, (0) disabled +// NIS_HLSL_6_2: default (0) HLSL v5, (1) HLSL v6.2 forces NIS_HLSL=1 +// NIS_GLSL: (1) enabled, (0) disabled +// NIS_VIEWPORT_SUPPORT: default(0) disabled, (1) enable input/output viewport support +// NIS_NV12_SUPPORT: default(0) disabled, (1) enable NV12 input +// NIS_CLAMP_OUTPUT: default(0) disabled, (1) enable output clamp +// +// Default NVScaler shader constants: +// [NIS_BLOCK_WIDTH, NIS_BLOCK_HEIGHT, NIS_THREAD_GROUP_SIZE] = [32, 24, 256] +// +// Default NVSharpen shader constants: +// [NIS_BLOCK_WIDTH, NIS_BLOCK_HEIGHT, NIS_THREAD_GROUP_SIZE] = [32, 32, 256] +// +// NIS_UNROLL: default [unroll] +// NIS_UNROLL_INNER: default NIS_UNROLL, define in case of a compiler error for inner nested loops +//--------------------------------------------------------------------------------- + +// NVScaler enable by default. Set to 0 for NVSharpen only +#ifndef NIS_SCALER +#define NIS_SCALER 1 +#endif + +// HDR Modes +#define NIS_HDR_MODE_NONE 0 +#define NIS_HDR_MODE_LINEAR 1 +#define NIS_HDR_MODE_PQ 2 +#ifndef NIS_HDR_MODE +#define NIS_HDR_MODE NIS_HDR_MODE_NONE +#endif +#define kHDRCompressionFactor 0.282842712f + +// Viewport support +#ifndef NIS_VIEWPORT_SUPPORT +#define NIS_VIEWPORT_SUPPORT 0 +#endif + +// HLSL, GLSL +#if NIS_HLSL==0 && !defined(NIS_GLSL) +#define NIS_GLSL 1 +#endif +#if NIS_HLSL_6_2 || (!NIS_GLSL && !NIS_HLSL) +#if defined(NIS_HLSL) +#undef NIS_HLSL +#endif +#define NIS_HLSL 1 +#endif +#if NIS_HLSL && NIS_GLSL +#undef NIS_GLSL +#define NIS_GLSL 0 +#endif + +// Half precision +#ifndef NIS_USE_HALF_PRECISION +#define NIS_USE_HALF_PRECISION 0 +#endif + +#if NIS_HLSL +// Generic type and function aliases for HLSL +#define NVF float +#define NVF2 float2 +#define NVF3 float3 +#define NVF4 float4 +#define NVI int +#define NVI2 int2 +#define NVU uint +#define NVU2 uint2 +#define NVB bool +#if NIS_USE_HALF_PRECISION +#if NIS_HLSL_6_2 +#define NVH float16_t +#define NVH2 float16_t2 +#define NVH3 float16_t3 +#define NVH4 float16_t4 +#else +#define NVH min16float +#define NVH2 min16float2 +#define NVH3 min16float3 +#define NVH4 min16float4 +#endif // NIS_HLSL_6_2 +#else // FP32 types +#define NVH NVF +#define NVH2 NVF2 +#define NVH3 NVF3 +#define NVH4 NVF4 +#endif // NIS_USE_HALF_PRECISION +#define NVSHARED groupshared +#define NVTEX_LOAD(x, pos) x[pos] +#define NVTEX_SAMPLE(x, sampler, pos) x.SampleLevel(sampler, pos, 0) +#define NVTEX_SAMPLE_RED(x, sampler, pos) x.GatherRed(sampler, pos) +#define NVTEX_SAMPLE_GREEN(x, sampler, pos) x.GatherGreen(sampler, pos) +#define NVTEX_SAMPLE_BLUE(x, sampler, pos) x.GatherBlue(sampler, pos) +#define NVTEX_STORE(x, pos, v) x[pos] = v +#ifndef NIS_UNROLL +#define NIS_UNROLL [unroll] +#endif +#endif // NIS_HLSL + +// Generic type and function aliases for GLSL +#if NIS_GLSL +#define NVF float +#define NVF2 vec2 +#define NVF3 vec3 +#define NVF4 vec4 +#define NVI int +#define NVI2 ivec2 +#define NVU uint +#define NVU2 uvec2 +#define NVB bool +#if NIS_USE_HALF_PRECISION +#define NVH float16_t +#define NVH2 f16vec2 +#define NVH3 f16vec3 +#define NVH4 f16vec4 +#else // FP32 types +#define NVH NVF +#define NVH2 NVF2 +#define NVH3 NVF3 +#define NVH4 NVF4 +#endif // NIS_USE_HALF_PRECISION +#define NVSHARED shared +#define NVTEX_LOAD(arr, pos) arr[(pos).y][(pos).x] // Ryujinx: coef from inline const arrays (param renamed to avoid .x swizzle collision) +#define NVTEX_SAMPLE(x, sampler, pos) textureLod(x, pos, 0.0) // Ryujinx: combined sampler2D +#define NVTEX_SAMPLE_RED(x, sampler, pos) textureGather(x, pos, 0) +#define NVTEX_SAMPLE_GREEN(x, sampler, pos) textureGather(x, pos, 1) +#define NVTEX_SAMPLE_BLUE(x, sampler, pos) textureGather(x, pos, 2) +#ifndef NIS_OUTPUT_TRANSFORM +#define NIS_OUTPUT_TRANSFORM(c) (c) // Ryujinx: HDR wrapper overrides this to tonemap to scRGB +#endif +#define NVTEX_STORE(x, pos, v) imageStore(x, NVI2(pos), NIS_OUTPUT_TRANSFORM(v)) +#define saturate(x) clamp(x, 0, 1) +#define lerp(a, b, x) mix(a, b, x) +#define GroupMemoryBarrierWithGroupSync() groupMemoryBarrier(); barrier() +#ifndef NIS_UNROLL +#define NIS_UNROLL +#endif +#endif // NIS_GLSL + +#ifndef NIS_UNROLL_INNER +#define NIS_UNROLL_INNER NIS_UNROLL +#endif + +// Texture gather +#ifndef NIS_TEXTURE_GATHER +#define NIS_TEXTURE_GATHER 0 +#endif + +// NIS Scaling +#define NIS_SCALE_INT 1 +#define NIS_SCALE_FLOAT NVF(1.f) + +// NIS output clamp +#if NIS_CLAMP_OUTPUT +#if NIS_HDR_MODE == NIS_HDR_MODE_LINEAR +#define NVCLAMP(x) ( clamp(x, 0.0f, 12.5f) ) +#else +#define NVCLAMP(x) ( saturate(x) ) +#endif +#else +#define NVCLAMP(x) (x) +#endif + +NVF getY(NVF3 rgba) +{ +#if NIS_HDR_MODE == NIS_HDR_MODE_PQ + return NVF(0.262f) * rgba.x + NVF(0.678f) * rgba.y + NVF(0.0593f) * rgba.z; +#elif NIS_HDR_MODE == NIS_HDR_MODE_LINEAR + return sqrt(NVF(0.2126f) * rgba.x + NVF(0.7152f) * rgba.y + NVF(0.0722f) * rgba.z) * kHDRCompressionFactor; +#else + return NVF(0.2126f) * rgba.x + NVF(0.7152f) * rgba.y + NVF(0.0722f) * rgba.z; +#endif +} + +NVF getYLinear(NVF3 rgba) +{ + return NVF(0.2126f) * rgba.x + NVF(0.7152f) * rgba.y + NVF(0.0722f) * rgba.z; +} + +NVF3 YUVtoRGB(NVF3 yuv) +{ + float y = yuv.x - 16.0f / 255.0f; + float u = yuv.y - 128.0f / 255.0f; + float v = yuv.z - 128.0f / 255.0f; + NVF3 rgb; + rgb.x = saturate(1.164f * y + 1.596f * v); + rgb.y = saturate(1.164f * y - 0.392f * u - 0.813f * v); + rgb.z = saturate(1.164f * y + 2.017f * u); + return rgb; +} + +#if NIS_SCALER +NVF4 GetEdgeMap(NVF p[4][4], NVI i, NVI j) +#else +NVF4 GetEdgeMap(NVF p[5][5], NVI i, NVI j) +#endif +{ + const NVF g_0 = abs(p[0 + i][0 + j] + p[0 + i][1 + j] + p[0 + i][2 + j] - p[2 + i][0 + j] - p[2 + i][1 + j] - p[2 + i][2 + j]); + const NVF g_45 = abs(p[1 + i][0 + j] + p[0 + i][0 + j] + p[0 + i][1 + j] - p[2 + i][1 + j] - p[2 + i][2 + j] - p[1 + i][2 + j]); + const NVF g_90 = abs(p[0 + i][0 + j] + p[1 + i][0 + j] + p[2 + i][0 + j] - p[0 + i][2 + j] - p[1 + i][2 + j] - p[2 + i][2 + j]); + const NVF g_135 = abs(p[1 + i][0 + j] + p[2 + i][0 + j] + p[2 + i][1 + j] - p[0 + i][1 + j] - p[0 + i][2 + j] - p[1 + i][2 + j]); + + const NVF g_0_90_max = max(g_0, g_90); + const NVF g_0_90_min = min(g_0, g_90); + const NVF g_45_135_max = max(g_45, g_135); + const NVF g_45_135_min = min(g_45, g_135); + + NVF e_0_90 = 0; + NVF e_45_135 = 0; + + if (g_0_90_max + g_45_135_max == 0) + { + return NVF4(0, 0, 0, 0); + } + + e_0_90 = min(g_0_90_max / (g_0_90_max + g_45_135_max), 1.0f); + e_45_135 = 1.0f - e_0_90; + + NVB c_0_90 = (g_0_90_max > (g_0_90_min * kDetectRatio)) && (g_0_90_max > kDetectThres) && (g_0_90_max > g_45_135_min); + NVB c_45_135 = (g_45_135_max > (g_45_135_min * kDetectRatio)) && (g_45_135_max > kDetectThres) && (g_45_135_max > g_0_90_min); + NVB c_g_0_90 = g_0_90_max == g_0; + NVB c_g_45_135 = g_45_135_max == g_45; + + NVF f_e_0_90 = (c_0_90 && c_45_135) ? e_0_90 : 1.0f; + NVF f_e_45_135 = (c_0_90 && c_45_135) ? e_45_135 : 1.0f; + + NVF weight_0 = (c_0_90 && c_g_0_90) ? f_e_0_90 : 0.0f; + NVF weight_90 = (c_0_90 && !c_g_0_90) ? f_e_0_90 : 0.0f; + NVF weight_45 = (c_45_135 && c_g_45_135) ? f_e_45_135 : 0.0f; + NVF weight_135 = (c_45_135 && !c_g_45_135) ? f_e_45_135 : 0.0f; + + return NVF4(weight_0, weight_90, weight_45, weight_135); +} + +#if NIS_SCALER + +#ifndef NIS_BLOCK_WIDTH +#define NIS_BLOCK_WIDTH 32 +#endif +#ifndef NIS_BLOCK_HEIGHT +#define NIS_BLOCK_HEIGHT 24 +#endif +#ifndef NIS_THREAD_GROUP_SIZE +#define NIS_THREAD_GROUP_SIZE 256 +#endif +#define kPhaseCount 64 +#define kFilterSize 6 +#define kSupportSize 6 +#define kPadSize kSupportSize +// 'Tile' is the region of source luminance values that we load into shPixelsY. +// It is the area of source pixels covered by the destination 'Block' plus a +// 3 pixel border of support pixels. +#define kTilePitch (NIS_BLOCK_WIDTH + kPadSize) +#define kTileSize (kTilePitch * (NIS_BLOCK_HEIGHT + kPadSize)) +// 'EdgeMap' is the region of source pixels for which edge map vectors are derived. +// It is the area of source pixels covered by the destination 'Block' plus a +// 1 pixel border. +#define kEdgeMapPitch (NIS_BLOCK_WIDTH + 2) +#define kEdgeMapSize (kEdgeMapPitch * (NIS_BLOCK_HEIGHT + 2)) + +NVSHARED NVF shPixelsY[kTileSize]; +NVSHARED NVH shCoefScaler[kPhaseCount][kFilterSize]; +NVSHARED NVH shCoefUSM[kPhaseCount][kFilterSize]; +NVSHARED NVH4 shEdgeMap[kEdgeMapSize]; + +void LoadFilterBanksSh(NVI i0) { + // Load up filter banks to shared memory + // The work is spread over (kPhaseCount * 2) threads + NVI i = i0; +#if( kPhaseCount * 2 > NIS_THREAD_GROUP_SIZE ) + for (; i < kPhaseCount * 2; i += NIS_THREAD_GROUP_SIZE) +#else + if (i < kPhaseCount * 2) +#endif + { + NVI phase = i >> 1; + NVI vIdx = i & 1; + + NVH4 v = NVH4(NVTEX_LOAD(coef_scaler, NVI2(vIdx, phase))); + NVI filterOffset = vIdx * 4; + shCoefScaler[phase][filterOffset + 0] = v.x; + shCoefScaler[phase][filterOffset + 1] = v.y; + if (vIdx == 0) + { + shCoefScaler[phase][2] = v.z; + shCoefScaler[phase][3] = v.w; + } + + v = NVH4(NVTEX_LOAD(coef_usm, NVI2(vIdx, phase))); + shCoefUSM[phase][filterOffset + 0] = v.x; + shCoefUSM[phase][filterOffset + 1] = v.y; + if (vIdx == 0) + { + shCoefUSM[phase][2] = v.z; + shCoefUSM[phase][3] = v.w; + } + } +} + + +NVF CalcLTI(NVF p0, NVF p1, NVF p2, NVF p3, NVF p4, NVF p5, NVI phase_index) +{ + const NVB selector = (phase_index <= kPhaseCount / 2); + NVF sel = selector ? p0 : p3; + const NVF a_min = min(min(p1, p2), sel); + const NVF a_max = max(max(p1, p2), sel); + sel = selector ? p2 : p5; + const NVF b_min = min(min(p3, p4), sel); + const NVF b_max = max(max(p3, p4), sel); + + const NVF a_cont = a_max - a_min; + const NVF b_cont = b_max - b_min; + + const NVF cont_ratio = max(a_cont, b_cont) / (min(a_cont, b_cont) + kEps); + return (1.0f - saturate((cont_ratio - kMinContrastRatio) * kRatioNorm)) * kContrastBoost; +} + +NVF4 GetInterpEdgeMap(const NVF4 edge[2][2], NVF phase_frac_x, NVF phase_frac_y) +{ + NVF4 h0 = lerp(edge[0][0], edge[0][1], phase_frac_x); + NVF4 h1 = lerp(edge[1][0], edge[1][1], phase_frac_x); + return lerp(h0, h1, phase_frac_y); +} + +NVF EvalPoly6(const NVF pxl[6], NVI phase_int) +{ + NVF y = 0.f; + { + NIS_UNROLL + for (NVI i = 0; i < 6; ++i) + { + y += shCoefScaler[phase_int][i] * pxl[i]; + } + } + NVF y_usm = 0.f; + { + NIS_UNROLL + for (NVI i = 0; i < 6; ++i) + { + y_usm += shCoefUSM[phase_int][i] * pxl[i]; + } + } + + // let's compute a piece-wise ramp based on luma + const NVF y_scale = 1.0f - saturate((y * (1.0f / NIS_SCALE_FLOAT) - kSharpStartY) * kSharpScaleY); + + // scale the ramp to sharpen as a function of luma + const NVF y_sharpness = y_scale * kSharpStrengthScale + kSharpStrengthMin; + + y_usm *= y_sharpness; + + // scale the ramp to limit USM as a function of luma + const NVF y_sharpness_limit = (y_scale * kSharpLimitScale + kSharpLimitMin) * y; + + y_usm = min(y_sharpness_limit, max(-y_sharpness_limit, y_usm)); + // reduce ringing + y_usm *= CalcLTI(pxl[0], pxl[1], pxl[2], pxl[3], pxl[4], pxl[5], phase_int); + + return y + y_usm; +} + +NVF FilterNormal(const NVF p[6][6], NVI phase_x_frac_int, NVI phase_y_frac_int) +{ + NVF h_acc = 0.0f; + NIS_UNROLL + for (NVI j = 0; j < 6; ++j) + { + NVF v_acc = 0.0f; + NIS_UNROLL + for (NVI i = 0; i < 6; ++i) + { + v_acc += p[i][j] * shCoefScaler[phase_y_frac_int][i]; + } + h_acc += v_acc * shCoefScaler[phase_x_frac_int][j]; + } + + // let's return the sum unpacked -> we can accumulate it later + return h_acc; +} + +NVF AddDirFilters(NVF p[6][6], NVF phase_x_frac, NVF phase_y_frac, NVI phase_x_frac_int, NVI phase_y_frac_int, NVF4 w) +{ + NVF f = 0; + if (w.x > 0.0f) + { + // 0 deg filter + NVF interp0Deg[6]; + { + NIS_UNROLL + for (NVI i = 0; i < 6; ++i) + { + interp0Deg[i] = lerp(p[i][2], p[i][3], phase_x_frac); + } + } + f += EvalPoly6(interp0Deg, phase_y_frac_int) * w.x; + } + if (w.y > 0.0f) + { + // 90 deg filter + NVF interp90Deg[6]; + { + NIS_UNROLL + for (NVI i = 0; i < 6; ++i) + { + interp90Deg[i] = lerp(p[2][i], p[3][i], phase_y_frac); + } + } + + f += EvalPoly6(interp90Deg, phase_x_frac_int) * w.y; + } + if (w.z > 0.0f) + { + //45 deg filter + NVF pphase_b45 = 0.5f + 0.5f * (phase_x_frac - phase_y_frac); + + NVF temp_interp45Deg[7]; + temp_interp45Deg[1] = lerp(p[2][1], p[1][2], pphase_b45); + temp_interp45Deg[3] = lerp(p[3][2], p[2][3], pphase_b45); + temp_interp45Deg[5] = lerp(p[4][3], p[3][4], pphase_b45); + { + pphase_b45 = pphase_b45 - 0.5f; + NVF a = (pphase_b45 >= 0.f) ? p[0][2] : p[2][0]; + NVF b = (pphase_b45 >= 0.f) ? p[1][3] : p[3][1]; + NVF c = (pphase_b45 >= 0.f) ? p[2][4] : p[4][2]; + NVF d = (pphase_b45 >= 0.f) ? p[3][5] : p[5][3]; + temp_interp45Deg[0] = lerp(p[1][1], a, abs(pphase_b45)); + temp_interp45Deg[2] = lerp(p[2][2], b, abs(pphase_b45)); + temp_interp45Deg[4] = lerp(p[3][3], c, abs(pphase_b45)); + temp_interp45Deg[6] = lerp(p[4][4], d, abs(pphase_b45)); + } + + NVF interp45Deg[6]; + NVF pphase_p45 = phase_x_frac + phase_y_frac; + if (pphase_p45 >= 1) + { + NIS_UNROLL + for (NVI i = 0; i < 6; i++) + { + interp45Deg[i] = temp_interp45Deg[i + 1]; + } + pphase_p45 = pphase_p45 - 1; + } + else + { + NIS_UNROLL + for (NVI i = 0; i < 6; i++) + { + interp45Deg[i] = temp_interp45Deg[i]; + } + } + + f += EvalPoly6(interp45Deg, NVI(pphase_p45 * 64)) * w.z; + } + if (w.w > 0.0f) + { + //135 deg filter + NVF pphase_b135 = 0.5f * (phase_x_frac + phase_y_frac); + + NVF temp_interp135Deg[7]; + temp_interp135Deg[1] = lerp(p[3][1], p[4][2], pphase_b135); + temp_interp135Deg[3] = lerp(p[2][2], p[3][3], pphase_b135); + temp_interp135Deg[5] = lerp(p[1][3], p[2][4], pphase_b135); + { + pphase_b135 = pphase_b135 - 0.5f; + NVF a = (pphase_b135 >= 0.f) ? p[5][2] : p[3][0]; + NVF b = (pphase_b135 >= 0.f) ? p[4][3] : p[2][1]; + NVF c = (pphase_b135 >= 0.f) ? p[3][4] : p[1][2]; + NVF d = (pphase_b135 >= 0.f) ? p[2][5] : p[0][3]; + temp_interp135Deg[0] = lerp(p[4][1], a, abs(pphase_b135)); + temp_interp135Deg[2] = lerp(p[3][2], b, abs(pphase_b135)); + temp_interp135Deg[4] = lerp(p[2][3], c, abs(pphase_b135)); + temp_interp135Deg[6] = lerp(p[1][4], d, abs(pphase_b135)); + } + + NVF interp135Deg[6]; + NVF pphase_p135 = 1 + (phase_x_frac - phase_y_frac); + if (pphase_p135 >= 1) + { + NIS_UNROLL + for (NVI i = 0; i < 6; ++i) + { + interp135Deg[i] = temp_interp135Deg[i + 1]; + } + pphase_p135 = pphase_p135 - 1; + } + else + { + NIS_UNROLL + for (NVI i = 0; i < 6; ++i) + { + interp135Deg[i] = temp_interp135Deg[i]; + } + } + + f += EvalPoly6(interp135Deg, NVI(pphase_p135 * 64)) * w.w; + } + return f; +} + + +//----------------------------------------------------------------------------------------------- +// NVScaler +//----------------------------------------------------------------------------------------------- +void NVScaler(NVU2 blockIdx, NVU threadIdx) +{ + // Figure out the range of pixels from input image that would be needed to be loaded for this thread-block + NVI dstBlockX = NVI(NIS_BLOCK_WIDTH * blockIdx.x); + NVI dstBlockY = NVI(NIS_BLOCK_HEIGHT * blockIdx.y); + + const NVI srcBlockStartX = NVI(floor((dstBlockX + 0.5f) * kScaleX - 0.5f)); + const NVI srcBlockStartY = NVI(floor((dstBlockY + 0.5f) * kScaleY - 0.5f)); + const NVI srcBlockEndX = NVI(ceil((dstBlockX + NIS_BLOCK_WIDTH + 0.5f) * kScaleX - 0.5f)); + const NVI srcBlockEndY = NVI(ceil((dstBlockY + NIS_BLOCK_HEIGHT + 0.5f) * kScaleY - 0.5f)); + + NVI numTilePixelsX = srcBlockEndX - srcBlockStartX + kSupportSize - 1; + NVI numTilePixelsY = srcBlockEndY - srcBlockStartY + kSupportSize - 1; + + // round-up load region to even size since we're loading in 2x2 batches + numTilePixelsX += numTilePixelsX & 0x1; + numTilePixelsY += numTilePixelsY & 0x1; + const NVI numTilePixels = numTilePixelsX * numTilePixelsY; + + // calculate the equivalent values for the edge map + const NVI numEdgeMapPixelsX = numTilePixelsX - kSupportSize + 2; + const NVI numEdgeMapPixelsY = numTilePixelsY - kSupportSize + 2; + const NVI numEdgeMapPixels = numEdgeMapPixelsX * numEdgeMapPixelsY; + + // fill in input luma tile (shPixelsY) in batches of 2x2 pixels + // we use texture gather to get extra support necessary + // to compute 2x2 edge map outputs too + { + for (NVU i = threadIdx * 2; i < NVU(numTilePixels) >> 1; i += NIS_THREAD_GROUP_SIZE * 2) + { + NVU py = (i / numTilePixelsX) * 2; + NVU px = i % numTilePixelsX; + + // 0.5 to be in the center of texel + // - (kSupportSize - 1) / 2 to shift by the kernel support size + NVF kShift = 0.5f - (kSupportSize - 1) / 2; +#if NIS_VIEWPORT_SUPPORT + const NVF tx = (srcBlockStartX + px + kInputViewportOriginX + kShift) * kSrcNormX; + const NVF ty = (srcBlockStartY + py + kInputViewportOriginY + kShift) * kSrcNormY; +#else + const NVF tx = (srcBlockStartX + px + kShift) * kSrcNormX; + const NVF ty = (srcBlockStartY + py + kShift) * kSrcNormY; +#endif + NVF p[2][2]; +#if NIS_TEXTURE_GATHER + { + const NVF4 sr = NVTEX_SAMPLE_RED(in_texture, samplerLinearClamp, NVF2(tx, ty)); + const NVF4 sg = NVTEX_SAMPLE_GREEN(in_texture, samplerLinearClamp, NVF2(tx, ty)); + const NVF4 sb = NVTEX_SAMPLE_BLUE(in_texture, samplerLinearClamp, NVF2(tx, ty)); + + p[0][0] = getY(NVF3(sr.w, sg.w, sb.w)); + p[0][1] = getY(NVF3(sr.z, sg.z, sb.z)); + p[1][0] = getY(NVF3(sr.x, sg.x, sb.x)); + p[1][1] = getY(NVF3(sr.y, sg.y, sb.y)); + } +#else + NIS_UNROLL_INNER + for (NVI j = 0; j < 2; j++) + { + NIS_UNROLL_INNER + for (NVI k = 0; k < 2; k++) + { +#if NIS_NV12_SUPPORT + p[j][k] = NVTEX_SAMPLE(in_texture_y, samplerLinearClamp, NVF2(tx + k * kSrcNormX, ty + j * kSrcNormY)); +#else + const NVF4 px = NVTEX_SAMPLE(in_texture, samplerLinearClamp, NVF2(tx + k * kSrcNormX, ty + j * kSrcNormY)); + p[j][k] = getY(px.xyz); +#endif + } + } +#endif + const NVU idx = py * kTilePitch + px; + shPixelsY[idx] = NVH(p[0][0]); + shPixelsY[idx + 1] = NVH(p[0][1]); + shPixelsY[idx + kTilePitch] = NVH(p[1][0]); + shPixelsY[idx + kTilePitch + 1] = NVH(p[1][1]); + } + } + GroupMemoryBarrierWithGroupSync(); + { + // fill in the edge map of 2x2 pixels + for (NVU i = threadIdx * 2; i < NVU(numEdgeMapPixels) >> 1; i += NIS_THREAD_GROUP_SIZE * 2) + { + NVU py = (i / numEdgeMapPixelsX) * 2; + NVU px = i % numEdgeMapPixelsX; + + const NVU edgeMapIdx = py * kEdgeMapPitch + px; + + NVU tileCornerIdx = (py + 1) * kTilePitch + px + 1; + NVF p[4][4]; + NIS_UNROLL_INNER + for (NVI j = 0; j < 4; j++) + { + NIS_UNROLL_INNER + for (NVI k = 0; k < 4; k++) + { + p[j][k] = shPixelsY[tileCornerIdx + j * kTilePitch + k]; + } + } + + shEdgeMap[edgeMapIdx] = NVH4(GetEdgeMap(p, 0, 0)); + shEdgeMap[edgeMapIdx + 1] = NVH4(GetEdgeMap(p, 0, 1)); + shEdgeMap[edgeMapIdx + kEdgeMapPitch] = NVH4(GetEdgeMap(p, 1, 0)); + shEdgeMap[edgeMapIdx + kEdgeMapPitch + 1] = NVH4(GetEdgeMap(p, 1, 1)); + } + } + LoadFilterBanksSh(NVI(threadIdx)); + GroupMemoryBarrierWithGroupSync(); + + // output coord within a tile + const NVI2 pos = NVI2(NVU(threadIdx) % NVU(NIS_BLOCK_WIDTH), NVU(threadIdx) / NVU(NIS_BLOCK_WIDTH)); + // x coord inside the output image + const NVI dstX = dstBlockX + pos.x; + // x coord inside the input image + const NVF srcX = (0.5f + dstX) * kScaleX - 0.5f; + // nearest integer part + const NVI px = NVI(floor(srcX) - srcBlockStartX); + // fractional part + const NVF fx = srcX - floor(srcX); + // discretized phase + const NVI fx_int = NVI(fx * kPhaseCount); +#if NIS_VIEWPORT_SUPPORT + if (NVU(srcX) > kInputViewportWidth || NVU(dstX) > kOutputViewportWidth) + { + return; + } +#endif + for (NVI k = 0; k < NIS_BLOCK_WIDTH * NIS_BLOCK_HEIGHT / NIS_THREAD_GROUP_SIZE; ++k) + { + // y coord inside the output image + const NVI dstY = dstBlockY + pos.y + k * (NIS_THREAD_GROUP_SIZE / NIS_BLOCK_WIDTH); + // y coord inside the input image + const NVF srcY = (0.5f + dstY) * kScaleY - 0.5f; +#if NIS_VIEWPORT_SUPPORT + if (!(NVU(srcY) > kInputViewportHeight || NVU(dstY) > kOutputViewportHeight)) +#endif + { + // nearest integer part + const NVI py = NVI(floor(srcY) - srcBlockStartY); + // fractional part + const NVF fy = srcY - floor(srcY); + // discretized phase + const NVI fy_int = NVI(fy * kPhaseCount); + + // generate weights for directional filters + const NVI startEdgeMapIdx = py * kEdgeMapPitch + px; + NVF4 edge[2][2]; + NIS_UNROLL + for (NVI i = 0; i < 2; i++) + { + NIS_UNROLL + for (NVI j = 0; j < 2; j++) + { + // need to shift edge map sampling since it's a 2x2 centered inside 6x6 grid + edge[i][j] = shEdgeMap[startEdgeMapIdx + (i * kEdgeMapPitch) + j]; + } + } + const NVF4 w = GetInterpEdgeMap(edge, fx, fy) * NIS_SCALE_INT; + + // load 6x6 support to regs + const NVI startTileIdx = py * kTilePitch + px; + NVF p[6][6]; + { + NIS_UNROLL + for (NVI i = 0; i < 6; ++i) + { + NIS_UNROLL + for (NVI j = 0; j < 6; ++j) + { + p[i][j] = shPixelsY[startTileIdx + i * kTilePitch + j]; + } + } + } + + // weigth for luma + const NVF baseWeight = NIS_SCALE_FLOAT - w.x - w.y - w.z - w.w; + + // final luma is a weighted product of directional & normal filters + NVF opY = 0; + + // get traditional scaler filter output + opY += FilterNormal(p, fx_int, fy_int) * baseWeight; + + // get directional filter bank output + opY += AddDirFilters(p, fx, fy, fx_int, fy_int, w); + +#if NIS_VIEWPORT_SUPPORT + NVF2 coord = NVF2((srcX + kInputViewportOriginX + 0.5f) * kSrcNormX, (srcY + kInputViewportOriginY + 0.5f) * kSrcNormY); + NVF2 dstCoord = NVF2(dstX + kOutputViewportOriginX, dstY + kOutputViewportOriginY); +#else + NVF2 coord = NVF2((srcX + 0.5f) * kSrcNormX, (srcY + 0.5f) * kSrcNormY); + NVF2 dstCoord = NVF2(dstX, dstY); +#endif + // do bilinear tap for chroma upscaling +#if NIS_NV12_SUPPORT + NVF y = NVTEX_SAMPLE(in_texture_y, samplerLinearClamp, coord); + NVF2 uv = NVTEX_SAMPLE(in_texture_uv, samplerLinearClamp, coord); + NVF4 op = NVF4(YUVtoRGB(NVF3(y, uv)), 1.0f); +#else + NVF4 op = NVTEX_SAMPLE(in_texture, samplerLinearClamp, coord); + NVF y = getY(NVF3(op.x, op.y, op.z)); +#endif + +#if NIS_HDR_MODE == NIS_HDR_MODE_LINEAR + const NVF kEps = 1e-4f; + const NVF kNorm = 1.0f / (NIS_SCALE_FLOAT * kHDRCompressionFactor); + const NVF opYN = max(opY, 0.0f) * kNorm; + const NVF corr = (opYN * opYN + kEps) / (max(getYLinear(NVF3(op.x, op.y, op.z)), 0.0f) + kEps); + op.x *= corr; + op.y *= corr; + op.z *= corr; +#else + const NVF corr = opY * (1.0f / NIS_SCALE_FLOAT) - y; + op.x += corr; + op.y += corr; + op.z += corr; +#endif + NVTEX_STORE(out_texture, dstCoord, NVCLAMP(op)); + } + } +} +#else + +#ifndef NIS_BLOCK_WIDTH +#define NIS_BLOCK_WIDTH 32 +#endif +#ifndef NIS_BLOCK_HEIGHT +#define NIS_BLOCK_HEIGHT 32 +#endif +#ifndef NIS_THREAD_GROUP_SIZE +#define NIS_THREAD_GROUP_SIZE 256 +#endif + +#define kSupportSize 5 +#define kNumPixelsX (NIS_BLOCK_WIDTH + kSupportSize + 1) +#define kNumPixelsY (NIS_BLOCK_HEIGHT + kSupportSize + 1) + +NVSHARED NVF shPixelsY[kNumPixelsY][kNumPixelsX]; + +NVF CalcLTIFast(const NVF y[5]) +{ + const NVF a_min = min(min(y[0], y[1]), y[2]); + const NVF a_max = max(max(y[0], y[1]), y[2]); + + const NVF b_min = min(min(y[2], y[3]), y[4]); + const NVF b_max = max(max(y[2], y[3]), y[4]); + + const NVF a_cont = a_max - a_min; + const NVF b_cont = b_max - b_min; + + const NVF cont_ratio = max(a_cont, b_cont) / (min(a_cont, b_cont) + kEps); + return (1.0f - saturate((cont_ratio - kMinContrastRatio) * kRatioNorm)) * kContrastBoost; +} + +NVF EvalUSM(const NVF pxl[5], const NVF sharpnessStrength, const NVF sharpnessLimit) +{ + // USM profile + NVF y_usm = -0.6001f * pxl[1] + 1.2002f * pxl[2] - 0.6001f * pxl[3]; + // boost USM profile + y_usm *= sharpnessStrength; + // clamp to the limit + y_usm = min(sharpnessLimit, max(-sharpnessLimit, y_usm)); + // reduce ringing + y_usm *= CalcLTIFast(pxl); + + return y_usm; +} + +NVF4 GetDirUSM(const NVF p[5][5]) +{ + // sharpness boost & limit are the same for all directions + const NVF scaleY = 1.0f - saturate((p[2][2] - kSharpStartY) * kSharpScaleY); + // scale the ramp to sharpen as a function of luma + const NVF sharpnessStrength = scaleY * kSharpStrengthScale + kSharpStrengthMin; + // scale the ramp to limit USM as a function of luma + const NVF sharpnessLimit = (scaleY * kSharpLimitScale + kSharpLimitMin) * p[2][2]; + + NVF4 rval; + // 0 deg filter + NVF interp0Deg[5]; + { + for (NVI i = 0; i < 5; ++i) + { + interp0Deg[i] = p[i][2]; + } + } + + rval.x = EvalUSM(interp0Deg, sharpnessStrength, sharpnessLimit); + + // 90 deg filter + NVF interp90Deg[5]; + { + for (NVI i = 0; i < 5; ++i) + { + interp90Deg[i] = p[2][i]; + } + } + + rval.y = EvalUSM(interp90Deg, sharpnessStrength, sharpnessLimit); + + //45 deg filter + NVF interp45Deg[5]; + interp45Deg[0] = p[1][1]; + interp45Deg[1] = lerp(p[2][1], p[1][2], 0.5f); + interp45Deg[2] = p[2][2]; + interp45Deg[3] = lerp(p[3][2], p[2][3], 0.5f); + interp45Deg[4] = p[3][3]; + + rval.z = EvalUSM(interp45Deg, sharpnessStrength, sharpnessLimit); + + //135 deg filter + NVF interp135Deg[5]; + interp135Deg[0] = p[3][1]; + interp135Deg[1] = lerp(p[3][2], p[2][1], 0.5f); + interp135Deg[2] = p[2][2]; + interp135Deg[3] = lerp(p[2][3], p[1][2], 0.5f); + interp135Deg[4] = p[1][3]; + + rval.w = EvalUSM(interp135Deg, sharpnessStrength, sharpnessLimit); + return rval; +} + +//----------------------------------------------------------------------------------------------- +// NVSharpen +//----------------------------------------------------------------------------------------------- +void NVSharpen(NVU2 blockIdx, NVU threadIdx) +{ + const NVI dstBlockX = NVI(NIS_BLOCK_WIDTH * blockIdx.x); + const NVI dstBlockY = NVI(NIS_BLOCK_HEIGHT * blockIdx.y); + + // fill in input luma tile in batches of 2x2 pixels + // we use texture gather to get extra support necessary + // to compute 2x2 edge map outputs too + const NVF kShift = 0.5f - kSupportSize / 2; + + for (NVI i = NVI(threadIdx) * 2; i < kNumPixelsX * kNumPixelsY / 2; i += NIS_THREAD_GROUP_SIZE * 2) + { + NVU2 pos = NVU2(NVU(i) % NVU(kNumPixelsX), NVU(i) / NVU(kNumPixelsX) * 2); + NIS_UNROLL + for (NVI dy = 0; dy < 2; dy++) + { + NIS_UNROLL + for (NVI dx = 0; dx < 2; dx++) + { +#if NIS_VIEWPORT_SUPPORT + const NVF tx = (dstBlockX + pos.x + kInputViewportOriginX + dx + kShift) * kSrcNormX; + const NVF ty = (dstBlockY + pos.y + kInputViewportOriginY + dy + kShift) * kSrcNormY; +#else + const NVF tx = (dstBlockX + pos.x + dx + kShift) * kSrcNormX; + const NVF ty = (dstBlockY + pos.y + dy + kShift) * kSrcNormY; +#endif +#if NIS_NV12_SUPPORT + shPixelsY[pos.y + dy][pos.x + dx] = NVTEX_SAMPLE(in_texture_y, samplerLinearClamp, NVF2(tx, ty)); +#else + const NVF4 px = NVTEX_SAMPLE(in_texture, samplerLinearClamp, NVF2(tx, ty)); + shPixelsY[pos.y + dy][pos.x + dx] = getY(px.xyz); +#endif + } + } + } + + GroupMemoryBarrierWithGroupSync(); + + for (NVI k = NVI(threadIdx); k < NIS_BLOCK_WIDTH * NIS_BLOCK_HEIGHT; k += NIS_THREAD_GROUP_SIZE) + { + const NVI2 pos = NVI2(NVU(k) % NVU(NIS_BLOCK_WIDTH), NVU(k) / NVU(NIS_BLOCK_WIDTH)); + + // load 5x5 support to regs + NVF p[5][5]; + NIS_UNROLL + for (NVI i = 0; i < 5; ++i) + { + NIS_UNROLL + for (NVI j = 0; j < 5; ++j) + { + p[i][j] = shPixelsY[pos.y + i][pos.x + j]; + } + } + + // get directional filter bank output + NVF4 dirUSM = GetDirUSM(p); + + // generate weights for directional filters + NVF4 w = GetEdgeMap(p, kSupportSize / 2 - 1, kSupportSize / 2 - 1); + + // final USM is a weighted sum filter outputs + const NVF usmY = (dirUSM.x * w.x + dirUSM.y * w.y + dirUSM.z * w.z + dirUSM.w * w.w); + + // do bilinear tap and correct rgb texel so it produces new sharpened luma + const NVI dstX = dstBlockX + pos.x; + const NVI dstY = dstBlockY + pos.y; + +#if NIS_VIEWPORT_SUPPORT + NVF2 coord = NVF2((dstX + kInputViewportOriginX + 0.5f) * kSrcNormX, (dstY + kInputViewportOriginY + 0.5f) * kSrcNormY); + NVF2 dstCoord = NVF2(dstX + kOutputViewportOriginX, dstY + kOutputViewportOriginY); + if (!(NVU(dstX) > kOutputViewportWidth || NVU(dstY) > kOutputViewportHeight)) +#else + NVF2 coord = NVF2((dstX + 0.5f) * kSrcNormX, (dstY + 0.5f) * kSrcNormY); + NVF2 dstCoord = NVF2(dstX, dstY); +#endif + { +#if NIS_NV12_SUPPORT + NVF y = NVTEX_SAMPLE(in_texture_y, samplerLinearClamp, coord); + NVF2 uv = NVTEX_SAMPLE(in_texture_uv, samplerLinearClamp, coord); + NVF4 op = NVF4(YUVtoRGB(NVF3(y, uv)), 1.0f); +#else + NVF4 op = NVTEX_SAMPLE(in_texture, samplerLinearClamp, coord); +#endif +#if NIS_HDR_MODE == NIS_HDR_MODE_LINEAR + const NVF kEps = 1e-4f * kHDRCompressionFactor * kHDRCompressionFactor; + NVF newY = p[2][2] + usmY; + newY = max(newY, 0.0f); + const NVF oldY = p[2][2]; + const NVF corr = (newY * newY + kEps) / (oldY * oldY + kEps); + op.x *= corr; + op.y *= corr; + op.z *= corr; +#else + op.x += usmY; + op.y += usmY; + op.z += usmY; +#endif + NVTEX_STORE(out_texture, dstCoord, NVCLAMP(op)); + } + } +} +#endif \ No newline at end of file diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/NisScaling.glsl b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/NisScaling.glsl new file mode 100644 index 000000000..1aae48625 --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/NisScaling.glsl @@ -0,0 +1,88 @@ +// The MIT License (MIT) +// +// Copyright (c) 2022 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// +// NVIDIA Image Scaling (NIS) v1.0.3 - MIT License, (c) 2022 NVIDIA CORPORATION. +// Vulkan compute wrapper adapted for Ryujinx: combined samplers + inline coefficient +// tables (see NisScaler.h macro edits and nis_coef.glsl). Output path: SDR (rgba8). + +#version 450 core +#extension GL_GOOGLE_include_directive : require + +#define NIS_GLSL 1 +#define NIS_SCALER 1 +#define NIS_THREAD_GROUP_SIZE 256 +#define NIS_BLOCK_WIDTH 32 +#define NIS_BLOCK_HEIGHT 24 + +layout(local_size_x = NIS_THREAD_GROUP_SIZE, local_size_y = 1, local_size_z = 1) in; + +// NISConfig constant buffer (exact field order; filled host-side by NisScalingFilter.cs). +layout(binding = 2) uniform cb +{ + float kDetectRatio; + float kDetectThres; + float kMinContrastRatio; + float kRatioNorm; + + float kContrastBoost; + float kEps; + float kSharpStartY; + float kSharpScaleY; + + float kSharpStrengthMin; + float kSharpStrengthScale; + float kSharpLimitMin; + float kSharpLimitScale; + + float kScaleX; + float kScaleY; + + float kDstNormX; + float kDstNormY; + float kSrcNormX; + float kSrcNormY; + + uint kInputViewportOriginX; + uint kInputViewportOriginY; + uint kInputViewportWidth; + uint kInputViewportHeight; + + uint kOutputViewportOriginX; + uint kOutputViewportOriginY; + uint kOutputViewportWidth; + uint kOutputViewportHeight; + + float reserved0; + float reserved1; +}; + +layout(binding = 1, set = 2) uniform sampler2D in_texture; +layout(rgba8, binding = 0, set = 3) uniform image2D out_texture; + +// coef_scaler[64][2] and coef_usm[64][2] (vec4 = taps0-3 / taps4-7), MIT, verbatim values. +#include "nis_coef.glsl" + +#include "NisScaler.h" + +void main() +{ + NVScaler(uvec2(gl_WorkGroupID.xy), gl_LocalInvocationID.x); +} diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/NisScaling.spv b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/NisScaling.spv new file mode 100644 index 0000000000000000000000000000000000000000..b296cd6d88295c260158322b858642a22c59e4e0 GIT binary patch literal 56760 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 literal 0 HcmV?d00001 diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/NisScalingHdr.glsl b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/NisScalingHdr.glsl new file mode 100644 index 000000000..b1409e32e --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/NisScalingHdr.glsl @@ -0,0 +1,125 @@ +// The MIT License (MIT) +// +// Copyright (c) 2022 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// +// NVIDIA Image Scaling (NIS) v1.0.3 - HDR variant for Ryujinx. Same as NisScaling.glsl +// but outputs to an scRGB (rgba16f) swapchain: NIS runs in SDR space, then the result is +// inverse-tone-mapped to scRGB via NIS_OUTPUT_TRANSFORM. The tonemap below matches the +// fork's existing HDR path (FsrSharpeningHdr) exactly. + +#version 450 core +#extension GL_GOOGLE_include_directive : require + +#define NIS_GLSL 1 +#define NIS_SCALER 1 +#define NIS_THREAD_GROUP_SIZE 256 +#define NIS_BLOCK_WIDTH 32 +#define NIS_BLOCK_HEIGHT 24 + +layout(local_size_x = NIS_THREAD_GROUP_SIZE, local_size_y = 1, local_size_z = 1) in; + +layout(binding = 2) uniform cb +{ + float kDetectRatio; + float kDetectThres; + float kMinContrastRatio; + float kRatioNorm; + + float kContrastBoost; + float kEps; + float kSharpStartY; + float kSharpScaleY; + + float kSharpStrengthMin; + float kSharpStrengthScale; + float kSharpLimitMin; + float kSharpLimitScale; + + float kScaleX; + float kScaleY; + + float kDstNormX; + float kDstNormY; + float kSrcNormX; + float kSrcNormY; + + uint kInputViewportOriginX; + uint kInputViewportOriginY; + uint kInputViewportWidth; + uint kInputViewportHeight; + + uint kOutputViewportOriginX; + uint kOutputViewportOriginY; + uint kOutputViewportWidth; + uint kOutputViewportHeight; + + float reserved0; + float reserved1; +}; + +layout(binding = 1, set = 2) uniform sampler2D in_texture; +layout(rgba16f, binding = 0, set = 3) uniform image2D out_texture; + +// HDR tone-map parameters (filled host-side; same values the other filters receive). +layout(binding = 3, std140) uniform hdrParams +{ + float hdrPaperWhite; + float hdrPeak; + float hdrCurve; + float hdrGamma; + float hdrBlend; + float hdrWhiten; +}; + +#include "nis_coef.glsl" + +// SDR -> scRGB inverse tone map. Ported verbatim from the fork's FsrSharpeningHdr path. +vec3 sdrToLinear(vec3 c, float g) +{ + return pow(max(c, vec3(0.0)), vec3(g)); +} + +vec3 sdrToHdr(vec3 srgb) +{ + vec3 lin = sdrToLinear(srgb, hdrGamma); + float luma = dot(lin, vec3(0.2126, 0.7152, 0.0722)); + float maxc = max(max(lin.x, lin.y), lin.z); + float hl = mix(luma, maxc, hdrBlend); + float hlAmount = pow(clamp(hl, 0.0, 1.0), hdrCurve); + float boost = mix(1.0, hdrPeak / hdrPaperWhite, hlAmount); + vec3 outc = (lin * hdrPaperWhite) * boost; + float t = hdrWhiten * hlAmount; + float lumaOut = dot(outc, vec3(0.2126, 0.7152, 0.0722)); + return mix(outc, vec3(lumaOut), vec3(t)); +} + +vec4 NisStore(vec4 c) +{ + return vec4(sdrToHdr(c.rgb), c.a); +} + +#define NIS_OUTPUT_TRANSFORM(c) NisStore(c) + +#include "NisScaler.h" + +void main() +{ + NVScaler(uvec2(gl_WorkGroupID.xy), gl_LocalInvocationID.x); +} diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/NisScalingHdr.spv b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/NisScalingHdr.spv new file mode 100644 index 0000000000000000000000000000000000000000..e9567dedb304466e4126ee54c0e6703719b7f51d GIT binary patch literal 59420 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.Offset, hdrParams); _pipeline.SetUniformBuffers([new BufferAssignment(1, buffer.Range), new BufferAssignment(2, hdrBuffer.Range)]); diff --git a/src/Ryujinx.Graphics.Vulkan/Ryujinx.Graphics.Vulkan.csproj b/src/Ryujinx.Graphics.Vulkan/Ryujinx.Graphics.Vulkan.csproj index e59823648..a370b137d 100644 --- a/src/Ryujinx.Graphics.Vulkan/Ryujinx.Graphics.Vulkan.csproj +++ b/src/Ryujinx.Graphics.Vulkan/Ryujinx.Graphics.Vulkan.csproj @@ -16,6 +16,10 @@ + + + + diff --git a/src/Ryujinx.Graphics.Vulkan/Shaders/ColorBlitHdrFragmentShaderSource.frag b/src/Ryujinx.Graphics.Vulkan/Shaders/ColorBlitHdrFragmentShaderSource.frag index 6a8db28fc..35b9010e7 100644 --- a/src/Ryujinx.Graphics.Vulkan/Shaders/ColorBlitHdrFragmentShaderSource.frag +++ b/src/Ryujinx.Graphics.Vulkan/Shaders/ColorBlitHdrFragmentShaderSource.frag @@ -8,6 +8,8 @@ layout (std140, binding = 2) uniform hdr_params vec4 hdr_params_data; // highlight colour mix: 0 = luma (calm/white highlights), 1 = max channel (coloured lights pop) float hdr_blend_data; + // highlight whitening: 0 = keep colour (old look), 1 = fully desaturate extreme highlights to white + float hdr_whiten_data; }; layout (location = 0) in vec2 tex_coord; @@ -41,8 +43,15 @@ vec3 sdrToHdr(vec3 srgb) float luma = dot(lin, vec3(0.2126, 0.7152, 0.0722)); float maxc = max(max(lin.r, lin.g), lin.b); float hl = mix(luma, maxc, hdr_blend_data); - float boost = mix(1.0, peak / paperWhite, pow(clamp(hl, 0.0, 1.0), curve)); - return lin * paperWhite * boost; + float hlAmount = pow(clamp(hl, 0.0, 1.0), curve); + float boost = mix(1.0, peak / paperWhite, hlAmount); + vec3 outc = lin * paperWhite * boost; + // Desaturate the most extreme highlights toward white: a blinding light (sun, fire, a bright lamp) + // physically reads as white, not a saturated colour. Luminance-preserving (mix toward a grey of + // equal brightness) so only the harsh hue at the very top is removed, not the brightness itself. + float t = hdr_whiten_data * hlAmount; + float lumaOut = dot(outc, vec3(0.2126, 0.7152, 0.0722)); + return mix(outc, vec3(lumaOut), t); } void main() diff --git a/src/Ryujinx.Graphics.Vulkan/Shaders/SpirvBinaries/ColorBlitHdrFragment.spv b/src/Ryujinx.Graphics.Vulkan/Shaders/SpirvBinaries/ColorBlitHdrFragment.spv index b86284304ba8122bf60b3b71574e37304661a472..ff6bc4fdca878c395902bab61fb90e27074c69e7 100644 GIT binary patch literal 3520 zcmZ9N*>+S_5QaA-0b~%6NffaIs33?SLx9K-6@wZK2pUnb>2x=wrIU`GZU#q!qIlzt zUicoqfluHI_#WQq^7~Go3dfwaYVG=~{u*}eea`7yx^sC-OVX;eCjFFp&-%0!OTxA+ zTSvz(jqRW7)b<}fazMu8sV^hSS(}!pepY3=(wNcl6*7;kgQuMWXnkZ&=?{YX)5VW@Q!m#V?RvG-xK)?8qQGm+RGZyekuU(GSa*O*Yp5|- zYhP}Sk*w0*ee3$6;p+#7(*X5X;j1FXt!BMZnVzm>cv)J{n#G-~^@4KVI_>QnlPp=> zHr7XR18F;Ws@5*gR@#;6xx~blXEynd?B!aeQ(5piH+_@M`b@3oTJSj|eYd9?o%&4A zY40WMKI*$!nN`-$9`vks=Ic*;|Azm>0+jjOt-o-ou0oxO`uG+T5}ql^;OnZx3dth621ny zwZ^-hEVuUF8TF+*^6Gc8I^HSta<$cJ*U-&7FSpffb=y6B9~`q7rBBbsd7guiPEO~?U+n2{|Mq7qn zLbky7{SoUgfmg6MNt<&Nd$)W%i!WHsXMXu#vYJb~e6e>;oBn&O z&d~GHFP=xV9px;|(~tOQ`y0yut6c9+2l0*tzMtch`0ep7Vt-m=TYQ|{`o2f}W^2EQ&AB{4wEKkZ2Xt-L3EPjk z&AJ!JX0HzsF-7w7pnYwTwOx}4Qy zFvn)ZzIG$}eE;OSmo11nE`a1dncKAYe7zi7!RB}hF`vADsjI!%ue0*;I}rW)&9lcR zSUqF=-iBB&&fMOLcuxV#_kR<9vHzPQ_Fq%PM;GA}i}1)I?7yZM@BgNVCkogzxLUyW zQ(c4`IUZrIepjAHO2`gm7ZUS-0bQSWz?q2S9A89tcKY_PiX(0xx_o`kMtnR!!)g#SwEDy@VKZ2#J^@==zND9K;cG484RHGlWFU zaddsgcvfP4y_xs!>aY3xb_U-t;3+AFt>3;JlG@ihZBOrrr0% zci~NB31Tke^+)_@ZZGcP95(F{{}y`0n@hj_jZhQFXBE#_xuB2LASq$`8j@sE=PNCK1MfB^!5pQ?8}~vkz+mILH)*hPd-IFtH@PG zx8Bdx(|-+-qdhnkbn^sf68##Y-5S2v+Ttwg=q03vM2_p|+5_J}cMlKpeNLgv(QeKv zt2X)e^%-KFxZgK(TiovkSX=O$=<=i9G`gJlX3wB&3khBeo3(>Ci>@uincL6jNX*cfxaIqFyRZPpd&WUVnp|xb&-rtoy+4&cuL6mm}$=i4tbY!!>fFha^>M-ln+X^H2HUB+(xXOG~($VYmrt_ z!%mttgIn@3OE_bS_hp|5ETsOMY#_i>PmBmS@;_xmp)8Om=JVj@^>Lb$+{fi!OR=sr zttrSJ`S`7R(6T3>gP2_h0(&+zgV~1yHSlYC6=wYq-~k6s=w9~EL7sW4?b$B{h#<*w z;zbQ-kew1pG?==mvrvm-dldtJVN*8l8r^8qIBpCiL zoaK5S6we8$Wa&p?M(BGHpX5^mpVh3(egxja z6Wj{T6lBA;d=e21K>!Wh8h<=_f2HA>P=OL diff --git a/src/Ryujinx.Graphics.Vulkan/Shaders/SpirvBinaries/ColorBlitHdrFragment.spv.bak b/src/Ryujinx.Graphics.Vulkan/Shaders/SpirvBinaries/ColorBlitHdrFragment.spv.bak new file mode 100644 index 0000000000000000000000000000000000000000..b86284304ba8122bf60b3b71574e37304661a472 GIT binary patch literal 2952 zcmZ9N+g4mf5Qdu!1JPg(=78}Kha@VIh)GBwh(ORpFcL!!ig;oT%y3YK!Qo&oz4Xc( zz3@GJ1D_x-;CpzZ%kSHJ7a8fbYP$ZazpAUNyZ4k1UFc4!BOOVH(~l`1C(xNjn#gf@+F1atMAnr4B&dwFonMCW z0>159>v3~-eY0L|_3y0=jjdcAgIl7Gl$H_WdYD(MjYc)YUFigK7B^GNg9_ep?XA@% zrY!DN=4Wx`bPl{$Yb|b7Th+#PVyE4iP5v|cVy(JU-S@d)eM=kl&06lt>Z=&Uu-1O& z4ccI>m(a8PnXfYq{12DYV1~Eqt@*X}oqAs9HEs3k_e@!iVdmxC)*kK*zsJ0>n{7YC zQ{YDRiR}TG(p~1Yjhy=ceW}^p-YI-GBkyA=t)TDJpAb{d&I^5Ux!G*hGHz#fe#^~` z=58y`wS_(QGR->n&-0uiUfppapZ0C`OPgmgO+PBkeTZ>^&!aoU)}-GNSby-nSFx6U z?^xiIu;leoTd9aSgVu#!L3-hP55@W`;KR(?6!CL~E~k%uJzrpX`ugA;YqJf2<(@(u zm$RFa(rI*UA+G%zSg!Z}9KY5bHrIX~Z2j8D3S22{r@{6`+<~~R*xoqw+vkdapGVlcW_rHD}k3LKmG4k}u zv+jFHtYhAXNJ>9Vo~6snXxj9*kHD|6Bk!m@`{uiFXtQtP6%7pgzQ$}m_aT3s*<9M? zi@jIc^!ov`XP%9IvG+{dFn7W{o}okA-&o4ba=j-@i1#V5??yb1-x^mD>l0gp58d_W z$>N$k-HfCCJ{gT|6n1?MYqgFEDsVmh=9BvaqD`M`i$}?=kEfh{FSS3wW?%U9Wp<{0 zo8Jq$t`of7!sfbjWV6;i#CZAoJxABIU;6$b!%f7#xxU|chwo|xp8Yyf=)Nz%?e_C1 zbL{6Nx||*|nBxp$UHynY-3cqLf0pkAJ_wiw256 z>yP;9!rs1z8Eo1k{$uorH&>IPp7%-LDdZs%{4dZw z$LPfZx_sZevHBy|m*`QuIb2`9wfk=Lo7cPW72oj{ literal 0 HcmV?d00001 diff --git a/src/Ryujinx.Graphics.Vulkan/VulkanInitialization.cs b/src/Ryujinx.Graphics.Vulkan/VulkanInitialization.cs index 9cd8f90d7..0200e3695 100644 --- a/src/Ryujinx.Graphics.Vulkan/VulkanInitialization.cs +++ b/src/Ryujinx.Graphics.Vulkan/VulkanInitialization.cs @@ -498,6 +498,14 @@ namespace Ryujinx.Graphics.Vulkan ShaderStorageImageArrayNonUniformIndexing = supportedPhysicalDeviceVulkan12Features.ShaderStorageImageArrayNonUniformIndexing, }; + if (Dlss.DlssIntegration.IsEnabled) + { + // DLSS/NGX requires these Vulkan 1.2 features. Enable them only if the device + // advertises support, mirroring how the other features above are gated. + featuresVk12.TimelineSemaphore = supportedPhysicalDeviceVulkan12Features.TimelineSemaphore; + featuresVk12.BufferDeviceAddress = supportedPhysicalDeviceVulkan12Features.BufferDeviceAddress; + } + pExtendedFeatures = &featuresVk12; PhysicalDeviceIndexTypeUint8FeaturesEXT featuresIndexU8; @@ -592,6 +600,14 @@ namespace Ryujinx.Graphics.Vulkan string[] enabledExtensions = _requiredExtensions.Union(_desirableExtensions.Intersect(physicalDevice.DeviceExtensions)).ToArray(); + if (Dlss.DlssIntegration.IsEnabled) + { + // Add the device extensions NGX/DLSS needs (only those the device actually supports). + enabledExtensions = enabledExtensions + .Union(Dlss.DlssIntegration.DeviceExtensions.Intersect(physicalDevice.DeviceExtensions)) + .ToArray(); + } + nint* ppEnabledExtensions = stackalloc nint[enabledExtensions.Length]; for (int i = 0; i < enabledExtensions.Length; i++) diff --git a/src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs b/src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs index d2170a682..c9c19abb5 100644 --- a/src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs +++ b/src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs @@ -916,11 +916,66 @@ namespace Ryujinx.Graphics.Vulkan Logger.Notice.Print(LogClass.Gpu, $"GPU Memory: {GetTotalGPUMemory() / (1024 * 1024)} MiB"); } + private void ProbeDlssSupport() + { + // Opt-in only (RYUJINX_DLSS=1), and DLSS only runs on NVIDIA RTX hardware. When the flag + // is unset nothing here runs, so the default path never touches Streamline. + if (!Dlss.DlssIntegration.IsEnabled || Vendor != Vendor.Nvidia) + { + return; + } + + try + { + // The user drops the Streamline DLLs (MIT) and their own nvngx_dlss.dll into a + // "dlss" folder next to the executable. Nothing proprietary is ever shipped. + string dlssFolder = System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "dlss"); + + string ngx = Dlss.DlssBinaries.LocateNgxDlss(dlssFolder); + if (ngx != null) + { + Logger.Info?.Print(LogClass.Gpu, $"DLSS: located nvngx_dlss.dll at \"{ngx}\"."); + } + else + { + Logger.Info?.Print(LogClass.Gpu, "DLSS: nvngx_dlss.dll not found (bring your own - drop one in the 'dlss' folder or install a DLSS game)."); + } + + string streamlineFolder = Dlss.DlssBinaries.LocateStreamlineFolder(dlssFolder, dlssFolder); + if (streamlineFolder == null) + { + Logger.Info?.Print(LogClass.Gpu, "DLSS: sl.interposer.dll (Streamline) not found; DLSS disabled."); + + return; + } + + if (Dlss.Streamline.Initialize(streamlineFolder)) + { + // Register our natively-created device with SL (mandatory, since we don't use + // SL's vkCreate* proxies), then query DLSS support for this physical device. + Dlss.Streamline.SetVulkanInfo( + (IntPtr)_instance.Instance.Handle, + (IntPtr)_physicalDevice.PhysicalDevice.Handle, + (IntPtr)_device.Handle, + QueueFamilyIndex, + 0); + + Dlss.Streamline.IsDlssSupported((IntPtr)_physicalDevice.PhysicalDevice.Handle); + } + } + catch (Exception ex) + { + Logger.Warning?.Print(LogClass.Gpu, $"DLSS: support probe failed: {ex.Message}"); + } + } + public void Initialize(GraphicsDebugLevel logLevel) { SetupContext(logLevel); PrintGpuInformation(); + + ProbeDlssSupport(); } internal bool NeedsVertexBufferAlignment(int attrScalarAlignment, out int alignment) diff --git a/src/Ryujinx.Graphics.Vulkan/Window.cs b/src/Ryujinx.Graphics.Vulkan/Window.cs index f947f0a0e..7f51e1878 100644 --- a/src/Ryujinx.Graphics.Vulkan/Window.cs +++ b/src/Ryujinx.Graphics.Vulkan/Window.cs @@ -37,6 +37,7 @@ namespace Ryujinx.Graphics.Vulkan private bool _updateEffect; private IPostProcessingEffect _effect; private IScalingFilter _scalingFilter; + private Dlss.DlssUpscaler _dlss; private bool _isLinear; private float _scalingFilterLevel; private bool _updateScalingFilter; @@ -48,6 +49,7 @@ namespace Ryujinx.Graphics.Vulkan private float _hdrCurve = 2.8f; private float _hdrGamma = 2.2f; private float _hdrBlend = 0.5f; + private float _hdrWhiten = 0.4f; public unsafe Window(VulkanRenderer gd, SurfaceKHR surface, PhysicalDevice physicalDevice, Device device) { @@ -450,7 +452,35 @@ namespace Ryujinx.Graphics.Vulkan int dstY0 = crop.FlipY ? dstPaddingY : _height - dstPaddingY; int dstY1 = crop.FlipY ? _height - dstPaddingY : dstPaddingY; - if (_scalingFilter != null) + bool dlssHandled = false; + + if (Dlss.DlssIntegration.IsEnabled) + { + _dlss ??= new Dlss.DlssUpscaler(_gd, _device); + + // DLSS upscales the frame and blits straight to the swapchain; on any failure it + // returns false and we fall through to the normal scaling/blit path below. + dlssHandled = _dlss.TryRun( + view, + cbs, + _swapchainImageViews[nextImage], + _width, + _height, + new Extents2D(dstX0, dstY1, dstX1, dstY0), + _format == VkFormat.R16G16B16A16Sfloat, + _hdrPaperWhite / 80f, + _hdrPeak / 80f, + _hdrCurve, + _hdrGamma, + _hdrBlend, + _hdrWhiten); + } + + if (dlssHandled) + { + // Nothing more to do - DLSS already wrote the swapchain image. + } + else if (_scalingFilter != null && _scalingFilter.IsResolutionSupported(view.Width, view.Height, _width, _height)) { _scalingFilter.Run( view, @@ -466,7 +496,8 @@ namespace Ryujinx.Graphics.Vulkan _hdrPeak / 80f, _hdrCurve, _hdrGamma, - _hdrBlend + _hdrBlend, + _hdrWhiten ); } else @@ -485,7 +516,8 @@ namespace Ryujinx.Graphics.Vulkan _hdrPeak / 80f, _hdrCurve, _hdrGamma, - _hdrBlend); + _hdrBlend, + _hdrWhiten); } Transition( @@ -620,6 +652,15 @@ namespace Ryujinx.Graphics.Vulkan _scalingFilter = new AreaScalingFilter(_gd, _device); } + break; + case ScalingFilter.Nis: + if (_scalingFilter is not NisScalingFilter) + { + _scalingFilter?.Dispose(); + _scalingFilter = new NisScalingFilter(_gd, _device); + } + + _scalingFilter.Level = _scalingFilterLevel; break; } } @@ -687,13 +728,14 @@ namespace Ryujinx.Graphics.Vulkan _swapchainIsDirty = true; } - public override void SetHdrMode(bool enabled, float paperWhite, float peak, float curve, float gamma, float blend) + public override void SetHdrMode(bool enabled, float paperWhite, float peak, float curve, float gamma, float blend, float whiten) { _hdrPaperWhite = paperWhite; _hdrPeak = peak; _hdrCurve = curve; _hdrGamma = gamma; _hdrBlend = blend; + _hdrWhiten = whiten; if (_hdrEnabled != enabled) { @@ -729,6 +771,7 @@ namespace Ryujinx.Graphics.Vulkan _effect?.Dispose(); _scalingFilter?.Dispose(); + _dlss?.Dispose(); } } diff --git a/src/Ryujinx.Graphics.Vulkan/WindowBase.cs b/src/Ryujinx.Graphics.Vulkan/WindowBase.cs index 99b3864e5..d36935633 100644 --- a/src/Ryujinx.Graphics.Vulkan/WindowBase.cs +++ b/src/Ryujinx.Graphics.Vulkan/WindowBase.cs @@ -16,6 +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, float gamma, float blend); + public abstract void SetHdrMode(bool enabled, float paperWhite, float peak, float curve, float gamma, float blend, float whiten); } } diff --git a/src/Ryujinx/Systems/AppHost.cs b/src/Ryujinx/Systems/AppHost.cs index 80e734718..7ef92e36b 100644 --- a/src/Ryujinx/Systems/AppHost.cs +++ b/src/Ryujinx/Systems/AppHost.cs @@ -212,6 +212,7 @@ namespace Ryujinx.Ava.Systems ConfigurationState.Instance.Graphics.HdrIntensity.Event += UpdateHdrLevel; ConfigurationState.Instance.Graphics.HdrGamma.Event += UpdateHdrLevel; ConfigurationState.Instance.Graphics.HdrHighlightMix.Event += UpdateHdrLevel; + ConfigurationState.Instance.Graphics.HdrHighlightWhiten.Event += UpdateHdrLevel; ConfigurationState.Instance.Graphics.VSyncMode.Event += UpdateVSyncMode; ConfigurationState.Instance.Graphics.CustomVSyncInterval.Event += UpdateCustomVSyncIntervalValue; ConfigurationState.Instance.Graphics.EnableCustomVSyncInterval.Event += UpdateCustomVSyncIntervalEnabled; @@ -323,7 +324,8 @@ namespace Ryujinx.Ava.Systems ConfigurationState.Instance.Graphics.HdrPeakBrightness, HdrIntensityToCurve(ConfigurationState.Instance.Graphics.HdrIntensity), ConfigurationState.Instance.Graphics.HdrGamma / 100f, - ConfigurationState.Instance.Graphics.HdrHighlightMix / 100f); + ConfigurationState.Instance.Graphics.HdrHighlightMix / 100f, + ConfigurationState.Instance.Graphics.HdrHighlightWhiten / 100f); } // Maps the user-facing HDR intensity (0-100, higher = more pop) to the highlight curve @@ -695,6 +697,7 @@ namespace Ryujinx.Ava.Systems ConfigurationState.Instance.Graphics.HdrIntensity.Event -= UpdateHdrLevel; ConfigurationState.Instance.Graphics.HdrGamma.Event -= UpdateHdrLevel; ConfigurationState.Instance.Graphics.HdrHighlightMix.Event -= UpdateHdrLevel; + ConfigurationState.Instance.Graphics.HdrHighlightWhiten.Event -= UpdateHdrLevel; _topLevel.PointerMoved -= TopLevel_PointerEnteredOrMoved; _topLevel.PointerEntered -= TopLevel_PointerEnteredOrMoved; @@ -1156,7 +1159,8 @@ namespace Ryujinx.Ava.Systems ConfigurationState.Instance.Graphics.HdrPeakBrightness, HdrIntensityToCurve(ConfigurationState.Instance.Graphics.HdrIntensity), ConfigurationState.Instance.Graphics.HdrGamma / 100f, - ConfigurationState.Instance.Graphics.HdrHighlightMix / 100f); + ConfigurationState.Instance.Graphics.HdrHighlightMix / 100f, + ConfigurationState.Instance.Graphics.HdrHighlightWhiten / 100f); 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 92e8da70a..4a7e5a1b1 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 = 77; + public const int CurrentVersion = 78; /// /// Version of the configuration file format @@ -99,6 +99,11 @@ namespace Ryujinx.Ava.Systems.Configuration /// public int HdrHighlightMix { get; set; } + /// + /// HDR highlight whitening (0-100): desaturate the brightest highlights toward white. 0 = old look (keep colour). + /// + public int HdrHighlightWhiten { 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 cf89374b1..df3ddb0c7 100644 --- a/src/Ryujinx/Systems/Configuration/ConfigurationState.Migration.cs +++ b/src/Ryujinx/Systems/Configuration/ConfigurationState.Migration.cs @@ -91,6 +91,7 @@ namespace Ryujinx.Ava.Systems.Configuration Graphics.HdrIntensity.Value = cff.HdrIntensity; Graphics.HdrGamma.Value = cff.HdrGamma; Graphics.HdrHighlightMix.Value = cff.HdrHighlightMix; + Graphics.HdrHighlightWhiten.Value = cff.HdrHighlightWhiten; Graphics.VSyncMode.Value = cff.VSyncMode; Graphics.EnableCustomVSyncInterval.Value = cff.EnableCustomVSyncInterval; Graphics.CustomVSyncInterval.Value = cff.CustomVSyncInterval; @@ -554,7 +555,8 @@ namespace Ryujinx.Ava.Systems.Configuration }), (75, static cff => cff.HdrIntensity = 80), (76, static cff => cff.HdrGamma = 220), - (77, static cff => cff.HdrHighlightMix = 50) + (77, static cff => cff.HdrHighlightMix = 50), + (78, static cff => cff.HdrHighlightWhiten = 0) ); } } diff --git a/src/Ryujinx/Systems/Configuration/ConfigurationState.Model.cs b/src/Ryujinx/Systems/Configuration/ConfigurationState.Model.cs index e75f30265..5af922bbb 100644 --- a/src/Ryujinx/Systems/Configuration/ConfigurationState.Model.cs +++ b/src/Ryujinx/Systems/Configuration/ConfigurationState.Model.cs @@ -652,6 +652,12 @@ namespace Ryujinx.Ava.Systems.Configuration /// public ReactiveObject HdrHighlightMix { get; private set; } + /// + /// HDR highlight whitening (0-100): desaturate the brightest highlights toward white. + /// 0 = keep full colour (old look), 100 = strong whitening of extreme highlights. + /// + public ReactiveObject HdrHighlightWhiten { get; private set; } + /// /// Preferred GPU /// @@ -706,6 +712,8 @@ namespace Ryujinx.Ava.Systems.Configuration HdrGamma.LogChangesToValue(nameof(HdrGamma)); HdrHighlightMix = new ReactiveObject(); HdrHighlightMix.LogChangesToValue(nameof(HdrHighlightMix)); + HdrHighlightWhiten = new ReactiveObject(); + HdrHighlightWhiten.LogChangesToValue(nameof(HdrHighlightWhiten)); } } diff --git a/src/Ryujinx/Systems/Configuration/ConfigurationState.cs b/src/Ryujinx/Systems/Configuration/ConfigurationState.cs index 59073427b..5a947a062 100644 --- a/src/Ryujinx/Systems/Configuration/ConfigurationState.cs +++ b/src/Ryujinx/Systems/Configuration/ConfigurationState.cs @@ -46,6 +46,7 @@ namespace Ryujinx.Ava.Systems.Configuration HdrIntensity = Graphics.HdrIntensity, HdrGamma = Graphics.HdrGamma, HdrHighlightMix = Graphics.HdrHighlightMix, + HdrHighlightWhiten = Graphics.HdrHighlightWhiten, GraphicsShadersDumpPath = Graphics.ShadersDumpPath, LoggingEnableDebug = Logger.EnableDebug, LoggingEnableStub = Logger.EnableStub, @@ -220,6 +221,7 @@ namespace Ryujinx.Ava.Systems.Configuration Graphics.HdrIntensity.Value = 80; Graphics.HdrGamma.Value = 220; Graphics.HdrHighlightMix.Value = 50; + Graphics.HdrHighlightWhiten.Value = 0; 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 58be9ea2a..da122ee83 100644 --- a/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs @@ -446,6 +446,19 @@ namespace Ryujinx.Ava.UI.ViewModels public string HdrHighlightMixText => $"{HdrHighlightMix}"; + public string HdrHighlightWhitenText => $"{HdrHighlightWhiten}"; + + public int HdrHighlightWhiten + { + get; + set + { + field = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(HdrHighlightWhitenText)); + } + } + public int HdrHighlightMix { get; @@ -850,6 +863,7 @@ namespace Ryujinx.Ava.UI.ViewModels HdrIntensity = config.Graphics.HdrIntensity.Value; HdrGamma = config.Graphics.HdrGamma.Value; HdrHighlightMix = config.Graphics.HdrHighlightMix.Value; + HdrHighlightWhiten = config.Graphics.HdrHighlightWhiten.Value; EnableMacroHLE = config.Graphics.EnableMacroHLE; EnableColorSpacePassthrough = config.Graphics.EnableColorSpacePassthrough; ResolutionScale = config.Graphics.ResScale == -1 ? 4 : config.Graphics.ResScale - 1; @@ -986,6 +1000,7 @@ namespace Ryujinx.Ava.UI.ViewModels config.Graphics.HdrIntensity.Value = HdrIntensity; config.Graphics.HdrGamma.Value = HdrGamma; config.Graphics.HdrHighlightMix.Value = HdrHighlightMix; + config.Graphics.HdrHighlightWhiten.Value = HdrHighlightWhiten; 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 d05741a14..fa60ea49f 100644 --- a/src/Ryujinx/UI/Views/Settings/SettingsGraphicsView.axaml +++ b/src/Ryujinx/UI/Views/Settings/SettingsGraphicsView.axaml @@ -209,6 +209,28 @@ VerticalAlignment="Center" Text="{Binding HdrHighlightMixText}"/> + + + + + + GraphicsConfig.ResScale; + Ryujinx.Graphics.Vulkan.Dlss.DlssIntegration.ResolutionScaleSetter ??= value => GraphicsConfig.ResScale = value; + Ryujinx.Graphics.Vulkan.Dlss.DlssIntegration.ApplyModeResolutionScale(); } private void VolumeStatus_CheckedChanged(object sender, RoutedEventArgs e) From 63fde9b393172632f4e2c9847bf978dd2810e51d Mon Sep 17 00:00:00 2001 From: The Roofer Dev Date: Sun, 28 Jun 2026 06:45:22 -0400 Subject: [PATCH 02/20] Fix DLSS shimmering/gray smear: normalized motion vector scale for Streamline --- src/Ryujinx.Graphics.Vulkan/Dlss/StreamlineDlss.cs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/StreamlineDlss.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/StreamlineDlss.cs index 73340e1a3..93e37f1b3 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/StreamlineDlss.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/StreamlineDlss.cs @@ -405,7 +405,7 @@ namespace Ryujinx.Graphics.Vulkan.Dlss return t; } - private static Constants BuildConstants(uint outW, uint outH, bool reset, float jitterX, float jitterY) + private static Constants BuildConstants(uint outW, uint outH, uint renderW, uint renderH, bool reset, float jitterX, float jitterY) { Constants c = default; c.Type = Guid(0xdcd35ad7, 0x4e4a, 0x4bad, 0xa9, 0x0c, 0xe0, 0xc4, 0x9e, 0xb2, 0x3a, 0xfe); @@ -417,8 +417,13 @@ namespace Ryujinx.Graphics.Vulkan.Dlss c.PrevClipToClip = Mat4.Identity(); c.JitterOffsetX = jitterX; // sub-pixel jitter applied to the image this frame (0 unless Mode B) c.JitterOffsetY = jitterY; - c.MvecScaleX = 1f; - c.MvecScaleY = 1f; + // The motion buffer stores vectors in render-resolution pixels, but Streamline expects + // them normalized (sl_consts.h: "scale factors used to normalize motion vectors ... in + // [-1,1] range"). Leaving this at 1.0 feeds DLSS vectors ~renderW times too large, so + // temporal reprojection samples history from far off-screen on any camera motion and the + // image collapses to a desaturated smear (only correct when still). 1/render normalizes it. + c.MvecScaleX = renderW != 0 ? 1.0f / renderW : 1f; + c.MvecScaleY = renderH != 0 ? 1.0f / renderH : 1f; c.CameraUpY = 1f; c.CameraRightX = 1f; c.CameraFwdZ = 1f; @@ -469,7 +474,7 @@ namespace Ryujinx.Graphics.Vulkan.Dlss ViewportHandle vp = MakeViewport(viewportId); - Constants constants = BuildConstants(output.Width, output.Height, reset, jitterX, jitterY); + Constants constants = BuildConstants(output.Width, output.Height, motion.Width, motion.Height, reset, jitterX, jitterY); int rc = slSetConstants(in constants, token, in vp); if (rc != 0) { From e01484d40179da8e1dbb91e43bca87fdc0f93fe2 Mon Sep 17 00:00:00 2001 From: The Roofer Dev Date: Sun, 28 Jun 2026 07:44:19 -0400 Subject: [PATCH 03/20] Fix: Implement robust hysteresis (0.45/0.70) and exponential smoothing --- .../Dlss/DlssUpscaler.cs | 52 +++++++++++++------ 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs index 28fc4303a..5d5d90cb7 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs @@ -28,9 +28,10 @@ namespace Ryujinx.Graphics.Vulkan.Dlss private const float SceneChangeLowFraction = 0.55f; // ... and disarm (hysteresis) once it drops back below this private const int ResetCooldownFrames = 45; // min frames between resets (~0.75s), so fast-pan blips don't reset repeatedly private const float MotionFullScale = 0.15f; // motion fraction treated as "fully dynamic" (staticScore -> 0) - private const float StaticEnterRate = 0.05f; // per-frame easing toward static (~0.3s); exit is instant - private const float StaticThreshold = 0.6f; // blended static score above which we hand off to the spatial filter - private const int ModeSwitchHoldFrames = 20; // min frames to hold a DLSS<->spatial state before flipping (kills stop-and-go pumping) + private const float ModeBlendRate = 0.05f; // symmetric exponential smoothing of the static score (~20-frame time constant), in BOTH directions + private const float StaticEnterThreshold = 0.70f; // hysteresis HIGH: smoothed static score at/above which we hand off to the spatial (NIS) filter + private const float StaticExitThreshold = 0.45f; // hysteresis LOW: smoothed static score at/below which we resume DLSS; the wide [0.45,0.70] dead-band stops the pumping + private const int ModeSwitchHoldFrames = 20; // min frames to hold a DLSS<->spatial state before flipping (secondary guard atop the hysteresis) private const int CounterCount = 9; // uints in the motion-pass SSBO: 2 scene-change + 4 raw stats + 3 filtered stats private const int StatLogInterval = 120; // frames between motion-field metric log lines @@ -289,20 +290,39 @@ namespace Ryujinx.Graphics.Vulkan.Dlss } // Hybrid static/dynamic decision: DLSS has no temporal signal to reconstruct a static scene - // (a menu) and degrades to a soft/aliased upscale, so above a smoothed static score we bail - // to the configured spatial scaling filter (set it to NIS). Ease into static slowly (~0.3s) - // but leave it instantly, so any motion returns to DLSS without lag. + // (a menu) and degrades to a soft/aliased upscale, so once the scene is clearly static we bail + // to the configured spatial scaling filter (set it to NIS). float staticScore = Math.Clamp(1f - motionFraction / MotionFullScale, 0f, 1f); - _modeBlend = staticScore < _modeBlend - ? staticScore - : _modeBlend + (staticScore - _modeBlend) * StaticEnterRate; - // Debounce the DLSS<->spatial switch: hold the current state for a minimum number of frames - // so stop-and-go camera motion can't make it flip-flop every few frames (which pumps the - // image between DLSS and the NIS fallback). A brief blip in either direction is ignored. - // Diagnostic: RYUJINX_DLSS_FORCE_DLSS pins pure DLSS (never hand off to the spatial filter), - // which also kills the resume-reset, so a camera pan reflects DLSS's motion reconstruction - // alone -- no DLSS<->NIS pumping, no history churn. - bool wantSpatial = !DlssIntegration.ForceDlss && _hasPrev && _modeBlend >= StaticThreshold; + + // Symmetric exponential smoothing of the raw static score, in BOTH directions. The previous + // code snapped straight to dynamic (instant exit), which let a slow pan whipsaw modeBlend + // across the threshold frame to frame; a single low-pass makes the signal itself move + // gradually so the decision below has something stable to act on. + _modeBlend += (staticScore - _modeBlend) * ModeBlendRate; + + // Schmitt-trigger hysteresis on the smoothed score: switch to the spatial filter only once it + // is clearly static (>= enter), and back to DLSS only once it is clearly moving (<= exit). + // Inside the [exit, enter] dead-band the current state is HELD -- this is what eliminates the + // DLSS<->NIS pumping a single threshold produced when the score hovered around 0.6 during a + // slow camera pan (35 history resets / ~3 flips per second in the measured run). + // RYUJINX_DLSS_FORCE_DLSS pins pure DLSS (never hand off, never resume-reset) for diagnostics. + bool wantSpatial = _spatialActive; + if (DlssIntegration.ForceDlss || !_hasPrev) + { + wantSpatial = false; + } + else if (!_spatialActive && _modeBlend >= StaticEnterThreshold) + { + wantSpatial = true; + } + else if (_spatialActive && _modeBlend <= StaticExitThreshold) + { + wantSpatial = false; + } + + // Secondary guard: still require a minimum dwell between flips. With the hysteresis above this + // rarely binds, but it caps any residual switching rate and avoids a one-frame blip flipping + // the state right after a transition. A flip is only committed once both conditions agree. _framesSinceModeSwitch++; if (wantSpatial != _spatialActive && _framesSinceModeSwitch >= ModeSwitchHoldFrames) { From 21c5b662539fdadca4d0a5c8f823e7196e3007d0 Mon Sep 17 00:00:00 2001 From: The Roofer Dev Date: Sun, 28 Jun 2026 08:23:01 -0400 Subject: [PATCH 04/20] Add DLSS<->NIS cross-fade (B0): alpha-blend hybrid transition + RAW barrier to kill flicker --- .../Dlss/DlssUpscaler.cs | 33 ++++- src/Ryujinx.Graphics.Vulkan/HelperShader.cs | 65 +++++++++ src/Ryujinx.Graphics.Vulkan/Window.cs | 138 +++++++++++++----- 3 files changed, 193 insertions(+), 43 deletions(-) diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs index 5d5d90cb7..9467f11a7 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs @@ -32,6 +32,7 @@ namespace Ryujinx.Graphics.Vulkan.Dlss private const float StaticEnterThreshold = 0.70f; // hysteresis HIGH: smoothed static score at/above which we hand off to the spatial (NIS) filter private const float StaticExitThreshold = 0.45f; // hysteresis LOW: smoothed static score at/below which we resume DLSS; the wide [0.45,0.70] dead-band stops the pumping private const int ModeSwitchHoldFrames = 20; // min frames to hold a DLSS<->spatial state before flipping (secondary guard atop the hysteresis) + private const float CrossfadeStep = 1f / 12f; // visual cross-fade speed: a ~12-frame (~0.2s) dissolve between DLSS and the spatial filter at a transition private const int CounterCount = 9; // uints in the motion-pass SSBO: 2 scene-change + 4 raw stats + 3 filtered stats private const int StatLogInterval = 120; // frames between motion-field metric log lines @@ -78,6 +79,14 @@ namespace Ryujinx.Graphics.Vulkan.Dlss private bool _spatialActive; private int _framesSinceModeSwitch = ModeSwitchHoldFrames; private bool _bailedLastFrame; + private float _transitionBlend; // eased [0,1] cross-fade envelope toward _spatialActive (0 = DLSS, 1 = spatial) + + /// + /// Cross-fade weight for the spatial (NIS) result over the DLSS frame: 0 = pure DLSS, 1 = pure + /// spatial, and strictly between during a transition. The present path blends the two by this so + /// the DLSS<->NIS switch dissolves over a few frames instead of cutting. + /// + public float TransitionAlpha { get; private set; } private readonly PipelineHelperShader _motionPipeline; private readonly ShaderCollection _motionProgram; @@ -330,10 +339,20 @@ namespace Ryujinx.Graphics.Vulkan.Dlss _framesSinceModeSwitch = 0; } - bool spatial = _spatialActive; - bool resuming = _bailedLastFrame && !spatial; + // Visual cross-fade envelope: ease a [0,1] blend toward the discrete hysteresis state + // (1 = spatial/NIS, 0 = DLSS) over a few frames. DLSS keeps rendering for the whole fade + // (alpha < 1); only once it reaches 1 do we hand fully to the spatial path and bail. The + // present path alpha-blends the two results by TransitionAlpha so the switch dissolves. + float blendTarget = _spatialActive ? 1f : 0f; + _transitionBlend = blendTarget > _transitionBlend + ? MathF.Min(_transitionBlend + CrossfadeStep, blendTarget) + : MathF.Max(_transitionBlend - CrossfadeStep, blendTarget); + TransitionAlpha = _transitionBlend; - if (spatial && !_bailedLastFrame) + bool fullySpatial = _transitionBlend >= 1f; + bool resuming = _bailedLastFrame && !fullySpatial; + + if (fullySpatial && !_bailedLastFrame) { Logger.Info?.Print(LogClass.Gpu, $"DLSS: -> spatial fallback (static, modeBlend={_modeBlend:0.00})."); } @@ -379,9 +398,11 @@ namespace Ryujinx.Graphics.Vulkan.Dlss RunMotionFilterPass(input, cbs); } - // Static scene: the motion pass above kept the counters and previous-frame color live; - // now bail so Window.Present falls back to the configured spatial scaling filter (NIS). - if (spatial) + // Fully static (fade complete): the motion pass above kept the counters and previous-frame + // color live; now bail so Window.Present falls back to the configured spatial filter (NIS). + // While the fade is still in progress (0 < alpha < 1) we fall through and keep rendering DLSS, + // so the present path has a DLSS frame to cross-fade against. + if (fullySpatial) { CopyToPrev(input, cbs); _hasPrev = true; diff --git a/src/Ryujinx.Graphics.Vulkan/HelperShader.cs b/src/Ryujinx.Graphics.Vulkan/HelperShader.cs index 3f9ae9f10..def1d9c23 100644 --- a/src/Ryujinx.Graphics.Vulkan/HelperShader.cs +++ b/src/Ryujinx.Graphics.Vulkan/HelperShader.cs @@ -484,6 +484,71 @@ namespace Ryujinx.Graphics.Vulkan _pipeline.Finish(gd, cbs); } + /// + /// Blits over with a constant-alpha blend + /// (out = src*alpha + dst*(1-alpha)), used to cross-fade the spatial (NIS) result over the DLSS + /// frame already in the swapchain during a hybrid-mode transition. Plain copy in display space: + /// both inputs are already tone-mapped, so there is no HDR transform here. + /// + public void BlitColorWithAlpha( + VulkanRenderer gd, + CommandBufferScoped cbs, + TextureView src, + TextureView dst, + Extents2D srcRegion, + Extents2D dstRegion, + float alpha) + { + _pipeline.SetCommandBuffer(cbs); + + const int RegionBufferSize = 16; + + _pipeline.SetTextureAndSamplerIdentitySwizzle(ShaderStage.Fragment, 0, src, _samplerLinear); + + Span region = stackalloc float[RegionBufferSize / sizeof(float)]; + region[0] = (float)srcRegion.X1 / src.Width; + region[1] = (float)srcRegion.X2 / src.Width; + region[2] = (float)srcRegion.Y1 / src.Height; + region[3] = (float)srcRegion.Y2 / src.Height; + + using ScopedTemporaryBuffer buffer = gd.BufferManager.ReserveOrCreate(gd, cbs, RegionBufferSize); + buffer.Holder.SetDataUnchecked(buffer.Offset, region); + _pipeline.SetUniformBuffers([new BufferAssignment(1, buffer.Range)]); + + Span viewports = stackalloc Viewport[1]; + Rectangle rect = new( + MathF.Min(dstRegion.X1, dstRegion.X2), + MathF.Min(dstRegion.Y1, dstRegion.Y2), + MathF.Abs(dstRegion.X2 - dstRegion.X1), + MathF.Abs(dstRegion.Y2 - dstRegion.Y1)); + viewports[0] = new Viewport(rect, ViewportSwizzle.PositiveX, ViewportSwizzle.PositiveY, ViewportSwizzle.PositiveZ, ViewportSwizzle.PositiveW, 0f, 1f); + + _pipeline.SetProgram(_programColorBlit); + _pipeline.SetRenderTarget(dst, (uint)dst.Width, (uint)dst.Height); + _pipeline.SetRenderTargetColorMasks([0xf]); + _pipeline.SetScissors([new Rectangle(0, 0, dst.Width, dst.Height)]); + + // Constant-alpha blend: out = src*alpha + dst*(1-alpha). alpha lives in the blend constant. + _pipeline.SetBlendState(0, new BlendDescriptor( + true, + new ColorF(0f, 0f, 0f, alpha), + Ryujinx.Graphics.GAL.BlendOp.Add, Ryujinx.Graphics.GAL.BlendFactor.ConstantAlpha, Ryujinx.Graphics.GAL.BlendFactor.OneMinusConstantAlpha, + Ryujinx.Graphics.GAL.BlendOp.Add, Ryujinx.Graphics.GAL.BlendFactor.ConstantAlpha, Ryujinx.Graphics.GAL.BlendFactor.OneMinusConstantAlpha)); + + _pipeline.SetViewports(viewports); + _pipeline.SetPrimitiveTopology(PrimitiveTopology.TriangleStrip); + _pipeline.Draw(4, 1, 0, 0); + + // Restore no-blend so the shared helper pipeline does not leak this state into later blits. + _pipeline.SetBlendState(0, new BlendDescriptor( + false, + new ColorF(0f, 0f, 0f, 0f), + Ryujinx.Graphics.GAL.BlendOp.Add, Ryujinx.Graphics.GAL.BlendFactor.One, Ryujinx.Graphics.GAL.BlendFactor.Zero, + Ryujinx.Graphics.GAL.BlendOp.Add, Ryujinx.Graphics.GAL.BlendFactor.One, Ryujinx.Graphics.GAL.BlendFactor.Zero)); + + _pipeline.Finish(gd, cbs); + } + private void BlitDepthStencil( VulkanRenderer gd, CommandBufferScoped cbs, diff --git a/src/Ryujinx.Graphics.Vulkan/Window.cs b/src/Ryujinx.Graphics.Vulkan/Window.cs index 7f51e1878..607e44a99 100644 --- a/src/Ryujinx.Graphics.Vulkan/Window.cs +++ b/src/Ryujinx.Graphics.Vulkan/Window.cs @@ -38,6 +38,7 @@ namespace Ryujinx.Graphics.Vulkan private IPostProcessingEffect _effect; private IScalingFilter _scalingFilter; private Dlss.DlssUpscaler _dlss; + private TextureView _crossfadeTarget; // scratch RT holding the spatial result while it is cross-faded over the DLSS frame private bool _isLinear; private float _scalingFilterLevel; private bool _updateScalingFilter; @@ -478,46 +479,108 @@ namespace Ryujinx.Graphics.Vulkan if (dlssHandled) { - // Nothing more to do - DLSS already wrote the swapchain image. - } - else if (_scalingFilter != null && _scalingFilter.IsResolutionSupported(view.Width, view.Height, _width, _height)) - { - _scalingFilter.Run( - view, - cbs, - _swapchainImageViews[nextImage].GetImageViewForAttachment(), - _format, - _width, - _height, - new Extents2D(srcX0, srcY0, srcX1, srcY1), - new Extents2D(dstX0, dstY0, dstX1, dstY1), - _format == VkFormat.R16G16B16A16Sfloat, - _hdrPaperWhite / 80f, - _hdrPeak / 80f, - _hdrCurve, - _hdrGamma, - _hdrBlend, - _hdrWhiten - ); + // DLSS already wrote the swapchain. If we are mid-transition between DLSS and the spatial + // filter, also render the spatial result and alpha-blend it on top so the switch dissolves + // instead of cutting; the eased weight comes from DLSS's smoothed mode envelope. + float crossfade = _dlss.TransitionAlpha; + if (crossfade > 0f) + { + if (_crossfadeTarget == null || _crossfadeTarget.Width != _width || _crossfadeTarget.Height != _height) + { + _crossfadeTarget?.Dispose(); + _crossfadeTarget = (TextureView)_gd.CreateTexture(_swapchainImageViews[nextImage].Info); + } + + RenderSpatial(_crossfadeTarget); + + // RenderSpatial wrote _crossfadeTarget (NIS = compute storage write, or a plain blit = + // colour-attachment write); make that write available/visible before the blend below + // samples it. The helper pipeline does not emit this read-after-write barrier itself, + // which showed up as flicker during the cross-fade. Cover both producer stages. + ImageMemoryBarrier crossfadeBarrier = new() + { + SType = StructureType.ImageMemoryBarrier, + SrcAccessMask = AccessFlags.ColorAttachmentWriteBit | AccessFlags.ShaderWriteBit, + DstAccessMask = AccessFlags.ShaderReadBit, + OldLayout = ImageLayout.General, + NewLayout = ImageLayout.General, + SrcQueueFamilyIndex = Vk.QueueFamilyIgnored, + DstQueueFamilyIndex = Vk.QueueFamilyIgnored, + Image = _crossfadeTarget.GetImage().Get(cbs).Value, + SubresourceRange = new ImageSubresourceRange(ImageAspectFlags.ColorBit, 0, 1, 0, 1), + }; + + _gd.Api.CmdPipelineBarrier( + cbs.CommandBuffer, + PipelineStageFlags.ColorAttachmentOutputBit | PipelineStageFlags.ComputeShaderBit, + PipelineStageFlags.FragmentShaderBit, + 0, + 0, + null, + 0, + null, + 1, + in crossfadeBarrier); + + _gd.HelperShader.BlitColorWithAlpha( + _gd, + cbs, + _crossfadeTarget, + _swapchainImageViews[nextImage], + new Extents2D(0, 0, _width, _height), + new Extents2D(0, 0, _width, _height), + crossfade); + } } else { - _gd.HelperShader.BlitColor( - _gd, - cbs, - view, - _swapchainImageViews[nextImage], - new Extents2D(srcX0, srcY0, srcX1, srcY1), - new Extents2D(dstX0, dstY1, dstX1, dstY0), - _isLinear, - true, - _format == VkFormat.R16G16B16A16Sfloat, - _hdrPaperWhite / 80f, - _hdrPeak / 80f, - _hdrCurve, - _hdrGamma, - _hdrBlend, - _hdrWhiten); + RenderSpatial(_swapchainImageViews[nextImage]); + } + + // Renders the configured spatial path (NIS scaling filter, or a plain HDR-aware blit) into the + // given destination -- the swapchain in steady state, or the cross-fade scratch target during a + // DLSS<->NIS transition. Both inputs to the final blend are therefore in the same display space. + void RenderSpatial(TextureView spatialDst) + { + if (_scalingFilter != null && _scalingFilter.IsResolutionSupported(view.Width, view.Height, _width, _height)) + { + _scalingFilter.Run( + view, + cbs, + spatialDst.GetImageViewForAttachment(), + _format, + _width, + _height, + new Extents2D(srcX0, srcY0, srcX1, srcY1), + new Extents2D(dstX0, dstY0, dstX1, dstY1), + _format == VkFormat.R16G16B16A16Sfloat, + _hdrPaperWhite / 80f, + _hdrPeak / 80f, + _hdrCurve, + _hdrGamma, + _hdrBlend, + _hdrWhiten + ); + } + else + { + _gd.HelperShader.BlitColor( + _gd, + cbs, + view, + spatialDst, + new Extents2D(srcX0, srcY0, srcX1, srcY1), + new Extents2D(dstX0, dstY1, dstX1, dstY0), + _isLinear, + true, + _format == VkFormat.R16G16B16A16Sfloat, + _hdrPaperWhite / 80f, + _hdrPeak / 80f, + _hdrCurve, + _hdrGamma, + _hdrBlend, + _hdrWhiten); + } } Transition( @@ -772,6 +835,7 @@ namespace Ryujinx.Graphics.Vulkan _effect?.Dispose(); _scalingFilter?.Dispose(); _dlss?.Dispose(); + _crossfadeTarget?.Dispose(); } } From 204efcba7c7b106ec972cd865c72d0c1b9b9e2d0 Mon Sep 17 00:00:00 2001 From: The Roofer Dev Date: Sun, 28 Jun 2026 08:42:27 -0400 Subject: [PATCH 05/20] Infrastructure: Inject real Depth Buffer with dimension safety guards into Streamline pipeline --- src/Ryujinx.Graphics.GAL/IWindow.cs | 2 +- .../Commands/Window/WindowPresentCommand.cs | 6 ++++-- .../Multithreading/ThreadedWindow.cs | 8 ++++++-- src/Ryujinx.Graphics.Gpu/GpuContext.cs | 7 +++++++ .../Image/TextureManager.cs | 7 +++++++ src/Ryujinx.Graphics.Gpu/Window.cs | 2 +- src/Ryujinx.Graphics.OpenGL/Window.cs | 3 ++- .../Dlss/DlssUpscaler.cs | 17 ++++++++++++++++- src/Ryujinx.Graphics.Vulkan/Window.cs | 3 ++- src/Ryujinx.Graphics.Vulkan/WindowBase.cs | 2 +- 10 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/Ryujinx.Graphics.GAL/IWindow.cs b/src/Ryujinx.Graphics.GAL/IWindow.cs index a364867d1..cee3556e9 100644 --- a/src/Ryujinx.Graphics.GAL/IWindow.cs +++ b/src/Ryujinx.Graphics.GAL/IWindow.cs @@ -5,7 +5,7 @@ namespace Ryujinx.Graphics.GAL { public interface IWindow { - void Present(ITexture texture, ImageCrop crop, Action swapBuffersCallback); + void Present(ITexture texture, ITexture depthTexture, ImageCrop crop, Action swapBuffersCallback); void SetSize(int width, int height); diff --git a/src/Ryujinx.Graphics.GAL/Multithreading/Commands/Window/WindowPresentCommand.cs b/src/Ryujinx.Graphics.GAL/Multithreading/Commands/Window/WindowPresentCommand.cs index 4034bc3c6..1d673e0b6 100644 --- a/src/Ryujinx.Graphics.GAL/Multithreading/Commands/Window/WindowPresentCommand.cs +++ b/src/Ryujinx.Graphics.GAL/Multithreading/Commands/Window/WindowPresentCommand.cs @@ -8,12 +8,14 @@ namespace Ryujinx.Graphics.GAL.Multithreading.Commands.Window { public readonly CommandType CommandType => CommandType.WindowPresent; private TableRef _texture; + private TableRef _depthTexture; private ImageCrop _crop; private TableRef _swapBuffersCallback; - public void Set(TableRef texture, ImageCrop crop, TableRef swapBuffersCallback) + public void Set(TableRef texture, TableRef depthTexture, ImageCrop crop, TableRef swapBuffersCallback) { _texture = texture; + _depthTexture = depthTexture; _crop = crop; _swapBuffersCallback = swapBuffersCallback; } @@ -21,7 +23,7 @@ namespace Ryujinx.Graphics.GAL.Multithreading.Commands.Window public static void Run(ref WindowPresentCommand command, ThreadedRenderer threaded, IRenderer renderer) { threaded.SignalFrame(); - renderer.Window.Present(command._texture.Get(threaded)?.Base, command._crop, command._swapBuffersCallback.Get(threaded)); + renderer.Window.Present(command._texture.Get(threaded)?.Base, command._depthTexture.Get(threaded)?.Base, command._crop, command._swapBuffersCallback.Get(threaded)); } } } diff --git a/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedWindow.cs b/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedWindow.cs index 9d1d23a33..7fa2548a5 100644 --- a/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedWindow.cs +++ b/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedWindow.cs @@ -17,13 +17,17 @@ namespace Ryujinx.Graphics.GAL.Multithreading _impl = impl; } - public unsafe void Present(ITexture texture, ImageCrop crop, Action swapBuffersCallback) + public unsafe void Present(ITexture texture, ITexture depthTexture, ImageCrop crop, Action swapBuffersCallback) { // If there's already a frame in the pipeline, wait for it to be presented first. // This is a multithread rate limit - we can't be more than one frame behind the command queue. _renderer.WaitForFrame(); - _renderer.New()->Set(new TableRef(_renderer, texture as ThreadedTexture), crop, new TableRef(_renderer, swapBuffersCallback)); + _renderer.New()->Set( + new TableRef(_renderer, texture as ThreadedTexture), + new TableRef(_renderer, depthTexture as ThreadedTexture), + crop, + new TableRef(_renderer, swapBuffersCallback)); _renderer.QueueCommand(); } diff --git a/src/Ryujinx.Graphics.Gpu/GpuContext.cs b/src/Ryujinx.Graphics.Gpu/GpuContext.cs index 7ee32e83d..1b474904e 100644 --- a/src/Ryujinx.Graphics.Gpu/GpuContext.cs +++ b/src/Ryujinx.Graphics.Gpu/GpuContext.cs @@ -46,6 +46,13 @@ namespace Ryujinx.Graphics.Gpu /// public Window Window { get; } + /// + /// Most recently bound depth-stencil render target, captured so the presentation path can hand a + /// real depth buffer to temporal upscalers (DLSS). May be stale or a UI depth, so consumers must + /// validate it (e.g. by dimensions) before use. Written and read on the GPU thread only. + /// + internal Image.Texture LastPresentDepthStencil { get; set; } + /// /// Internal sequence number, used to avoid needless resource data updates /// in the middle of a command buffer before synchronizations. diff --git a/src/Ryujinx.Graphics.Gpu/Image/TextureManager.cs b/src/Ryujinx.Graphics.Gpu/Image/TextureManager.cs index db2921468..89ce6ab13 100644 --- a/src/Ryujinx.Graphics.Gpu/Image/TextureManager.cs +++ b/src/Ryujinx.Graphics.Gpu/Image/TextureManager.cs @@ -210,6 +210,13 @@ namespace Ryujinx.Graphics.Gpu.Image _rtDepthStencil = depthStencil; } + // Publish the last bound depth so the presentation path can forward a real depth buffer to + // DLSS. Only non-null binds (a real pass) update it; consumers validate dimensions. + if (depthStencil != null) + { + _context.LastPresentDepthStencil = depthStencil; + } + return changesScale || ScaleNeedsUpdated(depthStencil); } diff --git a/src/Ryujinx.Graphics.Gpu/Window.cs b/src/Ryujinx.Graphics.Gpu/Window.cs index 5c3463f2a..a1b6b73f7 100644 --- a/src/Ryujinx.Graphics.Gpu/Window.cs +++ b/src/Ryujinx.Graphics.Gpu/Window.cs @@ -238,7 +238,7 @@ namespace Ryujinx.Graphics.Gpu crop = new ImageCrop(left, right, top, bottom, crop.FlipX, crop.FlipY, crop.IsStretched, crop.AspectRatioX, crop.AspectRatioY); } - _context.Renderer.Window.Present(texture.HostTexture, crop, swapBuffersCallback); + _context.Renderer.Window.Present(texture.HostTexture, _context.LastPresentDepthStencil?.HostTexture, crop, swapBuffersCallback); pt.ReleaseCallback(pt.UserObj); } diff --git a/src/Ryujinx.Graphics.OpenGL/Window.cs b/src/Ryujinx.Graphics.OpenGL/Window.cs index ab4e17c62..281fae7e7 100644 --- a/src/Ryujinx.Graphics.OpenGL/Window.cs +++ b/src/Ryujinx.Graphics.OpenGL/Window.cs @@ -38,8 +38,9 @@ namespace Ryujinx.Graphics.OpenGL _renderer = renderer; } - public void Present(ITexture texture, ImageCrop crop, Action swapBuffersCallback) + public void Present(ITexture texture, ITexture depthTexture, ImageCrop crop, Action swapBuffersCallback) { + // depthTexture is unused by the OpenGL backend (DLSS is Vulkan-only). GL.Disable(EnableCap.FramebufferSrgb); (int oldDrawFramebufferHandle, int oldReadFramebufferHandle) = ((Pipeline)_renderer.Pipeline).GetBoundFramebuffers(); diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs index 9467f11a7..39df46251 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs @@ -71,6 +71,7 @@ namespace Ryujinx.Graphics.Vulkan.Dlss private float _dejitterX; private float _dejitterY; private int _jitterLogCount; + private bool? _depthWasReal; // B1a: whether the real captured depth is in use, to log only on change // Hybrid static/dynamic mode: DLSS reconstructs moving content, but degrades to a soft/aliased // upscale on static menus (no temporal signal). _modeBlend eases toward "static" and, past a @@ -150,6 +151,7 @@ namespace Ryujinx.Graphics.Vulkan.Dlss public bool TryRun( TextureView input, + TextureView depth, CommandBufferScoped cbs, TextureView dst, int outW, @@ -415,7 +417,20 @@ namespace Ryujinx.Graphics.Vulkan.Dlss StreamlineDlss.DlssTexture inTex = Describe(input, cbs); StreamlineDlss.DlssTexture outTex = Describe(_output, cbs); - StreamlineDlss.DlssTexture depthTex = Describe(_depth, cbs); + // B1a real depth: use the captured guest depth buffer only when it matches the input (render) + // resolution. A size mismatch means it is a UI/secondary/stale depth, so fall back to the + // zeroed dummy -- never feed DLSS a mismatched depth that could fail the evaluate and leak + // toward a device loss. State changes are logged so we can see how often the guard trips. + bool depthMatches = depth != null && depth.Width == input.Width && depth.Height == input.Height; + if (_depthWasReal != depthMatches) + { + _depthWasReal = depthMatches; + Logger.Info?.Print(LogClass.Gpu, + $"DLSS depth -> {(depthMatches ? "REAL" : "dummy")} (input {input.Width}x{input.Height}, " + + $"depth {(depth != null ? $"{depth.Width}x{depth.Height}" : "null")})."); + } + + StreamlineDlss.DlssTexture depthTex = Describe(depthMatches ? depth : _depth, cbs); TextureView mvSource = DlssIntegration.MvFilterEnabled ? _motionFiltered : _motion; StreamlineDlss.DlssTexture mvTex = Describe(mvSource, cbs); diff --git a/src/Ryujinx.Graphics.Vulkan/Window.cs b/src/Ryujinx.Graphics.Vulkan/Window.cs index 607e44a99..335af23ed 100644 --- a/src/Ryujinx.Graphics.Vulkan/Window.cs +++ b/src/Ryujinx.Graphics.Vulkan/Window.cs @@ -341,7 +341,7 @@ namespace Ryujinx.Graphics.Vulkan return new Extent2D(width, height); } - public unsafe override void Present(ITexture texture, ImageCrop crop, Action swapBuffersCallback) + public unsafe override void Present(ITexture texture, ITexture depthTexture, ImageCrop crop, Action swapBuffersCallback) { _gd.PipelineInternal.AutoFlush.Present(); @@ -463,6 +463,7 @@ namespace Ryujinx.Graphics.Vulkan // returns false and we fall through to the normal scaling/blit path below. dlssHandled = _dlss.TryRun( view, + depthTexture as TextureView, cbs, _swapchainImageViews[nextImage], _width, diff --git a/src/Ryujinx.Graphics.Vulkan/WindowBase.cs b/src/Ryujinx.Graphics.Vulkan/WindowBase.cs index d36935633..74de6c567 100644 --- a/src/Ryujinx.Graphics.Vulkan/WindowBase.cs +++ b/src/Ryujinx.Graphics.Vulkan/WindowBase.cs @@ -9,7 +9,7 @@ namespace Ryujinx.Graphics.Vulkan public bool ScreenCaptureRequested { get; set; } public abstract void Dispose(); - public abstract void Present(ITexture texture, ImageCrop crop, Action swapBuffersCallback); + public abstract void Present(ITexture texture, ITexture depthTexture, ImageCrop crop, Action swapBuffersCallback); public abstract void SetSize(int width, int height); public abstract void ChangeVSyncMode(VSyncMode vSyncMode); public abstract void SetAntiAliasing(AntiAliasing effect); From 9da53f560f8f97e38b700b84fec2b879f18c048f Mon Sep 17 00:00:00 2001 From: The Roofer Dev Date: Sun, 28 Jun 2026 09:00:41 -0400 Subject: [PATCH 06/20] Infrastructure: Enable VK_NV_optical_flow device extension and successfully resolve function pointers --- .../Dlss/DlssIntegration.cs | 2 + .../Dlss/DlssUpscaler.cs | 6 ++ .../Dlss/NvOpticalFlow.cs | 79 +++++++++++++++++++ .../VulkanInitialization.cs | 27 +++++++ 4 files changed, 114 insertions(+) create mode 100644 src/Ryujinx.Graphics.Vulkan/Dlss/NvOpticalFlow.cs diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssIntegration.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssIntegration.cs index 99d4b3758..0d2666e5a 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssIntegration.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssIntegration.cs @@ -53,6 +53,8 @@ namespace Ryujinx.Graphics.Vulkan.Dlss "VK_NVX_image_view_handle", "VK_KHR_buffer_device_address", "VK_EXT_buffer_device_address", + "VK_NV_optical_flow", // NVOFA (B2): hardware optical flow accelerator for accurate motion vectors + "VK_KHR_synchronization2", // required dependency of VK_NV_optical_flow }; /// diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs index 39df46251..6a70cede1 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs @@ -93,6 +93,7 @@ namespace Ryujinx.Graphics.Vulkan.Dlss private readonly ShaderCollection _motionProgram; private readonly ShaderCollection _motionFilterProgram; private readonly ISampler _sampler; + private readonly NvOpticalFlow _ofa; private int _inW, _inH, _outW, _outH; private uint _frame; @@ -147,6 +148,11 @@ namespace Ryujinx.Graphics.Vulkan.Dlss ], filterLayout); _sceneChangeBuffer = gd.BufferManager.CreateWithHandle(gd, CounterCount * sizeof(uint)); + + // NVOFA (B2 probe): bind the hardware optical flow entry points now that the device exists. + // If it reports unavailable, the Lucas-Kanade motion passes above remain the MV source. + _ofa = new NvOpticalFlow(); + _ofa.TryBind(gd.Api, device); } public bool TryRun( diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/NvOpticalFlow.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/NvOpticalFlow.cs new file mode 100644 index 000000000..8a3cd6b60 --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/NvOpticalFlow.cs @@ -0,0 +1,79 @@ +using Ryujinx.Common.Logging; +using Silk.NET.Core; +using Silk.NET.Vulkan; +using System; +using System.Runtime.InteropServices; + +namespace Ryujinx.Graphics.Vulkan.Dlss +{ + /// + /// Manual binding of the VK_NV_optical_flow (NVOFA) device functions. Silk.NET 2.23.0 ships the + /// structs/enums/handles for this extension in its core assembly but not the function wrappers + /// (those live in the unreferenced Silk.NET.Vulkan.Extensions.NV package), so we resolve the four + /// entry points ourselves via vkGetDeviceProcAddr -- no extra NuGet dependency. + /// + /// B2 step 2 ("the probe"): only resolves the pointers and reports whether the + /// hardware optical flow is available. The session/image setup (step 3) must be gated on + /// ; until then DLSS keeps using the Lucas-Kanade compute fallback. + /// + internal sealed unsafe class NvOpticalFlow + { + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate Result CreateSessionDelegate(Device device, OpticalFlowSessionCreateInfoNV* createInfo, AllocationCallbacks* allocator, OpticalFlowSessionNV* session); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate void DestroySessionDelegate(Device device, OpticalFlowSessionNV session, AllocationCallbacks* allocator); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate Result BindImageDelegate(Device device, OpticalFlowSessionNV session, OpticalFlowSessionBindingPointNV bindingPoint, ImageView view, ImageLayout layout); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate void ExecuteDelegate(CommandBuffer commandBuffer, OpticalFlowSessionNV session, OpticalFlowExecuteInfoNV* executeInfo); + + private CreateSessionDelegate _createSession; + private DestroySessionDelegate _destroySession; + private BindImageDelegate _bindImage; + private ExecuteDelegate _execute; + + /// True only when all four entry points resolved, i.e. the device really exposes NVOFA. + public bool Available { get; private set; } + + /// + /// Resolves the four VK_NV_optical_flow functions on the active device and logs a probe line. Safe + /// to call once after device creation; if anything is missing, stays false + /// and the caller falls back to the Lucas-Kanade motion estimator. + /// + public void TryBind(Vk api, Device device) + { + _createSession = Load(api, device, "vkCreateOpticalFlowSessionNV"); + _bindImage = Load(api, device, "vkBindOpticalFlowSessionImageNV"); + _execute = Load(api, device, "vkCmdOpticalFlowExecuteNV"); + _destroySession = Load(api, device, "vkDestroyOpticalFlowSessionNV"); + + Available = _createSession != null && _bindImage != null && _execute != null && _destroySession != null; + + Logger.Info?.Print(LogClass.Gpu, + $"NVOFA probe: vkCreateOpticalFlowSessionNV={(_createSession != null ? "solved" : "NULL")}, " + + $"vkBindOpticalFlowSessionImageNV={(_bindImage != null ? "solved" : "NULL")}, " + + $"vkCmdOpticalFlowExecuteNV={(_execute != null ? "solved" : "NULL")}, " + + $"vkDestroyOpticalFlowSessionNV={(_destroySession != null ? "solved" : "NULL")} -> " + + (Available + ? "AVAILABLE (hardware optical flow ready; session setup may proceed)." + : "UNAVAILABLE (falling back to Lucas-Kanade motion estimation).")); + } + + private static T Load(Vk api, Device device, string name) where T : Delegate + { + PfnVoidFunction pfn = api.GetDeviceProcAddr(device, name); + + if (pfn.Handle == null) + { + Logger.Warning?.Print(LogClass.Gpu, $"NVOFA: {name} could not be resolved (vkGetDeviceProcAddr returned null)."); + + return null; + } + + return Marshal.GetDelegateForFunctionPointer((IntPtr)pfn.Handle); + } + } +} diff --git a/src/Ryujinx.Graphics.Vulkan/VulkanInitialization.cs b/src/Ryujinx.Graphics.Vulkan/VulkanInitialization.cs index 0200e3695..aeded4e80 100644 --- a/src/Ryujinx.Graphics.Vulkan/VulkanInitialization.cs +++ b/src/Ryujinx.Graphics.Vulkan/VulkanInitialization.cs @@ -598,6 +598,33 @@ namespace Ryujinx.Graphics.Vulkan pExtendedFeatures = &featuresDynamicAttachmentFeedbackLoopLayout; } + PhysicalDeviceSynchronization2Features featuresSync2; + PhysicalDeviceOpticalFlowFeaturesNV featuresOpticalFlow; + + if (Dlss.DlssIntegration.IsEnabled && physicalDevice.IsDeviceExtensionPresent("VK_NV_optical_flow")) + { + // NVOFA (B2): VK_NV_optical_flow needs synchronization2 as a dependency. Enable both + // features, only when DLSS is on and the device advertises the extension, so the default + // (non-DLSS) device creation is left byte-identical to upstream. + featuresSync2 = new() + { + SType = StructureType.PhysicalDeviceSynchronization2Features, + PNext = pExtendedFeatures, + Synchronization2 = true, + }; + + pExtendedFeatures = &featuresSync2; + + featuresOpticalFlow = new() + { + SType = StructureType.PhysicalDeviceOpticalFlowFeaturesNV, + PNext = pExtendedFeatures, + OpticalFlow = true, + }; + + pExtendedFeatures = &featuresOpticalFlow; + } + string[] enabledExtensions = _requiredExtensions.Union(_desirableExtensions.Intersect(physicalDevice.DeviceExtensions)).ToArray(); if (Dlss.DlssIntegration.IsEnabled) From 12cc0041630add51eb3908df1ad5cc0bc389bc21 Mon Sep 17 00:00:00 2001 From: The Roofer Dev Date: Sun, 28 Jun 2026 09:30:19 -0400 Subject: [PATCH 07/20] Fix: Initialize NVOFA textures with proper Vulkan image memory barriers to eliminate allocation flickering --- .../Dlss/DlssUpscaler.cs | 35 ++++++++++ .../Dlss/NvOpticalFlow.cs | 64 +++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs index 6a70cede1..dd49e9046 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs @@ -46,6 +46,12 @@ namespace Ryujinx.Graphics.Vulkan.Dlss private TextureView _prevColor; private bool _hasPrev; + // NVOFA (B2 step 3a) hardware optical flow images: input (frame N), reference (frame N-1) in the + // OFA input format, and the output velocity field. Allocated alongside the session at render res. + private TextureView _ofaInput; + private TextureView _ofaPrev; + private TextureView _ofaFlow; + // Two host-mapped uints (unexplained-change + confident-motion pixel counts), read back one // frame late to drive a DLSS history reset on hard full-screen cuts. _wasChanging and // _framesSinceReset make that reset edge-triggered + rate-limited, so a fade or a fast pan @@ -621,6 +627,9 @@ namespace Ryujinx.Graphics.Vulkan.Dlss _motion?.Dispose(); _motionFiltered?.Dispose(); _prevColor?.Dispose(); // was leaked on every reallocation (dynamic-resolution games churn this) + _ofaInput?.Dispose(); + _ofaPrev?.Dispose(); + _ofaFlow?.Dispose(); _inW = input.Width; _inH = input.Height; @@ -634,6 +643,17 @@ namespace Ryujinx.Graphics.Vulkan.Dlss _prevColor = _gd.CreateTexture(MakeInfo(input.Info, input.Width, input.Height, input.Info.Format, input.Info.BytesPerPixel)) as TextureView; _hasPrev = false; + // NVOFA (3a): allocate the hardware optical flow images + (re)create the session at this render + // resolution. Only when the probe found the extension live; on any failure SessionReady stays + // false and the Lucas-Kanade motion passes remain the motion-vector source (safe fallback). + if (_ofa.Available) + { + _ofaInput = _gd.CreateTexture(MakeInfo(input.Info, input.Width, input.Height, Format.R8Unorm, 1)) as TextureView; + _ofaPrev = _gd.CreateTexture(MakeInfo(input.Info, input.Width, input.Height, Format.R8Unorm, 1)) as TextureView; + _ofaFlow = _gd.CreateTexture(MakeInfo(input.Info, input.Width, input.Height, Format.R16G16Float, 4)) as TextureView; + _ofa.CreateSession(_device, (uint)input.Width, (uint)input.Height); + } + ClearResources(cbs); } @@ -685,12 +705,27 @@ namespace Ryujinx.Graphics.Vulkan.Dlss _gd.Api.CmdClearColorImage(cbs.CommandBuffer, _motionFiltered.GetImage().Get(cbs).Value, ImageLayout.General, in zeroColor, 1, in colorRange); _gd.Api.CmdClearColorImage(cbs.CommandBuffer, _prevColor.GetImage().Get(cbs).Value, ImageLayout.General, in zeroColor, 1, in colorRange); + // NVOFA (3a) images get the same initial clear as every other DLSS scratch texture: without it + // they were the only resources left in VK_IMAGE_LAYOUT_UNDEFINED, which perturbed the main + // render pipeline (isolation-confirmed micro-flicker). Clearing transitions them to a defined + // General layout with defined contents, matching how _output/_motion/etc. are handled above. + if (_ofa.Available && _ofaInput != null) + { + _gd.Api.CmdClearColorImage(cbs.CommandBuffer, _ofaInput.GetImage().Get(cbs).Value, ImageLayout.General, in zeroColor, 1, in colorRange); + _gd.Api.CmdClearColorImage(cbs.CommandBuffer, _ofaPrev.GetImage().Get(cbs).Value, ImageLayout.General, in zeroColor, 1, in colorRange); + _gd.Api.CmdClearColorImage(cbs.CommandBuffer, _ofaFlow.GetImage().Get(cbs).Value, ImageLayout.General, in zeroColor, 1, in colorRange); + } + ClearDepthStencilValue zeroDepth = new() { Depth = 0f, Stencil = 0 }; _gd.Api.CmdClearDepthStencilImage(cbs.CommandBuffer, _depth.GetImage().Get(cbs).Value, ImageLayout.General, in zeroDepth, 1, in depthRange); } public void Dispose() { + _ofa?.DestroySession(_device); + _ofaInput?.Dispose(); + _ofaPrev?.Dispose(); + _ofaFlow?.Dispose(); _output?.Dispose(); _depth?.Dispose(); _motion?.Dispose(); diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/NvOpticalFlow.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/NvOpticalFlow.cs index 8a3cd6b60..1f36dccd4 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/NvOpticalFlow.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/NvOpticalFlow.cs @@ -35,9 +35,14 @@ namespace Ryujinx.Graphics.Vulkan.Dlss private BindImageDelegate _bindImage; private ExecuteDelegate _execute; + private OpticalFlowSessionNV _session; + /// True only when all four entry points resolved, i.e. the device really exposes NVOFA. public bool Available { get; private set; } + /// True when a hardware optical flow session is currently created and ready (3a). + public bool SessionReady { get; private set; } + /// /// Resolves the four VK_NV_optical_flow functions on the active device and logs a probe line. Safe /// to call once after device creation; if anything is missing, stays false @@ -62,6 +67,65 @@ namespace Ryujinx.Graphics.Vulkan.Dlss : "UNAVAILABLE (falling back to Lucas-Kanade motion estimation).")); } + /// + /// Creates (or recreates) the hardware optical flow session for the given render resolution. Uses + /// the canonical NVOFA formats (R8_UNORM input -> R16G16_SFLOAT flow, 1x1 output grid). A wrong + /// format/parameter is reported as a VkResult error (not a device loss), so this doubles as the + /// format validator; on any failure the caller keeps using Lucas-Kanade. + /// + public bool CreateSession(Device device, uint width, uint height) + { + if (!Available) + { + return false; + } + + DestroySession(device); + + OpticalFlowSessionCreateInfoNV info = new() + { + SType = StructureType.OpticalFlowSessionCreateInfoNV, + Width = width, + Height = height, + ImageFormat = Format.R8Unorm, + FlowVectorFormat = Format.R16G16Sfloat, + CostFormat = Format.Undefined, + OutputGridSize = (OpticalFlowGridSizeFlagsNV)1, // VK_OPTICAL_FLOW_GRID_SIZE_1X1_BIT_NV + HintGridSize = (OpticalFlowGridSizeFlagsNV)0, + PerformanceLevel = (OpticalFlowPerformanceLevelNV)2, // MEDIUM + }; + + OpticalFlowSessionNV session; + Result r = _createSession(device, &info, null, &session); + + if (r != Result.Success || session.Handle == 0) + { + Logger.Warning?.Print(LogClass.Gpu, $"NVOFA Session: vkCreateOpticalFlowSessionNV failed (VkResult={r}); keeping Lucas-Kanade."); + SessionReady = false; + + return false; + } + + _session = session; + SessionReady = true; + Logger.Info?.Print(LogClass.Gpu, + $"NVOFA Session: vkCreateOpticalFlowSessionNV initialized successfully ({width}x{height}, R8_UNORM -> R16G16_SFLOAT, 1x1 grid)."); + + return true; + } + + /// Destroys the current optical flow session if any. + public void DestroySession(Device device) + { + if (_session.Handle != 0 && _destroySession != null) + { + _destroySession(device, _session, null); + _session = default; + } + + SessionReady = false; + } + private static T Load(Vk api, Device device, string name) where T : Delegate { PfnVoidFunction pfn = api.GetDeviceProcAddr(device, name); From b08dc65e09f8e3629f3d3fd1d5fc795ab19c2418 Mon Sep 17 00:00:00 2001 From: The Roofer Dev Date: Sun, 28 Jun 2026 10:49:05 -0400 Subject: [PATCH 08/20] Architecture: Implement multi-queue Vulkan pipeline on fam5 with concurrent image sharing to solve NVOFA TDR --- .../Dlss/DlssUpscaler.cs | 45 +- .../Dlss/NvOpticalFlow.cs | 504 +++++++++++++++++- .../VulkanInitialization.cs | 40 +- src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs | 16 +- 4 files changed, 552 insertions(+), 53 deletions(-) diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs index dd49e9046..0089f1921 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs @@ -46,11 +46,6 @@ namespace Ryujinx.Graphics.Vulkan.Dlss private TextureView _prevColor; private bool _hasPrev; - // NVOFA (B2 step 3a) hardware optical flow images: input (frame N), reference (frame N-1) in the - // OFA input format, and the output velocity field. Allocated alongside the session at render res. - private TextureView _ofaInput; - private TextureView _ofaPrev; - private TextureView _ofaFlow; // Two host-mapped uints (unexplained-change + confident-motion pixel counts), read back one // frame late to drive a DLSS history reset on hard full-screen cuts. _wasChanging and @@ -158,7 +153,7 @@ namespace Ryujinx.Graphics.Vulkan.Dlss // NVOFA (B2 probe): bind the hardware optical flow entry points now that the device exists. // If it reports unavailable, the Lucas-Kanade motion passes above remain the MV source. _ofa = new NvOpticalFlow(); - _ofa.TryBind(gd.Api, device); + _ofa.TryBind(gd.Api, device, gd.PhysicalDevice, gd.OpticalFlowQueue, gd.OpticalFlowQueueFamilyIndex, gd.QueueFamilyIndex); } public bool TryRun( @@ -427,6 +422,14 @@ namespace Ryujinx.Graphics.Vulkan.Dlss _bailedLastFrame = false; + // B2 multi-queue: run the hardware optical flow on its OWN queue (fam5), not this graphics + // command buffer (which TDRs). Self-contained fenced submit; output not consumed yet (DLSS still + // uses Lucas-Kanade), so this validates the OFA executes legally on its dedicated queue. + if (_ofa.SessionReady) + { + _ofa.ExecuteOnOfaQueue(); + } + StreamlineDlss.DlssTexture inTex = Describe(input, cbs); StreamlineDlss.DlssTexture outTex = Describe(_output, cbs); // B1a real depth: use the captured guest depth buffer only when it matches the input (render) @@ -627,9 +630,6 @@ namespace Ryujinx.Graphics.Vulkan.Dlss _motion?.Dispose(); _motionFiltered?.Dispose(); _prevColor?.Dispose(); // was leaked on every reallocation (dynamic-resolution games churn this) - _ofaInput?.Dispose(); - _ofaPrev?.Dispose(); - _ofaFlow?.Dispose(); _inW = input.Width; _inH = input.Height; @@ -643,15 +643,12 @@ namespace Ryujinx.Graphics.Vulkan.Dlss _prevColor = _gd.CreateTexture(MakeInfo(input.Info, input.Width, input.Height, input.Info.Format, input.Info.BytesPerPixel)) as TextureView; _hasPrev = false; - // NVOFA (3a): allocate the hardware optical flow images + (re)create the session at this render - // resolution. Only when the probe found the extension live; on any failure SessionReady stays - // false and the Lucas-Kanade motion passes remain the motion-vector source (safe fallback). + // NVOFA (3a/3b): (re)create the hardware optical flow session + its raw images at this render + // resolution. The images are created/owned inside NvOpticalFlow (they need the special pNext). + // On any failure SessionReady stays false and the Lucas-Kanade motion passes remain the source. if (_ofa.Available) { - _ofaInput = _gd.CreateTexture(MakeInfo(input.Info, input.Width, input.Height, Format.R8Unorm, 1)) as TextureView; - _ofaPrev = _gd.CreateTexture(MakeInfo(input.Info, input.Width, input.Height, Format.R8Unorm, 1)) as TextureView; - _ofaFlow = _gd.CreateTexture(MakeInfo(input.Info, input.Width, input.Height, Format.R16G16Float, 4)) as TextureView; - _ofa.CreateSession(_device, (uint)input.Width, (uint)input.Height); + _ofa.CreateSession(_device, cbs.CommandBuffer, (uint)input.Width, (uint)input.Height); } ClearResources(cbs); @@ -705,27 +702,13 @@ namespace Ryujinx.Graphics.Vulkan.Dlss _gd.Api.CmdClearColorImage(cbs.CommandBuffer, _motionFiltered.GetImage().Get(cbs).Value, ImageLayout.General, in zeroColor, 1, in colorRange); _gd.Api.CmdClearColorImage(cbs.CommandBuffer, _prevColor.GetImage().Get(cbs).Value, ImageLayout.General, in zeroColor, 1, in colorRange); - // NVOFA (3a) images get the same initial clear as every other DLSS scratch texture: without it - // they were the only resources left in VK_IMAGE_LAYOUT_UNDEFINED, which perturbed the main - // render pipeline (isolation-confirmed micro-flicker). Clearing transitions them to a defined - // General layout with defined contents, matching how _output/_motion/etc. are handled above. - if (_ofa.Available && _ofaInput != null) - { - _gd.Api.CmdClearColorImage(cbs.CommandBuffer, _ofaInput.GetImage().Get(cbs).Value, ImageLayout.General, in zeroColor, 1, in colorRange); - _gd.Api.CmdClearColorImage(cbs.CommandBuffer, _ofaPrev.GetImage().Get(cbs).Value, ImageLayout.General, in zeroColor, 1, in colorRange); - _gd.Api.CmdClearColorImage(cbs.CommandBuffer, _ofaFlow.GetImage().Get(cbs).Value, ImageLayout.General, in zeroColor, 1, in colorRange); - } - ClearDepthStencilValue zeroDepth = new() { Depth = 0f, Stencil = 0 }; _gd.Api.CmdClearDepthStencilImage(cbs.CommandBuffer, _depth.GetImage().Get(cbs).Value, ImageLayout.General, in zeroDepth, 1, in depthRange); } public void Dispose() { - _ofa?.DestroySession(_device); - _ofaInput?.Dispose(); - _ofaPrev?.Dispose(); - _ofaFlow?.Dispose(); + _ofa?.Dispose(_device); _output?.Dispose(); _depth?.Dispose(); _motion?.Dispose(); diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/NvOpticalFlow.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/NvOpticalFlow.cs index 1f36dccd4..0890d6d6b 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/NvOpticalFlow.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/NvOpticalFlow.cs @@ -7,14 +7,15 @@ using System.Runtime.InteropServices; namespace Ryujinx.Graphics.Vulkan.Dlss { /// - /// Manual binding of the VK_NV_optical_flow (NVOFA) device functions. Silk.NET 2.23.0 ships the - /// structs/enums/handles for this extension in its core assembly but not the function wrappers - /// (those live in the unreferenced Silk.NET.Vulkan.Extensions.NV package), so we resolve the four - /// entry points ourselves via vkGetDeviceProcAddr -- no extra NuGet dependency. + /// Manual binding + driving of VK_NV_optical_flow (NVOFA). Silk.NET 2.23.0 ships the structs/enums/ + /// handles in its core assembly but not the function wrappers (those live in the unreferenced + /// Silk.NET.Vulkan.Extensions.NV package), so we resolve the four entry points ourselves via + /// vkGetDeviceProcAddr -- no extra NuGet dependency. /// - /// B2 step 2 ("the probe"): only resolves the pointers and reports whether the - /// hardware optical flow is available. The session/image setup (step 3) must be gated on - /// ; until then DLSS keeps using the Lucas-Kanade compute fallback. + /// Session images MUST be created with a VkOpticalFlowImageFormatInfoNV chained into + /// VkImageCreateInfo.pNext (declaring INPUT/OUTPUT usage); Ryujinx's texture factory cannot inject + /// that pNext, so we create the three images as raw VkImages here. Everything is gated on + /// /; on any failure the caller keeps Lucas-Kanade. /// internal sealed unsafe class NvOpticalFlow { @@ -30,17 +31,53 @@ namespace Ryujinx.Graphics.Vulkan.Dlss [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate void ExecuteDelegate(CommandBuffer commandBuffer, OpticalFlowSessionNV session, OpticalFlowExecuteInfoNV* executeInfo); + // VK_OPTICAL_FLOW_USAGE_*_BIT_NV + private const uint UsageInput = 1; + private const uint UsageOutput = 2; + + // Output flow grid bit (1/2/4/8 = 1x1/2x2/4x4/8x8). Chosen at runtime from the hardware's + // supportedOutputGridSizes (see QueryHardware). The flow image is 1/_outputGrid the input size, + // and the bit value conveniently == the divisor. Default 4x4 until the query runs. + private uint _outputGrid = 4; + + // VkOpticalFlowSessionBindingPointNV + private const uint BindInput = 1; + private const uint BindReference = 2; + private const uint BindFlowVector = 4; + private CreateSessionDelegate _createSession; private DestroySessionDelegate _destroySession; private BindImageDelegate _bindImage; private ExecuteDelegate _execute; + private Vk _api; + private Device _device; + private PhysicalDevice _physicalDevice; + + // B2 multi-queue: dedicated optical-flow queue (fam5) + its own command pool/buffer + a fence for a + // self-contained synchronous submit. vkCmdOpticalFlowExecuteNV MUST run here, not on graphics. + private Queue _ofaQueue; + private uint _ofaFamily = uint.MaxValue; + private uint _graphicsFamily; + private CommandPool _cmdPool; + private CommandBuffer _cmdBuffer; + private Fence _fence; + private bool _queueReady; + private OpticalFlowSessionNV _session; + // Raw OFA session images: input (frame N), reference (frame N-1), flow-vector output. + private Image _imgInput, _imgRef, _imgFlow; + private DeviceMemory _memInput, _memRef, _memFlow; + private ImageView _viewInput, _viewRef, _viewFlow; + + private bool _bound; + private int _execLogCount; + /// True only when all four entry points resolved, i.e. the device really exposes NVOFA. public bool Available { get; private set; } - /// True when a hardware optical flow session is currently created and ready (3a). + /// True when a session + its images are created and bound, ready to execute. public bool SessionReady { get; private set; } /// @@ -48,8 +85,15 @@ namespace Ryujinx.Graphics.Vulkan.Dlss /// to call once after device creation; if anything is missing, stays false /// and the caller falls back to the Lucas-Kanade motion estimator. /// - public void TryBind(Vk api, Device device) + public void TryBind(Vk api, Device device, PhysicalDevice physicalDevice, Queue opticalFlowQueue, uint opticalFlowQueueFamily, uint graphicsQueueFamily) { + _api = api; + _device = device; + _physicalDevice = physicalDevice; + _ofaQueue = opticalFlowQueue; + _ofaFamily = opticalFlowQueueFamily; + _graphicsFamily = graphicsQueueFamily; + _createSession = Load(api, device, "vkCreateOpticalFlowSessionNV"); _bindImage = Load(api, device, "vkBindOpticalFlowSessionImageNV"); _execute = Load(api, device, "vkCmdOpticalFlowExecuteNV"); @@ -65,15 +109,129 @@ namespace Ryujinx.Graphics.Vulkan.Dlss (Available ? "AVAILABLE (hardware optical flow ready; session setup may proceed)." : "UNAVAILABLE (falling back to Lucas-Kanade motion estimation).")); + + if (Available) + { + QueryHardware(); + SetupOfaQueue(device); + } } /// - /// Creates (or recreates) the hardware optical flow session for the given render resolution. Uses - /// the canonical NVOFA formats (R8_UNORM input -> R16G16_SFLOAT flow, 1x1 output grid). A wrong - /// format/parameter is reported as a VkResult error (not a device loss), so this doubles as the - /// format validator; on any failure the caller keeps using Lucas-Kanade. + /// Creates the dedicated command pool + buffer + fence on the optical-flow queue family so the OFA + /// execute can be submitted there (the only legal family). Without it, _queueReady stays false and + /// the caller keeps Lucas-Kanade. /// - public bool CreateSession(Device device, uint width, uint height) + private void SetupOfaQueue(Device device) + { + if (_ofaFamily == uint.MaxValue) + { + Logger.Warning?.Print(LogClass.Gpu, "NVOFA queue setup: no dedicated optical-flow queue was created; OFA execute disabled."); + + return; + } + + CommandPoolCreateInfo pci = new() + { + SType = StructureType.CommandPoolCreateInfo, + QueueFamilyIndex = _ofaFamily, + Flags = CommandPoolCreateFlags.ResetCommandBufferBit, + }; + + if (_api.CreateCommandPool(device, &pci, null, out _cmdPool) != Result.Success) + { + Logger.Warning?.Print(LogClass.Gpu, "NVOFA queue setup: command pool creation failed."); + + return; + } + + CommandBufferAllocateInfo cbi = new() + { + SType = StructureType.CommandBufferAllocateInfo, + CommandPool = _cmdPool, + Level = CommandBufferLevel.Primary, + CommandBufferCount = 1, + }; + + CommandBuffer cb; + if (_api.AllocateCommandBuffers(device, &cbi, &cb) != Result.Success) + { + Logger.Warning?.Print(LogClass.Gpu, "NVOFA queue setup: command buffer allocation failed."); + + return; + } + + _cmdBuffer = cb; + + FenceCreateInfo fci = new() { SType = StructureType.FenceCreateInfo }; + if (_api.CreateFence(device, &fci, null, out _fence) != Result.Success) + { + Logger.Warning?.Print(LogClass.Gpu, "NVOFA queue setup: fence creation failed."); + + return; + } + + _queueReady = true; + Logger.Info?.Print(LogClass.Gpu, $"NVOFA queue setup: command pool + buffer + fence ready on optical-flow family {_ofaFamily}."); + } + + /// + /// Interrogates the real NVOFA hardware limits (logged at startup, before any execute, so the data + /// survives even if a later command TDRs). Reads supportedOutputGridSizes via + /// vkGetPhysicalDeviceProperties2 + VkPhysicalDeviceOpticalFlowPropertiesNV and picks the coarsest + /// supported grid (safest vs TDR). Also logs each queue family's optical-flow capability + /// (VK_QUEUE_OPTICAL_FLOW_BIT_NV = 0x100): vkCmdOpticalFlowExecuteNV is only valid on a queue family + /// that advertises it, so this tells us whether the graphics queue we record into is even allowed. + /// + private void QueryHardware() + { + PhysicalDeviceOpticalFlowPropertiesNV ofaProps = new() { SType = StructureType.PhysicalDeviceOpticalFlowPropertiesNV }; + PhysicalDeviceProperties2 props2 = new() { SType = StructureType.PhysicalDeviceProperties2, PNext = &ofaProps }; + _api.GetPhysicalDeviceProperties2(_physicalDevice, &props2); + + uint grids = (uint)ofaProps.SupportedOutputGridSizes; + + // Coarsest supported = least hardware load = best chance against a TDR. + if ((grids & 8) != 0) _outputGrid = 8; + else if ((grids & 4) != 0) _outputGrid = 4; + else if ((grids & 2) != 0) _outputGrid = 2; + else if ((grids & 1) != 0) _outputGrid = 1; + + Logger.Info?.Print(LogClass.Gpu, + $"NVOFA HW Query: supportedOutputGridSizes bitmask=0x{grids:X} " + + $"(1x1={(grids & 1) != 0}, 2x2={(grids & 2) != 0}, 4x4={(grids & 4) != 0}, 8x8={(grids & 8) != 0}), " + + $"min={ofaProps.MinWidth}x{ofaProps.MinHeight}, max={ofaProps.MaxWidth}x{ofaProps.MaxHeight} -> chosen grid {_outputGrid}x{_outputGrid}."); + + uint count = 0; + _api.GetPhysicalDeviceQueueFamilyProperties(_physicalDevice, ref count, null); + + if (count != 0) + { + QueueFamilyProperties[] fams = new QueueFamilyProperties[count]; + fixed (QueueFamilyProperties* p = fams) + { + _api.GetPhysicalDeviceQueueFamilyProperties(_physicalDevice, ref count, p); + } + + const uint OfaQueueBit = 0x100; // VK_QUEUE_OPTICAL_FLOW_BIT_NV + string s = ""; + for (int i = 0; i < count; i++) + { + uint flags = (uint)fams[i].QueueFlags; + s += $"[fam{i}: flags=0x{flags:X} gfx={(flags & (uint)QueueFlags.GraphicsBit) != 0} compute={(flags & (uint)QueueFlags.ComputeBit) != 0} OFA={(flags & OfaQueueBit) != 0}] "; + } + + Logger.Info?.Print(LogClass.Gpu, $"NVOFA HW Query: queue families = {s}(vkCmdOpticalFlowExecuteNV is only legal on a family with OFA=true)."); + } + } + + /// + /// Creates the session, its three raw OFA images (with the mandatory format pNext), transitions + /// them to GENERAL, clears them and binds them. Records the transition/clear into . + /// Any failure is reported and leaves false (caller keeps Lucas-Kanade); + /// a wrong format/parameter surfaces as a VkResult error rather than a device loss. + /// + public bool CreateSession(Device device, CommandBuffer cmd, uint width, uint height) { if (!Available) { @@ -90,7 +248,7 @@ namespace Ryujinx.Graphics.Vulkan.Dlss ImageFormat = Format.R8Unorm, FlowVectorFormat = Format.R16G16Sfloat, CostFormat = Format.Undefined, - OutputGridSize = (OpticalFlowGridSizeFlagsNV)1, // VK_OPTICAL_FLOW_GRID_SIZE_1X1_BIT_NV + OutputGridSize = (OpticalFlowGridSizeFlagsNV)_outputGrid, // chosen from supportedOutputGridSizes (QueryHardware) HintGridSize = (OpticalFlowGridSizeFlagsNV)0, PerformanceLevel = (OpticalFlowPerformanceLevelNV)2, // MEDIUM }; @@ -107,16 +265,59 @@ namespace Ryujinx.Graphics.Vulkan.Dlss } _session = session; + + // The three session images MUST carry VkOpticalFlowImageFormatInfoNV in their pNext. Inputs are + // full render resolution; the flow output is 1/_outputGrid (one motion vector per grid cell). + uint flowW = (width + _outputGrid - 1) / _outputGrid; + uint flowH = (height + _outputGrid - 1) / _outputGrid; + + if (!CreateImage(device, width, height, Format.R8Unorm, UsageInput, out _imgInput, out _memInput, out _viewInput) || + !CreateImage(device, width, height, Format.R8Unorm, UsageInput, out _imgRef, out _memRef, out _viewRef) || + !CreateImage(device, flowW, flowH, Format.R16G16Sfloat, UsageOutput, out _imgFlow, out _memFlow, out _viewFlow)) + { + Logger.Warning?.Print(LogClass.Gpu, "NVOFA Session: optical flow image creation failed; keeping Lucas-Kanade."); + DestroySession(device); + + return false; + } + + // UNDEFINED -> GENERAL + clear (defined contents) for all three. + TransitionToGeneral(cmd, _imgInput); + TransitionToGeneral(cmd, _imgRef); + TransitionToGeneral(cmd, _imgFlow); + ClearImage(cmd, _imgInput); + ClearImage(cmd, _imgRef); + ClearImage(cmd, _imgFlow); + + // Bind (host call); VK_NV_optical_flow has no dedicated layouts, the images stay GENERAL. + Result rIn = _bindImage(device, _session, (OpticalFlowSessionBindingPointNV)BindInput, _viewInput, ImageLayout.General); + Result rRef = _bindImage(device, _session, (OpticalFlowSessionBindingPointNV)BindReference, _viewRef, ImageLayout.General); + Result rFlow = _bindImage(device, _session, (OpticalFlowSessionBindingPointNV)BindFlowVector, _viewFlow, ImageLayout.General); + + _bound = rIn == Result.Success && rRef == Result.Success && rFlow == Result.Success; + + if (!_bound) + { + Logger.Warning?.Print(LogClass.Gpu, $"NVOFA Session: image bind failed (input={rIn}, reference={rRef}, flowVector={rFlow}); keeping Lucas-Kanade."); + DestroySession(device); + + return false; + } + SessionReady = true; Logger.Info?.Print(LogClass.Gpu, - $"NVOFA Session: vkCreateOpticalFlowSessionNV initialized successfully ({width}x{height}, R8_UNORM -> R16G16_SFLOAT, 1x1 grid)."); + $"NVOFA Session: vkCreateOpticalFlowSessionNV initialized successfully ({width}x{height} input, {flowW}x{flowH} flow @ {_outputGrid}x{_outputGrid} grid, R8_UNORM -> R16G16_SFLOAT, pNext images bound)."); return true; } - /// Destroys the current optical flow session if any. + /// Destroys the current session and its images, if any. public void DestroySession(Device device) { + DestroyImage(device, ref _imgInput, ref _memInput, ref _viewInput); + DestroyImage(device, ref _imgRef, ref _memRef, ref _viewRef); + DestroyImage(device, ref _imgFlow, ref _memFlow, ref _viewFlow); + if (_session.Handle != 0 && _destroySession != null) { _destroySession(device, _session, null); @@ -124,6 +325,275 @@ namespace Ryujinx.Graphics.Vulkan.Dlss } SessionReady = false; + _bound = false; + } + + /// + /// Records vkCmdOpticalFlowExecuteNV, wrapped in a coarse memory barrier so prior writes to the + /// input images are visible and the flow write is visible afterwards. No-op unless ready. + /// + public void Execute(CommandBuffer cmd) + { + if (!Available || !SessionReady || !_bound) + { + return; + } + + Barrier(cmd); + + OpticalFlowExecuteInfoNV info = new() + { + SType = StructureType.OpticalFlowExecuteInfoNV, + // Flags = 0 (forward flow), RegionCount = 0 (whole image). + }; + + _execute(cmd, _session, &info); + + Barrier(cmd); + + if (_execLogCount < 3) + { + _execLogCount++; + Logger.Info?.Print(LogClass.Gpu, "NVOFA Execute: Dummy hardware motion vectors generated (vkCmdOpticalFlowExecuteNV recorded, no error)."); + } + } + + /// + /// Self-contained synchronous run of the hardware optical flow on its OWN queue (fam5): records the + /// execute into the dedicated command buffer, submits it to the optical-flow queue and waits the + /// fence. This is the only legal place for vkCmdOpticalFlowExecuteNV. Output is not yet consumed by + /// graphics, so no cross-queue semaphores are needed for the dry run (they come with 3c). + /// + public void ExecuteOnOfaQueue() + { + if (!Available || !SessionReady || !_bound || !_queueReady) + { + return; + } + + _api.ResetCommandBuffer(_cmdBuffer, 0); + + CommandBufferBeginInfo begin = new() + { + SType = StructureType.CommandBufferBeginInfo, + Flags = CommandBufferUsageFlags.OneTimeSubmitBit, + }; + + if (_api.BeginCommandBuffer(_cmdBuffer, &begin) != Result.Success) + { + return; + } + + Execute(_cmdBuffer); // records the barrier + vkCmdOpticalFlowExecuteNV into the OFA command buffer + + if (_api.EndCommandBuffer(_cmdBuffer) != Result.Success) + { + return; + } + + CommandBuffer cb = _cmdBuffer; + SubmitInfo submit = new() + { + SType = StructureType.SubmitInfo, + CommandBufferCount = 1, + PCommandBuffers = &cb, + }; + + _api.ResetFences(_device, 1, in _fence); + _api.QueueSubmit(_ofaQueue, 1, &submit, _fence); + _api.WaitForFences(_device, 1, in _fence, true, ulong.MaxValue); + } + + /// Full teardown: session + images, then the queue command pool/buffer and fence. + public void Dispose(Device device) + { + DestroySession(device); + + if (_fence.Handle != 0) + { + _api.DestroyFence(device, _fence, null); + _fence = default; + } + + if (_cmdPool.Handle != 0) + { + _api.DestroyCommandPool(device, _cmdPool, null); + _cmdPool = default; + } + + _queueReady = false; + } + + private bool CreateImage(Device device, uint width, uint height, Format format, uint ofaUsage, out Image image, out DeviceMemory memory, out ImageView view) + { + image = default; + memory = default; + view = default; + + OpticalFlowImageFormatInfoNV ofaInfo = new() + { + SType = StructureType.OpticalFlowImageFormatInfoNV, + Usage = (OpticalFlowUsageFlagsNV)ofaUsage, + }; + + // Shared between the graphics family (clears/fills inputs, reads the flow) and the optical-flow + // family (the OFA execute) -- concurrent sharing avoids explicit queue-family ownership transfers. + uint* families = stackalloc uint[2]; + families[0] = _graphicsFamily; + families[1] = _ofaFamily; + bool concurrent = _queueReady && _graphicsFamily != _ofaFamily; + + ImageCreateInfo ci = new() + { + SType = StructureType.ImageCreateInfo, + PNext = &ofaInfo, + ImageType = ImageType.Type2D, + Format = format, + Extent = new Extent3D(width, height, 1), + MipLevels = 1, + ArrayLayers = 1, + Samples = SampleCountFlags.Count1Bit, + Tiling = ImageTiling.Optimal, + Usage = ImageUsageFlags.SampledBit | ImageUsageFlags.TransferDstBit, + SharingMode = concurrent ? SharingMode.Concurrent : SharingMode.Exclusive, + QueueFamilyIndexCount = concurrent ? 2u : 0u, + PQueueFamilyIndices = concurrent ? families : null, + InitialLayout = ImageLayout.Undefined, + }; + + Image img; + if (_api.CreateImage(device, &ci, null, &img) != Result.Success) + { + return false; + } + + _api.GetImageMemoryRequirements(device, img, out MemoryRequirements req); + + uint memType = FindMemoryType(req.MemoryTypeBits, MemoryPropertyFlags.DeviceLocalBit); + if (memType == uint.MaxValue) + { + _api.DestroyImage(device, img, null); + + return false; + } + + MemoryAllocateInfo ai = new() + { + SType = StructureType.MemoryAllocateInfo, + AllocationSize = req.Size, + MemoryTypeIndex = memType, + }; + + DeviceMemory mem; + if (_api.AllocateMemory(device, &ai, null, &mem) != Result.Success) + { + _api.DestroyImage(device, img, null); + + return false; + } + + _api.BindImageMemory(device, img, mem, 0); + + ImageViewCreateInfo vi = new() + { + SType = StructureType.ImageViewCreateInfo, + Image = img, + ViewType = ImageViewType.Type2D, + Format = format, + SubresourceRange = new ImageSubresourceRange(ImageAspectFlags.ColorBit, 0, 1, 0, 1), + }; + + ImageView v; + if (_api.CreateImageView(device, &vi, null, &v) != Result.Success) + { + _api.FreeMemory(device, mem, null); + _api.DestroyImage(device, img, null); + + return false; + } + + image = img; + memory = mem; + view = v; + + return true; + } + + private uint FindMemoryType(uint typeBits, MemoryPropertyFlags props) + { + _api.GetPhysicalDeviceMemoryProperties(_physicalDevice, out PhysicalDeviceMemoryProperties memProps); + + for (uint i = 0; i < memProps.MemoryTypeCount; i++) + { + if ((typeBits & (1u << (int)i)) != 0 && (memProps.MemoryTypes[(int)i].PropertyFlags & props) == props) + { + return i; + } + } + + return uint.MaxValue; + } + + private void TransitionToGeneral(CommandBuffer cmd, Image image) + { + ImageMemoryBarrier b = new() + { + SType = StructureType.ImageMemoryBarrier, + SrcAccessMask = 0, + DstAccessMask = AccessFlags.MemoryReadBit | AccessFlags.MemoryWriteBit, + OldLayout = ImageLayout.Undefined, + NewLayout = ImageLayout.General, + SrcQueueFamilyIndex = Vk.QueueFamilyIgnored, + DstQueueFamilyIndex = Vk.QueueFamilyIgnored, + Image = image, + SubresourceRange = new ImageSubresourceRange(ImageAspectFlags.ColorBit, 0, 1, 0, 1), + }; + + _api.CmdPipelineBarrier(cmd, PipelineStageFlags.TopOfPipeBit, PipelineStageFlags.AllCommandsBit, 0, 0, null, 0, null, 1, &b); + } + + private void ClearImage(CommandBuffer cmd, Image image) + { + ClearColorValue zero = default; + ImageSubresourceRange range = new(ImageAspectFlags.ColorBit, 0, 1, 0, 1); + + _api.CmdClearColorImage(cmd, image, ImageLayout.General, &zero, 1, &range); + } + + private void DestroyImage(Device device, ref Image image, ref DeviceMemory memory, ref ImageView view) + { + if (view.Handle != 0) + { + _api.DestroyImageView(device, view, null); + view = default; + } + + if (image.Handle != 0) + { + _api.DestroyImage(device, image, null); + image = default; + } + + if (memory.Handle != 0) + { + _api.FreeMemory(device, memory, null); + memory = default; + } + } + + // Coarse global memory barrier across ALL_COMMANDS -- conservatively covers the optical-flow stage + // for the dry run; replaced by a precise sync2 (PIPELINE_STAGE_2_OPTICAL_FLOW_BIT_NV) barrier once + // the flow output is actually consumed (3b-ii/3c). + private void Barrier(CommandBuffer cmd) + { + MemoryBarrier mb = new() + { + SType = StructureType.MemoryBarrier, + SrcAccessMask = AccessFlags.MemoryWriteBit, + DstAccessMask = AccessFlags.MemoryReadBit | AccessFlags.MemoryWriteBit, + }; + + _api.CmdPipelineBarrier(cmd, PipelineStageFlags.AllCommandsBit, PipelineStageFlags.AllCommandsBit, 0, 1, &mb, 0, null, 0, null); } private static T Load(Vk api, Device device, string name) where T : Delegate diff --git a/src/Ryujinx.Graphics.Vulkan/VulkanInitialization.cs b/src/Ryujinx.Graphics.Vulkan/VulkanInitialization.cs index aeded4e80..5398569b7 100644 --- a/src/Ryujinx.Graphics.Vulkan/VulkanInitialization.cs +++ b/src/Ryujinx.Graphics.Vulkan/VulkanInitialization.cs @@ -269,7 +269,7 @@ namespace Ryujinx.Graphics.Vulkan return InvalidIndex; } - internal static Device CreateDevice(Vk api, VulkanPhysicalDevice physicalDevice, uint queueFamilyIndex, uint queueCount) + internal static Device CreateDevice(Vk api, VulkanPhysicalDevice physicalDevice, uint queueFamilyIndex, uint queueCount, out uint opticalFlowQueueFamilyIndex) { if (queueCount > QueuesCount) { @@ -283,7 +283,28 @@ namespace Ryujinx.Graphics.Vulkan queuePriorities[i] = 1f; } - DeviceQueueCreateInfo queueCreateInfo = new() + // NVOFA (B2 multi-queue): vkCmdOpticalFlowExecuteNV is only legal on a queue family that + // advertises VK_QUEUE_OPTICAL_FLOW_BIT_NV (on NVIDIA a dedicated family, NOT graphics). When + // DLSS is on and such a family exists, request a second queue from it so optical flow work can + // be submitted on the correct family. uint.MaxValue means "no OFA queue". + opticalFlowQueueFamilyIndex = uint.MaxValue; + if (Dlss.DlssIntegration.IsEnabled && physicalDevice.IsDeviceExtensionPresent("VK_NV_optical_flow")) + { + for (uint i = 0; i < physicalDevice.QueueFamilyProperties.Length; i++) + { + if (i != queueFamilyIndex && ((uint)physicalDevice.QueueFamilyProperties[i].QueueFlags & 0x100u) != 0) + { + opticalFlowQueueFamilyIndex = i; + break; + } + } + } + + float ofaPriority = 1f; + uint queueCreateInfoCount = opticalFlowQueueFamilyIndex != uint.MaxValue ? 2u : 1u; + DeviceQueueCreateInfo* queueCreateInfos = stackalloc DeviceQueueCreateInfo[2]; + + queueCreateInfos[0] = new() { SType = StructureType.DeviceQueueCreateInfo, QueueFamilyIndex = queueFamilyIndex, @@ -291,6 +312,17 @@ namespace Ryujinx.Graphics.Vulkan PQueuePriorities = queuePriorities, }; + if (queueCreateInfoCount == 2) + { + queueCreateInfos[1] = new() + { + SType = StructureType.DeviceQueueCreateInfo, + QueueFamilyIndex = opticalFlowQueueFamilyIndex, + QueueCount = 1, + PQueuePriorities = &ofaPriority, + }; + } + bool useRobustBufferAccess = VendorUtils.FromId(physicalDevice.PhysicalDeviceProperties.VendorID) == Vendor.Nvidia; PhysicalDeviceFeatures2 features2 = new() @@ -646,8 +678,8 @@ namespace Ryujinx.Graphics.Vulkan { SType = StructureType.DeviceCreateInfo, PNext = pExtendedFeatures, - QueueCreateInfoCount = 1, - PQueueCreateInfos = &queueCreateInfo, + QueueCreateInfoCount = queueCreateInfoCount, + PQueueCreateInfos = queueCreateInfos, PpEnabledExtensionNames = (byte**)ppEnabledExtensions, EnabledExtensionCount = (uint)enabledExtensions.Length, PEnabledFeatures = &features, diff --git a/src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs b/src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs index c9c19abb5..3336ea071 100644 --- a/src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs +++ b/src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs @@ -37,6 +37,7 @@ namespace Ryujinx.Graphics.Vulkan internal HardwareCapabilities Capabilities; internal Vk Api { get; private set; } + internal PhysicalDevice PhysicalDevice => _physicalDevice.PhysicalDevice; // for NVOFA raw image memory-type selection internal KhrSurface SurfaceApi { get; private set; } internal KhrSwapchain SwapchainApi { get; private set; } internal ExtConditionalRendering ConditionalRenderingApi { get; private set; } @@ -49,6 +50,8 @@ namespace Ryujinx.Graphics.Vulkan internal uint QueueFamilyIndex { get; private set; } internal Queue Queue { get; private set; } internal Queue BackgroundQueue { get; private set; } + internal Queue OpticalFlowQueue { get; private set; } // NVOFA dedicated queue (B2 multi-queue); default null + internal uint OpticalFlowQueueFamilyIndex { get; private set; } = uint.MaxValue; internal Lock BackgroundQueueLock { get; private set; } internal Lock QueueLock { get; private set; } @@ -501,7 +504,7 @@ namespace Ryujinx.Graphics.Vulkan uint queueFamilyIndex = VulkanInitialization.FindSuitableQueueFamily(Api, _physicalDevice, _surface, out uint maxQueueCount); - _device = VulkanInitialization.CreateDevice(Api, _physicalDevice, queueFamilyIndex, maxQueueCount); + _device = VulkanInitialization.CreateDevice(Api, _physicalDevice, queueFamilyIndex, maxQueueCount, out uint opticalFlowQueueFamilyIndex); if (Api.TryGetDeviceExtension(_instance.Instance, _device, out KhrSwapchain swapchainApi)) { @@ -516,6 +519,17 @@ namespace Ryujinx.Graphics.Vulkan QueueFamilyIndex = queueFamilyIndex; + // NVOFA (B2 multi-queue): acquire the dedicated optical-flow queue if one was requested at + // device creation. vkCmdOpticalFlowExecuteNV must be submitted here, not on the graphics queue. + if (opticalFlowQueueFamilyIndex != uint.MaxValue) + { + Api.GetDeviceQueue(_device, opticalFlowQueueFamilyIndex, 0, out Queue opticalFlowQueue); + OpticalFlowQueue = opticalFlowQueue; + OpticalFlowQueueFamilyIndex = opticalFlowQueueFamilyIndex; + Ryujinx.Common.Logging.Logger.Info?.Print(Ryujinx.Common.Logging.LogClass.Gpu, + $"NVOFA: dedicated optical-flow queue acquired (family {opticalFlowQueueFamilyIndex}); device created with 2 queue families."); + } + _window = new Window(this, _surface, _physicalDevice.PhysicalDevice, _device); _initialized = true; From 470c065b31404141e8ce97fe7e18e3fbe0e4d6bd Mon Sep 17 00:00:00 2001 From: The Roofer Dev Date: Sun, 28 Jun 2026 11:25:54 -0400 Subject: [PATCH 09/20] WIP 3c-i: feed real frames to NVOFA + measure flow (S10.5 decode) vs Lucas-Kanade [measurement only, not wired to DLSS] --- .../Dlss/DlssUpscaler.cs | 15 +- .../Dlss/NvOpticalFlow.cs | 175 ++++++++++++++++++ 2 files changed, 186 insertions(+), 4 deletions(-) diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs index 0089f1921..08e837734 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs @@ -422,11 +422,18 @@ namespace Ryujinx.Graphics.Vulkan.Dlss _bailedLastFrame = false; - // B2 multi-queue: run the hardware optical flow on its OWN queue (fam5), not this graphics - // command buffer (which TDRs). Self-contained fenced submit; output not consumed yet (DLSS still - // uses Lucas-Kanade), so this validates the OFA executes legally on its dedicated queue. - if (_ofa.SessionReady) + // 3c-i: feed the REAL frames (N + N-1) into the OFA inputs, run it on fam5, and measure its flow + // field vs Lucas-Kanade. The OFA output is still NOT consumed by DLSS (LK stays the MV source) -- + // this run only compares quality with numbers before deciding to wire the hardware flow in. + if (_ofa.SessionReady && _hasPrev) { + _ofa.UploadFrames( + cbs.CommandBuffer, + input.GetImage().Get(cbs).Value, + _prevColor.GetImage().Get(cbs).Value, + (uint)input.Width, + (uint)input.Height); + _ofa.ExecuteOnOfaQueue(); } diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/NvOpticalFlow.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/NvOpticalFlow.cs index 0890d6d6b..00db6d292 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/NvOpticalFlow.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/NvOpticalFlow.cs @@ -74,6 +74,14 @@ namespace Ryujinx.Graphics.Vulkan.Dlss private bool _bound; private int _execLogCount; + // 3c-i measurement: flow dims + a host-visible readback of the flow field to compute RMS/outliers + // on the CPU and compare against the Lucas-Kanade metrics. + private uint _flowW, _flowH; + private Silk.NET.Vulkan.Buffer _readbackBuffer; + private DeviceMemory _readbackMemory; + private void* _readbackPtr; + private int _measureLogCount; + /// True only when all four entry points resolved, i.e. the device really exposes NVOFA. public bool Available { get; private set; } @@ -304,6 +312,10 @@ namespace Ryujinx.Graphics.Vulkan.Dlss return false; } + _flowW = flowW; + _flowH = flowH; + CreateReadbackBuffer(device, flowW, flowH); + SessionReady = true; Logger.Info?.Print(LogClass.Gpu, $"NVOFA Session: vkCreateOpticalFlowSessionNV initialized successfully ({width}x{height} input, {flowW}x{flowH} flow @ {_outputGrid}x{_outputGrid} grid, R8_UNORM -> R16G16_SFLOAT, pNext images bound)."); @@ -318,6 +330,24 @@ namespace Ryujinx.Graphics.Vulkan.Dlss DestroyImage(device, ref _imgRef, ref _memRef, ref _viewRef); DestroyImage(device, ref _imgFlow, ref _memFlow, ref _viewFlow); + if (_readbackBuffer.Handle != 0) + { + if (_readbackPtr != null) + { + _api.UnmapMemory(device, _readbackMemory); + _readbackPtr = null; + } + + _api.DestroyBuffer(device, _readbackBuffer, null); + _readbackBuffer = default; + } + + if (_readbackMemory.Handle != 0) + { + _api.FreeMemory(device, _readbackMemory, null); + _readbackMemory = default; + } + if (_session.Handle != 0 && _destroySession != null) { _destroySession(device, _session, null); @@ -386,6 +416,19 @@ namespace Ryujinx.Graphics.Vulkan.Dlss Execute(_cmdBuffer); // records the barrier + vkCmdOpticalFlowExecuteNV into the OFA command buffer + // 3c-i: copy the flow field into the host-visible buffer (fam5 supports transfer) so we can + // measure it on the CPU after the fence. Same command buffer -> covered by the same fence. + if (_readbackBuffer.Handle != 0) + { + BufferImageCopy copy = new() + { + ImageSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1), + ImageExtent = new Extent3D(_flowW, _flowH, 1), + }; + + _api.CmdCopyImageToBuffer(_cmdBuffer, _imgFlow, ImageLayout.General, _readbackBuffer, 1, ©); + } + if (_api.EndCommandBuffer(_cmdBuffer) != Result.Success) { return; @@ -402,6 +445,138 @@ namespace Ryujinx.Graphics.Vulkan.Dlss _api.ResetFences(_device, 1, in _fence); _api.QueueSubmit(_ofaQueue, 1, &submit, _fence); _api.WaitForFences(_device, 1, in _fence, true, ulong.MaxValue); + + MeasureFlow(); + } + + /// + /// Uploads the real current (frame N) and previous (frame N-1) colour images into the OFA R8 inputs + /// via a transfer blit (RGBA -> R, recorded on the GRAPHICS command buffer since it owns the + /// colour images; the OFA images are concurrent so graphics may write them). Replaces the dry-run + /// zeros so the hardware optical flow runs on real content. One frame of latency vs the synchronous + /// OFA submit is fine for measurement. + /// + public void UploadFrames(CommandBuffer graphicsCmd, Image current, Image previous, uint width, uint height) + { + if (!Available || !SessionReady) + { + return; + } + + BlitToR8(graphicsCmd, current, _imgInput, width, height); + BlitToR8(graphicsCmd, previous, _imgRef, width, height); + } + + private void BlitToR8(CommandBuffer cmd, Image src, Image dst, uint width, uint height) + { + ImageBlit region = new() + { + SrcSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1), + DstSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1), + }; + + region.SrcOffsets.Element0 = new Offset3D(0, 0, 0); + region.SrcOffsets.Element1 = new Offset3D((int)width, (int)height, 1); + region.DstOffsets.Element0 = new Offset3D(0, 0, 0); + region.DstOffsets.Element1 = new Offset3D((int)width, (int)height, 1); + + _api.CmdBlitImage(cmd, src, ImageLayout.General, dst, ImageLayout.General, 1, ®ion, Filter.Nearest); + } + + // 3c-i: parse the readback (R16G16_SFLOAT) and log the RMS / outlier rate of the hardware flow so + // it can be compared to the Lucas-Kanade metrics line. Throttled to ~every 120 frames. + private void MeasureFlow() + { + if (_readbackPtr == null) + { + return; + } + + // NV OFA flow vectors are S10.5 fixed-point: signed int16 where pixels = value / 32. They are + // NOT fp16 -- reading them as half gives garbage (huge values / fake NaNs) for any non-zero flow. + short* data = (short*)_readbackPtr; + int count = (int)(_flowW * _flowH); + double sumSq = 0.0; + double maxSq = 0.0; + int outliers = 0; + + for (int i = 0; i < count; i++) + { + float x = data[(i * 2) + 0] / 32.0f; + float y = data[(i * 2) + 1] / 32.0f; + float magSq = (x * x) + (y * y); + sumSq += magSq; + + if (magSq > maxSq) + { + maxSq = magSq; + } + + if (magSq > 4.0f) // |mv| > 2px + { + outliers++; + } + } + + if ((_measureLogCount++ % 120) == 0 && count > 0) + { + float rms = (float)Math.Sqrt(sumSq / count); + float max = (float)Math.Sqrt(maxSq); + Logger.Info?.Print(LogClass.Gpu, + $"NVOFA Flow Measure: HW vectors (S10.5) RMS={rms:0.000}px outliers={100f * outliers / count:0.0}% max={max:0.0}px ({_flowW}x{_flowH} @ {_outputGrid}x grid) -- compare vs the 'DLSS motion-field' Lucas-Kanade line."); + } + } + + private void CreateReadbackBuffer(Device device, uint flowW, uint flowH) + { + ulong size = (ulong)flowW * flowH * 4; // R16G16_SFLOAT = 4 bytes/texel + + BufferCreateInfo bci = new() + { + SType = StructureType.BufferCreateInfo, + Size = size, + Usage = BufferUsageFlags.TransferDstBit, + SharingMode = SharingMode.Exclusive, + }; + + if (_api.CreateBuffer(device, &bci, null, out _readbackBuffer) != Result.Success) + { + _readbackBuffer = default; + + return; + } + + _api.GetBufferMemoryRequirements(device, _readbackBuffer, out MemoryRequirements req); + + uint memType = FindMemoryType(req.MemoryTypeBits, MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + if (memType == uint.MaxValue) + { + _api.DestroyBuffer(device, _readbackBuffer, null); + _readbackBuffer = default; + + return; + } + + MemoryAllocateInfo ai = new() + { + SType = StructureType.MemoryAllocateInfo, + AllocationSize = req.Size, + MemoryTypeIndex = memType, + }; + + if (_api.AllocateMemory(device, &ai, null, out _readbackMemory) != Result.Success) + { + _api.DestroyBuffer(device, _readbackBuffer, null); + _readbackBuffer = default; + + return; + } + + _api.BindBufferMemory(device, _readbackBuffer, _readbackMemory, 0); + + void* ptr = null; + _api.MapMemory(device, _readbackMemory, 0, size, 0, ref ptr); + _readbackPtr = ptr; } /// Full teardown: session + images, then the queue command pool/buffer and fence. From 7be0d7d582e49389cf26d77dd57ab54be4694038 Mon Sep 17 00:00:00 2001 From: The Roofer Dev Date: Sun, 28 Jun 2026 12:37:51 -0400 Subject: [PATCH 10/20] Gold Master: Force continuous DLSS execution with real Depth Buffer infrastructure to completely eliminate hybrid breathing artifacts --- .../Dlss/DlssUpscaler.cs | 241 ++---------------- src/Ryujinx.Graphics.Vulkan/Window.cs | 62 +---- 2 files changed, 29 insertions(+), 274 deletions(-) diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs index 08e837734..b18e6bb4b 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs @@ -27,17 +27,9 @@ namespace Ryujinx.Graphics.Vulkan.Dlss private const float SceneChangeHighFraction = 0.85f; // arm a reset only on a near-total change (real scene cut), not fast camera motion private const float SceneChangeLowFraction = 0.55f; // ... and disarm (hysteresis) once it drops back below this private const int ResetCooldownFrames = 45; // min frames between resets (~0.75s), so fast-pan blips don't reset repeatedly - private const float MotionFullScale = 0.15f; // motion fraction treated as "fully dynamic" (staticScore -> 0) - private const float ModeBlendRate = 0.05f; // symmetric exponential smoothing of the static score (~20-frame time constant), in BOTH directions - private const float StaticEnterThreshold = 0.70f; // hysteresis HIGH: smoothed static score at/above which we hand off to the spatial (NIS) filter - private const float StaticExitThreshold = 0.45f; // hysteresis LOW: smoothed static score at/below which we resume DLSS; the wide [0.45,0.70] dead-band stops the pumping - private const int ModeSwitchHoldFrames = 20; // min frames to hold a DLSS<->spatial state before flipping (secondary guard atop the hysteresis) - private const float CrossfadeStep = 1f / 12f; // visual cross-fade speed: a ~12-frame (~0.2s) dissolve between DLSS and the spatial filter at a transition - private const int CounterCount = 9; // uints in the motion-pass SSBO: 2 scene-change + 4 raw stats + 3 filtered stats - private const int StatLogInterval = 120; // frames between motion-field metric log lines + private const int CounterCount = 9; // uints in the motion-pass SSBO (scene-change counters; the trailing stat slots are now always zero) private readonly VulkanRenderer _gd; - private readonly Device _device; private TextureView _output; private TextureView _depth; @@ -46,7 +38,6 @@ namespace Ryujinx.Graphics.Vulkan.Dlss private TextureView _prevColor; private bool _hasPrev; - // Two host-mapped uints (unexplained-change + confident-motion pixel counts), read back one // frame late to drive a DLSS history reset on hard full-screen cuts. _wasChanging and // _framesSinceReset make that reset edge-triggered + rate-limited, so a fade or a fast pan @@ -55,16 +46,6 @@ namespace Ryujinx.Graphics.Vulkan.Dlss private bool _wasChanging; private int _framesSinceReset; - // Phase 1 instrumentation: a batch of per-frame motion-field RMS/outlier samples, summarised - // to the log every StatLogInterval frames so a held-still scene yields a stable measurement. - private int _statFrames; - private float _statRmsSum; - private float _statRmsMin = float.MaxValue; - private float _statRmsMax; - private float _statOutlierSum; - private float _statRmsSumF; // filtered-field batch accumulators (A/B vs raw) - private float _statOutlierSumF; - // DLSS Mode B jitter: the offset the just-presented frame was rendered with, and the delta vs // the previous presented frame, used to de-jitter the motion field so DLSS receives M_real. private float _lastJitterX; @@ -74,27 +55,10 @@ namespace Ryujinx.Graphics.Vulkan.Dlss private int _jitterLogCount; private bool? _depthWasReal; // B1a: whether the real captured depth is in use, to log only on change - // Hybrid static/dynamic mode: DLSS reconstructs moving content, but degrades to a soft/aliased - // upscale on static menus (no temporal signal). _modeBlend eases toward "static" and, past a - // threshold, TryRun bails so the configured spatial scaling filter (e.g. NIS) draws instead. - private float _modeBlend; - private bool _spatialActive; - private int _framesSinceModeSwitch = ModeSwitchHoldFrames; - private bool _bailedLastFrame; - private float _transitionBlend; // eased [0,1] cross-fade envelope toward _spatialActive (0 = DLSS, 1 = spatial) - - /// - /// Cross-fade weight for the spatial (NIS) result over the DLSS frame: 0 = pure DLSS, 1 = pure - /// spatial, and strictly between during a transition. The present path blends the two by this so - /// the DLSS<->NIS switch dissolves over a few frames instead of cutting. - /// - public float TransitionAlpha { get; private set; } - private readonly PipelineHelperShader _motionPipeline; private readonly ShaderCollection _motionProgram; private readonly ShaderCollection _motionFilterProgram; private readonly ISampler _sampler; - private readonly NvOpticalFlow _ofa; private int _inW, _inH, _outW, _outH; private uint _frame; @@ -114,7 +78,6 @@ namespace Ryujinx.Graphics.Vulkan.Dlss public DlssUpscaler(VulkanRenderer gd, Device device) { _gd = gd; - _device = device; _motionPipeline = new PipelineHelperShader(gd, device); _motionPipeline.Initialize(); @@ -149,11 +112,6 @@ namespace Ryujinx.Graphics.Vulkan.Dlss ], filterLayout); _sceneChangeBuffer = gd.BufferManager.CreateWithHandle(gd, CounterCount * sizeof(uint)); - - // NVOFA (B2 probe): bind the hardware optical flow entry points now that the device exists. - // If it reports unavailable, the Lucas-Kanade motion passes above remain the MV source. - _ofa = new NvOpticalFlow(); - _ofa.TryBind(gd.Api, device, gd.PhysicalDevice, gd.OpticalFlowQueue, gd.OpticalFlowQueueFamilyIndex, gd.QueueFamilyIndex); } public bool TryRun( @@ -259,132 +217,29 @@ namespace Ryujinx.Graphics.Vulkan.Dlss Logger.Info?.Print(LogClass.Gpu, $"DLSS: using mode {mode} for internal {input.Width}x{input.Height} -> {dlssOutW}x{dlssOutH} (content {contentW}x{contentH})."); } - // Estimate this frame's motion vectors (previous vs current color) into _motion. - // On the very first frame there is no previous frame, so reset DLSS history. We also - // reset on a detected hard scene cut (page/menu swap): last frame's motion pass counts - // the pixels it could not explain, and if most of the screen changed in place we clear - // DLSS history so the stale-history ghosting on the cut resolves in ~1 frame. - ReadCounters(out uint unexplained, out uint motion, - out uint statSamples, out _, out uint sumMagSqFx, out uint outliers, - out _, out uint sumMagSqFxF, out uint outliersF); + // Gold Master: DLSS runs every frame (pure DLSS, no hybrid hand-off to the spatial filter). The + // motion pass below estimates this frame's vectors (previous vs current colour) into _motion, + // and we reset DLSS history on the very first frame and on a detected hard scene cut + // (loading->game, area/menu swap): last frame's motion pass counts the pixels it could not + // explain, and if most of the screen changed in place we clear DLSS history so the stale-history + // ghosting on the cut resolves in ~1 frame. + ReadCounters(out uint unexplained, out uint motion); int total = input.Width * input.Height; float changedFraction = total != 0 ? (float)unexplained / total : 0f; - float motionFraction = total != 0 ? (float)motion / total : 0f; - // Phase 1 instrumentation: fold this frame's motion-field statistics into a batch and log a - // summary every StatLogInterval frames. RMS is the noise floor of the vectors handed to - // DLSS: held still (static), the true motion is 0, so RMS < ~0.5px means the field is clean - // enough for temporal reconstruction (Mode B viable); higher means it is too noisy. - if (statSamples > 0) - { - float rms = MathF.Sqrt(MathF.Max((sumMagSqFx / 1024f) / statSamples, 0f)); - float outlierPct = 100f * outliers / statSamples; - float rmsF = MathF.Sqrt(MathF.Max((sumMagSqFxF / 1024f) / statSamples, 0f)); - float outlierPctF = 100f * outliersF / statSamples; - - _statFrames++; - _statRmsSum += rms; - _statRmsMin = MathF.Min(_statRmsMin, rms); - _statRmsMax = MathF.Max(_statRmsMax, rms); - _statOutlierSum += outlierPct; - _statRmsSumF += rmsF; - _statOutlierSumF += outlierPctF; - - if (_statFrames >= StatLogInterval) - { - Logger.Info?.Print(LogClass.Gpu, - $"DLSS motion-field [{_statFrames}f]: raw RMS={_statRmsSum / _statFrames:0.000}px outliers={_statOutlierSum / _statFrames:0.0}% " + - $"| filtered RMS={_statRmsSumF / _statFrames:0.000}px outliers={_statOutlierSumF / _statFrames:0.0}% " + - $"(raw min {_statRmsMin:0.000}/max {_statRmsMax:0.000}, {statSamples} samples/f, filter {(DlssIntegration.MvFilterEnabled ? "ON" : "OFF")})."); - - _statFrames = 0; - _statRmsSum = 0f; - _statRmsMin = float.MaxValue; - _statRmsMax = 0f; - _statOutlierSum = 0f; - _statRmsSumF = 0f; - _statOutlierSumF = 0f; - } - } - - // Hybrid static/dynamic decision: DLSS has no temporal signal to reconstruct a static scene - // (a menu) and degrades to a soft/aliased upscale, so once the scene is clearly static we bail - // to the configured spatial scaling filter (set it to NIS). - float staticScore = Math.Clamp(1f - motionFraction / MotionFullScale, 0f, 1f); - - // Symmetric exponential smoothing of the raw static score, in BOTH directions. The previous - // code snapped straight to dynamic (instant exit), which let a slow pan whipsaw modeBlend - // across the threshold frame to frame; a single low-pass makes the signal itself move - // gradually so the decision below has something stable to act on. - _modeBlend += (staticScore - _modeBlend) * ModeBlendRate; - - // Schmitt-trigger hysteresis on the smoothed score: switch to the spatial filter only once it - // is clearly static (>= enter), and back to DLSS only once it is clearly moving (<= exit). - // Inside the [exit, enter] dead-band the current state is HELD -- this is what eliminates the - // DLSS<->NIS pumping a single threshold produced when the score hovered around 0.6 during a - // slow camera pan (35 history resets / ~3 flips per second in the measured run). - // RYUJINX_DLSS_FORCE_DLSS pins pure DLSS (never hand off, never resume-reset) for diagnostics. - bool wantSpatial = _spatialActive; - if (DlssIntegration.ForceDlss || !_hasPrev) - { - wantSpatial = false; - } - else if (!_spatialActive && _modeBlend >= StaticEnterThreshold) - { - wantSpatial = true; - } - else if (_spatialActive && _modeBlend <= StaticExitThreshold) - { - wantSpatial = false; - } - - // Secondary guard: still require a minimum dwell between flips. With the hysteresis above this - // rarely binds, but it caps any residual switching rate and avoids a one-frame blip flipping - // the state right after a transition. A flip is only committed once both conditions agree. - _framesSinceModeSwitch++; - if (wantSpatial != _spatialActive && _framesSinceModeSwitch >= ModeSwitchHoldFrames) - { - _spatialActive = wantSpatial; - _framesSinceModeSwitch = 0; - } - - // Visual cross-fade envelope: ease a [0,1] blend toward the discrete hysteresis state - // (1 = spatial/NIS, 0 = DLSS) over a few frames. DLSS keeps rendering for the whole fade - // (alpha < 1); only once it reaches 1 do we hand fully to the spatial path and bail. The - // present path alpha-blends the two results by TransitionAlpha so the switch dissolves. - float blendTarget = _spatialActive ? 1f : 0f; - _transitionBlend = blendTarget > _transitionBlend - ? MathF.Min(_transitionBlend + CrossfadeStep, blendTarget) - : MathF.Max(_transitionBlend - CrossfadeStep, blendTarget); - TransitionAlpha = _transitionBlend; - - bool fullySpatial = _transitionBlend >= 1f; - bool resuming = _bailedLastFrame && !fullySpatial; - - if (fullySpatial && !_bailedLastFrame) - { - Logger.Info?.Print(LogClass.Gpu, $"DLSS: -> spatial fallback (static, modeBlend={_modeBlend:0.00})."); - } - else if (resuming) - { - Logger.Info?.Print(LogClass.Gpu, "DLSS: -> DLSS (motion resumed, history reset)."); - } - - // Reset DLSS history only on the rising edge of a *full-screen* change (loading->game, an - // area transition, opening a full menu), with hysteresis + a cooldown. A localized UI change - // over a paused scene (an inventory tab, or ambient animation while standing still) is left - // alone on purpose: it cannot be told apart from real content, and resetting on every such - // frame stops DLSS ever accumulating, leaving the image permanently aliased (pixelated menu). + // Reset DLSS history only on the rising edge of a *full-screen* change, with hysteresis + a + // cooldown. A localized UI change over a paused scene (an inventory tab, ambient animation while + // standing still) is left alone on purpose: it cannot be told apart from real content, and + // resetting on every such frame stops DLSS ever accumulating, leaving the image aliased. bool changing = _wasChanging ? changedFraction >= SceneChangeLowFraction : changedFraction >= SceneChangeHighFraction; _framesSinceReset++; - bool sceneCut = !DlssIntegration.ForceDlss && _hasPrev && changing && !_wasChanging && _framesSinceReset >= ResetCooldownFrames; + bool sceneCut = _hasPrev && changing && !_wasChanging && _framesSinceReset >= ResetCooldownFrames; _wasChanging = changing; - // ... and when resuming DLSS after a spatial stretch, since its history is stale by then. - bool reset = !_hasPrev || sceneCut || resuming; + bool reset = !_hasPrev || sceneCut; if (reset) { _framesSinceReset = 0; @@ -396,8 +251,8 @@ namespace Ryujinx.Graphics.Vulkan.Dlss $"DLSS: history reset (changed={100f * unexplained / total:0.#}% motion={100f * motion / total:0.#}%)."); } - // Clear the counters for this frame's pass. The buffer is host-mapped, so this CPU write - // lands before the command buffer is submitted, i.e. before the compute pass runs. + // Clear the scene-change counters for this frame's pass. The buffer is host-mapped, so this CPU + // write lands before the command buffer is submitted, i.e. before the compute pass runs. Span zero = stackalloc uint[] { 0u, 0u, 0u, 0u, 0u, 0u, 0u, 0u, 0u }; _gd.BufferManager.SetData(_sceneChangeBuffer, 0, zero); @@ -407,36 +262,6 @@ namespace Ryujinx.Graphics.Vulkan.Dlss RunMotionFilterPass(input, cbs); } - // Fully static (fade complete): the motion pass above kept the counters and previous-frame - // color live; now bail so Window.Present falls back to the configured spatial filter (NIS). - // While the fade is still in progress (0 < alpha < 1) we fall through and keep rendering DLSS, - // so the present path has a DLSS frame to cross-fade against. - if (fullySpatial) - { - CopyToPrev(input, cbs); - _hasPrev = true; - _bailedLastFrame = true; - - return false; - } - - _bailedLastFrame = false; - - // 3c-i: feed the REAL frames (N + N-1) into the OFA inputs, run it on fam5, and measure its flow - // field vs Lucas-Kanade. The OFA output is still NOT consumed by DLSS (LK stays the MV source) -- - // this run only compares quality with numbers before deciding to wire the hardware flow in. - if (_ofa.SessionReady && _hasPrev) - { - _ofa.UploadFrames( - cbs.CommandBuffer, - input.GetImage().Get(cbs).Value, - _prevColor.GetImage().Get(cbs).Value, - (uint)input.Width, - (uint)input.Height); - - _ofa.ExecuteOnOfaQueue(); - } - StreamlineDlss.DlssTexture inTex = Describe(input, cbs); StreamlineDlss.DlssTexture outTex = Describe(_output, cbs); // B1a real depth: use the captured guest depth buffer only when it matches the input (render) @@ -507,7 +332,8 @@ namespace Ryujinx.Graphics.Vulkan.Dlss _motionPipeline.SetTextureAndSampler(ShaderStage.Compute, 3, _prevColor, _sampler); // 8 floats = a full std140 2x vec4 block (width/height/maxMotion/metrics + dejitterX/Y + 2 pad). - ReadOnlySpan p = [input.Width, input.Height, MaxMotion, DlssIntegration.MetricsEnabled ? 1f : 0f, _dejitterX, _dejitterY, 0f, 0f]; + // metrics is hardcoded off: the heavy stat atomics in the shader are dead in the Gold Master. + ReadOnlySpan p = [input.Width, input.Height, MaxMotion, 0f, _dejitterX, _dejitterY, 0f, 0f]; using ScopedTemporaryBuffer buffer = _gd.BufferManager.ReserveOrCreate(_gd, cbs, p.Length * sizeof(float)); buffer.Holder.SetDataUnchecked(buffer.Offset, p); @@ -523,13 +349,14 @@ namespace Ryujinx.Graphics.Vulkan.Dlss private void RunMotionFilterPass(TextureView input, CommandBufferScoped cbs) { // Second compute pass: 3x3 median outlier filter on the raw motion field. Reads _motion - // (made visible by the previous pass's ComputeBarrier) and writes _motionFiltered, then - // re-measures the field statistics on the filtered output for the raw-vs-filtered A/B log. + // (made visible by the previous pass's ComputeBarrier) and writes _motionFiltered -- this is + // what makes the Lucas-Kanade field clean enough to hand to DLSS. _motionPipeline.SetCommandBuffer(cbs); _motionPipeline.SetProgram(_motionFilterProgram); // 8 floats = a full std140 2x vec4 block (width/height/maxMotion/metrics + dejitterX/Y + 2 pad). - ReadOnlySpan p = [input.Width, input.Height, MaxMotion, DlssIntegration.MetricsEnabled ? 1f : 0f, _dejitterX, _dejitterY, 0f, 0f]; + // metrics is hardcoded off: the heavy stat atomics in the shader are dead in the Gold Master. + ReadOnlySpan p = [input.Width, input.Height, MaxMotion, 0f, _dejitterX, _dejitterY, 0f, 0f]; using ScopedTemporaryBuffer buffer = _gd.BufferManager.ReserveOrCreate(_gd, cbs, p.Length * sizeof(float)); buffer.Holder.SetDataUnchecked(buffer.Offset, p); @@ -549,22 +376,13 @@ namespace Ryujinx.Graphics.Vulkan.Dlss /// spans several frames, so resetting one frame late still clears it, and reading last frame's /// value avoids a GPU stall. /// - private void ReadCounters(out uint changed, out uint motion, - out uint statSamples, out uint sumMagFx, out uint sumMagSqFx, out uint outliers, - out uint sumMagFxF, out uint sumMagSqFxF, out uint outliersF) + private void ReadCounters(out uint changed, out uint motion) { using PinnedSpan data = _gd.BufferManager.GetData(_sceneChangeBuffer, 0, CounterCount * sizeof(uint)); ReadOnlySpan bytes = data.Get(); - changed = bytes.Length >= 1 * sizeof(uint) ? BitConverter.ToUInt32(bytes[(0 * sizeof(uint))..]) : 0u; - motion = bytes.Length >= 2 * sizeof(uint) ? BitConverter.ToUInt32(bytes[(1 * sizeof(uint))..]) : 0u; - statSamples = bytes.Length >= 3 * sizeof(uint) ? BitConverter.ToUInt32(bytes[(2 * sizeof(uint))..]) : 0u; - sumMagFx = bytes.Length >= 4 * sizeof(uint) ? BitConverter.ToUInt32(bytes[(3 * sizeof(uint))..]) : 0u; - sumMagSqFx = bytes.Length >= 5 * sizeof(uint) ? BitConverter.ToUInt32(bytes[(4 * sizeof(uint))..]) : 0u; - outliers = bytes.Length >= 6 * sizeof(uint) ? BitConverter.ToUInt32(bytes[(5 * sizeof(uint))..]) : 0u; - sumMagFxF = bytes.Length >= 7 * sizeof(uint) ? BitConverter.ToUInt32(bytes[(6 * sizeof(uint))..]) : 0u; - sumMagSqFxF = bytes.Length >= 8 * sizeof(uint) ? BitConverter.ToUInt32(bytes[(7 * sizeof(uint))..]) : 0u; - outliersF = bytes.Length >= 9 * sizeof(uint) ? BitConverter.ToUInt32(bytes[(8 * sizeof(uint))..]) : 0u; + changed = bytes.Length >= 1 * sizeof(uint) ? BitConverter.ToUInt32(bytes[(0 * sizeof(uint))..]) : 0u; + motion = bytes.Length >= 2 * sizeof(uint) ? BitConverter.ToUInt32(bytes[(1 * sizeof(uint))..]) : 0u; } private void CopyToPrev(TextureView input, CommandBufferScoped cbs) @@ -650,14 +468,6 @@ namespace Ryujinx.Graphics.Vulkan.Dlss _prevColor = _gd.CreateTexture(MakeInfo(input.Info, input.Width, input.Height, input.Info.Format, input.Info.BytesPerPixel)) as TextureView; _hasPrev = false; - // NVOFA (3a/3b): (re)create the hardware optical flow session + its raw images at this render - // resolution. The images are created/owned inside NvOpticalFlow (they need the special pNext). - // On any failure SessionReady stays false and the Lucas-Kanade motion passes remain the source. - if (_ofa.Available) - { - _ofa.CreateSession(_device, cbs.CommandBuffer, (uint)input.Width, (uint)input.Height); - } - ClearResources(cbs); } @@ -715,7 +525,6 @@ namespace Ryujinx.Graphics.Vulkan.Dlss public void Dispose() { - _ofa?.Dispose(_device); _output?.Dispose(); _depth?.Dispose(); _motion?.Dispose(); diff --git a/src/Ryujinx.Graphics.Vulkan/Window.cs b/src/Ryujinx.Graphics.Vulkan/Window.cs index 335af23ed..3c3497fc3 100644 --- a/src/Ryujinx.Graphics.Vulkan/Window.cs +++ b/src/Ryujinx.Graphics.Vulkan/Window.cs @@ -38,7 +38,6 @@ namespace Ryujinx.Graphics.Vulkan private IPostProcessingEffect _effect; private IScalingFilter _scalingFilter; private Dlss.DlssUpscaler _dlss; - private TextureView _crossfadeTarget; // scratch RT holding the spatial result while it is cross-faded over the DLSS frame private bool _isLinear; private float _scalingFilterLevel; private bool _updateScalingFilter; @@ -478,62 +477,10 @@ namespace Ryujinx.Graphics.Vulkan _hdrWhiten); } - if (dlssHandled) - { - // DLSS already wrote the swapchain. If we are mid-transition between DLSS and the spatial - // filter, also render the spatial result and alpha-blend it on top so the switch dissolves - // instead of cutting; the eased weight comes from DLSS's smoothed mode envelope. - float crossfade = _dlss.TransitionAlpha; - if (crossfade > 0f) - { - if (_crossfadeTarget == null || _crossfadeTarget.Width != _width || _crossfadeTarget.Height != _height) - { - _crossfadeTarget?.Dispose(); - _crossfadeTarget = (TextureView)_gd.CreateTexture(_swapchainImageViews[nextImage].Info); - } - - RenderSpatial(_crossfadeTarget); - - // RenderSpatial wrote _crossfadeTarget (NIS = compute storage write, or a plain blit = - // colour-attachment write); make that write available/visible before the blend below - // samples it. The helper pipeline does not emit this read-after-write barrier itself, - // which showed up as flicker during the cross-fade. Cover both producer stages. - ImageMemoryBarrier crossfadeBarrier = new() - { - SType = StructureType.ImageMemoryBarrier, - SrcAccessMask = AccessFlags.ColorAttachmentWriteBit | AccessFlags.ShaderWriteBit, - DstAccessMask = AccessFlags.ShaderReadBit, - OldLayout = ImageLayout.General, - NewLayout = ImageLayout.General, - SrcQueueFamilyIndex = Vk.QueueFamilyIgnored, - DstQueueFamilyIndex = Vk.QueueFamilyIgnored, - Image = _crossfadeTarget.GetImage().Get(cbs).Value, - SubresourceRange = new ImageSubresourceRange(ImageAspectFlags.ColorBit, 0, 1, 0, 1), - }; - - _gd.Api.CmdPipelineBarrier( - cbs.CommandBuffer, - PipelineStageFlags.ColorAttachmentOutputBit | PipelineStageFlags.ComputeShaderBit, - PipelineStageFlags.FragmentShaderBit, - 0, - 0, - null, - 0, - null, - 1, - in crossfadeBarrier); - - _gd.HelperShader.BlitColorWithAlpha( - _gd, - cbs, - _crossfadeTarget, - _swapchainImageViews[nextImage], - new Extents2D(0, 0, _width, _height), - new Extents2D(0, 0, _width, _height), - crossfade); - } - } - else + // DLSS (when it ran) wrote the swapchain directly -- pure DLSS, no hybrid hand-off, nothing more + // to do. Otherwise (DLSS disabled, or it could not run this frame) render the configured spatial + // path: the NIS scaling filter, or a plain HDR-aware blit. + if (!dlssHandled) { RenderSpatial(_swapchainImageViews[nextImage]); } @@ -836,7 +783,6 @@ namespace Ryujinx.Graphics.Vulkan _effect?.Dispose(); _scalingFilter?.Dispose(); _dlss?.Dispose(); - _crossfadeTarget?.Dispose(); } } From bb1a2b24c887b84812fd980c6b4fc280e4b6cbce Mon Sep 17 00:00:00 2001 From: The Roofer Dev Date: Sun, 28 Jun 2026 14:27:08 -0400 Subject: [PATCH 11/20] WIP Mode B jitter: present-queue carrier fix + viewport-shift experiments (sign/scale/NDC/LOD) Carrier repaired (offset carried through the present frame queue -> reaches DLSS, proven). Viewport-shift jitter calibrated (signs -1/-1, magnitude swept) but trembles: viewport jitter on a non-jitter-aware guest does not give clean DLSS accumulation. Parked here to pursue the correct approach: clip-space jitter injected into guest vertex shaders (gl_Position.xy += jitterNDC * w). Gold Master 7be0d7d58 on dlss-wip-snapshot stays the intact reference. --- src/Ryujinx.Graphics.GAL/DlssJitterState.cs | 61 +++++-------------- .../Engine/Threed/StateUpdater.cs | 20 ++---- src/Ryujinx.Graphics.Gpu/Window.cs | 30 ++++++++- .../Dlss/DlssJitter.cs | 49 ++++++++++++++- .../Dlss/DlssUpscaler.cs | 48 +++++++++++---- src/Ryujinx.Graphics.Vulkan/SamplerHolder.cs | 5 +- 6 files changed, 133 insertions(+), 80 deletions(-) diff --git a/src/Ryujinx.Graphics.GAL/DlssJitterState.cs b/src/Ryujinx.Graphics.GAL/DlssJitterState.cs index 4aa666afb..717683076 100644 --- a/src/Ryujinx.Graphics.GAL/DlssJitterState.cs +++ b/src/Ryujinx.Graphics.GAL/DlssJitterState.cs @@ -1,61 +1,32 @@ -using System.Collections.Concurrent; - namespace Ryujinx.Graphics.GAL { /// /// Cross-layer hand-off for DLSS "Mode B" sub-pixel jitter. The DLSS backend generates the Halton - /// offset and publishes the next frame's value in /; the - /// GPU layer (StateUpdater) reads it to shift the resolution-scaled viewport, and tags the rendered - /// color target with the exact offset it used (). At present, the DLSS backend - /// looks the offset back up by the presented texture (), so the value handed to - /// DLSS always matches the jitter baked into that specific frame -- independent of frame-queue depth - /// or frames-in-flight. This lives in GAL because the GPU layer cannot reference the Vulkan backend. + /// offset and publishes the NEXT frame's value in /; the GPU + /// layer (StateUpdater) reads it to shift the resolution-scaled viewport of the frame it is rendering. + /// + /// The hard part is handing DLSS the offset the PRESENTED frame was actually rendered with. Matching by + /// texture reference failed (the presented framebuffer is a different texture than the jittered 3D + /// target), so instead the offset is carried THROUGH the present frame queue: it is snapshotted onto + /// each frame when it is enqueued for presentation, then written to / when that exact frame is dequeued and presented. The DLSS backend reads those, so + /// the offset always matches the frame on screen regardless of frame-queue depth or frames-in-flight. /// /// When is false the GPU layer skips all of this, so the path is byte-identical. + /// This lives in GAL because the GPU layer cannot reference the Vulkan backend. /// public static class DlssJitterState { public static bool Enabled; - /// The next frame's jitter offset, in internal-render pixels, published by the DLSS backend. + /// The next frame's jitter offset, in internal-render pixels, published by the DLSS backend + /// and read by the GPU layer to jitter the scaled viewport. public static float OffsetX; public static float OffsetY; - private static readonly ConcurrentDictionary _tags = new(); - - /// Records the jitter offset a color target was actually rendered with. - public static void Tag(ITexture target, float x, float y) - { - if (target == null) - { - return; - } - - // The few live framebuffers cycle through a handful of textures; bound the map so recreated - // textures can't leak it without an eviction policy (this is an experimental, gated feature). - if (_tags.Count > 64) - { - _tags.Clear(); - } - - _tags[target] = (x, y); - } - - /// Reads back the offset a presented texture was rendered with (0 if untagged). - public static bool TryGet(ITexture target, out float x, out float y) - { - if (target != null && _tags.TryGetValue(target, out (float X, float Y) value)) - { - x = value.X; - y = value.Y; - - return true; - } - - x = 0f; - y = 0f; - - return false; - } + /// The jitter offset the frame currently being presented was rendered with. Set by the GPU + /// present from the value carried alongside that frame through the queue; read by the DLSS backend. + public static float PresentX; + public static float PresentY; } } diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Threed/StateUpdater.cs b/src/Ryujinx.Graphics.Gpu/Engine/Threed/StateUpdater.cs index f2d1b2c41..ba7a4abbf 100644 --- a/src/Ryujinx.Graphics.Gpu/Engine/Threed/StateUpdater.cs +++ b/src/Ryujinx.Graphics.Gpu/Engine/Threed/StateUpdater.cs @@ -453,10 +453,6 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed /// /// Updates render targets (color and depth-stencil buffers) based on current render target state. /// - // DLSS Mode B jitter: the main (index 0) color target bound for the current frame, captured so - // the scaled-viewport jitter offset can be tagged onto exactly this frame for the present. - private Image.Texture _mainColorTarget; - private void UpdateRenderTargetState() { UpdateRenderTargetState(RenderTargetUpdateFlags.UpdateAll); @@ -493,8 +489,6 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed bool changedScale = false; uint rtNoAlphaMask = 0; - _mainColorTarget = null; - Span rtColorStateSpan = _state.State.RtColorState.AsSpan(); for (int index = 0; index < Constants.TotalRenderTargets; index++) @@ -526,11 +520,6 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed changedScale |= _channel.TextureManager.SetRenderTargetColor(index, color); - if (index == 0) - { - _mainColorTarget = color; - } - if (color != null) { if (clipRegionWidth > color.Width / samplesInX) @@ -750,16 +739,15 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed Span viewportTransformSpan = _state.State.ViewportTransform.AsSpan(); Span viewportExtentsSpan = _state.State.ViewportExtents.AsSpan(); - // DLSS Mode B: shift the resolution-scaled viewport by this frame's sub-pixel jitter, and - // tag the main color target with the exact offset so the present can hand it to DLSS. Only - // the scaled (main) pass; native passes (UI) keep RenderTargetScale 1 and stay untouched. - // No-op (offset 0, no tag) unless jitter is enabled, so the default path is unchanged. + // DLSS Mode B: shift the resolution-scaled viewport by this frame's sub-pixel jitter. Only the + // scaled (main) pass; native passes (UI) keep RenderTargetScale 1 and stay untouched. The offset + // is carried to DLSS through the present queue (see DlssJitterState), not tagged on a texture. + // No-op (offset 0) unless jitter is enabled, so the default path is unchanged. float jitterX = 0f, jitterY = 0f; if (DlssJitterState.Enabled && _channel.TextureManager.RenderTargetScale != 1f) { jitterX = DlssJitterState.OffsetX; jitterY = DlssJitterState.OffsetY; - DlssJitterState.Tag(_mainColorTarget?.HostTexture, jitterX, jitterY); } for (int index = 0; index < Constants.TotalViewports; index++) diff --git a/src/Ryujinx.Graphics.Gpu/Window.cs b/src/Ryujinx.Graphics.Gpu/Window.cs index a1b6b73f7..479b236ad 100644 --- a/src/Ryujinx.Graphics.Gpu/Window.cs +++ b/src/Ryujinx.Graphics.Gpu/Window.cs @@ -56,6 +56,13 @@ namespace Ryujinx.Graphics.Gpu /// public object UserObj { get; } + /// + /// DLSS Mode B sub-pixel jitter offset this frame was rendered with, snapshotted at enqueue and + /// carried through the queue so the present can hand the matching offset to DLSS. + /// + public float JitterX { get; } + public float JitterY { get; } + /// /// Creates a new instance of the presentation texture. /// @@ -73,7 +80,9 @@ namespace Ryujinx.Graphics.Gpu ImageCrop crop, Action acquireCallback, Action releaseCallback, - object userObj) + object userObj, + float jitterX, + float jitterY) { Cache = cache; Info = info; @@ -82,6 +91,8 @@ namespace Ryujinx.Graphics.Gpu AcquireCallback = acquireCallback; ReleaseCallback = releaseCallback; UserObj = userObj; + JitterX = jitterX; + JitterY = jitterY; } } @@ -176,6 +187,12 @@ namespace Ryujinx.Graphics.Gpu MultiRange range = new(address, (ulong)size); + // DLSS Mode B: snapshot the jitter offset this frame was rendered with (the value StateUpdater + // last applied to the scaled viewport) so it rides through the queue with the frame and reaches + // DLSS at present, instead of being looked up by a texture reference that does not match. + float jitterX = DlssJitterState.Enabled ? DlssJitterState.OffsetX : 0f; + float jitterY = DlssJitterState.Enabled ? DlssJitterState.OffsetY : 0f; + _frameQueue.Enqueue(new PresentationTexture( physicalMemory.TextureCache, info, @@ -183,7 +200,9 @@ namespace Ryujinx.Graphics.Gpu crop, acquireCallback, releaseCallback, - userObj)); + userObj, + jitterX, + jitterY)); return true; } @@ -238,6 +257,13 @@ namespace Ryujinx.Graphics.Gpu crop = new ImageCrop(left, right, top, bottom, crop.FlipX, crop.FlipY, crop.IsStretched, crop.AspectRatioX, crop.AspectRatioY); } + // DLSS Mode B: publish the jitter offset THIS exact frame was rendered with (carried through + // the queue), so the DLSS backend applies the matching sub-pixel offset. This is the carrier + // that replaces the old texture-reference lookup, which missed because the presented texture + // is not the jittered render target. + DlssJitterState.PresentX = pt.JitterX; + DlssJitterState.PresentY = pt.JitterY; + _context.Renderer.Window.Present(texture.HostTexture, _context.LastPresentDepthStencil?.HostTexture, crop, swapBuffersCallback); pt.ReleaseCallback(pt.UserObj); diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssJitter.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssJitter.cs index c40fd8e8a..84981f26e 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssJitter.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssJitter.cs @@ -1,14 +1,15 @@ using Ryujinx.Graphics.GAL; using System; +using System.Globalization; namespace Ryujinx.Graphics.Vulkan.Dlss { /// /// Generates the deterministic Halton(2,3) sub-pixel jitter for DLSS "Mode B" and publishes the /// NEXT frame's offset to the cross-layer , where the GPU layer reads - /// it to shift the resolution-scaled viewport. The value the present actually hands to DLSS is read - /// back from the frame's tag (), not from here, so it always - /// matches the jitter baked into that exact frame regardless of frame-queue depth. + /// it to shift the resolution-scaled viewport. The value the present actually hands to DLSS is the + /// one carried alongside that frame through the present queue (DlssJitterState.PresentX/Y), not read + /// from here, so it always matches the jitter baked into that exact frame regardless of frame-queue depth. /// /// Gated on RYUJINX_DLSS_JITTER=1; off => DlssJitterState stays disabled and the path is identical. /// @@ -19,6 +20,48 @@ namespace Ryujinx.Graphics.Vulkan.Dlss public static bool Enabled => _flag && DlssIntegration.IsEnabled; + // Magnitude of the jitter offset handed to DLSS, as a multiplier on our scaled-render-pixel value, + // to match NGX's expected unit. Live-tunable via RYUJINX_DLSS_JITTER_SCALE so the value can be swept + // without recompiling; defaults to 0.25. Read once at startup. NOTE: this scales ONLY the offset + // sent to Evaluate -- the motion-field de-jitter stays at full physical magnitude. + private static readonly float _scale = ParseScale(); + + public static float Scale => _scale; + + private static float ParseScale() + { + string value = Environment.GetEnvironmentVariable("RYUJINX_DLSS_JITTER_SCALE"); + + if (value != null && + float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out float scale) && + scale > 0f) + { + return scale; + } + + return 0.5f; // calibrated magic value that locks the image (was swept live from 0.25) + } + + // Texture mip LOD bias applied to every guest sampler while jitter is on (NVIDIA's DLSS guidance: a + // negative bias forces a sharper, stable mip so sub-pixel jitter does not flicker the mip selection + // frame to frame). Live-tunable via RYUJINX_DLSS_JITTER_LOD_BIAS; defaults to -0.5. Read once. + private static readonly float _lodBias = ParseLodBias(); + + public static float LodBias => _lodBias; + + private static float ParseLodBias() + { + string value = Environment.GetEnvironmentVariable("RYUJINX_DLSS_JITTER_LOD_BIAS"); + + if (value != null && + float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out float bias)) + { + return bias; + } + + return -0.5f; + } + private const uint SequenceLength = 16; // a short Halton phase set, classic for DLSS private static uint _index; diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs index b18e6bb4b..c8a7ab554 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs @@ -140,25 +140,39 @@ namespace Ryujinx.Graphics.Vulkan.Dlss return false; } - // Publish the NEXT frame's jitter to the GPU layer, then recover THIS frame's actual offset - // from its tag (set when the guest rendered it). Reading the tag by the presented texture is - // the queue-depth-independent sync: the offset always matches the image. The delta versus the - // previous presented frame de-jitters the motion field so DLSS receives M = M_real. + // Advance the Halton sequence (publishes the NEXT frame's offset to the GPU layer for its scaled + // viewport). THIS frame's actual offset is delivered by the present carrier + // (DlssJitterState.PresentX/Y), which the GPU present set from the value carried alongside this + // exact frame through the queue -- so it always matches the image, independent of frame-queue + // depth. The delta versus the previous presented frame de-jitters the motion field so DLSS + // receives M = M_real. + // Jitter sign calibration (Vulkan Y-down etc. vs the convention Streamline/NGX expects). The SAME + // signs apply to BOTH the offset handed to DLSS (Evaluate, below) AND the de-jitter that removes + // the jitter's apparent motion from the Lucas-Kanade field, so the AI's jitter and our motion + // compensation stay perfectly aligned. + const float JitterSignX = -1f; + const float JitterSignY = -1f; + DlssJitter.Advance(); - bool jitterTagHit = DlssJitterState.TryGet(input, out float frameJitterX, out float frameJitterY); - _dejitterX = frameJitterX - _lastJitterX; - _dejitterY = frameJitterY - _lastJitterY; + float frameJitterX = DlssJitterState.PresentX; + float frameJitterY = DlssJitterState.PresentY; + // De-jitter stays at FULL magnitude: it removes a real input-pixel shift from the Lucas-Kanade + // motion field, so it must not be scaled (scaling it would leave jitter motion in the vectors). + // Only the SIGN aligns it with the offset sent to DLSS; the magnitude scale is applied to that + // offset alone (RYUJINX_DLSS_JITTER_SCALE, at the Evaluate call below). + _dejitterX = JitterSignX * (frameJitterX - _lastJitterX); + _dejitterY = JitterSignY * (frameJitterY - _lastJitterY); _lastJitterX = frameJitterX; _lastJitterY = frameJitterY; - // Diagnostic probe (first few frames): tagHit=false means the carrier did not match this - // texture (DLSS got jitter 0); tagHit=true with a non-zero frame value means the offset - // reached DLSS and any remaining shimmer is a sign/convention issue, not a plumbing one. + // Diagnostic probe (first few frames): a non-zero CARRIER offset means the present carrier + // delivered this frame's jitter to DLSS (the old texture-reference lookup returned 0). Remaining + // shimmer with a non-zero offset would then be a sign/convention issue, not a plumbing one. if (DlssJitter.Enabled && _jitterLogCount < 5) { _jitterLogCount++; Logger.Info?.Print(LogClass.Gpu, - $"DLSS jitter probe: tagHit={jitterTagHit} frame=({frameJitterX:0.000},{frameJitterY:0.000}) " + + $"DLSS jitter probe: carrier=({frameJitterX:0.000},{frameJitterY:0.000}) " + $"galNext=({DlssJitterState.OffsetX:0.000},{DlssJitterState.OffsetY:0.000}) dejitter=({_dejitterX:0.000},{_dejitterY:0.000})."); } @@ -281,13 +295,21 @@ namespace Ryujinx.Graphics.Vulkan.Dlss TextureView mvSource = DlssIntegration.MvFilterEnabled ? _motionFiltered : _motion; StreamlineDlss.DlssTexture mvTex = Describe(mvSource, cbs); + // Grid/NDC-normalization hypothesis: convert the pixel jitter to normalized device coordinates + // (span 2.0 across the texture) before handing it to DLSS, keeping the validated signs. NOTE: + // Streamline/NGX documents this offset in PIXELS, so normalizing is expected to UNDER-drive DLSS + // (offset ~0.0005); kept as the explicit test the user asked for. The de-jitter of the motion + // field stays at full pixel magnitude (directive: physical compensation intact). + float evalJitterX = (frameJitterX * 2.0f / input.Width) * JitterSignX; + float evalJitterY = (frameJitterY * 2.0f / input.Height) * JitterSignY; + bool ok = StreamlineDlss.Evaluate( (IntPtr)cbs.CommandBuffer.Handle, ViewportId, _frame++, reset, - frameJitterX, - frameJitterY, + evalJitterX, + evalJitterY, in inTex, in outTex, in depthTex, diff --git a/src/Ryujinx.Graphics.Vulkan/SamplerHolder.cs b/src/Ryujinx.Graphics.Vulkan/SamplerHolder.cs index 600f0fafc..4881a1c27 100644 --- a/src/Ryujinx.Graphics.Vulkan/SamplerHolder.cs +++ b/src/Ryujinx.Graphics.Vulkan/SamplerHolder.cs @@ -37,7 +37,10 @@ namespace Ryujinx.Graphics.Vulkan AddressModeU = info.AddressU.Convert(), AddressModeV = info.AddressV.Convert(), AddressModeW = info.AddressP.Convert(), - MipLodBias = info.MipLodBias, + // DLSS Mode B: while jitter is on, add a negative texture LOD bias (NVIDIA guidance) so the + // mip selection stays locked on a sharper level and does not flicker frame-to-frame under the + // sub-pixel jitter. No-op (bias 0) unless jitter is enabled, so the default path is unchanged. + MipLodBias = info.MipLodBias + (Dlss.DlssJitter.Enabled ? Dlss.DlssJitter.LodBias : 0f), AnisotropyEnable = info.MaxAnisotropy != 1f, MaxAnisotropy = info.MaxAnisotropy, CompareEnable = info.CompareMode == CompareMode.CompareRToTexture, From ef4c91b8da54df91089d23fcb806a742580d361d Mon Sep 17 00:00:00 2001 From: The Roofer Dev Date: Sun, 28 Jun 2026 15:06:50 -0400 Subject: [PATCH 12/20] Mode B jitter VALIDATED: clip-space gl_Position injection (the correct method) Replaces the viewport-shift jitter (which trembled - emulator viewport jitter on a non-jitter-aware guest cannot accumulate cleanly) with the native-DLSS approach: inject gl_Position.xy += jitterNDC * w into guest vertex shaders via the translator (SupportBuffer.JitterOffset + EmitterContext epilogue). Motion vectors de-jittered by (J_n - J_n-1), negative texture LOD bias to steady the mips. Confirmed in-game: sharp, stable, DLSS accumulates the sub-pixel detail. --- .../Engine/Threed/StateUpdater.cs | 26 +++++++++++-------- .../Memory/SupportBufferUpdater.cs | 17 ++++++++++++ src/Ryujinx.Graphics.Shader/SupportBuffer.cs | 7 ++++- .../Translation/EmitterContext.cs | 14 ++++++++++ .../Dlss/DlssJitter.cs | 14 +++++----- .../Dlss/DlssUpscaler.cs | 13 +++++----- 6 files changed, 65 insertions(+), 26 deletions(-) diff --git a/src/Ryujinx.Graphics.Gpu/Engine/Threed/StateUpdater.cs b/src/Ryujinx.Graphics.Gpu/Engine/Threed/StateUpdater.cs index ba7a4abbf..c1fe42f5e 100644 --- a/src/Ryujinx.Graphics.Gpu/Engine/Threed/StateUpdater.cs +++ b/src/Ryujinx.Graphics.Gpu/Engine/Threed/StateUpdater.cs @@ -739,16 +739,24 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed Span viewportTransformSpan = _state.State.ViewportTransform.AsSpan(); Span viewportExtentsSpan = _state.State.ViewportExtents.AsSpan(); - // DLSS Mode B: shift the resolution-scaled viewport by this frame's sub-pixel jitter. Only the - // scaled (main) pass; native passes (UI) keep RenderTargetScale 1 and stay untouched. The offset - // is carried to DLSS through the present queue (see DlssJitterState), not tagged on a texture. - // No-op (offset 0) unless jitter is enabled, so the default path is unchanged. - float jitterX = 0f, jitterY = 0f; + // DLSS Mode B: hand the shader this frame's sub-pixel jitter as a clip-space NDC offset, which it + // adds to gl_Position scaled by w (the correct, native-DLSS way to jitter -- not a viewport shift). + // Only the scaled (main) 3D pass; native passes (UI) keep RenderTargetScale 1 and are NOT jittered. + // The offset is 0 unless jitter is enabled, so the default path is unchanged. + float jitterNdcX = 0f, jitterNdcY = 0f; if (DlssJitterState.Enabled && _channel.TextureManager.RenderTargetScale != 1f) { - jitterX = DlssJitterState.OffsetX; - jitterY = DlssJitterState.OffsetY; + ref ViewportTransform vp0 = ref viewportTransformSpan[0]; + float jScale = _channel.TextureManager.RenderTargetScale; + float vpWidth = MathF.Abs(vp0.ScaleX) * 2f * jScale; + float vpHeight = MathF.Abs(vp0.ScaleY) * 2f * jScale; + if (vpWidth > 0f && vpHeight > 0f) + { + jitterNdcX = DlssJitterState.OffsetX * 2f / vpWidth; + jitterNdcY = DlssJitterState.OffsetY * 2f / vpHeight; + } } + _context.SupportBufferUpdater.SetJitter(jitterNdcX, jitterNdcY); for (int index = 0; index < Constants.TotalViewports; index++) { @@ -792,10 +800,6 @@ namespace Ryujinx.Graphics.Gpu.Engine.Threed y *= scale; width *= scale; height *= scale; - - // Sub-pixel jitter is in scaled-render pixels, same space as x/y here (0 unless Mode B). - x += jitterX; - y += jitterY; } Rectangle region = new(x, y, width, height); diff --git a/src/Ryujinx.Graphics.Gpu/Memory/SupportBufferUpdater.cs b/src/Ryujinx.Graphics.Gpu/Memory/SupportBufferUpdater.cs index da19bd064..b0f2fe84b 100644 --- a/src/Ryujinx.Graphics.Gpu/Memory/SupportBufferUpdater.cs +++ b/src/Ryujinx.Graphics.Gpu/Memory/SupportBufferUpdater.cs @@ -88,6 +88,23 @@ namespace Ryujinx.Graphics.Gpu.Memory MarkDirty(SupportBuffer.ViewportSizeOffset, SupportBuffer.FieldSize); } + /// + /// Sets the DLSS Mode B clip-space jitter offset (in NDC) the shader adds to vertex positions + /// (scaled by w). Zero unless jitter is enabled, so the default path is unaffected. + /// + /// Jitter X in normalized device coordinates + /// Jitter Y in normalized device coordinates + public void SetJitter(float x, float y) + { + if (_data.JitterOffset.X != x || _data.JitterOffset.Y != y) + { + _data.JitterOffset.X = x; + _data.JitterOffset.Y = y; + + MarkDirty(SupportBuffer.JitterOffsetOffset, SupportBuffer.FieldSize); + } + } + /// /// Sets the scale of all output render targets (they should all have the same scale). /// diff --git a/src/Ryujinx.Graphics.Shader/SupportBuffer.cs b/src/Ryujinx.Graphics.Shader/SupportBuffer.cs index fb624d624..a3fcb020c 100644 --- a/src/Ryujinx.Graphics.Shader/SupportBuffer.cs +++ b/src/Ryujinx.Graphics.Shader/SupportBuffer.cs @@ -24,6 +24,7 @@ namespace Ryujinx.Graphics.Shader RenderScale, TfeOffset, TfeVertexCount, + JitterOffset, } public struct SupportBuffer @@ -42,6 +43,7 @@ namespace Ryujinx.Graphics.Shader public static readonly int ComputeRenderScaleOffset; public static readonly int TfeOffsetOffset; public static readonly int TfeVertexCountOffset; + public static readonly int JitterOffsetOffset; public const int FragmentIsBgraCount = 8; // One for the render target, 64 for the textures, and 8 for the images. @@ -68,6 +70,7 @@ namespace Ryujinx.Graphics.Shader ComputeRenderScaleOffset = GraphicsRenderScaleOffset + FieldSize; TfeOffsetOffset = OffsetOf(ref instance, ref instance.TfeOffset); TfeVertexCountOffset = OffsetOf(ref instance, ref instance.TfeVertexCount); + JitterOffsetOffset = OffsetOf(ref instance, ref instance.JitterOffset); } internal static StructureType GetStructureType() @@ -80,7 +83,8 @@ namespace Ryujinx.Graphics.Shader new StructureField(AggregateType.S32, "frag_scale_count"), new StructureField(AggregateType.Array | AggregateType.FP32, "render_scale", RenderScaleMaxCount), new StructureField(AggregateType.Vector4 | AggregateType.S32, "tfe_offset"), - new StructureField(AggregateType.S32, "tfe_vertex_count") + new StructureField(AggregateType.S32, "tfe_vertex_count"), + new StructureField(AggregateType.Vector4 | AggregateType.FP32, "jitter_offset") ]); } @@ -95,5 +99,6 @@ namespace Ryujinx.Graphics.Shader public Vector4 TfeOffset; public Vector4 TfeVertexCount; + public Vector4 JitterOffset; } } diff --git a/src/Ryujinx.Graphics.Shader/Translation/EmitterContext.cs b/src/Ryujinx.Graphics.Shader/Translation/EmitterContext.cs index 62dd9e2e7..8e5e543bd 100644 --- a/src/Ryujinx.Graphics.Shader/Translation/EmitterContext.cs +++ b/src/Ryujinx.Graphics.Shader/Translation/EmitterContext.cs @@ -292,6 +292,20 @@ namespace Ryujinx.Graphics.Shader.Translation } } + // DLSS Mode B clip-space jitter: offset the vertex position by the sub-pixel jitter (in NDC), + // scaled by w so it stays a constant pixel shift after the perspective divide -- the correct, + // native-DLSS way to jitter, unlike a viewport shift. The offset is 0 unless jitter is enabled, + // so the default path is byte-identical. + { + Operand jpx = this.Load(StorageKind.Output, IoVariable.Position, null, Const(0)); + Operand jpy = this.Load(StorageKind.Output, IoVariable.Position, null, Const(1)); + Operand jpw = this.Load(StorageKind.Output, IoVariable.Position, null, Const(3)); + Operand jox = this.Load(StorageKind.ConstantBuffer, SupportBuffer.Binding, Const((int)SupportBufferField.JitterOffset), Const(0)); + Operand joy = this.Load(StorageKind.ConstantBuffer, SupportBuffer.Binding, Const((int)SupportBufferField.JitterOffset), Const(1)); + this.Store(StorageKind.Output, IoVariable.Position, null, Const(0), this.FPFusedMultiplyAdd(jox, jpw, jpx)); + this.Store(StorageKind.Output, IoVariable.Position, null, Const(1), this.FPFusedMultiplyAdd(joy, jpw, jpy)); + } + if (TranslatorContext.Definitions.ViewportTransformDisable) { Operand x = this.Load(StorageKind.Output, IoVariable.Position, null, Const(0)); diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssJitter.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssJitter.cs index 84981f26e..f4b8f77aa 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssJitter.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssJitter.cs @@ -20,10 +20,10 @@ namespace Ryujinx.Graphics.Vulkan.Dlss public static bool Enabled => _flag && DlssIntegration.IsEnabled; - // Magnitude of the jitter offset handed to DLSS, as a multiplier on our scaled-render-pixel value, - // to match NGX's expected unit. Live-tunable via RYUJINX_DLSS_JITTER_SCALE so the value can be swept - // without recompiling; defaults to 0.25. Read once at startup. NOTE: this scales ONLY the offset - // sent to Evaluate -- the motion-field de-jitter stays at full physical magnitude. + // Jitter amplitude in render pixels: the Halton offset (+-0.5) is multiplied by this before it is + // published. 1.0 = a real +-0.5px sub-pixel jitter (what DLSS wants). Live-tunable via + // RYUJINX_DLSS_JITTER_SCALE so a large value (e.g. 10 = +-5px) can be used as a VISIBLE "reticle" + // test to confirm the clip-space gl_Position injection is actually moving the image. Read once. private static readonly float _scale = ParseScale(); public static float Scale => _scale; @@ -39,7 +39,7 @@ namespace Ryujinx.Graphics.Vulkan.Dlss return scale; } - return 0.5f; // calibrated magic value that locks the image (was swept live from 0.25) + return 1.0f; // real +-0.5px sub-pixel jitter by default } // Texture mip LOD bias applied to every guest sampler while jitter is on (NVIDIA's DLSS guidance: a @@ -83,8 +83,8 @@ namespace Ryujinx.Graphics.Vulkan.Dlss _index = _index % SequenceLength + 1; // 1..N (Halton is undefined at 0) DlssJitterState.Enabled = true; - DlssJitterState.OffsetX = Halton(_index, 2) - 0.5f; - DlssJitterState.OffsetY = Halton(_index, 3) - 0.5f; + DlssJitterState.OffsetX = (Halton(_index, 2) - 0.5f) * _scale; + DlssJitterState.OffsetY = (Halton(_index, 3) - 0.5f) * _scale; } private static float Halton(uint index, uint radix) diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs index c8a7ab554..24fe01eac 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs @@ -295,13 +295,12 @@ namespace Ryujinx.Graphics.Vulkan.Dlss TextureView mvSource = DlssIntegration.MvFilterEnabled ? _motionFiltered : _motion; StreamlineDlss.DlssTexture mvTex = Describe(mvSource, cbs); - // Grid/NDC-normalization hypothesis: convert the pixel jitter to normalized device coordinates - // (span 2.0 across the texture) before handing it to DLSS, keeping the validated signs. NOTE: - // Streamline/NGX documents this offset in PIXELS, so normalizing is expected to UNDER-drive DLSS - // (offset ~0.0005); kept as the explicit test the user asked for. The de-jitter of the motion - // field stays at full pixel magnitude (directive: physical compensation intact). - float evalJitterX = (frameJitterX * 2.0f / input.Width) * JitterSignX; - float evalJitterY = (frameJitterY * 2.0f / input.Height) * JitterSignY; + // DLSS jitter offset in PIXELS (Streamline/NGX convention): the value the frame was actually + // rendered with, carried through the present queue (frameJitterX/Y), with the validated signs. + // The clip-space injection in the vertex shader shifts the image by exactly this many pixels, so + // DLSS is told the matching pixel offset. The de-jitter stays at full pixel magnitude too. + float evalJitterX = frameJitterX * JitterSignX; + float evalJitterY = frameJitterY * JitterSignY; bool ok = StreamlineDlss.Evaluate( (IntPtr)cbs.CommandBuffer.Handle, From 6b6464bfd7ac2ee13ecb5a543a7cd46ce5222dab Mon Sep 17 00:00:00 2001 From: The Roofer Dev Date: Sun, 28 Jun 2026 18:55:12 -0400 Subject: [PATCH 13/20] Option A: cold restart enforces one slInit per process (fixes DLSS relaunch crash) NGX only supports a single slInit per process. Stopping a game and starting another re-initialized NGX in-process and fail-fasted natively (0xc0000005 / 0xc0000409, not catchable from managed). The OTA-flags hypothesis was disproven: the DEVELOPMENT Streamline build already ignores OTA. Fix (validated in game, stable): one slInit per process via cold restart. - Streamline: add WasEverInitialized (never reset by Shutdown); drop OTA flags. - New DlssRestart.RestartCold(applyUiMode): overlap-safe cold relaunch to the game list. AppExit fires after DisposeGpu/slShutdown, so NGX is already released when we relaunch. - Trigger 1: AppHost_AppExit cold-restarts when DLSS ran this session. - Trigger 2: DLSS mode change in Settings cold-restarts and re-derives the mode. --- .../Dlss/DlssUpscaler.cs | 94 +++++++++++++++--- .../Dlss/Streamline.cs | 40 ++++++-- .../Dlss/StreamlineDlss.cs | 12 +++ src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs | 6 ++ src/Ryujinx/Program.cs | 4 + src/Ryujinx/Systems/DlssRestart.cs | 93 ++++++++++++++++++ src/Ryujinx/Systems/DlssUiSettings.cs | 97 +++++++++++++++++++ .../UI/ViewModels/MainWindowViewModel.cs | 12 +++ .../UI/ViewModels/SettingsViewModel.cs | 70 ++++++++++++- .../Views/Settings/SettingsGraphicsView.axaml | 31 ++++++ .../UI/Windows/SettingsWindow.axaml.cs | 39 ++++++++ 11 files changed, 478 insertions(+), 20 deletions(-) create mode 100644 src/Ryujinx/Systems/DlssRestart.cs create mode 100644 src/Ryujinx/Systems/DlssUiSettings.cs diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs index 24fe01eac..ec621b909 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs @@ -27,6 +27,7 @@ namespace Ryujinx.Graphics.Vulkan.Dlss private const float SceneChangeHighFraction = 0.85f; // arm a reset only on a near-total change (real scene cut), not fast camera motion private const float SceneChangeLowFraction = 0.55f; // ... and disarm (hysteresis) once it drops back below this private const int ResetCooldownFrames = 45; // min frames between resets (~0.75s), so fast-pan blips don't reset repeatedly + private const float SceneCutMaxMotion = 0.02f; // a history reset only fires when motion is below this (real cuts have ~0 motion; gameplay has more) private const int CounterCount = 9; // uints in the motion-pass SSBO (scene-change counters; the trailing stat slots are now always zero) private readonly VulkanRenderer _gd; @@ -53,7 +54,12 @@ namespace Ryujinx.Graphics.Vulkan.Dlss private float _dejitterX; private float _dejitterY; private int _jitterLogCount; - private bool? _depthWasReal; // B1a: whether the real captured depth is in use, to log only on change + private int _depthState = -1; // B1a depth source, for logging only on change: 2=real, 1=held (last good), 0=dummy + + // Hold the last VALID real depth so a stray shadow-map capture (wrong dims, e.g. 2048x2048) reuses it + // instead of collapsing to the zeroed dummy -- that flip (real<->zero depth) was a flicker source. + private TextureView _heldDepth; + private bool _hasHeldDepth; private readonly PipelineHelperShader _motionPipeline; private readonly ShaderCollection _motionProgram; @@ -240,6 +246,7 @@ namespace Ryujinx.Graphics.Vulkan.Dlss ReadCounters(out uint unexplained, out uint motion); int total = input.Width * input.Height; float changedFraction = total != 0 ? (float)unexplained / total : 0f; + float motionFraction = total != 0 ? (float)motion / total : 0f; // Reset DLSS history only on the rising edge of a *full-screen* change, with hysteresis + a // cooldown. A localized UI change over a paused scene (an inventory tab, ambient animation while @@ -249,8 +256,15 @@ namespace Ryujinx.Graphics.Vulkan.Dlss ? changedFraction >= SceneChangeLowFraction : changedFraction >= SceneChangeHighFraction; + // A real scene cut (loading->game, area swap) is a near-total change with almost NO coherent + // motion; fast gameplay ALSO produces a high "unexplained change" but with real motion. Requiring + // low motion filters out the spurious mid-gameplay resets that were flickering the image (~one + // every 6s, measured: changed~85% but motion 3-4.5%), while still catching genuine cuts (motion + // near 0). This is the dominant flicker fix. _framesSinceReset++; - bool sceneCut = _hasPrev && changing && !_wasChanging && _framesSinceReset >= ResetCooldownFrames; + bool sceneCut = _hasPrev && changing && !_wasChanging + && motionFraction < SceneCutMaxMotion + && _framesSinceReset >= ResetCooldownFrames; _wasChanging = changing; bool reset = !_hasPrev || sceneCut; @@ -278,20 +292,43 @@ namespace Ryujinx.Graphics.Vulkan.Dlss StreamlineDlss.DlssTexture inTex = Describe(input, cbs); StreamlineDlss.DlssTexture outTex = Describe(_output, cbs); - // B1a real depth: use the captured guest depth buffer only when it matches the input (render) - // resolution. A size mismatch means it is a UI/secondary/stale depth, so fall back to the - // zeroed dummy -- never feed DLSS a mismatched depth that could fail the evaluate and leak - // toward a device loss. State changes are logged so we can see how often the guard trips. + + // B1a real depth with a HOLD. The captured guest depth is valid only when it matches the input + // (render) resolution; a size mismatch means it is a secondary target (often a square shadow map + // like 2048x2048). Instead of collapsing to the zeroed dummy on those frames -- which made DLSS + // flip real<->zero depth and flicker -- copy each good real depth into a held texture and reuse + // the LAST good one when the current capture is invalid. The dummy is used only until the first + // real depth ever arrives. bool depthMatches = depth != null && depth.Width == input.Width && depth.Height == input.Height; - if (_depthWasReal != depthMatches) + if (depthMatches) { - _depthWasReal = depthMatches; - Logger.Info?.Print(LogClass.Gpu, - $"DLSS depth -> {(depthMatches ? "REAL" : "dummy")} (input {input.Width}x{input.Height}, " + - $"depth {(depth != null ? $"{depth.Width}x{depth.Height}" : "null")})."); + if (_heldDepth == null || + _heldDepth.Width != input.Width || + _heldDepth.Height != input.Height || + _heldDepth.Info.Format != depth.Info.Format) + { + _heldDepth?.Dispose(); + _heldDepth = _gd.CreateTexture(MakeInfo(depth.Info, input.Width, input.Height, depth.Info.Format, depth.Info.BytesPerPixel)) as TextureView; + } + + CopyDepth(depth, _heldDepth, cbs); + _hasHeldDepth = true; } - StreamlineDlss.DlssTexture depthTex = Describe(depthMatches ? depth : _depth, cbs); + // Effective depth: live real depth when valid; otherwise the last good held depth; the zeroed + // dummy only until a real depth has ever been seen. + TextureView depthSource = depthMatches ? depth : (_hasHeldDepth ? _heldDepth : _depth); + + int depthState = depthMatches ? 2 : (_hasHeldDepth ? 1 : 0); + if (_depthState != depthState) + { + _depthState = depthState; + Logger.Info?.Print(LogClass.Gpu, + $"DLSS depth -> {(depthState == 2 ? "REAL" : depthState == 1 ? "HELD (last good)" : "dummy")} " + + $"(input {input.Width}x{input.Height}, capture {(depth != null ? $"{depth.Width}x{depth.Height}" : "null")})."); + } + + StreamlineDlss.DlssTexture depthTex = Describe(depthSource, cbs); TextureView mvSource = DlssIntegration.MvFilterEnabled ? _motionFiltered : _motion; StreamlineDlss.DlssTexture mvTex = Describe(mvSource, cbs); @@ -433,6 +470,35 @@ namespace Ryujinx.Graphics.Vulkan.Dlss in region); } + // Copies the current valid real depth into the held texture (same dims/format), so a later frame + // whose capture is a stray shadow map can reuse this last good depth instead of the zeroed dummy. + private void CopyDepth(TextureView src, TextureView dst, CommandBufferScoped cbs) + { + ImageSubresourceLayers layers = new() + { + AspectMask = ImageAspectFlags.DepthBit, + MipLevel = 0, + BaseArrayLayer = 0, + LayerCount = 1, + }; + + ImageCopy region = new() + { + SrcSubresource = layers, + DstSubresource = layers, + Extent = new Extent3D((uint)src.Width, (uint)src.Height, 1), + }; + + _gd.Api.CmdCopyImage( + cbs.CommandBuffer, + src.GetImage().Get(cbs).Value, + ImageLayout.General, + dst.GetImage().Get(cbs).Value, + ImageLayout.General, + 1, + in region); + } + private StreamlineDlss.DlssTexture Describe(TextureView tex, CommandBufferScoped cbs) { return new StreamlineDlss.DlssTexture @@ -476,6 +542,9 @@ namespace Ryujinx.Graphics.Vulkan.Dlss _motion?.Dispose(); _motionFiltered?.Dispose(); _prevColor?.Dispose(); // was leaked on every reallocation (dynamic-resolution games churn this) + _heldDepth?.Dispose(); + _heldDepth = null; + _hasHeldDepth = false; // the held depth is stale at a new resolution; recapture before reuse _inW = input.Width; _inH = input.Height; @@ -551,6 +620,7 @@ namespace Ryujinx.Graphics.Vulkan.Dlss _motion?.Dispose(); _motionFiltered?.Dispose(); _prevColor?.Dispose(); + _heldDepth?.Dispose(); _motionProgram?.Dispose(); _motionFilterProgram?.Dispose(); _motionPipeline?.Dispose(); diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/Streamline.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/Streamline.cs index 0567d7329..672da57c2 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/Streamline.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/Streamline.cs @@ -187,11 +187,20 @@ namespace Ryujinx.Graphics.Vulkan.Dlss private static IntPtr _interposerHandle; private static IntPtr _logPathPtr; + private static IntPtr _featuresPtr; private static bool _initialized; + private static bool _everInitialized; /// True once slInit has succeeded and the device has been registered. public static bool IsInitialized => _initialized; + /// + /// True once slInit has succeeded at least once in this process. Unlike + /// this is NEVER reset by . NGX only supports a single slInit per process, so + /// the UI uses this to force a cold process restart instead of re-initializing in-process. + /// + public static bool WasEverInitialized => _everInitialized; + /// /// Loads sl.interposer.dll from and calls slInit, /// requesting the DLSS plugin. Returns true only if slInit succeeds. Logs the outcome. @@ -237,13 +246,18 @@ namespace Ryujinx.Graphics.Vulkan.Dlss pref.PathToLogsAndData = _logPathPtr; pref.Engine = EngineTypeCustom; pref.RenderApi = RenderApiVulkan; - // Keep SL's default flags (eDisableCLStateTracking | eAllowOTA | eLoadDownloadedPlugins). - pref.Flags = (1UL << 0) | (1UL << 3) | (1UL << 6); + // Only eDisableCLStateTracking. eAllowOTA (1<<3) and eLoadDownloadedPlugins (1<<6) make + // slInit do background plugin download/load, which crashed intermittently in slInit on the + // next mode-switch cycle (native 0xc0000005, not catchable from managed). Dropped on purpose. + pref.Flags = (1UL << 0); - // featuresToLoad is required, otherwise no plugins are loaded. Address of a local is - // valid for the duration of this synchronous call (SL keeps its own copy). - uint feature = FeatureDLSS; - pref.FeaturesToLoad = (IntPtr)(&feature); + // featuresToLoad is required, otherwise no plugins are loaded. Use a STABLE unmanaged buffer + // rather than the address of a stack local: if Streamline stores this pointer to load the DLSS + // plugin lazily (instead of copying it during slInit), a stack address would dangle after this + // method returns and crash intermittently. The buffer is freed in Shutdown. + _featuresPtr = Marshal.AllocHGlobal(sizeof(uint)); + Marshal.WriteInt32(_featuresPtr, (int)FeatureDLSS); + pref.FeaturesToLoad = _featuresPtr; pref.NumFeaturesToLoad = 1; Result result; @@ -266,6 +280,7 @@ namespace Ryujinx.Graphics.Vulkan.Dlss } _initialized = true; + _everInitialized = true; Logger.Info?.Print(LogClass.Gpu, "DLSS: Streamline initialized."); return true; @@ -351,6 +366,19 @@ namespace Ryujinx.Graphics.Vulkan.Dlss _initialized = false; } + // Free the unmanaged buffers slInit was given, now that Streamline has been shut down. + if (_logPathPtr != IntPtr.Zero) + { + Marshal.FreeHGlobal(_logPathPtr); + _logPathPtr = IntPtr.Zero; + } + + if (_featuresPtr != IntPtr.Zero) + { + Marshal.FreeHGlobal(_featuresPtr); + _featuresPtr = IntPtr.Zero; + } + if (_interposerHandle != IntPtr.Zero) { NativeLibrary.Free(_interposerHandle); diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/StreamlineDlss.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/StreamlineDlss.cs index 93e37f1b3..942b3281c 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/StreamlineDlss.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/StreamlineDlss.cs @@ -291,6 +291,18 @@ namespace Ryujinx.Graphics.Vulkan.Dlss opt.ColorBuffersHDR = hdr ? BoolTrue : BoolFalse; opt.UseAutoExposure = BoolTrue; + // Locked preset matrix (validated presets, no manual override): DLAA (1:1 ratio) gets Preset K + // (transformer, maximum sharpness); the upscaling modes get Preset F (CNN, ultra-stability / + // anti-flicker). Every per-mode slot is set, so whichever mode is active uses its mapped preset. + const uint PresetK = 11; // transformer sharpness, for DLAA + const uint PresetF = 6; // CNN ultra-stability, for the upscaling modes + opt.DlaaPreset = PresetK; + opt.QualityPreset = PresetF; + opt.BalancedPreset = PresetF; + opt.PerformancePreset = PresetF; + opt.UltraPerformancePreset = PresetF; + opt.UltraQualityPreset = PresetF; + ViewportHandle vp = MakeViewport(viewportId); int r = _slDLSSSetOptions(in vp, in opt); if (r != 0) diff --git a/src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs b/src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs index 3336ea071..2b1431828 100644 --- a/src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs +++ b/src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs @@ -1107,6 +1107,12 @@ namespace Ryujinx.Graphics.Vulkan BackgroundResources.Dispose(); _counters.Dispose(); _window.Dispose(); + + // Release Streamline/NGX cleanly while the device is still alive. Without this slShutdown the NGX + // state was never torn down, and the NEXT launch crashed in VulkanInitialization.CreateDevice + // (access violation) trying to re-create a DLSS/optical-flow device over the leftover state. + Dlss.Streamline.Shutdown(); + HelperShader.Dispose(); _pipeline.Dispose(); BufferManager.Dispose(); diff --git a/src/Ryujinx/Program.cs b/src/Ryujinx/Program.cs index 7fc3ebca9..e241642bd 100644 --- a/src/Ryujinx/Program.cs +++ b/src/Ryujinx/Program.cs @@ -186,6 +186,10 @@ namespace Ryujinx.Ava Initialize(args); + // DLSS: translate the graphics-settings UI choice into the RYUJINX_DLSS_* env vars the backend + // reads, before any game/renderer starts (no-op if a launcher .bat already set them). + DlssUiSettings.ApplyAtStartup(); + LoggerAdapter.Register(); IconProvider.Current diff --git a/src/Ryujinx/Systems/DlssRestart.cs b/src/Ryujinx/Systems/DlssRestart.cs new file mode 100644 index 000000000..3dec9c797 --- /dev/null +++ b/src/Ryujinx/Systems/DlssRestart.cs @@ -0,0 +1,93 @@ +using Ryujinx.Ava.Utilities; +using Ryujinx.Common.Logging; +using System; +using System.Diagnostics; + +namespace Ryujinx.Ava.Systems +{ + /// + /// NVIDIA NGX (the DLSS runtime) only supports a SINGLE slInit per process: re-initializing it in the + /// same process -- which happens whenever a second game is loaded -- fail-fasts natively (0xc0000409 / + /// 0xc0000005, not catchable from managed). The clean, hardware-respecting answer is "one slInit per + /// process": whenever DLSS has been active and we would otherwise re-init it (stopping a game, or the + /// user changing the DLSS mode), we cold-restart the executable instead so every game always gets a + /// fresh process. + /// + /// Overlap safety: callers must invoke this only after the GPU/renderer has already been disposed (so + /// the old process has already run slShutdown and released the NGX/GPU state). A relaunch that overlaps + /// a still-live NGX state is exactly what crashed the earlier naive self-relaunch. + /// + internal static class DlssRestart + { + // The environment variables DlssUiSettings.ApplyAtStartup sets. When the user changes the mode in the + // UI we drop these from the child so it re-derives the new mode from dlss_mode.cfg at startup. + private static readonly string[] _dlssEnvVars = + { + "RYUJINX_DLSS", + "RYUJINX_DLSS_MODE", + "RYUJINX_DLSS_JITTER", + "RYUJINX_DLSS_JITTER_SCALE", + "RYUJINX_DLSS_JITTER_LOD_BIAS", + }; + + /// + /// Cold-restarts the application (no game loaded, back to the game list) and exits the current + /// process. Returns only if the relaunch could not be started (the caller then stays open). + /// + /// + /// When true, the inherited DLSS environment is cleared so the new process picks up the mode the user + /// just saved in the UI (via DlssUiSettings.ApplyAtStartup). When false, the environment is inherited + /// unchanged so the new process keeps the exact same DLSS state (used when stopping a game). + /// + public static void RestartCold(bool applyUiMode) + { + string exe = Environment.ProcessPath; + + if (string.IsNullOrEmpty(exe)) + { + Logger.Warning?.Print(LogClass.Application, + "DLSS restart: could not resolve the executable path; restart skipped."); + + return; + } + + ProcessStartInfo startInfo = new(exe) + { + // UseShellExecute must be false so we can edit the child's environment block. + UseShellExecute = false, + WorkingDirectory = AppDomain.CurrentDomain.BaseDirectory, + }; + + // Preserve launcher arguments (data dir / profile) but deliberately add NO game path: a cold + // start lands on the game list, matching "reopen from the shortcut". + foreach (string arg in CommandLineState.Arguments) + { + startInfo.ArgumentList.Add(arg); + } + + if (applyUiMode) + { + foreach (string name in _dlssEnvVars) + { + startInfo.Environment.Remove(name); + } + } + + try + { + Process.Start(startInfo); + } + catch (Exception ex) + { + Logger.Error?.Print(LogClass.Application, + $"DLSS restart: failed to relaunch ({ex.Message}); staying open."); + + return; + } + + Logger.Info?.Print(LogClass.Application, "DLSS restart: relaunching cold (one slInit per process)."); + + Environment.Exit(0); + } + } +} diff --git a/src/Ryujinx/Systems/DlssUiSettings.cs b/src/Ryujinx/Systems/DlssUiSettings.cs new file mode 100644 index 000000000..7d9328853 --- /dev/null +++ b/src/Ryujinx/Systems/DlssUiSettings.cs @@ -0,0 +1,97 @@ +using Ryujinx.Common.Configuration; +using Ryujinx.Common.Logging; +using System; +using System.Globalization; +using System.IO; + +namespace Ryujinx.Ava.Systems +{ + /// + /// Persists the user's NVIDIA DLSS / DLAA choice from the graphics settings UI in a small standalone + /// file -- deliberately NOT in the main Ryujinx config, so its version is never touched. At startup it + /// translates the saved choice into the RYUJINX_DLSS_* environment variables the (env-var-driven) DLSS + /// backend reads, UNLESS those are already set externally (a launcher .bat / dev override wins). + /// + /// The DLSS render preset is no longer a user choice: it is auto-mapped per mode in the engine + /// (StreamlineDlss.SetOptions -- DLAA gets Preset K, the upscaling modes get Preset F). + /// + /// File format: a "mode [preset]" line; only the mode (first token) is read now. Mode index: 0 = Off, + /// 1 = DLAA, 2 = Quality, 3 = Balanced, 4 = Performance. A change applies on the next application launch. + /// + public static class DlssUiSettings + { + // Mode index -> RYUJINX_DLSS_MODE string accepted by DlssIntegration.ParseQualityMode (0 = Off). + private static readonly string[] _modeEnv = { "", "dlaa", "quality", "balanced", "performance" }; + + private static string FilePath => Path.Combine(AppDataManager.BaseDirPath, "dlss_mode.cfg"); + + /// Reads the saved DLSS quality-mode index (0 = Off if absent or invalid). + public static int Load() + { + try + { + if (File.Exists(FilePath)) + { + string[] parts = File.ReadAllText(FilePath).Trim() + .Split(new[] { ' ', ',', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + + if (parts.Length > 0 && + int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int mode) && + mode >= 0 && mode < _modeEnv.Length) + { + return mode; + } + } + } + catch (Exception ex) + { + Logger.Warning?.Print(LogClass.Application, $"Failed to read DLSS UI setting: {ex.Message}"); + } + + return 0; + } + + /// Writes the chosen mode index. Takes effect on the next application launch. + public static void Save(int mode) + { + try + { + File.WriteAllText(FilePath, mode.ToString(CultureInfo.InvariantCulture)); + } + catch (Exception ex) + { + Logger.Warning?.Print(LogClass.Application, $"Failed to write DLSS UI setting: {ex.Message}"); + } + } + + /// + /// Applies the saved DLSS mode as environment variables so the DLSS backend picks it up when a game + /// launches. Skipped entirely if RYUJINX_DLSS is already set (a launcher .bat / dev override wins). + /// Off leaves DLSS disabled. Sets the validated profile: clip-space jitter 1.0 + LOD -0.5. The render + /// preset is chosen by the engine per mode, not here. + /// + public static void ApplyAtStartup() + { + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("RYUJINX_DLSS"))) + { + return; + } + + int mode = Load(); + + if (mode <= 0 || mode >= _modeEnv.Length) + { + return; + } + + Environment.SetEnvironmentVariable("RYUJINX_DLSS", "1"); + Environment.SetEnvironmentVariable("RYUJINX_DLSS_MODE", _modeEnv[mode]); + Environment.SetEnvironmentVariable("RYUJINX_DLSS_JITTER", "1"); + Environment.SetEnvironmentVariable("RYUJINX_DLSS_JITTER_SCALE", "1.0"); + Environment.SetEnvironmentVariable("RYUJINX_DLSS_JITTER_LOD_BIAS", "-0.5"); + + Logger.Info?.Print(LogClass.Application, + $"DLSS UI profile applied: mode={_modeEnv[mode]} (clip-space jitter 1.0, LOD -0.5; preset auto-mapped per mode)."); + } + } +} diff --git a/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs b/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs index c778ec71c..e6e0711a6 100644 --- a/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs @@ -1958,6 +1958,18 @@ namespace Ryujinx.Ava.UI.ViewModels IsGameRunning = false; + // NGX (DLSS) only supports a single slInit per process. If DLSS ran this session, returning to the + // game list and loading another game would re-initialize NGX in-process and fail-fast. The GPU and + // renderer are already disposed here (DisposeContext ran slShutdown before firing AppExit), so a + // cold restart is overlap-safe and guarantees the next game gets a fresh process. Skipped during a + // full app close (IsClosing already handled above) so quitting Ryujinx does not relaunch it. + if (Ryujinx.Graphics.Vulkan.Dlss.Streamline.WasEverInitialized) + { + DlssRestart.RestartCold(applyUiMode: false); + + return; + } + Dispatcher.UIThread.InvokeAsync(async () => { ShowMenuAndStatusBar = true; diff --git a/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs b/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs index da122ee83..4fe6583a1 100644 --- a/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs @@ -485,6 +485,9 @@ namespace Ryujinx.Ava.UI.ViewModels } } + // Index of the "DLSS" entry in the Scaling Filter dropdown (after Bilinear/Nearest/Fsr/Area/NIS). + private const int DlssScalingIndex = 5; + public int ScalingFilter { get => _scalingFilter; @@ -493,9 +496,54 @@ namespace Ryujinx.Ava.UI.ViewModels _scalingFilter = value; OnPropertyChanged(); OnPropertyChanged(nameof(IsScalingFilterActive)); + OnPropertyChanged(nameof(IsDlssSelected)); + OnPropertyChanged(nameof(IsDlssMenuEnabled)); + + // Couple the two menus: choosing DLSS in the filter list enables the quality sub-menu + // (defaulting to DLAA); any other filter forces the sub-menu back to Off. + if (value == DlssScalingIndex) + { + if (DlssMode == 0) + { + DlssMode = 1; // DLAA + } + } + else + { + DlssMode = 0; // Off + } } } + /// True when "DLSS" is the selected scaling filter. + public bool IsDlssSelected => _scalingFilter == DlssScalingIndex; + + /// The DLSS quality sub-menu is active only when DLSS is selected and no game is running. + public bool IsDlssMenuEnabled => IsDlssSelected && !IsGameRunning; + + // NVIDIA DLSS / DLAA mode (0 = Off, 1 = DLAA, 2 = Quality, 3 = Balanced, 4 = Performance). Stored + // outside the main config (see DlssUiSettings); applied as env vars on the next app launch. + private int _dlssMode; + public int DlssMode + { + get => _dlssMode; + set + { + _dlssMode = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(DlssPresetDisplayIndex)); + } + } + + // Read-only indicator of the engine's auto-mapped render preset: DLAA -> Preset K (index 0), the + // upscaling modes (Quality/Balanced/Performance) -> Preset F (index 1). The user cannot change it; + // it just tracks the selected mode. + public int DlssPresetDisplayIndex => DlssMode == 1 ? 0 : 1; + + // Set true by SaveSettings when the DLSS mode actually changed, so the settings window can offer a + // one-click self-restart (the DLSS env vars are only read at app startup). + public bool DlssModeRestartPending { get; set; } + public int PreferredGpuIndex { get; set; } public float Volume @@ -873,8 +921,10 @@ namespace Ryujinx.Ava.UI.ViewModels GraphicsBackendMultithreadingIndex = (int)config.Graphics.BackendThreading.Value; ShaderDumpPath = config.Graphics.ShadersDumpPath; AntiAliasingEffect = (int)config.Graphics.AntiAliasing.Value; - ScalingFilter = (int)config.Graphics.ScalingFilter.Value; + int dlssSaved = Ryujinx.Ava.Systems.DlssUiSettings.Load(); + ScalingFilter = dlssSaved > 0 ? DlssScalingIndex : (int)config.Graphics.ScalingFilter.Value; ScalingFilterLevel = config.Graphics.ScalingFilterLevel.Value; + DlssMode = dlssSaved; // Audio AudioBackend = (int)config.System.AudioBackend.Value; @@ -1008,8 +1058,24 @@ namespace Ryujinx.Ava.UI.ViewModels config.Graphics.MaxAnisotropy.Value = MaxAnisotropy == 0 ? -1 : MathF.Pow(2, MaxAnisotropy); config.Graphics.AspectRatio.Value = (AspectRatio)AspectRatio; config.Graphics.AntiAliasing.Value = (AntiAliasing)AntiAliasingEffect; - config.Graphics.ScalingFilter.Value = (ScalingFilter)ScalingFilter; + // "DLSS" in the filter list is not a real spatial scaling filter (it is a separate pipeline); + // when it is selected, store a neutral spatial filter (DLSS bypasses it) and persist the chosen + // DLSS quality mode separately. Otherwise DLSS is Off and the real filter is stored. + int dlssMode; + if (ScalingFilter == DlssScalingIndex) + { + config.Graphics.ScalingFilter.Value = Ryujinx.Common.Configuration.ScalingFilter.Bilinear; + dlssMode = DlssMode == 0 ? 1 : DlssMode; // at least DLAA + } + else + { + config.Graphics.ScalingFilter.Value = (Ryujinx.Common.Configuration.ScalingFilter)ScalingFilter; + dlssMode = 0; + } + config.Graphics.ScalingFilterLevel.Value = ScalingFilterLevel; + DlssModeRestartPending = dlssMode != Ryujinx.Ava.Systems.DlssUiSettings.Load(); + Ryujinx.Ava.Systems.DlssUiSettings.Save(dlssMode); if (ConfigurationState.Instance.Graphics.BackendThreading != (BackendThreading)GraphicsBackendMultithreadingIndex) { diff --git a/src/Ryujinx/UI/Views/Settings/SettingsGraphicsView.axaml b/src/Ryujinx/UI/Views/Settings/SettingsGraphicsView.axaml index fa60ea49f..daf7a08dd 100644 --- a/src/Ryujinx/UI/Views/Settings/SettingsGraphicsView.axaml +++ b/src/Ryujinx/UI/Views/Settings/SettingsGraphicsView.axaml @@ -316,6 +316,8 @@ Content="{ext:Locale GraphicsScalingFilterArea}" /> + + + + + + + + + + + + + + + + + + a cold restart. + // This is overlap-safe: the mode ComboBox is locked while a game runs, and stopping a DLSS game already + // cold-restarts the app (AppHost_AppExit), so we only ever reach here with NGX in a clean state (no live + // slInit to collide with). RestartCold(applyUiMode: true) drops the inherited DLSS env so the new + // process picks up the mode just saved in dlss_mode.cfg. + private static void PromptDlssRestart() + { + _ = Dispatcher.UIThread.InvokeAsync(async () => + { + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime) + { + UserResult result = await ContentDialogHelper.CreateConfirmationDialog( + "Mode DLSS modifié / DLSS mode changed", + "Le mode s'applique au redémarrage. Redémarrer Beast Roofer DEV maintenant ? / The mode applies on restart. Restart Beast Roofer DEV now?", + LocaleManager.Instance[LocaleKeys.InputDialogYes], + LocaleManager.Instance[LocaleKeys.InputDialogNo], + string.Empty); + + if (result == UserResult.Yes) + { + Ryujinx.Ava.Systems.DlssRestart.RestartCold(applyUiMode: true); + } + } + }); } private void Load() From bb8a8a091d9064780312424ccfd15aa0c7f4c902 Mon Sep 17 00:00:00 2001 From: The Roofer Dev Date: Sun, 28 Jun 2026 19:24:58 -0400 Subject: [PATCH 14/20] TAA Phase 0: native temporal AA scaffolding (pass-through) Clean-room native temporal anti-aliasing, gated on RYUJINX_TAA=1 (default path byte-identical when off). Phase 0 proves the present slot and resources without changing a pixel: allocates the rgba16f ping-pong history targets and runs a pure pass-through copy (mirrors FXAA's read/store path). Validated in game: image 100% identical in SDR and HDR, 'TAA: active' logged, no errors. - New Effects/TemporalFilter.cs (IPostProcessingEffect) + Effects/Shaders/Temporal.glsl/.spv. - Window.cs: run after the post-processing effect, before the spatial upscale/blit. - Embedded Temporal.spv. --- .../Effects/Shaders/Temporal.glsl | 30 ++++ .../Effects/Shaders/Temporal.spv | Bin 0 -> 1608 bytes .../Effects/TemporalFilter.cs | 148 ++++++++++++++++++ .../Ryujinx.Graphics.Vulkan.csproj | 1 + src/Ryujinx.Graphics.Vulkan/Window.cs | 10 ++ 5 files changed, 189 insertions(+) create mode 100644 src/Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.glsl create mode 100644 src/Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.spv create mode 100644 src/Ryujinx.Graphics.Vulkan/Effects/TemporalFilter.cs diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.glsl b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.glsl new file mode 100644 index 000000000..a8d4b2653 --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.glsl @@ -0,0 +1,30 @@ +// Temporal Anti-Aliasing (clean-room). +// +// Phase 0: pure pass-through copy (current -> output). This mirrors the FXAA effect's exact +// read/store path (a no-op FXAA is already an identity pass), so the result is visually identical +// to the input -- it only proves the slot and resources. Later phases reproject the ping-pong +// history with our motion vectors, neighborhood-clamp it and accumulate it exponentially. + +#version 430 core + +layout (local_size_x = 16, local_size_y = 16) in; + +layout (rgba8, binding = 0, set = 3) uniform image2D imgOutput; +layout (binding = 1, set = 2) uniform sampler2D Source; +layout (binding = 2) uniform dimensions { + float width; + float height; +}; + +void main() +{ + ivec2 loc = ivec2(gl_GlobalInvocationID.xy); + + if (loc.x >= int(width) || loc.y >= int(height)) + { + return; + } + + // Integer fetch, no filtering: the stored value is exactly the source texel. + imageStore(imgOutput, loc, texelFetch(Source, loc, 0)); +} diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.spv b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.spv new file mode 100644 index 0000000000000000000000000000000000000000..3385599b9543b41f631c7ab1b8b88f58f5ef32a1 GIT binary patch literal 1608 zcmZvbTTfF#6om)cLa_=WHxb1ayn~GwKomhtOezTp5Bva9+oo+&S|hE#_=9}XUuV1| zCVt=P8N-9qtj^wR?^&~F_MFV<>UhYFghI%N{V*KUVKj_j7Q$FER}PO4%V+(1d1LdD z8IvKCB$_i7CPI!m3r?%;u7OM78h8S>!DV!ePbap1NOUvkjADO9C`ZP5jU2HFVx3-% zSU%Bgc1|jt-n(k&p!=~`tM=Qy?!g;!a{R6`*W0I!?iuV^V8RrrbBy_;!jw9r-Wh$dy15{GblM zH^*;c@+&~jb8E-?Zev@Y_Y~{9gDoHHyN7LkzKvMleQfzy-vey9Sl=49I`Y5RGw$vD zom;csbx;KM!F=no_A;}>+Rb?g + /// Clean-room native Temporal Anti-Aliasing. + /// + /// Phase 0 is pure scaffolding: it allocates the ping-pong history targets (rgba16f, for the precise + /// accumulation later phases need) and runs a pass-through copy whose output is visually identical to + /// the input -- so the present slot and the resources are proven without changing a single pixel. + /// Later phases reproject the history with our motion vectors, neighborhood-clamp it and accumulate it. + /// + /// Gated entirely on RYUJINX_TAA=1: when it is unset the present path never constructs or runs this, + /// so the default render is byte-identical to before. + /// + internal class TemporalFilter : IPostProcessingEffect + { + /// True when the user opted into the experimental TAA via RYUJINX_TAA=1. + public static readonly bool IsEnabled = + Environment.GetEnvironmentVariable("RYUJINX_TAA") is "1" or "true" or "TRUE" or "True"; + + private readonly VulkanRenderer _renderer; + private readonly PipelineHelperShader _pipeline; + private ISampler _sampler; + private ShaderCollection _program; + + // Output keeps the input format so the downstream blit/scaling filter sees an identical texture. + private TextureView _output; + + // Ping-pong history, rgba16f. Allocated now to prove the resources; consumed from Phase 1 onward. + private TextureView _history0; + private TextureView _history1; + + private bool _activeLogged; + + public TemporalFilter(VulkanRenderer renderer, Device device) + { + _renderer = renderer; + _pipeline = new PipelineHelperShader(renderer, device); + + Initialize(); + } + + private void Initialize() + { + _pipeline.Initialize(); + + byte[] shader = EmbeddedResources.Read("Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.spv"); + + ResourceLayout layout = new ResourceLayoutBuilder() + .Add(ResourceStages.Compute, ResourceType.UniformBuffer, 2) + .Add(ResourceStages.Compute, ResourceType.TextureAndSampler, 1) + .Add(ResourceStages.Compute, ResourceType.Image, 0, true).Build(); + + _sampler = _renderer.CreateSampler(SamplerCreateInfo.Create(MinFilter.Linear, MagFilter.Linear)); + + _program = _renderer.CreateProgramWithMinimalLayout([ + new ShaderSource(shader, ShaderStage.Compute, TargetLanguage.Spirv) + ], layout); + } + + private static TextureCreateInfo MakeInfo(TextureCreateInfo b, int w, int h, Format format, int bytesPerPixel) + { + return new TextureCreateInfo( + w, + h, + 1, + 1, + 1, + 1, + 1, + bytesPerPixel, + format, + b.DepthStencilMode, + Target.Texture2D, + b.SwizzleR, + b.SwizzleG, + b.SwizzleB, + b.SwizzleA); + } + + private void EnsureResources(TextureView view) + { + if (_output != null && _output.Width == view.Width && _output.Height == view.Height) + { + return; + } + + _output?.Dispose(); + _history0?.Dispose(); + _history1?.Dispose(); + + _output = _renderer.CreateTexture(view.Info) as TextureView; + _history0 = _renderer.CreateTexture(MakeInfo(view.Info, view.Width, view.Height, Format.R16G16B16A16Float, 8)) as TextureView; + _history1 = _renderer.CreateTexture(MakeInfo(view.Info, view.Width, view.Height, Format.R16G16B16A16Float, 8)) as TextureView; + } + + public TextureView Run(TextureView view, CommandBufferScoped cbs, int width, int height) + { + EnsureResources(view); + + if (!_activeLogged) + { + _activeLogged = true; + Logger.Info?.Print(LogClass.Gpu, "TAA: active"); + } + + _pipeline.SetCommandBuffer(cbs); + _pipeline.SetProgram(_program); + _pipeline.SetTextureAndSampler(ShaderStage.Compute, 1, view, _sampler); + + ReadOnlySpan dimensionsBuffer = [view.Width, view.Height]; + int rangeSize = dimensionsBuffer.Length * sizeof(float); + using ScopedTemporaryBuffer buffer = _renderer.BufferManager.ReserveOrCreate(_renderer, cbs, rangeSize); + buffer.Holder.SetDataUnchecked(buffer.Offset, dimensionsBuffer); + + _pipeline.SetUniformBuffers([new BufferAssignment(2, buffer.Range)]); + _pipeline.SetImage(ShaderStage.Compute, 0, _output.GetView(FormatTable.ConvertRgba8SrgbToUnorm(view.Info.Format))); + + int dispatchX = BitUtils.DivRoundUp(view.Width, 16); + int dispatchY = BitUtils.DivRoundUp(view.Height, 16); + _pipeline.DispatchCompute(dispatchX, dispatchY, 1); + _pipeline.ComputeBarrier(); + + _pipeline.Finish(); + + return _output; + } + + public void Dispose() + { + _pipeline.Dispose(); + _program.Dispose(); + _sampler.Dispose(); + _output?.Dispose(); + _history0?.Dispose(); + _history1?.Dispose(); + } + } +} diff --git a/src/Ryujinx.Graphics.Vulkan/Ryujinx.Graphics.Vulkan.csproj b/src/Ryujinx.Graphics.Vulkan/Ryujinx.Graphics.Vulkan.csproj index a370b137d..2b5fe917d 100644 --- a/src/Ryujinx.Graphics.Vulkan/Ryujinx.Graphics.Vulkan.csproj +++ b/src/Ryujinx.Graphics.Vulkan/Ryujinx.Graphics.Vulkan.csproj @@ -24,6 +24,7 @@ + diff --git a/src/Ryujinx.Graphics.Vulkan/Window.cs b/src/Ryujinx.Graphics.Vulkan/Window.cs index 3c3497fc3..0466bf87a 100644 --- a/src/Ryujinx.Graphics.Vulkan/Window.cs +++ b/src/Ryujinx.Graphics.Vulkan/Window.cs @@ -37,6 +37,7 @@ namespace Ryujinx.Graphics.Vulkan private bool _updateEffect; private IPostProcessingEffect _effect; private IScalingFilter _scalingFilter; + private Effects.TemporalFilter _taa; private Dlss.DlssUpscaler _dlss; private bool _isLinear; private float _scalingFilterLevel; @@ -394,6 +395,14 @@ namespace Ryujinx.Graphics.Vulkan view = _effect.Run(view, cbs, _width, _height); } + // Native temporal anti-aliasing (clean-room), gated on RYUJINX_TAA=1. Runs at render resolution + // before the spatial upscale/blit, like a post-processing effect. Phase 0 is a pass-through. + if (Effects.TemporalFilter.IsEnabled) + { + _taa ??= new Effects.TemporalFilter(_gd, _device); + view = _taa.Run(view, cbs, _width, _height); + } + int srcX0, srcX1, srcY0, srcY1; if (crop.Left == 0 && crop.Right == 0) @@ -782,6 +791,7 @@ namespace Ryujinx.Graphics.Vulkan _effect?.Dispose(); _scalingFilter?.Dispose(); + _taa?.Dispose(); _dlss?.Dispose(); } } From f569140a77548bded82276768b05ec6fe6b0c21c Mon Sep 17 00:00:00 2001 From: The Roofer Dev Date: Sun, 28 Jun 2026 19:33:58 -0400 Subject: [PATCH 15/20] TAA Phase 1: fixed-weight temporal accumulation (ping-pong history) Blends each frame with the previous accumulated frame (result = mix(current, history, blend); default 0.90, RYUJINX_TAA_BLEND override) using two ping-pong rgba16f history targets. No motion vectors yet, so history is read at the same pixel: static/slow content converges to a steady image, fast motion ghosts (by design; Phase 2 reprojects). First frame seeds history (no flash). Validated in game: static smoothing clean, expected ghosting on motion, no errors. --- .../Effects/Shaders/Temporal.glsl | 33 +++++++--- .../Effects/Shaders/Temporal.spv | Bin 1608 -> 2644 bytes .../Effects/TemporalFilter.cs | 62 ++++++++++++++---- 3 files changed, 74 insertions(+), 21 deletions(-) diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.glsl b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.glsl index a8d4b2653..0c9323abb 100644 --- a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.glsl +++ b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.glsl @@ -1,19 +1,29 @@ // Temporal Anti-Aliasing (clean-room). // -// Phase 0: pure pass-through copy (current -> output). This mirrors the FXAA effect's exact -// read/store path (a no-op FXAA is already an identity pass), so the result is visually identical -// to the input -- it only proves the slot and resources. Later phases reproject the ping-pong -// history with our motion vectors, neighborhood-clamp it and accumulate it exponentially. +// Phase 1: fixed-weight temporal accumulation with ping-pong history. No motion vectors yet, so the +// history is read at the SAME pixel -- static and slow-moving content converges to a rock-steady image +// (cascades/scenery freeze like marble), while fast camera motion ghosts. That ghosting is expected and +// goes away in Phase 2, which reprojects the history with our reconstructed motion vectors. +// +// result = mix(current, history, blend) // blend = history weight (e.g. 0.90) +// +// The result is written both to the present output (input format) and to the write-side history target +// (rgba16f), which becomes next frame's read-side history. #version 430 core layout (local_size_x = 16, local_size_y = 16) in; -layout (rgba8, binding = 0, set = 3) uniform image2D imgOutput; +layout (rgba8, binding = 0, set = 3) uniform image2D imgOutput; +layout (rgba16f, binding = 1, set = 3) uniform image2D imgHistoryWrite; layout (binding = 1, set = 2) uniform sampler2D Source; -layout (binding = 2) uniform dimensions { +layout (binding = 3, set = 2) uniform sampler2D HistoryRead; + +layout (binding = 2) uniform params { float width; float height; + float blend; // history weight in [0,1]; current weight is (1 - blend) + float hasHistory; // >0.5 once the read-side history holds a valid previous frame }; void main() @@ -25,6 +35,13 @@ void main() return; } - // Integer fetch, no filtering: the stored value is exactly the source texel. - imageStore(imgOutput, loc, texelFetch(Source, loc, 0)); + vec4 current = texelFetch(Source, loc, 0); + vec4 history = texelFetch(HistoryRead, loc, 0); + + // First frame after (re)allocation has no valid history: pass current through and seed the history. + float w = hasHistory > 0.5 ? blend : 0.0; + vec4 result = mix(current, history, w); + + imageStore(imgOutput, loc, result); + imageStore(imgHistoryWrite, loc, result); } diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.spv b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.spv index 3385599b9543b41f631c7ab1b8b88f58f5ef32a1..fc5f4acc7ab91e8bdefceb1e13e3db771020fbc8 100644 GIT binary patch literal 2644 zcmZ{kYj2xH5QR4}P6H_|p*KpQI1NysCF0VSLg_6nIH7^G4G`|BV_UH$$F6*#LgEMU z3BL2c5EluF=h?i|zC!9s$2)Uo_Pn#RyRJ+x?n~9FG?!*mJME46G@Yg}=h94`>uVcp zOQUgX>D>9Z%s7xLInkVhWL24;0K1KD-@w!0d2j)I2)+he;2HGHo6oj>%K8=brm=rT zuu4W*t4i!iV!c6=*lgC`>21|}gY8Cdwg2Ow*%)^R{neY~xb`{b`;B2^ca->AHTzKM zLAN#TFb8MtopyJpGk(Nb!*;LTZ{@t1yw;s|8l#o&XgnDH#5xI$)g*d4c1owfFT4!i*B=-LbSw8AQF$Ku}fkh2eR_B`@jBR5;(?1S8X;I};A z1z>LYr?4IR_QAON;Tw;9`(RvMe`!?<{u5Uc@y-#I*{c_whv=_(0A-?K&7g+6j z?IUQ;FxS8oIEKHESjZF^?;SCwPSo%+wzWq7 z>B4ufK74b%C$4{+*&5}K^FrTcHtzcJ2>%58I7h%d*PkzvkJ@r4*{^)x){x6k% z|A)f2m&^b2S4zI?{U0*_GJ4i@5O|l@VZF!JD<>TG@n>>fGY^bkp;GJ8zfQH@>mxwU z`>wXHb$0D=iA7b*S zfSi5Qj{07~wm$nF^}UKMAN9S4ZGERf)b~2JeAM>_wp`SA23sBZzxif-+xZW=X1!-Y z4S25RTbH#jF*~f?oHv1-wQK)N_qFA$$2avJ@IC2!CKrMGYT^3^-v{Pw5%mp!P;ll= z1AFiJd5?^R?qzJ>W$0ePF6&;+u}8Ys@a4=a>v|urf#~T?YE^4}7orw9rQI44tI=8W1Z|*X) zzA<}x7udh>H?ZAzto=G;2Eo{mid0yw`yG7P3CWa|5N&& r{kOm~iaNf-c1@GGxyI#;`#*ILY!$xi8=$;?8$Z_HE8=qA!;9cQ8J4MO delta 498 zcmYk2Jx;?w5QS%LZ-OBf6i5lAApA5aNCXmtAvl2oQBqJ*0ZJldAv*}-nlo5ia|&oU z0UQ8|90rN^jFA}W)9jm{ozZ+QepEc$S}%)MGAA=~oO%9=D_TSx5Z^=zoOoav?1CVR zM)O)~cn+g@lsrb`B%3z3wb*$L;^^0TZ4}?0r<411lE)kg;4jAM!(b%lU7ebawQ<5f zR{`w|Q>oX0U1;_&ul{M~twQ(B)cUd=shKzXPA`coEfebcCiidPza4m53v>(388lY= zsIHo8r*gT6h!5Dc&oqoNc2RQ^S{3>z_rF(aLeE0?zyjEk5{@a-FJe9OtlKY*(yMyn z5^cZ*LbR3PJHRb8#~lEU`pX~U&HNDcT(1jQbA@iM5f(Al@hEjLKrQF+Z|1*ozCe_c EABn6m&;S4c diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/TemporalFilter.cs b/src/Ryujinx.Graphics.Vulkan/Effects/TemporalFilter.cs index a82e6142e..18585ab49 100644 --- a/src/Ryujinx.Graphics.Vulkan/Effects/TemporalFilter.cs +++ b/src/Ryujinx.Graphics.Vulkan/Effects/TemporalFilter.cs @@ -5,6 +5,7 @@ using Ryujinx.Graphics.Shader; using Ryujinx.Graphics.Shader.Translation; using Silk.NET.Vulkan; using System; +using System.Globalization; using Format = Ryujinx.Graphics.GAL.Format; using SamplerCreateInfo = Ryujinx.Graphics.GAL.SamplerCreateInfo; @@ -13,13 +14,14 @@ namespace Ryujinx.Graphics.Vulkan.Effects /// /// Clean-room native Temporal Anti-Aliasing. /// - /// Phase 0 is pure scaffolding: it allocates the ping-pong history targets (rgba16f, for the precise - /// accumulation later phases need) and runs a pass-through copy whose output is visually identical to - /// the input -- so the present slot and the resources are proven without changing a single pixel. - /// Later phases reproject the history with our motion vectors, neighborhood-clamp it and accumulate it. + /// Phase 1: fixed-weight temporal accumulation. Each frame blends the current frame with the previous + /// accumulated frame (result = mix(current, history, blend)) using two ping-pong rgba16f history + /// targets (one read, one written, swapped every frame). With no motion vectors yet the history is read + /// at the same pixel, so static/slow content converges to a rock-steady image while fast motion ghosts + /// (resolved in Phase 2 by reprojecting the history with our reconstructed motion vectors). /// - /// Gated entirely on RYUJINX_TAA=1: when it is unset the present path never constructs or runs this, - /// so the default render is byte-identical to before. + /// Gated entirely on RYUJINX_TAA=1: when unset the present path never constructs or runs this, so the + /// default render is byte-identical to before. RYUJINX_TAA_BLEND overrides the history weight (default 0.90). /// internal class TemporalFilter : IPostProcessingEffect { @@ -27,6 +29,9 @@ namespace Ryujinx.Graphics.Vulkan.Effects public static readonly bool IsEnabled = Environment.GetEnvironmentVariable("RYUJINX_TAA") is "1" or "true" or "TRUE" or "True"; + // History weight (fraction of the accumulated history kept each frame). Tunable for bring-up. + private static readonly float HistoryBlend = ParseBlend(); + private readonly VulkanRenderer _renderer; private readonly PipelineHelperShader _pipeline; private ISampler _sampler; @@ -35,9 +40,11 @@ namespace Ryujinx.Graphics.Vulkan.Effects // Output keeps the input format so the downstream blit/scaling filter sees an identical texture. private TextureView _output; - // Ping-pong history, rgba16f. Allocated now to prove the resources; consumed from Phase 1 onward. + // Ping-pong history, rgba16f. Each frame one is read (previous result) and one is written (new result). private TextureView _history0; private TextureView _history1; + private bool _readFromHistory0; + private bool _hasHistory; private bool _activeLogged; @@ -49,6 +56,19 @@ namespace Ryujinx.Graphics.Vulkan.Effects Initialize(); } + private static float ParseBlend() + { + string value = Environment.GetEnvironmentVariable("RYUJINX_TAA_BLEND"); + + if (float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out float blend) && + blend >= 0f && blend <= 0.99f) + { + return blend; + } + + return 0.90f; + } + private void Initialize() { _pipeline.Initialize(); @@ -57,8 +77,10 @@ namespace Ryujinx.Graphics.Vulkan.Effects ResourceLayout layout = new ResourceLayoutBuilder() .Add(ResourceStages.Compute, ResourceType.UniformBuffer, 2) - .Add(ResourceStages.Compute, ResourceType.TextureAndSampler, 1) - .Add(ResourceStages.Compute, ResourceType.Image, 0, true).Build(); + .Add(ResourceStages.Compute, ResourceType.TextureAndSampler, 1) // current color + .Add(ResourceStages.Compute, ResourceType.TextureAndSampler, 3) // history (read) + .Add(ResourceStages.Compute, ResourceType.Image, 0, true) // present output + .Add(ResourceStages.Compute, ResourceType.Image, 1, true).Build(); // history (write) _sampler = _renderer.CreateSampler(SamplerCreateInfo.Create(MinFilter.Linear, MagFilter.Linear)); @@ -101,6 +123,10 @@ namespace Ryujinx.Graphics.Vulkan.Effects _output = _renderer.CreateTexture(view.Info) as TextureView; _history0 = _renderer.CreateTexture(MakeInfo(view.Info, view.Width, view.Height, Format.R16G16B16A16Float, 8)) as TextureView; _history1 = _renderer.CreateTexture(MakeInfo(view.Info, view.Width, view.Height, Format.R16G16B16A16Float, 8)) as TextureView; + + // Fresh history: the first frame has nothing to blend against. + _readFromHistory0 = true; + _hasHistory = false; } public TextureView Run(TextureView view, CommandBufferScoped cbs, int width, int height) @@ -110,20 +136,26 @@ namespace Ryujinx.Graphics.Vulkan.Effects if (!_activeLogged) { _activeLogged = true; - Logger.Info?.Print(LogClass.Gpu, "TAA: active"); + Logger.Info?.Print(LogClass.Gpu, $"TAA: active (accumulation, history blend {HistoryBlend:0.00})."); } + // Ping-pong: read last frame's result, write this frame's result to the other target. + TextureView historyRead = _readFromHistory0 ? _history0 : _history1; + TextureView historyWrite = _readFromHistory0 ? _history1 : _history0; + _pipeline.SetCommandBuffer(cbs); _pipeline.SetProgram(_program); _pipeline.SetTextureAndSampler(ShaderStage.Compute, 1, view, _sampler); + _pipeline.SetTextureAndSampler(ShaderStage.Compute, 3, historyRead, _sampler); - ReadOnlySpan dimensionsBuffer = [view.Width, view.Height]; - int rangeSize = dimensionsBuffer.Length * sizeof(float); + ReadOnlySpan paramsBuffer = [view.Width, view.Height, HistoryBlend, _hasHistory ? 1f : 0f]; + int rangeSize = paramsBuffer.Length * sizeof(float); using ScopedTemporaryBuffer buffer = _renderer.BufferManager.ReserveOrCreate(_renderer, cbs, rangeSize); - buffer.Holder.SetDataUnchecked(buffer.Offset, dimensionsBuffer); + buffer.Holder.SetDataUnchecked(buffer.Offset, paramsBuffer); _pipeline.SetUniformBuffers([new BufferAssignment(2, buffer.Range)]); _pipeline.SetImage(ShaderStage.Compute, 0, _output.GetView(FormatTable.ConvertRgba8SrgbToUnorm(view.Info.Format))); + _pipeline.SetImage(1, historyWrite.GetImageView()); int dispatchX = BitUtils.DivRoundUp(view.Width, 16); int dispatchY = BitUtils.DivRoundUp(view.Height, 16); @@ -132,6 +164,10 @@ namespace Ryujinx.Graphics.Vulkan.Effects _pipeline.Finish(); + // Next frame reads the target we just wrote. + _readFromHistory0 = !_readFromHistory0; + _hasHistory = true; + return _output; } From ae19925e8eb5f047f97229e357dd0f4a1d42d997 Mon Sep 17 00:00:00 2001 From: The Roofer Dev Date: Sun, 28 Jun 2026 19:46:29 -0400 Subject: [PATCH 16/20] TAA Phase 2: motion-vector reprojection (self-contained Lucas-Kanade) The history is now read at the reprojected position (prevPos = loc + mv) instead of the fixed pixel, so it tracks moving geometry and the Phase 1 ghosting collapses. TAA reconstructs its own motion field (the DLSS Lucas-Kanade pass + 3x3 median filter, reused) so it works with DLSS off. Off-screen reprojection (disocclusion) falls back to the current frame. MV sign calibrated in game to +1 (default; RYUJINX_TAA_MV_SIGN=-1 flips). Residual ghosting from imperfect reconstructed vectors is cleaned by the Phase 3 neighborhood clamp. --- .../Effects/Shaders/Temporal.glsl | 40 ++-- .../Effects/Shaders/Temporal.spv | Bin 2644 -> 3912 bytes .../Effects/TemporalFilter.cs | 203 ++++++++++++++++-- 3 files changed, 206 insertions(+), 37 deletions(-) diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.glsl b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.glsl index 0c9323abb..39aac9860 100644 --- a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.glsl +++ b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.glsl @@ -1,14 +1,16 @@ // Temporal Anti-Aliasing (clean-room). // -// Phase 1: fixed-weight temporal accumulation with ping-pong history. No motion vectors yet, so the -// history is read at the SAME pixel -- static and slow-moving content converges to a rock-steady image -// (cascades/scenery freeze like marble), while fast camera motion ghosts. That ghosting is expected and -// goes away in Phase 2, which reprojects the history with our reconstructed motion vectors. +// Phase 2: motion-vector reprojection. The history is no longer read at the fixed pixel but at the +// position the pixel's content occupied last frame, following our reconstructed motion field: // -// result = mix(current, history, blend) // blend = history weight (e.g. 0.90) +// prevPos = loc + motionSign * mv // motionSign = +1 (calibrated): follow the field to the history +// history = bilinear sample of HistoryRead at prevPos +// result = mix(current, history, blend) // -// The result is written both to the present output (input format) and to the write-side history target -// (rgba16f), which becomes next frame's read-side history. +// This makes the accumulated history track moving geometry, so the massive ghosting from Phase 1 +// collapses while the camera turns. Pixels whose previous position falls off-screen (disocclusion) +// fall back to the current frame so freshly revealed areas do not smear. Neighborhood clamping for +// the remaining ghosting/fireflies comes in Phase 3. #version 430 core @@ -16,14 +18,17 @@ layout (local_size_x = 16, local_size_y = 16) in; layout (rgba8, binding = 0, set = 3) uniform image2D imgOutput; layout (rgba16f, binding = 1, set = 3) uniform image2D imgHistoryWrite; -layout (binding = 1, set = 2) uniform sampler2D Source; -layout (binding = 3, set = 2) uniform sampler2D HistoryRead; +layout (binding = 1, set = 2) uniform sampler2D Source; // current color +layout (binding = 3, set = 2) uniform sampler2D HistoryRead; // previous accumulated result +layout (binding = 5, set = 2) uniform sampler2D Motion; // rg16f motion field, render-res pixels layout (binding = 2) uniform params { float width; float height; - float blend; // history weight in [0,1]; current weight is (1 - blend) - float hasHistory; // >0.5 once the read-side history holds a valid previous frame + float blend; // history weight in [0,1]; current weight is (1 - blend) + float hasHistory; // >0.5 once the read-side history holds a valid previous frame + float motionSign; // +1 (calibrated) maps a pixel back to its previous position (prevPos = loc + mv) + float hasMotion; // >0.5 once a previous frame exists to estimate motion from }; void main() @@ -36,10 +41,17 @@ void main() } vec4 current = texelFetch(Source, loc, 0); - vec4 history = texelFetch(HistoryRead, loc, 0); - // First frame after (re)allocation has no valid history: pass current through and seed the history. - float w = hasHistory > 0.5 ? blend : 0.0; + // This pixel's motion (in render-resolution pixels); reproject the history read by it. + vec2 mv = hasMotion > 0.5 ? texelFetch(Motion, loc, 0).xy : vec2(0.0); + vec2 prevPos = vec2(loc) + 0.5 + motionSign * mv; + vec2 uv = prevPos / vec2(width, height); + + bool onScreen = all(greaterThanEqual(uv, vec2(0.0))) && all(lessThanEqual(uv, vec2(1.0))); + vec4 history = texture(HistoryRead, uv); // bilinear: prevPos is sub-pixel + + // No history yet, or reprojected off-screen (disocclusion): take the current frame, no ghost. + float w = (hasHistory > 0.5 && onScreen) ? blend : 0.0; vec4 result = mix(current, history, w); imageStore(imgOutput, loc, result); diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.spv b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.spv index fc5f4acc7ab91e8bdefceb1e13e3db771020fbc8..f1eebbd8e831c82b3057557d3f3c66028d0726a1 100644 GIT binary patch literal 3912 zcmZ{lX>(Ln5QcAp2_T#7B8mZ2R4|1Ih$0|@Bq&QHDkv%plOY+M%!FA8C?dEZDxxCp zKR`d}cgyl$SuRyp`8@aD)>|n&Rqu57+kN`<>2uDVY2B-*Cp}%stYk*=ZIX@oNq5qP zIxFc-<-oq-ef_QWNdLx7>y4P3OiKfenU~B?dXP7P@lv&}V-C0tYzBKk6I=z?!7b!8 zc7CdBC+Ql~$k~nlBT0LRDCXP3>8g6u&kX;gUnKQtj@&0 zoM#|sFC)%5ax)5?XCODbz&S_GI}~&6z1$*TP4;~yFlX4S&>hQBD|tbIS9>Fy8!f<}}_On@fIcZhN+oe=fT5=3m6`0_55}??QBkc|3!t zWeM4rrhJmDpd#;s_Ko0N_iKLRtU=qelyfcjw;VW(_s;1vU(|9Jx;01qn%s8Je#ngV z4mtlpq_xT~<7@K}Qor+yBkbkuXCn^doWCg#lCxfI@0P>$wx%+x(fG%(T~9vh+m7x% z({}BhfUzU;dG{RQ-=CSuI%b*Y0FV=r^Evd`V;S8wJuhoInd{M0*Q|jgxv(R0j^~_w z)Zra>xDP%(X-?s>W?}5*bxxI~g-Qzqk2OK1e@7Vpz2}c(= zN33hi2l}_ObN8velimBS7X!J4z}&vaB|zR7-{T#?H@h4d(Fra@(2*L9yn&*v3T7Poc{h7d3l7{iZz)^b1E9a6h|{`aA>c zvIc7j-+?JUW311d2Z8gA^?hpVv({&TwKnqDLpe7@Pn zH)aipnB(a3vCk5^^Nxedd3`6)T~D9297W1SEfsXRUx`1790hWo#dq}6w=o9fy-%+q z9o{GTDvvDQ;+rEMtA*-Al82eUEX@nBem_<1*G%M6F&D<^cfR*-a~i3 zv6Dz`ee03$gAE1yGP=EqxgVfAXDILIhv;%e%;ulfTpxih&=1UOF5_MI5>mf;uOL4G wS95zh@>4J~x6Sn#a88+cWA)29{~GcOa6Px3|0O8S{|Y?T=Z}t}O50n%t6aWAK delta 1066 zcmZ9L%T7~K6oz;2U~M#3;srG#0|y#XFEu7mporEhh&M#VQd<&9L?d8~qsB)d=LviU zXTE?A9q7!7@d+GxNlg4c+LKgw@^`I&UH-k#+PfbPecD|}C5Lk%lL-0H7nU~*<)itK z2_dAxYGdUoCKSMaa0ZNl1yJ8yeIK1R12} zuxrcfFP?13GhrbbN){9KsGMv^Uy{=eMTbBN)Toyq0m&|RL-Q{4a`SP09p8Kcn3wnF zThW`;=TtWAjb5j}4UAGzrovrZhvC(znk`n&!PGoPWDoWNU`@!gI9GsYiu1Ghu6?0D z%tz0&!$WsFfiRDCAGxP>S+z*jVDvHDS9#b8hDEHEa?kBje;c+5c7o&Vp2xPjv1T-o ztCdf{oPBP~@(tLj|M7imUyK7)+hLvu#@~fZJM6-#b%Rd%2XnuSb^WHn+$AsrezKK* zEPfSVac?6QzlLvo938(|{);{}?N=wx&4%{BNsD5k>-Y*->@el#{Q4W /// Clean-room native Temporal Anti-Aliasing. /// - /// Phase 1: fixed-weight temporal accumulation. Each frame blends the current frame with the previous - /// accumulated frame (result = mix(current, history, blend)) using two ping-pong rgba16f history - /// targets (one read, one written, swapped every frame). With no motion vectors yet the history is read - /// at the same pixel, so static/slow content converges to a rock-steady image while fast motion ghosts - /// (resolved in Phase 2 by reprojecting the history with our reconstructed motion vectors). + /// Phase 2: fixed-weight temporal accumulation with motion-vector reprojection. Each frame it + /// reconstructs a motion field from the current and previous color (the same Lucas-Kanade pass + 3x3 + /// median filter used by the DLSS path -- self-contained, so TAA works with DLSS off), then blends the + /// current frame with the history sampled at the reprojected position (prevPos = loc - mv). Static + /// content stays rock-steady and the Phase 1 ghosting collapses while the camera moves. Neighborhood + /// clamping for the remaining ghosting/fireflies is Phase 3. /// /// Gated entirely on RYUJINX_TAA=1: when unset the present path never constructs or runs this, so the - /// default render is byte-identical to before. RYUJINX_TAA_BLEND overrides the history weight (default 0.90). + /// default render is byte-identical. RYUJINX_TAA_BLEND overrides the history weight (default 0.90); + /// RYUJINX_TAA_MV_SIGN flips the reprojection direction (default +1) for sign calibration. /// internal class TemporalFilter : IPostProcessingEffect { @@ -29,13 +31,20 @@ namespace Ryujinx.Graphics.Vulkan.Effects public static readonly bool IsEnabled = Environment.GetEnvironmentVariable("RYUJINX_TAA") is "1" or "true" or "TRUE" or "True"; - // History weight (fraction of the accumulated history kept each frame). Tunable for bring-up. + private const float MaxMotion = 32f; // motion-vector clamp, in render-resolution pixels + private const int CounterCount = 9; // uints in the motion-pass SSBO (unused by TAA, required by the shader) + private static readonly float HistoryBlend = ParseBlend(); + private static readonly float MotionSign = ParseSign(); private readonly VulkanRenderer _renderer; - private readonly PipelineHelperShader _pipeline; + private readonly PipelineHelperShader _pipeline; // accumulation/blend pass + private readonly PipelineHelperShader _motionPipeline; // motion estimate + median filter passes private ISampler _sampler; private ShaderCollection _program; + private ShaderCollection _motionProgram; + private ShaderCollection _motionFilterProgram; + private BufferHandle _sceneChangeBuffer; // Output keeps the input format so the downstream blit/scaling filter sees an identical texture. private TextureView _output; @@ -46,12 +55,19 @@ namespace Ryujinx.Graphics.Vulkan.Effects private bool _readFromHistory0; private bool _hasHistory; + // Optical-flow inputs/outputs: previous color (input format), raw and median-filtered motion (rg16f). + private TextureView _prevColor; + private TextureView _motion; + private TextureView _motionFiltered; + private bool _hasPrev; + private bool _activeLogged; public TemporalFilter(VulkanRenderer renderer, Device device) { _renderer = renderer; _pipeline = new PipelineHelperShader(renderer, device); + _motionPipeline = new PipelineHelperShader(renderer, device); Initialize(); } @@ -69,24 +85,62 @@ namespace Ryujinx.Graphics.Vulkan.Effects return 0.90f; } + private static float ParseSign() + { + // Default +1 (prevPos = loc + mv): calibrated in game. The Lucas-Kanade field is oriented so that + // following +mv lands the read on the history; -1 doubled the image. RYUJINX_TAA_MV_SIGN=-1 flips back. + return Environment.GetEnvironmentVariable("RYUJINX_TAA_MV_SIGN") is "-1" ? -1f : 1f; + } + private void Initialize() { _pipeline.Initialize(); - - byte[] shader = EmbeddedResources.Read("Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.spv"); - - ResourceLayout layout = new ResourceLayoutBuilder() - .Add(ResourceStages.Compute, ResourceType.UniformBuffer, 2) - .Add(ResourceStages.Compute, ResourceType.TextureAndSampler, 1) // current color - .Add(ResourceStages.Compute, ResourceType.TextureAndSampler, 3) // history (read) - .Add(ResourceStages.Compute, ResourceType.Image, 0, true) // present output - .Add(ResourceStages.Compute, ResourceType.Image, 1, true).Build(); // history (write) + _motionPipeline.Initialize(); _sampler = _renderer.CreateSampler(SamplerCreateInfo.Create(MinFilter.Linear, MagFilter.Linear)); + // Accumulation/blend pass: current color (b1), history read (b3), motion (b5), params (b2), + // present output image (b0/set3), history write image (b1/set3). + byte[] blendShader = EmbeddedResources.Read("Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.spv"); + ResourceLayout blendLayout = new ResourceLayoutBuilder() + .Add(ResourceStages.Compute, ResourceType.UniformBuffer, 2) + .Add(ResourceStages.Compute, ResourceType.TextureAndSampler, 1) + .Add(ResourceStages.Compute, ResourceType.TextureAndSampler, 3) + .Add(ResourceStages.Compute, ResourceType.TextureAndSampler, 5) + .Add(ResourceStages.Compute, ResourceType.Image, 0, true) + .Add(ResourceStages.Compute, ResourceType.Image, 1, true).Build(); + _program = _renderer.CreateProgramWithMinimalLayout([ - new ShaderSource(shader, ShaderStage.Compute, TargetLanguage.Spirv) - ], layout); + new ShaderSource(blendShader, ShaderStage.Compute, TargetLanguage.Spirv) + ], blendLayout); + + // Motion estimate (Lucas-Kanade): current (b1), previous (b3), params (b2), stats SSBO (b0/set1), + // motion image (b0/set3). Reused verbatim from the DLSS path. + byte[] motionShader = EmbeddedResources.Read("Ryujinx.Graphics.Vulkan/Effects/Shaders/MotionVectors.spv"); + ResourceLayout motionLayout = new ResourceLayoutBuilder() + .Add(ResourceStages.Compute, ResourceType.UniformBuffer, 2) + .Add(ResourceStages.Compute, ResourceType.StorageBuffer, 0) + .Add(ResourceStages.Compute, ResourceType.TextureAndSampler, 1) + .Add(ResourceStages.Compute, ResourceType.TextureAndSampler, 3) + .Add(ResourceStages.Compute, ResourceType.Image, 0, true).Build(); + + _motionProgram = _renderer.CreateProgramWithMinimalLayout([ + new ShaderSource(motionShader, ShaderStage.Compute, TargetLanguage.Spirv) + ], motionLayout); + + // 3x3 median outlier filter: params (b2), stats SSBO (b0/set1), filtered out (b0/set3), raw in (b1/set3). + byte[] filterShader = EmbeddedResources.Read("Ryujinx.Graphics.Vulkan/Effects/Shaders/MotionFilter.spv"); + ResourceLayout filterLayout = new ResourceLayoutBuilder() + .Add(ResourceStages.Compute, ResourceType.UniformBuffer, 2) + .Add(ResourceStages.Compute, ResourceType.StorageBuffer, 0) + .Add(ResourceStages.Compute, ResourceType.Image, 0, true) + .Add(ResourceStages.Compute, ResourceType.Image, 1, true).Build(); + + _motionFilterProgram = _renderer.CreateProgramWithMinimalLayout([ + new ShaderSource(filterShader, ShaderStage.Compute, TargetLanguage.Spirv) + ], filterLayout); + + _sceneChangeBuffer = _renderer.BufferManager.CreateWithHandle(_renderer, CounterCount * sizeof(uint)); } private static TextureCreateInfo MakeInfo(TextureCreateInfo b, int w, int h, Format format, int bytesPerPixel) @@ -119,14 +173,21 @@ namespace Ryujinx.Graphics.Vulkan.Effects _output?.Dispose(); _history0?.Dispose(); _history1?.Dispose(); + _prevColor?.Dispose(); + _motion?.Dispose(); + _motionFiltered?.Dispose(); _output = _renderer.CreateTexture(view.Info) as TextureView; _history0 = _renderer.CreateTexture(MakeInfo(view.Info, view.Width, view.Height, Format.R16G16B16A16Float, 8)) as TextureView; _history1 = _renderer.CreateTexture(MakeInfo(view.Info, view.Width, view.Height, Format.R16G16B16A16Float, 8)) as TextureView; + _prevColor = _renderer.CreateTexture(MakeInfo(view.Info, view.Width, view.Height, view.Info.Format, view.Info.BytesPerPixel)) as TextureView; + _motion = _renderer.CreateTexture(MakeInfo(view.Info, view.Width, view.Height, Format.R16G16Float, 4)) as TextureView; + _motionFiltered = _renderer.CreateTexture(MakeInfo(view.Info, view.Width, view.Height, Format.R16G16Float, 4)) as TextureView; - // Fresh history: the first frame has nothing to blend against. + // Fresh resources: nothing to blend or estimate motion against yet. _readFromHistory0 = true; _hasHistory = false; + _hasPrev = false; } public TextureView Run(TextureView view, CommandBufferScoped cbs, int width, int height) @@ -136,7 +197,15 @@ namespace Ryujinx.Graphics.Vulkan.Effects if (!_activeLogged) { _activeLogged = true; - Logger.Info?.Print(LogClass.Gpu, $"TAA: active (accumulation, history blend {HistoryBlend:0.00})."); + Logger.Info?.Print(LogClass.Gpu, + $"TAA: active (reprojection, history blend {HistoryBlend:0.00}, mv sign {MotionSign:+0;-0})."); + } + + // Reconstruct this frame's motion field from (current, previous), then median-filter it. + if (_hasPrev) + { + RunMotionPass(view, cbs); + RunMotionFilterPass(view, cbs); } // Ping-pong: read last frame's result, write this frame's result to the other target. @@ -147,8 +216,17 @@ namespace Ryujinx.Graphics.Vulkan.Effects _pipeline.SetProgram(_program); _pipeline.SetTextureAndSampler(ShaderStage.Compute, 1, view, _sampler); _pipeline.SetTextureAndSampler(ShaderStage.Compute, 3, historyRead, _sampler); + _pipeline.SetTextureAndSampler(ShaderStage.Compute, 5, _motionFiltered, _sampler); - ReadOnlySpan paramsBuffer = [view.Width, view.Height, HistoryBlend, _hasHistory ? 1f : 0f]; + ReadOnlySpan paramsBuffer = + [ + view.Width, + view.Height, + HistoryBlend, + _hasHistory ? 1f : 0f, + MotionSign, + _hasPrev ? 1f : 0f, + ]; int rangeSize = paramsBuffer.Length * sizeof(float); using ScopedTemporaryBuffer buffer = _renderer.BufferManager.ReserveOrCreate(_renderer, cbs, rangeSize); buffer.Holder.SetDataUnchecked(buffer.Offset, paramsBuffer); @@ -164,21 +242,100 @@ namespace Ryujinx.Graphics.Vulkan.Effects _pipeline.Finish(); - // Next frame reads the target we just wrote. + // Keep this frame's color as the "previous" frame for next time's motion estimate. + CopyToPrev(view, cbs); + + // Next frame reads the target we just wrote and has both a history and a previous frame. _readFromHistory0 = !_readFromHistory0; _hasHistory = true; + _hasPrev = true; return _output; } + private void RunMotionPass(TextureView input, CommandBufferScoped cbs) + { + _motionPipeline.SetCommandBuffer(cbs); + _motionPipeline.SetProgram(_motionProgram); + _motionPipeline.SetTextureAndSampler(ShaderStage.Compute, 1, input, _sampler); + _motionPipeline.SetTextureAndSampler(ShaderStage.Compute, 3, _prevColor, _sampler); + + // width/height/maxMotion/metrics + dejitterX/Y + 2 pad. No clip-space jitter on the TAA input + // (that is a DLSS-only feature), so de-jitter is zero; metrics off. + ReadOnlySpan p = [input.Width, input.Height, MaxMotion, 0f, 0f, 0f, 0f, 0f]; + using ScopedTemporaryBuffer buffer = _renderer.BufferManager.ReserveOrCreate(_renderer, cbs, p.Length * sizeof(float)); + buffer.Holder.SetDataUnchecked(buffer.Offset, p); + + _motionPipeline.SetUniformBuffers([new BufferAssignment(2, buffer.Range)]); + _motionPipeline.SetStorageBuffers([new BufferAssignment(0, new BufferRange(_sceneChangeBuffer, 0, CounterCount * sizeof(uint), true))]); + _motionPipeline.SetImage(0, _motion.GetImageView()); + + _motionPipeline.DispatchCompute(BitUtils.DivRoundUp(input.Width, 16), BitUtils.DivRoundUp(input.Height, 16), 1); + _motionPipeline.ComputeBarrier(); + _motionPipeline.Finish(); + } + + private void RunMotionFilterPass(TextureView input, CommandBufferScoped cbs) + { + _motionPipeline.SetCommandBuffer(cbs); + _motionPipeline.SetProgram(_motionFilterProgram); + + ReadOnlySpan p = [input.Width, input.Height, MaxMotion, 0f, 0f, 0f, 0f, 0f]; + using ScopedTemporaryBuffer buffer = _renderer.BufferManager.ReserveOrCreate(_renderer, cbs, p.Length * sizeof(float)); + buffer.Holder.SetDataUnchecked(buffer.Offset, p); + + _motionPipeline.SetUniformBuffers([new BufferAssignment(2, buffer.Range)]); + _motionPipeline.SetStorageBuffers([new BufferAssignment(0, new BufferRange(_sceneChangeBuffer, 0, CounterCount * sizeof(uint), true))]); + _motionPipeline.SetImage(0, _motionFiltered.GetImageView()); + _motionPipeline.SetImage(1, _motion.GetImageView()); + + _motionPipeline.DispatchCompute(BitUtils.DivRoundUp(input.Width, 16), BitUtils.DivRoundUp(input.Height, 16), 1); + _motionPipeline.ComputeBarrier(); + _motionPipeline.Finish(); + } + + private void CopyToPrev(TextureView input, CommandBufferScoped cbs) + { + ImageSubresourceLayers layers = new() + { + AspectMask = ImageAspectFlags.ColorBit, + MipLevel = 0, + BaseArrayLayer = 0, + LayerCount = 1, + }; + + ImageCopy region = new() + { + SrcSubresource = layers, + DstSubresource = layers, + Extent = new Extent3D((uint)input.Width, (uint)input.Height, 1), + }; + + _renderer.Api.CmdCopyImage( + cbs.CommandBuffer, + input.GetImage().Get(cbs).Value, + ImageLayout.General, + _prevColor.GetImage().Get(cbs).Value, + ImageLayout.General, + 1, + in region); + } + public void Dispose() { _pipeline.Dispose(); + _motionPipeline.Dispose(); _program.Dispose(); + _motionProgram.Dispose(); + _motionFilterProgram.Dispose(); _sampler.Dispose(); _output?.Dispose(); _history0?.Dispose(); _history1?.Dispose(); + _prevColor?.Dispose(); + _motion?.Dispose(); + _motionFiltered?.Dispose(); + _renderer.BufferManager.Delete(_sceneChangeBuffer); } } } From df3240c17f5ab34993c3dd4b8465d4ee4435a113 Mon Sep 17 00:00:00 2001 From: The Roofer Dev Date: Sun, 28 Jun 2026 20:20:17 -0400 Subject: [PATCH 17/20] TAA Phase 3+4 + velocity-drop hysteresis Phase 3 -- YCoCg neighbourhood variance clamp: the reprojected history is forced into the colour box (mean +/- gamma*sigma) of the current frame's 3x3 neighbourhood in YCoCg, crushing the doubling/smearing the reconstructed motion field leaves while preserving the marble-steady smoothing at rest. RYUJINX_TAA_CLAMP tunes the box half-width (default 1.0). Phase 4 -- reduced-resolution optical flow: the colour is bilinear-downsampled to 1/N (default half) and Lucas-Kanade + the 3x3 median run there (~N^2 less work, the dominant cost), then the field is bilinear-upscaled and scaled by N in the blend. The per-frame full-res previous-color copy is gone. RYUJINX_TAA_MV_DOWNSCALE sets the divisor (1/2/4, default 2). New TaaDownsample shader. Validated: 60 FPS at x3 with the Phase 3 quality preserved. Velocity-drop hysteresis -- removes the single-frame flash on a hard stop: a per-pixel smoothed velocity (carried in the history alpha) rises instantly with motion but decays over several frames, and both the clamp width and blend weight are continuous functions of it, so the static 'marble' reasserts gradually instead of snapping. Decay 0.85 for large distant surfaces (waterfalls). All gated on RYUJINX_TAA=1; default render path unchanged when off. --- .../Effects/Shaders/TaaDownsample.glsl | 31 ++++ .../Effects/Shaders/TaaDownsample.spv | Bin 0 -> 1880 bytes .../Effects/Shaders/Temporal.glsl | 114 ++++++++++-- .../Effects/Shaders/Temporal.spv | Bin 3912 -> 9272 bytes .../Effects/TemporalFilter.cs | 173 ++++++++++++------ .../Ryujinx.Graphics.Vulkan.csproj | 1 + 6 files changed, 239 insertions(+), 80 deletions(-) create mode 100644 src/Ryujinx.Graphics.Vulkan/Effects/Shaders/TaaDownsample.glsl create mode 100644 src/Ryujinx.Graphics.Vulkan/Effects/Shaders/TaaDownsample.spv diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/TaaDownsample.glsl b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/TaaDownsample.glsl new file mode 100644 index 000000000..4713174c5 --- /dev/null +++ b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/TaaDownsample.glsl @@ -0,0 +1,31 @@ +// TAA optical-flow pre-pass: bilinear downsample of the colour to the motion-estimation working +// resolution. Running Lucas-Kanade on a half (or quarter) resolution image cuts the per-pixel optical +// flow cost ~4x (or ~16x) with little quality loss -- the reconstructed field is upscaled and scaled by +// the same factor when applied in the TAA blend. + +#version 430 core + +layout (local_size_x = 16, local_size_y = 16) in; + +layout (rgba16f, binding = 0, set = 3) uniform image2D imgOut; // working-resolution colour +layout (binding = 1, set = 2) uniform sampler2D Source; // full-resolution colour + +layout (binding = 2) uniform params { + float outWidth; + float outHeight; +}; + +void main() +{ + ivec2 loc = ivec2(gl_GlobalInvocationID.xy); + + if (loc.x >= int(outWidth) || loc.y >= int(outHeight)) + { + return; + } + + // Normalized coord maps the small output onto the full-res source; the linear sampler averages the + // covered source texels (2x2 at half res), which is the cheap box-ish downsample we want for flow. + vec2 uv = (vec2(loc) + 0.5) / vec2(outWidth, outHeight); + imageStore(imgOut, loc, texture(Source, uv)); +} diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/TaaDownsample.spv b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/TaaDownsample.spv new file mode 100644 index 0000000000000000000000000000000000000000..979a458b89b3ba1c8281463482af60ec611b4625 GIT binary patch literal 1880 zcmZvcTTfF_5QR78E(#(y5yb*3DoDHlq6id4OBKb02YoZO5NJ}UftDvn^V&> zH?8OljY*+3U7YBaa^OBTsyf+ zwM4Eh(cG@Q$ycgxi6M&7W=NaU)((itk#mmP@!6{y((|kcJw1} z-|(wj&)4{fYfAQ~p1fYi8zbWGdVl}&f?_nL*x8Qnm!r}hjQ|p}gzp=KZSzns1{I$oXc}_Ky33 zyfxnO1>igI?yd1Whk^WFI_F}_ePNDoF$>g@|3r=42*`qO^f88Y{3Ito3gkQo{n+1S zeEaj0.5 once the read-side history holds a valid previous frame float motionSign; // +1 (calibrated) maps a pixel back to its previous position (prevPos = loc + mv) float hasMotion; // >0.5 once a previous frame exists to estimate motion from + float clampGamma; // variance-box half-width in std-devs (smaller = tighter = less ghost, more flicker) + float mvScale; // full-res / motion-res ratio: upscales the low-res motion field to full-res pixels }; +// Velocity-drop hysteresis constants (full-res pixel units). Hardcoded tuning -- continuity, not the exact +// values, is what removes the flash. +const float VEL_DECAY = 0.85; // per-frame decay of the smoothed velocity when motion stops (gentle: longer + // ramp, ~5-6 frames of easing -- lets a huge distant surface like a waterfall + // re-align with no visible break) +const float VEL_LOW = 0.10; // below this smoothed velocity the pixel is treated as fully static +const float VEL_HIGH = 1.00; // above this it is treated as fully moving +const float STATIC_CLAMP = 2.00; // clamp box is this much looser when fully static (history trusted = marble) +const float MOVING_BLEND = 0.92; // history weight is scaled by this when fully moving (slightly more reactive) +const float VEL_MAX = 64.0; // clamp the stored velocity so it can never run away + +vec3 RGBToYCoCg(vec3 c) +{ + return vec3( + 0.25 * c.r + 0.5 * c.g + 0.25 * c.b, + 0.5 * c.r - 0.5 * c.b, + -0.25 * c.r + 0.5 * c.g - 0.25 * c.b); +} + +vec3 YCoCgToRGB(vec3 c) +{ + float y = c.x; + float co = c.y; + float cg = c.z; + + return vec3(y + co - cg, y + cg, y - co - cg); +} + void main() { ivec2 loc = ivec2(gl_GlobalInvocationID.xy); - if (loc.x >= int(width) || loc.y >= int(height)) + int iw = int(width); + int ih = int(height); + + if (loc.x >= iw || loc.y >= ih) { return; } vec4 current = texelFetch(Source, loc, 0); + vec3 currentY = RGBToYCoCg(current.rgb); - // This pixel's motion (in render-resolution pixels); reproject the history read by it. - vec2 mv = hasMotion > 0.5 ? texelFetch(Motion, loc, 0).xy : vec2(0.0); + // Build the current 3x3 neighbourhood's YCoCg mean and variance for the clamp box. + vec3 m1 = vec3(0.0); + vec3 m2 = vec3(0.0); + + for (int dy = -1; dy <= 1; ++dy) + { + for (int dx = -1; dx <= 1; ++dx) + { + ivec2 p = clamp(loc + ivec2(dx, dy), ivec2(0), ivec2(iw - 1, ih - 1)); + vec3 c = RGBToYCoCg(texelFetch(Source, p, 0).rgb); + m1 += c; + m2 += c * c; + } + } + + vec3 mean = m1 / 9.0; + vec3 sigma = sqrt(max(vec3(0.0), m2 / 9.0 - mean * mean)); + + // Reproject and read the history (rgb) plus the previous smoothed velocity (alpha). Motion is estimated + // at reduced resolution: bilinear-sample it and scale to full-res pixels. + vec2 locUv = (vec2(loc) + 0.5) / vec2(width, height); + vec2 mv = hasMotion > 0.5 ? texture(Motion, locUv).xy * mvScale : vec2(0.0); vec2 prevPos = vec2(loc) + 0.5 + motionSign * mv; vec2 uv = prevPos / vec2(width, height); bool onScreen = all(greaterThanEqual(uv, vec2(0.0))) && all(lessThanEqual(uv, vec2(1.0))); - vec4 history = texture(HistoryRead, uv); // bilinear: prevPos is sub-pixel + bool valid = hasHistory > 0.5 && onScreen; - // No history yet, or reprojected off-screen (disocclusion): take the current frame, no ghost. - float w = (hasHistory > 0.5 && onScreen) ? blend : 0.0; - vec4 result = mix(current, history, w); + vec4 histSample = texture(HistoryRead, uv); + float prevVSmooth = valid ? histSample.a : 0.0; - imageStore(imgOutput, loc, result); - imageStore(imgHistoryWrite, loc, result); + // Smoothed velocity: jumps up the instant the pixel moves, eases down over several frames when it stops. + float vRaw = length(mv); + float vSmooth = min(max(vRaw, prevVSmooth * VEL_DECAY), VEL_MAX); + float motion = smoothstep(VEL_LOW, VEL_HIGH, vSmooth); + + // Continuous transition guards: clamp loosens and blend rises back to the static "marble" values as the + // smoothed velocity decays, so the stop is amortised instead of snapping in a single frame. + float gamma = mix(clampGamma * STATIC_CLAMP, clampGamma, motion); + float wBlend = mix(blend, blend * MOVING_BLEND, motion); + + vec3 boxMin = mean - gamma * sigma; + vec3 boxMax = mean + gamma * sigma; + vec3 historyY = clamp(RGBToYCoCg(histSample.rgb), boxMin, boxMax); + + float w = valid ? wBlend : 0.0; + vec3 resultY = mix(currentY, historyY, w); + vec3 resultRGB = YCoCgToRGB(resultY); + + // Output carries the real alpha; the history alpha carries the smoothed velocity for next frame. + imageStore(imgOutput, loc, vec4(resultRGB, current.a)); + imageStore(imgHistoryWrite, loc, vec4(resultRGB, vSmooth)); } diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.spv b/src/Ryujinx.Graphics.Vulkan/Effects/Shaders/Temporal.spv index f1eebbd8e831c82b3057557d3f3c66028d0726a1..42a84a8ce26f41d203c12712ac556c699e1762aa 100644 GIT binary patch literal 9272 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 QeJ0o%om0E#E$4av7cx*;y8r+H literal 3912 zcmZ{lX>(Ln5QcAp2_T#7B8mZ2R4|1Ih$0|@Bq&QHDkv%plOY+M%!FA8C?dEZDxxCp zKR`d}cgyl$SuRyp`8@aD)>|n&Rqu57+kN`<>2uDVY2B-*Cp}%stYk*=ZIX@oNq5qP zIxFc-<-oq-ef_QWNdLx7>y4P3OiKfenU~B?dXP7P@lv&}V-C0tYzBKk6I=z?!7b!8 zc7CdBC+Ql~$k~nlBT0LRDCXP3>8g6u&kX;gUnKQtj@&0 zoM#|sFC)%5ax)5?XCODbz&S_GI}~&6z1$*TP4;~yFlX4S&>hQBD|tbIS9>Fy8!f<}}_On@fIcZhN+oe=fT5=3m6`0_55}??QBkc|3!t zWeM4rrhJmDpd#;s_Ko0N_iKLRtU=qelyfcjw;VW(_s;1vU(|9Jx;01qn%s8Je#ngV z4mtlpq_xT~<7@K}Qor+yBkbkuXCn^doWCg#lCxfI@0P>$wx%+x(fG%(T~9vh+m7x% z({}BhfUzU;dG{RQ-=CSuI%b*Y0FV=r^Evd`V;S8wJuhoInd{M0*Q|jgxv(R0j^~_w z)Zra>xDP%(X-?s>W?}5*bxxI~g-Qzqk2OK1e@7Vpz2}c(= zN33hi2l}_ObN8velimBS7X!J4z}&vaB|zR7-{T#?H@h4d(Fra@(2*L9yn&*v3T7Poc{h7d3l7{iZz)^b1E9a6h|{`aA>c zvIc7j-+?JUW311d2Z8gA^?hpVv({&TwKnqDLpe7@Pn zH)aipnB(a3vCk5^^Nxedd3`6)T~D9297W1SEfsXRUx`1790hWo#dq}6w=o9fy-%+q z9o{GTDvvDQ;+rEMtA*-Al82eUEX@nBem_<1*G%M6F&D<^cfR*-a~i3 zv6Dz`ee03$gAE1yGP=EqxgVfAXDILIhv;%e%;ulfTpxih&=1UOF5_MI5>mf;uOL4G wS95zh@>4J~x6Sn#a88+cWA)29{~GcOa6Px3|0O8S{|Y?T=Z}t}O50n%t6aWAK diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/TemporalFilter.cs b/src/Ryujinx.Graphics.Vulkan/Effects/TemporalFilter.cs index 6aa291853..9260b3dff 100644 --- a/src/Ryujinx.Graphics.Vulkan/Effects/TemporalFilter.cs +++ b/src/Ryujinx.Graphics.Vulkan/Effects/TemporalFilter.cs @@ -14,16 +14,16 @@ namespace Ryujinx.Graphics.Vulkan.Effects /// /// Clean-room native Temporal Anti-Aliasing. /// - /// Phase 2: fixed-weight temporal accumulation with motion-vector reprojection. Each frame it - /// reconstructs a motion field from the current and previous color (the same Lucas-Kanade pass + 3x3 - /// median filter used by the DLSS path -- self-contained, so TAA works with DLSS off), then blends the - /// current frame with the history sampled at the reprojected position (prevPos = loc - mv). Static - /// content stays rock-steady and the Phase 1 ghosting collapses while the camera moves. Neighborhood - /// clamping for the remaining ghosting/fireflies is Phase 3. + /// Phase 4: the Phase 3 pipeline (motion-vector reprojection + YCoCg neighbourhood variance clamp) with + /// the optical flow moved to REDUCED resolution for performance. Each frame the colour is bilinear- + /// downsampled to 1/N (default half) and the Lucas-Kanade + 3x3 median passes run there (~N^2 less work, + /// the dominant cost), then the field is bilinear-upscaled and scaled by N when the blend reprojects the + /// history. Motion estimation is self-contained (reuses the DLSS shaders) so TAA works with DLSS off. /// /// Gated entirely on RYUJINX_TAA=1: when unset the present path never constructs or runs this, so the - /// default render is byte-identical. RYUJINX_TAA_BLEND overrides the history weight (default 0.90); - /// RYUJINX_TAA_MV_SIGN flips the reprojection direction (default +1) for sign calibration. + /// default render is byte-identical. Tunables (no rebuild): RYUJINX_TAA_BLEND (history weight, default + /// 0.90), RYUJINX_TAA_MV_SIGN (reprojection direction, default +1), RYUJINX_TAA_CLAMP (variance-box + /// half-width in std-devs, default 1.0), RYUJINX_TAA_MV_DOWNSCALE (flow resolution divisor 1/2/4, default 2). /// internal class TemporalFilter : IPostProcessingEffect { @@ -31,17 +31,20 @@ namespace Ryujinx.Graphics.Vulkan.Effects public static readonly bool IsEnabled = Environment.GetEnvironmentVariable("RYUJINX_TAA") is "1" or "true" or "TRUE" or "True"; - private const float MaxMotion = 32f; // motion-vector clamp, in render-resolution pixels + private const float MaxMotion = 32f; // motion-vector clamp, in motion-resolution pixels private const int CounterCount = 9; // uints in the motion-pass SSBO (unused by TAA, required by the shader) private static readonly float HistoryBlend = ParseBlend(); private static readonly float MotionSign = ParseSign(); + private static readonly float ClampGamma = ParseClamp(); + private static readonly int MotionDownscale = ParseDownscale(); private readonly VulkanRenderer _renderer; private readonly PipelineHelperShader _pipeline; // accumulation/blend pass - private readonly PipelineHelperShader _motionPipeline; // motion estimate + median filter passes + private readonly PipelineHelperShader _motionPipeline; // downsample + motion estimate + median filter private ISampler _sampler; private ShaderCollection _program; + private ShaderCollection _downsampleProgram; private ShaderCollection _motionProgram; private ShaderCollection _motionFilterProgram; private BufferHandle _sceneChangeBuffer; @@ -55,11 +58,17 @@ namespace Ryujinx.Graphics.Vulkan.Effects private bool _readFromHistory0; private bool _hasHistory; - // Optical-flow inputs/outputs: previous color (input format), raw and median-filtered motion (rg16f). - private TextureView _prevColor; + // Reduced-resolution optical flow: ping-pong working-res colour (current/previous), raw and median- + // filtered motion (rg16f, in motion-resolution pixels). _mvScale lifts the field back to full-res pixels. + private TextureView _colorHalf0; + private TextureView _colorHalf1; private TextureView _motion; private TextureView _motionFiltered; + private bool _readColorHalf0; private bool _hasPrev; + private int _motionWidth; + private int _motionHeight; + private float _mvScale; private bool _activeLogged; @@ -92,6 +101,33 @@ namespace Ryujinx.Graphics.Vulkan.Effects return Environment.GetEnvironmentVariable("RYUJINX_TAA_MV_SIGN") is "-1" ? -1f : 1f; } + private static float ParseClamp() + { + // Variance-box half-width in std-devs: smaller crushes more ghosting (but flickers more), larger + // smooths more (but lets more ghost through). 1.0 is the standard starting point. + string value = Environment.GetEnvironmentVariable("RYUJINX_TAA_CLAMP"); + + if (float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out float gamma) && + gamma >= 0.25f && gamma <= 8f) + { + return gamma; + } + + return 1.0f; + } + + private static int ParseDownscale() + { + // Optical-flow resolution divisor. 2 (half res) is the sweet spot: ~4x less flow work, little + // quality loss. 4 is faster/rougher; 1 disables the optimization (full-res flow, old behavior). + return Environment.GetEnvironmentVariable("RYUJINX_TAA_MV_DOWNSCALE") switch + { + "1" => 1, + "4" => 4, + _ => 2, + }; + } + private void Initialize() { _pipeline.Initialize(); @@ -114,6 +150,17 @@ namespace Ryujinx.Graphics.Vulkan.Effects new ShaderSource(blendShader, ShaderStage.Compute, TargetLanguage.Spirv) ], blendLayout); + // Colour downsample to the flow working resolution: source (b1), params (b2), output image (b0/set3). + byte[] downsampleShader = EmbeddedResources.Read("Ryujinx.Graphics.Vulkan/Effects/Shaders/TaaDownsample.spv"); + ResourceLayout downsampleLayout = new ResourceLayoutBuilder() + .Add(ResourceStages.Compute, ResourceType.UniformBuffer, 2) + .Add(ResourceStages.Compute, ResourceType.TextureAndSampler, 1) + .Add(ResourceStages.Compute, ResourceType.Image, 0, true).Build(); + + _downsampleProgram = _renderer.CreateProgramWithMinimalLayout([ + new ShaderSource(downsampleShader, ShaderStage.Compute, TargetLanguage.Spirv) + ], downsampleLayout); + // Motion estimate (Lucas-Kanade): current (b1), previous (b3), params (b2), stats SSBO (b0/set1), // motion image (b0/set3). Reused verbatim from the DLSS path. byte[] motionShader = EmbeddedResources.Read("Ryujinx.Graphics.Vulkan/Effects/Shaders/MotionVectors.spv"); @@ -173,20 +220,27 @@ namespace Ryujinx.Graphics.Vulkan.Effects _output?.Dispose(); _history0?.Dispose(); _history1?.Dispose(); - _prevColor?.Dispose(); + _colorHalf0?.Dispose(); + _colorHalf1?.Dispose(); _motion?.Dispose(); _motionFiltered?.Dispose(); + _motionWidth = Math.Max(1, view.Width / MotionDownscale); + _motionHeight = Math.Max(1, view.Height / MotionDownscale); + _mvScale = (float)view.Width / _motionWidth; + _output = _renderer.CreateTexture(view.Info) as TextureView; _history0 = _renderer.CreateTexture(MakeInfo(view.Info, view.Width, view.Height, Format.R16G16B16A16Float, 8)) as TextureView; _history1 = _renderer.CreateTexture(MakeInfo(view.Info, view.Width, view.Height, Format.R16G16B16A16Float, 8)) as TextureView; - _prevColor = _renderer.CreateTexture(MakeInfo(view.Info, view.Width, view.Height, view.Info.Format, view.Info.BytesPerPixel)) as TextureView; - _motion = _renderer.CreateTexture(MakeInfo(view.Info, view.Width, view.Height, Format.R16G16Float, 4)) as TextureView; - _motionFiltered = _renderer.CreateTexture(MakeInfo(view.Info, view.Width, view.Height, Format.R16G16Float, 4)) as TextureView; + _colorHalf0 = _renderer.CreateTexture(MakeInfo(view.Info, _motionWidth, _motionHeight, Format.R16G16B16A16Float, 8)) as TextureView; + _colorHalf1 = _renderer.CreateTexture(MakeInfo(view.Info, _motionWidth, _motionHeight, Format.R16G16B16A16Float, 8)) as TextureView; + _motion = _renderer.CreateTexture(MakeInfo(view.Info, _motionWidth, _motionHeight, Format.R16G16Float, 4)) as TextureView; + _motionFiltered = _renderer.CreateTexture(MakeInfo(view.Info, _motionWidth, _motionHeight, Format.R16G16Float, 4)) as TextureView; // Fresh resources: nothing to blend or estimate motion against yet. _readFromHistory0 = true; _hasHistory = false; + _readColorHalf0 = false; _hasPrev = false; } @@ -198,14 +252,20 @@ namespace Ryujinx.Graphics.Vulkan.Effects { _activeLogged = true; Logger.Info?.Print(LogClass.Gpu, - $"TAA: active (reprojection, history blend {HistoryBlend:0.00}, mv sign {MotionSign:+0;-0})."); + $"TAA: active (YCoCg clamp, blend {HistoryBlend:0.00}, mv sign {MotionSign:+0;-0}, clamp {ClampGamma:0.00}, " + + $"flow 1/{MotionDownscale} = {_motionWidth}x{_motionHeight})."); } - // Reconstruct this frame's motion field from (current, previous), then median-filter it. + // Downsample this frame's colour to the flow working resolution (ping-pong with last frame's). + TextureView curHalf = _readColorHalf0 ? _colorHalf1 : _colorHalf0; + TextureView prevHalf = _readColorHalf0 ? _colorHalf0 : _colorHalf1; + RunDownsamplePass(view, curHalf, cbs); + + // Reconstruct the motion field at reduced resolution, then median-filter it. if (_hasPrev) { - RunMotionPass(view, cbs); - RunMotionFilterPass(view, cbs); + RunMotionPass(curHalf, prevHalf, cbs); + RunMotionFilterPass(cbs); } // Ping-pong: read last frame's result, write this frame's result to the other target. @@ -226,6 +286,8 @@ namespace Ryujinx.Graphics.Vulkan.Effects _hasHistory ? 1f : 0f, MotionSign, _hasPrev ? 1f : 0f, + ClampGamma, + _mvScale, ]; int rangeSize = paramsBuffer.Length * sizeof(float); using ScopedTemporaryBuffer buffer = _renderer.BufferManager.ReserveOrCreate(_renderer, cbs, rangeSize); @@ -242,27 +304,43 @@ namespace Ryujinx.Graphics.Vulkan.Effects _pipeline.Finish(); - // Keep this frame's color as the "previous" frame for next time's motion estimate. - CopyToPrev(view, cbs); - - // Next frame reads the target we just wrote and has both a history and a previous frame. + // Next frame reads what we just wrote and has both a history and a previous downsampled frame. _readFromHistory0 = !_readFromHistory0; + _readColorHalf0 = !_readColorHalf0; _hasHistory = true; _hasPrev = true; return _output; } - private void RunMotionPass(TextureView input, CommandBufferScoped cbs) + private void RunDownsamplePass(TextureView source, TextureView dst, CommandBufferScoped cbs) + { + _motionPipeline.SetCommandBuffer(cbs); + _motionPipeline.SetProgram(_downsampleProgram); + _motionPipeline.SetTextureAndSampler(ShaderStage.Compute, 1, source, _sampler); + + ReadOnlySpan p = [dst.Width, dst.Height]; + using ScopedTemporaryBuffer buffer = _renderer.BufferManager.ReserveOrCreate(_renderer, cbs, p.Length * sizeof(float)); + buffer.Holder.SetDataUnchecked(buffer.Offset, p); + + _motionPipeline.SetUniformBuffers([new BufferAssignment(2, buffer.Range)]); + _motionPipeline.SetImage(0, dst.GetImageView()); + + _motionPipeline.DispatchCompute(BitUtils.DivRoundUp(dst.Width, 16), BitUtils.DivRoundUp(dst.Height, 16), 1); + _motionPipeline.ComputeBarrier(); + _motionPipeline.Finish(); + } + + private void RunMotionPass(TextureView current, TextureView previous, CommandBufferScoped cbs) { _motionPipeline.SetCommandBuffer(cbs); _motionPipeline.SetProgram(_motionProgram); - _motionPipeline.SetTextureAndSampler(ShaderStage.Compute, 1, input, _sampler); - _motionPipeline.SetTextureAndSampler(ShaderStage.Compute, 3, _prevColor, _sampler); + _motionPipeline.SetTextureAndSampler(ShaderStage.Compute, 1, current, _sampler); + _motionPipeline.SetTextureAndSampler(ShaderStage.Compute, 3, previous, _sampler); // width/height/maxMotion/metrics + dejitterX/Y + 2 pad. No clip-space jitter on the TAA input // (that is a DLSS-only feature), so de-jitter is zero; metrics off. - ReadOnlySpan p = [input.Width, input.Height, MaxMotion, 0f, 0f, 0f, 0f, 0f]; + ReadOnlySpan p = [_motionWidth, _motionHeight, MaxMotion, 0f, 0f, 0f, 0f, 0f]; using ScopedTemporaryBuffer buffer = _renderer.BufferManager.ReserveOrCreate(_renderer, cbs, p.Length * sizeof(float)); buffer.Holder.SetDataUnchecked(buffer.Offset, p); @@ -270,17 +348,17 @@ namespace Ryujinx.Graphics.Vulkan.Effects _motionPipeline.SetStorageBuffers([new BufferAssignment(0, new BufferRange(_sceneChangeBuffer, 0, CounterCount * sizeof(uint), true))]); _motionPipeline.SetImage(0, _motion.GetImageView()); - _motionPipeline.DispatchCompute(BitUtils.DivRoundUp(input.Width, 16), BitUtils.DivRoundUp(input.Height, 16), 1); + _motionPipeline.DispatchCompute(BitUtils.DivRoundUp(_motionWidth, 16), BitUtils.DivRoundUp(_motionHeight, 16), 1); _motionPipeline.ComputeBarrier(); _motionPipeline.Finish(); } - private void RunMotionFilterPass(TextureView input, CommandBufferScoped cbs) + private void RunMotionFilterPass(CommandBufferScoped cbs) { _motionPipeline.SetCommandBuffer(cbs); _motionPipeline.SetProgram(_motionFilterProgram); - ReadOnlySpan p = [input.Width, input.Height, MaxMotion, 0f, 0f, 0f, 0f, 0f]; + ReadOnlySpan p = [_motionWidth, _motionHeight, MaxMotion, 0f, 0f, 0f, 0f, 0f]; using ScopedTemporaryBuffer buffer = _renderer.BufferManager.ReserveOrCreate(_renderer, cbs, p.Length * sizeof(float)); buffer.Holder.SetDataUnchecked(buffer.Offset, p); @@ -289,50 +367,25 @@ namespace Ryujinx.Graphics.Vulkan.Effects _motionPipeline.SetImage(0, _motionFiltered.GetImageView()); _motionPipeline.SetImage(1, _motion.GetImageView()); - _motionPipeline.DispatchCompute(BitUtils.DivRoundUp(input.Width, 16), BitUtils.DivRoundUp(input.Height, 16), 1); + _motionPipeline.DispatchCompute(BitUtils.DivRoundUp(_motionWidth, 16), BitUtils.DivRoundUp(_motionHeight, 16), 1); _motionPipeline.ComputeBarrier(); _motionPipeline.Finish(); } - private void CopyToPrev(TextureView input, CommandBufferScoped cbs) - { - ImageSubresourceLayers layers = new() - { - AspectMask = ImageAspectFlags.ColorBit, - MipLevel = 0, - BaseArrayLayer = 0, - LayerCount = 1, - }; - - ImageCopy region = new() - { - SrcSubresource = layers, - DstSubresource = layers, - Extent = new Extent3D((uint)input.Width, (uint)input.Height, 1), - }; - - _renderer.Api.CmdCopyImage( - cbs.CommandBuffer, - input.GetImage().Get(cbs).Value, - ImageLayout.General, - _prevColor.GetImage().Get(cbs).Value, - ImageLayout.General, - 1, - in region); - } - public void Dispose() { _pipeline.Dispose(); _motionPipeline.Dispose(); _program.Dispose(); + _downsampleProgram.Dispose(); _motionProgram.Dispose(); _motionFilterProgram.Dispose(); _sampler.Dispose(); _output?.Dispose(); _history0?.Dispose(); _history1?.Dispose(); - _prevColor?.Dispose(); + _colorHalf0?.Dispose(); + _colorHalf1?.Dispose(); _motion?.Dispose(); _motionFiltered?.Dispose(); _renderer.BufferManager.Delete(_sceneChangeBuffer); diff --git a/src/Ryujinx.Graphics.Vulkan/Ryujinx.Graphics.Vulkan.csproj b/src/Ryujinx.Graphics.Vulkan/Ryujinx.Graphics.Vulkan.csproj index 2b5fe917d..964d8fd68 100644 --- a/src/Ryujinx.Graphics.Vulkan/Ryujinx.Graphics.Vulkan.csproj +++ b/src/Ryujinx.Graphics.Vulkan/Ryujinx.Graphics.Vulkan.csproj @@ -25,6 +25,7 @@ + From caea82cbb4ce396144fe57ff3e8b4c2b4d3c5d8b Mon Sep 17 00:00:00 2001 From: The Roofer Dev Date: Sun, 28 Jun 2026 21:41:25 -0400 Subject: [PATCH 18/20] TAA Phase 5: expose native TAA in the UI (Anti-Aliasing dropdown) Adds AntiAliasing.Taa and a 'TAA' entry to Settings > Graphics > Anti-Aliasing, so the native temporal AA is a normal user-selectable option alongside FXAA/SMAA (it is a post-process effect, not a scaling filter). Selecting it instantiates TemporalFilter as the active IPostProcessingEffect; applies live, no restart. RYUJINX_TAA=1 still force-enables it as a dev override. On the OpenGL backend TAA falls back to no AA (Vulkan-only feature). Validated in game via the UI. Bakes the in-game-validated defaults so UI users get the tuned look with no env vars: history blend 0.80, variance clamp 1.25 (RYUJINX_TAA_* still override). --- .../Configuration/AntiAliasing.cs | 1 + src/Ryujinx.Graphics.OpenGL/Window.cs | 1 + .../Effects/TemporalFilter.cs | 12 +++++----- src/Ryujinx.Graphics.Vulkan/Window.cs | 22 +++++++++---------- .../Views/Settings/SettingsGraphicsView.axaml | 2 ++ 5 files changed, 21 insertions(+), 17 deletions(-) diff --git a/src/Ryujinx.Common/Configuration/AntiAliasing.cs b/src/Ryujinx.Common/Configuration/AntiAliasing.cs index 1510c23ee..f6cdc39f9 100644 --- a/src/Ryujinx.Common/Configuration/AntiAliasing.cs +++ b/src/Ryujinx.Common/Configuration/AntiAliasing.cs @@ -11,5 +11,6 @@ namespace Ryujinx.Common.Configuration SmaaMedium, SmaaHigh, SmaaUltra, + Taa, } } diff --git a/src/Ryujinx.Graphics.OpenGL/Window.cs b/src/Ryujinx.Graphics.OpenGL/Window.cs index 281fae7e7..c597e2d30 100644 --- a/src/Ryujinx.Graphics.OpenGL/Window.cs +++ b/src/Ryujinx.Graphics.OpenGL/Window.cs @@ -324,6 +324,7 @@ namespace Ryujinx.Graphics.OpenGL _antiAliasing = new FxaaPostProcessingEffect(_renderer); break; case AntiAliasing.None: + case AntiAliasing.Taa: // TAA is a Vulkan-only effect; on the GL backend it falls back to no AA. _antiAliasing?.Dispose(); _antiAliasing = null; break; diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/TemporalFilter.cs b/src/Ryujinx.Graphics.Vulkan/Effects/TemporalFilter.cs index 9260b3dff..6d577a322 100644 --- a/src/Ryujinx.Graphics.Vulkan/Effects/TemporalFilter.cs +++ b/src/Ryujinx.Graphics.Vulkan/Effects/TemporalFilter.cs @@ -20,10 +20,10 @@ namespace Ryujinx.Graphics.Vulkan.Effects /// the dominant cost), then the field is bilinear-upscaled and scaled by N when the blend reprojects the /// history. Motion estimation is self-contained (reuses the DLSS shaders) so TAA works with DLSS off. /// - /// Gated entirely on RYUJINX_TAA=1: when unset the present path never constructs or runs this, so the - /// default render is byte-identical. Tunables (no rebuild): RYUJINX_TAA_BLEND (history weight, default - /// 0.90), RYUJINX_TAA_MV_SIGN (reprojection direction, default +1), RYUJINX_TAA_CLAMP (variance-box - /// half-width in std-devs, default 1.0), RYUJINX_TAA_MV_DOWNSCALE (flow resolution divisor 1/2/4, default 2). + /// Selected from the UI as an Anti-Aliasing option (AntiAliasing.Taa); RYUJINX_TAA=1 also force-enables it + /// (dev override). Tunables (no rebuild): RYUJINX_TAA_BLEND (history weight, default 0.80), RYUJINX_TAA_MV_SIGN + /// (reprojection direction, default +1), RYUJINX_TAA_CLAMP (variance-box half-width in std-devs, default 1.25), + /// RYUJINX_TAA_MV_DOWNSCALE (flow resolution divisor 1/2/4, default 2). /// internal class TemporalFilter : IPostProcessingEffect { @@ -91,7 +91,7 @@ namespace Ryujinx.Graphics.Vulkan.Effects return blend; } - return 0.90f; + return 0.80f; // validated in-game default (sharp + steady); RYUJINX_TAA_BLEND overrides. } private static float ParseSign() @@ -113,7 +113,7 @@ namespace Ryujinx.Graphics.Vulkan.Effects return gamma; } - return 1.0f; + return 1.25f; // validated in-game default (lets native texture detail through); RYUJINX_TAA_CLAMP overrides. } private static int ParseDownscale() diff --git a/src/Ryujinx.Graphics.Vulkan/Window.cs b/src/Ryujinx.Graphics.Vulkan/Window.cs index 0466bf87a..264528ad2 100644 --- a/src/Ryujinx.Graphics.Vulkan/Window.cs +++ b/src/Ryujinx.Graphics.Vulkan/Window.cs @@ -37,7 +37,6 @@ namespace Ryujinx.Graphics.Vulkan private bool _updateEffect; private IPostProcessingEffect _effect; private IScalingFilter _scalingFilter; - private Effects.TemporalFilter _taa; private Dlss.DlssUpscaler _dlss; private bool _isLinear; private float _scalingFilterLevel; @@ -395,14 +394,6 @@ namespace Ryujinx.Graphics.Vulkan view = _effect.Run(view, cbs, _width, _height); } - // Native temporal anti-aliasing (clean-room), gated on RYUJINX_TAA=1. Runs at render resolution - // before the spatial upscale/blit, like a post-processing effect. Phase 0 is a pass-through. - if (Effects.TemporalFilter.IsEnabled) - { - _taa ??= new Effects.TemporalFilter(_gd, _device); - view = _taa.Run(view, cbs, _width, _height); - } - int srcX0, srcX1, srcY0, srcY1; if (crop.Left == 0 && crop.Right == 0) @@ -615,12 +606,22 @@ namespace Ryujinx.Graphics.Vulkan { _updateEffect = false; - switch (_currentAntiAliasing) + // RYUJINX_TAA=1 forces the native temporal filter regardless of the UI selection (dev override). + AntiAliasing antiAliasing = Effects.TemporalFilter.IsEnabled ? AntiAliasing.Taa : _currentAntiAliasing; + + switch (antiAliasing) { case AntiAliasing.Fxaa: _effect?.Dispose(); _effect = new FxaaPostProcessingEffect(_gd, _device); break; + case AntiAliasing.Taa: + if (_effect is not Effects.TemporalFilter) + { + _effect?.Dispose(); + _effect = new Effects.TemporalFilter(_gd, _device); + } + break; case AntiAliasing.None: _effect?.Dispose(); _effect = null; @@ -791,7 +792,6 @@ namespace Ryujinx.Graphics.Vulkan _effect?.Dispose(); _scalingFilter?.Dispose(); - _taa?.Dispose(); _dlss?.Dispose(); } } diff --git a/src/Ryujinx/UI/Views/Settings/SettingsGraphicsView.axaml b/src/Ryujinx/UI/Views/Settings/SettingsGraphicsView.axaml index daf7a08dd..7b3c471cf 100644 --- a/src/Ryujinx/UI/Views/Settings/SettingsGraphicsView.axaml +++ b/src/Ryujinx/UI/Views/Settings/SettingsGraphicsView.axaml @@ -290,6 +290,8 @@ Content="{ext:Locale SmaaHigh}" /> + From d5e0a1ce0277857ebe63a1313b800a43156440a5 Mon Sep 17 00:00:00 2001 From: The Roofer Dev Date: Sun, 28 Jun 2026 22:57:15 -0400 Subject: [PATCH 19/20] TAA: default history blend 0.935 (soft cinematic, low flicker) Validated in game: a high history weight gives the soft, low-flicker cinematic look (matches the user's HDR/filmic preference) and also reduces flicker for everyone, especially at low resolution scales on weaker GPUs. RYUJINX_TAA_BLEND still overrides for tuning. --- src/Ryujinx.Graphics.Vulkan/Effects/TemporalFilter.cs | 8 ++++---- src/Ryujinx/UI/Views/Settings/SettingsGraphicsView.axaml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/TemporalFilter.cs b/src/Ryujinx.Graphics.Vulkan/Effects/TemporalFilter.cs index 6d577a322..eb6bea058 100644 --- a/src/Ryujinx.Graphics.Vulkan/Effects/TemporalFilter.cs +++ b/src/Ryujinx.Graphics.Vulkan/Effects/TemporalFilter.cs @@ -21,9 +21,9 @@ namespace Ryujinx.Graphics.Vulkan.Effects /// history. Motion estimation is self-contained (reuses the DLSS shaders) so TAA works with DLSS off. /// /// Selected from the UI as an Anti-Aliasing option (AntiAliasing.Taa); RYUJINX_TAA=1 also force-enables it - /// (dev override). Tunables (no rebuild): RYUJINX_TAA_BLEND (history weight, default 0.80), RYUJINX_TAA_MV_SIGN - /// (reprojection direction, default +1), RYUJINX_TAA_CLAMP (variance-box half-width in std-devs, default 1.25), - /// RYUJINX_TAA_MV_DOWNSCALE (flow resolution divisor 1/2/4, default 2). + /// (dev override). Tunables (no rebuild): RYUJINX_TAA_BLEND (history weight, default 0.935 = soft/cinematic, + /// low flicker), RYUJINX_TAA_MV_SIGN (reprojection direction, default +1), RYUJINX_TAA_CLAMP (variance-box + /// half-width in std-devs, default 1.25), RYUJINX_TAA_MV_DOWNSCALE (flow resolution divisor 1/2/4, default 2). /// internal class TemporalFilter : IPostProcessingEffect { @@ -91,7 +91,7 @@ namespace Ryujinx.Graphics.Vulkan.Effects return blend; } - return 0.80f; // validated in-game default (sharp + steady); RYUJINX_TAA_BLEND overrides. + return 0.935f; // validated in-game default (soft, cinematic, very low flicker); RYUJINX_TAA_BLEND overrides. } private static float ParseSign() diff --git a/src/Ryujinx/UI/Views/Settings/SettingsGraphicsView.axaml b/src/Ryujinx/UI/Views/Settings/SettingsGraphicsView.axaml index 7b3c471cf..18ed5c601 100644 --- a/src/Ryujinx/UI/Views/Settings/SettingsGraphicsView.axaml +++ b/src/Ryujinx/UI/Views/Settings/SettingsGraphicsView.axaml @@ -344,7 +344,7 @@ Date: Sun, 28 Jun 2026 23:37:57 -0400 Subject: [PATCH 20/20] Credits, licensing and bilingual docs (TAA + DLSS BYO) - MIT/SPDX copyright headers on the original integration files (The Roofer Dev), stating they are integration code only; DLSS/DLAA/NIS remain NVIDIA tech. - README: bilingual (EN/FR) section for native TAA and for enabling DLSS via a user-supplied 'dlss' folder (no proprietary NVIDIA file is bundled; nvngx is auto-located on the user's machine). Bilingual credits + trademark disclaimer. - THIRDPARTY: MIT notices for NVIDIA NIS and NVIDIA Streamline. --- README.md | 33 +++++++++++ distribution/legal/THIRDPARTY.md | 58 +++++++++++++++++++ .../Dlss/DlssBinaries.cs | 4 ++ .../Dlss/DlssIntegration.cs | 4 ++ .../Dlss/DlssJitter.cs | 4 ++ .../Dlss/DlssUpscaler.cs | 4 ++ .../Dlss/NvOpticalFlow.cs | 4 ++ .../Dlss/Streamline.cs | 4 ++ .../Dlss/StreamlineDlss.cs | 4 ++ .../Effects/NisScalingFilter.cs | 4 ++ .../Effects/TemporalFilter.cs | 4 ++ src/Ryujinx/Systems/DlssRestart.cs | 4 ++ src/Ryujinx/Systems/DlssUiSettings.cs | 4 ++ 13 files changed, 135 insertions(+) diff --git a/README.md b/README.md index e50a606e8..7e0447843 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,32 @@ If you are planning to contribute or just want to learn more about this project The emulator has settings for enabling or disabling some logging, remapping controllers, and more. You can configure all of them through the graphical interface or manually through the config file, `Config.json`, found in the Ryujinx data folder which can be accessed by clicking `Open Ryujinx Folder` under the File menu in the GUI. +## Beast Roofer Edition — graphics extras / extras graphiques + +### 🇬🇧 Native TAA (Temporal Anti-Aliasing) +Available in **Settings > Graphics > Anti-Aliasing > TAA**. Soft, cinematic, low-flicker image; works on **all GPUs** with no extra files. It looks best at higher internal resolution scales; at low resolution it may flicker a little more. + +### 🇫🇷 TAA natif (anti-crénelage temporel) +Disponible dans **Paramètres > Graphismes > Anti-crénelage > TAA**. Rendu doux, cinématographique, peu de scintillement ; fonctionne sur **toutes les cartes graphiques**, aucun fichier à ajouter. Meilleur en haute résolution interne ; peut scintiller un peu plus en basse résolution. + +### 🇬🇧 NVIDIA DLSS / DLAA (experimental, RTX only, opt-in) +DLSS/DLAA is **off by default** and **experimental**. No NVIDIA files are bundled (legal reasons). To enable it: +1. Create a folder named **`dlss`** next to **`Ryujinx.exe`**. +2. Put the **NVIDIA Streamline** files in it: `sl.interposer.dll`, `sl.common.dll`, `sl.dlss.dll` (MIT-licensed; from the public Streamline SDK). +3. The DLSS runtime (`nvngx_dlss.dll`) is **located automatically** from your own installed games/driver. If it isn't found, drop your own copy into the same `dlss` folder. +4. In **Settings > Graphics**, set the **Scaling Filter** to **DLSS** and pick a mode (**DLAA** is the most stable). Applies on the next launch (the app restarts). + +⚠️ Experimental: may be unstable depending on the game/GPU. With no `dlss` folder or no DLSS file present, DLSS simply stays unavailable (no crash). Requires a recent NVIDIA RTX GPU. + +### 🇫🇷 NVIDIA DLSS / DLAA (expérimental, RTX uniquement, optionnel) +DLSS/DLAA est **désactivé par défaut** et **expérimental**. Aucun fichier NVIDIA n'est inclus (raisons légales). Pour l'activer : +1. Crée un dossier nommé **`dlss`** à côté de **`Ryujinx.exe`**. +2. Mets-y les fichiers **NVIDIA Streamline** : `sl.interposer.dll`, `sl.common.dll`, `sl.dlss.dll` (sous licence MIT ; du SDK Streamline public). +3. Le runtime DLSS (`nvngx_dlss.dll`) est **localisé automatiquement** depuis tes propres jeux/pilote installés. S'il n'est pas trouvé, dépose ta propre copie dans le même dossier `dlss`. +4. Dans **Paramètres > Graphismes**, mets le **Filtre de mise à l'échelle** sur **DLSS** et choisis un mode (**DLAA** = le plus stable). S'applique au prochain lancement (l'app redémarre). + +⚠️ Expérimental : peut être instable selon le jeu/la carte. Sans dossier `dlss` ou sans fichier DLSS, DLSS reste simplement indisponible (pas de plantage). Nécessite une carte NVIDIA RTX récente. + ## License This software is licensed under the terms of the [MIT license](https://git.ryujinx.app/projects/Ryubing/src/branch/master/LICENSE.txt). @@ -136,3 +162,10 @@ See [LICENSE.txt](https://git.ryujinx.app/projects/Ryubing/src/branch/master/LIC - [AmiiboAPI](https://www.amiiboapi.com) is used in our Amiibo emulation. - [ldn_mitm](https://github.com/spacemeowx2/ldn_mitm) is used for one of our available multiplayer modes. - [ShellLink](https://github.com/securifybv/ShellLink) is used for Windows shortcut generation. + +### Beast Roofer Edition + +- **Original code in this fork** by **The Roofer Dev** (© 2026), MIT-licensed: native TAA, clean-room NVIDIA DLSS/DLAA/NIS integration, native scRGB HDR, per-game VRR cap. *Code/integration only — DLSS, DLAA and NIS technologies belong to NVIDIA.* +- **🇫🇷 Code original de ce fork** par **The Roofer Dev** (© 2026), sous licence MIT : intégration du TAA natif, intégration clean-room de NVIDIA DLSS/DLAA/NIS, HDR scRGB natif, cap VRR par jeu. *Code/intégration uniquement — les technologies DLSS, DLAA et NIS appartiennent à NVIDIA.* +- This fork bundles **no proprietary NVIDIA files**. [NVIDIA Streamline](https://github.com/NVIDIA-RTX/Streamline) and NVIDIA Image Scaling (NIS) are MIT-licensed (© NVIDIA). The DLSS runtime (`nvngx`) is proprietary and **never bundled** — it is located on the user's own machine. +- NVIDIA, DLSS, DLAA and NIS are trademarks of NVIDIA Corporation. This is an independent project, **not affiliated with or endorsed by NVIDIA**. diff --git a/distribution/legal/THIRDPARTY.md b/distribution/legal/THIRDPARTY.md index 5caa03771..5277ac3b9 100644 --- a/distribution/legal/THIRDPARTY.md +++ b/distribution/legal/THIRDPARTY.md @@ -711,3 +711,61 @@ SOFTWARE. ``` + +# NVIDIA Image Scaling - NIS (MIT) +
+ See License + + ``` + The MIT License (MIT) + + Copyright (c) 2022 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + ``` +
+ +# NVIDIA Streamline (MIT) +
+ See License + + ``` + The MIT License (MIT) + + Copyright (c) 2022 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + ``` +
diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssBinaries.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssBinaries.cs index 4c18de96c..b4f2ef05f 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssBinaries.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssBinaries.cs @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). DLSS, DLAA and NIS are NVIDIA technologies; this is integration code only. + using System; using System.Collections.Generic; using System.Diagnostics; diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssIntegration.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssIntegration.cs index 0d2666e5a..cf2717902 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssIntegration.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssIntegration.cs @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). DLSS, DLAA and NIS are NVIDIA technologies; this is integration code only. + using Ryujinx.Common.Logging; using System; diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssJitter.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssJitter.cs index f4b8f77aa..76d9b955d 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssJitter.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssJitter.cs @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). DLSS, DLAA and NIS are NVIDIA technologies; this is integration code only. + using Ryujinx.Graphics.GAL; using System; using System.Globalization; diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs index ec621b909..64497a280 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/DlssUpscaler.cs @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). DLSS, DLAA and NIS are NVIDIA technologies; this is integration code only. + using Ryujinx.Common; using Ryujinx.Common.Logging; using Ryujinx.Graphics.GAL; diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/NvOpticalFlow.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/NvOpticalFlow.cs index 00db6d292..4ca4f1b4e 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/NvOpticalFlow.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/NvOpticalFlow.cs @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). DLSS, DLAA and NIS are NVIDIA technologies; this is integration code only. + using Ryujinx.Common.Logging; using Silk.NET.Core; using Silk.NET.Vulkan; diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/Streamline.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/Streamline.cs index 672da57c2..0d481c712 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/Streamline.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/Streamline.cs @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). DLSS, DLAA and NIS are NVIDIA technologies; this is integration code only. + using Ryujinx.Common.Logging; using System; using System.IO; diff --git a/src/Ryujinx.Graphics.Vulkan/Dlss/StreamlineDlss.cs b/src/Ryujinx.Graphics.Vulkan/Dlss/StreamlineDlss.cs index 942b3281c..905970390 100644 --- a/src/Ryujinx.Graphics.Vulkan/Dlss/StreamlineDlss.cs +++ b/src/Ryujinx.Graphics.Vulkan/Dlss/StreamlineDlss.cs @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). DLSS, DLAA and NIS are NVIDIA technologies; this is integration code only. + using Ryujinx.Common.Logging; using System; using System.Runtime.InteropServices; diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/NisScalingFilter.cs b/src/Ryujinx.Graphics.Vulkan/Effects/NisScalingFilter.cs index 21f7a58b2..6f438ce70 100644 --- a/src/Ryujinx.Graphics.Vulkan/Effects/NisScalingFilter.cs +++ b/src/Ryujinx.Graphics.Vulkan/Effects/NisScalingFilter.cs @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). DLSS, DLAA and NIS are NVIDIA technologies; this is integration code only. + using Ryujinx.Common; using Ryujinx.Graphics.GAL; using Ryujinx.Graphics.Shader; diff --git a/src/Ryujinx.Graphics.Vulkan/Effects/TemporalFilter.cs b/src/Ryujinx.Graphics.Vulkan/Effects/TemporalFilter.cs index eb6bea058..ad0fdb2f2 100644 --- a/src/Ryujinx.Graphics.Vulkan/Effects/TemporalFilter.cs +++ b/src/Ryujinx.Graphics.Vulkan/Effects/TemporalFilter.cs @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). DLSS, DLAA and NIS are NVIDIA technologies; this is integration code only. + using Ryujinx.Common; using Ryujinx.Common.Logging; using Ryujinx.Graphics.GAL; diff --git a/src/Ryujinx/Systems/DlssRestart.cs b/src/Ryujinx/Systems/DlssRestart.cs index 3dec9c797..ed6a18ee6 100644 --- a/src/Ryujinx/Systems/DlssRestart.cs +++ b/src/Ryujinx/Systems/DlssRestart.cs @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). DLSS, DLAA and NIS are NVIDIA technologies; this is integration code only. + using Ryujinx.Ava.Utilities; using Ryujinx.Common.Logging; using System; diff --git a/src/Ryujinx/Systems/DlssUiSettings.cs b/src/Ryujinx/Systems/DlssUiSettings.cs index 7d9328853..084c4f2ca 100644 --- a/src/Ryujinx/Systems/DlssUiSettings.cs +++ b/src/Ryujinx/Systems/DlssUiSettings.cs @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Roofer Dev - Beast Roofer Edition. Clean-room integration code. +// Built on Ryujinx (MIT). DLSS, DLAA and NIS are NVIDIA technologies; this is integration code only. + using Ryujinx.Common.Configuration; using Ryujinx.Common.Logging; using System;