Dynamic Split-Screen in Godot 4: GPU Shader-Based Approach

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:

  • One extra SubViewport render pass (identical to main camera)
  • One shader invocation per pixel (canvas_item fragment)
  • The shader reads one texture and outputs it unchanged
  • 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:

  • 1-2 texture() calls (depending on state)
  • One distance_to_line() call (a few multiplies + one sqrt)
  • One branch (split_active check)
  • Optional debug drawing (only if 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:

  • 2-3 Vector3 subtractions and length calculations
  • 2 unproject_position() calls (CPU-side projection math)
  • 6 set_shader_parameter() calls (uniform updates)
  • 2 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:

  • 2 textures × 1920 × 1080 × 4 bytes ≈ 16.6 MB
  • 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:

  • Clipping issues: SubViewportContainer clips to its rectangle. A diagonal split can’t be expressed as two rectangles without complex masking.
  • Input handling: Mouse and touch events need to be remapped from screen space to each SubViewport’s coordinate space. Godot handles this for nested containers, but dynamically resizing containers confuses the input system.
  • UI layering: UI elements (health bars, minimaps) need to render once across both views. With container-based splitting, UI must be duplicated or carefully layered.
  • 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:

  • If the green dot (P1) didn’t track the player on screen, the unproject_position math was wrong.
  • If the yellow dot (P2) stayed at screen center, _player2 was null.
  • The cyan dots at (0.25, 0.5) and (0.75, 0.5) showed whether the split was rendering near the vertical midpoint.
  • 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

  • Benjamin Navarro’s Original Implementation (GitHub) — The algorithm this system is based on, later adopted as the official Godot engine demo.
  • Godot Documentation: SubViewport
  • Godot Documentation: Canvas Shaders
  • Our camera_architecture.md — How this system fits into the broader camera architecture
  • Our camera_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._


    Posted

    in

    by

    Tags:

    Comments

    Leave a Reply

    Your email address will not be published. Required fields are marked *