Latest Post: Compound Components Pattern: React and Svelte Examples

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

6 min read
Logo of Godot in the center of a grid background

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?

  1. Decoupled: The player camera keeps doing player things. Cutscene cams live anywhere.

  2. Reusable: One switcher can drive any number of cameras.

  3. Zero reparenting: No moving the gameplay rig during cinematics.

  4. 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

Related Posts

Stay in the loop!

Get to know some good resources. Once per month.

Frontend & Game Development, tools that make my life easier, newest blog posts and resources, codepens or some snippets. All for free!

No spam, just cool stuff. Promised. Unsubscribe anytime.