Post-shader framebuffers (for shaders at output resolution, and those that keep the previous frame) are sized for the output they were built for. Moving the game to a TV or back, or the TV changing size, left them sized for the other one. SetOutputOverride now asks for a rebuild when the output changes. Also corrects the design doc: "auto" render resolution follows the device's screen, not the TV. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
19 KiB
Multiple swapchains (design)
Status: implemented on Metal and Vulkan, used by iOS TV mode and --game-window (Mac, Linux and
Windows).
The first user is an iOS "TV mode": with AirPlay connected, the game runs full screen on the TV and the phone shows only the touch controls (and the menus). The same mechanism is meant to carry later uses: ImGui debugger windows outside the main window (ImGui viewports), a desktop window mirroring a VR headset, and an external display on Android or desktop. So the graphics side is general, and the iOS part is just its first client.
Where we are
Everything assumes one output, at every layer:
- thin3d: the backbuffer is the
nullptrframebuffer.BindFramebufferAsRenderTarget(nullptr),GetFramebufferDimensions(nullptr),CopyFramebufferToMemory(nullptr),Present(), theLOST_BACKBUFFER/GOT_BACKBUFFERevents and the present-mode caps all mean "the" swapchain (Common/GPU/thin3d.h). - Vulkan:
VulkanContextholds onesurface_/swapchain_/presentation_and the surface-derived state (format, extent, caps, transform, present modes). The render manager has oneFrameDataShared::swapchainImages_, one set of backbuffer framebuffers and one depth buffer (VulkanQueueRunner.h). A frame can contain only one backbuffer pass (SanityCheckPassesOnAddasserts). The BACKBUFFER render pass is looked up without a format in its key (VKRRenderPass::Get). Queue selection checks present support against the one surface (ChooseQueue). VulkanPresentation(Common/GPU/Vulkan/VulkanPresentation.h) already abstracts an image chain (acquire, present, images, extent, format, final layout). libretro and headless use it to replace the swapchain. It is the seed of this design.- Metal: one
layer_,rtDrawable_andbbDepth_inMetalRenderManager. Backbuffer passes are merged against a singleBACKBUFFER_TARGETsentinel. - D3D11: the platform context (
D3D11Context) owns the oneIDXGISwapChainand hands its RTV in throughGOT_BACKBUFFER. - OpenGL: one context, one default FBO (
g_defaultFBO), oneswapFunction_. - Frontend: one global
g_display, read in about 250 places, from layout to input scaling.ScreenManager::Renderis the only place that binds the backbuffer.EmuScreen::renderdraws the game (gpu->CopyDisplayToOutput) and then the touch controls (renderUI) into that same pass.
Graphics: Draw::Swapchain
A swapchain is a presentable chain of images: a window, a CAMetalLayer, an XR swapchain, a libretro
frame callback. It's a real object, a kind of Framebuffer, so the existing framebuffer calls take it
and the backbuffer stops being a special nullptr. The current implicit backbuffer becomes the
primary swapchain, which the draw context creates along with itself.
namespace Draw {
// A Framebuffer you can render to and read back, but not texture from or blit from. Its size
// follows the surface. Owned by the DrawContext.
class Swapchain : public Framebuffer {
// IsSwapchain() on Framebuffer returns true. The backend's subclass holds the presentation state.
};
struct SwapchainDesc {
WindowSystem winsys;
void *data1; // The same as GraphicsContext::InitSurface: CAMetalLayer, HWND, ANativeWindow...
void *data2;
int width; // In pixels. Used when the window system can't report it (iOS, Metal).
int height;
bool pacing; // See "Frame pacing". Exactly one swapchain paces; the primary by default.
const char *tag;
};
class DrawContext {
// ...existing interface...
// Null if the backend can't (caps.multipleSwapchains false), or the surface can't be presented
// from our queue. Call between frames, on the render thread.
virtual Swapchain *CreateSwapchain(const SwapchainDesc &desc, std::string *error) { return nullptr; }
virtual void DestroySwapchain(Swapchain *swapchain) {}
virtual void ResizeSwapchain(Swapchain *swapchain, int width, int height) {}
virtual void SetPacingSwapchain(Swapchain *swapchain) {}
Swapchain *GetPrimarySwapchain();
// Unchanged signatures, now also taking a Swapchain:
// BindFramebufferAsRenderTarget(swapchain, rp, tag): at most one pass per swapchain per frame,
// after all offscreen passes.
// GetFramebufferDimensions(swapchain, &w, &h), CopyFramebufferToMemory(swapchain, ...).
// BindFramebufferAsTexture, BlitFramebuffer and CopyFramebufferImage assert on a swapchain.
};
}
-
nullptris an alias for the primary, so nothing breaks. The direct callers useGetPrimarySwapchain(). Some code still passes aFramebuffer *that may be null (non-buffered rendering, for one), sonullptrcan only become an assert after that's audited. Backends that support one swapchain map the primary to their oldnullptrpath (PrimaryToNull). Metal treats all its swapchains the same way. -
Size changes on resize. Unlike other framebuffers, a swapchain's
Width()/Height()change when its surface does, and only at a frame boundary. -
Platform code keeps the primary out of its hands.
GraphicsContext::InitSurfacestill creates the primary, so platform code doesn't change. -
One frame, many presents.
BeginFrame/EndFrameand the frame semaphores and fences stay global.Present()presents every swapchain that got a pass this frame. A swapchain without a pass is not acquired or presented, so a debugger window can update at 10 Hz for free. -
Lifetime:
CreateSwapchainandDestroySwapchainrun on the render thread at a frame boundary. Destroy waits for the frames in flight that use it. The platform keeps the native surface (layer, window) alive until destroy returns (see "iOS"). -
Out-of-date and resize are per swapchain. Recreating the TV's swapchain must not touch the primary's. Losing a secondary surface makes it skip frames until resized or destroyed, never fail the frame.
-
Formats: phase 1 requires every swapchain to use the primary's color format, which is BGRA8 on every platform we care about. Then the BACKBUFFER render pass and every pipeline compiled against it stay compatible. Mixed formats would need the format in the render-pass key and per-format pipeline variants; that's left for when something needs it (HDR output).
-
Caps:
DeviceCaps::multipleSwapchains. The frontend checks it and falls back to mirroring.
Frame pacing
FIFO swapchains block on acquire. With two of them, the frame loop paces to whichever is slower, and an unlucky vsync phase between two displays makes that stutter. Policy:
- Exactly one swapchain paces. Its acquire blocks as today, and the frame-timing machinery
(present IDs,
FrameTimeHistory,GetCurrentPresentMode) follows it. In TV mode that's the TV, since the game is there. - The others never block. They acquire with a timeout of 0. If no image is ready, that swapchain skips the frame (its pass becomes a no-op), and it's presented on the next one. They use MAILBOX or IMMEDIATE where the surface offers it.
- On iOS a
CAMetalLayeris always vsynced (displaySyncEnabledis macOS-only), so a non-pacing layer just drops frames when it has no drawable. That's acceptable for touch controls. This needs measuring with a 60 Hz TV and a 120 Hz ProMotion phone.
Vulkan
The main swapchain stays where it is, in VulkanContext and FrameDataShared. The others are
VKRSwapchains (VulkanFrameData.h), owned by the render manager:
- Creating one:
VulkanContext::CreateSurface(the per-platform surface code, shared with the main surface), a check that the graphics queue can present to it, then the swapchain in the main one's format, with its own image views, framebuffers (compatible with the backbuffer render pass) and depth buffer. Per in-flight frame it has an acquire semaphore, and per image a rendering-complete one. - Steps: a RENDER step with no framebuffer has a
swapchain, null for the main one. The queue runner uses its framebuffer and size, and doesn't rotate it to the display.SanityCheckPassesOnAddallows one pass per swapchain, after all the offscreen ones. Swapchain passes are never merged. - Pacing: acquiring one doesn't wait (timeout 0). If there's no image, the pass is skipped.
- Submit and present: the frame's last submit waits on the acquire semaphore of every swapchain
acquired that frame, and signals each one's rendering-complete semaphore. They're presented one
vkQueuePresentKHReach, after the main one, even when the main one skipped the frame. - Out of date, resize, destroy: the render thread only flags a swapchain as out of date. It's recreated at the start of the next frame, on the emu thread. Recreating and destroying stop the render threads and wait for the queue, like a resize of the main swapchain does. Creating doesn't need to, since nothing refers to the new one yet.
- Rotation: extra swapchains aren't pre-rotated. They ask for the identity transform, and their
viewports, scissors and UI projection (
ComputeOrthoMatrix(..., false)) leave out the main swapchain's rotation, which is Android's. - Not done: reading back an extra swapchain.
VulkanPresentation(libretro, offscreen) still only replaces the main swapchain.
With only the main swapchain, the frame does what it did before: frametests on Vulkan match.
Metal
layer_, rtDrawable_, bbDepth_ and the pending size move into a MetalSwapchain. The
BACKBUFFER_TARGET sentinel becomes the swapchain pointer, so passes merge per target. Replay acquires
each drawable lazily, as today, and the frame's last command buffer does presentDrawable: for each.
For a non-pacing layer, skip the pass when nextDrawable would block.
D3D11 and OpenGL
- D3D11: easy, and later. Creating the
IDXGISwapChainmoves fromD3D11Contextinto the draw context, one per HWND, andPresent()presents each one. - OpenGL: not supported, and not planned:
multipleSwapchainsstays false. With OpenGL, the platforms keep their single-display behaviour (iOS mirrors to the TV).
Frontend: outputs and roles
g_display stays as it is and describes the primary output: the screen the UI and touch input
live on (the phone). Its 250 readers don't change. Making it per-output would touch layout, input,
DPI and fonts everywhere, for no gain here.
Secondary outputs are described separately:
enum class OutputRole {
Game, // Just the emulated display, laid out full screen. No UI.
Mirror, // A copy of the primary (unused at first, the OS mirrors for us).
ImGui, // Later: an ImGui viewport.
};
struct OutputDisplay {
Draw::Swapchain *swapchain;
OutputRole role;
int pixelWidth, pixelHeight;
float refreshHz;
float overscanInset; // TVs.
};
These live in a small OutputManager owned by the screen manager. The platform layer adds and
removes them (see "iOS").
Rendering
There is still one screen stack. A screen can draw to more than one output:
// Draws this screen's content for a secondary output. The swapchain is already bound.
// Returns false if the screen has nothing for it.
virtual bool renderOutput(const OutputDisplay &output) { return false; }
ScreenManager::Render stays the only place that binds a swapchain:
PreRenderfor every screen, as now. Emulation and post-shader passes run here.- For each secondary output, bind its swapchain and ask the stack from the top down. The first
screen whose
renderOutputreturns true has drawn it. If no screen claims it, the output falls back to clearing it (or, on iOS, it's taken away so the system mirrors again).EmuScreenclaimsGameoutputs. It drawsCopyDisplayToOutputwith that output'sOutputParamsand nothing else.- The pause menu and the other dialogs don't claim anything. So while the pause menu is up, the phone shows the menu and the TV keeps the game (darkened, as behind the menu today).
- Later, other screens can claim a TV too. The game browser could show a big-screen backdrop instead of a mirror of the menu.
- The primary pass, as now. While
EmuScreensends the game to another output, itsrenderleaves out its ownCopyDisplayToOutputand draws the touch controls over black. Optionally it draws a small game preview, which is a secondCopyToOutput.
The other option was a stack entry holding one screen per swapchain. It doesn't fit, because input, focus and dialogs belong to one place (the phone). Per-output screens would have to be pushed and popped in step, and most entries would have only one.
ImGui windows aren't screens. ImGui owns its viewports. After the screens, the ImGui step draws each viewport to its own swapchain, so it shares the swapchain layer with the screen stack and nothing else.
Presentation parameters
PresentationCommon should get its output size and layout explicitly instead of from g_display.
Mostly it already does, through PSP_CoreParameter().pixelWidth/Height and
UpdateDisplaySize. "Auto" render resolution follows the primary's size, not the TV's (not done). What
still reads the global:
GetScreenFrame(insets, DPI)- the stretch logic in
CalculateDisplayOutputRect(device orientation) rot_matrixinCopyToOutputand the post-shader passes- the DisplayLayoutConfig choice (
GetDeviceOrientation())
These become an OutputParams { width, height, rotation, insets, orientation } handed to
CopyDisplayToOutput. The game output on a TV is always landscape, with no rotation and with overscan
as its insets. That refactor is contained (about five call sites) and is worth doing first, since it
can be tested on its own.
Restrictions
- Skip buffer effects (non-buffered rendering) draws the game straight into the bound backbuffer during emulation, so it can't target another output. In that mode the game stays on the phone, with a one-time notice.
- Screenshots taken from the backbuffer (the primary swapchain) would capture the phone. Game screenshots should read the game output, or render the game to an offscreen buffer.
- Cardboard VR turns off on a
Gameoutput.
iOS
- Getting the scene: up to iOS 26, a
UIWindowSceneSessionRoleExternalDisplayNonInteractiveconfiguration in Info.plist, withExternalDisplaySceneDelegate. From iOS 27, that's ignored, and the app has to register from a view controller instead (registerSceneAccessory:withUISceneAccessory externalNonInteractiveSceneAccessoryWithConfiguration:). We do both. Without a scene, iOS just mirrors. An AirPlay Apple TV 4K comes in at 3840x2160. - Connect: the delegate keeps the scene, and posts "external display available" (size from
screen.nativeBounds, refresh rate, overscan) to the native side. It doesn't create a window yet. - TV mode on: when it should be active (setting on,
EmuScreenrunning), the main thread creates aUIWindowon the scene with aCAMetalLayer-backed view (contentsScale = screen.nativeScale). It then queues a request to the render thread, which callsCreateSwapchain(METAL_EXT, layer)at a frame boundary and registers aGameoutput. - TV mode off, disconnect, or resign active: the render thread destroys the swapchain first, and
the main thread waits for that (the same handshake as
requestExitVulkanRenderLoop). Only then does it remove the window. Removing the window should give the TV back to system mirroring. Verify on the device: if iOS doesn't resume mirroring, we need aMirroroutput for the menus instead. - Setting: Graphics → "External display": "Show the game" (the default) or "Mirror the screen"
(
iExternalDisplay, iOS and Android). It's disabled on backends withoutmultipleSwapchains, and a change applies right away (SystemNotification::EXTERNAL_DISPLAY_CHANGED). - Out of scope: iPad Stage Manager's interactive extended displays (role
Application), and tvOS. - AirPlay latency: this is the same video stream as mirroring, so the same lag. Input stays local, but the picture is late.
Android
- The display: Android shows a second display to apps as a presentation display: HDMI or USB-C DisplayPort out (Samsung DeX has to be off, or the TV is a desktop), some wireless displays (Miracast, like Samsung's Smart View), and Developer options → "Simulate secondary displays". Casting to a Chromecast or an Android TV is mirroring only.
- Showing it: while it's started, the activity watches
DisplayManagerand, when there's aDISPLAY_CATEGORY_PRESENTATIONdisplay, the backend is Vulkan and the setting says "Show the game" (queryConfig("externalDisplayOutput")), shows anExternalDisplayPresentationthere. Displays that are part of the device (a dual-screen handheld's second screen) are skipped, where Android can tell (12 and up,DeviceProductInfo.CONNECTION_TO_SINK_BUILT_IN). That's aPresentationwith aSurfaceView, whose surface callbacks add, resize and remove aGameoutput through JNI. Otherwise Android mirrors. A backend change recreates the activity, which checks again. - Removing: the surface mustn't be touched once
surfaceDestroyedreturns, so it removes the output withNative_RemoveOutputAndWait(). That returns once the render thread has processed the removal, or right away if the graphics are shut down (the outputs have no swapchains then). The activity dismisses the presentation inonStop, before it stops the render thread. - Device name: "In menus on %1" uses
Settings.Global.DEVICE_NAME(Android 7.1 and up).
Later clients
-
ImGui viewports:
ImGuiConfigFlags_ViewportsEnable, a platform backend that creates SDL or Win32 windows, and renderer hooks that map to this design:Renderer_CreateWindow→CreateSwapchainRenderer_RenderWindow→BindFramebufferAsRenderTarget(swapchain)and drawRenderer_SwapBuffers→ nothing,Present()covers it
It also needs input routed per window, which is its own piece of work.
-
Desktop VR mirror: with OpenXR on Vulkan, each XR swapchain is a
VulkanPresentation(acquire maps toxrAcquireSwapchainImage/xrWaitSwapchainImage, present toxrReleaseSwapchainImage), and the desktop window stays primary. VR is GL-only today, so this also needs a Vulkan OpenXR path.
Phases
OutputParamsrefactor ofPresentationCommonandCopyDisplayToOutput. No change in behaviour, checked with frametests.- thin3d
SwapchainAPI plus Metal, the iOS default. Single-output path unchanged: unit tests, pspautotests, frametests. - Dev client on macOS SDL: a
--game-windowoption opens a second SDL window as aGameoutput (with--graphics=metal). This tests most of the design in a fast loop, without AirPlay. Headless can add an offscreen second output, so frametests can check what it shows. - iOS TV mode, on Metal, tested with the Apple TV.
- Vulkan: done, tested with
--game-windowon Mac, Linux (SDL) and Windows (a second Win32 window), and on iOS with MoltenVK. D3D11 when something wants it. - Android, on Vulkan, through
Presentation.
Open questions:
- Does removing the external window resume mirroring?
- How do two vsynced layers pace on a ProMotion phone?