[GPU] Add internal render resolution scaling (#1038)
* [VideoOut] Add an internal render resolution scale option Adds the host-side setting an internal-resolution implementation needs: --render-scale on the command line, SHARPEMU_RENDER_SCALE for scripted runs, and a global and per-game choice in the GUI. The value is a host resolution multiplier of the guest render targets, normalized to a hundredth in [0.25, 2] so the command line, the GUI and the environment agree bit for bit; the renderer compares scales when it decides whether two views of the same guest memory match. RenderScalePolicy holds the decision rule. It is a pure function of the guest image description, so every view, alias, copy and descriptor of one guest range reaches the same answer without consulting the cache. Only screen-class, single-level, single-sample, uncompressed 2D render and depth targets scale; block grids, multisample footprints, HTile and DCC coverage, mip chains, cube and array faces, asset tilings and small surfaces (shadow maps, lookup tables, eye-adaptation readbacks) keep their guest resolution. The environment variable wins over the stored setting so test runs do not depend on the user's configuration. No renderer consumes the scale yet; a scale of one is the current behaviour everywhere. * [GPU] Hold scalable guest images at a host render resolution The backing of an image the scaling rules accept is created at ceil(guest * scale) while its ImageDescription keeps the guest geometry, so the cache, the overlap rules and the page tracker go on reasoning in guest bytes. Everything that moves guest bytes now goes through a guest-resolution twin of the scaled image: an upload fills the twin 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 whose two images disagree on host resolution runs at guest resolution the same way, so callers keep describing rectangles in guest pixels; the destination seeds its twin first because a partial copy is blitted back whole. The multisample depth conversion takes a guest-sized source for the same reason, and the resolve region crosses into the destination's resolution instead of silently resolving the top-left corner. Two fallbacks keep correctness ahead of the saving. A format that cannot blit keeps its guest resolution, since the twin could never reach it. A storage image drops back to guest resolution the first time a compute shader binds it: the dispatch grid does not shrink with the host image, so the shader would write it with holes above 1.0 and redundantly below. The guest range is remembered, so every later image over that memory is created unscaled too. * [GPU] Scale the render area, viewport and scissor with the attachments A resolved target now reports the host extent of its attachment beside the guest one, and the three places that size rasterization follow it: the dynamic rendering area, which also bounds every load-op clear; the guest viewport transform; and the guest scissor, whose edges round outward so no guest pixel the game asked for loses host coverage. All attachments of a pass share one rule, so the first bound target decides the factor. The clip-disable path needs care because the vertex program bakes the viewport transform against a reference extent. Dividing that reference by an upscale keeps the host viewport (reference x scale) inside the device limit while the baked transform stays scale independent, so the same program maps guest pixels onto host positions at either resolution. At a scale of one the reference is the device limit, exactly as before. * [SpirvTranslator] Translate pixel positions and image coordinates across the render scale A program expresses its pixel position, its integer image coordinates and the sizes it queries in guest pixels, but the host may hold the image behind any of them at another size. Which images are scaled is a per-draw fact, so the factors travel in shader data rather than in a permutation: the coordinate factor, its reciprocal, a 64-bit mask of scaled image resources and the factor the pixel position is divided by. With that: - gl_FragCoord is divided by the attachments' factor as it is loaded, so POS_X/POS_Y, POS_FIXED_PT and everything derived from them - screen UVs above all - stay in guest pixels. - An integer image coordinate is mapped onto host texels through the bound image's factor. Only the two scaled axes move; an array layer or a volume slice is not a pixel. - A resource-info query reports the guest size, because the program derives coordinates and loop bounds from it. A select against the mask makes an unscaled binding cost one instruction, and the layout only carries the dwords when the scale is not one, so nothing changes at native resolution. * [GPU] Keep one host resolution across the attachments of a pass A 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 - a depth target with a stencil plane next to a scalable colour target, say - and the pass then renders a fraction of its area. The draw now drops every scaled attachment back to guest resolution and resolves its targets again; the guest ranges are remembered, so the decision converges after one draw and cannot oscillate. That fallback only works if the executor knows what the cache actually gave it, which is not what the scaling rules would answer: a demoted range still looks scalable to them. The resolved targets therefore carry the image's own factor, and the render area, viewport, scissor and the depth/stencil copy extent read it from there. Two rules change with it. DCC only describes guest bytes the host never stores, so a colour target that carries it can still be scaled; HTile encodes guest depth blocks the renderer reads back as clear state and stays native. And a storage image only drops back when the program actually writes it, since a read-only load is mapped correctly. Scaling now also requires a backend that installed the format predicate - the Vulkan device - so the Metal host is unaffected. * [Docs] Describe internal resolution scaling and its limits Replaces the note saying arbitrary internal resolution scaling is not implemented: it is, for screen-class render and depth targets. Records what the renderer translates (rasterization, shader coordinates, guest transfers, presentation), the exact rule for what scales, every case that falls back to guest resolution, the artefacts measured on Silent Hill, and the per-scope GPU times at 50, 100 and 200 percent. * [SpirvTranslator] Preserve guest texel offsets at scaled resolutions
This commit is contained in:
parent
8acc081205
commit
38f7196470
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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=<native>] [--console=<ps5>] [--console-language=<language>] [--log-level=<level>] [--log-file[=<path>]] [--window-mode=<windowed|borderless|exclusive>] [--resolution=<WIDTHxHEIGHT>] [--guest-resolution=<1920x1080|3840x2160>] [--display=<N>] [--refresh-rate=<HZ>] [--scaling=<fit|cover|stretch|integer>] [--vsync=<on|off>] [--hdr=<auto|on|off>] [--overlay=<on|off>] [--overlay-mode=<full|minimal|titlebar>] [--overlay-corner=<topleft|topright|bottomright|bottomleft>] [--debug-server[=host:port]] <path-to-eboot.bin>");
|
||||
Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=<native>] [--console=<ps5>] [--console-language=<language>] [--log-level=<level>] [--log-file[=<path>]] [--window-mode=<windowed|borderless|exclusive>] [--resolution=<WIDTHxHEIGHT>] [--guest-resolution=<1920x1080|3840x2160>] [--render-scale=<0.5|0.75|1|1.5|2>] [--display=<N>] [--refresh-rate=<HZ>] [--scaling=<fit|cover|stretch|integer>] [--vsync=<on|off>] [--hdr=<auto|on|off>] [--overlay=<on|off>] [--overlay-mode=<full|minimal|titlebar>] [--overlay-corner=<topleft|topright|bottomright|bottomleft>] [--debug-server[=host:port]] <path-to-eboot.bin>");
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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": "معدل التحديث",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "リフレッシュレート",
|
||||
|
|
|
|||
|
|
@ -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": "새로 고침 빈도",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Частота обновления",
|
||||
|
|
|
|||
|
|
@ -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ı",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -864,6 +864,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||
Classes="optionValue"
|
||||
VerticalAlignment="Center" />
|
||||
</local:SettingRow>
|
||||
<local:SettingRow x:Name="GameRenderScaleRow"
|
||||
Classes="optionRow"
|
||||
Label="{Binding [Options.RenderScale.Label], Source={x:Static local:Localization.Instance}}"
|
||||
Description="{Binding [Options.RenderScale.Desc], Source={x:Static local:Localization.Instance}}">
|
||||
<ComboBox x:Name="GameRenderScaleBox"
|
||||
Classes="optionValue"
|
||||
VerticalAlignment="Center" />
|
||||
</local:SettingRow>
|
||||
<local:SettingRow x:Name="GameDisplayRow"
|
||||
Classes="optionRow"
|
||||
Label="{Binding [Options.Display.Label],
|
||||
|
|
@ -1504,6 +1512,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||
Description="{Binding [Options.GuestResolution.Desc], Source={x:Static local:Localization.Instance}}">
|
||||
<ComboBox x:Name="GuestResolutionBox" Classes="optionValue" VerticalAlignment="Center" />
|
||||
</local:SettingRow>
|
||||
<local:SettingRow x:Name="RenderScaleRow"
|
||||
Classes="optionRow"
|
||||
Label="{Binding [Options.RenderScale.Label], Source={x:Static local:Localization.Instance}}"
|
||||
Description="{Binding [Options.RenderScale.Desc], Source={x:Static local:Localization.Instance}}">
|
||||
<ComboBox x:Name="RenderScaleBox" Classes="optionValue" VerticalAlignment="Center" />
|
||||
</local:SettingRow>
|
||||
<local:SettingRow x:Name="DisplayRow"
|
||||
Classes="optionRow"
|
||||
Label="{Binding [Options.Display.Label], Source={x:Static local:Localization.Instance}}"
|
||||
|
|
|
|||
|
|
@ -323,6 +323,8 @@ public partial class MainWindow : Window
|
|||
DisplayBox.SelectionChanged += (_, _) => 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()}");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<CachedImage, CachedImage> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -296,6 +296,16 @@ public sealed unsafe partial class CachedImage
|
|||
|
||||
public void UploadFromBuffer(ReadOnlySpan<BufferImageCopy> 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<BufferImageCopy> 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(
|
||||
|
|
|
|||
|
|
@ -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<CachedImageView> 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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<GuestSpan> _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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Format, bool>? _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<Format, bool> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<uint> 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<uint> 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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -337,12 +337,21 @@ public sealed class BindingLayout : IEquatable<BindingLayout>
|
|||
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<uint> UserDataRegisters { get; init; } = [];
|
||||
public IReadOnlyList<DescriptorBinding> 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<BindingLayout>
|
|||
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<DescriptorBinding>();
|
||||
if (info.Buffers.Count != 0)
|
||||
|
|
@ -644,6 +655,7 @@ public sealed class BindingLayout : IEquatable<BindingLayout>
|
|||
MemoryOffsetDword = memoryOffsetDword,
|
||||
MemoryOffsetCount = memoryOffsetCount,
|
||||
UsesDispatchThreadLimits = usesDispatchThreadLimits,
|
||||
UsesRenderScale = usesRenderScale,
|
||||
UserDataRegisters = userDataRegisters,
|
||||
Descriptors = descriptors,
|
||||
};
|
||||
|
|
@ -657,13 +669,14 @@ public sealed class BindingLayout : IEquatable<BindingLayout>
|
|||
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(
|
||||
|
|
|
|||
|
|
@ -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("")]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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<Parsed> 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<Parsed> 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<Parsed> 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<Parsed> 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<Parsed> 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<Parsed> Parse(byte[] spirv)
|
||||
{
|
||||
var instructions = new List<Parsed>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue