3DS Dual-Screen System
Overview
The Nintendo 3DS has two physical screens:
| Screen | Resolution | Features |
|---|---|---|
| Top | 400 x 240 | Stereoscopic 3D (left/right eye rendering) |
| Bottom | 320 x 240 | Touch input, mono only |
The hardware 3D slider on the side of the console controls stereoscopic depth. When the slider is at 0, the top screen renders a single mono view. When the slider is above 0, the top screen renders two views (left eye and right eye) to produce a stereoscopic 3D effect.
The New Nintendo 3DS variant is detected at startup via APT_CheckNew3DS(), which enables CPU speedup (osSetSpeedupEnable(true)).
Architecture: World-per-Screen
On 3DS, Polyphase creates two World objects at startup -- one per screen:
World 0 --> Top screen (screen index 0)
World 1 --> Bottom screen (screen index 1)
Each world has its own independent scene graph, camera, physics simulation, and node hierarchy. During the render loop, the engine iterates over both worlds:
for (int32_t i = 0; i < int32_t(sWorlds.size()); ++i)
{
Renderer::Get()->Render(sWorlds[i], i);
}
The world index directly maps to the screen index. This mapping is fixed and cannot be changed at runtime.
The SUPPORTS_SECOND_SCREEN constant is set to 1 only for the Citro3D graphics backend (3DS). All other backends set it to 0.
Stereoscopic 3D
How It Works
The 3DS graphics backend creates three render targets:
| Render Target | Size (W x H) | Output |
|---|---|---|
mRenderTargetLeft |
240 x 400 | GFX_TOP, GFX_LEFT |
mRenderTargetRight |
240 x 400 | GFX_TOP, GFX_RIGHT |
mRenderTargetBottom |
240 x 320 | GFX_BOTTOM, GFX_LEFT |
Note: Render target dimensions are swapped (height x width) because the 3DS screen is physically rotated.
3D Slider and IOD
Each frame, SYS_Update() reads the 3D slider position via osGet3DSliderState() and stores it in SystemState::mSlider (a float, roughly 0.0 to 1.0).
The interocular distance (IOD) is computed from the slider value:
IOD = slider / 3
- Left eye (view 0): IOD is negated (
IOD * -1) - Right eye (view 1): IOD is used as-is
The IOD feeds into Mtx_PerspStereoTilt() to produce offset perspective matrices for each eye, creating the stereoscopic depth effect.
View Count
GFX_GetNumViews() returns:
- 2 for the top screen (screen 0) when the 3D slider is above 0
- 1 otherwise (slider at 0, or bottom screen)
The bottom screen always renders a single view.
Screen Dimensions
Screen dimensions are stored in EngineState:
| Field | Value | Description |
|---|---|---|
mWindowWidth |
400 | Top screen width |
mWindowHeight |
240 | Top screen height |
mSecondWindowWidth |
320 | Bottom screen width |
mSecondWindowHeight |
240 | Bottom screen height |
These values are set during SYS_Initialize() and do not change at runtime. The scissor test in the graphics backend uses these widths (400 vs 320) to correctly clip rendering for each screen.
Lua API
Getting a World by Screen
Use Engine.GetWorld(index) to get the world associated with a screen. Lua uses 1-based indexing:
local topWorld = Engine.GetWorld(1) -- World for top screen
local bottomWorld = Engine.GetWorld(2) -- World for bottom screen
Loading Scenes to a Screen
Each world can load its own scene independently:
-- Load a gameplay scene on the top screen
Engine.GetWorld(1):LoadScene("Gameplay")
-- Load a HUD/map scene on the bottom screen
Engine.GetWorld(2):LoadScene("BottomHUD")
Querying Screen State
-- Get the screen index currently being rendered (0 or 1)
local screenIdx = Renderer.GetScreenIndex()
-- Get resolution of a specific screen (1-indexed)
local topRes = Renderer.GetScreenResolution(1) -- Vector(400, 240)
local botRes = Renderer.GetScreenResolution(2) -- Vector(320, 240)
-- Get resolution of the screen currently being rendered
local activeRes = Renderer.GetActiveScreenResolution()
Adaptive UI Example
Because the two screens have different widths (400 vs 320), you may need to adjust widget layouts:
function OnStart(self)
local res = Renderer.GetActiveScreenResolution()
-- Center a widget horizontally regardless of screen width
local widget = self:FindChild("StatusBar")
widget:SetPosition(res.x / 2, widget:GetPosition().y)
end
New 3DS Detection
The SystemState::mNew3DS boolean indicates whether the game is running on a New Nintendo 3DS. This is detected at startup via APT_CheckNew3DS().
On a New 3DS, the engine enables CPU speedup (osSetSpeedupEnable(true)) automatically. The platform tier reported by SYS_GetPlatformTier() is:
- 1 on New 3DS
- 0 on original 3DS
Limitations
The following aspects are hardcoded and cannot be changed at runtime:
- Screen resolutions -- 400x240 (top) and 320x240 (bottom) are fixed by hardware
- Number of screens -- always 2 on 3DS
- World-to-screen mapping -- world index always equals screen index
- Stereoscopic 3D -- always enabled on the top screen; the hardware slider is the only control
- No wide mode or screen layout configuration API
What developers can control:
- Which scene loads on which screen (via
Engine.GetWorld(index):LoadScene()) - Per-screen camera setup (each world has its own active camera)
- Querying screen dimensions and current screen index for adaptive UI
- Independent scene graphs, physics, and node hierarchies per world
Editor 3DS Preview Filtering
How It Works
During Play-In-Editor, all scenes live in a single shared game world. The 3DS Preview panel renders this world twice (once per screen), using mTargetScreenFilter on the Renderer to filter nodes by their mTargetScreen property at the scene-root level.
The filter is applied in GatherDrawData(): when mTargetScreenFilter >= 0, direct children of the world root whose GetTargetScreen() does not match the filter are skipped entirely (including their whole subtree of 3D nodes and widgets).
On actual 3DS hardware, the two worlds are already separate, so the filter acts as a safety net (set to screenIndex via SUPPORTS_SECOND_SCREEN).
Scene Panel Screen Filter
The Scene Panel includes a Screen Filter combo dropdown with three options:
- All Screens (default) -- shows all nodes in both the hierarchy and viewport
- Top Screen -- only shows subtrees whose root has
mTargetScreen == 0 - Bottom Screen -- only shows subtrees whose root has
mTargetScreen == 1
This filter affects:
- The scene hierarchy tree (subtrees are hidden)
- The editor viewport (via mTargetScreenFilter in GatherDrawData())
During PIE, the viewport filter is disabled (shows everything), but the 3DS Preview panel still applies per-screen filtering.
Target Screen Property
Set mTargetScreen on scene root nodes to control which 3DS screen the subtree renders on. The convention matches FindSceneForScreen(): root children with GetTargetScreen() == 0 are top-screen, 1 are bottom-screen.
UIDocument Interaction
When a UIDocument is mounted to a widget via UIDocument::Mount(), the entire widget tree inherits the parent's mTargetScreen value. This ensures consistency even though the scene-root subtree filter already handles rendering.
Key Source Files
| File | Contents |
|---|---|
Engine/Source/System/3DS/System_3DS.cpp |
Platform init, 3D slider read, New 3DS detection |
Engine/Source/Graphics/C3D/Graphics_C3D.cpp |
Render targets, stereo rendering, scissor/viewport |
Engine/Source/Graphics/C3D/C3dTypes.h |
C3dContext struct (render targets, IOD, current screen) |
Engine/Source/Graphics/GraphicsConstants.h |
SUPPORTS_SECOND_SCREEN constant |
Engine/Source/Engine/Engine.cpp |
World creation and render loop |
Engine/Source/Engine/Renderer.cpp |
Screen index tracking, resolution queries, mTargetScreenFilter |
Engine/Source/Engine/EngineTypes.h |
mWindowWidth/Height, mSecondWindowWidth/Height |
Engine/Source/System/SystemTypes.h |
SystemState 3DS fields (mSlider, mNew3DS) |
Engine/Source/LuaBindings/Renderer_Lua.cpp |
GetScreenIndex, GetScreenResolution bindings |
Engine/Source/LuaBindings/Engine_Lua.cpp |
Engine.GetWorld() binding |
Engine/Source/Editor/SecondScreenPreview/SecondScreenPreview.cpp |
3DS Preview panel, PIE screen filtering |
Engine/Source/Editor/EditorState.h |
mSceneScreenFilter for Scene Panel filter |