Skip to content

Fix window resize/fullscreen rendering, add ultrawide fill mode - #254

Closed
KohlsAdrian wants to merge 3 commits into
sonicnext-dev:mainfrom
KohlsAdrian:fix/window-resize-present-scaling
Closed

KohlsAdrian wants to merge 3 commits into
sonicnext-dev:mainfrom
KohlsAdrian:fix/window-resize-present-scaling

Conversation

@KohlsAdrian

@KohlsAdrian KohlsAdrian commented Sep 16, 2026

Copy link
Copy Markdown

The issue

Any change to the window size after startup broke rendering: the game kept drawing at its launch size in the top-left corner of the window, the rest of the window stayed black, and the 3D camera appeared off-centre relative to the window.

It reproduced on macOS/Metal by:

  • resizing or maximising the window,
  • toggling fullscreen (ALT+ENTER),
  • moving the window to another monitor.

Launching directly at a given size was always correct — only later changes were affected.

Root cause

The guest is told its render resolution exactly once, in Sonicteam::AppMarathon::AppMarathon (app.cpp), and it builds its render targets from that. It has no way to re-create them at a different size at runtime. This is the same unimplemented "buffer resize" that WindowSize, Monitor, AspectRatio, ResolutionScale and Fullscreen are all disabled behind in the options menu.

On a later size change:

  1. ComputeViewportDimensions() grew s_viewportWidth/Height to the new window size,
  2. the intermediary back buffer was recreated at that larger size,
  3. the guest still drew a launch-sized image at the origin of that larger target,
  4. and the gamma correction blit copied it 1:1 (texture.Load, black outside g_ViewportSize).

Hence content in the corner, black elsewhere, and an apparently off-centre camera. ALT+ENTER and window-manager resizing bypass the disabled options, which is how the state is reached.

A second, macOS-specific problem compounded it: the Metal swap chain resolves its size from a cached window query performed on the render thread, so needsResize() could miss the change entirely and report a stale size.

What was done

Since the guest genuinely cannot re-render at a new size, its resolution is pinned and the result is scaled at present time.

  • Video::LockGuestResolution() — called in app.cpp right where the guest receives its render config. After this, ComputeViewportDimensions() no longer resizes the viewport, but still tracks the real output size and recomputes the aspect ratio offsets.
  • Video::ComputePresentRect() — computes the destination rectangle for the guest's image inside the render output.
  • Scaling blitgamma_correction_ps.metal and gamma_correction_ps.hlsl now scale the source into the destination rectangle using manual bilinear filtering, so no additional sampler binding was needed.
  • Authoritative output size on macOSGameWindow tracks SDL_GetWindowSizeInPixels on the main thread (s_pixelWidth/s_pixelHeight, updated on RESIZED, SIZE_CHANGED, DISPLAY_CHANGED and per frame). CheckSwapChain() detects output-size changes itself rather than relying on needsResize().
  • ImGui mouse mapping — input is mapped back through the scaled destination rectangle so menus stay clickable.

Behaviour before the guest locks its resolution (installer, boot) is unchanged: in both ComputeViewportDimensions() branches the viewport matches the output on one axis, so the fit scale is exactly 1.0 — identical 1:1 centring to before. Only the post-lock path changes.

Nothing under thirdparty/ was modified.

Feature: ultrawide fill mode

Config::AspectRatio now selects how the image is fit to the window, and is enabled in the options menu because it no longer requires a buffer resize — it is applied when presenting and takes effect live:

  • Auto (default) — fills the entire window. No letterboxing or pillarboxing, so the HUD reaches the window corners. Intended for ultrawide displays, alongside the existing UIAlignmentMode::Edge and CutsceneAspectRatio::Unlocked, which already extend the HUD to the edges and unlock in-game cutscenes.
  • Original — preserves the game's aspect ratio and letterboxes instead.

The existing option was reused rather than adding a new one, since Auto vs Original already carries this meaning, and a new setting would need localisation across all supported languages.

Limitations

  • The guest still renders at the aspect ratio it was launched with. Launching at the target resolution (for example starting in ultrawide fullscreen) renders natively and is pixel-correct; changing to a different aspect ratio at runtime is scaled, which stretches geometry in Auto mode. Original avoids the stretch at the cost of bars. Since Config::Fullscreen is persisted, toggling fullscreen and restarting yields the native path.
  • Pre-rendered videos remain letterboxed, as that is baked into the source media. In-game cutscenes are covered by CutsceneAspectRatio::Unlocked.
  • This does not implement real buffer resize; the remaining // TODO: implement buffer resize options stay disabled. Proper runtime resolution changes would still require the guest to re-create its render targets.

Testing

  • Full clean build on macOS (arm64, Metal), including regeneration of both the SPIR-V and Metal shader binaries.
  • Verified in-game that resizing, maximising, fullscreen toggling and moving between monitors all render across the whole window with a correctly centred camera.
  • Ultrawide fill mode confirmed to remove letterboxing with the HUD reaching the window edges.

Only D3D12/Vulkan were not exercised at runtime; the shared code path and the equivalent HLSL change mirror the Metal one, and the pre-lock behaviour is unchanged by construction.


Preview

Ultrawide Monitor:
Screenshot 2026-09-16 at 16 55 02

Settings:
Screenshot 2026-09-16 at 16 55 42
Screenshot 2026-09-16 at 16 55 49

4K Monitor:
Screenshot 2026-09-16 at 16 56 07


This was done using Claude Opus 5 with Kiro.

The guest is given its render resolution once, when it constructs its
renderer, and cannot re-create its render targets at a different size
afterwards (the unimplemented "buffer resize" that Fullscreen, Monitor,
WindowSize, AspectRatio and ResolutionScale are all disabled behind).

Growing the viewport after that point made the guest draw a launch-sized
image into the corner of a larger render target, which the present blit
then copied 1:1, leaving the rest of the window black and the camera
visibly off-centre. This happened on any post-startup size change:
resizing the window, toggling fullscreen, or moving to another monitor.

Pin the viewport to the guest's resolution once it has been handed over,
and scale that image to the window when presenting instead:

- Add Video::LockGuestResolution(), called where the guest receives its
  render config, so ComputeViewportDimensions() stops resizing the
  viewport afterwards while still tracking the real output size.
- Add Video::ComputePresentRect() and make the gamma correction blit
  scale the guest's image into it, with manual bilinear filtering so no
  additional sampler binding is required.
- Track the output size from SDL's main-thread pixel size on macOS. The
  Metal swap chain resolves its size from a cached window query on the
  render thread, so needsResize() can miss resizes, fullscreen toggles
  and monitor changes entirely; CheckSwapChain() now detects the change
  itself.
- Map ImGui mouse input through the scaled destination rectangle.

Config::AspectRatio now selects how the image is fit to the window and
is enabled in the options menu, since it no longer needs a buffer resize:
Auto fills the whole window so the HUD reaches the corners (best for
ultrawide, alongside UIAlignmentMode::Edge and CutsceneAspectRatio::
Unlocked), Original preserves the aspect ratio and letterboxes.

Note that the guest still renders at the aspect ratio it was launched
with, so launching at the target resolution renders natively while a
later change to a different aspect is scaled.
Present-time scaling can only stretch or letterbox a fixed-resolution
guest image, so ComputePresentRect now always preserves the guest's
aspect ratio (contain fit) instead of stretching in Auto. This removes
the geometry distortion that happened when the window aspect differed
from the launch aspect.

Because the guest builds its render targets once at launch and cannot
re-create them at a new size at runtime (the unimplemented buffer
resize), the only way to fill a differently-shaped window natively is
to relaunch so the targets are rebuilt at the new size. In Auto mode
GameWindow::MaybeRestartForAspectChange() now relaunches via App::Restart
when the window aspect ratio differs from the guest's launch aspect
(Config::WindowSize/Fullscreen persist across the restart). It is
suppressed while initialising, loading, saving or changing display so a
relaunch never interrupts that state. Original keeps a fixed 16:9 and is
unaffected.
@KohlsAdrian

Copy link
Copy Markdown
Author

Update: no distortion in Auto + automatic re-render at the new window aspect

Follow-up to feedback that Auto stretched the image when the window aspect differed from the launch aspect. Pushed in 09ad683.

What changed

1. Auto no longer distorts (present-time fix)
Video::ComputePresentRect previously filled the whole window in Auto by stretching the guest image to the output rectangle, which distorted geometry whenever the window aspect ratio differed from the aspect the guest was launched at. It now always uses an aspect-preserving contain fit (the same fit Original uses). The guest image is never stretched or cropped:

  • When the window matches the guest's launch aspect, the fit is 1.0 and the image fills the whole window edge to edge (full ultrawide, HUD at the borders).
  • When the window aspect differs, it is letterboxed/pillarboxed rather than distorted.

2. Auto re-renders natively at the new aspect via restart
The guest builds its 3D render targets once, from the resolution it is handed at launch (app.cppVideo::LockGuestResolution), and has no runtime path to re-create them at a different size — this is the same unimplemented "buffer resize" that WindowSize, Monitor, ResolutionScale and Fullscreen are all still disabled behind. I verified there is no safe in-process way to resize the guest's own render targets (they are guest-allocated via CreateSurface at guest-computed dimensions; the SetResolution/sub_82E9EE38 hook is init-only and commented out). The only wired-up mechanism for a genuine resolution/aspect change is App::Restart, which relaunches so the guest rebuilds its targets at the new size (Config::WindowSize/Fullscreen persist across the relaunch).

So GameWindow::MaybeRestartForAspectChange() (called each frame from GameWindow::Update) relaunches the game immediately when, in Auto mode, the window's pixel aspect ratio differs from the guest's launch aspect by more than ~1.5% (a scale-independent relative threshold to ignore rounding/DPI jitter). After the restart the guest renders natively at the new aspect, so the window is filled with no stretch and no black bars. It is suppressed while initialising, loading, saving, or changing display so a relaunch never interrupts that state. Original keeps a fixed 16:9 and is unaffected.

Constraints / behaviour to be aware of

  • Restart on host/window aspect change: changing the window aspect ratio in Auto triggers a process relaunch (this is the only way to get a native, undistorted fill given the fixed guest render targets). Because it is immediate (no debounce), dragging a resize across the threshold can relaunch mid-drag; toggling fullscreen, maximising, or moving to a differently-shaped monitor will also relaunch. Launching directly at the target size is of course still pixel-perfect with no restart.
  • The guest still cannot resize its render targets in place; this feature works entirely by relaunching, not by implementing true runtime buffer resize. The remaining // TODO: implement buffer resize options stay disabled.
  • Only the aspect ratio matters — resizing within the same aspect does not restart and is handled by the aspect-preserving present blit.
  • Shaders and thirdparty/ are unchanged.

Testing

  • Full clean build on macOS (arm64, Metal).
  • Verified via runtime logging that Auto present is aspect-correct (no distortion) and that a window aspect change relaunches and comes back rendering natively at the new aspect.
  • D3D12/Vulkan not exercised at runtime; the changed code is backend-agnostic (present rect math + an App::Restart call).

…reen)

Replace the guest-vs-output aspect comparison with a loop-safe trigger
based on the render OUTPUT aspect ratio changing and settling. Comparing
against the last observed output aspect (rather than the guest's fixed
launch aspect) prevents the restart loop that occurred when the guest
could not lock exactly to the live drawable aspect: after a relaunch the
output aspect is stable, so the baseline matches and it does not fire
again. A short settle delay avoids relaunching on every intermediate
size during a resize drag or fullscreen transition.

This also covers entering fullscreen via the macOS green title-bar
button, which enters a native fullscreen Space that sets no SDL
fullscreen flag but does change the drawable size:
- IsFullscreen() now masks SDL_WINDOW_FULLSCREEN (covers both the
  borderless desktop fullscreen used by ALT+ENTER and native fullscreen).
- When deciding what to persist for the relaunch, treat "the window
  covers the display" (width >= 98%, height >= 90% of the display bounds,
  allowing for the menu bar/notch inset) as fullscreen, so the relaunched
  instance comes back as borderless desktop fullscreen at the new aspect
  instead of windowed.

Only applies in EAspectRatio::Auto; suppressed while initialising,
loading or saving.
@KohlsAdrian

Copy link
Copy Markdown
Author

Update 2: loop-safe auto-restart + macOS fullscreen handling

Refines the Auto auto-restart from the previous update (pushed in a703871). The earlier version compared the window aspect against the guest's fixed launch aspect and restarted immediately, which could restart-loop (the relaunched guest may never lock to exactly the live drawable aspect) and did not correctly handle the macOS green title-bar button.

What changed

  • Loop-safe trigger. The relaunch now triggers on the render output aspect ratio changing and then settling, compared against the last observed output aspect rather than the guest aspect. After a relaunch the output aspect is stable, so the baseline matches and it does not fire again. A short settle delay (~400 ms) avoids relaunching on every intermediate size during a resize drag or a fullscreen transition.
  • macOS green-button fullscreen. That button enters a native fullscreen Space which sets no SDL fullscreen flag but does change the drawable size. Two fixes:
    • GameWindow::IsFullscreen() now masks SDL_WINDOW_FULLSCREEN (covers both the borderless desktop fullscreen used by ALT+ENTER and native fullscreen).
    • When persisting state for the relaunch, "the window covers the display" (width ≥ 98% and height ≥ 90% of the display bounds, allowing for the menu bar / notch inset) is also treated as fullscreen, so the relaunched instance comes back as borderless desktop fullscreen at the new aspect instead of windowed.
  • Only applies in EAspectRatio::Auto; suppressed while initialising, loading or saving. Original is unaffected (fixed 16:9, letterboxed).

Constraints / behaviour to be aware of

  • Auto relaunches the game when the output aspect ratio settles at a new value — windowed resize to a different aspect, ALT+ENTER, or the macOS green button. This is the only way to get a native, undistorted, bar-free fill given the guest's fixed render targets (Config::WindowSize/Fullscreen persist across the relaunch). Resizing within the same aspect does not restart and is handled by the aspect-preserving present blit.
  • The macOS green button relaunches into borderless desktop fullscreen (covering the display at the correct aspect), not a native macOS fullscreen Space.
  • Verified on macOS (arm64, Metal): ALT+ENTER, the green button, and windowed aspect changes each relaunch exactly once into a correctly-filled native render with no distortion, no bars, and no restart loop. Diagnostics used during development were removed. Shaders and thirdparty/ unchanged.

@hyperbx

hyperbx commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

That's a lot of words you didn't write to say "I broke arbitrary aspect ratio support".

This is not the resize behaviour that we are aiming to implement. There is work already being done to allow for buffer resize, which can properly provide that context to all of the pre-existing patches for aspect ratio correction.

This is a problem that requires reverse-engineering the game code to fix, it's not something you can solve by throwing an LLM at it.

@hyperbx hyperbx closed this Sep 18, 2026

This branch was successfully deployed

1 active deployment
external a703871f Deployed Sep 18, 2026 by KohlsAdrian via authorize #599
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants