Godot 4 Camera Switcher: Smooth Camera Transitions for Cutscenes
Learn how to switch cleanly between the player camera and cinematic cameras in Godot 4 using a lightweight CameraSwitcher. Supports instant cuts, smooth blends, and fade-to-black transitions—perfect for cutscenes and scripted moments

Manuel Sanchez

This Camera Switcher is a perfect companion for the CutsceneDirector pattern. You can trigger any of the methods you will see right from your director (or pass a CameraSwitcher instance to your actions) so camera moves are orchestrated alongside dialog, movement, and UI. If you don’t have a director yet, check the article Godot 4 CutsceneDirector: A Script-Based Alternative to Timelines for more context.
Cameras make or break the feel of a cutscene. You want the gameplay camera to hand off to a cinematic shot, linger, then return—all without popping, jitter, or re-parenting the player. Below is a compact pattern built around a CameraSwitcher node that gives you three transitions:
- Cut — instant swap (no animation)
- Blend — smooth motion blend from one Camera2D to another
- Fade — fade to black → swap → fade back in
Why a switcher node?
-
Decoupled: The player camera keeps doing player things. Cutscene cams live anywhere.
-
Reusable: One switcher can drive any number of cameras.
-
Zero reparenting: No moving the gameplay rig during cinematics.
-
Pixel-art safe: Avoids re-anchoring jitter; just hand off views.
The CutsceneDirector file
The main file to consider here is the CutsceneDirector script, which extends Node2D and defines reusable cutscene actions like moving characters, fading the screen, fading in and out objects, and loading dialogues. Here’s the script in action with some example actions:
WorldRoot (Node2D)
├─ Player
│ └─ Camera2D (player_cam) # current gameplay camera
├─ CutsceneCameras (Node2D) # place any number of cinematic cameras here
│ ├─ ForestCamera (Camera2D)
│ └─ DoorCamera (Camera2D)
└─ CameraSwitcher (Node2D) # the helper
├─ BlendCam (Camera2D) # used only during "blend" (keep out of "cameras" group)
└─ CanvasLayer
└─ ColorRect # full-screen black for fades
Tip: Use a “cameras” group for all the cameras inside CutsceneCameras, but do not put BlendCam in it. It’s job is just internal.
Now let’s see the script that we will attach to the CameraSwitcher node so that he can handle the camera transitions.
# res://systems/camera/CameraSwitcher.gd
extends Node2D
class_name CameraSwitcher
@onready var blend_cam: Camera2D = $BlendCam
@onready var fade_layer: CanvasLayer = $CanvasLayer
@onready var fade_rect: ColorRect = $CanvasLayer/ColorRect
var _current: Camera2D
var _adopted: Camera2D
func _ready() -> void:
_current = _find_current_camera()
_configure_fade(false, 0.0)
# Safety: keep BlendCam internal
if blend_cam.is_in_group("cameras"):
blend_cam.remove_from_group("cameras")
blend_cam.current = false
# Remember the gameplay camera (nice for returning later).
func adopt(cam: Camera2D) -> void:
_adopted = cam
if cam:
cut_to(cam)
func cut_to(target: Camera2D) -> void:
if not target: return
if _current: _current.current = false
target.current = true
_current = target
blend_cam.current = false
func blend_to(target: Camera2D, duration := 0.7) -> void:
if not target or target == _current: return
# Pose BlendCam at current
blend_cam.global_position = _current.global_position
blend_cam.zoom = _current.zoom
blend_cam.rotation = _current.rotation
blend_cam.current = true
var tw := create_tween().set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
tw.tween_property(blend_cam, "global_position", target.global_position, duration)
tw.parallel().tween_property(blend_cam, "zoom", target.zoom, duration)
tw.parallel().tween_property(blend_cam, "rotation", target.rotation, duration)
await tw.finished
cut_to(target)
func fade_to(target: Camera2D, fade_time := 0.25, hold := 0.0) -> void:
if not target: return
await _fade(1.0, fade_time)
cut_to(target)
if hold > 0.0:
await get_tree().create_timer(hold).timeout
await _fade(0.0, fade_time)
func _fade(alpha: float, time: float) -> void:
_configure_fade(true, fade_rect.modulate.a)
var tw := create_tween().set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN_OUT)
tw.tween_property(fade_rect, "modulate:a", alpha, max(0.0, time))
await tw.finished
if alpha <= 0.0:
_configure_fade(false, 0.0)
func _configure_fade(visible: bool, a: float) -> void:
fade_layer.visible = visible or a > 0.0
fade_rect.modulate = Color(0,0,0,a)
fade_rect.size = get_viewport_rect().size
func _find_current_camera() -> Camera2D:
# Prefer explicitly current cameras
for node in get_tree().get_nodes_in_group("cameras"):
if node is Camera2D and node.current:
return node
# Fallback: first Camera2D found
for node in get_tree().get_nodes_in_group(""):
if node is Camera2D:
return node
return null
Check this video example in which we are sending the camera from the first character (Eytran) to the second one (Torgil) through a blend and then we give it to the third character (Braecen) through a fade to black. Later, the camera comes back to the player camera through another blend.
Example of a cutscene using the CameraSwitcher in Godot 4
Cool, right? Let me give you that code in a sec! But first, let’s see how we can use this CameraSwitcher with our CutsceneDirector. As mentioned before, if you don’t have a director yet, check the article Godot 4 CutsceneDirector: A Script-Based Alternative to Timelines for more context.
By the way, these are methods (and characters) I am using for my game The Runic Edda. Take a look at it and give me some support on Bluesky if you like it!
Using the CameraSwitcher with CutsceneDirector
Add three actions that accept the switcher as a parameter (so cutscenes without one still work):
func _act_switch_cam_cut(cam_switcher: CameraSwitcher, target: Camera2D) -> void:
if cam_switcher and target:
cam_switcher.cut_to(target)
emit_signal("cutscene_action_done")
func _act_switch_cam_blend(cam_switcher: CameraSwitcher, target: Camera2D, duration := 0.7) -> void:
if cam_switcher and target:
await cam_switcher.blend_to(target, duration)
emit_signal("cutscene_action_done")
func _act_switch_cam_fade(cam_switcher: CameraSwitcher, target: Camera2D, fade_time := 0.25, hold := 0.0) -> void:
if cam_switcher and target:
await cam_switcher.fade_to(target, fade_time, hold)
emit_signal("cutscene_action_done")
Then, we just take one cutscene extending our CutsceneDirector and wire up the switcher:
extends CutsceneDirector
@onready var switcher: CameraSwitcher = $CameraSwitcher
@onready var braecen_camera: Camera2D = $CutsceneCameras/BraecenCamera
@onready var torgil_camera: Camera2D = $CutsceneCameras/TorgilCamera
@onready var text_braecen: RichTextLabel = $TextBraecen // secondary, text for Braecen
@onready var text_torgil: RichTextLabel = $TextTorgil // secondary, text for Torgil
func _ready() -> void:
text_braecen.hide()
text_torgil.hide()
var player_cam = Global.player_manager.player.camera
super()
switcher.adopt(player_cam)
// We have two options: directly with the method or with the cutscene director
await switcher.blend_to(torgil_camera, 2.0)
2. await cca(CutsceneAction.SWITCH_CAMERA_BLEND, [switcher, torgil_camera, 2.0])
await cca(CutsceneAction.APPEAR_NODE_FADE, [text_torgil, 0.30, Vector2(0, 8), 0.00])
await cca(CutsceneAction.WAIT, [8.0])
await cca(CutsceneAction.DISAPPEAR_NODE_FADE, [text_torgil, 0.20, Vector2(0, -8), 0.00])
await cca(CutsceneAction.WAIT, [2.0])
await switcher.fade_to(braecen_camera, 0.8, 0.1)
await cca(CutsceneAction.APPEAR_NODE_FADE, [text_braecen, 0.30, Vector2(0, 8), 0.00])
await cca(CutsceneAction.WAIT, [8.0])
await cca(CutsceneAction.DISAPPEAR_NODE_FADE, [text_braecen, 0.20, Vector2(0, -8), 0.00])
await cca(CutsceneAction.WAIT, [3.0])
await cca(CutsceneAction.SWITCH_CAMERA_BLEND, [switcher, player_cam, 2.0])
As a freebie for reading this far, here’s some nice methods for cool UI fades:
func appear_node_fade(node: CanvasItem, time := 0.25, offset := Vector2.ZERO) -> void:
if not node: return
node.visible = true
node.modulate.a = 0.0
node.position += offset
var tw := create_tween().set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
tw.tween_property(node, "modulate:a", 1.0, time)
tw.parallel().tween_property(node, "position", node.position - offset, time)
await tw.finished
func disappear_node_fade(node: CanvasItem, time := 0.20, offset := Vector2.ZERO) -> void:
if not node: return
var tw := create_tween().set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN)
tw.tween_property(node, "modulate:a", 0.0, time)
tw.parallel().tween_property(node, "position", node.position + offset, time)
await tw.finished
node.visible = false
Conclusion
A small CameraSwitcher
node gives you cuts, blends, and fades that feel professional and play nicely with your CutsceneDirector. It keeps gameplay logic clean, scales to any number of cinematic cameras, and is safe for pixel-art pipelines. Drop it in, wire two methods, and your scenes will immediately feel more polished.
FAQ about Cutscenes in Godot 4
No. You never target it. The switcher uses it internally during blend_to().
Share article