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.
Resolution order
At startup the engine loads the default scene into world 0 only (Engine.cpp,
the #if !EDITOR block). World 1 is created but stays empty unless the game fills
it. So there are two ways content reaches the bottom screen, and they compose:
- Explicit — the game populates world 1.
Engine.GetWorld(2):SetRootNode(node)orEngine.GetWorld(2):LoadScene("BottomHUD"). This world is rendered on screen 1 as-is, with no screen filtering. It always wins. - Authored — Target Screen on scene roots. If world 1 has no root node, the
render loop renders world 0 again for screen 1, and
RenderersetsmTargetScreenFilter = screenIndexso each scene-root subtree is pruned to the screen it was assigned in the editor. This is what makes a scene authored withTopScreen/BottomScreenchild scenes work on hardware without any script.
In case 2 the world's single active camera can't serve both screens, so
Renderer::Render temporarily installs World::FindCameraForScreen(screenIndex)
as the world's camera override for the duration of that screen's pass.
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
-- Which screen a node renders on (TargetScreen.Top / .Bottom / .All)
local screen = someNode:GetTargetScreen()
-- Resolution of the screen that node is on
local res = someNode:GetScreenResolution()
-- Or a specific screen by index (1-indexed, matching Engine.GetWorld)
local topRes = Renderer.GetScreenResolution(1) -- Vector(400, 240)
local botRes = Renderer.GetScreenResolution(2) -- Vector(320, 240)
GetScreenIndexandGetActiveScreenResolutionare not exposed to Lua. Both resolve throughRenderer::mScreenIndex, which is assigned insideRenderer::Renderand never reset afterwards. Every Lua entry point runs duringWorld::Update, before the render loop, so from a script they could only ever return a stale value from the previous frame. In the editor that value settles back to 0 every frame, which is why loggingGetScreenIndex()from scripts on two different screens printed0both times. The C++ accessors remain and are used byWidgetandTextduring rendering, where they are correct.For the same reason
Renderer.GetScreenResolutionrequires an explicit index. Omitting it previously fell through to the currently-rendering screen.
Note that Lua's Node:GetTargetScreen() maps to C++ Node::GetEffectiveTargetScreen(), not to the
C++ getter of the same name. The raw property is only authored on scene-root-level nodes, so
exposing it directly would report Top from every node nested inside a bottom-screen scene. Script
code only ever wants the resolved answer, so that is what the single Lua name gives it.
Adaptive UI Example
Because the two screens have different widths (400 vs 320), you may need to adjust widget layouts. Ask the node for its own screen size:
function StatusBar:Start()
local res = self:GetScreenResolution()
-- Center a widget horizontally regardless of screen width
local widget = self:FindChild("StatusBar")
widget:SetPosition(res.x / 2, widget:GetPosition().y)
end
A node set to TargetScreen.All (255) has no single resolution, so GetScreenResolution reports
the main screen for it.
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 (an empty world 1 falls back to rendering world 0 with the Target Screen filter; it cannot be remapped)
- 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 Renderer::Render arms the same filter with mTargetScreenFilter = screenIndex (guarded by SUPPORTS_SECOND_SCREEN && !EDITOR), so the device and the preview panel apply identical rules. When the game has populated world 1 explicitly, that world holds only its own content and the filter is a no-op for it.
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. 0xFF means "all screens" (Skybox3D sets it in its constructor).
Three constraints follow from how the filter works, and they are easy to trip over:
- It is only read on direct children of the world root.
GatherDrawDatachecksnode->GetParent() == world->GetRootNode(). Setting Target Screen on a deeper node does nothing. - It is not inherited. There is no resolved/effective-screen walk — a
Camera3DorCanvasnested inside a bottom-screen scene still reports0. That is why per-screen camera lookup goes throughWorld::FindCameraForScreen, which matches by subtree membership rather than the camera's own value. The one exception isUIDocument::Mount, which push-copies the parent's value onto a mounted widget tree. - The inspector only exposes it on instantiated child scenes.
EditorImgui.cppstrips the property row for any node whereIsSceneLinked(false)is false, so artists set it on the scene instance in the hierarchy and never need to set it on an innerCanvas.Node::GatherPropertiesstill emits it unconditionally —Scene::Instantiategathers properties before the node is parented, so gating the gather itself would silently drop every stored override on load.
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, per-screen camera override |
Engine/Source/Engine/World.cpp |
World::FindCameraForScreen (subtree-based per-screen camera lookup) |
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 |