diff --git a/docs/rendering-resolution.md b/docs/rendering-resolution.md index 26a231d..6a41d18 100644 --- a/docs/rendering-resolution.md +++ b/docs/rendering-resolution.md @@ -5,28 +5,108 @@ SPDX-License-Identifier: GPL-2.0-or-later # Rendering resolution -Options → Rendering exposes two independent sizes, globally and per game: +Options → Rendering exposes three independent sizes, globally and per game: - **Resolution** sets the host window size or exclusive fullscreen mode. - **Game display mode** reports a 1080p display to the game by default. Select 3840×2160 to request a 4K display. A registered presentation buffer cannot change this setting. +- **Internal resolution scale** multiplies the host size of the game's + screen-sized render targets. 100% is native and changes nothing. -The CLI equivalents are `--resolution=1280x720` and -`--guest-resolution=1920x1080` (or `3840x2160`). For example, a 1080p game display -can be presented in a 720p window; changing the window alone does not reduce the -work the game renders. +The CLI equivalents are `--resolution=1280x720`, +`--guest-resolution=1920x1080` (or `3840x2160`) and +`--render-scale=0.5`. `SHARPEMU_RENDER_SCALE=0.5` does the same for scripted +runs and overrides the stored setting. The accepted range is 0.25 to 2, +rounded to a hundredth. The game chooses its internal render targets. Some games adapt those targets to the reported display mode; others render at a fixed resolution and may still register 4K buffers with a 1080p display. Silent Hill: The Short Message does so in current testing. The size of that presentation buffer alone also does not -establish the sizes of its intermediate scene targets. +establish the sizes of its intermediate scene targets. Changing the window alone +does not reduce the work the game renders; the internal resolution scale does. -Arbitrary internal resolution scaling is not implemented. The previous -`RenderResolutionScale` setting and its `SHARPEMU_RENDER_SCALE` export had no -renderer consumer; they have been removed. They did not change rendering work. -A correct implementation must preserve guest memory layouts while scaling host -attachments, viewports, scissors, image fetches, compute accesses, aliases and -readbacks consistently. Reducing only the output image or window is not an -internal-resolution implementation. +## How the scale is applied + +Guest memory layouts never change. A scaled image keeps its guest geometry in +its description - extent, pitch, tile mode, mip layout - and only its host +backing is created at `ceil(guest × scale)`. Everything that would notice: + +- **Rasterization.** The render area, the viewport and the scissor are scaled + with the attachments. Scissor edges round outward so no guest pixel loses its + host coverage. With clipping disabled the vertex program bakes the viewport + transform against a reference extent that is divided by an upscale, so the + same program maps guest pixels onto host positions at either resolution. +- **Shaders.** `gl_FragCoord` is divided by the attachments' factor as it is + loaded, so the pixel position inputs and everything derived from them - screen + UVs above all - stay in guest pixels. Integer image coordinates are mapped + onto host texels through the bound image's factor, and resource-info queries + report guest sizes. Dynamic sample offsets are converted from guest texels + to host texels before normalization. The factors and a mask of scaled image resources travel in + shader data, so no shader is specialized per scale. +- **Guest transfers.** An upload fills a guest-resolution twin of the image from + the copy regions the tiler built and blits into the backing; a download blits + the backing into the twin and copies out of it. A copy between two images that + disagree on host resolution runs at guest resolution the same way. +- **Presentation.** The flip path already works from the host extent of the + captured image, so a scaled flip buffer still fills the window. + +## What scales and what does not + +Only screen-class, single-level, single-sample, uncompressed 2D render and depth +targets scale: width ≥ 1280, height ≥ 720, not square, render or depth tiling, +guest-placed, in a format the device can blit. The decision is a pure function +of the guest image description, so every view, alias and copy of one guest range +agrees on it. + +Everything else keeps its guest resolution: + +- block-compressed images, multisample footprints, mip chains, cube and array + faces, 3D images; +- depth targets with a stencil plane, because Vulkan cannot blit stencil and the + guest-resolution twin could not be filled; +- HTile, which encodes guest depth blocks the renderer reads back as clear + state. DCC only describes guest bytes the host never stores, so it does scale; +- asset tilings (linear, standard), shadow maps, lookup tables, probe atlases + and eye-adaptation readbacks, which are below the size threshold or square; +- storage images the moment a compute program writes one. The guest dispatch + grid does not shrink with the host image, so a scaled image would be written + with holes above 100% and redundantly below. The guest range is remembered and + every later image over that memory stays native; +- every attachment of a pass whose attachments disagree, which happens when one + of them falls into a case above. The draw drops the scaled attachments back to + guest resolution and resolves its targets again. + +Only the Vulkan backend scales; the Metal backend ignores the setting. + +## Known limitations + +- **Compute-written surfaces never scale.** In a deferred renderer that is a + large part of the frame, so the saving is smaller than the scale suggests. +- **Depth with stencil never scales**, and it takes its pass with it. +- **Tone and screen-space effects shift.** Measured on Silent Hill: The Short + Message, the title scene is correct in composition, coverage and UI placement + at 50% and 200%, but slightly brighter than native at both, and at 50% the + character's coat picks up colour speckle. Effects that reason about texels + rather than about the screen - screen-space shadows, subsurface scattering, + dithering - change their footprint with the host resolution. +- **Guest stencil uploads and downloads** of a scaled image would lose the + stencil plane; this cannot happen today only because stencil images never + scale. + +## Measured effect + +Silent Hill: The Short Message at the title menu, 1080p display mode, GPU time +per draw scope (averaged over a whole run): + +| scope | 50% | 100% | 200% | +|---|---|---|---| +| `vs=CBE40391 ps=E0D7BA42` | 1.68 ms | 7.60 ms | 41.81 ms | +| `vs=9A9C9C47 ps=B2FD2C67` | 0.71 ms | 2.44 ms | 10.52 ms | +| `vs=9A9C9C47 ps=E5A1AB7F` | 0.78 ms | 2.35 ms | 8.27 ms | + +Pixel work follows the area, as it should. Frame rate does not: this title is +bound by guest command translation and compute, not by pixels, and stays at +roughly 2.5 fps at every scale. Dreaming Sarah holds 60 fps at 50% with one +scaled target. diff --git a/src/SharpEmu.CLI/Program.cs b/src/SharpEmu.CLI/Program.cs index 4d93c62..a2daec2 100644 --- a/src/SharpEmu.CLI/Program.cs +++ b/src/SharpEmu.CLI/Program.cs @@ -9,6 +9,7 @@ using SharpEmu.Libs.VideoOut; using SharpEmu.Logging; using System.Runtime.InteropServices; using System.Runtime.Loader; +using System.Globalization; using System.Text; using System.Text.Json; @@ -1011,7 +1012,7 @@ internal static partial class Program private static void PrintUsage() { - Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=] [--console=] [--console-language=] [--log-level=] [--log-file[=]] [--window-mode=] [--resolution=] [--guest-resolution=<1920x1080|3840x2160>] [--display=] [--refresh-rate=] [--scaling=] [--vsync=] [--hdr=] [--overlay=] [--overlay-mode=] [--overlay-corner=] [--debug-server[=host:port]] "); + Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=] [--console=] [--console-language=] [--log-level=] [--log-file[=]] [--window-mode=] [--resolution=] [--guest-resolution=<1920x1080|3840x2160>] [--render-scale=<0.5|0.75|1|1.5|2>] [--display=] [--refresh-rate=] [--scaling=] [--vsync=] [--hdr=] [--overlay=] [--overlay-mode=] [--overlay-corner=] [--debug-server[=host:port]] "); Log.Info(@"Example: SharpEmu.CLI --cpu-engine=native --trace-imports=64 --log-level=debug --log-file ""E:\Games\...\eboot.bin"""); Log.Info("Debug server: --debug-server starts a live debug listener (default 127.0.0.1:5714); connect with SharpEmu.DebugClient."); } @@ -1077,6 +1078,7 @@ internal static partial class Program HostWindowMode? windowModeOverride = null; HostScalingMode? scalingModeOverride = null; GuestDisplayResolution? guestResolutionOverride = null; + float? renderScaleOverride = null; int? windowWidthOverride = null; int? windowHeightOverride = null; int? displayIndexOverride = null; @@ -1148,6 +1150,19 @@ internal static partial class Program ? GuestDisplayResolution.UltraHd : GuestDisplayResolution.Hd; continue; } + if (TrySplitOption(argument, "--render-scale", out var renderScaleText)) + { + if (!float.TryParse(renderScaleText, NumberStyles.Float, CultureInfo.InvariantCulture, out var renderScale) || + !float.IsFinite(renderScale) || + renderScale < HostVideoOptions.MinimumRenderScale || renderScale > HostVideoOptions.MaximumRenderScale) + { + ebootPath = string.Empty; + runtimeOptions = default; + return false; + } + renderScaleOverride = renderScale; + continue; + } if (TrySplitOption(argument, "--display", out var displayText)) { if (!int.TryParse(displayText, out var displayIndex) || displayIndex < 0) @@ -1406,6 +1421,7 @@ internal static partial class Program WindowMode = windowModeOverride ?? configuredVideoOptions.WindowMode, ScalingMode = scalingModeOverride ?? configuredVideoOptions.ScalingMode, GuestResolution = guestResolutionOverride ?? configuredVideoOptions.GuestResolution, + RenderScale = renderScaleOverride ?? configuredVideoOptions.RenderScale, Width = windowWidthOverride ?? configuredVideoOptions.Width, Height = windowHeightOverride ?? configuredVideoOptions.Height, DisplayIndex = displayIndexOverride ?? configuredVideoOptions.DisplayIndex, @@ -1448,6 +1464,7 @@ internal static partial class Program ScalingMode = scalingMode, GuestResolution = effective.GuestResolution == "3840x2160" ? GuestDisplayResolution.UltraHd : GuestDisplayResolution.Hd, + RenderScale = effective.RenderScale, Width = hasResolution ? configuredWidth : defaults.Width, Height = hasResolution ? configuredHeight : defaults.Height, DisplayIndex = effective.DisplayIndex, diff --git a/src/SharpEmu.GUI/GuiSettings.cs b/src/SharpEmu.GUI/GuiSettings.cs index 54655a1..af2068f 100644 --- a/src/SharpEmu.GUI/GuiSettings.cs +++ b/src/SharpEmu.GUI/GuiSettings.cs @@ -75,6 +75,23 @@ public sealed class GuiSettings internal static string NormalizeGuestResolution(string? value) => string.Equals(value, "3840x2160", StringComparison.OrdinalIgnoreCase) ? "3840x2160" : "1920x1080"; + // Host resolution multiplier of the render targets the renderer is allowed to scale. + public float RenderScale { get; set; } = 1.0f; + + internal static float NormalizeRenderScale(float value) => + SharpEmu.Libs.VideoOut.HostVideoOptions.NormalizeRenderScale(value); + + internal static readonly string[] RenderScaleChoices = ["50%", "75%", "100%", "125%", "150%", "200%"]; + + internal static string RenderScaleText(float value) => + ((int)MathF.Round(NormalizeRenderScale(value) * 100)).ToString(System.Globalization.CultureInfo.InvariantCulture) + "%"; + + internal static float ParseRenderScale(string? text) => + int.TryParse(text?.TrimEnd('%'), System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out var percent) + ? NormalizeRenderScale(percent / 100.0f) + : 1.0f; + public int DisplayIndex { get; set; } public int RefreshRate { get; set; } @@ -187,6 +204,7 @@ public sealed class GuiSettings settings.WindowMode = NormalizeChoice(settings.WindowMode, "Windowed", "Borderless", "Exclusive"); settings.Resolution = NormalizeResolution(settings.Resolution); settings.GuestResolution = NormalizeGuestResolution(settings.GuestResolution); + settings.RenderScale = NormalizeRenderScale(settings.RenderScale); settings.ScalingMode = NormalizeChoice(settings.ScalingMode, "Fit", "Cover", "Stretch", "Integer"); settings.HdrMode = NormalizeChoice(settings.HdrMode, "Auto", "On", "Off"); settings.BinkPlaybackMode = NormalizeChoice(settings.BinkPlaybackMode, "Guest", "Host", "Skip"); diff --git a/src/SharpEmu.GUI/Languages/ar.json b/src/SharpEmu.GUI/Languages/ar.json index 0c6ee8d..7484ea7 100644 --- a/src/SharpEmu.GUI/Languages/ar.json +++ b/src/SharpEmu.GUI/Languages/ar.json @@ -221,6 +221,8 @@ "Options.Resolution.Desc": "حجم النافذة الأولي أو دقة ملء الشاشة الحصرية.", "Options.GuestResolution.Label": "وضع عرض اللعبة", "Options.GuestResolution.Desc": "دقة العرض المُبلّغ عنها للعبة. الافتراضي 1080p. تختار الألعاب دقة التصيير الداخلية؛ وقد تتجاهل الألعاب ذات الدقة الثابتة هذا الإعداد.", + "Options.RenderScale.Label": "مقياس الدقة الداخلية", + "Options.RenderScale.Desc": "مضاعف دقة المضيف المطبّق على أهداف التصيير بحجم الشاشة في اللعبة. أقل من 100% يقايض جودة الصورة بالسرعة، وأكثر من 100% يفعل العكس. تبقى خرائط الظلال وجداول البحث والمواد المضغوطة والأسطح التي تكتبها مظللات الحوسبة بحجمها الأصلي دائمًا.", "Options.Display.Label": "الشاشة", "Options.Display.Desc": "الشاشة المستخدمة للتوسيط وملء الشاشة.", "Options.RefreshRate.Label": "معدل التحديث", diff --git a/src/SharpEmu.GUI/Languages/az.json b/src/SharpEmu.GUI/Languages/az.json index 29c3913..00900ff 100644 --- a/src/SharpEmu.GUI/Languages/az.json +++ b/src/SharpEmu.GUI/Languages/az.json @@ -76,6 +76,8 @@ "Options.Resolution.Desc": "Pəncərənin başlanğıc ölçüsü və ya eksklüziv tam ekranın təsvir ölçüsü.", "Options.GuestResolution.Label": "Oyunun ekran rejimi", "Options.GuestResolution.Desc": "Oyuna bildirilən ekran təsvir ölçüsü. Standart 1080p-dir. Daxili render ölçüsünü oyun seçir; sabit ölçülü oyunlar bu ayarı nəzərə almaya bilər.", + "Options.RenderScale.Label": "Daxili təsvir ölçüsü əmsalı", + "Options.RenderScale.Desc": "Oyunun ekran ölçüsündəki render hədəflərinə tətbiq olunan host təsvir ölçüsü vuruğu. 100%-dən aşağı təsvir keyfiyyətini sürətə dəyişir, yuxarı isə əksinə. Kölgə xəritələri, axtarış cədvəlləri, sıxılmış teksturlar və hesablama şeyderlərinin yazdığı səthlər həmişə öz ölçüsündə qalır.", "Options.Display.Label": "Ekran", "Options.Display.Desc": "Pəncərənin mərkəzə yerləşdirilməsi və tam ekran üçün istifadə olunan monitor.", "Options.RefreshRate.Label": "Yenilənmə tezliyi", diff --git a/src/SharpEmu.GUI/Languages/br.json b/src/SharpEmu.GUI/Languages/br.json index f6c9b8c..6c20521 100644 --- a/src/SharpEmu.GUI/Languages/br.json +++ b/src/SharpEmu.GUI/Languages/br.json @@ -222,6 +222,8 @@ "Options.Resolution.Desc": "Tamanho inicial da janela ou resolução de tela cheia exclusiva.", "Options.GuestResolution.Label": "Modo de exibição do jogo", "Options.GuestResolution.Desc": "Resolução informada ao jogo. O padrão é 1080p. O jogo escolhe a resolução interna de renderização; jogos com resolução fixa podem ignorar esta opção.", + "Options.RenderScale.Label": "Escala de resolução interna", + "Options.RenderScale.Desc": "Multiplicador da resolução do host aplicado aos alvos de renderização do tamanho da tela. Abaixo de 100% troca qualidade de imagem por velocidade; acima de 100% faz o contrário. Mapas de sombra, tabelas de consulta, texturas comprimidas e superfícies escritas por shaders de computação sempre mantêm o tamanho nativo.", "Options.Display.Label": "Tela", "Options.Display.Desc": "Monitor usado para centralização e tela cheia.", "Options.RefreshRate.Label": "Taxa de atualização", diff --git a/src/SharpEmu.GUI/Languages/de.json b/src/SharpEmu.GUI/Languages/de.json index 6991a68..e9e16aa 100644 --- a/src/SharpEmu.GUI/Languages/de.json +++ b/src/SharpEmu.GUI/Languages/de.json @@ -221,6 +221,8 @@ "Options.Resolution.Desc": "Anfängliche Fenstergröße oder exklusive Vollbildauflösung.", "Options.GuestResolution.Label": "Anzeigemodus des Spiels", "Options.GuestResolution.Desc": "Dem Spiel gemeldete Bildschirmauflösung. Standard ist 1080p. Spiele wählen ihre interne Renderauflösung selbst; Spiele mit fester Auflösung können diese Einstellung ignorieren.", + "Options.RenderScale.Label": "Interne Auflösungsskalierung", + "Options.RenderScale.Desc": "Host-Auflösungsfaktor für die bildschirmgroßen Renderziele des Spiels. Unter 100 % wird Bildqualität gegen Geschwindigkeit getauscht, über 100 % umgekehrt. Shadow Maps, Lookup-Tabellen, komprimierte Texturen und von Compute-Shadern beschriebene Oberflächen behalten immer ihre native Größe.", "Options.Display.Label": "Anzeige", "Options.Display.Desc": "Monitor für Zentrierung und Vollbilddarstellung.", "Options.RefreshRate.Label": "Bildwiederholrate", diff --git a/src/SharpEmu.GUI/Languages/dk.json b/src/SharpEmu.GUI/Languages/dk.json index 0a4b553..b36393f 100644 --- a/src/SharpEmu.GUI/Languages/dk.json +++ b/src/SharpEmu.GUI/Languages/dk.json @@ -221,6 +221,8 @@ "Options.Resolution.Desc": "Oprindelig vinduesstørrelse eller opløsning i eksklusiv fuldskærm.", "Options.GuestResolution.Label": "Spillets skærmtilstand", "Options.GuestResolution.Desc": "Skærmopløsning oplyst til spillet. Standard er 1080p. Spillet vælger den interne renderopløsning; spil med fast opløsning kan ignorere denne indstilling.", + "Options.RenderScale.Label": "Intern opløsningsskalering", + "Options.RenderScale.Desc": "Værtsopløsningsfaktor for spillets skærmstore rendermål. Under 100 % byttes billedkvalitet for hastighed; over 100 % omvendt. Skyggekort, opslagstabeller, komprimerede teksturer og overflader skrevet af compute-shaders beholder altid deres oprindelige størrelse.", "Options.Display.Label": "Skærm", "Options.Display.Desc": "Skærm, der bruges til centrering og fuldskærm.", "Options.RefreshRate.Label": "Opdateringshastighed", diff --git a/src/SharpEmu.GUI/Languages/ee.json b/src/SharpEmu.GUI/Languages/ee.json index 1fe2b6c..fda62d0 100644 --- a/src/SharpEmu.GUI/Languages/ee.json +++ b/src/SharpEmu.GUI/Languages/ee.json @@ -87,6 +87,8 @@ "Options.Resolution.Desc": "Algne akna suurus või eksklusiivse täisekraani eraldusvõime.", "Options.GuestResolution.Label": "Mängu kuvarežiim", "Options.GuestResolution.Desc": "Mängule teatatud ekraani eraldusvõime. Vaikimisi 1080p. Mäng valib sisemise renderduse eraldusvõime; fikseeritud eraldusvõimega mängud võivad seda sätet eirata.", + "Options.RenderScale.Label": "Sisemise eraldusvõime skaala", + "Options.RenderScale.Desc": "Hosti eraldusvõime kordaja mängu ekraanisuuruste renderdussihtide jaoks. Alla 100% vahetab pildikvaliteedi kiiruse vastu, üle 100% vastupidi. Varjukaardid, otsingutabelid, pakitud tekstuurid ja arvutusvarjundajate kirjutatud pinnad jäävad alati oma algsesse suurusesse.", "Options.Display.Label": "Kuvar", "Options.Display.Desc": "Monitor, mida kasutatakse tsentreerimiseks ja täisekraanrežiimiks.", "Options.RefreshRate.Label": "Värskendussagedus", diff --git a/src/SharpEmu.GUI/Languages/en.json b/src/SharpEmu.GUI/Languages/en.json index e12e4df..4740c3a 100644 --- a/src/SharpEmu.GUI/Languages/en.json +++ b/src/SharpEmu.GUI/Languages/en.json @@ -87,6 +87,8 @@ "Options.Resolution.Desc": "Initial window size or exclusive fullscreen resolution.", "Options.GuestResolution.Label": "Game display mode", "Options.GuestResolution.Desc": "Display resolution reported to the game. Defaults to 1080p. Games choose their internal rendering resolution; fixed-resolution games may ignore this setting.", + "Options.RenderScale.Label": "Internal resolution scale", + "Options.RenderScale.Desc": "Host resolution multiplier applied to the game's screen-sized render targets. Below 100% trades image quality for speed; above 100% does the opposite. Shadow maps, lookup tables, compressed textures and compute-written surfaces always stay at their native size.", "Options.Display.Label": "Display", "Options.Display.Desc": "Monitor used for centering and fullscreen.", "Options.RefreshRate.Label": "Refresh rate", diff --git a/src/SharpEmu.GUI/Languages/es.json b/src/SharpEmu.GUI/Languages/es.json index b1583a2..20752ce 100644 --- a/src/SharpEmu.GUI/Languages/es.json +++ b/src/SharpEmu.GUI/Languages/es.json @@ -222,6 +222,8 @@ "Options.Resolution.Desc": "Tamaño inicial de la ventana o resolución de pantalla completa exclusiva.", "Options.GuestResolution.Label": "Modo de pantalla del juego", "Options.GuestResolution.Desc": "Resolución de pantalla comunicada al juego. El valor predeterminado es 1080p. El juego elige su resolución interna de renderizado; los juegos con resolución fija pueden ignorar este ajuste.", + "Options.RenderScale.Label": "Escala de resolución interna", + "Options.RenderScale.Desc": "Multiplicador de resolución del anfitrión aplicado a los destinos de renderizado del tamaño de la pantalla. Por debajo del 100 % se cambia calidad de imagen por velocidad; por encima, lo contrario. Los mapas de sombras, las tablas de consulta, las texturas comprimidas y las superficies escritas por sombreadores de cómputo conservan siempre su tamaño nativo.", "Options.Display.Label": "Pantalla", "Options.Display.Desc": "Monitor utilizado para centrar y mostrar a pantalla completa.", "Options.RefreshRate.Label": "Frecuencia de actualización", diff --git a/src/SharpEmu.GUI/Languages/fr.json b/src/SharpEmu.GUI/Languages/fr.json index f7e4a39..7beaef1 100644 --- a/src/SharpEmu.GUI/Languages/fr.json +++ b/src/SharpEmu.GUI/Languages/fr.json @@ -222,6 +222,8 @@ "Options.Resolution.Desc": "Taille initiale de la fenêtre ou résolution du plein écran exclusif.", "Options.GuestResolution.Label": "Mode d’affichage du jeu", "Options.GuestResolution.Desc": "Résolution d’écran indiquée au jeu. La valeur par défaut est 1080p. Le jeu choisit sa résolution de rendu interne ; les jeux à résolution fixe peuvent ignorer ce réglage.", + "Options.RenderScale.Label": "Échelle de résolution interne", + "Options.RenderScale.Desc": "Multiplicateur de résolution hôte appliqué aux cibles de rendu plein écran du jeu. En dessous de 100 %, la qualité d’image est échangée contre de la vitesse ; au-dessus, l’inverse. Les cartes d’ombres, les tables de correspondance, les textures compressées et les surfaces écrites par les shaders de calcul gardent toujours leur taille native.", "Options.Display.Label": "Écran", "Options.Display.Desc": "Moniteur utilisé pour le centrage et le plein écran.", "Options.RefreshRate.Label": "Fréquence de rafraîchissement", diff --git a/src/SharpEmu.GUI/Languages/hu.json b/src/SharpEmu.GUI/Languages/hu.json index dbc4fce..68d45a5 100644 --- a/src/SharpEmu.GUI/Languages/hu.json +++ b/src/SharpEmu.GUI/Languages/hu.json @@ -222,6 +222,8 @@ "Options.Resolution.Desc": "Kezdeti ablakméret vagy kizárólagos teljes képernyős felbontás.", "Options.GuestResolution.Label": "A játék megjelenítési módja", "Options.GuestResolution.Desc": "A játéknak jelzett képernyőfelbontás. Alapértelmezés: 1080p. A belső renderelési felbontást a játék választja; a rögzített felbontású játékok figyelmen kívül hagyhatják ezt a beállítást.", + "Options.RenderScale.Label": "Belső felbontás skálázása", + "Options.RenderScale.Desc": "A gazdagép felbontásszorzója a játék képernyőméretű rendercéljaira. 100% alatt a képminőséget cseréli sebességre, 100% fölött fordítva. Az árnyéktérképek, a keresőtáblák, a tömörített textúrák és a compute shaderek által írt felületek mindig natív méretükben maradnak.", "Options.Display.Label": "Kijelző", "Options.Display.Desc": "A középre igazításhoz és teljes képernyőhöz használt monitor.", "Options.RefreshRate.Label": "Frissítési gyakoriság", diff --git a/src/SharpEmu.GUI/Languages/it.json b/src/SharpEmu.GUI/Languages/it.json index 4bd1552..0822364 100644 --- a/src/SharpEmu.GUI/Languages/it.json +++ b/src/SharpEmu.GUI/Languages/it.json @@ -226,6 +226,8 @@ "Options.Resolution.Desc": "Dimensione iniziale della finestra o risoluzione dello schermo intero esclusivo.", "Options.GuestResolution.Label": "Modalità schermo del gioco", "Options.GuestResolution.Desc": "Risoluzione dello schermo comunicata al gioco. Il valore predefinito è 1080p. Il gioco sceglie la risoluzione interna di rendering; i giochi con risoluzione fissa possono ignorare questa impostazione.", + "Options.RenderScale.Label": "Scala della risoluzione interna", + "Options.RenderScale.Desc": "Moltiplicatore della risoluzione host applicato ai target di rendering a schermo intero del gioco. Sotto il 100% si scambia qualità d'immagine con velocità; sopra il 100% avviene il contrario. Shadow map, tabelle di consultazione, texture compresse e superfici scritte dai compute shader restano sempre alla dimensione nativa.", "Options.Display.Label": "Schermo", "Options.Display.Desc": "Monitor utilizzato per il centraggio e lo schermo intero.", "Options.RefreshRate.Label": "Frequenza di aggiornamento", diff --git a/src/SharpEmu.GUI/Languages/ja.json b/src/SharpEmu.GUI/Languages/ja.json index 9377619..56429b8 100644 --- a/src/SharpEmu.GUI/Languages/ja.json +++ b/src/SharpEmu.GUI/Languages/ja.json @@ -221,6 +221,8 @@ "Options.Resolution.Desc": "初期ウィンドウサイズまたは排他フルスクリーンの解像度。", "Options.GuestResolution.Label": "ゲームの表示モード", "Options.GuestResolution.Desc": "ゲームに通知する画面解像度。既定値は1080pです。内部描画解像度はゲームが決定するため、解像度が固定されたゲームではこの設定が反映されない場合があります。", + "Options.RenderScale.Label": "内部解像度スケール", + "Options.RenderScale.Desc": "ゲームの画面サイズのレンダーターゲットに適用するホスト解像度の倍率です。100%未満は画質と引き換えに速度を得て、100%超はその逆になります。シャドウマップ、ルックアップテーブル、圧縮テクスチャ、コンピュートシェーダーが書き込むサーフェスは常に元のサイズのままです。", "Options.Display.Label": "ディスプレイ", "Options.Display.Desc": "中央配置とフルスクリーンに使用するモニター。", "Options.RefreshRate.Label": "リフレッシュレート", diff --git a/src/SharpEmu.GUI/Languages/ko.json b/src/SharpEmu.GUI/Languages/ko.json index e1ce2f7..a49aee2 100644 --- a/src/SharpEmu.GUI/Languages/ko.json +++ b/src/SharpEmu.GUI/Languages/ko.json @@ -221,6 +221,8 @@ "Options.Resolution.Desc": "초기 창 크기 또는 독점 전체 화면 해상도.", "Options.GuestResolution.Label": "게임 디스플레이 모드", "Options.GuestResolution.Desc": "게임에 전달하는 화면 해상도입니다. 기본값은 1080p입니다. 내부 렌더링 해상도는 게임이 결정하며, 해상도가 고정된 게임은 이 설정을 무시할 수 있습니다.", + "Options.RenderScale.Label": "내부 해상도 배율", + "Options.RenderScale.Desc": "게임의 화면 크기 렌더 타깃에 적용되는 호스트 해상도 배율입니다. 100% 미만은 화질을 속도와 맞바꾸고 100% 초과는 그 반대입니다. 섀도 맵, 룩업 테이블, 압축 텍스처, 컴퓨트 셰이더가 쓰는 표면은 항상 원래 크기를 유지합니다.", "Options.Display.Label": "디스플레이", "Options.Display.Desc": "가운데 배치와 전체 화면에 사용할 모니터.", "Options.RefreshRate.Label": "새로 고침 빈도", diff --git a/src/SharpEmu.GUI/Languages/nl.json b/src/SharpEmu.GUI/Languages/nl.json index 9938f9c..a133e84 100644 --- a/src/SharpEmu.GUI/Languages/nl.json +++ b/src/SharpEmu.GUI/Languages/nl.json @@ -221,6 +221,8 @@ "Options.Resolution.Desc": "Initiële venstergrootte of resolutie voor exclusief volledig scherm.", "Options.GuestResolution.Label": "Weergavemodus van het spel", "Options.GuestResolution.Desc": "Schermresolutie die aan het spel wordt doorgegeven. Standaard 1080p. Het spel kiest de interne renderresolutie; spellen met een vaste resolutie kunnen deze instelling negeren.", + "Options.RenderScale.Label": "Interne resolutieschaal", + "Options.RenderScale.Desc": "Hostresolutievermenigvuldiger voor de schermgrote rendertargets van het spel. Onder 100% ruil je beeldkwaliteit in voor snelheid; boven 100% andersom. Schaduwkaarten, opzoektabellen, gecomprimeerde texturen en door compute-shaders beschreven oppervlakken behouden altijd hun oorspronkelijke grootte.", "Options.Display.Label": "Beeldscherm", "Options.Display.Desc": "Monitor die wordt gebruikt voor centrering en volledig scherm.", "Options.RefreshRate.Label": "Verversingssnelheid", diff --git a/src/SharpEmu.GUI/Languages/pl.json b/src/SharpEmu.GUI/Languages/pl.json index 8d19639..75f882f 100644 --- a/src/SharpEmu.GUI/Languages/pl.json +++ b/src/SharpEmu.GUI/Languages/pl.json @@ -87,6 +87,8 @@ "Options.Resolution.Desc": "Początkowy rozmiar okna lub rozdzielczość pełnego ekranu wyłącznego.", "Options.GuestResolution.Label": "Tryb wyświetlania gry", "Options.GuestResolution.Desc": "Rozdzielczość ekranu zgłaszana grze. Domyślnie 1080p. Gra wybiera wewnętrzną rozdzielczość renderowania; gry ze stałą rozdzielczością mogą zignorować to ustawienie.", + "Options.RenderScale.Label": "Skala rozdzielczości wewnętrznej", + "Options.RenderScale.Desc": "Mnożnik rozdzielczości hosta dla pełnoekranowych buforów renderowania gry. Poniżej 100% oddaje jakość obrazu za wydajność; powyżej 100% odwrotnie. Mapy cieni, tablice LUT, tekstury skompresowane i powierzchnie zapisywane przez shadery obliczeniowe zawsze zachowują natywny rozmiar.", "Options.Display.Label": "Wyświetlacz", "Options.Display.Desc": "Monitor używany do centrowania i trybu pełnoekranowego.", "Options.RefreshRate.Label": "Częstotliwość odświeżania", diff --git a/src/SharpEmu.GUI/Languages/pt.json b/src/SharpEmu.GUI/Languages/pt.json index 479bc89..bca186e 100644 --- a/src/SharpEmu.GUI/Languages/pt.json +++ b/src/SharpEmu.GUI/Languages/pt.json @@ -222,6 +222,8 @@ "Options.Resolution.Desc": "Tamanho inicial da janela ou resolução de ecrã inteiro exclusivo.", "Options.GuestResolution.Label": "Modo de apresentação do jogo", "Options.GuestResolution.Desc": "Resolução do ecrã comunicada ao jogo. A predefinição é 1080p. O jogo escolhe a resolução interna de renderização; jogos com resolução fixa podem ignorar esta opção.", + "Options.RenderScale.Label": "Escala de resolução interna", + "Options.RenderScale.Desc": "Multiplicador da resolução do anfitrião aplicado aos alvos de renderização do tamanho do ecrã. Abaixo de 100% troca qualidade de imagem por velocidade; acima de 100% faz o contrário. Mapas de sombras, tabelas de consulta, texturas comprimidas e superfícies escritas por sombreadores de computação mantêm sempre o tamanho nativo.", "Options.Display.Label": "Ecrã", "Options.Display.Desc": "Monitor utilizado para centrar e apresentar em ecrã inteiro.", "Options.RefreshRate.Label": "Taxa de atualização", diff --git a/src/SharpEmu.GUI/Languages/ru.json b/src/SharpEmu.GUI/Languages/ru.json index 08a0f98..af1dfa1 100644 --- a/src/SharpEmu.GUI/Languages/ru.json +++ b/src/SharpEmu.GUI/Languages/ru.json @@ -83,6 +83,8 @@ "Options.Resolution.Desc": "Начальный размер окна или разрешение эксклюзивного полноэкранного режима.", "Options.GuestResolution.Label": "Режим экрана игры", "Options.GuestResolution.Desc": "Разрешение экрана, сообщаемое игре. По умолчанию 1080p. Игра выбирает внутреннее разрешение рендеринга; игры с фиксированным разрешением могут игнорировать эту настройку.", + "Options.RenderScale.Label": "Масштаб внутреннего разрешения", + "Options.RenderScale.Desc": "Множитель разрешения хоста для полноэкранных целей рендеринга игры. Ниже 100% качество изображения обменивается на скорость, выше 100% — наоборот. Карты теней, таблицы поиска, сжатые текстуры и поверхности, записываемые вычислительными шейдерами, всегда сохраняют исходный размер.", "Options.Display.Label": "Монитор", "Options.Display.Desc": "Монитор, используемый для центрирования окна и полноэкранного режима.", "Options.RefreshRate.Label": "Частота обновления", diff --git a/src/SharpEmu.GUI/Languages/tr.json b/src/SharpEmu.GUI/Languages/tr.json index b6ca332..e3cab4e 100644 --- a/src/SharpEmu.GUI/Languages/tr.json +++ b/src/SharpEmu.GUI/Languages/tr.json @@ -65,6 +65,8 @@ "Options.Resolution.Desc": "Başlangıç pencere boyutu veya özel tam ekran çözünürlüğü.", "Options.GuestResolution.Label": "Oyunun ekran modu", "Options.GuestResolution.Desc": "Oyuna bildirilen ekran çözünürlüğü. Varsayılan 1080p’dir. Dahili işleme çözünürlüğünü oyun seçer; sabit çözünürlüklü oyunlar bu ayarı yok sayabilir.", + "Options.RenderScale.Label": "Dahili çözünürlük ölçeği", + "Options.RenderScale.Desc": "Oyunun ekran boyutundaki işleme hedeflerine uygulanan ana makine çözünürlük çarpanı. %100'ün altında görüntü kalitesi hıza takas edilir, üzerinde ise tersi olur. Gölge haritaları, arama tabloları, sıkıştırılmış dokular ve hesaplama gölgelendiricilerinin yazdığı yüzeyler her zaman kendi boyutlarında kalır.", "Options.Display.Label": "Ekran", "Options.Display.Desc": "Ortalama ve tam ekran için kullanılan monitör.", "Options.RefreshRate.Label": "Yenileme hızı", diff --git a/src/SharpEmu.GUI/MainWindow.GameOptions.cs b/src/SharpEmu.GUI/MainWindow.GameOptions.cs index f441fa8..d6fdae4 100644 --- a/src/SharpEmu.GUI/MainWindow.GameOptions.cs +++ b/src/SharpEmu.GUI/MainWindow.GameOptions.cs @@ -46,6 +46,7 @@ public partial class MainWindow GameWindowModeBox.ItemsSource = _windowModeChoices; GameScalingModeBox.ItemsSource = _scalingModeChoices; GameGuestResolutionBox.ItemsSource = new[] { "1920x1080", "3840x2160" }; + GameRenderScaleBox.ItemsSource = GuiSettings.RenderScaleChoices; GameHdrModeBox.ItemsSource = _hdrModeChoices; GameOverlayModeBox.ItemsSource = _overlayModeChoices; GameOverlayCornerBox.ItemsSource = _overlayCornerChoices; @@ -117,6 +118,7 @@ public partial class MainWindow GameScalingModeBox.SelectionChanged += (_, _) => PersistOpenGameSettings(); GameVSyncToggle.IsCheckedChanged += (_, _) => PersistOpenGameSettings(); GameGuestResolutionBox.SelectionChanged += (_, _) => PersistOpenGameSettings(); + GameRenderScaleBox.SelectionChanged += (_, _) => PersistOpenGameSettings(); GameHdrModeBox.SelectionChanged += (_, _) => PersistOpenGameSettings(); GameOverlayEnabledToggle.IsCheckedChanged += (_, _) => PersistOpenGameSettings(); GameOverlayModeBox.SelectionChanged += (_, _) => PersistOpenGameSettings(); @@ -219,6 +221,7 @@ public partial class MainWindow effective.ScalingMode, "Fit"); GameGuestResolutionBox.SelectedItem = effective.GuestResolution; + GameRenderScaleBox.SelectedItem = GuiSettings.RenderScaleText(effective.RenderScale); GameVSyncToggle.IsChecked = effective.VSync; GameOverlayEnabledToggle.IsChecked = effective.OverlayEnabled; GameOverlayModeBox.SelectedItem = FindChoice(_overlayModeChoices, effective.OverlayMode, "TitleBar"); @@ -281,6 +284,7 @@ public partial class MainWindow WindowMode = SelectedComboText(GameWindowModeBox, "Windowed"), Resolution = SelectedComboText(GameResolutionBox, "1920x1080"), GuestResolution = SelectedComboText(GameGuestResolutionBox, "1920x1080"), + RenderScale = GuiSettings.ParseRenderScale(SelectedComboText(GameRenderScaleBox, "100%")), DisplayIndex = GameDisplayBox.SelectedItem is HostDisplayOption display ? display.Index : 0, diff --git a/src/SharpEmu.GUI/MainWindow.axaml b/src/SharpEmu.GUI/MainWindow.axaml index 9581eb3..d3abe0f 100644 --- a/src/SharpEmu.GUI/MainWindow.axaml +++ b/src/SharpEmu.GUI/MainWindow.axaml @@ -864,6 +864,14 @@ SPDX-License-Identifier: GPL-2.0-or-later Classes="optionValue" VerticalAlignment="Center" /> + + + + + + OnHostDisplayChanged(); ResolutionBox.SelectionChanged += (_, _) => OnHostResolutionChanged(); GuestResolutionBox.SelectionChanged += (_, _) => _settings.GuestResolution = SelectedComboText(GuestResolutionBox, "1920x1080"); + RenderScaleBox.SelectionChanged += (_, _) => + _settings.RenderScale = GuiSettings.ParseRenderScale(SelectedComboText(RenderScaleBox, "100%")); RefreshRateBox.SelectionChanged += (_, _) => OnHostRefreshRateChanged(); ScalingModeBox.SelectionChanged += (_, _) => _settings.ScalingMode = SelectedComboText(ScalingModeBox, "Fit"); VSyncToggle.IsCheckedChanged += (_, _) => _settings.VSync = VSyncToggle.IsChecked == true; @@ -1251,6 +1253,7 @@ public partial class MainWindow : Window WindowModeBox.ItemsSource = _windowModeChoices; ScalingModeBox.ItemsSource = _scalingModeChoices; GuestResolutionBox.ItemsSource = new[] { "1920x1080", "3840x2160" }; + RenderScaleBox.ItemsSource = GuiSettings.RenderScaleChoices; HdrModeBox.ItemsSource = _hdrModeChoices; BinkPlaybackBox.ItemsSource = _binkPlaybackChoices; OverlayModeBox.ItemsSource = _overlayModeChoices; @@ -1327,6 +1330,7 @@ public partial class MainWindow : Window ScalingModeBox.SelectedIndex = ChoiceIndex(_settings.ScalingMode, "Fit", "Cover", "Stretch", "Integer"); VSyncToggle.IsChecked = _settings.VSync; GuestResolutionBox.SelectedItem = GuiSettings.NormalizeGuestResolution(_settings.GuestResolution); + RenderScaleBox.SelectedItem = GuiSettings.RenderScaleText(_settings.RenderScale); HdrModeBox.SelectedIndex = ChoiceIndex(_settings.HdrMode, "Auto", "On", "Off"); OverlayEnabledToggle.IsChecked = _settings.OverlayEnabled; OverlayModeBox.SelectedIndex = ChoiceIndex(_settings.OverlayMode, "Full", "Minimal", "TitleBar"); @@ -2781,6 +2785,8 @@ public partial class MainWindow : Window arguments.Add($"--window-mode={launch.Settings.WindowMode.ToLowerInvariant()}"); arguments.Add($"--resolution={launch.Settings.Resolution}"); arguments.Add($"--guest-resolution={launch.Settings.GuestResolution}"); + arguments.Add( + $"--render-scale={launch.Settings.RenderScale.ToString(System.Globalization.CultureInfo.InvariantCulture)}"); arguments.Add($"--display={launch.Settings.DisplayIndex}"); arguments.Add($"--refresh-rate={launch.Settings.RefreshRate}"); arguments.Add($"--scaling={launch.Settings.ScalingMode.ToLowerInvariant()}"); diff --git a/src/SharpEmu.GUI/PerGameSettings.cs b/src/SharpEmu.GUI/PerGameSettings.cs index 7c8e403..3a2aa40 100644 --- a/src/SharpEmu.GUI/PerGameSettings.cs +++ b/src/SharpEmu.GUI/PerGameSettings.cs @@ -28,6 +28,8 @@ public sealed class PerGameSettings public string? GuestResolution { get; set; } + public float? RenderScale { get; set; } + public int? DisplayIndex { get; set; } public int? RefreshRate { get; set; } @@ -55,6 +57,7 @@ public sealed class PerGameSettings WindowMode is null && Resolution is null && GuestResolution is null && + RenderScale is null && DisplayIndex is null && RefreshRate is null && ScalingMode is null && @@ -153,6 +156,12 @@ public sealed class PerGameSettings GuestResolution = null; } + if (RenderScale is { } renderScale && + GuiSettings.NormalizeRenderScale(renderScale) == GuiSettings.NormalizeRenderScale(global.RenderScale)) + { + RenderScale = null; + } + if (DisplayIndex == global.DisplayIndex) { DisplayIndex = null; @@ -281,6 +290,7 @@ public sealed record EffectiveLaunchSettings( string WindowMode, string Resolution, string GuestResolution, + float RenderScale, int DisplayIndex, int RefreshRate, string ScalingMode, @@ -299,6 +309,7 @@ public sealed record EffectiveLaunchSettings( perGame?.WindowMode ?? global.WindowMode, perGame?.Resolution ?? global.Resolution, GuiSettings.NormalizeGuestResolution(perGame?.GuestResolution ?? global.GuestResolution), + GuiSettings.NormalizeRenderScale(perGame?.RenderScale ?? global.RenderScale), Math.Max(0, perGame?.DisplayIndex ?? global.DisplayIndex), Math.Clamp(perGame?.RefreshRate ?? global.RefreshRate, 0, 1000), perGame?.ScalingMode ?? global.ScalingMode, diff --git a/src/SharpEmu.Libs/Gpu/Buffers/GpuDeviceInfo.cs b/src/SharpEmu.Libs/Gpu/Buffers/GpuDeviceInfo.cs index d4c636c..5396bab 100644 --- a/src/SharpEmu.Libs/Gpu/Buffers/GpuDeviceInfo.cs +++ b/src/SharpEmu.Libs/Gpu/Buffers/GpuDeviceInfo.cs @@ -34,6 +34,8 @@ public sealed unsafe class GpuDeviceInfo : IImageFormatSupport MaxMemoryAllocationCount = properties.Limits.MaxMemoryAllocationCount; MaxComputeWorkGroupCount = (properties.Limits.MaxComputeWorkGroupCount[0], properties.Limits.MaxComputeWorkGroupCount[1], properties.Limits.MaxComputeWorkGroupCount[2]); Slabs = new GpuMemorySlabs(this); + const FormatFeatureFlags blit = FormatFeatureFlags.BlitSrcBit | FormatFeatureFlags.BlitDstBit; + Images.RenderScalePolicy.ConfigureFormatSupport(format => (GetFormatProperties(format).OptimalTilingFeatures & blit) == blit); } // Shared chunks the small buffers are carved from; freed at device teardown. diff --git a/src/SharpEmu.Libs/Gpu/Images/CachedImage.Scaling.cs b/src/SharpEmu.Libs/Gpu/Images/CachedImage.Scaling.cs new file mode 100644 index 0000000..c5009c7 --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Images/CachedImage.Scaling.cs @@ -0,0 +1,135 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.Libs.Gpu.Scheduling; +using Silk.NET.Vulkan; + +namespace SharpEmu.Libs.Gpu.Images; + +// The guest-resolution twin of a scaled image and the blits that bridge the two resolutions. +// +// Guest memory layouts never change. Every path that moves guest bytes, and every copy that +// pairs this image with one at another host resolution, runs against the twin at the guest +// extent and then blits across, so callers keep describing rectangles in guest pixels. +public sealed unsafe partial class CachedImage +{ + private CachedImage? _guestSizedTwin; + + private CachedImage GuestSizedTwin() + { + if (_guestSizedTwin is { } existing) + { + return existing; + } + + // The twin is host-owned: dropping the guest placement keeps the image cache from + // tracking it and keeps RenderScalePolicy from scaling it in turn. + var description = Description; + description.Data = default; + description.Stencil = default; + description.Metadata = default; + description.HtileClearMask = uint.MaxValue; + _guestSizedTwin = new CachedImage(_device, _scheduler, _guestBacking, description); + return _guestSizedTwin; + } + + // The image at guest resolution: this image when it is not scaled, otherwise a twin + // holding a downscaled copy of its current contents. + internal CachedImage AtGuestResolution() + { + if (!IsScaled) + { + return this; + } + + var twin = GuestSizedTwin(); + twin.BlitFrom(this); + return twin; + } + + private void BlitFrom(CachedImage source) + { + var aspect = ViewFormatRules.FullAspects(Backing.Format); + if (source.Backing.ImageType != ImageType.Type2D || Backing.ImageType != ImageType.Type2D || + source.Backing.Samples != 1 || Backing.Samples != 1 || + source.Backing.Format != Backing.Format || (aspect & ImageAspectFlags.StencilBit) != 0) + { + throw SubmissionScheduler.Fatal( + $"A scaled image blit needs single-sample 2D images of one format without stencil: " + + $"sourceFormat={(int)source.Backing.Format} format={(int)Backing.Format} " + + $"sourceType={(int)source.Backing.ImageType} type={(int)Backing.ImageType} " + + $"sourceSamples={source.Backing.Samples} samples={Backing.Samples}."); + } + + _scheduler.EndRendering(); + var command = new CommandBuffer(_scheduler.Current.Handle); + source.Transition(ImageLayout.TransferSrcOptimal, AccessFlags.TransferReadBit, null, command); + Transition(ImageLayout.TransferDstOptimal, AccessFlags.TransferWriteBit, null, command); + var levels = Math.Min(source.Backing.MipLevels, Backing.MipLevels); + var layers = Math.Min(source.Backing.Layers, Backing.Layers); + var regions = new ImageBlit[levels]; + for (uint level = 0; level < levels; level++) + { + var subresource = new ImageSubresourceLayers(aspect, level, 0, layers); + regions[level] = new ImageBlit + { + SrcSubresource = subresource, + DstSubresource = subresource, + SrcOffsets = new ImageBlit.SrcOffsetsBuffer + { + Element1 = new Offset3D( + (int)Math.Max(source.Backing.Extent.Width >> (int)level, 1), + (int)Math.Max(source.Backing.Extent.Height >> (int)level, 1), + 1), + }, + DstOffsets = new ImageBlit.DstOffsetsBuffer + { + Element1 = new Offset3D( + (int)Math.Max(Backing.Extent.Width >> (int)level, 1), + (int)Math.Max(Backing.Extent.Height >> (int)level, 1), + 1), + }, + }; + } + + // Depth values must not be interpolated, and Vulkan forbids a filtered depth blit. + var filter = (aspect & ImageAspectFlags.DepthBit) != 0 ? Filter.Nearest : Filter.Linear; + fixed (ImageBlit* blits = regions) + { + _device.Vk.CmdBlitImage(command, source.Backing.Handle, ImageLayout.TransferSrcOptimal, + Backing.Handle, ImageLayout.TransferDstOptimal, (uint)regions.Length, blits, filter); + } + + Transition(ReadyLayout, ReadyAccess, null, command); + } + + // True when a copy between the two images would cross host resolutions. Host-owned + // helpers are built to match the image they mirror, so only guest-placed pairs qualify. + private bool CrossesRenderScale(CachedImage other) => + RenderScale != other.RenderScale && IsGuestPlaced && other.IsGuestPlaced; + + // Runs the copy at guest resolution: the scaled side contributes or receives its twin, + // so the rectangles the caller describes keep their guest meaning. + private bool TryCopyAcrossScales(CachedImage source, Action copy) + { + if (!CrossesRenderScale(source)) + { + return false; + } + + var guestSource = source.AtGuestResolution(); + if (!IsScaled) + { + copy(this, guestSource); + return true; + } + + // The copy may touch only part of the image, so the twin starts from the current + // contents and the result is blitted back over the whole image. + var twin = GuestSizedTwin(); + twin.BlitFrom(this); + copy(twin, guestSource); + BlitFrom(twin); + return true; + } +} diff --git a/src/SharpEmu.Libs/Gpu/Images/CachedImage.Transfers.cs b/src/SharpEmu.Libs/Gpu/Images/CachedImage.Transfers.cs index e46bc1d..36362f2 100644 --- a/src/SharpEmu.Libs/Gpu/Images/CachedImage.Transfers.cs +++ b/src/SharpEmu.Libs/Gpu/Images/CachedImage.Transfers.cs @@ -296,6 +296,16 @@ public sealed unsafe partial class CachedImage public void UploadFromBuffer(ReadOnlySpan copies, VkBuffer buffer, ulong offset, ulong size) { + // Guest bytes describe guest geometry, so a scaled image receives them through its + // guest-resolution twin and takes the result as a blit. + if (IsScaled) + { + var twin = GuestSizedTwin(); + twin.UploadFromBuffer(copies, buffer, offset, size); + BlitFrom(twin); + return; + } + var command = BeginTransfer(copies, buffer, size); var sanitized = SanitizeUploadCopies(copies); var uploadCopies = sanitized is null ? copies : sanitized.AsSpan(); @@ -314,6 +324,12 @@ public sealed unsafe partial class CachedImage public void DownloadToBuffer(ReadOnlySpan copies, VkBuffer buffer, ulong offset, ulong size) { + if (IsScaled) + { + AtGuestResolution().DownloadToBuffer(copies, buffer, offset, size); + return; + } + var command = BeginTransfer(copies, buffer, size); var bufferBarrier = BufferBarrier(buffer, offset, size, MemoryAccess, AccessFlags.TransferWriteBit); var (imageBarriers, sourceStages) = GetBarriers(ImageLayout.TransferSrcOptimal, AccessFlags.TransferReadBit, PipelineStageFlags.TransferBit, null); @@ -352,6 +368,11 @@ public sealed unsafe partial class CachedImage // Copies every shared mip level of the source; stencil is never copied here. public void CopyFrom(CachedImage source) { + if (TryCopyAcrossScales(source, static (destination, origin) => destination.CopyFrom(origin))) + { + return; + } + if (source.Backing.Samples != Backing.Samples) { throw SubmissionScheduler.Fatal($"An image copy needs equal sample counts: source={source.Backing.Samples} destination={Backing.Samples}."); @@ -421,6 +442,13 @@ public sealed unsafe partial class CachedImage // Resolves or copies one mip of the source into one mip of this single-sample image. public void ResolveFrom(CachedImage source, in SubresourceRange sourceRange, in SubresourceRange destinationRange) { + var fromRange = sourceRange; + var toRange = destinationRange; + if (TryCopyAcrossScales(source, (destination, origin) => destination.ResolveFrom(origin, fromRange, toRange))) + { + return; + } + if (Backing.Samples != 1 || source.Backing.ImageType != ImageType.Type2D || Backing.ImageType != ImageType.Type2D || sourceRange.LevelCount != 1 || destinationRange.LevelCount != 1 || sourceRange.BaseLevel >= source.Backing.MipLevels || destinationRange.BaseLevel >= Backing.MipLevels || @@ -437,16 +465,18 @@ public sealed unsafe partial class CachedImage var destinationHeight = Math.Max(Backing.Extent.Height >> (int)destinationRange.BaseLevel, 1); var copy = source.Backing.Samples == 1; var formatsAgree = copy ? ViewFormatRules.AreCompatible(source.Backing.Format, Backing.Format) : source.Backing.Format == Backing.Format; - if (layers == 0 || Description.Extent.Width > sourceWidth || Description.Extent.Height > sourceHeight || - Description.Extent.Width > destinationWidth || Description.Extent.Height > destinationHeight || !formatsAgree) + // The resolve moves host texels, so the guest extent crosses into this image's resolution. + var resolveExtent = RenderScalePolicy.ScaleExtent(Description.Extent, RenderScale); + if (layers == 0 || resolveExtent.Width > sourceWidth || resolveExtent.Height > sourceHeight || + resolveExtent.Width > destinationWidth || resolveExtent.Height > destinationHeight || !formatsAgree) { throw SubmissionScheduler.Fatal( - $"The resolve extent or formats do not agree: extent={Description.Extent.Width}x{Description.Extent.Height} source={sourceWidth}x{sourceHeight} destination={destinationWidth}x{destinationHeight} layers={layers} sourceFormat={(int)source.Backing.Format} destinationFormat={(int)Backing.Format}."); + $"The resolve extent or formats do not agree: extent={resolveExtent.Width}x{resolveExtent.Height} source={sourceWidth}x{sourceHeight} destination={destinationWidth}x{destinationHeight} layers={layers} sourceFormat={(int)source.Backing.Format} destinationFormat={(int)Backing.Format}."); } var resolvedSource = sourceRange with { LayerCount = layers }; var resolvedDestination = destinationRange with { LayerCount = layers }; - var extent = new Extent3D(Description.Extent.Width, Description.Extent.Height, 1); + var extent = new Extent3D(resolveExtent.Width, resolveExtent.Height, 1); _scheduler.EndRendering(); var command = new CommandBuffer(_scheduler.Current.Handle); source.Transition(ImageLayout.TransferSrcOptimal, AccessFlags.TransferReadBit, resolvedSource, command); @@ -467,6 +497,13 @@ public sealed unsafe partial class CachedImage public void CopyDepthStencilFrom(CachedImage source, in SubresourceRange range, in Extent3D extent, ImageAspectFlags aspects) { + var copyRange = range; + var copyExtent = extent; + if (TryCopyAcrossScales(source, (destination, origin) => destination.CopyDepthStencilFrom(origin, copyRange, copyExtent, aspects))) + { + return; + } + var requested = aspects & (ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit); var available = ViewFormatRules.FullAspects(Backing.Format); if (requested == 0 || (requested & ~available) != 0 || source.Backing.Format != Backing.Format || @@ -564,6 +601,11 @@ public sealed unsafe partial class CachedImage private void CopyThroughBuffer(CachedImage source, GpuBuffer buffer, ImageAspectFlags sourceAspect, ImageAspectFlags destinationAspect) { + if (TryCopyAcrossScales(source, (destination, origin) => destination.CopyThroughBuffer(origin, buffer, sourceAspect, destinationAspect))) + { + return; + } + if (buffer.Handle.Handle == 0 || source.Backing.Samples != 1 || Backing.Samples != 1) { throw SubmissionScheduler.Fatal($"A copy through a buffer needs single-sample images and a buffer: sourceSamples={source.Backing.Samples} destinationSamples={Backing.Samples}."); @@ -633,6 +675,11 @@ public sealed unsafe partial class CachedImage // Copies a whole standalone image into one mip and layer of this image. public void CopyMipFrom(CachedImage source, uint mip, uint layer) { + if (TryCopyAcrossScales(source, (destination, origin) => destination.CopyMipFrom(origin, mip, layer))) + { + return; + } + if (source.Backing.Samples != Backing.Samples || mip >= Backing.MipLevels || layer >= Backing.Layers) { throw SubmissionScheduler.Fatal($"The mip copy target is invalid: mip={mip} layer={layer} levels={Backing.MipLevels} layers={Backing.Layers} sourceSamples={source.Backing.Samples} destinationSamples={Backing.Samples}."); @@ -681,6 +728,11 @@ public sealed unsafe partial class CachedImage // Copies a rectangle of the source's first mip into one mip of this image, at its origin. public void CopyRegionFrom(CachedImage source, uint sourceX, uint sourceY, uint mip, uint width, uint height) { + if (TryCopyAcrossScales(source, (destination, origin) => destination.CopyRegionFrom(origin, sourceX, sourceY, mip, width, height))) + { + return; + } + if (mip >= Backing.MipLevels || sourceX + width > source.Backing.Extent.Width || sourceY + height > source.Backing.Extent.Height) { throw SubmissionScheduler.Fatal( diff --git a/src/SharpEmu.Libs/Gpu/Images/CachedImage.cs b/src/SharpEmu.Libs/Gpu/Images/CachedImage.cs index 365bced..460badc 100644 --- a/src/SharpEmu.Libs/Gpu/Images/CachedImage.cs +++ b/src/SharpEmu.Libs/Gpu/Images/CachedImage.cs @@ -73,6 +73,11 @@ public sealed unsafe partial class CachedImage : IDisposable private bool _bufferHoldsGpuContents; public ImageDescription Description; + + // The host resolution multiplier of this image. Guest geometry in Description never + // changes; only the backing is larger or smaller, and guest bytes move through a + // guest-resolution twin (CachedImage.Transfers). + public readonly float RenderScale = 1.0f; public readonly ImageBacking Backing = new(); public readonly List Views = new(); public ImageUses Uses; @@ -86,7 +91,7 @@ public sealed unsafe partial class CachedImage : IDisposable public int RecencyEntryIndex; public CachedImage(GpuDeviceInfo device, SubmissionScheduler scheduler, IGuestBackedSpace guestBacking, in ImageDescription description, - ImageBackingPool? pool = null) + ImageBackingPool? pool = null, bool allowScaling = true) { _device = device; _scheduler = scheduler; @@ -94,6 +99,7 @@ public sealed unsafe partial class CachedImage : IDisposable _pool = pool; Description = description; Description.Validate(); + RenderScale = allowScaling ? RenderScalePolicy.ScaleFor(Description) : 1.0f; _cpuDirty = !ImageDescription.IsEmptyRange(Description.Data) && Description.Metadata.Compression == DisplayCompression.Uncompressed; if (Description.PixelFormat == Format.Undefined) { @@ -102,7 +108,7 @@ public sealed unsafe partial class CachedImage : IDisposable Backing.Format = Description.PixelFormat; Backing.ImageType = HostImageType(Description.Type); - Backing.Extent = Description.Extent; + Backing.Extent = RenderScalePolicy.ScaleExtent(Description.Extent, RenderScale); Backing.GuestPitch = Description.Pitch; Backing.Layers = Description.IsVolume ? 1 : Description.Resources.Layers; Backing.MipLevels = Description.Resources.Levels; @@ -320,6 +326,13 @@ public sealed unsafe partial class CachedImage : IDisposable return usage; } + public bool IsScaled => RenderScale != 1.0f; + + // Host-owned helpers - feedback snapshots, the stencil proxy, the guest-resolution twin - + // carry no guest placement and are created to match the image they mirror, so a copy + // between them and that image never crosses a resolution. + internal bool IsGuestPlaced => !ImageDescription.IsEmptyRange(Description.Data); + public void AssociateDepth(ResourceSlotIdentifier depthImage) => DepthOwner = depthImage; internal ulong LastCpuWriteAddress { get; private set; } @@ -604,6 +617,8 @@ public sealed unsafe partial class CachedImage : IDisposable // Immediate destruction; the owner waits for GPU work before disposing. public void Dispose() { + _guestSizedTwin?.Dispose(); + _guestSizedTwin = null; foreach (var cached in Views) { _device.Vk.DestroyImageView(_device.Device, cached.View, null); diff --git a/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.Overlap.cs b/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.Overlap.cs index f61d4b8..a63631f 100644 --- a/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.Overlap.cs +++ b/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.Overlap.cs @@ -421,7 +421,9 @@ public sealed partial class GuestImageCache } PrepareCopyTarget(replacement); - _blit.Reinterpret(cached, replacement); + // The conversion renders at the destination's guest extent, and a multisample + // destination never scales, so a scaled source contributes its guest-sized twin. + _blit.Reinterpret(cached.AtGuestResolution(), replacement); TakeGpuOwnership(replacement); } else diff --git a/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.cs b/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.cs index 8d50e0a..175f736 100644 --- a/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.cs +++ b/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.cs @@ -220,6 +220,14 @@ public sealed unsafe partial class GuestImageCache : IGuestImageCache, IGuestIma } } + // A compute shader indexes a storage image in guest pixels and its dispatch grid does + // not shrink with the host resolution, so a scaled image would be written with holes + // (upscale) or redundantly (downscale). Such memory drops back to guest resolution. + if (result.IsValid && request.ShaderWrite && _slots[result].IsScaled) + { + result = ReplaceScaled(result); + } + if (!result.IsValid) { result = InsertImage(request.Description); @@ -598,10 +606,61 @@ public sealed unsafe partial class GuestImageCache : IGuestImageCache, IGuestIma return imageIdentifier; } + // Guest ranges a compute shader has written through a storage image; see the storage + // branch of FindImage. Every image over such a range keeps its guest resolution. + private readonly List _unscaledRanges = new(); + + private bool IsUnscaledRange(in GuestSpan data) + { + if (ImageDescription.IsEmptyRange(data)) + { + return false; + } + + foreach (var range in _unscaledRanges) + { + if (GuestRangeOverlap.Bytes(range, data)) + { + return true; + } + } + + return false; + } + + // Drops one image back to guest resolution for good; later images over the same guest + // memory are created unscaled too, so the decision cannot oscillate. + public void DemoteRenderScale(ResourceSlotIdentifier imageIdentifier) + { + using var held = _lock.Hold(); + if (_slots[imageIdentifier].IsScaled) + { + ReplaceScaled(imageIdentifier); + } + } + + private ResourceSlotIdentifier ReplaceScaled(ResourceSlotIdentifier cachedImageIdentifier) + { + var cached = _slots[cachedImageIdentifier]; + _unscaledRanges.Add(cached.Description.Data); + var replacementImageIdentifier = InsertImage(cached.Description); + var replacement = _slots[replacementImageIdentifier]; + replacement.Uses = cached.Uses; + if (cached.Binding.IsBound || cached.Binding.IsTarget) + { + cached.Binding.NeedsRebind = true; + } + + CopyWholeImage(replacementImageIdentifier, cachedImageIdentifier); + ReleaseImage(cachedImageIdentifier); + return replacementImageIdentifier; + } + private ResourceSlotIdentifier InsertImage(in ImageDescription description) { using var profileScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.ImageCreate); - var imageIdentifier = _slots.Insert(new CachedImage(_device, _scheduler, _backing, description, _backingPool)); + var allowScaling = !IsUnscaledRange(description.Data); + var imageIdentifier = _slots.Insert(new CachedImage(_device, _scheduler, _backing, description, _backingPool, allowScaling)); if (!ImageDescription.IsEmptyRange(description.Data)) { AddToIndex(imageIdentifier); diff --git a/src/SharpEmu.Libs/Gpu/Images/ImageRequest.cs b/src/SharpEmu.Libs/Gpu/Images/ImageRequest.cs index 79be977..beaa5e4 100644 --- a/src/SharpEmu.Libs/Gpu/Images/ImageRequest.cs +++ b/src/SharpEmu.Libs/Gpu/Images/ImageRequest.cs @@ -19,6 +19,9 @@ public struct ImageRequest public ImageViewDescription View; public ImageRole Role; + // The bound program stores into this image, rather than only reading it. + public bool ShaderWrite; + public ImageRequest(in ImageDescription description, in ImageViewDescription view, ImageRole role) { Description = description; diff --git a/src/SharpEmu.Libs/Gpu/Images/RenderScalePolicy.cs b/src/SharpEmu.Libs/Gpu/Images/RenderScalePolicy.cs new file mode 100644 index 0000000..ab85857 --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Images/RenderScalePolicy.cs @@ -0,0 +1,146 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Globalization; +using Silk.NET.Vulkan; + +namespace SharpEmu.Libs.Gpu.Images; + +// Picks the host resolution of one guest image. +// +// The choice is a pure function of the guest description, so every view, alias, copy and +// descriptor of the same guest memory reaches the same answer without consulting the cache. +// Guest memory layouts never change: a scaled image only differs in the size of its host +// backing, and the transfer paths route guest bytes through a guest-sized intermediate. +public static class RenderScalePolicy +{ + public const string VariableName = "SHARPEMU_RENDER_SCALE"; + + // Below this a target is a shadow map, a LUT, a probe atlas or an eye-adaptation + // readback, and scaling it changes what the game computes rather than how it looks. + public const uint MinimumScaledWidth = 1280; + public const uint MinimumScaledHeight = 720; + + private static readonly float? _environmentScale = ReadEnvironmentScale(); + private static float _scale = _environmentScale ?? 1.0f; + + public static float Scale => Volatile.Read(ref _scale); + + public static bool Enabled => Scale != 1.0f; + + // The environment variable wins so scripted runs do not depend on stored settings. + public static void Configure(float scale) + { + if (_environmentScale is not null) + { + return; + } + + Volatile.Write(ref _scale, VideoOut.HostVideoOptions.NormalizeRenderScale(scale)); + } + + private static float? ReadEnvironmentScale() + { + var text = Environment.GetEnvironmentVariable(VariableName); + if (string.IsNullOrWhiteSpace(text) || + !float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)) + { + return null; + } + + return VideoOut.HostVideoOptions.NormalizeRenderScale(parsed); + } + + public static uint ScaleLength(uint length, float scale) + { + if (scale == 1.0f || length <= 1) + { + return length; + } + + var scaled = (uint)Math.Ceiling(length * (double)scale); + return Math.Clamp(scaled, 1u, 32768u); + } + + // The guest-pixel extent the clip-disable vertex transform maps onto. Dividing the device + // limit by an upscale keeps the host viewport (reference x scale) inside that limit while + // the transform the vertex program bakes stays scale independent. + public static uint ClipSpaceReferenceExtent(uint limit) + { + var scale = Scale; + return scale <= 1.0f ? limit : Math.Max(1u, (uint)(limit / scale)); + } + + public static Extent3D ScaleExtent(Extent3D extent, float scale) => scale == 1.0f + ? extent + : new Extent3D(ScaleLength(extent.Width, scale), ScaleLength(extent.Height, scale), extent.Depth); + + private static Func? _formatSupport; + + // Installed once with the device: a scaled image is resized with vkCmdBlitImage whenever + // guest bytes or a differently scaled alias cross into it, so a format that cannot blit + // must keep its guest resolution. + public static void ConfigureFormatSupport(Func supportsScaledResize) => + Volatile.Write(ref _formatSupport, supportsScaledResize); + + public static float ScaleFor(in ImageDescription description) => ScaleFor(description, Scale); + + // Only screen-class, single-level, single-sample, uncompressed 2D render and depth + // targets scale. Everything the host cannot reproduce at another size - block grids, + // multisample footprints, HTile and DCC block coverage, mip chains whose levels would + // round apart, cube and array faces - keeps its guest resolution. + public static float ScaleFor(in ImageDescription description, float scale) + { + if (scale == 1.0f || !IsScalable(description)) + { + return 1.0f; + } + + return scale; + } + + public static bool IsScalable(in ImageDescription description) + { + // A stencil-association entry carries no host image, and an image without guest + // memory is an internal helper whose extent must track whatever it mirrors. + if (description.PixelFormat == Format.Undefined || !ImageDescription.IsValidRange(description.Data)) + { + return false; + } + + if (description.Type != GuestImageType.Color2D || description.Samples != 1 || + description.Resources.Levels != 1 || description.Resources.Layers != 1) + { + return false; + } + + // DCC only describes guest bytes the host never stores, so a scaled backing keeps it + // meaningful. HTile encodes guest depth blocks the renderer reads back as clear state. + if (description.IsBlock || description.HasStencil || description.Metadata.Kind == MetadataKind.Htile) + { + return false; + } + + // Render and depth tilings mark the surfaces a game draws into. Asset textures use + // the standard tilings and must stay at their authored size. + if (description.TileMode is not (GuestTileMode.RenderTarget or GuestTileMode.Depth)) + { + return false; + } + + if (description.Extent.Width < MinimumScaledWidth || description.Extent.Height < MinimumScaledHeight) + { + return false; + } + + // A square target at screen size is a cube face or a shadow atlas far more often + // than it is a scene buffer. + if (description.Extent.Width == description.Extent.Height) + { + return false; + } + + // Only a backend that installed the predicate (the Vulkan device) can resize an image. + return Volatile.Read(ref _formatSupport) is { } supported && supported(description.PixelFormat); + } +} diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalCommandStreamHost.Rendering.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalCommandStreamHost.Rendering.cs index d179b07..9c0af7c 100644 --- a/src/SharpEmu.Libs/Gpu/Metal/MetalCommandStreamHost.Rendering.cs +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalCommandStreamHost.Rendering.cs @@ -280,6 +280,18 @@ internal sealed partial class MetalCommandStreamHost : IRenderHost, IShaderPipel _ = image; } + // The Metal backend never scales an image, so its attachments always agree. + void IRenderHost.DemoteRenderScale(ResourceSlotIdentifier image) + { + _ = image; + } + + float IRenderHost.GetRenderScale(ResourceSlotIdentifier image) + { + _ = image; + return 1f; + } + // The records of one draw or dispatch live until the executor resets; the submitted copy owns its bytes. void IRenderHost.ResetBindings() { diff --git a/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPipelineCache.cs b/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPipelineCache.cs index 8e5b32f..c7aa44f 100644 --- a/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPipelineCache.cs +++ b/src/SharpEmu.Libs/Gpu/Pipelines/ShaderPipelineCache.cs @@ -201,8 +201,8 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider viewport.YScale, viewport.XOffset, viewport.YOffset, - Math.Min(limits.MaxViewportWidth, MaxViewportDimension) * 0.5f, - Math.Min(limits.MaxViewportHeight, MaxViewportDimension) * 0.5f); + Images.RenderScalePolicy.ClipSpaceReferenceExtent(Math.Min(limits.MaxViewportWidth, MaxViewportDimension)) * 0.5f, + Images.RenderScalePolicy.ClipSpaceReferenceExtent(Math.Min(limits.MaxViewportHeight, MaxViewportDimension)) * 0.5f); } return VertexInputResolver.ResolveVertexInputs(_context, source.Registered, source.UserData, diff --git a/src/SharpEmu.Libs/Gpu/Pipelines/ShaderProgramCache.cs b/src/SharpEmu.Libs/Gpu/Pipelines/ShaderProgramCache.cs index 5972ae1..fd06d43 100644 --- a/src/SharpEmu.Libs/Gpu/Pipelines/ShaderProgramCache.cs +++ b/src/SharpEmu.Libs/Gpu/Pipelines/ShaderProgramCache.cs @@ -513,7 +513,7 @@ internal sealed class ShaderProgramCache { resources = ResourceMaterializer.ApplyTo(plan, specialization); layout = AllocateLayout(program, plan, resources, source.UserDataBase, (uint)source.UserData.Length, pushDataCursor, - source.Stage == ShaderStage.Compute && options.ComputeInfo!.DispatchThreadDimensions); + source.Stage == ShaderStage.Compute && options.ComputeInfo!.DispatchThreadDimensions, source.Stage); } catch (ResourcePlanException exception) { @@ -654,7 +654,7 @@ internal sealed class ShaderProgramCache } private static BindingLayout AllocateLayout(Gen5ShaderProgram program, ShaderResourcePlan plan, SpecializedResourceInfo resources, - uint userDataBase, uint userDataCount, uint pushDataCursor, bool usesDispatchThreadLimits) => + uint userDataBase, uint userDataCount, uint pushDataCursor, bool usesDispatchThreadLimits, ShaderStage stage) => BindingLayout.Allocate( resources.Info, BindingLayout.CollectUserDataRegisters(program, userDataBase, userDataCount), @@ -662,7 +662,11 @@ internal sealed class ShaderProgramCache ShaderCompileRequest.RequiresFlattenedTable(plan, resources), BindingLayout.ReadsShaderBase(program), pushDataCursor, - usesDispatchThreadLimits: usesDispatchThreadLimits); + usesDispatchThreadLimits: usesDispatchThreadLimits, + // A pixel program reads its position in guest pixels; any program that samples or + // fetches an image needs to know which of them the host holds at another size. + usesRenderScale: Images.RenderScalePolicy.Enabled && + (stage == ShaderStage.Pixel || resources.Info.Images.Count != 0)); private static ShaderCompileRequest BuildComputeRequest(ShaderResourcePlan plan, SpecializedResourceInfo resources, BindingLayout layout, ComputeInputInfo info, Gen5ComputeSystemRegisters? systemRegisters, bool sharedInt64Atomics, bool execGuardElision, @@ -700,7 +704,7 @@ internal sealed class ShaderProgramCache waveSize: record.Info.WaveSize); var resources = ResourceMaterializer.ApplyTo(plan, record.Specialization); layout = AllocateLayout(program, plan, resources, record.UserDataBase, record.UserDataCount, record.PushDataCursor, - record.Info.DispatchThreadDimensions); + record.Info.DispatchThreadDimensions, ShaderStage.Compute); var request = BuildComputeRequest(plan, resources, layout, record.Info, record.SystemRegisters, sharedInt64Atomics, execGuardElision, nativeHalfConversion, zeroOutOfBoundsReads); return compiler.TryCompileProgram(request, out compiled, out error) && compiled is not null; diff --git a/src/SharpEmu.Libs/Gpu/Rendering/DrawTargets.cs b/src/SharpEmu.Libs/Gpu/Rendering/DrawTargets.cs index 0b901da..3912c01 100644 --- a/src/SharpEmu.Libs/Gpu/Rendering/DrawTargets.cs +++ b/src/SharpEmu.Libs/Gpu/Rendering/DrawTargets.cs @@ -15,13 +15,23 @@ public struct ColorTargetState public ResourceSlotIdentifier Image; public ImageView View; - public ColorTargetState(in ColorTargetResolution resolution, uint slot, ResourceSlotIdentifier image) + // The host resolution the cache actually gave this image, which is one once the image + // has been dropped back to guest resolution. Asking the scaling rules again would miss + // that, so the render area, viewport and scissor read it from here. + public float RenderScale; + + public ColorTargetState(in ColorTargetResolution resolution, uint slot, ResourceSlotIdentifier image, float renderScale) { Resolution = resolution; Slot = slot; Image = image; View = default; + RenderScale = renderScale; } + + public readonly Extent2D HostExtent => new( + RenderScalePolicy.ScaleLength(Resolution.Extent.Width, RenderScale), + RenderScalePolicy.ScaleLength(Resolution.Extent.Height, RenderScale)); } // The depth target of a draw; HasTarget is false when no depth or stencil state is active. @@ -32,14 +42,22 @@ public struct DepthAttachmentState public ImageView View; public bool MetadataClear; - public DepthAttachmentState(in DepthTargetState target, ResourceSlotIdentifier image) + // See ColorTargetState.RenderScale. + public float RenderScale; + + public DepthAttachmentState(in DepthTargetState target, ResourceSlotIdentifier image, float renderScale) { Target = target; Image = image; View = default; MetadataClear = false; + RenderScale = renderScale; } + public readonly uint HostWidth => RenderScalePolicy.ScaleLength(Target.Target.Width, RenderScale); + + public readonly uint HostHeight => RenderScalePolicy.ScaleLength(Target.Target.Height, RenderScale); + public readonly bool HasTarget => Image.IsValid && Target.Target.Format != Format.Undefined; // The attachment loads a clear when the registers or the metadata say so. diff --git a/src/SharpEmu.Libs/Gpu/Rendering/IRenderHost.cs b/src/SharpEmu.Libs/Gpu/Rendering/IRenderHost.cs index 21a3037..56c4b1e 100644 --- a/src/SharpEmu.Libs/Gpu/Rendering/IRenderHost.cs +++ b/src/SharpEmu.Libs/Gpu/Rendering/IRenderHost.cs @@ -99,6 +99,13 @@ public interface IRenderHost void BindRenderTarget(ResourceSlotIdentifier image); + // The attachments of one pass must share a host resolution; a disagreement drops the + // scaled image back to guest resolution for good and the draw resolves its targets again. + void DemoteRenderScale(ResourceSlotIdentifier image); + + // The host resolution multiplier of one cached image; one when it is at guest resolution. + float GetRenderScale(ResourceSlotIdentifier image); + void ResetBindings(); // Finds the image again when it changed, makes the view, sets the layout and resolves a metadata clear. diff --git a/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.DynamicState.cs b/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.DynamicState.cs index cc31c82..4f4ca77 100644 --- a/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.DynamicState.cs +++ b/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.DynamicState.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-2.0-or-later using SharpEmu.Libs.Gpu.GpuCommands.Registers; +using SharpEmu.Libs.Gpu.Images; using SharpEmu.Libs.VideoOut; using Silk.NET.Vulkan; @@ -28,6 +29,18 @@ public sealed partial class RenderExecutor return hostDepthBits == 0 ? guestFactor : MathF.ScaleB(guestFactor, hostDepthBits + offset.NegativeDepthBits); } + // The host resolution of the attachments a draw writes; all of them share one rule, so + // the first bound target decides. + private static float AttachmentRenderScale(in DrawState state) + { + if (state.ColorCount > 0 && state.Colors[0].Image.IsValid) + { + return state.Colors[0].RenderScale; + } + + return state.Depth.Image.IsValid ? state.Depth.RenderScale : 1f; + } + // The dynamic state of a draw from the context bank and the resolved targets. private DynamicDrawState BuildDynamicState(ContextRegisters context, in DrawState state) { @@ -36,6 +49,9 @@ public sealed partial class RenderExecutor var limits = _host.Limits; uint framebufferWidth; uint framebufferHeight; + // The guest describes its viewport and scissor in guest pixels; the attachments decide + // how many host texels one of those covers. + var renderScale = AttachmentRenderScale(in state); if (state.ColorCount > 0 && state.Colors[0].Image.IsValid) { framebufferWidth = state.Colors[0].Resolution.Extent.Width; @@ -53,6 +69,13 @@ public sealed partial class RenderExecutor } var scissor = ResolveScissor(viewportRegisters, context.ScanMode, framebufferWidth, framebufferHeight); + if (renderScale != 1f) + { + scissor = ScaleScissor(scissor, renderScale, + RenderScalePolicy.ScaleLength(framebufferWidth, renderScale), + RenderScalePolicy.ScaleLength(framebufferHeight, renderScale)); + } + ref readonly var viewport = ref viewportRegisters.Viewports[0]; float viewportX; float viewportY; @@ -60,17 +83,19 @@ public sealed partial class RenderExecutor float viewportHeight; if (context.Clip.ClipDisable) { + // The vertex program bakes the transform onto the same reference extent, so + // multiplying only the host viewport puts guest pixels at host positions. viewportX = 0; viewportY = 0; - viewportWidth = Math.Min(limits.MaxViewportWidth, MaxViewportDimension); - viewportHeight = Math.Min(limits.MaxViewportHeight, MaxViewportDimension); + viewportWidth = RenderScalePolicy.ClipSpaceReferenceExtent(Math.Min(limits.MaxViewportWidth, MaxViewportDimension)) * renderScale; + viewportHeight = RenderScalePolicy.ClipSpaceReferenceExtent(Math.Min(limits.MaxViewportHeight, MaxViewportDimension)) * renderScale; } else { - viewportX = viewport.XOffset - viewport.XScale; - viewportY = viewport.YOffset - viewport.YScale; - viewportWidth = viewport.XScale * 2f; - viewportHeight = viewport.YScale * 2f; + viewportX = (viewport.XOffset - viewport.XScale) * renderScale; + viewportY = (viewport.YOffset - viewport.YScale) * renderScale; + viewportWidth = viewport.XScale * 2f * renderScale; + viewportHeight = viewport.YScale * 2f * renderScale; } var lineWidth = context.LineWidth; @@ -139,6 +164,21 @@ public sealed partial class RenderExecutor colorWriteMask); } + // A guest scissor in host texels. The edges round outward so no guest pixel the game + // asked for loses its host coverage, and the result stays inside the attachment. + public static ScissorRectangle ScaleScissor(ScissorRectangle scissor, float scale, uint hostWidth, uint hostHeight) + { + static int Floor(int value, float scale) => (int)MathF.Floor(value * scale); + static int Ceiling(int value, float scale) => (int)MathF.Ceiling(value * scale); + var left = Math.Clamp(Floor(scissor.Left, scale), 0, (int)hostWidth); + var top = Math.Clamp(Floor(scissor.Top, scale), 0, (int)hostHeight); + return new ScissorRectangle( + left, + top, + Math.Clamp(Ceiling(scissor.Right, scale), left, (int)hostWidth), + Math.Clamp(Ceiling(scissor.Bottom, scale), top, (int)hostHeight)); + } + // The screen, window, generic, viewport and clip rectangles intersected and clamped to the extent. public static ScissorRectangle ResolveScissor(ScreenViewportRegisters viewport, in ScanModeRegisters scanMode, uint width, uint height) { diff --git a/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.Resources.cs b/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.Resources.cs index dc570ef..32c576a 100644 --- a/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.Resources.cs +++ b/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.Resources.cs @@ -217,8 +217,13 @@ public sealed partial class RenderExecutor IPreparedBindings? pixelBindings; try { - vertexBindings = PrepareBindings(vertexInput.Stage); - pixelBindings = state.PixelActive ? PrepareBindings(pixelInput.Stage) : null; + // The pixel program reads its position in host texels; the attachments say how + // many of those one guest pixel covers. + var attachmentScale = AttachmentRenderScale(in state); + vertexBindings = PrepareBindings(vertexInput.Stage with { AttachmentRenderScale = attachmentScale }); + pixelBindings = state.PixelActive + ? PrepareBindings(pixelInput.Stage with { AttachmentRenderScale = attachmentScale }) + : null; } catch (DrawImageTypeMismatchException rejection) { diff --git a/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.Targets.cs b/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.Targets.cs index 51d750a..ba546c7 100644 --- a/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.Targets.cs +++ b/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.Targets.cs @@ -24,30 +24,18 @@ public sealed partial class RenderExecutor return false; } - for (var slot = 0u; slot < ContextRegisters.ColorTargetCount; slot++) + for (var attempt = 0; attempt < 2; attempt++) { - if (slot != 0 && (context.RenderTargetMaskForSlot(slot) == 0 || context.ColorTargets[slot].BaseAddress == 0)) + ResolveTargetImages(context, ref state); + if (!TryDemoteMismatchedRenderScales(ref state)) { - continue; + break; } - if (ColorTargetResolver.Resolve(context, slot, DrawLayerOffset, ignoreTargetMask: false, out var resolvedSlot) is not { } resolution) - { - continue; - } - - var request = resolution.Request; - var image = _host.FindImage(ref request, exactFormat: false); - _host.BindRenderTarget(image); - state.Colors[(int)state.ColorCount++] = new ColorTargetState(in resolution, resolvedSlot, image); - } - - if (DepthTargetResolver.Resolve(context, _host.FormatSupport, _host.Fatal) is { } depthTarget) - { - var request = depthTarget.Target.Request; - var image = _host.FindImage(ref request, exactFormat: false); - _host.BindRenderTarget(image); - state.Depth = new DepthAttachmentState(in depthTarget, image); + // Every attachment is back at guest resolution now; resolve them again so the + // draw sees the replacements instead of the images that were just released. + state.ColorCount = 0; + state.Depth = default; } state.PixelActive = HasActivePixelShader(banks); @@ -78,6 +66,73 @@ public sealed partial class RenderExecutor return true; } + private void ResolveTargetImages(ContextRegisters context, ref DrawState state) + { + for (var slot = 0u; slot < ContextRegisters.ColorTargetCount; slot++) + { + if (slot != 0 && (context.RenderTargetMaskForSlot(slot) == 0 || context.ColorTargets[slot].BaseAddress == 0)) + { + continue; + } + + if (ColorTargetResolver.Resolve(context, slot, DrawLayerOffset, ignoreTargetMask: false, out var resolvedSlot) is not { } resolution) + { + continue; + } + + var request = resolution.Request; + var image = _host.FindImage(ref request, exactFormat: false); + _host.BindRenderTarget(image); + state.Colors[(int)state.ColorCount++] = new ColorTargetState(in resolution, resolvedSlot, image, _host.GetRenderScale(image)); + } + + if (DepthTargetResolver.Resolve(context, _host.FormatSupport, _host.Fatal) is { } depthTarget) + { + var request = depthTarget.Target.Request; + var image = _host.FindImage(ref request, exactFormat: false); + _host.BindRenderTarget(image); + state.Depth = new DepthAttachmentState(in depthTarget, image, _host.GetRenderScale(image)); + } + } + + // One rendering scope has one render area, one viewport and one scissor, so its + // attachments have to agree on a host resolution. They disagree when one of them cannot + // be scaled at all, and then the whole pass goes back to guest resolution. + private bool TryDemoteMismatchedRenderScales(ref DrawState state) + { + if (!RenderScalePolicy.Enabled) + { + return false; + } + + var scale = AttachmentRenderScale(in state); + var mismatched = state.Depth.HasTarget && state.Depth.RenderScale != scale; + for (var i = 0; i < state.ColorCount && !mismatched; i++) + { + mismatched = state.Colors[i].RenderScale != scale; + } + + if (!mismatched) + { + return false; + } + + for (var i = 0; i < state.ColorCount; i++) + { + if (state.Colors[i].RenderScale != 1f) + { + _host.DemoteRenderScale(state.Colors[i].Image); + } + } + + if (state.Depth.HasTarget && state.Depth.RenderScale != 1f) + { + _host.DemoteRenderScale(state.Depth.Image); + } + + return true; + } + // Whether the bound vertex (or emulated geometry) stage stores to a buffer or a // storage image, which is the only observable effect a draw without attachments // and without a pixel stage can have. @@ -175,7 +230,12 @@ public sealed partial class RenderExecutor readRequest.View.LevelCount, readRequest.View.BaseLayer, readRequest.View.LayerCount); - var extent = new Extent3D(write.Width, write.Height, 1); + // The copy moves host texels, so the guest extent crosses into the images' resolution. + var copyScale = _host.GetRenderScale(destination); + var extent = new Extent3D( + RenderScalePolicy.ScaleLength(write.Width, copyScale), + RenderScalePolicy.ScaleLength(write.Height, copyScale), + 1); var aspects = (depthCopy ? ImageAspectFlags.DepthBit : 0) | (stencilCopy ? ImageAspectFlags.StencilBit : 0); _host.MarkGpuWritten(destination); _host.CopyDepthStencilImage(source, destination, in range, in extent, aspects); @@ -192,7 +252,7 @@ public sealed partial class RenderExecutor var request = resolution.Request; var image = _host.FindImage(ref request, exactFormat: true); _host.BindRenderTarget(image); - return image.IsValid ? new ColorTargetState(in resolution, resolvedSlot, image) : null; + return image.IsValid ? new ColorTargetState(in resolution, resolvedSlot, image, _host.GetRenderScale(image)) : null; } private static bool IsSupportedSampleCount(uint samples) => samples is 1 or 2 or 4 or 8; @@ -269,8 +329,10 @@ public sealed partial class RenderExecutor } var view = target.Resolution.Request.View; - rendering.Width = Math.Min(rendering.Width, target.Resolution.Extent.Width); - rendering.Height = Math.Min(rendering.Height, target.Resolution.Extent.Height); + // The render area covers host texels, which a scaled attachment has more or fewer of. + var hostExtent = target.HostExtent; + rendering.Width = Math.Min(rendering.Width, hostExtent.Width); + rendering.Height = Math.Min(rendering.Height, hostExtent.Height); rendering.Layers = Math.Min(rendering.Layers, view.LayerCount); var clear = acquired.MetadataClear ? acquired.MetadataClearValue : target.Resolution.ColorClearValue; rendering.ColorAttachments[i] = new RenderingAttachment( @@ -317,8 +379,8 @@ public sealed partial class RenderExecutor _host.DepthAttachmentLayout(in depth)); _host.TransitionDepthAttachment(in depth, layout, loadState.AttachmentWriteAspects(target.Format)); var view = target.Request.View; - rendering.Width = Math.Min(rendering.Width, target.Width); - rendering.Height = Math.Min(rendering.Height, target.Height); + rendering.Width = Math.Min(rendering.Width, depth.HostWidth); + rendering.Height = Math.Min(rendering.Height, depth.HostHeight); rendering.Layers = Math.Min(rendering.Layers, view.LayerCount); var aspects = ViewFormatRules.DepthAspects(target.Format); rendering.DepthStencilAttachment = new RenderingAttachment( diff --git a/src/SharpEmu.Libs/Gpu/Rendering/ShaderStageResources.cs b/src/SharpEmu.Libs/Gpu/Rendering/ShaderStageResources.cs index 8d6aa08..1b2f5ee 100644 --- a/src/SharpEmu.Libs/Gpu/Rendering/ShaderStageResources.cs +++ b/src/SharpEmu.Libs/Gpu/Rendering/ShaderStageResources.cs @@ -80,6 +80,9 @@ public readonly record struct ShaderStageResources(ShaderProgramInfo? Program, R public DispatchThreadLimits? ThreadLimits { get; init; } + // The host resolution of the attachments this draw writes; null outside a draw. + public float? AttachmentRenderScale { get; init; } + public void WriteDispatchThreadLimits(Span shaderData) { if (Program?.Bindings is not { UsesDispatchThreadLimits: true } layout) return; @@ -93,6 +96,25 @@ public readonly record struct ShaderStageResources(ShaderProgramInfo? Program, R shaderData[offset + 1] = limits.Y; shaderData[offset + 2] = limits.Z; } + + // The internal-resolution dwords: the factor that maps one guest pixel onto host texels, + // its reciprocal, which image resources the host holds at that factor, and the factor the + // pixel position is divided by so the program keeps reading guest pixels. + public void WriteRenderScale(Span shaderData, ulong scaledImages, float imageScale) + { + if (Program?.Bindings is not { UsesRenderScale: true } layout) return; + if (shaderData.Length != layout.ShaderDataDwordCount || !float.IsFinite(imageScale) || imageScale <= 0) + { + throw SubmissionScheduler.Fatal("The draw has invalid shader data or an invalid scale for its render scale dwords."); + } + + var offset = (int)layout.RenderScaleDword; + shaderData[offset] = BitConverter.SingleToUInt32Bits(imageScale); + shaderData[offset + 1] = BitConverter.SingleToUInt32Bits(1.0f / imageScale); + shaderData[offset + 2] = (uint)scaledImages; + shaderData[offset + 3] = (uint)(scaledImages >> 32); + shaderData[offset + 4] = BitConverter.SingleToUInt32Bits(1.0f / (AttachmentRenderScale ?? 1.0f)); + } } public readonly record struct DispatchThreadLimits(uint X, uint Y, uint Z); diff --git a/src/SharpEmu.Libs/VideoOut/HostVideoOptions.cs b/src/SharpEmu.Libs/VideoOut/HostVideoOptions.cs index 4581d72..b0bca72 100644 --- a/src/SharpEmu.Libs/VideoOut/HostVideoOptions.cs +++ b/src/SharpEmu.Libs/VideoOut/HostVideoOptions.cs @@ -64,6 +64,10 @@ public sealed record HostVideoOptions public GuestDisplayResolution GuestResolution { get; init; } = GuestDisplayResolution.Hd; + // Host resolution multiplier of the guest render targets the scaling rules accept. + // One renders every target at the size the game asked for. + public float RenderScale { get; init; } = 1.0f; + public int DisplayIndex { get; init; } public int RefreshRate { get; init; } @@ -87,11 +91,20 @@ public sealed record HostVideoOptions internal bool CanUseHdr(bool displayHdrEnabled, bool surfaceSupportsHdr, bool autoAllowed) => surfaceSupportsHdr && (HdrMode == HostHdrMode.On || HdrMode == HostHdrMode.Auto && autoAllowed && displayHdrEnabled); + public const float MinimumRenderScale = 0.25f; + public const float MaximumRenderScale = 2.0f; + + // Rounds to a hundredth so the GUI, the command line and the environment agree bit for bit; + // the renderer compares scales for equality when it decides whether two images alias. + public static float NormalizeRenderScale(float scale) => + !float.IsFinite(scale) ? 1.0f : MathF.Round(Math.Clamp(scale, MinimumRenderScale, MaximumRenderScale), 2); + public HostVideoOptions Normalize() => this with { Width = Math.Clamp(Width, 640, 16384), Height = Math.Clamp(Height, 360, 16384), GuestResolution = Enum.IsDefined(GuestResolution) ? GuestResolution : GuestDisplayResolution.Hd, + RenderScale = NormalizeRenderScale(RenderScale), DisplayIndex = Math.Max(0, DisplayIndex), RefreshRate = Math.Clamp(RefreshRate, 0, 1000), HdrMode = Enum.IsDefined(HdrMode) ? HdrMode : HostHdrMode.Auto, @@ -115,6 +128,7 @@ public static class HostVideoHost { var normalized = options.Normalize(); Volatile.Write(ref _currentOptions, normalized); + Gpu.Images.RenderScalePolicy.Configure(normalized.RenderScale); var configured = VulkanVideoPresenter.TryConfigureVideo(normalized) & MetalVideoPresenter.TryConfigureVideo(normalized); if (configured) diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CachedImages.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CachedImages.cs index 45f9aab..5068036 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CachedImages.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CachedImages.cs @@ -186,6 +186,10 @@ internal static unsafe partial class VulkanVideoPresenter TrackImageBinding(imageIdentifier); } + public void DemoteRenderScale(ResourceSlotIdentifier imageIdentifier) => _imageCache.DemoteRenderScale(imageIdentifier); + + public float GetRenderScale(ResourceSlotIdentifier imageIdentifier) => _imageCache.GetImage(imageIdentifier).RenderScale; + private void ResetImageBindings() { foreach (var imageIdentifier in _trackedImageBindings) diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Descriptors.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Descriptors.cs index 5afa4e8..d64b782 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Descriptors.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Descriptors.cs @@ -122,6 +122,7 @@ internal static unsafe partial class VulkanVideoPresenter var resolution = ImageRequestBuilders.Texture(words, ShapeOf(image)); _ = BeginBatchedGuestCommands(); var request = resolution.Request; + request.ShaderWrite = storage && (image.Written || image.Atomic); var imageIdentifier = _imageCache.FindImage(ref request, resolution.ExactFormat); resolution = resolution with { Request = request }; imageIdentifier = ImageRequestBuilders.ValidateTextureOwner(_imageCache, imageIdentifier, resolution); @@ -293,6 +294,22 @@ internal static unsafe partial class VulkanVideoPresenter } stage.WriteDispatchThreadLimits(shaderData); + if (layout.UsesRenderScale) + { + ulong scaledImages = 0; + var scaledCount = Math.Min(info.Images.Count, 64); + for (var index = 0; index < scaledCount; index++) + { + var identifier = descriptors.Images[index].ImageIdentifier; + if (identifier.IsValid && _imageCache.GetImage(identifier).IsScaled) + { + scaledImages |= 1UL << index; + } + } + + stage.WriteRenderScale(shaderData, scaledImages, RenderScalePolicy.Scale); + } + prepared.ShaderData = shaderData; if (layout.Find(DescriptorBindingKind.GlobalDataShare) is not null) { diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.RenderScale.cs b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.RenderScale.cs new file mode 100644 index 0000000..0648de1 --- /dev/null +++ b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.RenderScale.cs @@ -0,0 +1,96 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.ShaderCompiler; + +namespace SharpEmu.ShaderCompiler.Vulkan; + +// Internal resolution scaling. The host may hold a guest render target at another size, so +// everything the program expresses in pixels has to be translated: its own pixel position, +// the integer coordinates it addresses an image with and the sizes a resource query reports. +// Which images are scaled is a per-draw fact, so the factors arrive in shader data rather +// than through a permutation. +public static partial class Gen5SpirvTranslator +{ + private sealed partial class CompilationContext + { + // Shader-data dwords, relative to BindingLayout.RenderScaleDword. + private const uint ImageCoordinateFactorDword = 0; + private const uint ImageSizeFactorDword = 1; + private const uint ScaledImageMaskLowDword = 2; + private const uint ScaledImageMaskHighDword = 3; + private const uint PixelPositionFactorDword = 4; + + private bool UsesRenderScale => _request.Bindings.UsesRenderScale; + + private uint LoadRenderScaleFloat(uint dword) => + Bitcast(_floatType, LoadShaderDataDword(UInt(_request.Bindings.RenderScaleDword + dword))); + + // One when the host holds this image at guest resolution, the scale otherwise. The + // mask is loaded per use so the value never has to dominate a later block. + private uint LoadImageScaleFactor(in SpirvImageResource resource, uint factorDword) + { + var index = resource.ResourceIndex; + var maskDword = index < 32 ? ScaledImageMaskLowDword : ScaledImageMaskHighDword; + var bit = UInt(1u << (int)(index & 31)); + var mask = LoadShaderDataDword(UInt(_request.Bindings.RenderScaleDword + maskDword)); + var scaled = _module.AddInstruction(SpirvOp.INotEqual, _boolType, + _module.AddInstruction(SpirvOp.BitwiseAnd, _uintType, mask, bit), UInt(0)); + return _module.AddInstruction(SpirvOp.Select, _floatType, scaled, LoadRenderScaleFloat(factorDword), Float(1f)); + } + + // The pixel position addresses host texels; dividing x and y by the attachments' + // factor hands the program the guest pixel it would have shaded natively. + private uint ScalePixelPosition(uint fragCoord) + { + if (!UsesRenderScale) + { + return fragCoord; + } + + var factor = LoadRenderScaleFloat(PixelPositionFactorDword); + var components = new uint[4]; + for (uint component = 0; component < 4; component++) + { + var value = _module.AddInstruction(SpirvOp.CompositeExtract, _floatType, fragCoord, component); + components[component] = component < 2 + ? _module.AddInstruction(SpirvOp.FMul, _floatType, value, factor) + : value; + } + + return _module.AddInstruction(SpirvOp.CompositeConstruct, _vec4Type, components); + } + + // A guest pixel coordinate in host texels. The program computed it against the guest + // size, so it is multiplied by the factor and truncated back to a texel index. + private uint ScaleIntegerImageCoordinate(uint coordinate, in SpirvImageResource resource) + { + if (!UsesRenderScale) + { + return coordinate; + } + + var factor = LoadImageScaleFactor(resource, ImageCoordinateFactorDword); + var scaled = _module.AddInstruction(SpirvOp.FMul, _floatType, + _module.AddInstruction(SpirvOp.ConvertSToF, _floatType, coordinate), factor); + return _module.AddInstruction(SpirvOp.ConvertFToS, _intType, scaled); + } + + // A resource query reports the size the game declared, not the host's: the program + // derives texture coordinates and loop bounds from it in guest pixels. + private uint ScaleImageQuerySize(uint size, in SpirvImageResource resource) + { + if (!UsesRenderScale) + { + return size; + } + + var factor = LoadImageScaleFactor(resource, ImageSizeFactorDword); + var scaled = _module.AddInstruction(SpirvOp.FAdd, _floatType, + _module.AddInstruction(SpirvOp.FMul, _floatType, + _module.AddInstruction(SpirvOp.ConvertSToF, _floatType, size), factor), + Float(0.5f)); + return _module.AddInstruction(SpirvOp.ConvertFToS, _intType, scaled); + } + } +} diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Resources.cs b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Resources.cs index c0fbe00..f4ed0ff 100644 --- a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Resources.cs +++ b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Resources.cs @@ -1597,7 +1597,8 @@ public static partial class Gen5SpirvTranslator imageClass.Dimension, imageInfo.ConversionFormat, imageInfo.ShaderSwizzle, - imageInfo.EmulatedCompareFunction); + imageInfo.EmulatedCompareFunction, + (uint)resourceIndex); dstSelect = imageInfo.ShaderSwizzle; return true; } diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs index a896618..adcc5b8 100644 --- a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs +++ b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs @@ -234,7 +234,9 @@ public static partial class Gen5SpirvTranslator SpirvImageDim Dimension = SpirvImageDim.Dim2D, uint ConversionFormat = 0, uint ShaderSwizzle = 0, - int EmulatedCompareFunction = -1); + int EmulatedCompareFunction = -1, + // The dense image resource index, which names this image's bit in the scaled-image mask. + uint ResourceIndex = 0); private readonly record struct SpirvVertexInput( uint Variable, @@ -1260,7 +1262,7 @@ public static partial class Gen5SpirvTranslator } else if (_stage == Gen5SpirvStage.Pixel) { - var fragCoord = Load(_vec4Type, _fragCoordInput); + var fragCoord = ScalePixelPosition(Load(_vec4Type, _fragCoordInput)); EmitPixelInputState(fragCoord); foreach (var output in _pixelOutputs.Values) { @@ -5204,6 +5206,12 @@ public static partial class Gen5SpirvTranslator _intType, size, component); + // The game reasons about its own resolution; report the guest size. + if (component < Math.Min(2u, ImageSpatialComponentCount(resource))) + { + signedValue = ScaleImageQuerySize(signedValue, resource); + } + value = Bitcast(_uintType, signedValue); } else if (component == 3) @@ -5265,6 +5273,7 @@ public static partial class Gen5SpirvTranslator var coordinateComponentCount = ImageCoordinateComponentCount(resource); var coordinates = BuildIntegerCoordinates( + resource, image, 0, coordinateComponentCount); @@ -5334,6 +5343,7 @@ public static partial class Gen5SpirvTranslator var isMax = instruction.Opcode == "ImageAtomicFmax"; var floatCoordinateCount = ImageCoordinateComponentCount(resource); var floatCoordinates = BuildIntegerCoordinates( + resource, image, 0, floatCoordinateCount); @@ -5378,6 +5388,7 @@ public static partial class Gen5SpirvTranslator var coordinateComponentCount = ImageCoordinateComponentCount(resource); var coordinates = BuildIntegerCoordinates( + resource, image, 0, coordinateComponentCount); @@ -5427,6 +5438,7 @@ public static partial class Gen5SpirvTranslator var coordinateComponentCount = ImageCoordinateComponentCount(resource); var coordinates = BuildIntegerCoordinates( + resource, image, 0, coordinateComponentCount); @@ -5443,6 +5455,7 @@ public static partial class Gen5SpirvTranslator var coordinateComponentCount = ImageCoordinateComponentCount(resource); var coordinates = BuildIntegerCoordinates( + resource, image, 0, coordinateComponentCount); @@ -6339,16 +6352,22 @@ public static partial class Gen5SpirvTranslator } private uint BuildIntegerCoordinates( + in SpirvImageResource resource, Gen5ImageControl image, int start, uint componentCount) { + // Only the two scaled axes move; an array layer or a volume slice is not a pixel. + var scaledComponents = start == 0 ? Math.Min(2, (int)ImageSpatialComponentCount(resource)) : 0; var components = new uint[checked((int)componentCount)]; for (var component = 0; component < components.Length; component++) { - components[component] = Bitcast( + var value = Bitcast( _intType, LoadImageIntegerAddress(image, start + component)); + components[component] = component < scaledComponents + ? ScaleIntegerImageCoordinate(value, resource) + : value; } if (componentCount == 1) @@ -6478,6 +6497,30 @@ public static partial class Gen5SpirvTranslator SpirvOp.ConvertSToF, spatialFloatType, texelOffset); + // The instruction's offset is in guest texels. Convert it to host texels + // before normalizing by the host mip extent. Array layers and depth are + // never scaled; the per-image mask also preserves unscaled bindings. + if (UsesRenderScale) + { + var factor = LoadImageScaleFactor(resource, ImageCoordinateFactorDword); + if (spatialComponentCount == 1) + { + offsetFloat = _module.AddInstruction(SpirvOp.FMul, _floatType, offsetFloat, factor); + } + else + { + var components = new uint[checked((int)spatialComponentCount)]; + for (uint component = 0; component < spatialComponentCount; component++) + { + var value = _module.AddInstruction(SpirvOp.CompositeExtract, _floatType, offsetFloat, component); + components[component] = component < 2 + ? _module.AddInstruction(SpirvOp.FMul, _floatType, value, factor) + : value; + } + offsetFloat = _module.AddInstruction(SpirvOp.CompositeConstruct, spatialFloatType, components); + } + } + var normalizedOffset = _module.AddInstruction( SpirvOp.FDiv, spatialFloatType, diff --git a/src/SharpEmu.ShaderCompiler/Resources/BindingLayout.cs b/src/SharpEmu.ShaderCompiler/Resources/BindingLayout.cs index 9829b63..f039f87 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/BindingLayout.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/BindingLayout.cs @@ -337,12 +337,21 @@ public sealed class BindingLayout : IEquatable public uint MemoryOffsetDword { get; init; } public uint MemoryOffsetCount { get; init; } public bool UsesDispatchThreadLimits { get; init; } + + // The module reads the internal-resolution dwords: the factor that maps a guest pixel + // onto host texels, its reciprocal, the mask of scaled image resources and the factor + // the pixel position is divided by. + public bool UsesRenderScale { get; init; } public IReadOnlyList UserDataRegisters { get; init; } = []; public IReadOnlyList Descriptors { get; init; } = []; + public const uint RenderScaleDwordCount = 5; + public uint DispatchThreadLimitsDword => MemoryOffsetDword + (MemoryOffsetCount + 3) / 4; - public uint ShaderDataDwordCount => DispatchThreadLimitsDword + (UsesDispatchThreadLimits ? 3u : 0u); + public uint RenderScaleDword => DispatchThreadLimitsDword + (UsesDispatchThreadLimits ? 3u : 0u); + + public uint ShaderDataDwordCount => RenderScaleDword + (UsesRenderScale ? RenderScaleDwordCount : 0u); public bool UsesPushData => PushDataStartDword != PushData.NoStart; @@ -561,12 +570,14 @@ public sealed class BindingLayout : IEquatable bool usesFlattenedTable, bool usesShaderBase, uint pushDataStartDword = 0, - bool usesDispatchThreadLimits = false) + bool usesDispatchThreadLimits = false, + bool usesRenderScale = false) { var shaderBaseDword = usesShaderBase ? (uint)userDataRegisters.Count : NoShaderBase; var memoryOffsetDword = (uint)userDataRegisters.Count + (usesShaderBase ? ShaderBaseDwordCount : 0); var memoryOffsetCount = (uint)info.Buffers.Count; - var shaderDataDwords = memoryOffsetDword + (memoryOffsetCount + 3) / 4 + (usesDispatchThreadLimits ? 3u : 0u); + var shaderDataDwords = memoryOffsetDword + (memoryOffsetCount + 3) / 4 + (usesDispatchThreadLimits ? 3u : 0u) + + (usesRenderScale ? RenderScaleDwordCount : 0u); var pushStart = PushData.StartFor(pushDataStartDword, shaderDataDwords); var descriptors = new List(); if (info.Buffers.Count != 0) @@ -644,6 +655,7 @@ public sealed class BindingLayout : IEquatable MemoryOffsetDword = memoryOffsetDword, MemoryOffsetCount = memoryOffsetCount, UsesDispatchThreadLimits = usesDispatchThreadLimits, + UsesRenderScale = usesRenderScale, UserDataRegisters = userDataRegisters, Descriptors = descriptors, }; @@ -657,13 +669,14 @@ public sealed class BindingLayout : IEquatable MemoryOffsetDword == other.MemoryOffsetDword && MemoryOffsetCount == other.MemoryOffsetCount && UsesDispatchThreadLimits == other.UsesDispatchThreadLimits && + UsesRenderScale == other.UsesRenderScale && UserDataRegisters.SequenceEqual(other.UserDataRegisters) && Descriptors.Count == other.Descriptors.Count && Descriptors.Zip(other.Descriptors).All(pair => pair.First.Kind == pair.Second.Kind && pair.First.Resources.SequenceEqual(pair.Second.Resources)); public override bool Equals(object? obj) => Equals(obj as BindingLayout); - public override int GetHashCode() => HashCode.Combine(PushDataStartDword, MemoryOffsetDword, MemoryOffsetCount, Descriptors.Count, UsesDispatchThreadLimits); + public override int GetHashCode() => HashCode.Combine(PushDataStartDword, MemoryOffsetDword, MemoryOffsetCount, Descriptors.Count, UsesDispatchThreadLimits, UsesRenderScale); } // Recomputes the layout an emitter was given from the same inputs and the same push @@ -685,7 +698,7 @@ public static class BindingLayoutValidator throw new ResourcePlanException("Only a compute shader can use dispatch thread limits."); } - var expected = BindingLayout.Allocate(info, userDataRegisters, usesGlobalDataShare, usesFlattenedTable, usesShaderBase, layout.AllocationCursor, layout.UsesDispatchThreadLimits); + var expected = BindingLayout.Allocate(info, userDataRegisters, usesGlobalDataShare, usesFlattenedTable, usesShaderBase, layout.AllocationCursor, layout.UsesDispatchThreadLimits, layout.UsesRenderScale); if (!expected.Equals(layout)) { throw new ResourcePlanException( diff --git a/tests/SharpEmu.Libs.Tests/GUI/PerGameSettingsTests.cs b/tests/SharpEmu.Libs.Tests/GUI/PerGameSettingsTests.cs index 5f67277..b18bbb2 100644 --- a/tests/SharpEmu.Libs.Tests/GUI/PerGameSettingsTests.cs +++ b/tests/SharpEmu.Libs.Tests/GUI/PerGameSettingsTests.cs @@ -24,6 +24,32 @@ public sealed class PerGameSettingsTests Assert.Equal("3840x2160", EffectiveLaunchSettings.Resolve(global, game).GuestResolution); } + [Fact] + public void RenderScaleDefaultsToNativeAndCanBeOverriddenPerGame() + { + var global = new GuiSettings(); + Assert.Equal(1.0f, EffectiveLaunchSettings.Resolve(global, null).RenderScale); + var game = new PerGameSettings { RenderScale = 0.5f }; + game.RemoveInheritedValues(global); + Assert.False(game.IsEmpty); + var restored = PerGameSettings.NormalizeFromJson(System.Text.Json.JsonSerializer.Serialize(game)); + Assert.Equal(0.5f, EffectiveLaunchSettings.Resolve(global, restored).RenderScale); + global.RenderScale = 0.5f; + game.RemoveInheritedValues(global); + Assert.True(game.IsEmpty); + Assert.Equal(0.5f, EffectiveLaunchSettings.Resolve(global, game).RenderScale); + } + + [Theory] + [InlineData(0f, 0.25f)] + [InlineData(100f, 2.0f)] + [InlineData(float.NaN, 1.0f)] + public void OutOfRangeRenderScaleIsClamped(float value, float expected) + { + Assert.Equal(expected, EffectiveLaunchSettings.Resolve( + new GuiSettings(), new PerGameSettings { RenderScale = value }).RenderScale); + } + [Theory] [InlineData(null)] [InlineData("")] diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Images/RenderScalePolicyTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/Images/RenderScalePolicyTests.cs new file mode 100644 index 0000000..32e8344 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Gpu/Images/RenderScalePolicyTests.cs @@ -0,0 +1,154 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.HLE.GpuMemory; +using SharpEmu.Libs.Gpu.Images; +using Silk.NET.Vulkan; +using Xunit; + +namespace SharpEmu.Libs.Tests.Gpu.Images; + +public sealed class RenderScalePolicyTests +{ + private const float Half = 0.5f; + + // Without a device nothing can be resized; these tests ask about the geometry rules. + static RenderScalePolicyTests() => RenderScalePolicy.ConfigureFormatSupport(_ => true); + + private static ImageDescription ColorTarget(uint width = 1920, uint height = 1080) + { + var description = ImageDescription.Create(); + description.Data = new GuestSpan(0x1_0000, (ulong)width * height * 4); + description.PixelFormat = Format.R8G8B8A8Unorm; + description.GuestFormat = GuestPixelFormat.Bits8_8_8_8UNorm; + description.Extent = new Extent3D(width, height, 1); + description.Pitch = width; + description.BytesPerBlock = 4; + description.TileMode = GuestTileMode.RenderTarget; + description.MipLayout[0] = new MipLevelLayout { Offset = 0, Size = description.Data.Size, Pitch = width, Height = height }; + return description; + } + + [Fact] + public void ScreenClassRenderTargetsScale() + { + Assert.Equal(Half, RenderScalePolicy.ScaleFor(ColorTarget(), Half)); + Assert.Equal(Half, RenderScalePolicy.ScaleFor(ColorTarget(3840, 2160), Half)); + Assert.Equal(2.0f, RenderScalePolicy.ScaleFor(ColorTarget(2304, 1296), 2.0f)); + } + + [Fact] + public void NativeScaleNeverChangesAnything() + { + Assert.Equal(1.0f, RenderScalePolicy.ScaleFor(ColorTarget(), 1.0f)); + Assert.Equal(1.0f, RenderScalePolicy.ScaleFor(ColorTarget(640, 360), 1.0f)); + } + + [Fact] + public void DepthTargetsScale() + { + var description = ColorTarget(); + description.PixelFormat = Format.D32Sfloat; + description.GuestFormat = GuestPixelFormat.Bits32Float; + description.TileMode = GuestTileMode.Depth; + Assert.Equal(Half, RenderScalePolicy.ScaleFor(description, Half)); + } + + [Theory] + [InlineData(1279u, 1080u)] + [InlineData(1920u, 719u)] + [InlineData(64u, 64u)] + public void SmallTargetsStayNative(uint width, uint height) + { + Assert.Equal(1.0f, RenderScalePolicy.ScaleFor(ColorTarget(width, height), Half)); + } + + [Fact] + public void SquareTargetsStayNative() + { + Assert.Equal(1.0f, RenderScalePolicy.ScaleFor(ColorTarget(2048, 2048), Half)); + } + + [Fact] + public void AssetTilingsStayNative() + { + foreach (var tileMode in new[] { GuestTileMode.Linear, GuestTileMode.Standard64KB, GuestTileMode.Standard4KB, GuestTileMode.Prt }) + { + var description = ColorTarget(); + description.TileMode = tileMode; + Assert.Equal(1.0f, RenderScalePolicy.ScaleFor(description, Half)); + } + } + + [Fact] + public void FootprintsTheHostCannotReproduceStayNative() + { + var multisample = ColorTarget(); + multisample.Samples = 4; + Assert.Equal(1.0f, RenderScalePolicy.ScaleFor(multisample, Half)); + + var mipped = ColorTarget(); + mipped.Resources = new SubresourceCount(4, 1); + Assert.Equal(1.0f, RenderScalePolicy.ScaleFor(mipped, Half)); + + var layered = ColorTarget(); + layered.Resources = new SubresourceCount(1, 6); + Assert.Equal(1.0f, RenderScalePolicy.ScaleFor(layered, Half)); + + var volume = ColorTarget(); + volume.Type = GuestImageType.Color3D; + Assert.Equal(1.0f, RenderScalePolicy.ScaleFor(volume, Half)); + + var compressed = ColorTarget(); + compressed.GuestFormat = GuestPixelFormat.Bc7UNorm; + Assert.Equal(1.0f, RenderScalePolicy.ScaleFor(compressed, Half)); + + var htile = ColorTarget(); + htile.PixelFormat = Format.D32Sfloat; + htile.GuestFormat = GuestPixelFormat.Bits32Float; + htile.TileMode = GuestTileMode.Depth; + htile.Metadata = new MetadataDescription { Kind = MetadataKind.Htile, Range = new GuestSpan(0x40_0000, 0x1000) }; + Assert.Equal(1.0f, RenderScalePolicy.ScaleFor(htile, Half)); + + var stencil = ColorTarget(); + stencil.Stencil = new GuestSpan(0x80_0000, 0x1000); + Assert.Equal(1.0f, RenderScalePolicy.ScaleFor(stencil, Half)); + } + + // DCC describes guest bytes the host image never stores, so the backing can be any size. + [Fact] + public void ColourCompressionMetadataStillScales() + { + var description = ColorTarget(); + description.Metadata = new MetadataDescription { Kind = MetadataKind.Dcc, Range = new GuestSpan(0x40_0000, 0x1000) }; + Assert.Equal(Half, RenderScalePolicy.ScaleFor(description, Half)); + } + + [Fact] + public void HelperImagesWithoutGuestMemoryStayNative() + { + var description = ColorTarget(); + description.Data = default; + Assert.Equal(1.0f, RenderScalePolicy.ScaleFor(description, Half)); + } + + [Theory] + [InlineData(1920u, 0.5f, 960u)] + [InlineData(1080u, 0.5f, 540u)] + [InlineData(1281u, 0.5f, 641u)] + [InlineData(1920u, 0.75f, 1440u)] + [InlineData(1920u, 2.0f, 3840u)] + [InlineData(1u, 0.5f, 1u)] + [InlineData(0u, 0.5f, 0u)] + public void LengthsRoundUpSoNoGuestPixelIsLost(uint length, float scale, uint expected) + { + Assert.Equal(expected, RenderScalePolicy.ScaleLength(length, scale)); + } + + [Fact] + public void ExtentScalingLeavesDepthAlone() + { + var scaled = RenderScalePolicy.ScaleExtent(new Extent3D(1920, 1080, 4), Half); + Assert.Equal(new Extent3D(960, 540, 4), scaled); + } +} diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/ShaderPipelineCacheTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/ShaderPipelineCacheTests.cs index 4d2a98b..b23893b 100644 --- a/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/ShaderPipelineCacheTests.cs +++ b/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/ShaderPipelineCacheTests.cs @@ -315,7 +315,7 @@ public sealed class ShaderPipelineCacheTests : IDisposable var programs = Programs(pixelStage: Stage(new ShaderProgramInfo { Stage = ShaderStageKind.Pixel, PixelColorExportMasks = exportMasks })); var resolution = new ColorTargetResolution( default, 0x1000, 0x10000, new Extent2D(64, 64), 0, 0, 1, ColorComponentMap.Identity, false, false, default); - ColorTargetState[] colors = [new(in resolution, 0, new SharpEmu.Libs.Gpu.Buffers.ResourceSlotIdentifier(1, 1))]; + ColorTargetState[] colors = [new(in resolution, 0, new SharpEmu.Libs.Gpu.Buffers.ResourceSlotIdentifier(1, 1), 1f)]; var rendering = new RenderingState { Samples = 1, ColorAttachmentCount = 1 }; rendering.ColorAttachments[0] = new RenderingAttachment( default, ImageLayout.ColorAttachmentOptimal, Format.R8G8B8A8Unorm, 0, 0, 0, 0, false, false, false, false, false); @@ -335,7 +335,7 @@ public sealed class ShaderPipelineCacheTests : IDisposable var programs = Programs(); var resolution = new ColorTargetResolution( default, 0x1000, 0x10000, new Extent2D(64, 64), 0, 0, 1, ColorComponentMap.Identity, false, false, default); - ColorTargetState[] colors = [new(in resolution, 0, new SharpEmu.Libs.Gpu.Buffers.ResourceSlotIdentifier(1, 1))]; + ColorTargetState[] colors = [new(in resolution, 0, new SharpEmu.Libs.Gpu.Buffers.ResourceSlotIdentifier(1, 1), 1f)]; var rendering = new RenderingState { Samples = 1, ColorAttachmentCount = 1 }; rendering.ColorAttachments[0] = new RenderingAttachment( default, ImageLayout.ColorAttachmentOptimal, Format.R8G8B8A8Unorm, 0, 0, 0, 0, false, false, false, false, false); diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Rendering/RenderExecutorStateTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/Rendering/RenderExecutorStateTests.cs index 7eefe51..3aebdab 100644 --- a/tests/SharpEmu.Libs.Tests/Gpu/Rendering/RenderExecutorStateTests.cs +++ b/tests/SharpEmu.Libs.Tests/Gpu/Rendering/RenderExecutorStateTests.cs @@ -205,6 +205,21 @@ public sealed class RenderExecutorStateTests : IDisposable Assert.Equal(new ScissorRectangle(0, 0, 0, 0), RenderExecutor.ResolveScissor(viewport, in scanMode, 64, 32)); } + [Fact] + public void ScaledScissorRoundsOutwardAndStaysInsideTheAttachment() + { + // A guest rectangle keeps its host coverage: the edges round outward, so no guest + // pixel the game asked for is clipped away by the mapping itself. + Assert.Equal(new ScissorRectangle(5, 2, 51, 29), RenderExecutor.ScaleScissor(new ScissorRectangle(11, 5, 101, 57), 0.5f, 64, 36)); + Assert.Equal(new ScissorRectangle(22, 10, 202, 114), RenderExecutor.ScaleScissor(new ScissorRectangle(11, 5, 101, 57), 2f, 256, 144)); + + // Rounding outward must not push the rectangle past the attachment. + Assert.Equal(new ScissorRectangle(0, 0, 32, 18), RenderExecutor.ScaleScissor(new ScissorRectangle(0, 0, 65, 37), 0.5f, 32, 18)); + + // An empty scissor stays empty rather than growing a row. + Assert.Equal(new ScissorRectangle(5, 5, 5, 5), RenderExecutor.ScaleScissor(new ScissorRectangle(10, 10, 10, 10), 0.5f, 64, 36)); + } + [Fact] public void Scissor_ClipRectangleRulesIntersectTheSelectedRectangles() { diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Rendering/RenderExecutorTestSupport.cs b/tests/SharpEmu.Libs.Tests/Gpu/Rendering/RenderExecutorTestSupport.cs index 36f7234..0fe26c9 100644 --- a/tests/SharpEmu.Libs.Tests/Gpu/Rendering/RenderExecutorTestSupport.cs +++ b/tests/SharpEmu.Libs.Tests/Gpu/Rendering/RenderExecutorTestSupport.cs @@ -177,6 +177,10 @@ internal sealed class RecordingRenderHost : IRenderHost public void BindRenderTarget(ResourceSlotIdentifier image) => Calls.Add($"bind_target {image.Index}"); + public void DemoteRenderScale(ResourceSlotIdentifier image) => Calls.Add($"demote_scale {image.Index}"); + + public float GetRenderScale(ResourceSlotIdentifier image) => 1f; + public void ResetBindings() => Calls.Add("reset_bindings"); public ColorAttachmentAcquisition AcquireColorAttachment(in ColorTargetState target) diff --git a/tests/SharpEmu.Libs.Tests/VideoOut/HostVideoOptionsTests.cs b/tests/SharpEmu.Libs.Tests/VideoOut/HostVideoOptionsTests.cs index 40755b3..fd583fd 100644 --- a/tests/SharpEmu.Libs.Tests/VideoOut/HostVideoOptionsTests.cs +++ b/tests/SharpEmu.Libs.Tests/VideoOut/HostVideoOptionsTests.cs @@ -28,6 +28,30 @@ public sealed class HostVideoOptionsTests Assert.Equal(HostHdrMode.Auto, options.HdrMode); Assert.Equal(GuestDisplayResolution.Hd, options.GuestResolution); } + + [Theory] + [InlineData(0.5f, 0.5f)] + [InlineData(0.75f, 0.75f)] + [InlineData(1.0f, 1.0f)] + [InlineData(2.0f, 2.0f)] + [InlineData(0.0f, HostVideoOptions.MinimumRenderScale)] + [InlineData(-3.0f, HostVideoOptions.MinimumRenderScale)] + [InlineData(9.0f, HostVideoOptions.MaximumRenderScale)] + [InlineData(float.NaN, 1.0f)] + [InlineData(float.PositiveInfinity, 1.0f)] + [InlineData(0.333f, 0.33f)] + public void NormalizeClampsRenderScale(float requested, float expected) + { + Assert.Equal(expected, new HostVideoOptions { RenderScale = requested }.Normalize().RenderScale); + Assert.Equal(expected, HostVideoOptions.NormalizeRenderScale(requested)); + } + + [Fact] + public void RenderScaleDefaultsToNative() + { + Assert.Equal(1.0f, HostVideoOptions.Default.RenderScale); + Assert.Equal(1.0f, HostVideoOptions.Default.Normalize().RenderScale); + } [Theory] [InlineData(1920, 1080, GuestDisplayResolution.UltraHd)] [InlineData(3840, 2160, GuestDisplayResolution.Hd)] diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Gen5RenderScaleTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Gen5RenderScaleTests.cs new file mode 100644 index 0000000..75c9156 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Tests/Gen5RenderScaleTests.cs @@ -0,0 +1,255 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using System.Diagnostics; +using SharpEmu.ShaderCompiler.Resources; +using SharpEmu.ShaderCompiler.Tests.Resources; +using SharpEmu.ShaderCompiler.Vulkan; +using Xunit; + +namespace SharpEmu.ShaderCompiler.Tests; + +// Internal resolution scaling: the module has to translate the pixel position, integer +// image coordinates and size queries between guest pixels and host texels. +public sealed class Gen5RenderScaleTests +{ + private const uint PositionXBit = 1u << 8; + private const uint PositionYBit = 1u << 9; + private const uint PositionZBit = 1u << 10; + + [Fact] + public void RenderScaleDwordsFollowTheDispatchLimits() + { + var info = new ShaderResourceInfo(); + var plain = BindingLayout.Allocate(info, [0u], false, false, false); + var scaled = BindingLayout.Allocate(info, [0u], false, false, false, 0, false, usesRenderScale: true); + var both = BindingLayout.Allocate(info, [0u], false, false, false, 0, true, usesRenderScale: true); + + Assert.False(plain.UsesRenderScale); + Assert.True(scaled.UsesRenderScale); + Assert.Equal(plain.ShaderDataDwordCount + BindingLayout.RenderScaleDwordCount, scaled.ShaderDataDwordCount); + Assert.Equal(plain.ShaderDataDwordCount, scaled.RenderScaleDword); + Assert.Equal(plain.ShaderDataDwordCount + 3, both.RenderScaleDword); + Assert.NotEqual(plain, scaled); + + // The validator recomputes the layout from the flag, so declarations never drift. + BindingLayoutValidator.Validate(plain, info, [0u], false, false, false, 0, ShaderStage.Pixel); + BindingLayoutValidator.Validate(scaled, info, [0u], false, false, false, 0, ShaderStage.Pixel); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void PixelPositionIsDividedByTheAttachmentFactor(bool usesRenderScale) + { + var spirv = CompilePixelPosition(usesRenderScale); + var instructions = Parse(spirv); + var fragCoord = Load(instructions, FragCoordVariable(instructions)); + + // Only the two scaled axes move; depth and 1/w keep their values. + Assert.Equal(usesRenderScale, FeedsOperation(instructions, fragCoord, 0, SpirvOp.FMul)); + Assert.Equal(usesRenderScale, FeedsOperation(instructions, fragCoord, 1, SpirvOp.FMul)); + Assert.False(FeedsOperation(instructions, fragCoord, 2, SpirvOp.FMul)); + ValidateWhenAvailable(spirv); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void IntegerImageCoordinatesAreMappedOntoHostTexels(bool usesRenderScale) + { + var spirv = CompileImageAccess("ImageLoad", usesRenderScale); + var instructions = Parse(spirv); + var fetch = Assert.Single(instructions, item => item.Opcode == SpirvOp.ImageFetch); + var coordinates = Definition(instructions, fetch.Operands[3]); + + // Two coordinates, each either the raw register or that register mapped through + // the image's factor; a selected factor of one leaves an unscaled image alone. + Assert.Equal(SpirvOp.CompositeConstruct, coordinates.Opcode); + foreach (var component in coordinates.Operands.Skip(2)) + { + Assert.Equal(usesRenderScale ? SpirvOp.ConvertFToS : SpirvOp.Bitcast, Definition(instructions, component).Opcode); + } + + if (usesRenderScale) + { + // The factor is selected per image, so an unscaled binding costs only the select. + Assert.Contains(instructions, item => item.Opcode == SpirvOp.Select); + Assert.Contains(instructions, item => item.Opcode == SpirvOp.ConvertSToF); + } + else + { + Assert.DoesNotContain(instructions, item => item.Opcode == SpirvOp.ConvertSToF); + } + ValidateWhenAvailable(spirv); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void SizeQueriesReportGuestSizes(bool usesRenderScale) + { + var spirv = CompileImageAccess("ImageGetResinfo", usesRenderScale); + var instructions = Parse(spirv); + var query = Assert.Single(instructions, item => item.Opcode == SpirvOp.ImageQuerySizeLod); + var extracts = instructions + .Where(item => item.Opcode == SpirvOp.CompositeExtract && item.Operands[2] == query.Operands[1]) + .ToArray(); + + Assert.NotEmpty(extracts); + foreach (var extract in extracts) + { + Assert.Equal(usesRenderScale, FeedsOpcode(instructions, extract.Operands[1], SpirvOp.ConvertSToF)); + } + + ValidateWhenAvailable(spirv); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void DynamicSampleOffsetsRemainInGuestTexels(bool usesRenderScale) + { + var spirv = CompileImageAccess("ImageSampleLzO", usesRenderScale); + var instructions = Parse(spirv); + var divide = Assert.Single(instructions, item => item.Opcode == SpirvOp.FDiv); + var offset = Definition(instructions, divide.Operands[2]); + Assert.Equal(usesRenderScale ? SpirvOp.CompositeConstruct : SpirvOp.ConvertSToF, offset.Opcode); + if (usesRenderScale) + { + foreach (var component in offset.Operands.Skip(2)) + { + var multiply = Definition(instructions, component); + Assert.Equal(SpirvOp.FMul, multiply.Opcode); + // Each image selects its factor; unscaled bindings select one. + Assert.Equal(SpirvOp.Select, Definition(instructions, multiply.Operands[3]).Opcode); + } + } + ValidateWhenAvailable(spirv); + } + + private static byte[] CompilePixelPosition(bool usesRenderScale) + { + var program = ResourceTestProgram.Program( + ResourceTestProgram.MoveVector(0, 0, 0), + ResourceTestProgram.EndProgram(4)); + var request = BuildRequest(program, ShaderStage.Pixel, usesRenderScale, + PositionXBit | PositionYBit | PositionZBit); + Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); + return shader.Spirv; + } + + private static byte[] CompileImageAccess(string opcode, bool usesRenderScale) + { + var program = ResourceTestProgram.Program( + ResourceTestProgram.Image(0, opcode, resourceRegister: 8, dimension: 1, dmask: 0x3), + ResourceTestProgram.EndProgram(8)); + var request = BuildRequest(program, ShaderStage.Compute, usesRenderScale); + Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); + return shader.Spirv; + } + + private static ShaderCompileRequest BuildRequest(Gen5ShaderProgram program, ShaderStage stage, bool usesRenderScale, + uint pixelInputs = 0) + { + var plan = ShaderResourcePlan.Extract(program, stage, ResourceTestProgram.Hash, 0, 64); + var resources = ResourceMaterializer.ApplyTo(plan, ResourceSpecialization.Default(plan.Info)); + var layout = BindingLayout.Allocate( + resources.Info, + BindingLayout.CollectUserDataRegisters(program, 0, 64), + BindingLayout.UsesGlobalDataShare(program), + ShaderCompileRequest.RequiresFlattenedTable(plan, resources), + BindingLayout.ReadsShaderBase(program), + 0, + false, + usesRenderScale); + return new ShaderCompileRequest(plan, resources, layout) + { + PixelOutputs = stage == ShaderStage.Pixel ? [new Gen5PixelOutputBinding(0, 0, Gen5PixelOutputKind.Float)] : [], + PixelInputAddress = pixelInputs, + PixelInputEnable = pixelInputs, + }; + } + + private static uint FragCoordVariable(IReadOnlyList instructions) => Assert.Single(instructions, + item => item.Opcode == SpirvOp.Decorate && item.Operands.Length >= 3 && + item.Operands[1] == (uint)SpirvDecoration.BuiltIn && item.Operands[2] == (uint)SpirvBuiltIn.FragCoord).Operands[0]; + + private static uint Load(IReadOnlyList instructions, uint variable) => + Assert.Single(instructions, item => item.Opcode == SpirvOp.Load && item.Operands[2] == variable).Operands[1]; + + // True when the given component of the composite is extracted and consumed by the opcode. + private static bool FeedsOperation(IReadOnlyList instructions, uint composite, uint component, SpirvOp opcode) => + instructions.Any(item => item.Opcode == SpirvOp.CompositeExtract && item.Operands[2] == composite && + item.Operands.Length >= 4 && item.Operands[3] == component && + FeedsOpcode(instructions, item.Operands[1], opcode)); + + private static bool FeedsOpcode(IReadOnlyList instructions, uint value, SpirvOp opcode) => + instructions.Any(item => item.Opcode == opcode && item.Operands.Skip(2).Contains(value)); + + // The producer of a value, among the opcodes these tests expect to see one of. + private static Parsed Definition(IReadOnlyList instructions, uint id) => + Assert.Single(instructions, item => ValueOpcodes.Contains(item.Opcode) && item.Operands.Length >= 2 && item.Operands[1] == id); + + private static readonly SpirvOp[] ValueOpcodes = + [ + SpirvOp.CompositeConstruct, SpirvOp.CompositeExtract, SpirvOp.Bitcast, SpirvOp.Load, + SpirvOp.ConvertFToS, SpirvOp.ConvertSToF, SpirvOp.FMul, SpirvOp.FAdd, SpirvOp.Select, + ]; + + private readonly record struct Parsed(SpirvOp Opcode, uint[] Operands); + + private static IReadOnlyList Parse(byte[] spirv) + { + var instructions = new List(); + for (var offset = 5 * sizeof(uint); offset < spirv.Length;) + { + var header = BinaryPrimitives.ReadUInt32LittleEndian(spirv.AsSpan(offset)); + var wordCount = checked((int)(header >> 16)); + Assert.InRange(wordCount, 1, (spirv.Length - offset) / sizeof(uint)); + var operands = new uint[wordCount - 1]; + for (var operand = 0; operand < operands.Length; operand++) + { + operands[operand] = BinaryPrimitives.ReadUInt32LittleEndian(spirv.AsSpan(offset + (operand + 1) * sizeof(uint))); + } + + instructions.Add(new Parsed((SpirvOp)(ushort)header, operands)); + offset += wordCount * sizeof(uint); + } + + return instructions; + } + + private static void ValidateWhenAvailable(byte[] spirv) + { + var sdk = Environment.GetEnvironmentVariable("VULKAN_SDK"); + if (string.IsNullOrEmpty(sdk)) + { + return; + } + + var validator = Path.Combine(sdk, "bin", "spirv-val"); + if (!File.Exists(validator)) + { + return; + } + + var path = Path.GetTempFileName(); + try + { + File.WriteAllBytes(path, spirv); + using var process = Process.Start(new ProcessStartInfo(validator, ["--target-env", "vulkan1.2", path]) + { + RedirectStandardError = true, + })!; + var errors = process.StandardError.ReadToEnd(); + process.WaitForExit(); + Assert.True(process.ExitCode == 0, errors); + } + finally + { + File.Delete(path); + } + } +}