Files
ppsspp/docs/multi-swapchain.md
T
Henrik RydgårdandClaude Opus 5.5 5799bdee59 Outputs: Rebuild the post-shader chain when the game moves to another output
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>
2026-10-22 23:00:13 +02:00

329 lines
19 KiB
Markdown

# 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 `nullptr` framebuffer. `BindFramebufferAsRenderTarget(nullptr)`,
`GetFramebufferDimensions(nullptr)`, `CopyFramebufferToMemory(nullptr)`, `Present()`, the
`LOST_BACKBUFFER`/`GOT_BACKBUFFER` events and the present-mode caps all mean "the" swapchain
(`Common/GPU/thin3d.h`).
- **Vulkan:** `VulkanContext` holds one `surface_`/`swapchain_`/`presentation_` and the surface-derived
state (format, extent, caps, transform, present modes). The render manager has one
`FrameDataShared::swapchainImages_`, one set of backbuffer framebuffers and one depth buffer
(`VulkanQueueRunner.h`). A frame can contain only one backbuffer pass (`SanityCheckPassesOnAdd`
asserts). 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_` and `bbDepth_` in `MetalRenderManager`. Backbuffer passes are
merged against a single `BACKBUFFER_TARGET` sentinel.
- **D3D11:** the platform context (`D3D11Context`) owns the one `IDXGISwapChain` and hands its RTV in
through `GOT_BACKBUFFER`.
- **OpenGL:** one context, one default FBO (`g_defaultFBO`), one `swapFunction_`.
- **Frontend:** one global `g_display`, read in about 250 places, from layout to input scaling.
`ScreenManager::Render` is the only place that binds the backbuffer. `EmuScreen::render` draws 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.
```cpp
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.
};
}
```
- **`nullptr` is an alias for the primary,** so nothing breaks. The direct callers use
`GetPrimarySwapchain()`. Some code still passes a `Framebuffer *` that may be null (non-buffered
rendering, for one), so `nullptr` can only become an assert after that's audited. Backends that
support one swapchain map the primary to their old `nullptr` path (`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::InitSurface` still creates
the primary, so platform code doesn't change.
- **One frame, many presents.** `BeginFrame`/`EndFrame` and 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:** `CreateSwapchain` and `DestroySwapchain` run 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 `CAMetalLayer` is always vsynced (`displaySyncEnabled` is 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
`VKRSwapchain`s (`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.
`SanityCheckPassesOnAdd` allows 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
`vkQueuePresentKHR` each, 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 `IDXGISwapChain` moves from `D3D11Context` into the draw
context, one per HWND, and `Present()` presents each one.
- **OpenGL:** not supported, and not planned: `multipleSwapchains` stays 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:
```cpp
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:
```cpp
// 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:
1. `PreRender` for every screen, as now. Emulation and post-shader passes run here.
2. For each secondary output, bind its swapchain and ask the stack from the top down. The first
screen whose `renderOutput` returns 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).
- `EmuScreen` claims `Game` outputs. It draws `CopyDisplayToOutput` with that output's
`OutputParams` and 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.
3. The primary pass, as now. While `EmuScreen` sends the game to another output, its `render` leaves
out its own `CopyDisplayToOutput` and draws the touch controls over black. Optionally it draws a
small game preview, which is a second `CopyToOutput`.
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_matrix` in `CopyToOutput` and 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 `Game` output.
## iOS
- **Getting the scene:** up to iOS 26, a `UIWindowSceneSessionRoleExternalDisplayNonInteractive`
configuration in Info.plist, with `ExternalDisplaySceneDelegate`. From iOS 27, that's ignored, and the
app has to register from a view controller instead (`registerSceneAccessory:` with
`UISceneAccessory 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, `EmuScreen` running), the main thread creates
a `UIWindow` on the scene with a `CAMetalLayer`-backed view (`contentsScale = screen.nativeScale`).
It then queues a request to the render thread, which calls `CreateSwapchain(METAL_EXT, layer)` at a
frame boundary and registers a `Game` output.
- **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 a `Mirror` output 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 without `multipleSwapchains`, 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 `DisplayManager` and, when there's a
`DISPLAY_CATEGORY_PRESENTATION` display, the backend is Vulkan and the setting says "Show the game"
(`queryConfig("externalDisplayOutput")`), shows an `ExternalDisplayPresentation` there. 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 a `Presentation` with a `SurfaceView`, whose
surface callbacks add, resize and remove a `Game` output through JNI. Otherwise Android mirrors.
A backend change recreates the activity, which checks again.
- **Removing:** the surface mustn't be touched once `surfaceDestroyed` returns, so it removes the output
with `Native_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 in `onStop`, 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` → `CreateSwapchain`
- `Renderer_RenderWindow` → `BindFramebufferAsRenderTarget(swapchain)` and draw
- `Renderer_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 to `xrAcquireSwapchainImage`/`xrWaitSwapchainImage`, present to `xrReleaseSwapchainImage`),
and the desktop window stays primary. VR is GL-only today, so this also needs a Vulkan OpenXR path.
## Phases
1. **`OutputParams` refactor** of `PresentationCommon` and `CopyDisplayToOutput`. No change in
behaviour, checked with frametests.
2. **thin3d `Swapchain` API plus Metal,** the iOS default. Single-output path unchanged: unit tests,
pspautotests, frametests.
3. **Dev client on macOS SDL:** a `--game-window` option opens a second SDL window as a `Game` output
(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.
4. **iOS TV mode,** on Metal, tested with the Apple TV.
5. **Vulkan:** done, tested with `--game-window` on Mac, Linux (SDL) and Windows (a second Win32
window), and on iOS with MoltenVK. D3D11 when something wants it.
6. **Android,** on Vulkan, through `Presentation`.
Open questions:
- Does removing the external window resume mirroring?
- How do two vsynced layers pace on a ProMotion phone?