Skip to content

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:

  1. Explicit — the game populates world 1. Engine.GetWorld(2):SetRootNode(node) or Engine.GetWorld(2):LoadScene("BottomHUD"). This world is rendered on screen 1 as-is, with no screen filtering. It always wins.
  2. Authored — Target Screen on scene roots. If world 1 has no root node, the render loop renders world 0 again for screen 1, and Renderer sets mTargetScreenFilter = screenIndex so each scene-root subtree is pruned to the screen it was assigned in the editor. This is what makes a scene authored with TopScreen / BottomScreen child 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)

GetScreenIndex and GetActiveScreenResolution are not exposed to Lua. Both resolve through Renderer::mScreenIndex, which is assigned inside Renderer::Render and never reset afterwards. Every Lua entry point runs during World::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 logging GetScreenIndex() from scripts on two different screens printed 0 both times. The C++ accessors remain and are used by Widget and Text during rendering, where they are correct.

For the same reason Renderer.GetScreenResolution requires 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. GatherDrawData checks node->GetParent() == world->GetRootNode(). Setting Target Screen on a deeper node does nothing.
  • It is not inherited. There is no resolved/effective-screen walk — a Camera3D or Canvas nested inside a bottom-screen scene still reports 0. That is why per-screen camera lookup goes through World::FindCameraForScreen, which matches by subtree membership rather than the camera's own value. The one exception is UIDocument::Mount, which push-copies the parent's value onto a mounted widget tree.
  • The inspector only exposes it on instantiated child scenes. EditorImgui.cpp strips the property row for any node where IsSceneLinked(false) is false, so artists set it on the scene instance in the hierarchy and never need to set it on an inner Canvas. Node::GatherProperties still emits it unconditionally — Scene::Instantiate gathers 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