Dynamic Split-Screen in Godot 4: GPU Shader-Based Approach
1. The Problem
Traditional split-screen in local co-op games has always been a compromise. Two players on one screen usually means either a fixed vertical/horizontal divider — permanently sacrificing half the screen per player — or a shared camera that zooms out to keep everyone in frame, shrinking everything to the point of illegibility.
Our game _Runners_ is a ship-driving game where two players explore islands, board vessels, and navigate open water together. Most of the time, players stick close to each other: boarding the same ship, walking through the same dock, fighting the same enemies. In these moments, splitting the screen wastes real estate. Both cameras render nearly identical views, and the divider is just dead pixels.
But players also separate — sometimes by design, sometimes by accident. One player drives the boat while the other scouts an island. When that happens, a single shared camera zooms out to keep both players visible, and characters become ant-sized on screen.
Dynamic split-screen solves both problems: merge the views when players are close, split them when they separate. The transition is smooth and continuous — no jarring cuts, no loading screens, no configuration toggles. It just works.
This post covers our implementation, which is based on Benjamin Navarro’s algorithm (later adopted as the official Godot dynamic split-screen demo). We adapted it to Godot 4, added occlusion handling, per-player camera configuration, and integrated it into an existing isometric camera system without breaking anything.
2. Three Layers
The system has three layers:
| Layer | Location | Responsibility |
| Scene | dynamic_split_screen.tscn |
Two SubViewport nodes, each with a Camera3D + RayCast3D child. A CanvasLayer with a full-screen ColorRect + ShaderMaterial renders the final composite. |
| Script | dynamic_split_screen.gd |
Player detection, camera positioning on the segment between players, split-state management, occlusion fading, and shader uniform updates every frame. |
| Shader | shaders/dynamic_split_screen.gdshader |
Per-pixel blending of two viewport textures. Computes the dynamic split line, decides which viewport to sample per pixel, draws debugging overlays. |
The architecture is cleanly separated: the scene holds the render targets and the overlay, the script is the brain, and the shader is the GPU-side blender. The scene is a drop-in Node3D — add it to any level and it works independently of your existing camera setup. It does not require you to modify your player nodes, your world, or your rendering pipeline.
3. Scene Setup
Here’s the node tree of dynamic_split_screen.tscn (58 lines of .tscn, rendered here as pseudocode):
DynamicSplitScreen (Node3D)
├── Viewport1 (SubViewport)
│ ├── Camera1 (Camera3D, current=true)
│ └── OcclusionRay (RayCast3D)
├── Viewport2 (SubViewport)
│ ├── Camera2 (Camera3D, current=true)
│ └── OcclusionRay (RayCast3D)
└── SplitScreenCanvas (CanvasLayer, layer=128)
└── OverlayRoot (Control, full-screen, mouse_filter=IGNORE)
└── SplitScreenOverlay (ColorRect, full-screen, ShaderMaterial)
Both SubViewports render at full screen resolution. This is important — the shader blends per-pixel, so each viewport must match the output resolution exactly. Both cameras have current = true, meaning SubViewport respects the flag internally but the shader decides which sees the final screen.
CanvasLayer at layer 128 ensures the overlay renders above all game UI (which typically lives at layers 1-10 in Godot 4). The mouse_filter = IGNORE on the OverlayRoot and ColorRect means clicks pass through to the game underneath — the split-screen overlay is visual-only.
The scene is a drop-in. You can instantiate dynamic_split_screen.tscn in any level and it will auto-detect players (by NodePath, slot metadata, or group membership — see Section 6). It inherits the world’s World3D environment, so lighting, fog, and post-processing apply identically to both viewports.
4. The Shader
The shader is the heart of the system. It runs as a canvas_item on the full-screen ColorRect, and for every pixel on screen, it decides: should this pixel come from Viewport1 or Viewport2?
4.1 Uniforms
uniform vec2 viewport_size; // Screen resolution (e.g., 1920x1080)
uniform sampler2D viewport1 : source_color;
uniform sampler2D viewport2 : source_color;
uniform bool split_active; // Merged (false) or split (true)
uniform vec2 player1_position; // Player 1 UV position on screen (0-1)
uniform vec2 player2_position; // Player 2 UV position on screen
uniform float split_line_thickness; // Pixels (0 = hidden line)
uniform vec4 split_line_color : source_color;
uniform bool debug_solid_split; // Force 50/50 vertical split
uniform bool debug_show_colors; // Tint view1 red, view2 blue
uniform bool debug_force_red; // Entire screen red
uniform bool debug_show_centers; // Draw position markers
The only uniforms that change every frame are player1_position, player2_position, split_active, and split_line_thickness. The render target textures (viewport1, viewport2) are set once at startup and never change — Godot’s SubViewport.get_texture() returns a texture that the GPU updates in-place.
4.2 Perpendicular Bisector Logic
When split_active = true, the shader computes a line through the screen center that is perpendicular to the vector between the two player positions. Pixels on one side of the line come from Viewport1; pixels on the other side come from Viewport2.
The math:
Player positions in UV space: p1 = (x1, y1), p2 = (x2, y2)
Vector between them: d = (x2 - x1, y2 - y1)
Perpendicular bisector slope: m = d.x / d.y (negated by line equation form)
Line passes through screen center: origin = (0.5, 0.5)
The shader computes the split line’s intersection with the left and right screen edges, then for each pixel computes the distance to that line. If the distance is less than split_line_thickness, it draws the line color. Otherwise, it determines which side the pixel is on by comparing the pixel’s y-coordinate against the line’s y at the same x:
// Compute perpendicular bisector slope
float split_slope;
if (abs(dx.y) > 0.0001) {
split_slope = dx.x / dx.y;
} else {
split_slope = 100000.0; // Near-vertical line guard
}
// Compute line segment from left edge to right edge
vec2 split_line_start = vec2(0.0, height * ((0.5 - 0.0) * split_slope + 0.5));
vec2 split_line_end = vec2(width, height * ((0.5 - 1.0) * split_slope + 0.5));
// Distance from current pixel to split line
float dist = distance_to_line(split_line_start, split_line_end, pixel_pos);
The distance_to_line() function uses the standard point-to-line formula:
float distance_to_line(vec2 p1, vec2 p2, vec2 point) {
float a = p1.y - p2.y;
float b = p2.x - p1.x;
float denom = sqrt(a * a + b * b);
if (denom < 0.0001) {
return 0.0; // Degenerate line guard
} else {
return abs(a * point.x + b * point.y + p1.x * p2.y - p2.x * p1.y) / denom;
}
}
4.3 Side Determination
Once the distance is computed, the shader either draws the line or picks a viewport:
if (dist < split_line_thickness) {
COLOR = split_line_color; // Draw the dividing line
} else {
// Determine which side of the line this pixel is on
float split_current_y = (0.5 - UV.x) * split_slope + 0.5;
float split_p1_y = (0.5 - player1_position.x) * split_slope + 0.5;
if (UV.y > split_current_y) {
if (player1_position.y > split_p1_y) {
COLOR = vec4(view1, 1.0); // Same side as player 1
} else {
COLOR = vec4(view2, 1.0); // Same side as player 2
}
} else {
if (player1_position.y < split_p1_y) {
COLOR = vec4(view1, 1.0);
} else {
COLOR = vec4(view2, 1.0);
}
}
}
The side determination works by checking whether the pixel and player 1 are on the same side of the split line. If the pixel’s y relative to the line matches player 1’s y relative to the line, the pixel belongs to Viewport1. Otherwise, it belongs to Viewport2.
4.4 Proximity Merging
When split_active = false — meaning the two players are within max_separation of each other — the shader skips all split-line math and outputs Viewport1’s texture directly:
if (!split_active) {
COLOR = vec4(view1, 1.0);
}
This is what creates the “merged” view. Both cameras are positioned at the midpoint between the two players (see Section 5), so they render identical scenes. Outputting only Viewport1 avoids sampling Viewport2 entirely, saving both GPU texture reads and the compute of the split line.
4.5 Debug Modes
The shader includes four debug modes, all controllable from the inspector at runtime:
| Mode | Shader Behavior |
debug_solid_split |
Ignores player positions. Splits screen vertically at 50%. Useful for verifying that both viewports are rendering correctly. |
debug_show_colors |
Tints Viewport1 red and Viewport2 blue. Makes it obvious which side of the split each viewport occupies. |
debug_force_red |
Fills the entire screen with solid red — immediately confirms the shader is active. |
debug_show_centers |
Draws green/yellow circles at player1/player2 UV positions, a white dot at screen center, and cyan dots at the quarter-split points. Invaluable for verifying that UV coordinates are correct. |
The center markers are particularly useful during development. They draw directly in the fragment shader:
if (debug_show_centers) {
float pixel_range = viewport_size.x * 0.015;
float d1 = length(UV - player1_position) * viewport_size.x;
float d2 = length(UV - player2_position) * viewport_size.x;
float dc = length(UV - vec2(0.5, 0.5)) * viewport_size.x;
if (d1 < pixel_range) {
COLOR = mix(COLOR, vec4(0.0, 1.0, 0.0, 1.0), 0.8); // P1: green
}
if (d2 < pixel_range) {
COLOR = mix(COLOR, vec4(1.0, 1.0, 0.0, 1.0), 0.8); // P2: yellow
}
if (dc < pixel_range * 0.6) {
COLOR = mix(COLOR, vec4(1.0, 1.0, 1.0, 1.0), 0.9); // Center: white
}
}
5. The Controller Script
dynamic_split_screen.gd (325 lines) is a class_name DynamicSplitScreen attached to the root Node3D. It runs in _process() and updates camera positions, split state, and shader uniforms every frame.
5.1 Player Detection (Three-Tier Fallback)
The script needs references to two player Node3D nodes. It uses a three-tier fallback:
func _resolve_players() -> void:
# Tier 1: Explicit NodePath from inspector
if player1_path != NodePath():
_player1 = get_node_or_null(player1_path) as Node3D
if player2_path != NodePath():
_player2 = get_node_or_null(player2_path) as Node3D
# Tier 2: Slot metadata (local_player_slot = 0 or 1)
if not _player1:
_player1 = _find_player_by_slot(0)
if not _player2:
_player2 = _find_player_by_slot(1)
# Tier 3: First two nodes in "player" group
if not _player1 or not _player2:
var all := _get_all_players()
if not _player1 and all.size() > 0:
_player1 = all[0]
if not _player2 and all.size() > 1:
_player2 = all[1]
This means the split-screen scene works with zero configuration in most setups. If your players are in the player group (Godot’s built-in grouping system), they’re detected automatically. Slot metadata is used when available; otherwise, insertion order determines P1/P2.
5.2 Camera Positioning: The Segment Math
The core insight from Navarro’s algorithm: cameras don’t follow players directly. Instead, both cameras sit on the line segment connecting the two players. When players are close, both cameras converge at the midpoint — producing identical views. When players separate, the cameras push apart but are clamped to max_separation, ensuring the split view never zooms out too far.
func _move_cameras() -> void:
# Horizontal-only difference between players
var diff := _player2.global_position - _player1.global_position
diff.y = 0.0
var horizontal_dist := diff.length()
# Clamp separation to max_separation
var clamped_dist := clampf(horizontal_dist, 0.0, max_separation)
var clamped_diff := diff
if horizontal_dist > 0.001:
clamped_diff = diff.normalized() * clamped_dist
else:
clamped_diff = Vector3.ZERO # Guard: zero distance
# Camera horizontal positions on the segment
var cam1_h := _player1.global_position + clamped_diff / 2.0
var cam2_h := _player2.global_position - clamped_diff / 2.0
# Apply per-player offset + basis
_camera1.global_position = cam1_h + cfg1.offset
_camera1.basis = cfg1.basis
_camera2.global_position = cam2_h + cfg2.offset
_camera2.basis = cfg2.basis
When horizontal_dist = 0: both cameras at the midpoint (Player1’s position = Player2’s position). Zero-distance guard prevents division by zero.
When horizontal_dist < max_separation: cameras sit between the players, producing overlapping views. Because camera1 is positioned at p1 + clamped_diff/2 and camera2 at p2 - clamped_diff/2, they converge to the midpoint when players are close.
When horizontal_dist >= max_separation: cameras are pushed to their maximum separation. Camera1 = p1 + (direction * max_separation / 2), Camera2 = p2 – (direction * max_separation / 2). The cameras are now max_separation apart horizontally, and the split line becomes fully visible.
5.3 Fixed Isometric Angle
The isometric camera basis is computed once in _ready() and never changes:
func _ready() -> void:
if camera_offset.length_squared() > 0.0001:
var look_dir := -camera_offset.normalized()
_isometric_basis = Basis.looking_at(look_dir, Vector3.UP)
else:
_isometric_basis = Basis()
This is critical. In early prototypes, we recalculated the camera rotation every frame based on the segment direction, and the resulting camera rotation jittered as players moved. The isometric perspective should remain stable — players expect the camera angle to be consistent. The basis is computed from the camera_offset vector: it looks in the opposite direction of the offset (so the camera looks _at_ the player area from above and behind).
5.4 Split Activation and Adaptive Line Thickness
Whether the screen splits depends on a single comparison:
var distance := diff.length() # Horizontal distance between players
var split_active := distance > max_separation
But the split line doesn’t appear instantly. The adaptive_split_line_thickness system fades it in over a narrow band just beyond max_separation:
if adaptive_split_line_thickness and split_active:
thickness = lerpf(0.0, split_line_thickness,
(distance - max_separation) / maxf(max_separation, 0.01))
thickness = clampf(thickness, 0.0, split_line_thickness)
else:
thickness = split_line_thickness if split_active else 0.0
As players cross the threshold, the line fades in from 0 to split_line_thickness pixels over a distance equal to max_separation. This prevents a jarring appearance of the split line. Once players have separated by 2 * max_separation or more, the line is at full thickness.
5.5 Performance: Disabling the Second Viewport
When the screen is merged (split_active = false), the second viewport stops rendering:
if split_active:
_viewport2.render_target_update_mode = SubViewport.UPDATE_ALWAYS
else:
_viewport2.render_target_update_mode = SubViewport.UPDATE_DISABLED
This means when players are together — which is most of the time in a cooperative game — we only pay for one camera render pass, one viewport texture update, and zero split-line GPU compute. The shader hits the fast path (!split_active → view1 only) and skips both texture(viewport2) and the line math.
5.6 UV Projection
Player world positions must be converted to UV coordinates (0-1 range) for the shader:
var p1_uv := _camera1.unproject_position(_player1.global_position) / screen_size
var p2_uv := _camera1.unproject_position(_player2.global_position) / screen_size
Both positions are projected through Camera1. This is intentional: both players must be in the same UV space for the split line to be meaningful. Camera1’s view serves as the reference frame. When the cameras separate, player2’s UV drifts toward the screen edge, which is exactly what we want — the split line rotates to put player2 in Viewport2’s half.
5.7 Window Resize Handling
func _on_size_changed() -> void:
_viewport_size = get_viewport().get_visible_rect().size
if _viewport1:
_viewport1.size = _viewport_size
if _viewport2:
_viewport2.size = _viewport_size
if _shader_material:
_shader_material.set_shader_parameter("viewport_size", _viewport_size)
The script connects to the viewport’s size_changed signal and resizes both SubViewports to match. This handles window resizing, fullscreen toggles, and resolution changes without artifacts.
6. Per-Player CameraConfig
Each player scene can contain a CameraConfig child node — a plain Camera3D whose position field defines a custom camera offset. This lets individual players have different camera angles without modifying the split-screen script.
func _get_player_camera_config(player: Node3D) -> Dictionary:
var cam: Camera3D = player.get_node_or_null("CameraConfig") as Camera3D
if cam and is_instance_valid(cam):
var offset: Vector3 = cam.position
var basis := _isometric_basis
if offset.length_squared() > 0.0001:
basis = Basis.looking_at(-offset.normalized(), Vector3.UP)
return {"offset": offset, "basis": basis}
return {"offset": camera_offset, "basis": _isometric_basis}
If no CameraConfig child exists, the script falls back to the global camera_offset export. The basis is computed from the offset direction — the camera always looks toward the player. The rotation set in the CameraConfig node’s inspector is ignored at runtime; only its position matters.
In our game, both P1 and P2 inherit from a base player.tscn that includes a CameraConfig child at position (0, 10, 10) — the default isometric angle. Players can diverge their camera configs independently.
7. Occlusion Fading
When a building, tree, or cliff stands between the camera and the player, that geometry should fade to transparency. Each split-screen camera has a child RayCast3D that fires from the camera position toward the player’s focus point:
func _update_occlusion(camera: Camera3D, player: Node3D) -> void:
var raycast := camera.get_node_or_null("OcclusionRay") as RayCast3D
if not raycast:
return
var focus_point := player.global_position + Vector3(0, 0.9, 0)
raycast.global_position = camera.global_position
raycast.target_position = raycast.to_local(focus_point)
raycast.force_raycast_update()
if raycast.is_colliding():
var collider := raycast.get_collider()
if collider is GeometryInstance3D and collider != player:
collider.transparency = 0.75 # Semi-transparent
# Track for restoration later
Each camera maintains its own dictionary of occluded instances. When geometry stops blocking the ray, transparency is restored to 0.0. The system uses GeometryInstance3D.transparency directly (Godot 4’s built-in alpha — no custom material or layer swap needed).
This is a simpler version of the more sophisticated occlusion system in camera_follow.gd (which supports dither shaders, multi-hit stepping, and exception lists). For the split-screen cameras, the single-ray approach is sufficient because the isometric angle means fewer occluders and the players are always the focus of interest.
8. Integration with camera_follow.gd
Our existing camera system (camera_follow.gd) was built for a single shared camera with group-framing behavior. It zooms out to keep all active players in view, handles ship-boarding camera switches, and manages space-flight chase cameras. The split-screen system lives alongside it, not instead of it.
The integration point is a single export on camera_follow.gd:
## When >= 0, this camera follows only the player at this index (0,1,2,3).
## Used by DynamicSplitScreen controller. -1 = legacy group-framing mode.
@export var split_screen_player_index: int = -1
When split_screen_player_index = -1 (default), the camera behaves as before — group framing, zoom-out, ship switching. When set to 0 or 1, the camera locks onto a single player and disables all group-framing logic:
if split_screen_player_index >= 0:
var split_target := _get_player_by_index(split_screen_player_index)
if split_target and is_instance_valid(split_target):
target = split_target
# ... single-player follow logic
return # Skip group framing entirely
This means the split-screen system does not use camera_follow.gd at all during co-op play. The Camera1 and Camera2 inside the SubViewports have their own positioning logic in dynamic_split_screen.gd. The camera_follow.gd integration is for single-player cameras that coexist with the split-screen scene — for example, a UI camera or a minimap camera that still needs to follow an individual player during split mode.
The split-screen SubViewport cameras are NOT instances of camera_follow.gd. They are plain Camera3D nodes whose position and basis are set directly by the split-screen script.
9. Edge Cases
9.1 Single Player
When only one player exists (player 2 is null, or the second controller never joins), the script detects this and runs in single-player mode:
if _player2 and is_instance_valid(_player2):
_move_cameras()
_update_splitscreen()
else:
_move_camera_single(_player1, _camera1)
_shader_material.set_shader_parameter("split_active", false)
_viewport2.render_target_update_mode = SubViewport.UPDATE_DISABLED
Camera1 follows player 1 with full occlusion handling. Camera2 and Viewport2 are disabled entirely. The shader outputs Viewport1 unconditionally. No split line ever renders.
9.2 Zero-Distance Guard
When both players occupy exactly the same position (possible during boarding or at spawn):
if horizontal_dist > 0.001:
clamped_diff = diff.normalized() * clamped_dist
else:
clamped_diff = Vector3.ZERO
Without this guard, normalizing a zero-length vector would produce NaN, which would cascade through the camera positions and produce unpredictable behavior.
9.3 Window Resize
As described in Section 5.7, the script connects to get_viewport().size_changed and resizes both SubViewports to match. The shader’s viewport_size uniform is updated simultaneously.
9.4 Missing Nodes
If the scene is instantiated without the expected child structure (e.g., a SubViewport was accidentally deleted):
if not _viewport1 or not _viewport2 or not _camera1 or not _camera2 or not _shader_material:
push_error("[SplitScreen] FATAL: missing child nodes")
set_process(false)
return
The script disables itself with a clear error message rather than silently failing.
9.5 Editor Mode
The script does not check Engine.is_editor_hint(), meaning it runs in the editor if you press Play Scene on the split-screen scene directly. This is intentional — it helps with debugging the scene in isolation. In a real level, the split-screen scene is instantiated at runtime.
9.6 World3D Inheritance
if not _viewport1.world_3d:
_viewport1.world_3d = get_viewport().world_3d
if not _viewport2.world_3d:
_viewport2.world_3d = get_viewport().world_3d
If the SubViewports don’t have a World3D assigned (which they won’t by default), they inherit the main viewport’s world. This ensures lighting, environment effects, and physics visibility are identical between the two render targets.
9.7 Player Re-detection
Players can come and go (joining mid-game, dying and respawning). The script re-runs _resolve_players() every frame when either player reference goes invalid:
if not _player1 or not is_instance_valid(_player1) or not _player2 or not is_instance_valid(_player2):
_resolve_players()
This keeps the split-screen working through player lifecycle events.
10. Key Parameters
All exported parameters with their defaults and effects:
| Parameter | Type | Default | Effect |
player1_path |
NodePath | (empty) | Explicit reference to Player 1 |
player2_path |
NodePath | (empty) | Explicit reference to Player 2 |
max_separation |
float | 20.0 | World units before screen splits |
camera_offset |
Vector3 | (0, 10, 10) | Default isometric camera offset (up + back) |
split_line_thickness |
float | 3.0 | Maximum thickness of split line in pixels |
split_line_color |
Color | BLACK | Color of the dividing line |
adaptive_split_line_thickness |
bool | true | Fade line in smoothly across threshold |
debug_log_enabled |
bool | true | Print camera positions to console |
debug_log_interval |
float | 1.0 | Seconds between debug log prints |
debug_solid_split |
bool | false | Force 50/50 vertical split (ignoring players) |
debug_show_colors |
bool | false | Tint viewports red/blue |
debug_force_red |
bool | false | Fill entire screen red |
debug_show_centers |
bool | true | Draw position marker circles |
Tuning max_separation is the most impactful change. Lower values cause the screen to split more readily; higher values keep it merged longer. Our value of 20.0 meters represents roughly one screen-width at default zoom — players need to be more than a screen apart before splitting.
11. Performance Notes
11.1 Rendering Cost
| State | SubViewport1 | SubViewport2 | Shader |
| Merged (players close) | Renders full scene | UPDATE_DISABLED |
Fast path: single texture read, no split math |
| Split (players far) | Renders full scene | Renders full scene | Slow path: two texture reads, line math, side determination |
In merged mode, the performance cost is approximately:
SubViewport render pass (identical to main camera)canvas_item fragment)In split mode, the cost doubles (two render passes) plus the per-pixel split-line computation. This is unavoidable — you need two views to show two separated players. The optimization is that split mode only activates when players are far apart, which in many cooperative games is less than 20% of playtime.
11.2 GPU Shader Cost
The fragment shader is lightweight:
texture() calls (depending on state)distance_to_line() call (a few multiplies + one sqrt)debug_show_centers = true)On a modern GPU, this shader runs in under 0.02ms at 1080p. The bottleneck in split mode is the second SubViewport render pass, not the overlay shader.
11.3 CPU Cost
The GDScript _process() does per frame:
unproject_position() calls (CPU-side projection math)set_shader_parameter() calls (uniform updates)force_raycast_update() calls (occlusion)This totals roughly 0.05-0.1ms on a mid-range CPU. The script is not a bottleneck.
11.4 Memory
Both SubViewports allocate full-resolution render textures. At 1080p with 32-bit RGBA, that’s approximately:
This is well within budget for a local co-op game running on desktop hardware. At 4K, it would be ~66 MB — still acceptable but worth noting.
12. Lessons Learned
12.1 Shader-Based Beats Viewport-Container-Based
Our first attempt used SubViewportContainer nodes with dynamic anchors — resizing two halves of the screen based on player positions. This approach breaks in several ways:
SubViewportContainer clips to its rectangle. A diagonal split can’t be expressed as two rectangles without complex masking.The shader-based approach sidesteps all of these. The split line is a visual effect computed per-pixel; the game world, input, and UI all operate in a single screen-space coordinate system.
12.2 Fixed Isometric Angle Matters
In our first prototype, we recalculated the camera basis every frame based on the segment direction. The camera rotation wobbled as players moved relative to each other, creating a nauseating effect. Players expect a fixed isometric angle. The camera position can move (translating the viewpoint) but the rotation should be locked.
The _isometric_basis computed once in _ready() and reused forever was the fix. Per-player CameraConfig nodes can override this if a player genuinely wants a different angle, but the default is stability.
12.3 Debug Overlays in the Shader Are Invaluable
The debug_show_centers mode — drawing green/yellow circles at player UV positions directly in the fragment shader — was the single most useful debugging tool during development. Without it, verifying that world-to-UV projection was correct required logging coordinate values and manually checking them. The visual overlay made mismatches immediately obvious:
unproject_position math was wrong._player2 was null.We left debug_show_centers defaulting to true so new developers can toggle it off after confirming their setup works.
12.4 Adaptive Line Thickness Prevents Jarring Transitions
Without adaptive thickness, the split line appears at full width the instant players cross the max_separation threshold. This creates a visible “pop” — a black line snapping into existence. The lerp-based fade in (Section 5.4) makes the transition imperceptible. The line appears gradually over a ~20-meter band, which at typical movement speeds takes about a second to fully materialize.
12.5 World3D Inheritance Avoids Environment Mismatches
SubViewports don’t automatically share the main viewport’s World3D. In our first test, Viewport2 had a different environment (no fog, no directional light) because it created a default World3D internally. Forcing both SubViewports to inherit get_viewport().world_3d fixed this. The scene’s .tscn file doesn’t need to specify a World3D — it’s set at runtime.
12.6 The Second Viewport Should Be OFF by Default
Both SubViewports start with render_target_update_mode = UPDATE_WHEN_VISIBLE (value 4 in the .tscn). The script switches Viewport2 to UPDATE_DISABLED whenever the screen is merged. This is more conservative than using UPDATE_ALWAYS for both — during merged play, only one viewport renders, saving half the GPU cost of the split-screen system.
12.7 Integration With Existing Cameras Requires Clear Boundaries
We spent significant time determining how the split-screen cameras interact with our existing camera_follow.gd system. The answer: they don’t. The split-screen SubViewport cameras are plain Camera3D nodes controlled entirely by dynamic_split_screen.gd. The camera_follow.gd script provides an opt-in split_screen_player_index export for cameras that need to follow a specific player during split-screen, but the two systems are otherwise independent.
This separation was deliberate. Mixing the two systems (having a camera_follow.gd instance try to control a SubViewport camera during split-screen) would create a feedback loop: the camera would try to frame all players, the split-screen would override its position, the framing logic would fight back. Clear ownership boundaries prevented this category of bugs entirely.
13. Debug Logging
The script includes a built-in debug logger that prints camera positions and split state at configurable intervals:
[SplitScreen] READY — viewport1.world_3d=OK viewport2.world_3d=OK
[SplitScreen] SHARED dist=12.3 cam1=(5,12,15) cam2=(5,12,15)
[SplitScreen] >>> SPLIT <<< dist=22.1/20.0
[SplitScreen] SPLIT dist=22.1 cam1=(-2,12,18) cam2=(12,12,12)
The >>> SPLIT <<< line only prints when the state transitions — making it easy to grep logs and find exactly when the screen split or merged. Set debug_log_interval to 0.0 for per-frame logging (very verbose) or 5.0 for periodic summaries.
14. References
camera_architecture.md — How this system fits into the broader camera architecturecamera_improvements_jun2026.md — The development log where this system was designed_This post was written for the Runners development blog. All code is from the working implementation in Godot 4. Parameter values reflect the current build as of July 2026._
Leave a Reply