Transicao de Cena com Fade no Godot 4: Autoload, Tween e Zero Corte Seco

Aprenda transicao de cena godot com fade no Godot 4: autoload com CanvasLayer e ColorRect, tween de fade in e out, e como evitar travada em cena pesada.
Transicao de Cena com Fade no Godot 4: Autoload, Tween e Zero Corte Seco
Troca de cena com corte seco é uma daquelas coisas que ninguém elogia quando está boa, mas todo mundo sente quando está ruim. O jogador encosta na porta, a tela pisca, e de repente ele está em outro lugar. Funciona, mas parece protótipo. Uma transicao de cena godot bem feita é um fade de meio segundo: a tela escurece, a cena troca por baixo do pano, a tela clareia. Pronto, o jogo parece outro.
A boa notícia é que isso custa um script de umas quarenta linhas. A receita do Godot 4 é um autoload com um CanvasLayer e um ColorRect preto por cima de tudo, dois tweens via create_tween() e o change_scene_to_file() no meio do sanduíche. Nesse artigo eu monto isso do zero, explico cada decisão e fecho com o problema que aparece depois: a travada quando a cena de destino é pesada, e o que dá pra fazer sobre ela.
Por que a transicao de cena godot vive num autoload
O change_scene_to_file() destrói a cena atual inteira e instancia a nova. Qualquer node que esteja dentro da cena atual morre junto, incluindo um eventual ColorRect de fade que você tenha colocado nela. Se o fade vive na cena, ele desaparece no exato momento em que mais precisava estar na tela. Por isso o fade precisa morar fora da árvore de cenas trocáveis, e o lugar pra isso no Godot é um autoload.
Autoload é um node que o Godot adiciona como filho direto da raiz quando o jogo abre, e que sobrevive a toda troca de cena. Se o conceito ainda é nebuloso pra você, eu destrinchei o padrão em autoload e singleton no Godot, vale a leitura antes de seguir. Aqui o que importa é: o overlay preto fica no autoload, então ele continua na tela enquanto a cena embaixo dele é destruída e recriada.
O segundo ingrediente é o CanvasLayer. Um ColorRect comum desenha na camada da cena e disputa ordem com o resto da UI. Dentro de um CanvasLayer com layer alto, tipo 100, ele desenha por cima de absolutamente tudo: HUD, menus, partículas, tudo. É a garantia de que o fade cobre a tela inteira sem depender da estrutura de cada cena do jogo.
Montando o autoload de transição
Crie uma cena nova com esta estrutura:
SceneTransition (CanvasLayer)
└── ColorRect
No SceneTransition, defina layer = 100. No ColorRect, pinte de preto, use o preset de âncora Full Rect pra cobrir a tela toda, e um detalhe que derruba muita gente: mude mouse_filter pra Ignore. Sem isso, mesmo invisível, o retângulo engole todo clique do jogo, porque alpha zero não desliga input.
Salve como res://scene_transition.tscn e o script:
extends CanvasLayer
@onready var color_rect: ColorRect = $ColorRect
@export var fade_duration: float = 0.3
var is_transitioning := false
func _ready() -> void:
# Começa transparente; o ColorRect só aparece durante a troca.
color_rect.modulate.a = 0.0
func change_scene(path: String) -> void:
if is_transitioning:
return
is_transitioning = true
# Fade out: escurece a tela.
var tween := create_tween()
tween.tween_property(color_rect, "modulate:a", 1.0, fade_duration)
await tween.finished
# Tela preta: troca a cena escondido do jogador.
get_tree().change_scene_to_file(path)
# Fade in: revela a cena nova.
tween = create_tween()
tween.tween_property(color_rect, "modulate:a", 0.0, fade_duration)
await tween.finished
is_transitioning = false
Registre em Projeto, Configurações do Projeto, aba Autoload, com o nome SceneTransition, apontando pra cena (não pro script solto, porque o autoload precisa do ColorRect junto).
A partir daí, qualquer lugar do jogo troca de cena com uma linha:
func _on_porta_body_entered(body: Node2D) -> void:
if body.is_in_group("player"):
SceneTransition.change_scene("res://fases/fase_02.tscn")
Três decisões do script que merecem explicação. Primeiro, animei modulate:a em vez de color:a porque modulate funciona igual pra qualquer node, então se um dia você trocar o ColorRect por uma TextureRect com uma arte de transição, nada muda. Segundo, o is_transitioning é a trava contra clique duplo: sem ela, o jogador que encosta em duas portas no mesmo frame dispara duas transições sobrepostas e o resultado é fade brigando com fade. Terceiro, o tween via create_tween() é criado pelo autoload, que nunca sai da árvore, então o tween nunca é morto no meio da animação, que é exatamente o bug que aconteceria se o tween fosse criado por um node da cena que está sendo destruída.
O fade in esconde um detalhe: a cena nova já está rodando
Repare na ordem: o change_scene_to_file() roda com a tela preta, e o fade in acontece por cima da cena nova já viva. Isso significa que o _ready() da cena nova, a física, os inimigos, tudo já está processando durante o fade in. Na maioria dos jogos isso é o comportamento desejado, o mundo "acorda" enquanto a tela clareia.
Mas se a sua cena tem uma cutscene de entrada, ou o player pode tomar dano no primeiro meio segundo, talvez você queira segurar o jogo até o fade terminar. O jeito limpo é a própria cena esperar um sinal do autoload. Adicione no topo do script de transição:
signal transition_finished
E emita depois do fade in, logo antes de liberar a trava:
transition_finished.emit()
is_transitioning = false
Na cena que precisa esperar:
func _ready() -> void:
if SceneTransition.is_transitioning:
await SceneTransition.transition_finished
iniciar_cutscene()
Outro detalhe de robustez: change_scene_to_file() retorna um código de erro. Em projeto pequeno dá pra ignorar, mas custa pouco logar quando o caminho está errado:
var err := get_tree().change_scene_to_file(path)
if err != OK:
push_error("Falha ao trocar para a cena: " + path)
Erro de digitação em caminho de cena é silencioso sem isso, e você fica olhando uma tela preta sem pista nenhuma.
Evitando a travada em cena pesada
Agora o problema que o fade não resolve sozinho: o change_scene_to_file() carrega a cena de destino de forma síncrona, no mesmo frame. Se a fase tem texturas grandes, muitos nodes ou shaders pra compilar, o jogo congela durante o carregamento. Com o fade, o congelamento fica escondido atrás da tela preta, o que já melhora bastante a percepção, mas a música engasga e a janela pode até ser marcada como sem resposta se passar de alguns segundos.
A solução do Godot 4 é o ResourceLoader.load_threaded_request(), que carrega a cena numa thread enquanto o jogo continua rodando. A versão do change_scene com carregamento em background:
func change_scene_async(path: String) -> void:
if is_transitioning:
return
is_transitioning = true
# Dispara o carregamento em thread antes mesmo do fade.
ResourceLoader.load_threaded_request(path)
var tween := create_tween()
tween.tween_property(color_rect, "modulate:a", 1.0, fade_duration)
await tween.finished
# Espera o carregamento terminar sem congelar o jogo.
while ResourceLoader.load_threaded_get_status(path) == ResourceLoader.THREAD_LOAD_IN_PROGRESS:
await get_tree().process_frame
var packed_scene: PackedScene = ResourceLoader.load_threaded_get(path)
get_tree().change_scene_to_packed(packed_scene)
tween = create_tween()
tween.tween_property(color_rect, "modulate:a", 0.0, fade_duration)
await tween.finished
is_transitioning = false
Dois pontos importantes aqui. O pedido de carregamento é disparado antes do fade out, então a thread já trabalha durante os 0.3 segundo de escurecimento, que em fase média costuma ser tempo suficiente pra terminar. E como o resultado chega como PackedScene, a troca usa change_scene_to_packed() em vez de change_scene_to_file(), que aceita a cena já carregada e instancia na hora.
Se a fase é tão pesada que nem isso esconde a espera, o passo seguinte é uma tela de loading com barra de progresso lendo load_threaded_get_status() com o array de progresso, mas pra maioria dos jogos 2D o fade com carregamento em thread já elimina o engasgo por completo.
Uma observação de escopo: tudo isso vale pra trocar a cena raiz inteira. Se o que você quer é colocar uma cena dentro de outra, tipo spawnar uma bala ou abrir um menu por cima do jogo, o caminho é outro, é instantiate() com add_child(), e eu cubro isso em como instanciar cenas no Godot.
Variações que custam uma linha
Com a estrutura pronta, personalizar é barato. Duração diferente por contexto: como fade_duration é @export, dá pra ajustar no Inspector do autoload, ou aceitar como parâmetro opcional em change_scene(path, duration). Fade pra branco em vez de preto, pra flashback ou teleporte: muda a cor do ColorRect antes do tween. Curva de aceleração: encadeie set_trans(Tween.TRANS_SINE) no tween pra um fade que começa suave e termina suave, em vez do linear padrão.
E se um dia você quiser uma transição de círculo fechando estilo Zelda, ou um dissolve com ruído, a arquitetura não muda nada: o ColorRect ganha um ShaderMaterial e o tween passa a animar um parâmetro do shader em vez do alpha. O autoload, a trava, o sinal e o carregamento em thread continuam exatamente iguais. É por isso que vale montar essa base direito uma vez só.
Fechando
Transição de cena com fade no Godot 4 se resume a quatro peças: um autoload pra sobreviver à troca, um CanvasLayer com ColorRect pra cobrir a tela, dois create_tween() pra escurecer e clarear, e o change_scene_to_file() escondido no meio. Pra cena pesada, troque o carregamento síncrono pelo load_threaded_request e a travada some atrás da tela preta.
Meu conselho prático: implemente isso na primeira semana do projeto, não na última. Trocar todos os change_scene_to_file() espalhados pelo código por SceneTransition.change_scene() depois de seis meses é chato; começar com o autoload no lugar custa dez minutos e todo novo nível do jogo já nasce com transição decente.


