Skip to content

Commit 98d0986

Browse files
feat(ds): Metal sub-rectangle dual-screen rendering for Nintendo DS
Implements GPU-side split rendering for DS dual-screen skins. - Add `dual_screen_blit.metal` shader for sub-rectangle blitting - Add `PVMetalViewController+DualScreen` with DualScreenRenderInfo, pipeline setup, and single-pass dual-screen render method - Add `dualScreenLayout` and `dualScreenBlitPipeline` properties to PVMetalViewController; hook into directRender - Add `PVEmulatorViewController+MetalDualScreen` to compute per-screen DualScreenRenderInfo from the active skin's DeltaSkinScreen groups and install them on PVMetalViewController - Route DS dual-screen applyDualScreenViewport through the Metal path when a compatible skin is active; clear layout when skins are disabled - Enable supportsSkins=true for PVMelonDSCore and PVDesmume2015Core The combined 256×384 DS framebuffer is split into top (y=0..191) and bottom (y=192..383) sub-rectangles, each rendered to the viewport position defined by the skin's DeltaSkinScreen.outputFrame. Part of #3373 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 4e44eab commit 98d0986

7 files changed

Lines changed: 496 additions & 4 deletions

File tree

Cores/Desmume2015/PVDesmume2015Core/Core/PVDesmume2015Core.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ import PVCoreBridgeRetro
1515
@objc
1616
@objcMembers
1717
open class PVDesmume2015Core: PVEmulatorCore {
18-
/// Dual-screen skin layouts are not yet supported; disable until implemented.
19-
public override var supportsSkins: Bool { false }
18+
/// Metal sub-rectangle dual-screen rendering is now implemented.
19+
public override var supportsSkins: Bool { true }
2020

2121
public override var supportsDualScreens: Bool { true }
2222

Cores/melonDS/PVMelonDSCore/Core/PVMelonDSCore.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ import PVCoreBridgeRetro
1818
// to the melonDS run-loop thread (main thread). PVMelonDSCore+PVNetplayCapable
1919
// dispatches via MainActor.run to preserve this invariant.
2020
public final class PVMelonDSCore: PVEmulatorCore, @unchecked Sendable {
21-
/// Dual-screen skin layouts are not yet supported; disable until implemented.
22-
public override var supportsSkins: Bool { false }
21+
/// Metal sub-rectangle dual-screen rendering is now implemented.
22+
public override var supportsSkins: Bool { true }
2323

2424
public override var supportsDualScreens: Bool { true }
2525

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// dual_screen_blit.metal
2+
// PVShaders
3+
//
4+
// Metal shaders for DS dual-screen sub-rectangle blitting.
5+
//
6+
// Each screen is rendered by a separate draw call in a single render pass.
7+
// The vertex buffer carries interleaved (NDC-position, UV) data so the vertex
8+
// function needs no uniforms — all per-screen geometry is baked in by the CPU.
9+
//
10+
// Usage pattern (per screen):
11+
// 1. Compute 4 vertices of the form float4(ndcX, ndcY, srcU, srcV)
12+
// arranged as a triangle-strip (TL, TR, BL, BR).
13+
// 2. Call setVertexBytes(_:length:index:) with buffer index 0.
14+
// 3. drawPrimitives(.triangleStrip, vertexStart: 0, vertexCount: 4)
15+
//
16+
// The fragment shader samples 'source' at the UV carried from the vertex stage.
17+
// flipY is handled on the CPU (swap V0/V1 when the texture is OpenGL-origin).
18+
19+
#include <metal_stdlib>
20+
using namespace metal;
21+
22+
struct DualScreenVSOut {
23+
float4 position [[position]];
24+
float2 texCoord;
25+
};
26+
27+
// vertices[vid] = (NDC.x, NDC.y, UV.u, UV.v)
28+
vertex DualScreenVSOut dual_screen_vs(
29+
uint vid [[vertex_id]],
30+
constant float4 *vertices [[buffer(0)]])
31+
{
32+
DualScreenVSOut out;
33+
out.position = float4(vertices[vid].xy, 0.0f, 1.0f);
34+
out.texCoord = vertices[vid].zw;
35+
return out;
36+
}
37+
38+
fragment half4 dual_screen_ps(
39+
DualScreenVSOut in [[stage_in]],
40+
texture2d<half> source [[texture(0)]],
41+
sampler samp [[sampler(0)]])
42+
{
43+
half4 color = source.sample(samp, in.texCoord);
44+
color.a = 1.0h;
45+
return color;
46+
}

PVUI/Sources/PVUIBase/PVEmulatorVC/PVEmulatorViewController+DualScreen.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,20 @@ extension PVEmulatorViewController {
213213
return
214214
}
215215

216+
// Metal dual-screen path: for DS cores (melonDS, DeSmuME) with an active
217+
// skin, hand the sub-rectangle layout to PVMetalViewController so it can
218+
// render both screens in a single GPU pass instead of resizing the view.
219+
if canUseMetalDualScreenRendering {
220+
if applyMetalDualScreenLayout() {
221+
DLOG("🎮 Applied Metal dual-screen layout")
222+
return
223+
}
224+
// Layout computation failed — fall through to the legacy path.
225+
} else {
226+
// Skin disabled or core not Metal-based; clear any stale layout.
227+
clearMetalDualScreenLayout()
228+
}
229+
216230
// Ensure GPU view is visible and properly layered before positioning
217231
ensureGPUViewVisibilityAndZOrder()
218232

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
// PVEmulatorViewController+MetalDualScreen.swift
2+
// PVUI
3+
//
4+
// Bridges the DeltaSkin layout system to the Metal dual-screen sub-rectangle
5+
// renderer in PVMetalViewController.
6+
//
7+
// When a dual-screen game (e.g. Nintendo DS) is running with an active skin:
8+
// 1. The skin describes where each screen should appear via DeltaSkinScreen.
9+
// 2. This extension computes DualScreenRenderInfo values from those descriptions
10+
// and installs them on PVMetalViewController.
11+
// 3. PVMetalViewController then splits the combined framebuffer (e.g. 256×384
12+
// for DS) into two independently-positioned viewports in a single pass.
13+
//
14+
// Called from applyDualScreenViewport() in PVEmulatorViewController+DualScreen.
15+
16+
import UIKit
17+
import PVEmulatorCore
18+
import PVLogging
19+
20+
// MARK: - Metal Dual-Screen Layout
21+
22+
extension PVEmulatorViewController {
23+
24+
// MARK: Public API
25+
26+
/// Returns `true` when all conditions for Metal sub-rectangle dual-screen
27+
/// rendering are met:
28+
/// • the GPU view controller is a `PVMetalViewController`
29+
/// • the emulator core declares dual-screen support
30+
/// • a DeltaSkin with screen-group data is active
31+
/// • we are NOT running on tvOS (skins are disabled there)
32+
var canUseMetalDualScreenRendering: Bool {
33+
#if os(tvOS)
34+
return false
35+
#else
36+
guard gpuViewController is PVMetalViewController else { return false }
37+
guard core.supportsDualScreens else { return false }
38+
guard isDeltaSkinEnabled, currentSkin != nil else { return false }
39+
return true
40+
#endif
41+
}
42+
43+
/// Computes the Metal dual-screen layout from the current skin and installs it
44+
/// on the `PVMetalViewController`. Also expands the Metal view to fill the
45+
/// parent view so Metal can position each screen freely.
46+
///
47+
/// Call this instead of `applyFrameToGPUView` when `canUseMetalDualScreenRendering`
48+
/// is `true`.
49+
///
50+
/// - Returns: `true` if the layout was successfully applied.
51+
@discardableResult
52+
func applyMetalDualScreenLayout() -> Bool {
53+
guard let metalVC = gpuViewController as? PVMetalViewController else { return false }
54+
guard isDeltaSkinEnabled, let skin = currentSkin else { return false }
55+
56+
#if !os(tvOS)
57+
let skinDevice: DeltaSkinDevice = UIDevice.current.userInterfaceIdiom == .pad ? .ipad : .iphone
58+
#else
59+
let skinDevice: DeltaSkinDevice = .tv
60+
#endif
61+
let orientation: DeltaSkinOrientation = view.bounds.width > view.bounds.height ? .landscape : .portrait
62+
let traits = DeltaSkinTraits(device: skinDevice,
63+
displayType: .standard,
64+
orientation: orientation,
65+
gameIdentifier: game?.title)
66+
67+
// We need a skin with at least two screens in a screen group.
68+
guard let screenGroups = skin.screenGroups(for: traits),
69+
let group = screenGroups.first,
70+
group.screens.count >= 2 else {
71+
DLOG("dual-screen metal: skin has no screen groups with 2+ screens, falling back")
72+
metalVC.dualScreenLayout = nil
73+
return false
74+
}
75+
76+
// Input-texture dimensions — used to normalise the skin's inputFrame.
77+
let bufferSize = core.bufferSize
78+
let texW = bufferSize.width > 0 ? bufferSize.width : 256
79+
let texH = bufferSize.height > 0 ? bufferSize.height : 384
80+
81+
// View layout parameters (mirrors currentDualScreenViewportFrame()).
82+
let viewSize = view.bounds.size
83+
guard viewSize.width > 0, viewSize.height > 0 else { return false }
84+
85+
guard let mappingSize = skin.mappingSize(for: traits),
86+
mappingSize.width > 0, mappingSize.height > 0 else { return false }
87+
88+
// Scale factor: fit the skin's mapping size into the view.
89+
// Mirrors the calculation in currentDualScreenViewportFrame().
90+
let isPortraitPhone = skinDevice == .iphone && orientation == .portrait
91+
let scale: CGFloat
92+
if isPortraitPhone {
93+
let ws = viewSize.width / mappingSize.width
94+
let hs = viewSize.height / mappingSize.height
95+
scale = ws * mappingSize.height <= viewSize.height ? ws : min(ws, hs)
96+
} else {
97+
scale = min(viewSize.width / mappingSize.width,
98+
viewSize.height / mappingSize.height)
99+
}
100+
101+
let scaledW = mappingSize.width * scale
102+
let scaledH = mappingSize.height * scale
103+
let xOff = (viewSize.width - scaledW) / 2
104+
let yOff: CGFloat = isPortraitPhone ? viewSize.height - scaledH
105+
: (viewSize.height - scaledH) / 2
106+
107+
// Sort screens top-to-bottom by their outputFrame Y value.
108+
let sorted = group.screens.sorted { a, b in
109+
(a.outputFrame?.minY ?? 0) < (b.outputFrame?.minY ?? 0)
110+
}
111+
112+
var renderInfos: [DualScreenRenderInfo] = []
113+
114+
for (index, screen) in sorted.enumerated() {
115+
guard let outputFrame = screen.outputFrame else { continue }
116+
117+
// --- Source UV ---
118+
// Use skin-specified inputFrame if available; otherwise default to
119+
// equal halves of the combined framebuffer (DS convention).
120+
let srcRect: CGRect
121+
if let inFrame = screen.inputFrame, inFrame.width > 0, inFrame.height > 0 {
122+
srcRect = CGRect(x: inFrame.minX / texW,
123+
y: inFrame.minY / texH,
124+
width: inFrame.width / texW,
125+
height: inFrame.height / texH)
126+
} else {
127+
// Default: split the framebuffer into equal vertical halves.
128+
let halfH: CGFloat = 0.5
129+
srcRect = CGRect(x: 0, y: CGFloat(index) * halfH, width: 1, height: halfH)
130+
}
131+
132+
// --- Destination (view-space points) ---
133+
// Mirror the calculation in currentDualScreenViewportFrame() exactly.
134+
let inLayout = CGRect(x: outputFrame.minX * scaledW,
135+
y: outputFrame.minY * scaledH,
136+
width: outputFrame.width * scaledW,
137+
height: outputFrame.height * scaledH)
138+
let destRect = CGRect(x: xOff + inLayout.midX - inLayout.width / 2,
139+
y: yOff + inLayout.midY - inLayout.height / 2,
140+
width: inLayout.width,
141+
height: inLayout.height)
142+
143+
renderInfos.append(DualScreenRenderInfo(normalizedSourceRect: srcRect,
144+
viewDestRect: destRect))
145+
}
146+
147+
guard renderInfos.count >= 2 else {
148+
DLOG("dual-screen metal: could not compute 2+ render infos, falling back")
149+
metalVC.dualScreenLayout = nil
150+
return false
151+
}
152+
153+
ILOG("dual-screen metal: installing layout with \(renderInfos.count) screens")
154+
metalVC.dualScreenLayout = renderInfos
155+
156+
// Expand the Metal view to fill the parent so both screen quads are visible.
157+
expandMetalViewToFillParent(metalVC)
158+
return true
159+
}
160+
161+
/// Removes the Metal dual-screen layout (reverts to standard fullscreen blit).
162+
func clearMetalDualScreenLayout() {
163+
(gpuViewController as? PVMetalViewController)?.dualScreenLayout = nil
164+
}
165+
166+
// MARK: Helpers
167+
168+
/// Expands the Metal view controller's view to fill `self.view` so the dual-screen
169+
/// quads can be positioned freely anywhere within the screen.
170+
private func expandMetalViewToFillParent(_ metalVC: PVMetalViewController) {
171+
let fullFrame = view.bounds
172+
173+
(metalVC as PVGPUViewController).useCustomPositioning = true
174+
(metalVC as PVGPUViewController).customFrame = fullFrame
175+
176+
metalVC.view.autoresizingMask = []
177+
metalVC.mtlView.autoresizingMask = []
178+
179+
UIView.performWithoutAnimation {
180+
metalVC.view.frame = fullFrame
181+
metalVC.mtlView.frame = metalVC.view.bounds
182+
}
183+
184+
let drawScale = metalVC.renderSettings.nativeScaleEnabled
185+
? (metalVC.view.window?.screen.scale ?? UIScreen.main.scale)
186+
: 1.0
187+
metalVC.mtlView.drawableSize = CGSize(width: fullFrame.width * drawScale,
188+
height: fullFrame.height * drawScale)
189+
metalVC.mtlView.contentScaleFactor = drawScale
190+
metalVC.view.isHidden = false
191+
metalVC.mtlView.isHidden = false
192+
193+
ensureGPUViewVisibilityAndZOrder()
194+
195+
DLOG("dual-screen metal: expanded Metal view to full frame \(fullFrame)")
196+
}
197+
}

0 commit comments

Comments
 (0)