Pārlūkot izejas kodu

Implement Zero-Configuration Resolution Scaling (#1365)

* Initial implementation of Render Target Scaling

Works with most games I have. No GUI option right now, it is hardcoded.

Missing handling for texelFetch operation.

* Realtime Configuration, refactoring.

* texelFetch scaling on fragment shader (WIP)

* Improve Shader-Side changes.

* Fix potential crash when no color/depth bound

* Workaround random uses of textures in compute.

This was blacklisting textures in a few games despite causing no bugs. Will eventually add full support so this doesn't break anything.

* Fix scales oscillating when changing between non-native scales.

* Scaled textures on compute, cleanup, lazier uniform update.

* Cleanup.

* Fix stupidity

* Address Thog Feedback.

* Cover most of GDK's feedback (two comments remain)

* Fix bad rename

* Move IsDepthStencil to FormatExtensions, add docs.

* Fix default config, square texture detection.

* Three final fixes:

- Nearest copy when texture is integer format.
- Texture2D -> Texture3D copy correctly blacklists the texture before trying an unscaled copy (caused driver error)
- Discount small textures.

* Remove scale threshold.

Not needed right now - we'll see if we run into problems.

* All CPU modification blacklists scale.

* Fix comment.
riperiperi 5 gadi atpakaļ
vecāks
revīzija
484eb645ae
49 mainītis faili ar 1163 papildinājumiem un 131 dzēšanām
  1. 11 1
      Ryujinx.Common/Configuration/ConfigurationFileFormat.cs
  2. 28 0
      Ryujinx.Common/Configuration/ConfigurationState.cs
  3. 141 0
      Ryujinx.Graphics.GAL/Format.cs
  4. 4 0
      Ryujinx.Graphics.GAL/IPipeline.cs
  5. 1 1
      Ryujinx.Graphics.GAL/IRenderer.cs
  6. 4 0
      Ryujinx.Graphics.GAL/ITexture.cs
  7. 3 3
      Ryujinx.Graphics.Gpu/Engine/Compute.cs
  8. 3 3
      Ryujinx.Graphics.Gpu/Engine/MethodClear.cs
  9. 27 15
      Ryujinx.Graphics.Gpu/Engine/MethodCopyTexture.cs
  10. 43 8
      Ryujinx.Graphics.Gpu/Engine/Methods.cs
  11. 5 0
      Ryujinx.Graphics.Gpu/GraphicsConfig.cs
  12. 200 17
      Ryujinx.Graphics.Gpu/Image/Texture.cs
  13. 14 2
      Ryujinx.Graphics.Gpu/Image/TextureBindingInfo.cs
  14. 19 0
      Ryujinx.Graphics.Gpu/Image/TextureBindingsManager.cs
  15. 179 8
      Ryujinx.Graphics.Gpu/Image/TextureManager.cs
  16. 1 21
      Ryujinx.Graphics.Gpu/Image/TexturePool.cs
  17. 14 0
      Ryujinx.Graphics.Gpu/Image/TextureScaleMode.cs
  18. 2 1
      Ryujinx.Graphics.Gpu/Image/TextureSearchFlags.cs
  19. 1 1
      Ryujinx.Graphics.Gpu/Window.cs
  20. 8 3
      Ryujinx.Graphics.OpenGL/Image/TextureBase.cs
  21. 8 0
      Ryujinx.Graphics.OpenGL/Image/TextureCopy.cs
  22. 6 5
      Ryujinx.Graphics.OpenGL/Image/TextureCopyUnscaled.cs
  23. 24 18
      Ryujinx.Graphics.OpenGL/Image/TextureStorage.cs
  24. 8 7
      Ryujinx.Graphics.OpenGL/Image/TextureView.cs
  25. 84 0
      Ryujinx.Graphics.OpenGL/Pipeline.cs
  26. 6 0
      Ryujinx.Graphics.OpenGL/Program.cs
  27. 2 2
      Ryujinx.Graphics.OpenGL/Renderer.cs
  28. 11 2
      Ryujinx.Graphics.OpenGL/Window.cs
  29. 17 0
      Ryujinx.Graphics.Shader/CodeGen/Glsl/CodeGenContext.cs
  30. 35 0
      Ryujinx.Graphics.Shader/CodeGen/Glsl/Declarations.cs
  31. 7 0
      Ryujinx.Graphics.Shader/CodeGen/Glsl/HelperFunctions/TexelFetchScale_cp.glsl
  32. 11 0
      Ryujinx.Graphics.Shader/CodeGen/Glsl/HelperFunctions/TexelFetchScale_fp.glsl
  33. 28 1
      Ryujinx.Graphics.Shader/CodeGen/Glsl/Instructions/InstGenMemory.cs
  34. 2 2
      Ryujinx.Graphics.Shader/CodeGen/Glsl/OperandManager.cs
  35. 4 0
      Ryujinx.Graphics.Shader/Instructions/InstEmitMemory.cs
  36. 6 0
      Ryujinx.Graphics.Shader/Instructions/InstEmitTexture.cs
  37. 2 0
      Ryujinx.Graphics.Shader/Ryujinx.Graphics.Shader.csproj
  38. 6 0
      Ryujinx.Graphics.Shader/TextureDescriptor.cs
  39. 16 0
      Ryujinx.Graphics.Shader/TextureUsageFlags.cs
  40. 16 0
      Ryujinx.Graphics.Shader/Translation/EmitterContext.cs
  41. 18 0
      Ryujinx.Graphics.Shader/Translation/FeatureFlags.cs
  42. 4 0
      Ryujinx.Graphics.Shader/Translation/ShaderConfig.cs
  43. 13 4
      Ryujinx.Graphics.Shader/Translation/Translator.cs
  44. 3 1
      Ryujinx/Config.json
  45. 5 0
      Ryujinx/Ui/GLRenderer.cs
  46. 10 3
      Ryujinx/Ui/MainWindow.cs
  47. 16 0
      Ryujinx/Ui/SettingsWindow.cs
  48. 65 1
      Ryujinx/Ui/SettingsWindow.glade
  49. 22 1
      Ryujinx/_schema.json

+ 11 - 1
Ryujinx.Common/Configuration/ConfigurationFileFormat.cs

@@ -13,10 +13,20 @@ namespace Ryujinx.Configuration
         /// <summary>
         /// The current version of the file format
         /// </summary>
-        public const int CurrentVersion = 10;
+        public const int CurrentVersion = 11;
 
         public int Version { get; set; }
 
+        /// <summary>
+        /// Resolution Scale. An integer scale applied to applicable render targets. Values 1-4, or -1 to use a custom floating point scale instead.
+        /// </summary>
+        public int ResScale { get; set; }
+
+        /// <summary>
+        /// Custom Resolution Scale. A custom floating point scale applied to applicable render targets. Only active when Resolution Scale is -1.
+        /// </summary>
+        public float ResScaleCustom { get; set; }
+
         /// <summary>
         /// Max Anisotropy. Values range from 0 - 16. Set to -1 to let the game decide.
         /// </summary>

+ 28 - 0
Ryujinx.Common/Configuration/ConfigurationState.cs

@@ -271,6 +271,16 @@ namespace Ryujinx.Configuration
             /// </summary>
             public ReactiveObject<float> MaxAnisotropy { get; private set; }
 
+            /// <summary>
+            /// Resolution Scale. An integer scale applied to applicable render targets. Values 1-4, or -1 to use a custom floating point scale instead.
+            /// </summary>
+            public ReactiveObject<int> ResScale { get; private set; }
+
+            /// <summary>
+            /// Custom Resolution Scale. A custom floating point scale applied to applicable render targets. Only active when Resolution Scale is -1.
+            /// </summary>
+            public ReactiveObject<float> ResScaleCustom { get; private set; }
+
             /// <summary>
             /// Dumps shaders in this local directory
             /// </summary>
@@ -283,6 +293,8 @@ namespace Ryujinx.Configuration
 
             public GraphicsSection()
             {
+                ResScale        = new ReactiveObject<int>();
+                ResScaleCustom  = new ReactiveObject<float>();
                 MaxAnisotropy   = new ReactiveObject<float>();
                 ShadersDumpPath = new ReactiveObject<string>();
                 EnableVsync     = new ReactiveObject<bool>();
@@ -354,6 +366,8 @@ namespace Ryujinx.Configuration
             ConfigurationFileFormat configurationFile = new ConfigurationFileFormat
             {
                 Version                   = ConfigurationFileFormat.CurrentVersion,
+                ResScale                  = Graphics.ResScale,
+                ResScaleCustom            = Graphics.ResScaleCustom,
                 MaxAnisotropy             = Graphics.MaxAnisotropy,
                 GraphicsShadersDumpPath   = Graphics.ShadersDumpPath,
                 LoggingEnableDebug        = Logger.EnableDebug,
@@ -410,6 +424,8 @@ namespace Ryujinx.Configuration
 
         public void LoadDefault()
         {
+            Graphics.ResScale.Value                = 1;
+            Graphics.ResScaleCustom.Value          = 1.0f;
             Graphics.MaxAnisotropy.Value           = -1;
             Graphics.ShadersDumpPath.Value         = "";
             Logger.EnableDebug.Value               = false;
@@ -652,10 +668,22 @@ namespace Ryujinx.Configuration
                 configurationFileUpdated = true;
             }
 
+            if (configurationFileFormat.Version < 11)
+            {
+                Common.Logging.Logger.PrintWarning(LogClass.Application, $"Outdated configuration version {configurationFileFormat.Version}, migrating to version 11.");
+
+                configurationFileFormat.ResScale = 1;
+                configurationFileFormat.ResScaleCustom = 1.0f;
+
+                configurationFileUpdated = true;
+            }
+
             List<InputConfig> inputConfig = new List<InputConfig>();
             inputConfig.AddRange(configurationFileFormat.ControllerConfig);
             inputConfig.AddRange(configurationFileFormat.KeyboardConfig);
 
+            Graphics.ResScale.Value                = configurationFileFormat.ResScale;
+            Graphics.ResScaleCustom.Value          = configurationFileFormat.ResScaleCustom;
             Graphics.MaxAnisotropy.Value           = configurationFileFormat.MaxAnisotropy;
             Graphics.ShadersDumpPath.Value         = configurationFileFormat.GraphicsShadersDumpPath;
             Logger.EnableDebug.Value               = configurationFileFormat.LoggingEnableDebug;

+ 141 - 0
Ryujinx.Graphics.GAL/Format.cs

@@ -162,11 +162,21 @@ namespace Ryujinx.Graphics.GAL
 
     public static class FormatExtensions
     {
+        /// <summary>
+        /// Checks if the texture format is an ASTC format.
+        /// </summary>
+        /// <param name="format">Texture format</param>
+        /// <returns>True if the texture format is an ASTC format, false otherwise</returns>
         public static bool IsAstc(this Format format)
         {
             return format.IsAstcUnorm() || format.IsAstcSrgb();
         }
 
+        /// <summary>
+        /// Checks if the texture format is an ASTC Unorm format.
+        /// </summary>
+        /// <param name="format">Texture format</param>
+        /// <returns>True if the texture format is an ASTC Unorm format, false otherwise</returns>
         public static bool IsAstcUnorm(this Format format)
         {
             switch (format)
@@ -191,6 +201,11 @@ namespace Ryujinx.Graphics.GAL
             return false;
         }
 
+        /// <summary>
+        /// Checks if the texture format is an ASTC SRGB format.
+        /// </summary>
+        /// <param name="format">Texture format</param>
+        /// <returns>True if the texture format is an ASTC SRGB format, false otherwise</returns>
         public static bool IsAstcSrgb(this Format format)
         {
             switch (format)
@@ -214,5 +229,131 @@ namespace Ryujinx.Graphics.GAL
 
             return false;
         }
+
+        /// <summary>
+        /// Checks if the texture format is a depth, stencil or depth-stencil format.
+        /// </summary>
+        /// <param name="format">Texture format</param>
+        /// <returns>True if the format is a depth, stencil or depth-stencil format, false otherwise</returns>
+        public static bool IsDepthOrStencil(this Format format)
+        {
+            switch (format)
+            {
+                case Format.D16Unorm:
+                case Format.D24UnormS8Uint:
+                case Format.D24X8Unorm:
+                case Format.D32Float:
+                case Format.D32FloatS8Uint:
+                case Format.S8Uint:
+                    return true;
+            }
+
+            return false;
+        }
+
+        /// <summary>
+        /// Checks if the texture format is an unsigned integer color format.
+        /// </summary>
+        /// <param name="format">Texture format</param>
+        /// <returns>True if the texture format is an unsigned integer color format, false otherwise</returns>
+        public static bool IsUint(this Format format)
+        {
+            switch (format)
+            {
+                case Format.R8Uint:
+                case Format.R16Uint:
+                case Format.R32Uint:
+                case Format.R8G8Uint:
+                case Format.R16G16Uint:
+                case Format.R32G32Uint:
+                case Format.R8G8B8Uint:
+                case Format.R16G16B16Uint:
+                case Format.R32G32B32Uint:
+                case Format.R8G8B8A8Uint:
+                case Format.R16G16B16A16Uint:
+                case Format.R32G32B32A32Uint:
+                case Format.R10G10B10A2Uint:
+                case Format.R8G8B8X8Uint:
+                case Format.R16G16B16X16Uint:
+                case Format.R32G32B32X32Uint:
+                    return true;
+            }
+
+            return false;
+        }
+
+        /// <summary>
+        /// Checks if the texture format is a signed integer color format.
+        /// </summary>
+        /// <param name="format">Texture format</param>
+        /// <returns>True if the texture format is a signed integer color format, false otherwise</returns>
+        public static bool IsSint(this Format format)
+        {
+            switch (format)
+            {
+                case Format.R8Sint:
+                case Format.R16Sint:
+                case Format.R32Sint:
+                case Format.R8G8Sint:
+                case Format.R16G16Sint:
+                case Format.R32G32Sint:
+                case Format.R8G8B8Sint:
+                case Format.R16G16B16Sint:
+                case Format.R32G32B32Sint:
+                case Format.R8G8B8A8Sint:
+                case Format.R16G16B16A16Sint:
+                case Format.R32G32B32A32Sint:
+                case Format.R10G10B10A2Sint:
+                case Format.R8G8B8X8Sint:
+                case Format.R16G16B16X16Sint:
+                case Format.R32G32B32X32Sint:
+                    return true;
+            }
+
+            return false;
+        }
+
+        /// <summary>
+        /// Checks if the texture format is an integer color format.
+        /// </summary>
+        /// <param name="format">Texture format</param>
+        /// <returns>True if the texture format is an integer color format, false otherwise</returns>
+        public static bool IsInteger(this Format format)
+        {
+            return format.IsUint() || format.IsSint();
+        }
+
+        /// <summary>
+        /// Checks if the texture format only has one component.
+        /// </summary>
+        /// <param name="format">Texture format</param>
+        /// <returns>True if the texture format only has one component, false otherwise</returns>
+        public static bool HasOneComponent(this Format format)
+        {
+            switch (format)
+            {
+                case Format.R8Unorm:
+                case Format.R8Snorm:
+                case Format.R8Uint:
+                case Format.R8Sint:
+                case Format.R16Float:
+                case Format.R16Unorm:
+                case Format.R16Snorm:
+                case Format.R16Uint:
+                case Format.R16Sint:
+                case Format.R32Float:
+                case Format.R32Uint:
+                case Format.R32Sint:
+                case Format.R8Uscaled:
+                case Format.R8Sscaled:
+                case Format.R16Uscaled:
+                case Format.R16Sscaled:
+                case Format.R32Uscaled:
+                case Format.R32Sscaled:
+                    return true;
+            }
+
+            return false;
+        }
     }
 }

+ 4 - 0
Ryujinx.Graphics.GAL/IPipeline.cs

@@ -54,6 +54,8 @@ namespace Ryujinx.Graphics.GAL
 
         void SetRasterizerDiscard(bool discard);
 
+        void SetRenderTargetScale(float scale);
+
         void SetRenderTargetColorMasks(ReadOnlySpan<uint> componentMask);
 
         void SetRenderTargets(ITexture[] colors, ITexture depthStencil);
@@ -84,5 +86,7 @@ namespace Ryujinx.Graphics.GAL
         bool TryHostConditionalRendering(ICounterEvent value, ulong compare, bool isEqual);
         bool TryHostConditionalRendering(ICounterEvent value, ICounterEvent compare, bool isEqual);
         void EndHostConditionalRendering();
+
+        void UpdateRenderScale(ShaderStage stage, int textureCount);
     }
 }

+ 1 - 1
Ryujinx.Graphics.GAL/IRenderer.cs

@@ -16,7 +16,7 @@ namespace Ryujinx.Graphics.GAL
         IProgram CreateProgram(IShader[] shaders);
 
         ISampler CreateSampler(SamplerCreateInfo info);
-        ITexture CreateTexture(TextureCreateInfo info);
+        ITexture CreateTexture(TextureCreateInfo info, float scale);
 
         void DeleteBuffer(BufferHandle buffer);
 

+ 4 - 0
Ryujinx.Graphics.GAL/ITexture.cs

@@ -4,6 +4,10 @@ namespace Ryujinx.Graphics.GAL
 {
     public interface ITexture : IDisposable
     {
+        int Width { get; }
+        int Height { get; }
+        float ScaleFactor { get; }
+
         void CopyTo(ITexture destination, int firstLayer, int firstLevel);
         void CopyTo(ITexture destination, Extents2D srcRegion, Extents2D dstRegion, bool linearFilter);
 

+ 3 - 3
Ryujinx.Graphics.Gpu/Engine/Compute.cs

@@ -132,11 +132,11 @@ namespace Ryujinx.Graphics.Gpu.Engine
 
                 if (descriptor.IsBindless)
                 {
-                    textureBindings[index] = new TextureBindingInfo(target, descriptor.CbufOffset, descriptor.CbufSlot);
+                    textureBindings[index] = new TextureBindingInfo(target, descriptor.CbufOffset, descriptor.CbufSlot, descriptor.Flags);
                 }
                 else
                 {
-                    textureBindings[index] = new TextureBindingInfo(target, descriptor.HandleIndex);
+                    textureBindings[index] = new TextureBindingInfo(target, descriptor.HandleIndex, descriptor.Flags);
                 }
             }
 
@@ -150,7 +150,7 @@ namespace Ryujinx.Graphics.Gpu.Engine
 
                 Target target = GetTarget(descriptor.Type);
 
-                imageBindings[index] = new TextureBindingInfo(target, descriptor.HandleIndex);
+                imageBindings[index] = new TextureBindingInfo(target, descriptor.HandleIndex, descriptor.Flags);
             }
 
             TextureManager.SetComputeImages(imageBindings);

+ 3 - 3
Ryujinx.Graphics.Gpu/Engine/MethodClear.cs

@@ -26,7 +26,9 @@ namespace Ryujinx.Graphics.Gpu.Engine
                 UpdateScissorState(state);
             }
 
-            UpdateRenderTargetState(state, useControl: false);
+            int index = (argument >> 6) & 0xf;
+
+            UpdateRenderTargetState(state, useControl: false, singleUse: index);
 
             TextureManager.CommitGraphicsBindings();
 
@@ -35,8 +37,6 @@ namespace Ryujinx.Graphics.Gpu.Engine
 
             uint componentMask = (uint)((argument >> 2) & 0xf);
 
-            int index = (argument >> 6) & 0xf;
-
             if (componentMask != 0)
             {
                 var clearColor = state.Get<ClearColors>(MethodOffset.ClearColors);

+ 27 - 15
Ryujinx.Graphics.Gpu/Engine/MethodCopyTexture.cs

@@ -1,5 +1,6 @@
 using Ryujinx.Graphics.GAL;
 using Ryujinx.Graphics.Gpu.State;
+using System;
 
 namespace Ryujinx.Graphics.Gpu.Engine
 {
@@ -32,13 +33,18 @@ namespace Ryujinx.Graphics.Gpu.Engine
                 dstCopyTexture.Format = RtFormat.D32Float;
             }
 
-            Texture dstTexture = TextureManager.FindOrCreateTexture(dstCopyTexture);
+            Texture dstTexture = TextureManager.FindOrCreateTexture(dstCopyTexture, srcTexture.ScaleMode == Image.TextureScaleMode.Scaled);
 
             if (dstTexture == null)
             {
                 return;
             }
 
+            if (srcTexture.ScaleFactor != dstTexture.ScaleFactor)
+            {
+                srcTexture.PropagateScale(dstTexture);
+            }
+
             var control = state.Get<CopyTextureControl>(MethodOffset.CopyTextureControl);
 
             var region = state.Get<CopyRegion>(MethodOffset.CopyRegion);
@@ -55,17 +61,19 @@ namespace Ryujinx.Graphics.Gpu.Engine
             int dstX2 = region.DstX + region.DstWidth;
             int dstY2 = region.DstY + region.DstHeight;
 
+            float scale = srcTexture.ScaleFactor; // src and dest scales are identical now.
+
             Extents2D srcRegion = new Extents2D(
-                srcX1 / srcTexture.Info.SamplesInX,
-                srcY1 / srcTexture.Info.SamplesInY,
-                srcX2 / srcTexture.Info.SamplesInX,
-                srcY2 / srcTexture.Info.SamplesInY);
+                (int)Math.Ceiling(scale * (srcX1 / srcTexture.Info.SamplesInX)),
+                (int)Math.Ceiling(scale * (srcY1 / srcTexture.Info.SamplesInY)),
+                (int)Math.Ceiling(scale * (srcX2 / srcTexture.Info.SamplesInX)),
+                (int)Math.Ceiling(scale * (srcY2 / srcTexture.Info.SamplesInY)));
 
             Extents2D dstRegion = new Extents2D(
-                dstX1 / dstTexture.Info.SamplesInX,
-                dstY1 / dstTexture.Info.SamplesInY,
-                dstX2 / dstTexture.Info.SamplesInX,
-                dstY2 / dstTexture.Info.SamplesInY);
+                (int)Math.Ceiling(scale * (dstX1 / dstTexture.Info.SamplesInX)),
+                (int)Math.Ceiling(scale * (dstY1 / dstTexture.Info.SamplesInY)),
+                (int)Math.Ceiling(scale * (dstX2 / dstTexture.Info.SamplesInX)),
+                (int)Math.Ceiling(scale * (dstY2 / dstTexture.Info.SamplesInY)));
 
             bool linearFilter = control.UnpackLinearFilter();
 
@@ -79,17 +87,21 @@ namespace Ryujinx.Graphics.Gpu.Engine
             // the second handles the region outside of the bounds).
             // We must also extend the source texture by one line to ensure we can wrap on the last line.
             // This is required by the (guest) OpenGL driver.
-            if (srcRegion.X2 > srcTexture.Info.Width)
+            if (srcX2 / srcTexture.Info.SamplesInX > srcTexture.Info.Width)
             {
                 srcCopyTexture.Height++;
 
-                srcTexture = TextureManager.FindOrCreateTexture(srcCopyTexture);
+                srcTexture = TextureManager.FindOrCreateTexture(srcCopyTexture, srcTexture.ScaleMode == Image.TextureScaleMode.Scaled);
+                if (srcTexture.ScaleFactor != dstTexture.ScaleFactor)
+                {
+                    srcTexture.PropagateScale(dstTexture);
+                }
 
                 srcRegion = new Extents2D(
-                    srcRegion.X1 - srcTexture.Info.Width,
-                    srcRegion.Y1 + 1,
-                    srcRegion.X2 - srcTexture.Info.Width,
-                    srcRegion.Y2 + 1);
+                    (int)Math.Ceiling(scale * ((srcX1 / srcTexture.Info.SamplesInX) - srcTexture.Info.Width)),
+                    (int)Math.Ceiling(scale * ((srcY1 / srcTexture.Info.SamplesInY) + 1)),
+                    (int)Math.Ceiling(scale * ((srcX2 / srcTexture.Info.SamplesInX) - srcTexture.Info.Width)),
+                    (int)Math.Ceiling(scale * ((srcY2 / srcTexture.Info.SamplesInY) + 1)));
 
                 srcTexture.HostTexture.CopyTo(dstTexture.HostTexture, srcRegion, dstRegion, linearFilter);
             }

+ 43 - 8
Ryujinx.Graphics.Gpu/Engine/Methods.cs

@@ -313,7 +313,8 @@ namespace Ryujinx.Graphics.Gpu.Engine
         /// </summary>
         /// <param name="state">Current GPU state</param>
         /// <param name="useControl">Use draw buffers information from render target control register</param>
-        private void UpdateRenderTargetState(GpuState state, bool useControl)
+        /// <param name="singleUse">If this is not -1, it indicates that only the given indexed target will be used.</param> 
+        private void UpdateRenderTargetState(GpuState state, bool useControl, int singleUse = -1)
         {
             var rtControl = state.Get<RtControl>(MethodOffset.RtControl);
 
@@ -324,6 +325,8 @@ namespace Ryujinx.Graphics.Gpu.Engine
             int samplesInX = msaaMode.SamplesInX();
             int samplesInY = msaaMode.SamplesInY();
 
+            bool changedScale = false;
+
             for (int index = 0; index < Constants.TotalRenderTargets; index++)
             {
                 int rtIndex = useControl ? rtControl.UnpackPermutationIndex(index) : index;
@@ -332,14 +335,14 @@ namespace Ryujinx.Graphics.Gpu.Engine
 
                 if (index >= count || !IsRtEnabled(colorState))
                 {
-                    TextureManager.SetRenderTargetColor(index, null);
+                    changedScale |= TextureManager.SetRenderTargetColor(index, null);
 
                     continue;
                 }
 
                 Texture color = TextureManager.FindOrCreateTexture(colorState, samplesInX, samplesInY);
 
-                TextureManager.SetRenderTargetColor(index, color);
+                changedScale |= TextureManager.SetRenderTargetColor(index, color);
 
                 if (color != null)
                 {
@@ -359,7 +362,16 @@ namespace Ryujinx.Graphics.Gpu.Engine
                 depthStencil = TextureManager.FindOrCreateTexture(dsState, dsSize, samplesInX, samplesInY);
             }
 
-            TextureManager.SetRenderTargetDepthStencil(depthStencil);
+            changedScale |= TextureManager.SetRenderTargetDepthStencil(depthStencil);
+
+            if (changedScale)
+            {
+                TextureManager.UpdateRenderTargetScale(singleUse);
+                _context.Renderer.Pipeline.SetRenderTargetScale(TextureManager.RenderTargetScale);
+
+                UpdateViewportTransform(state);
+                UpdateScissorState(state);
+            }
 
             if (depthStencil != null)
             {
@@ -394,7 +406,21 @@ namespace Ryujinx.Graphics.Gpu.Engine
 
                 if (enable)
                 {
-                    _context.Renderer.Pipeline.SetScissor(index, scissor.X1, scissor.Y1, scissor.X2 - scissor.X1, scissor.Y2 - scissor.Y1);
+                    int x = scissor.X1;
+                    int y = scissor.Y1;
+                    int width = scissor.X2 - x;
+                    int height = scissor.Y2 - y;
+
+                    float scale = TextureManager.RenderTargetScale;
+                    if (scale != 1f)
+                    {
+                        x = (int)(x * scale);
+                        y = (int)(y * scale);
+                        width = (int)Math.Ceiling(width * scale);
+                        height = (int)Math.Ceiling(height * scale);
+                    }
+
+                    _context.Renderer.Pipeline.SetScissor(index, x, y, width, height);
                 }
             }
         }
@@ -460,6 +486,15 @@ namespace Ryujinx.Graphics.Gpu.Engine
                 float width  = MathF.Abs(transform.ScaleX) * 2;
                 float height = MathF.Abs(transform.ScaleY) * 2;
 
+                float scale = TextureManager.RenderTargetScale;
+                if (scale != 1f)
+                {
+                    x *= scale;
+                    y *= scale;
+                    width *= scale;
+                    height *= scale;
+                }
+
                 RectangleF region = new RectangleF(x, y, width, height);
 
                 ViewportSwizzle swizzleX = transform.UnpackSwizzleX();
@@ -909,11 +944,11 @@ namespace Ryujinx.Graphics.Gpu.Engine
 
                     if (descriptor.IsBindless)
                     {
-                        textureBindings[index] = new TextureBindingInfo(target, descriptor.CbufSlot, descriptor.CbufOffset);
+                        textureBindings[index] = new TextureBindingInfo(target, descriptor.CbufSlot, descriptor.CbufOffset, descriptor.Flags);
                     }
                     else
                     {
-                        textureBindings[index] = new TextureBindingInfo(target, descriptor.HandleIndex);
+                        textureBindings[index] = new TextureBindingInfo(target, descriptor.HandleIndex, descriptor.Flags);
                     }
                 }
 
@@ -927,7 +962,7 @@ namespace Ryujinx.Graphics.Gpu.Engine
 
                     Target target = GetTarget(descriptor.Type);
 
-                    imageBindings[index] = new TextureBindingInfo(target, descriptor.HandleIndex);
+                    imageBindings[index] = new TextureBindingInfo(target, descriptor.HandleIndex, descriptor.Flags);
                 }
 
                 TextureManager.SetGraphicsImages(stage, imageBindings);

+ 5 - 0
Ryujinx.Graphics.Gpu/GraphicsConfig.cs

@@ -5,6 +5,11 @@ namespace Ryujinx.Graphics.Gpu
     /// </summary>
     public static class GraphicsConfig
     {
+        /// <summary>
+        /// Resolution scale.
+        /// </summary>
+        public static float ResScale = 1f;
+
         /// <summary>
         /// Max Anisotropy. Values range from 0 - 16. Set to -1 to let the game decide.
         /// </summary>

+ 200 - 17
Ryujinx.Graphics.Gpu/Image/Texture.cs

@@ -29,10 +29,20 @@ namespace Ryujinx.Graphics.Gpu.Image
         /// </summary>
         public TextureInfo Info { get; private set; }
 
+        /// <summary>
+        /// Host scale factor.
+        /// </summary>
+        public float ScaleFactor { get; private set; }
+
+        /// <summary>
+        /// Upscaling mode. Informs if a texture is scaled, or is eligible for scaling.
+        /// </summary>
+        public TextureScaleMode ScaleMode { get; private set; }
+
         private int _depth;
         private int _layers;
-        private readonly int _firstLayer;
-        private readonly int _firstLevel;
+        private int _firstLayer;
+        private int _firstLevel;
 
         private bool _hasData;
 
@@ -92,18 +102,25 @@ namespace Ryujinx.Graphics.Gpu.Image
         /// <param name="sizeInfo">Size information of the texture</param>
         /// <param name="firstLayer">The first layer of the texture, or 0 if the texture has no parent</param>
         /// <param name="firstLevel">The first mipmap level of the texture, or 0 if the texture has no parent</param>
+        /// <param name="scaleFactor">The floating point scale factor to initialize with</param>
+        /// <param name="scaleMode">The scale mode to initialize with</param>
         private Texture(
-            GpuContext  context,
-            TextureInfo info,
-            SizeInfo    sizeInfo,
-            int         firstLayer,
-            int         firstLevel)
+            GpuContext       context,
+            TextureInfo      info,
+            SizeInfo         sizeInfo,
+            int              firstLayer,
+            int              firstLevel,
+            float            scaleFactor,
+            TextureScaleMode scaleMode)
         {
             InitializeTexture(context, info, sizeInfo);
 
             _firstLayer = firstLayer;
             _firstLevel = firstLevel;
 
+            ScaleFactor = scaleFactor;
+            ScaleMode = scaleMode;
+
             _hasData = true;
         }
 
@@ -113,13 +130,23 @@ namespace Ryujinx.Graphics.Gpu.Image
         /// <param name="context">GPU context that the texture belongs to</param>
         /// <param name="info">Texture information</param>
         /// <param name="sizeInfo">Size information of the texture</param>
-        public Texture(GpuContext context, TextureInfo info, SizeInfo sizeInfo)
+        /// <param name="scaleMode">The scale mode to initialize with. If scaled, the texture's data is loaded immediately and scaled up</param>
+        public Texture(GpuContext context, TextureInfo info, SizeInfo sizeInfo, TextureScaleMode scaleMode)
         {
+            ScaleFactor = 1f; // Texture is first loaded at scale 1x.
+            ScaleMode = scaleMode;
+
             InitializeTexture(context, info, sizeInfo);
 
             TextureCreateInfo createInfo = TextureManager.GetCreateInfo(info, context.Capabilities);
 
-            HostTexture = _context.Renderer.CreateTexture(createInfo);
+            HostTexture = _context.Renderer.CreateTexture(createInfo, ScaleFactor);
+
+            if (scaleMode == TextureScaleMode.Scaled)
+            {
+                SynchronizeMemory(); // Load the data and then scale it up.
+                SetScale(GraphicsConfig.ResScale);
+            }
         }
 
         /// <summary>
@@ -162,7 +189,9 @@ namespace Ryujinx.Graphics.Gpu.Image
                 info,
                 sizeInfo,
                 _firstLayer + firstLayer,
-                _firstLevel + firstLevel);
+                _firstLevel + firstLevel,
+                ScaleFactor,
+                ScaleMode);
 
             TextureCreateInfo createInfo = TextureManager.GetCreateInfo(info, _context.Capabilities);
 
@@ -282,7 +311,7 @@ namespace Ryujinx.Graphics.Gpu.Image
             }
             else
             {
-                ITexture newStorage = _context.Renderer.CreateTexture(createInfo);
+                ITexture newStorage = _context.Renderer.CreateTexture(createInfo, ScaleFactor);
 
                 HostTexture.CopyTo(newStorage, 0, 0);
 
@@ -290,6 +319,149 @@ namespace Ryujinx.Graphics.Gpu.Image
             }
         }
 
+        /// <summary>
+        /// Blacklists this texture from being scaled. Resets its scale to 1 if needed.
+        /// </summary>
+        public void BlacklistScale()
+        {
+            ScaleMode = TextureScaleMode.Blacklisted;
+            SetScale(1f);
+        }
+
+        /// <summary>
+        /// Propagates the scale between this texture and another to ensure they have the same scale.
+        /// If one texture is blacklisted from scaling, the other will become blacklisted too.
+        /// </summary>
+        /// <param name="other">The other texture</param>
+        public void PropagateScale(Texture other)
+        {
+            if (other.ScaleMode == TextureScaleMode.Blacklisted || ScaleMode == TextureScaleMode.Blacklisted)
+            {
+                BlacklistScale();
+                other.BlacklistScale();
+            }
+            else
+            {
+                // Prefer the configured scale if present. If not, prefer the max.
+                float targetScale = GraphicsConfig.ResScale;
+                float sharedScale = (ScaleFactor == targetScale || other.ScaleFactor == targetScale) ? targetScale : Math.Max(ScaleFactor, other.ScaleFactor);
+
+                SetScale(sharedScale);
+                other.SetScale(sharedScale);
+            }
+        }
+
+        /// <summary>
+        /// Helper method for copying our Texture2DArray texture to the given target, with scaling.
+        /// This creates temporary views for each array layer on both textures, copying each one at a time.
+        /// </summary>
+        /// <param name="target">The texture array to copy to</param>
+        private void CopyArrayScaled(ITexture target)
+        {
+            TextureInfo viewInfo = new TextureInfo(
+                Info.Address,
+                Info.Width,
+                Info.Height,
+                1,
+                Info.Levels,
+                Info.SamplesInX,
+                Info.SamplesInY,
+                Info.Stride,
+                Info.IsLinear,
+                Info.GobBlocksInY,
+                Info.GobBlocksInZ,
+                Info.GobBlocksInTileX,
+                Target.Texture2D,
+                Info.FormatInfo,
+                Info.DepthStencilMode,
+                Info.SwizzleR,
+                Info.SwizzleG,
+                Info.SwizzleB,
+                Info.SwizzleA);
+
+            TextureCreateInfo createInfo = TextureManager.GetCreateInfo(viewInfo, _context.Capabilities);
+
+            for (int i = 0; i < Info.DepthOrLayers; i++)
+            {
+                ITexture from = HostTexture.CreateView(createInfo, i, 0);
+                ITexture to = target.CreateView(createInfo, i, 0);
+
+                from.CopyTo(to, new Extents2D(0, 0, from.Width, from.Height), new Extents2D(0, 0, to.Width, to.Height), true);
+
+                from.Dispose();
+                to.Dispose();
+            }
+        }
+
+        /// <summary>
+        /// Sets the Scale Factor on this texture, and immediately recreates it at the correct size.
+        /// When a texture is resized, a scaled copy is performed from the old texture to the new one, to ensure no data is lost.
+        /// If scale is equivalent, this only propagates the blacklisted/scaled mode.
+        /// If called on a view, its storage is resized instead.
+        /// When resizing storage, all texture views are recreated.
+        /// </summary>
+        /// <param name="scale">The new scale factor for this texture</param>
+        public void SetScale(float scale)
+        {
+            TextureScaleMode newScaleMode = ScaleMode == TextureScaleMode.Blacklisted ? ScaleMode : TextureScaleMode.Scaled;
+
+            if (_viewStorage != this)
+            {
+                _viewStorage.ScaleMode = newScaleMode;
+                _viewStorage.SetScale(scale);
+                return;
+            }
+
+            if (ScaleFactor != scale)
+            {
+                Logger.PrintDebug(LogClass.Gpu, $"Rescaling {Info.Width}x{Info.Height} {Info.FormatInfo.Format.ToString()} to ({ScaleFactor} to {scale}). ");
+                TextureCreateInfo createInfo = TextureManager.GetCreateInfo(Info, _context.Capabilities);
+
+                ScaleFactor = scale;
+
+                ITexture newStorage = _context.Renderer.CreateTexture(createInfo, ScaleFactor);
+
+                if (Info.Target == Target.Texture2DArray)
+                {
+                    CopyArrayScaled(newStorage);
+                }
+                else
+                {
+                    HostTexture.CopyTo(newStorage, new Extents2D(0, 0, HostTexture.Width, HostTexture.Height), new Extents2D(0, 0, newStorage.Width, newStorage.Height), true);
+                }
+
+                Logger.PrintDebug(LogClass.Gpu, $"  Copy performed: {HostTexture.Width}x{HostTexture.Height} to {newStorage.Width}x{newStorage.Height}");
+
+                ReplaceStorage(newStorage);
+
+                // All views must be recreated against the new storage.
+
+                foreach (var view in _views)
+                {
+                    Logger.PrintDebug(LogClass.Gpu, $"  Recreating view {Info.Width}x{Info.Height} {Info.FormatInfo.Format.ToString()}.");
+                    view.ScaleFactor = scale;
+
+                    TextureCreateInfo viewCreateInfo = TextureManager.GetCreateInfo(view.Info, _context.Capabilities);
+
+                    ITexture newView = HostTexture.CreateView(viewCreateInfo, view._firstLayer - _firstLayer, view._firstLevel - _firstLevel);
+
+                    view.ReplaceStorage(newView);
+
+                    view.ScaleMode = newScaleMode;
+                }
+            }
+
+            if (ScaleMode != newScaleMode)
+            {
+                ScaleMode = newScaleMode;
+
+                foreach (var view in _views)
+                {
+                    view.ScaleMode = newScaleMode;
+                }
+            }
+        }
+
         /// <summary>
         /// Synchronizes guest and host memory.
         /// This will overwrite the texture data with the texture data on the guest memory, if a CPU
@@ -310,9 +482,14 @@ namespace Ryujinx.Graphics.Gpu.Image
 
             int modifiedCount = _context.PhysicalMemory.QueryModified(Address, Size, ResourceName.Texture, _modifiedRanges);
 
-            if (modifiedCount == 0 && _hasData)
+            if (_hasData)
             {
-                return;
+                if (modifiedCount == 0)
+                {
+                    return;
+                }
+
+                BlacklistScale();
             }
 
             ReadOnlySpan<byte> data = _context.PhysicalMemory.GetSpan(Address, (int)Size);
@@ -432,6 +609,7 @@ namespace Ryujinx.Graphics.Gpu.Image
         /// </summary>
         public void Flush()
         {
+            BlacklistScale();
             _context.PhysicalMemory.Write(Address, GetTextureDataFromGpu());
         }
 
@@ -445,6 +623,7 @@ namespace Ryujinx.Graphics.Gpu.Image
         /// <returns>Host texture data</returns>
         private Span<byte> GetTextureDataFromGpu()
         {
+            BlacklistScale();
             Span<byte> data = HostTexture.GetData();
 
             if (Info.IsLinear)
@@ -980,10 +1159,14 @@ namespace Ryujinx.Graphics.Gpu.Image
         /// <param name="parent">The parent texture</param>
         /// <param name="info">The new view texture information</param>
         /// <param name="hostTexture">The new host texture</param>
-        public void ReplaceView(Texture parent, TextureInfo info, ITexture hostTexture)
+        /// <param name="firstLayer">The first layer of the view</param>
+        /// <param name="firstLevel">The first level of the view</param>
+        public void ReplaceView(Texture parent, TextureInfo info, ITexture hostTexture, int firstLayer, int firstLevel)
         {
             ReplaceStorage(hostTexture);
 
+            _firstLayer = parent._firstLayer + firstLayer;
+            _firstLevel = parent._firstLevel + firstLevel;
             parent._viewStorage.AddView(this);
 
             SetInfo(info);
@@ -1075,7 +1258,7 @@ namespace Ryujinx.Graphics.Gpu.Image
             // already deleted (views count is 0).
             if (_referenceCount == 0 && _views.Count == 0)
             {
-                DisposeTextures();
+                Dispose();
             }
         }
 
@@ -1088,8 +1271,6 @@ namespace Ryujinx.Graphics.Gpu.Image
 
             _arrayViewTexture?.Dispose();
             _arrayViewTexture = null;
-
-            Disposed?.Invoke(this);
         }
 
         /// <summary>
@@ -1098,6 +1279,8 @@ namespace Ryujinx.Graphics.Gpu.Image
         public void Dispose()
         {
             DisposeTextures();
+
+            Disposed?.Invoke(this);
         }
     }
 }

+ 14 - 2
Ryujinx.Graphics.Gpu/Image/TextureBindingInfo.cs

@@ -1,4 +1,5 @@
 using Ryujinx.Graphics.GAL;
+using Ryujinx.Graphics.Shader;
 
 namespace Ryujinx.Graphics.Gpu.Image
 {
@@ -37,12 +38,18 @@ namespace Ryujinx.Graphics.Gpu.Image
         /// </summary>
         public int CbufOffset { get; }
 
+        /// <summary>
+        /// Flags from the texture descriptor that indicate how the texture is used.
+        /// </summary>
+        public TextureUsageFlags Flags { get; }
+
         /// <summary>
         /// Constructs the texture binding information structure.
         /// </summary>
         /// <param name="target">The shader sampler target type</param>
         /// <param name="handle">The shader texture handle (read index into the texture constant buffer)</param>
-        public TextureBindingInfo(Target target, int handle)
+        /// <param name="flags">The texture's usage flags, indicating how it is used in the shader</param>
+        public TextureBindingInfo(Target target, int handle, TextureUsageFlags flags)
         {
             Target = target;
             Handle = handle;
@@ -51,6 +58,8 @@ namespace Ryujinx.Graphics.Gpu.Image
 
             CbufSlot   = 0;
             CbufOffset = 0;
+
+            Flags = flags;
         }
 
         /// <summary>
@@ -59,7 +68,8 @@ namespace Ryujinx.Graphics.Gpu.Image
         /// <param name="target">The shader sampler target type</param>
         /// <param name="cbufSlot">Constant buffer slot where the bindless texture handle is located</param>
         /// <param name="cbufOffset">Constant buffer offset of the bindless texture handle</param>
-        public TextureBindingInfo(Target target, int cbufSlot, int cbufOffset)
+        /// <param name="flags">The texture's usage flags, indicating how it is used in the shader</param>
+        public TextureBindingInfo(Target target, int cbufSlot, int cbufOffset, TextureUsageFlags flags)
         {
             Target = target;
             Handle = 0;
@@ -68,6 +78,8 @@ namespace Ryujinx.Graphics.Gpu.Image
 
             CbufSlot   = cbufSlot;
             CbufOffset = cbufOffset;
+
+            Flags = flags;
         }
     }
 }

+ 19 - 0
Ryujinx.Graphics.Gpu/Image/TextureBindingsManager.cs

@@ -174,6 +174,8 @@ namespace Ryujinx.Graphics.Gpu.Image
                 return;
             }
 
+            bool changed = false;
+
             for (int index = 0; index < _textureBindings[stageIndex].Length; index++)
             {
                 TextureBindingInfo binding = _textureBindings[stageIndex][index];
@@ -216,6 +218,11 @@ namespace Ryujinx.Graphics.Gpu.Image
 
                 Texture texture = pool.Get(textureId);
 
+                if ((binding.Flags & TextureUsageFlags.ResScaleUnsupported) != 0)
+                {
+                    texture?.BlacklistScale();
+                }
+
                 ITexture hostTexture = texture?.GetTargetTexture(binding.Target);
 
                 if (_textureState[stageIndex][index].Texture != hostTexture || _rebind)
@@ -223,6 +230,8 @@ namespace Ryujinx.Graphics.Gpu.Image
                     _textureState[stageIndex][index].Texture = hostTexture;
 
                     _context.Renderer.Pipeline.SetTexture(index, stage, hostTexture);
+
+                    changed = true;
                 }
 
                 if (hostTexture != null && texture.Info.Target == Target.TextureBuffer)
@@ -244,6 +253,11 @@ namespace Ryujinx.Graphics.Gpu.Image
                     _context.Renderer.Pipeline.SetSampler(index, stage, hostSampler);
                 }
             }
+
+            if (changed)
+            {
+                _context.Renderer.Pipeline.UpdateRenderScale(stage, _textureBindings[stageIndex].Length);
+            }
         }
 
         /// <summary>
@@ -269,6 +283,11 @@ namespace Ryujinx.Graphics.Gpu.Image
 
                 Texture texture = pool.Get(textureId);
 
+                if ((binding.Flags & TextureUsageFlags.ResScaleUnsupported) != 0)
+                {
+                    texture?.BlacklistScale();
+                }
+
                 ITexture hostTexture = texture?.GetTargetTexture(binding.Target);
 
                 if (_imageState[stageIndex][index].Texture != hostTexture || _rebind)

+ 179 - 8
Ryujinx.Graphics.Gpu/Image/TextureManager.cs

@@ -39,6 +39,11 @@ namespace Ryujinx.Graphics.Gpu.Image
         private readonly HashSet<Texture> _modified;
         private readonly HashSet<Texture> _modifiedLinear;
 
+        /// <summary>
+        /// The scaling factor applied to all currently bound render targets.
+        /// </summary>
+        public float RenderTargetScale { get; private set; } = 1f;
+
         /// <summary>
         /// Constructs a new instance of the texture manager.
         /// </summary>
@@ -169,18 +174,112 @@ namespace Ryujinx.Graphics.Gpu.Image
         /// </summary>
         /// <param name="index">The index of the color buffer to set (up to 8)</param>
         /// <param name="color">The color buffer texture</param>
-        public void SetRenderTargetColor(int index, Texture color)
+        /// <returns>True if render target scale must be updated.</returns>
+        public bool SetRenderTargetColor(int index, Texture color)
         {
+            bool hasValue = color != null;
+            bool changesScale = (hasValue != (_rtColors[index] != null)) || (hasValue && RenderTargetScale != color.ScaleFactor);
             _rtColors[index] = color;
+
+            return changesScale || (hasValue && color.ScaleMode != TextureScaleMode.Blacklisted && color.ScaleFactor != GraphicsConfig.ResScale);
+        }
+
+        /// <summary>
+        /// Updates the Render Target scale, given the currently bound render targets.
+        /// This will update scale to match the configured scale, scale textures that are eligible but not scaled,
+        /// and propagate blacklisted status from one texture to the ones bound with it.
+        /// </summary>
+        /// <param name="singleUse">If this is not -1, it indicates that only the given indexed target will be used.</param> 
+        public void UpdateRenderTargetScale(int singleUse)
+        {
+            // Make sure all scales for render targets are at the highest they should be. Blacklisted targets should propagate their scale to the other targets.
+            bool mismatch = false;
+            bool blacklisted = false;
+            bool hasUpscaled = false;
+            float targetScale = GraphicsConfig.ResScale;
+
+            void ConsiderTarget(Texture target)
+            {
+                if (target == null) return;
+                float scale = target.ScaleFactor;
+
+                switch (target.ScaleMode)
+                {
+                    case TextureScaleMode.Blacklisted:
+                        mismatch |= scale != 1f;
+                        blacklisted = true;
+                        break;
+                    case TextureScaleMode.Eligible:
+                        mismatch = true; // We must make a decision.
+                        break;
+                    case TextureScaleMode.Scaled:
+                        hasUpscaled = true;
+                        mismatch |= scale != targetScale; // If the target scale has changed, reset the scale for all targets.
+                        break;
+                }
+            }
+
+            if (singleUse != -1)
+            {
+                // If only one target is in use (by a clear, for example) the others do not need to be checked for mismatching scale.
+                ConsiderTarget(_rtColors[singleUse]);
+            }
+            else
+            {
+                foreach (Texture color in _rtColors)
+                {
+                    ConsiderTarget(color);
+                }
+            }
+
+            ConsiderTarget(_rtDepthStencil);
+
+            mismatch |= blacklisted && hasUpscaled;
+
+            if (blacklisted)
+            {
+                targetScale = 1f;
+            }
+
+            if (mismatch)
+            {
+                if (blacklisted)
+                {
+                    // Propagate the blacklisted state to the other textures.
+                    foreach (Texture color in _rtColors)
+                    {
+                        color?.BlacklistScale();
+                    }
+
+                    _rtDepthStencil?.BlacklistScale();
+                }
+                else
+                {
+                    // Set the scale of the other textures.
+                    foreach (Texture color in _rtColors)
+                    {
+                        color?.SetScale(targetScale);
+                    }
+
+                    _rtDepthStencil?.SetScale(targetScale);
+                }
+            }
+
+            RenderTargetScale = targetScale;
         }
 
         /// <summary>
         /// Sets the render target depth-stencil buffer.
         /// </summary>
         /// <param name="depthStencil">The depth-stencil buffer texture</param>
-        public void SetRenderTargetDepthStencil(Texture depthStencil)
+        /// <returns>True if render target scale must be updated.</returns>
+        public bool SetRenderTargetDepthStencil(Texture depthStencil)
         {
+            bool hasValue = depthStencil != null;
+            bool changesScale = (hasValue != (_rtDepthStencil != null)) || (hasValue && RenderTargetScale != depthStencil.ScaleFactor);
             _rtDepthStencil = depthStencil;
+
+            return changesScale || (hasValue && depthStencil.ScaleMode != TextureScaleMode.Blacklisted && depthStencil.ScaleFactor != GraphicsConfig.ResScale);
         }
 
         /// <summary>
@@ -262,12 +361,59 @@ namespace Ryujinx.Graphics.Gpu.Image
             }
         }
 
+        /// <summary>
+        /// Determines if a given texture is eligible for upscaling from its info.
+        /// </summary>
+        /// <param name="info">The texture info to check</param>
+        /// <returns>True if eligible</returns>
+        public bool IsUpscaleCompatible(TextureInfo info)
+        {
+            return (info.Target == Target.Texture2D || info.Target == Target.Texture2DArray) && info.Levels == 1 && !info.FormatInfo.IsCompressed && UpscaleSafeMode(info);
+        }
+
+        /// <summary>
+        /// Determines if a given texture is "safe" for upscaling from its info.
+        /// Note that this is different from being compatible - this elilinates targets that would have detrimental effects when scaled.
+        /// </summary>
+        /// <param name="info">The texture info to check</param>
+        /// <returns>True if safe</returns>
+        public bool UpscaleSafeMode(TextureInfo info)
+        {
+            // While upscaling works for all targets defined by IsUpscaleCompatible, we additionally blacklist targets here that
+            // may have undesirable results (upscaling blur textures) or simply waste GPU resources (upscaling texture atlas).
+
+            if (!(info.FormatInfo.Format.IsDepthOrStencil() || info.FormatInfo.Format.HasOneComponent()))
+            {
+                // Discount square textures that aren't depth-stencil like. (excludes game textures, cubemap faces, most 3D texture LUT, texture atlas)
+                // Detect if the texture is possibly square. Widths may be aligned, so to remove the uncertainty we align both the width and height.
+
+                int widthAlignment = (info.IsLinear ? 32 : 64) / info.FormatInfo.BytesPerPixel;
+
+                bool possiblySquare = BitUtils.AlignUp(info.Width, widthAlignment) == BitUtils.AlignUp(info.Height, widthAlignment);
+
+                if (possiblySquare)
+                {
+                    return false;
+                }
+            }
+
+            int aspect = (int)Math.Round((info.Width / (float)info.Height) * 9);
+            if (aspect == 16 && info.Height < 360)
+            {
+                // Targets that are roughly 16:9 can only be rescaled if they're equal to or above 360p. (excludes blur and bloom textures)
+                return false;
+            }
+
+            return true;
+        }
+
         /// <summary>
         /// Tries to find an existing texture, or create a new one if not found.
         /// </summary>
         /// <param name="copyTexture">Copy texture to find or create</param>
+        /// <param name="preferScaling">Indicates if the texture should be scaled from the start</param>
         /// <returns>The texture</returns>
-        public Texture FindOrCreateTexture(CopyTexture copyTexture)
+        public Texture FindOrCreateTexture(CopyTexture copyTexture, bool preferScaling = true)
         {
             ulong address = _context.MemoryManager.Translate(copyTexture.Address.Pack());
 
@@ -308,7 +454,14 @@ namespace Ryujinx.Graphics.Gpu.Image
                 Target.Texture2D,
                 formatInfo);
 
-            Texture texture = FindOrCreateTexture(info, TextureSearchFlags.IgnoreMs);
+            TextureSearchFlags flags = TextureSearchFlags.IgnoreMs;
+
+            if (preferScaling)
+            {
+                flags |= TextureSearchFlags.WithUpscale;
+            }
+
+            Texture texture = FindOrCreateTexture(info, flags);
 
             texture.SynchronizeMemory();
 
@@ -391,7 +544,7 @@ namespace Ryujinx.Graphics.Gpu.Image
                 target,
                 formatInfo);
 
-            Texture texture = FindOrCreateTexture(info);
+            Texture texture = FindOrCreateTexture(info, TextureSearchFlags.WithUpscale);
 
             texture.SynchronizeMemory();
 
@@ -440,7 +593,7 @@ namespace Ryujinx.Graphics.Gpu.Image
                 target,
                 formatInfo);
 
-            Texture texture = FindOrCreateTexture(info);
+            Texture texture = FindOrCreateTexture(info, TextureSearchFlags.WithUpscale);
 
             texture.SynchronizeMemory();
 
@@ -457,6 +610,14 @@ namespace Ryujinx.Graphics.Gpu.Image
         {
             bool isSamplerTexture = (flags & TextureSearchFlags.Sampler) != 0;
 
+            bool isScalable = IsUpscaleCompatible(info);
+
+            TextureScaleMode scaleMode = TextureScaleMode.Blacklisted;
+            if (isScalable)
+            {
+                scaleMode = (flags & TextureSearchFlags.WithUpscale) != 0 ? TextureScaleMode.Scaled : TextureScaleMode.Eligible;
+            }
+
             // Try to find a perfect texture match, with the same address and parameters.
             int sameAddressOverlapsCount = _textures.FindOverlaps(info.Address, ref _textureOverlaps);
 
@@ -556,7 +717,7 @@ namespace Ryujinx.Graphics.Gpu.Image
             // No match, create a new texture.
             if (texture == null)
             {
-                texture = new Texture(_context, info, sizeInfo);
+                texture = new Texture(_context, info, sizeInfo, scaleMode);
 
                 // We need to synchronize before copying the old view data to the texture,
                 // otherwise the copied data would be overwritten by a future synchronization.
@@ -572,6 +733,14 @@ namespace Ryujinx.Graphics.Gpu.Image
 
                         TextureCreateInfo createInfo = GetCreateInfo(overlapInfo, _context.Capabilities);
 
+                        if (texture.ScaleFactor != overlap.ScaleFactor)
+                        {
+                            // A bit tricky, our new texture may need to contain an existing texture that is upscaled, but isn't itself. 
+                            // In that case, we prefer the higher scale only if our format is render-target-like, otherwise we scale the view down before copy.
+
+                            texture.PropagateScale(overlap);
+                        }
+
                         ITexture newView = texture.HostTexture.CreateView(createInfo, firstLayer, firstLevel);
 
                         overlap.HostTexture.CopyTo(newView, 0, 0);
@@ -583,7 +752,7 @@ namespace Ryujinx.Graphics.Gpu.Image
                             CacheTextureModified(texture);
                         }
 
-                        overlap.ReplaceView(texture, overlapInfo, newView);
+                        overlap.ReplaceView(texture, overlapInfo, newView, firstLayer, firstLevel);
                     }
                 }
 
@@ -602,6 +771,8 @@ namespace Ryujinx.Graphics.Gpu.Image
                             out int firstLayer,
                             out int firstLevel))
                         {
+                            overlap.BlacklistScale();
+
                             overlap.HostTexture.CopyTo(texture.HostTexture, firstLayer, firstLevel);
 
                             if (IsTextureModified(overlap))

+ 1 - 21
Ryujinx.Graphics.Gpu/Image/TexturePool.cs

@@ -174,7 +174,7 @@ namespace Ryujinx.Graphics.Gpu.Image
                 swizzleB,
                 swizzleA);
 
-            if (IsDepthStencil(formatInfo.Format))
+            if (formatInfo.Format.IsDepthOrStencil())
             {
                 swizzleR = SwizzleComponent.Red;
                 swizzleG = SwizzleComponent.Red;
@@ -263,26 +263,6 @@ namespace Ryujinx.Graphics.Gpu.Image
                    component == SwizzleComponent.Green;
         }
 
-        /// <summary>
-        /// Checks if the texture format is a depth, stencil or depth-stencil format.
-        /// </summary>
-        /// <param name="format">Texture format</param>
-        /// <returns>True if the format is a depth, stencil or depth-stencil format, false otherwise</returns>
-        private static bool IsDepthStencil(Format format)
-        {
-            switch (format)
-            {
-                case Format.D16Unorm:
-                case Format.D24UnormS8Uint:
-                case Format.D24X8Unorm:
-                case Format.D32Float:
-                case Format.D32FloatS8Uint:
-                    return true;
-            }
-
-            return false;
-        }
-
         /// <summary>
         /// Decrements the reference count of the texture.
         /// This indicates that the texture pool is not using it anymore.

+ 14 - 0
Ryujinx.Graphics.Gpu/Image/TextureScaleMode.cs

@@ -0,0 +1,14 @@
+namespace Ryujinx.Graphics.Gpu.Image
+{
+    /// <summary>
+    /// The scale mode for a given texture.
+    /// Blacklisted textures cannot be scaled, Eligible textures have not been scaled yet,
+    /// and Scaled textures have been scaled already.
+    /// </summary>
+    enum TextureScaleMode
+    {
+        Eligible = 0,
+        Scaled = 1,
+        Blacklisted = 2
+    }
+}

+ 2 - 1
Ryujinx.Graphics.Gpu/Image/TextureSearchFlags.cs

@@ -11,6 +11,7 @@ namespace Ryujinx.Graphics.Gpu.Image
         None     = 0,
         IgnoreMs = 1 << 0,
         Strict   = 1 << 1 | Sampler,
-        Sampler  = 1 << 2
+        Sampler  = 1 << 2,
+        WithUpscale = 1 << 3
     }
 }

+ 1 - 1
Ryujinx.Graphics.Gpu/Window.cs

@@ -146,7 +146,7 @@ namespace Ryujinx.Graphics.Gpu
             {
                 pt.AcquireCallback(_context, pt.UserObj);
 
-                Texture texture = _context.Methods.TextureManager.FindOrCreateTexture(pt.Info);
+                Texture texture = _context.Methods.TextureManager.FindOrCreateTexture(pt.Info, TextureSearchFlags.WithUpscale);
 
                 texture.SynchronizeMemory();
 

+ 8 - 3
Ryujinx.Graphics.OpenGL/Image/TextureBase.cs

@@ -1,5 +1,6 @@
 using OpenTK.Graphics.OpenGL;
 using Ryujinx.Graphics.GAL;
+using System;
 
 namespace Ryujinx.Graphics.OpenGL.Image
 {
@@ -9,15 +10,19 @@ namespace Ryujinx.Graphics.OpenGL.Image
 
         protected TextureCreateInfo Info { get; }
 
-        public int Width => Info.Width;
-        public int Height => Info.Height;
+        public int Width { get; }
+        public int Height { get; }
+        public float ScaleFactor { get; }
 
         public Target Target => Info.Target;
         public Format Format => Info.Format;
 
-        public TextureBase(TextureCreateInfo info)
+        public TextureBase(TextureCreateInfo info, float scaleFactor = 1f)
         {
             Info = info;
+            Width = (int)Math.Ceiling(Info.Width * scaleFactor);
+            Height = (int)Math.Ceiling(Info.Height * scaleFactor);
+            ScaleFactor = scaleFactor;
 
             Handle = GL.GenTexture();
         }

+ 8 - 0
Ryujinx.Graphics.OpenGL/Image/TextureCopy.cs

@@ -33,6 +33,11 @@ namespace Ryujinx.Graphics.OpenGL.Image
 
             ClearBufferMask mask = GetMask(src.Format);
 
+            if ((mask & (ClearBufferMask.DepthBufferBit | ClearBufferMask.StencilBufferBit)) != 0 || src.Format.IsInteger())
+            {
+                linearFilter = false;
+            }
+
             BlitFramebufferFilter filter = linearFilter
                 ? BlitFramebufferFilter.Linear
                 : BlitFramebufferFilter.Nearest;
@@ -55,6 +60,9 @@ namespace Ryujinx.Graphics.OpenGL.Image
                 mask,
                 filter);
 
+            Attach(FramebufferTarget.ReadFramebuffer, src.Format, 0);
+            Attach(FramebufferTarget.DrawFramebuffer, dst.Format, 0);
+
             GL.BindFramebuffer(FramebufferTarget.ReadFramebuffer, oldReadFramebufferHandle);
             GL.BindFramebuffer(FramebufferTarget.DrawFramebuffer, oldDrawFramebufferHandle);
 

+ 6 - 5
Ryujinx.Graphics.OpenGL/Image/TextureCopyUnscaled.cs

@@ -15,15 +15,16 @@ namespace Ryujinx.Graphics.OpenGL.Image
             int srcLayer,
             int dstLayer,
             int srcLevel,
-            int dstLevel)
+            int dstLevel,
+            float scaleFactor = 1f)
         {
-            int srcWidth  = srcInfo.Width;
-            int srcHeight = srcInfo.Height;
+            int srcWidth  = (int)Math.Ceiling(srcInfo.Width * scaleFactor);
+            int srcHeight = (int)Math.Ceiling(srcInfo.Height * scaleFactor);
             int srcDepth  = srcInfo.GetDepthOrLayers();
             int srcLevels = srcInfo.Levels;
 
-            int dstWidth  = dstInfo.Width;
-            int dstHeight = dstInfo.Height;
+            int dstWidth  = (int)Math.Ceiling(dstInfo.Width * scaleFactor);
+            int dstHeight = (int)Math.Ceiling(dstInfo.Height * scaleFactor);
             int dstDepth  = dstInfo.GetDepthOrLayers();
             int dstLevels = dstInfo.Levels;
 

+ 24 - 18
Ryujinx.Graphics.OpenGL/Image/TextureStorage.cs

@@ -1,12 +1,14 @@
 using OpenTK.Graphics.OpenGL;
 using Ryujinx.Common.Logging;
 using Ryujinx.Graphics.GAL;
+using System;
 
 namespace Ryujinx.Graphics.OpenGL.Image
 {
     class TextureStorage
     {
         public int Handle { get; private set; }
+        public float ScaleFactor { get; private set; }
 
         public TextureCreateInfo Info { get; }
 
@@ -14,12 +16,13 @@ namespace Ryujinx.Graphics.OpenGL.Image
 
         private int _viewsCount;
 
-        public TextureStorage(Renderer renderer, TextureCreateInfo info)
+        public TextureStorage(Renderer renderer, TextureCreateInfo info, float scaleFactor)
         {
             _renderer = renderer;
             Info      = info;
 
             Handle = GL.GenTexture();
+            ScaleFactor = scaleFactor;
 
             CreateImmutableStorage();
         }
@@ -32,6 +35,9 @@ namespace Ryujinx.Graphics.OpenGL.Image
 
             GL.BindTexture(target, Handle);
 
+            int width = (int)Math.Ceiling(Info.Width * ScaleFactor);
+            int height = (int)Math.Ceiling(Info.Height * ScaleFactor);
+
             FormatInfo format = FormatTable.GetFormatInfo(Info.Format);
 
             SizedInternalFormat internalFormat;
@@ -52,7 +58,7 @@ namespace Ryujinx.Graphics.OpenGL.Image
                         TextureTarget1d.Texture1D,
                         Info.Levels,
                         internalFormat,
-                        Info.Width);
+                        width);
                     break;
 
                 case Target.Texture1DArray:
@@ -60,8 +66,8 @@ namespace Ryujinx.Graphics.OpenGL.Image
                         TextureTarget2d.Texture1DArray,
                         Info.Levels,
                         internalFormat,
-                        Info.Width,
-                        Info.Height);
+                        width,
+                        height);
                     break;
 
                 case Target.Texture2D:
@@ -69,8 +75,8 @@ namespace Ryujinx.Graphics.OpenGL.Image
                         TextureTarget2d.Texture2D,
                         Info.Levels,
                         internalFormat,
-                        Info.Width,
-                        Info.Height);
+                        width,
+                        height);
                     break;
 
                 case Target.Texture2DArray:
@@ -78,8 +84,8 @@ namespace Ryujinx.Graphics.OpenGL.Image
                         TextureTarget3d.Texture2DArray,
                         Info.Levels,
                         internalFormat,
-                        Info.Width,
-                        Info.Height,
+                        width,
+                        height,
                         Info.Depth);
                     break;
 
@@ -88,8 +94,8 @@ namespace Ryujinx.Graphics.OpenGL.Image
                         TextureTargetMultisample2d.Texture2DMultisample,
                         Info.Samples,
                         internalFormat,
-                        Info.Width,
-                        Info.Height,
+                        width,
+                        height,
                         true);
                     break;
 
@@ -98,8 +104,8 @@ namespace Ryujinx.Graphics.OpenGL.Image
                         TextureTargetMultisample3d.Texture2DMultisampleArray,
                         Info.Samples,
                         internalFormat,
-                        Info.Width,
-                        Info.Height,
+                        width,
+                        height,
                         Info.Depth,
                         true);
                     break;
@@ -109,8 +115,8 @@ namespace Ryujinx.Graphics.OpenGL.Image
                         TextureTarget3d.Texture3D,
                         Info.Levels,
                         internalFormat,
-                        Info.Width,
-                        Info.Height,
+                        width,
+                        height,
                         Info.Depth);
                     break;
 
@@ -119,8 +125,8 @@ namespace Ryujinx.Graphics.OpenGL.Image
                         TextureTarget2d.TextureCubeMap,
                         Info.Levels,
                         internalFormat,
-                        Info.Width,
-                        Info.Height);
+                        width,
+                        height);
                     break;
 
                 case Target.CubemapArray:
@@ -128,8 +134,8 @@ namespace Ryujinx.Graphics.OpenGL.Image
                         (TextureTarget3d)All.TextureCubeMapArray,
                         Info.Levels,
                         internalFormat,
-                        Info.Width,
-                        Info.Height,
+                        width,
+                        height,
                         Info.Depth);
                     break;
 

+ 8 - 7
Ryujinx.Graphics.OpenGL/Image/TextureView.cs

@@ -22,7 +22,7 @@ namespace Ryujinx.Graphics.OpenGL.Image
             TextureStorage    parent,
             TextureCreateInfo info,
             int               firstLayer,
-            int               firstLevel) : base(info)
+            int               firstLevel) : base(info, parent.ScaleFactor)
         {
             _renderer = renderer;
             _parent   = parent;
@@ -101,7 +101,7 @@ namespace Ryujinx.Graphics.OpenGL.Image
                 // So we emulate that here with a texture copy (see the first CopyTo overload).
                 // However right now it only does a single copy right after the view is created,
                 // so it doesn't work for all cases.
-                TextureView emulatedView = (TextureView)_renderer.CreateTexture(info);
+                TextureView emulatedView = (TextureView)_renderer.CreateTexture(info, ScaleFactor);
 
                 emulatedView._emulatedViewParent = this;
 
@@ -122,10 +122,10 @@ namespace Ryujinx.Graphics.OpenGL.Image
             {
                 if (_incompatibleFormatView == null)
                 {
-                    _incompatibleFormatView = (TextureView)_renderer.CreateTexture(Info);
+                    _incompatibleFormatView = (TextureView)_renderer.CreateTexture(Info, ScaleFactor);
                 }
 
-                TextureCopyUnscaled.Copy(_parent.Info, _incompatibleFormatView.Info, _parent.Handle, _incompatibleFormatView.Handle, FirstLayer, 0, FirstLevel, 0);
+                TextureCopyUnscaled.Copy(_parent.Info, _incompatibleFormatView.Info, _parent.Handle, _incompatibleFormatView.Handle, FirstLayer, 0, FirstLevel, 0, ScaleFactor);
 
                 return _incompatibleFormatView.Handle;
             }
@@ -137,7 +137,7 @@ namespace Ryujinx.Graphics.OpenGL.Image
         {
             if (_incompatibleFormatView != null)
             {
-                TextureCopyUnscaled.Copy(_incompatibleFormatView.Info, _parent.Info, _incompatibleFormatView.Handle, _parent.Handle, 0, FirstLayer, 0, FirstLevel);
+                TextureCopyUnscaled.Copy(_incompatibleFormatView.Info, _parent.Info, _incompatibleFormatView.Handle, _parent.Handle, 0, FirstLayer, 0, FirstLevel, ScaleFactor);
             }
         }
 
@@ -145,7 +145,7 @@ namespace Ryujinx.Graphics.OpenGL.Image
         {
             TextureView destinationView = (TextureView)destination;
 
-            TextureCopyUnscaled.Copy(Info, destinationView.Info, Handle, destinationView.Handle, 0, firstLayer, 0, firstLevel);
+            TextureCopyUnscaled.Copy(Info, destinationView.Info, Handle, destinationView.Handle, 0, firstLayer, 0, firstLevel, ScaleFactor);
 
             if (destinationView._emulatedViewParent != null)
             {
@@ -157,7 +157,8 @@ namespace Ryujinx.Graphics.OpenGL.Image
                     0,
                     destinationView.FirstLayer,
                     0,
-                    destinationView.FirstLevel);
+                    destinationView.FirstLevel,
+                    ScaleFactor);
             }
         }
 

+ 84 - 0
Ryujinx.Graphics.OpenGL/Pipeline.cs

@@ -31,7 +31,12 @@ namespace Ryujinx.Graphics.OpenGL
         private int _boundDrawFramebuffer;
         private int _boundReadFramebuffer;
 
+        private float[] _fpRenderScale = new float[33];
+        private float[] _cpRenderScale = new float[32];
+
         private TextureBase _unit0Texture;
+        private TextureBase _rtColor0Texture;
+        private TextureBase _rtDepthTexture;
 
         private ClipOrigin _clipOrigin;
         private ClipDepthMode _clipDepthMode;
@@ -54,6 +59,16 @@ namespace Ryujinx.Graphics.OpenGL
             {
                 _componentMasks[index] = 0xf;
             }
+
+            for (int index = 0; index < _fpRenderScale.Length; index++)
+            {
+                _fpRenderScale[index] = 1f;
+            }
+
+            for (int index = 0; index < _cpRenderScale.Length; index++)
+            {
+                _cpRenderScale[index] = 1f;
+            }
         }
 
         public void Barrier()
@@ -685,6 +700,8 @@ namespace Ryujinx.Graphics.OpenGL
         {
             _program = (Program)program;
             _program.Bind();
+
+            SetRenderTargetScale(_fpRenderScale[0]);
         }
 
         public void SetRasterizerDiscard(bool discard)
@@ -701,6 +718,16 @@ namespace Ryujinx.Graphics.OpenGL
             _rasterizerDiscard = discard;
         }
 
+        public void SetRenderTargetScale(float scale)
+        {
+            _fpRenderScale[0] = scale;
+
+            if (_program != null && _program.FragmentRenderScaleUniform != -1)
+            {
+                GL.Uniform1(_program.FragmentRenderScaleUniform, 1, _fpRenderScale); // Just the first element.
+            }
+        }
+
         public void SetRenderTargetColorMasks(ReadOnlySpan<uint> componentMasks)
         {
             for (int index = 0; index < componentMasks.Length; index++)
@@ -715,6 +742,9 @@ namespace Ryujinx.Graphics.OpenGL
         {
             EnsureFramebuffer();
 
+            _rtColor0Texture = (TextureBase)colors[0];
+            _rtDepthTexture = (TextureBase)depthStencil;
+
             for (int index = 0; index < colors.Length; index++)
             {
                 TextureView color = (TextureView)colors[index];
@@ -826,6 +856,37 @@ namespace Ryujinx.Graphics.OpenGL
                 {
                     ((TextureBase)texture).Bind(unit);
                 }
+
+                // Update scale factor for bound textures.
+
+                switch (stage)
+                {
+                    case ShaderStage.Fragment:
+                        if (_program.FragmentRenderScaleUniform != -1)
+                        {
+                            // Only update and send sampled texture scales if the shader uses them.
+                            bool interpolate = false;
+                            float scale = texture.ScaleFactor;
+
+                            if (scale != 1)
+                            {
+                                TextureBase activeTarget = _rtColor0Texture ?? _rtDepthTexture;
+
+                                if (activeTarget != null && activeTarget.Width / (float)texture.Width == activeTarget.Height / (float)texture.Height)
+                                {
+                                    // If the texture's size is a multiple of the sampler size, enable interpolation using gl_FragCoord. (helps "invent" new integer values between scaled pixels)
+                                    interpolate = true;
+                                }
+                            }
+
+                            _fpRenderScale[index + 1] = interpolate ? -scale : scale;
+                        }
+                        break;
+
+                    case ShaderStage.Compute:
+                        _cpRenderScale[index] = texture.ScaleFactor;
+                        break;
+                }
             }
         }
 
@@ -1089,5 +1150,28 @@ namespace Ryujinx.Graphics.OpenGL
             _framebuffer?.Dispose();
             _vertexArray?.Dispose();
         }
+
+        public void UpdateRenderScale(ShaderStage stage, int textureCount)
+        {
+            if (_program != null)
+            {
+                switch (stage)
+                {
+                    case ShaderStage.Fragment:
+                        if (_program.FragmentRenderScaleUniform != -1)
+                        {
+                            GL.Uniform1(_program.FragmentRenderScaleUniform, textureCount + 1, _fpRenderScale);
+                        }
+                        break;
+
+                    case ShaderStage.Compute:
+                        if (_program.ComputeRenderScaleUniform != -1)
+                        {
+                            GL.Uniform1(_program.ComputeRenderScaleUniform, textureCount, _cpRenderScale);
+                        }
+                        break;
+                }
+            }
+        }
     }
 }

+ 6 - 0
Ryujinx.Graphics.OpenGL/Program.cs

@@ -21,6 +21,9 @@ namespace Ryujinx.Graphics.OpenGL
 
         public int Handle { get; private set; }
 
+        public int FragmentRenderScaleUniform { get; }
+        public int ComputeRenderScaleUniform { get; }
+
         public bool IsLinked { get; private set; }
 
         private int[] _ubBindingPoints;
@@ -162,6 +165,9 @@ namespace Ryujinx.Graphics.OpenGL
                     imageUnit++;
                 }
             }
+
+            FragmentRenderScaleUniform = GL.GetUniformLocation(Handle, "fp_renderScale");
+            ComputeRenderScaleUniform = GL.GetUniformLocation(Handle, "cp_renderScale");
         }
 
         public void Bind()

+ 2 - 2
Ryujinx.Graphics.OpenGL/Renderer.cs

@@ -54,9 +54,9 @@ namespace Ryujinx.Graphics.OpenGL
             return new Sampler(info);
         }
 
-        public ITexture CreateTexture(TextureCreateInfo info)
+        public ITexture CreateTexture(TextureCreateInfo info, float scaleFactor)
         {
-            return info.Target == Target.TextureBuffer ? new TextureBuffer(info) : new TextureStorage(this, info).CreateDefaultView();
+            return info.Target == Target.TextureBuffer ? new TextureBuffer(info) : new TextureStorage(this, info, scaleFactor).CreateDefaultView();
         }
 
         public void DeleteBuffer(BufferHandle buffer)

+ 11 - 2
Ryujinx.Graphics.OpenGL/Window.cs

@@ -65,11 +65,12 @@ namespace Ryujinx.Graphics.OpenGL
             GL.Clear(ClearBufferMask.ColorBufferBit);
 
             int srcX0, srcX1, srcY0, srcY1;
+            float scale = view.ScaleFactor;
 
             if (crop.Left == 0 && crop.Right == 0)
             {
                 srcX0 = 0;
-                srcX1 = view.Width;
+                srcX1 = (int)(view.Width / scale);
             }
             else
             {
@@ -80,7 +81,7 @@ namespace Ryujinx.Graphics.OpenGL
             if (crop.Top == 0 && crop.Bottom == 0)
             {
                 srcY0 = 0;
-                srcY1 = view.Height;
+                srcY1 = (int)(view.Height / scale);
             }
             else
             {
@@ -88,6 +89,14 @@ namespace Ryujinx.Graphics.OpenGL
                 srcY1 = crop.Bottom;
             }
 
+            if (scale != 1f)
+            {
+                srcX0 = (int)(srcX0 * scale);
+                srcY0 = (int)(srcY0 * scale);
+                srcX1 = (int)Math.Ceiling(srcX1 * scale);
+                srcY1 = (int)Math.Ceiling(srcY1 * scale);
+            }
+
             float ratioX = MathF.Min(1f, (_height * (float)NativeWidth)  / ((float)NativeHeight * _width));
             float ratioY = MathF.Min(1f, (_width  * (float)NativeHeight) / ((float)NativeWidth  * _height));
 

+ 17 - 0
Ryujinx.Graphics.Shader/CodeGen/Glsl/CodeGenContext.cs

@@ -1,3 +1,5 @@
+using Ryujinx.Graphics.Shader.IntermediateRepresentation;
+using Ryujinx.Graphics.Shader.StructuredIr;
 using Ryujinx.Graphics.Shader.Translation;
 using System.Collections.Generic;
 using System.Text;
@@ -75,6 +77,21 @@ namespace Ryujinx.Graphics.Shader.CodeGen.Glsl
             AppendLine("}" + suffix);
         }
 
+        public int FindTextureDescriptorIndex(AstTextureOperation texOp)
+        {
+            AstOperand operand = texOp.GetSource(0) as AstOperand;
+            bool bindless = (texOp.Flags & TextureFlags.Bindless) > 0;
+
+            int cBufSlot = bindless ? operand.CbufSlot : 0;
+            int cBufOffset = bindless ? operand.CbufOffset : 0;
+
+            return TextureDescriptors.FindIndex(descriptor => 
+                descriptor.Type == texOp.Type && 
+                descriptor.HandleIndex == texOp.Handle && 
+                descriptor.CbufSlot == cBufSlot &&
+                descriptor.CbufOffset == cBufOffset);
+        }
+
         private void UpdateIndentation()
         {
             _indentation = GetIndentation(_level);

+ 35 - 0
Ryujinx.Graphics.Shader/CodeGen/Glsl/Declarations.cs

@@ -137,6 +137,14 @@ namespace Ryujinx.Graphics.Shader.CodeGen.Glsl
                 context.AppendLine();
             }
 
+            if (context.Config.Stage == ShaderStage.Fragment || context.Config.Stage == ShaderStage.Compute)
+            {
+                if (DeclareRenderScale(context))
+                {
+                    context.AppendLine();
+                }
+            }
+
             if ((info.HelperFunctionsMask & HelperFunctionsMask.MultiplyHighS32) != 0)
             {
                 AppendHelperFunction(context, "Ryujinx.Graphics.Shader/CodeGen/Glsl/HelperFunctions/MultiplyHighS32.glsl");
@@ -219,6 +227,33 @@ namespace Ryujinx.Graphics.Shader.CodeGen.Glsl
             }
         }
 
+        private static bool DeclareRenderScale(CodeGenContext context)
+        {
+            if ((context.Config.UsedFeatures & (FeatureFlags.FragCoordXY | FeatureFlags.IntegerSampling)) != 0)
+            {
+                string stage = OperandManager.GetShaderStagePrefix(context.Config.Stage);
+
+                int scaleElements = context.TextureDescriptors.Count;
+                
+                if (context.Config.Stage == ShaderStage.Fragment)
+                {
+                    scaleElements++; // Also includes render target scale, for gl_FragCoord.
+                }
+
+                context.AppendLine($"uniform float {stage}_renderScale[{scaleElements}];");
+
+                if (context.Config.UsedFeatures.HasFlag(FeatureFlags.IntegerSampling))
+                {
+                    context.AppendLine();
+                    AppendHelperFunction(context, $"Ryujinx.Graphics.Shader/CodeGen/Glsl/HelperFunctions/TexelFetchScale_{stage}.glsl");
+                }
+
+                return true;
+            }
+
+            return false;
+        }
+
         private static void DeclareStorages(CodeGenContext context, StructuredProgramInfo info)
         {
             string sbName = OperandManager.GetShaderStagePrefix(context.Config.Stage);

+ 7 - 0
Ryujinx.Graphics.Shader/CodeGen/Glsl/HelperFunctions/TexelFetchScale_cp.glsl

@@ -0,0 +1,7 @@
+ivec2 Helper_TexelFetchScale(ivec2 inputVec, int samplerIndex) {
+    float scale = cp_renderScale[samplerIndex];
+    if (scale == 1.0) {
+        return inputVec;
+    }
+    return ivec2(vec2(inputVec) * scale);
+}

+ 11 - 0
Ryujinx.Graphics.Shader/CodeGen/Glsl/HelperFunctions/TexelFetchScale_fp.glsl

@@ -0,0 +1,11 @@
+ivec2 Helper_TexelFetchScale(ivec2 inputVec, int samplerIndex) {
+    float scale = fp_renderScale[1 + samplerIndex];
+    if (scale == 1.0) {
+        return inputVec;
+    }
+    if (scale < 0.0) { // If less than 0, try interpolate between texels by using the screen position.
+        return ivec2(vec2(inputVec) * (-scale) + mod(gl_FragCoord.xy, -scale));
+    } else {
+        return ivec2(vec2(inputVec) * scale);
+    }
+}

+ 28 - 1
Ryujinx.Graphics.Shader/CodeGen/Glsl/Instructions/InstGenMemory.cs

@@ -390,7 +390,34 @@ namespace Ryujinx.Graphics.Shader.CodeGen.Glsl.Instructions
                 }
             }
 
-            Append(AssemblePVector(pCount));
+            string ApplyScaling(string vector)
+            {
+                if (intCoords)
+                {
+                    int index = context.FindTextureDescriptorIndex(texOp);
+
+                    if ((context.Config.Stage == ShaderStage.Fragment || context.Config.Stage == ShaderStage.Compute) &&
+                        (texOp.Flags & TextureFlags.Bindless) == 0 &&
+                        texOp.Type != SamplerType.Indexed &&
+                        pCount == 2)
+                    {
+                        return "Helper_TexelFetchScale(" + vector + ", " + index + ")";
+                    }
+                    else
+                    {
+                        // Resolution scaling cannot be applied to this texture right now.
+                        // Flag so that we know to blacklist scaling on related textures when binding them.
+
+                        TextureDescriptor descriptor = context.TextureDescriptors[index];
+                        descriptor.Flags |= TextureUsageFlags.ResScaleUnsupported;
+                        context.TextureDescriptors[index] = descriptor;
+                    }
+                }
+
+                return vector;
+            }
+
+            Append(ApplyScaling(AssemblePVector(pCount)));
 
             string AssembleDerivativesVector(int count)
             {

+ 2 - 2
Ryujinx.Graphics.Shader/CodeGen/Glsl/OperandManager.cs

@@ -185,8 +185,8 @@ namespace Ryujinx.Graphics.Shader.CodeGen.Glsl
                     {
                         switch (value & ~3)
                         {
-                            case AttributeConsts.PositionX: return "gl_FragCoord.x";
-                            case AttributeConsts.PositionY: return "gl_FragCoord.y";
+                            case AttributeConsts.PositionX: return "(gl_FragCoord.x / fp_renderScale[0])";
+                            case AttributeConsts.PositionY: return "(gl_FragCoord.y / fp_renderScale[0])";
                             case AttributeConsts.PositionZ: return "gl_FragCoord.z";
                             case AttributeConsts.PositionW: return "gl_FragCoord.w";
                         }

+ 4 - 0
Ryujinx.Graphics.Shader/Instructions/InstEmitMemory.cs

@@ -32,6 +32,8 @@ namespace Ryujinx.Graphics.Shader.Instructions
 
                 Operand src = Attribute(op.AttributeOffset + index * 4);
 
+                context.FlagAttributeRead(src.Value);
+
                 context.Copy(Register(rd), context.LoadAttribute(src, primVertex));
             }
         }
@@ -96,6 +98,8 @@ namespace Ryujinx.Graphics.Shader.Instructions
         {
             OpCodeIpa op = (OpCodeIpa)context.CurrOp;
 
+            context.FlagAttributeRead(op.AttributeOffset);
+
             Operand res = Attribute(op.AttributeOffset);
 
             if (op.AttributeOffset >= AttributeConsts.UserAttributeBase &&

+ 6 - 0
Ryujinx.Graphics.Shader/Instructions/InstEmitTexture.cs

@@ -283,11 +283,15 @@ namespace Ryujinx.Graphics.Shader.Instructions
 
         public static void Tld(EmitterContext context)
         {
+            context.UsedFeatures |= FeatureFlags.IntegerSampling;
+
             EmitTextureSample(context, TextureFlags.IntCoords);
         }
 
         public static void TldB(EmitterContext context)
         {
+            context.UsedFeatures |= FeatureFlags.IntegerSampling;
+
             EmitTextureSample(context, TextureFlags.IntCoords | TextureFlags.Bindless);
         }
 
@@ -428,6 +432,8 @@ namespace Ryujinx.Graphics.Shader.Instructions
                     return;
                 }
 
+                context.UsedFeatures |= FeatureFlags.IntegerSampling;
+
                 flags = ConvertTextureFlags(tldsOp.Target) | TextureFlags.IntCoords;
 
                 if (tldsOp.Target == TexelLoadTarget.Texture1DLodZero && context.Config.GpuAccessor.QueryIsTextureBuffer(tldsOp.Immediate))

+ 2 - 0
Ryujinx.Graphics.Shader/Ryujinx.Graphics.Shader.csproj

@@ -8,6 +8,8 @@
     <EmbeddedResource Include="CodeGen\Glsl\HelperFunctions\ShuffleUp.glsl" />
     <EmbeddedResource Include="CodeGen\Glsl\HelperFunctions\ShuffleXor.glsl" />
     <EmbeddedResource Include="CodeGen\Glsl\HelperFunctions\SwizzleAdd.glsl" />
+    <EmbeddedResource Include="CodeGen\Glsl\HelperFunctions\TexelFetchScale_fp.glsl" />
+    <EmbeddedResource Include="CodeGen\Glsl\HelperFunctions\TexelFetchScale_cp.glsl" />
   </ItemGroup>
 
   <ItemGroup>

+ 6 - 0
Ryujinx.Graphics.Shader/TextureDescriptor.cs

@@ -13,6 +13,8 @@ namespace Ryujinx.Graphics.Shader
         public int CbufSlot   { get; }
         public int CbufOffset { get; }
 
+        public TextureUsageFlags Flags { get; set; }
+
         public TextureDescriptor(string name, SamplerType type, int handleIndex)
         {
             Name        = name;
@@ -23,6 +25,8 @@ namespace Ryujinx.Graphics.Shader
 
             CbufSlot   = 0;
             CbufOffset = 0;
+
+            Flags = TextureUsageFlags.None;
         }
 
         public TextureDescriptor(string name, SamplerType type, int cbufSlot, int cbufOffset)
@@ -35,6 +39,8 @@ namespace Ryujinx.Graphics.Shader
 
             CbufSlot   = cbufSlot;
             CbufOffset = cbufOffset;
+
+            Flags = TextureUsageFlags.None;
         }
     }
 }

+ 16 - 0
Ryujinx.Graphics.Shader/TextureUsageFlags.cs

@@ -0,0 +1,16 @@
+using System;
+
+namespace Ryujinx.Graphics.Shader
+{
+    /// <summary>
+    /// Flags that indicate how a texture will be used in a shader.
+    /// </summary>
+    [Flags]
+    public enum TextureUsageFlags
+    {
+        None = 0,
+
+        // Integer sampled textures must be noted for resolution scaling.
+        ResScaleUnsupported = 1 << 0
+    }
+}

+ 16 - 0
Ryujinx.Graphics.Shader/Translation/EmitterContext.cs

@@ -11,6 +11,8 @@ namespace Ryujinx.Graphics.Shader.Translation
         public Block  CurrBlock { get; set; }
         public OpCode CurrOp    { get; set; }
 
+        public FeatureFlags UsedFeatures { get; set; }
+
         public ShaderConfig Config { get; }
 
         private List<Operation> _operations;
@@ -40,6 +42,20 @@ namespace Ryujinx.Graphics.Shader.Translation
             _operations.Add(operation);
         }
 
+        public void FlagAttributeRead(int attribute)
+        {
+            if (Config.Stage == ShaderStage.Fragment)
+            {
+                switch (attribute)
+                {
+                    case AttributeConsts.PositionX:
+                    case AttributeConsts.PositionY:
+                        UsedFeatures |= FeatureFlags.FragCoordXY;
+                        break;
+                }
+            }
+        }
+
         public void MarkLabel(Operand label)
         {
             Add(Instruction.MarkLabel, label);

+ 18 - 0
Ryujinx.Graphics.Shader/Translation/FeatureFlags.cs

@@ -0,0 +1,18 @@
+using System;
+
+namespace Ryujinx.Graphics.Shader.Translation
+{
+    /// <summary>
+    /// Features used by the shader that are important for the code generator to know in advance.
+    /// These typically change the declarations in the shader header.
+    /// </summary>
+    [Flags]
+    public enum FeatureFlags
+    {
+        None = 0,
+
+        // Affected by resolution scaling.
+        FragCoordXY     = 1 << 1,
+        IntegerSampling = 1 << 0
+    }
+}

+ 4 - 0
Ryujinx.Graphics.Shader/Translation/ShaderConfig.cs

@@ -22,6 +22,8 @@ namespace Ryujinx.Graphics.Shader.Translation
 
         public TranslationFlags Flags { get; }
 
+        public FeatureFlags UsedFeatures { get; set; }
+
         public ShaderConfig(IGpuAccessor gpuAccessor, TranslationFlags flags)
         {
             Stage             = ShaderStage.Compute;
@@ -34,6 +36,7 @@ namespace Ryujinx.Graphics.Shader.Translation
             OmapDepth         = false;
             GpuAccessor       = gpuAccessor;
             Flags             = flags;
+            UsedFeatures      = FeatureFlags.None;
         }
 
         public ShaderConfig(ShaderHeader header, IGpuAccessor gpuAccessor, TranslationFlags flags)
@@ -48,6 +51,7 @@ namespace Ryujinx.Graphics.Shader.Translation
             OmapDepth         = header.OmapDepth;
             GpuAccessor       = gpuAccessor;
             Flags             = flags;
+            UsedFeatures      = FeatureFlags.None;
         }
 
         public int GetDepthRegister()

+ 13 - 4
Ryujinx.Graphics.Shader/Translation/Translator.cs

@@ -16,15 +16,19 @@ namespace Ryujinx.Graphics.Shader.Translation
 
         public static ShaderProgram Translate(ulong address, IGpuAccessor gpuAccessor, TranslationFlags flags)
         {
-            Operation[] ops = DecodeShader(address, gpuAccessor, flags, out ShaderConfig config, out int size);
+            Operation[] ops = DecodeShader(address, gpuAccessor, flags, out ShaderConfig config, out int size, out FeatureFlags featureFlags);
+
+            config.UsedFeatures = featureFlags;
 
             return Translate(ops, config, size);
         }
 
         public static ShaderProgram Translate(ulong addressA, ulong addressB, IGpuAccessor gpuAccessor, TranslationFlags flags)
         {
-            Operation[] opsA = DecodeShader(addressA, gpuAccessor, flags | TranslationFlags.VertexA, out _, out int sizeA);
-            Operation[] opsB = DecodeShader(addressB, gpuAccessor, flags, out ShaderConfig config, out int sizeB);
+            Operation[] opsA = DecodeShader(addressA, gpuAccessor, flags | TranslationFlags.VertexA, out _, out int sizeA, out FeatureFlags featureFlagsA);
+            Operation[] opsB = DecodeShader(addressB, gpuAccessor, flags, out ShaderConfig config, out int sizeB, out FeatureFlags featureFlagsB);
+
+            config.UsedFeatures = featureFlagsA | featureFlagsB;
 
             return Translate(Combine(opsA, opsB), config, sizeB, sizeA);
         }
@@ -67,7 +71,8 @@ namespace Ryujinx.Graphics.Shader.Translation
             IGpuAccessor     gpuAccessor,
             TranslationFlags flags,
             out ShaderConfig config,
-            out int          size)
+            out int          size,
+            out FeatureFlags featureFlags)
         {
             Block[] cfg;
 
@@ -90,6 +95,8 @@ namespace Ryujinx.Graphics.Shader.Translation
 
                 size = 0;
 
+                featureFlags = FeatureFlags.None;
+
                 return Array.Empty<Operation>();
             }
 
@@ -192,6 +199,8 @@ namespace Ryujinx.Graphics.Shader.Translation
 
             size = (int)maxEndAddress + (((flags & TranslationFlags.Compute) != 0) ? 0 : HeaderSize);
 
+            featureFlags = context.UsedFeatures;
+
             return context.GetOperations();
         }
 

+ 3 - 1
Ryujinx/Config.json

@@ -1,5 +1,7 @@
 {
-  "version": 10,
+  "version": 11,
+  "res_scale": 2,
+  "res_scale_custom": 1,
   "max_anisotropy": -1,
   "graphics_shaders_dump_path": "",
   "logging_enable_debug": false,

+ 5 - 0
Ryujinx/Ui/GLRenderer.cs

@@ -328,6 +328,11 @@ namespace Ryujinx.Ui
                 }
 
                 string dockedMode = ConfigurationState.Instance.System.EnableDockedMode ? "Docked" : "Handheld";
+                float scale = Graphics.Gpu.GraphicsConfig.ResScale;
+                if (scale != 1)
+                {
+                    dockedMode += $" ({scale}x)";
+                }
 
                 if (_ticks >= _ticksPerFrame)
                 {

+ 10 - 3
Ryujinx/Ui/MainWindow.cs

@@ -390,9 +390,7 @@ namespace Ryujinx.Ui
 
                 HLE.Switch device = InitializeSwitchInstance();
 
-                // TODO: Move this somewhere else + reloadable?
-                Graphics.Gpu.GraphicsConfig.MaxAnisotropy   = ConfigurationState.Instance.Graphics.MaxAnisotropy;
-                Graphics.Gpu.GraphicsConfig.ShadersDumpPath = ConfigurationState.Instance.Graphics.ShadersDumpPath;
+                UpdateGraphicsConfig();
 
                 Logger.PrintInfo(LogClass.Application, $"Using Firmware Version: {_contentManager.GetCurrentFirmwareVersion()?.VersionString}");
 
@@ -605,6 +603,15 @@ namespace Ryujinx.Ui
             }
         }
 
+        public static void UpdateGraphicsConfig()
+        {
+            int resScale = ConfigurationState.Instance.Graphics.ResScale;
+            float resScaleCustom = ConfigurationState.Instance.Graphics.ResScaleCustom;
+            Graphics.Gpu.GraphicsConfig.ResScale = (resScale == -1) ? resScaleCustom : resScale;
+            Graphics.Gpu.GraphicsConfig.MaxAnisotropy = ConfigurationState.Instance.Graphics.MaxAnisotropy;
+            Graphics.Gpu.GraphicsConfig.ShadersDumpPath = ConfigurationState.Instance.Graphics.ShadersDumpPath;
+        }
+
         public static void SaveConfig()
         {
             ConfigurationState.Instance.ToFileFormat().SaveConfig(Program.ConfigurationPath);

+ 16 - 0
Ryujinx/Ui/SettingsWindow.cs

@@ -63,6 +63,8 @@ namespace Ryujinx.Ui
         [GUI] Entry        _addGameDirBox;
         [GUI] Entry        _graphicsShadersDumpPath;
         [GUI] ComboBoxText _anisotropy;
+        [GUI] ComboBoxText _resScaleCombo;
+        [GUI] Entry        _resScaleText;
         [GUI] ToggleButton _configureController1;
         [GUI] ToggleButton _configureController2;
         [GUI] ToggleButton _configureController3;
@@ -95,6 +97,8 @@ namespace Ryujinx.Ui
             _configureController8.Pressed += (sender, args) => ConfigureController_Pressed(sender, args, PlayerIndex.Player8);
             _configureControllerH.Pressed += (sender, args) => ConfigureController_Pressed(sender, args, PlayerIndex.Handheld);
 
+            _resScaleCombo.Changed += (sender, args) => _resScaleText.Visible = _resScaleCombo.ActiveId == "-1";
+
             //Setup Currents
             if (ConfigurationState.Instance.Logger.EnableFileLog)
             {
@@ -204,9 +208,12 @@ namespace Ryujinx.Ui
             _systemRegionSelect.SetActiveId(ConfigurationState.Instance.System.Region.Value.ToString());
             _audioBackendSelect.SetActiveId(ConfigurationState.Instance.System.AudioBackend.Value.ToString());
             _systemTimeZoneSelect.SetActiveId(timeZoneContentManager.SanityCheckDeviceLocationName());
+            _resScaleCombo.SetActiveId(ConfigurationState.Instance.Graphics.ResScale.Value.ToString());
             _anisotropy.SetActiveId(ConfigurationState.Instance.Graphics.MaxAnisotropy.Value.ToString());
 
             _custThemePath.Buffer.Text           = ConfigurationState.Instance.Ui.CustomThemePath;
+            _resScaleText.Buffer.Text            = ConfigurationState.Instance.Graphics.ResScaleCustom.Value.ToString();
+            _resScaleText.Visible                = _resScaleCombo.ActiveId == "-1";
             _graphicsShadersDumpPath.Buffer.Text = ConfigurationState.Instance.Graphics.ShadersDumpPath;
             _fsLogSpinAdjustment.Value           = ConfigurationState.Instance.System.FsGlobalAccessLogMode;
             _systemTimeOffset                    = ConfigurationState.Instance.System.SystemTimeOffset;
@@ -408,6 +415,12 @@ namespace Ryujinx.Ui
                 _gameDirsBoxStore.IterNext(ref treeIter);
             }
 
+            float resScaleCustom;
+            if (!float.TryParse(_resScaleText.Buffer.Text, out resScaleCustom) || resScaleCustom <= 0.0f)
+            {
+                resScaleCustom = 1.0f;
+            }
+
             ConfigurationState.Instance.Logger.EnableError.Value               = _errorLogToggle.Active;
             ConfigurationState.Instance.Logger.EnableWarn.Value                = _warningLogToggle.Active;
             ConfigurationState.Instance.Logger.EnableInfo.Value                = _infoLogToggle.Active;
@@ -435,8 +448,11 @@ namespace Ryujinx.Ui
             ConfigurationState.Instance.Ui.GameDirs.Value                      = gameDirs;
             ConfigurationState.Instance.System.FsGlobalAccessLogMode.Value     = (int)_fsLogSpinAdjustment.Value;
             ConfigurationState.Instance.Graphics.MaxAnisotropy.Value           = float.Parse(_anisotropy.ActiveId);
+            ConfigurationState.Instance.Graphics.ResScale.Value                = int.Parse(_resScaleCombo.ActiveId);
+            ConfigurationState.Instance.Graphics.ResScaleCustom.Value          = resScaleCustom;
 
             MainWindow.SaveConfig();
+            MainWindow.UpdateGraphicsConfig();
             MainWindow.ApplyTheme();
             Dispose();
         }

+ 65 - 1
Ryujinx/Ui/SettingsWindow.glade

@@ -1677,6 +1677,70 @@
                                 <property name="margin_left">10</property>
                                 <property name="margin_right">10</property>
                                 <property name="orientation">vertical</property>
+                                <child>
+                                  <object class="GtkBox">
+                                    <property name="visible">True</property>
+                                    <property name="can_focus">False</property>
+                                    <property name="margin_top">5</property>
+                                    <property name="margin_bottom">5</property>
+                                    <child>
+                                      <object class="GtkLabel">
+                                        <property name="visible">True</property>
+                                        <property name="can_focus">False</property>
+                                        <property name="tooltip_text" translatable="yes">Resolution Scale applied to applicable render targets.</property>
+                                        <property name="label" translatable="yes">Resolution Scale:</property>
+                                      </object>
+                                      <packing>
+                                        <property name="expand">False</property>
+                                        <property name="fill">True</property>
+                                        <property name="padding">5</property>
+                                        <property name="position">0</property>
+                                      </packing>
+                                    </child>
+                                    <child>
+                                      <object class="GtkComboBoxText" id="_resScaleCombo">
+                                        <property name="visible">True</property>
+                                        <property name="can_focus">False</property>
+                                        <property name="tooltip_text" translatable="yes">Resolution Scale applied to applicable render targets.</property>
+                                        <property name="active_id">1</property>
+                                        <items>
+                                          <item id="1" translatable="yes">Native (720p/1080p)</item>
+                                          <item id="2" translatable="yes">2x (1440p/2160p)</item>
+                                          <item id="3" translatable="yes">3x (2160p/3240p)</item>
+                                          <item id="4" translatable="yes">4x (2880p/4320p)</item>
+                                          <item id="-1" translatable="yes">Custom (not recommended)</item>
+                                        </items>
+                                      </object>
+                                      <packing>
+                                        <property name="expand">False</property>
+                                        <property name="fill">True</property>
+                                        <property name="position">1</property>
+                                      </packing>
+                                    </child>
+                                    <child>
+                                      <object class="GtkEntry" id="_resScaleText">
+                                        <property name="visible">True</property>
+                                        <property name="can_focus">True</property>
+                                        <property name="tooltip_text" translatable="yes">Floating point resolution scale, such as 1.5. Non-integral scales are more likely to cause issues or crash.</property>
+                                        <property name="valign">center</property>
+                                        <property name="caps_lock_warning">False</property>
+                                        <property name="placeholder-text">1.0</property>
+                                        <property name="input-purpose">GTK_INPUT_PURPOSE_NUMBER</property>
+                                      </object>
+                                      <packing>
+                                        <property name="expand">True</property>
+                                        <property name="fill">True</property>
+                                        <property name="position">2</property>
+                                      </packing>
+                                    </child>
+                                  </object>
+                                  <packing>
+                                    <property name="expand">False</property>
+                                    <property name="fill">True</property>
+                                    <property name="padding">5</property>
+                                    <property name="position">0</property>
+                                  </packing>
+                                </child>
                                 <child>
                                   <object class="GtkBox">
                                     <property name="visible">True</property>
@@ -1722,7 +1786,7 @@
                                     <property name="expand">False</property>
                                     <property name="fill">True</property>
                                     <property name="padding">5</property>
-                                    <property name="position">0</property>
+                                    <property name="position">1</property>
                                   </packing>
                                 </child>
                               </object>

+ 22 - 1
Ryujinx/_schema.json

@@ -700,6 +700,27 @@
     }
   },
   "properties": {
+    "res_scale": {
+      "$id": "#/properties/res_scale",
+      "type": "integer",
+      "title": "Resolution Scale",
+      "description": "An integer scale applied to applicable render targets. Values 1-4, or -1 to use a custom floating point scale instead.",
+      "default": -1,
+      "examples": [
+        -1,
+        1,
+        2,
+        3,
+        4
+      ]
+    },
+    "res_scale_custom": {
+      "$id": "#/properties/res_scale_custom",
+      "type": "number",
+      "title": "Custom Resolution Scale",
+      "description": "A custom floating point scale applied to applicable render targets. Only active when Resolution Scale is -1.",
+      "default": 1.0,
+    },
     "max_anisotropy": {
       "$id": "#/properties/max_anisotropy",
       "type": "integer",
@@ -1211,7 +1232,7 @@
             "button_sr": "Unbound"
           }
         }
-      ] 
+      ]
     },
     "controller_config": {
       "$id": "#/properties/controller_config",