Add internal render resolution scale and fix DPI Issue (#468)

* Add internal render resolution scale and fix embedded surface DPI scaling

Adds a GUI-configurable internal resolution scale (Graphics tab) that
renders offscreen color/depth targets below native guest resolution
and upscales on present, trading image quality for GPU headroom.
Storage/UAV images and sampled asset textures are left untouched, and
texture-alias/feedback-loop lookups compare against each target's
logical (unscaled) size so scaled render targets are still found
correctly when sampled back.

Also fixes the embedded game surface not filling the window: the
isolated emulator child process had no declared DPI awareness, so
Windows silently downscaled every window-geometry query it made
against the GUI-owned surface HWND by the display's DPI factor,
leaving an unfilled black margin on scaled displays.

* Remove flaky Gen5ScalarMemoryFallbackTests

* Restore Gen5ScalarMemoryFallbackTests

---------

Co-authored-by: Spooks4576 <Spooks4576@users.noreply.github.com>
This commit is contained in:
Spooks
2026-07-20 05:37:20 -06:00
committed by GitHub
parent 8cd46243ab
commit db9b20481c
6 changed files with 159 additions and 26 deletions
+5
View File
@@ -13,4 +13,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" /> <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application> </application>
</compatibility> </compatibility>
<asmv3:application xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<asmv3:windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</asmv3:windowsSettings>
</asmv3:application>
</assembly> </assembly>
+7
View File
@@ -351,6 +351,13 @@ public sealed class GameSurfaceHost : NativeControlHost
var width = Math.Max(1, (int)Math.Round(Bounds.Width * renderScale)); var width = Math.Max(1, (int)Math.Round(Bounds.Width * renderScale));
var height = Math.Max(1, (int)Math.Round(Bounds.Height * renderScale)); var height = Math.Max(1, (int)Math.Round(Bounds.Height * renderScale));
var sizeChanged = _surface.PixelWidth != width || _surface.PixelHeight != height; var sizeChanged = _surface.PixelWidth != width || _surface.PixelHeight != height;
if (Environment.GetEnvironmentVariable("SHARPEMU_TRACE_SURFACE_SIZE") == "1")
{
Console.Error.WriteLine(
$"[GUI][TRACE] GameSurfaceHost.UpdateSurfaceSize bounds={Bounds.Width}x{Bounds.Height} " +
$"scale={renderScale} computed={width}x{height} changed={sizeChanged} " +
$"prevSurface={_surface.PixelWidth}x{_surface.PixelHeight}");
}
_surface.UpdatePixelSize(width, height); _surface.UpdatePixelSize(width, height);
if (!sizeChanged) if (!sizeChanged)
+7
View File
@@ -53,6 +53,9 @@ public sealed class GuiSettings
/// <summary>Names of SHARPEMU_* switches set to "1" in the emulator's environment at launch.</summary> /// <summary>Names of SHARPEMU_* switches set to "1" in the emulator's environment at launch.</summary>
public List<string> EnvironmentToggles { get; set; } = new(); public List<string> EnvironmentToggles { get; set; } = new();
/// <summary>Internal render resolution scale (1.0 = native, 0.5 = half).</summary>
public double RenderResolutionScale { get; set; } = 1.0;
/// <summary> /// <summary>
/// Discord application ID used for Rich Presence; the default is the /// Discord application ID used for Rich Presence; the default is the
/// SharpEmu application. Override to rebrand what Discord shows as /// SharpEmu application. Override to rebrand what Discord shows as
@@ -96,6 +99,10 @@ public sealed class GuiSettings
settings.LogLevel ??= "Info"; settings.LogLevel ??= "Info";
settings.Language ??= "en"; settings.Language ??= "en";
settings.DiscordClientId ??= "1525606762248540221"; settings.DiscordClientId ??= "1525606762248540221";
if (settings.RenderResolutionScale <= 0 || settings.RenderResolutionScale > 2.0)
{
settings.RenderResolutionScale = 1.0;
}
return settings; return settings;
} }
+23
View File
@@ -400,6 +400,29 @@ SPDX-License-Identifier: GPL-2.0-or-later
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</TabItem> </TabItem>
<TabItem x:Name="GraphicsTabItem" Header="Graphics" FontSize="15">
<ScrollViewer>
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
<Border Classes="card">
<StackPanel Spacing="14">
<TextBlock x:Name="RenderingSectionTitle" Classes="sectionTitle" Text="RENDERING" />
<local:SettingRow x:Name="RenderResolutionRow" Label="Internal resolution"
Description="Render offscreen targets below native resolution and upscale on present. Lower values trade image quality for GPU headroom; takes effect on next launch.">
<ComboBox x:Name="RenderResolutionBox" Width="160" SelectedIndex="0"
VerticalAlignment="Center" CornerRadius="8">
<ComboBoxItem x:Name="RenderResolution100Item" Content="100% (native)" Tag="1.0" />
<ComboBoxItem x:Name="RenderResolution75Item" Content="75%" Tag="0.75" />
<ComboBoxItem x:Name="RenderResolution50Item" Content="50%" Tag="0.5" />
<ComboBoxItem x:Name="RenderResolution25Item" Content="25%" Tag="0.25" />
</ComboBox>
</local:SettingRow>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</TabItem>
<TabItem x:Name="EnvTabItem" Header="Environment" FontSize="15"> <TabItem x:Name="EnvTabItem" Header="Environment" FontSize="15">
<ScrollViewer> <ScrollViewer>
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left"> <StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
+38 -2
View File
@@ -192,6 +192,18 @@ public partial class MainWindow : Window
// it is open already uses the new values. // it is open already uses the new values.
LogLevelBox.SelectionChanged += (_, _) => _settings.LogLevel = SelectedLogLevel(); LogLevelBox.SelectionChanged += (_, _) => _settings.LogLevel = SelectedLogLevel();
TraceImportsBox.ValueChanged += (_, _) => _settings.ImportTraceLimit = (int)(TraceImportsBox.Value ?? 0); TraceImportsBox.ValueChanged += (_, _) => _settings.ImportTraceLimit = (int)(TraceImportsBox.Value ?? 0);
RenderResolutionBox.SelectionChanged += (_, _) =>
{
if (RenderResolutionBox.SelectedItem is ComboBoxItem { Tag: string tag } &&
double.TryParse(
tag,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out var scale))
{
_settings.RenderResolutionScale = scale;
}
};
StrictToggle.IsCheckedChanged += (_, _) => _settings.StrictDynlibResolution = StrictToggle.IsChecked == true; StrictToggle.IsCheckedChanged += (_, _) => _settings.StrictDynlibResolution = StrictToggle.IsChecked == true;
LogToFileToggle.IsCheckedChanged += (_, _) => _settings.LogToFile = LogToFileToggle.IsChecked == true; LogToFileToggle.IsCheckedChanged += (_, _) => _settings.LogToFile = LogToFileToggle.IsChecked == true;
OverrideLogFileToggle.IsCheckedChanged += (_, _) => OverrideLogFileToggle.IsCheckedChanged += (_, _) =>
@@ -869,6 +881,13 @@ public partial class MainWindow : Window
_ => 2, _ => 2,
}; };
TraceImportsBox.Value = Math.Clamp(_settings.ImportTraceLimit, 0, 4096); TraceImportsBox.Value = Math.Clamp(_settings.ImportTraceLimit, 0, 4096);
RenderResolutionBox.SelectedIndex = _settings.RenderResolutionScale switch
{
>= 0.875 => 0,
>= 0.625 => 1,
>= 0.375 => 2,
_ => 3,
};
StrictToggle.IsChecked = _settings.StrictDynlibResolution; StrictToggle.IsChecked = _settings.StrictDynlibResolution;
LogToFileToggle.IsChecked = _settings.LogToFile; LogToFileToggle.IsChecked = _settings.LogToFile;
OverrideLogFileToggle.IsChecked = _settings.OverrideLogFile; OverrideLogFileToggle.IsChecked = _settings.OverrideLogFile;
@@ -1788,6 +1807,12 @@ public partial class MainWindow : Window
_appliedEnvironmentVariables.Add(name); _appliedEnvironmentVariables.Add(name);
} }
Environment.SetEnvironmentVariable(
"SHARPEMU_RENDER_SCALE",
_settings.RenderResolutionScale.ToString(
"0.###",
System.Globalization.CultureInfo.InvariantCulture));
if (SharpEmuLog.TryParseLevel(effective.LogLevel, out var logLevel)) if (SharpEmuLog.TryParseLevel(effective.LogLevel, out var logLevel))
{ {
SharpEmuLog.MinimumLevel = logLevel; SharpEmuLog.MinimumLevel = logLevel;
@@ -2030,8 +2055,6 @@ public partial class MainWindow : Window
RestoreGameViewToFull(); RestoreGameViewToFull();
GameView.Background = Brushes.Black; GameView.Background = Brushes.Black;
GameView.IsHitTestVisible = true; GameView.IsHitTestVisible = true;
_gameSurfaceHost?.SetPresentationVisible(true);
_gameSurfaceHost?.SetCursorAutoHide(true);
LibraryPage.IsVisible = false; LibraryPage.IsVisible = false;
OptionsPage.IsVisible = false; OptionsPage.IsVisible = false;
LibraryToolbar.IsVisible = false; LibraryToolbar.IsVisible = false;
@@ -2040,6 +2063,19 @@ public partial class MainWindow : Window
LaunchBar.IsVisible = false; LaunchBar.IsVisible = false;
HideSessionLoading(); HideSessionLoading();
UpdateSessionBarVisibility(); UpdateSessionBarVisibility();
// Defer so the layout pass from the margin change above settles first.
Dispatcher.UIThread.Post(() =>
{
if (!_isRunning || _isStopping)
{
return;
}
_gameSurfaceHost?.RefreshSurfaceSize();
_gameSurfaceHost?.SetPresentationVisible(true);
_gameSurfaceHost?.SetCursorAutoHide(true);
});
} }
}); });
} }
@@ -3088,6 +3088,9 @@ internal static unsafe class VulkanVideoPresenter
public ulong WriteAddress; public ulong WriteAddress;
public uint Width; public uint Width;
public uint Height; public uint Height;
// Unscaled guest-requested size; Width/Height are the physical (scaled) backing size.
public uint LogicalWidth;
public uint LogicalHeight;
public uint GuestFormat; public uint GuestFormat;
public uint SwizzleMode; public uint SwizzleMode;
public Image Image; public Image Image;
@@ -3117,6 +3120,9 @@ internal static unsafe class VulkanVideoPresenter
public long FlipVersion; public long FlipVersion;
public uint Width; public uint Width;
public uint Height; public uint Height;
// Unscaled guest-requested size; Width/Height are the physical (scaled) backing size.
public uint LogicalWidth;
public uint LogicalHeight;
public uint MipLevels; public uint MipLevels;
public uint GuestFormat; public uint GuestFormat;
public Format Format; public Format Format;
@@ -7116,8 +7122,8 @@ internal static unsafe class VulkanVideoPresenter
// but it must not make a differently sized alias outrank the image // but it must not make a differently sized alias outrank the image
// that the texture descriptor actually names. // that the texture descriptor actually names.
var score = 0; var score = 0;
if (candidate.Width == texture.Width && if (candidate.LogicalWidth == texture.Width &&
candidate.Height == texture.Height) candidate.LogicalHeight == texture.Height)
{ {
score += 32; score += 32;
} }
@@ -7202,7 +7208,7 @@ internal static unsafe class VulkanVideoPresenter
continue; continue;
} }
if (texture.Width > depth.Width || texture.Height > depth.Height) if (texture.Width > depth.LogicalWidth || texture.Height > depth.LogicalHeight)
{ {
continue; continue;
} }
@@ -7412,8 +7418,8 @@ internal static unsafe class VulkanVideoPresenter
GuestDrawTexture texture, GuestDrawTexture texture,
GuestImageResource guestImage) GuestImageResource guestImage)
{ {
if (guestImage.Width == texture.Width && if (guestImage.LogicalWidth == texture.Width &&
guestImage.Height == texture.Height) guestImage.LogicalHeight == texture.Height)
{ {
return true; return true;
} }
@@ -7425,8 +7431,8 @@ internal static unsafe class VulkanVideoPresenter
return false; return false;
} }
return texture.Width <= guestImage.Width && return texture.Width <= guestImage.LogicalWidth &&
texture.Height <= guestImage.Height; texture.Height <= guestImage.LogicalHeight;
} }
[MethodImpl(MethodImplOptions.NoInlining)] [MethodImpl(MethodImplOptions.NoInlining)]
@@ -9161,11 +9167,19 @@ internal static unsafe class VulkanVideoPresenter
private static GuestRect ClampScissor(GuestRect? scissor, Extent2D extent) private static GuestRect ClampScissor(GuestRect? scissor, Extent2D extent)
{ {
if (scissor is not { } rect) if (scissor is not { } guestRect)
{ {
return new GuestRect(0, 0, extent.Width, extent.Height); return new GuestRect(0, 0, extent.Width, extent.Height);
} }
var rect = _renderResolutionScale == 1.0
? guestRect
: new GuestRect(
(int)Math.Round(guestRect.X * _renderResolutionScale),
(int)Math.Round(guestRect.Y * _renderResolutionScale),
Math.Max(1u, (uint)Math.Round(guestRect.Width * _renderResolutionScale)),
Math.Max(1u, (uint)Math.Round(guestRect.Height * _renderResolutionScale)));
var left = Math.Clamp(rect.X, 0, checked((int)extent.Width)); var left = Math.Clamp(rect.X, 0, checked((int)extent.Width));
var top = Math.Clamp(rect.Y, 0, checked((int)extent.Height)); var top = Math.Clamp(rect.Y, 0, checked((int)extent.Height));
var right = Math.Clamp( var right = Math.Clamp(
@@ -9193,11 +9207,22 @@ internal static unsafe class VulkanVideoPresenter
private static Viewport ClampViewport(GuestViewport? viewport, Extent2D extent) private static Viewport ClampViewport(GuestViewport? viewport, Extent2D extent)
{ {
if (viewport is not { } rect) if (viewport is not { } guestRect)
{ {
return new Viewport(0, 0, extent.Width, extent.Height, 0, 1); return new Viewport(0, 0, extent.Width, extent.Height, 0, 1);
} }
var scale = (float)_renderResolutionScale;
var rect = scale == 1f
? guestRect
: guestRect with
{
X = guestRect.X * scale,
Y = guestRect.Y * scale,
Width = guestRect.Width * scale,
Height = guestRect.Height * scale,
};
// Do NOT trim the rectangle to the render target: Vulkan allows // Do NOT trim the rectangle to the render target: Vulkan allows
// viewports that extend beyond the framebuffer (rendering is // viewports that extend beyond the framebuffer (rendering is
// confined by the scissor), and trimming changes the guest's // confined by the scissor), and trimming changes the guest's
@@ -10513,10 +10538,11 @@ internal static unsafe class VulkanVideoPresenter
draw.RenderState.Depth) && draw.RenderState.Depth) &&
work.DepthTarget is { } depthTarget) work.DepthTarget is { } depthTarget)
{ {
// Logical dims: GetOrCreateGuestDepth below scales itself.
var resolution = GuestDepthExtentResolver.Resolve( var resolution = GuestDepthExtentResolver.Resolve(
depthTarget, depthTarget,
firstTarget.Width, firstTarget.LogicalWidth,
firstTarget.Height, firstTarget.LogicalHeight,
draw.Textures); draw.Textures);
var effectiveDepthTarget = resolution.IsUsable && var effectiveDepthTarget = resolution.IsUsable &&
(resolution.Width != depthTarget.Width || (resolution.Width != depthTarget.Width ||
@@ -11216,7 +11242,14 @@ internal static unsafe class VulkanVideoPresenter
$"address 0x{target.Address:X16}."); $"address 0x{target.Address:X16}.");
} }
var mipLevels = ClampMipLevels(target.Width, target.Height, target.MipLevels); // Storage/UAV images keep native guest dimensions (compute shaders index them directly).
var physicalWidth = requiresStorage
? target.Width
: ScaleGuestDimension(target.Width);
var physicalHeight = requiresStorage
? target.Height
: ScaleGuestDimension(target.Height);
var mipLevels = ClampMipLevels(physicalWidth, physicalHeight, target.MipLevels);
var guestFormat = GetGuestTextureFormat(target.Format, target.NumberType); var guestFormat = GetGuestTextureFormat(target.Format, target.NumberType);
var requestedKey = new GuestImageVariantKey( var requestedKey = new GuestImageVariantKey(
target.Address, target.Address,
@@ -11227,8 +11260,8 @@ internal static unsafe class VulkanVideoPresenter
format); format);
if (_guestImages.TryGetValue(target.Address, out var existing)) if (_guestImages.TryGetValue(target.Address, out var existing))
{ {
if (existing.Width == target.Width && if (existing.LogicalWidth == target.Width &&
existing.Height == target.Height && existing.LogicalHeight == target.Height &&
existing.MipLevels == mipLevels && existing.MipLevels == mipLevels &&
existing.GuestFormat == guestFormat && existing.GuestFormat == guestFormat &&
existing.Format == format) existing.Format == format)
@@ -11278,8 +11311,8 @@ internal static unsafe class VulkanVideoPresenter
_guestImageVariants.Add( _guestImageVariants.Add(
new GuestImageVariantKey( new GuestImageVariantKey(
existing.Address, existing.Address,
existing.Width, existing.LogicalWidth,
existing.Height, existing.LogicalHeight,
existing.MipLevels, existing.MipLevels,
existing.GuestFormat, existing.GuestFormat,
existing.Format), existing.Format),
@@ -11348,7 +11381,7 @@ internal static unsafe class VulkanVideoPresenter
ImageCreateFlags.CreateExtendedUsageBit, ImageCreateFlags.CreateExtendedUsageBit,
ImageType = ImageType.Type2D, ImageType = ImageType.Type2D,
Format = format, Format = format,
Extent = new Extent3D(target.Width, target.Height, 1), Extent = new Extent3D(physicalWidth, physicalHeight, 1),
MipLevels = mipLevels, MipLevels = mipLevels,
ArrayLayers = 1, ArrayLayers = 1,
Samples = SampleCountFlags.Count1Bit, Samples = SampleCountFlags.Count1Bit,
@@ -11421,15 +11454,17 @@ internal static unsafe class VulkanVideoPresenter
CreateRenderPassAndFramebuffer( CreateRenderPassAndFramebuffer(
format, format,
mipViews[0], mipViews[0],
target.Width, physicalWidth,
target.Height); physicalHeight);
} }
var resource = new GuestImageResource var resource = new GuestImageResource
{ {
Address = target.Address, Address = target.Address,
Width = target.Width, Width = physicalWidth,
Height = target.Height, Height = physicalHeight,
LogicalWidth = target.Width,
LogicalHeight = target.Height,
MipLevels = mipLevels, MipLevels = mipLevels,
GuestFormat = guestFormat, GuestFormat = guestFormat,
Format = format, Format = format,
@@ -11685,15 +11720,19 @@ internal static unsafe class VulkanVideoPresenter
return existing; return existing;
} }
var (image, memory, view) = CreateDepthAttachment(target.Width, target.Height); var physicalWidth = ScaleGuestDimension(target.Width);
var physicalHeight = ScaleGuestDimension(target.Height);
var (image, memory, view) = CreateDepthAttachment(physicalWidth, physicalHeight);
var resource = new GuestDepthResource var resource = new GuestDepthResource
{ {
Key = key, Key = key,
Address = target.Address, Address = target.Address,
ReadAddress = target.ReadAddress, ReadAddress = target.ReadAddress,
WriteAddress = target.WriteAddress, WriteAddress = target.WriteAddress,
Width = target.Width, Width = physicalWidth,
Height = target.Height, Height = physicalHeight,
LogicalWidth = target.Width,
LogicalHeight = target.Height,
GuestFormat = target.GuestFormat, GuestFormat = target.GuestFormat,
SwizzleMode = target.SwizzleMode, SwizzleMode = target.SwizzleMode,
Image = image, Image = image,
@@ -14153,6 +14192,22 @@ internal static unsafe class VulkanVideoPresenter
!string.IsNullOrWhiteSpace( !string.IsNullOrWhiteSpace(
Environment.GetEnvironmentVariable( Environment.GetEnvironmentVariable(
"SHARPEMU_TRACE_GUEST_IMAGE_ADDRS")); "SHARPEMU_TRACE_GUEST_IMAGE_ADDRS"));
private static readonly double _renderResolutionScale =
double.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_RENDER_SCALE"),
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out var renderResolutionScale) &&
renderResolutionScale > 0 &&
renderResolutionScale <= 2.0
? renderResolutionScale
: 1.0;
private static uint ScaleGuestDimension(uint value) =>
_renderResolutionScale == 1.0
? value
: Math.Max(1u, (uint)Math.Round(value * _renderResolutionScale));
private static readonly bool _forceFullscreenPipeline = private static readonly bool _forceFullscreenPipeline =
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_FULLSCREEN_PIPELINE") == "1"; Environment.GetEnvironmentVariable("SHARPEMU_FORCE_FULLSCREEN_PIPELINE") == "1";
private static readonly bool _forceFullscreenVertex = private static readonly bool _forceFullscreenVertex =