Tela de Carregamento no Godot 4: Loading Assíncrono com Barra de Progresso

Tela de carregamento no Godot 4 com loading assíncrono: carregue cenas pesadas em thread, mostre a barra de progresso e troque de cena sem travar.
Tela de Carregamento no Godot 4: Loading Assíncrono com Barra de Progresso
Toda vez que seu jogo troca de fase e a imagem congela por um instante, é a mesma coisa acontecendo por baixo: a engine está carregando uma cena pesada na thread principal e, enquanto isso não termina, ela não consegue desenhar nada. Uma tela de carregamento bem feita resolve esse problema não escondendo o travamento, mas eliminando ele de verdade. A ideia é carregar o recurso em outra thread e deixar a thread principal livre para animar uma barra de progresso e responder ao jogador.
Neste post do CursoGame.Dev você vai montar esse fluxo do zero no Godot 4, usando a API real de carregamento assíncrono: ResourceLoader.load_threaded_request(), ResourceLoader.load_threaded_get_status() com o array de progresso, ResourceLoader.load_threaded_get() para pegar o recurso pronto e get_tree().change_scene_to_packed() para entrar na cena. Tudo em GDScript tipado, sem gambiarra.
Por que a tela de carregamento comum trava o jogo
Quando você escreve get_tree().change_scene_to_file("res://fase_2.tscn"), a engine para tudo, lê o arquivo do disco, instancia todos os nós, carrega texturas, sons e sub-cenas, e só depois desenha o primeiro frame da fase nova. Se a fase for pequena, isso acontece rápido e ninguém percebe. Se a fase for grande, com muitos assets, o jogo fica congelado por meio segundo, um segundo, às vezes mais. E não adianta colocar uma imagem de "Carregando..." antes da chamada: como a thread principal está ocupada carregando, a própria imagem não seria animada.
O problema não é a imagem de loading. O problema é que o carregamento e o desenho disputam a mesma thread. A solução é separar os dois.
O Godot 4 oferece exatamente isso através do ResourceLoader com carregamento em thread. Você pede para a engine começar a carregar um recurso em segundo plano, e ela devolve o controle imediatamente. A cada frame, você pergunta como está o progresso, atualiza a barra, e quando o carregamento termina, você troca de cena. A thread principal nunca fica bloqueada, então a barra desliza suave e o jogador vê que algo está acontecendo.
Os três métodos do carregamento assíncrono
O fluxo inteiro se apoia em três chamadas do ResourceLoader. Vale entender cada uma antes de juntar tudo.
A primeira é load_threaded_request(path). Você passa o caminho do recurso, por exemplo "res://cenas/fase_2.tscn", e a engine dispara o carregamento em uma thread separada. Ela retorna um código de erro (Error), então você consegue saber na hora se o caminho é inválido. Depois dessa chamada, o carregamento já está rolando em segundo plano.
A segunda é load_threaded_get_status(path, progress). Aqui está o coração da barra de progresso. Você passa o mesmo caminho e um Array vazio. A engine preenche a posição 0 desse array com um float entre 0.0 e 1.0, indicando quanto já carregou. O retorno da função é um status: carregando, terminado, falhou ou inválido. Você chama isso a cada frame para atualizar a ProgressBar.
A terceira é load_threaded_get(path). Quando o status indica que terminou, você chama esse método para pegar o recurso já pronto na memória. No caso de uma cena, o que volta é uma PackedScene, exatamente o tipo que o change_scene_to_packed() espera receber.
Repare no encaixe: você carrega a PackedScene em thread e depois entrega ela pronta para a árvore de cenas. Nada é carregado duas vezes.
Montando a cena de loading
Crie uma cena nova que vai servir de tela de carregamento. A estrutura mínima é um Control na raiz, com uma ProgressBar dentro. Você pode enfeitar com um Label mostrando a porcentagem, uma logo, um fundo, o que quiser. O que importa para o código é ter a referência da ProgressBar.
Deixe o min_value da barra em 0 e o max_value em 100. Assim você trabalha com porcentagem direto e o número fica intuitivo.
O script abaixo vai no nó raiz dessa cena. Ele guarda o caminho da cena que queremos carregar, dispara o pedido no _ready() e acompanha o progresso no _process().
extends Control
# Caminho da cena pesada que queremos carregar
@export var caminho_proxima_cena: String = "res://cenas/fase_2.tscn"
@onready var barra: ProgressBar = $ProgressBar
@onready var rotulo: Label = $Label
# Array que o Godot preenche com o progresso (0.0 a 1.0)
var progresso: Array = []
# Guarda se o carregamento ainda esta em andamento
var carregando: bool = false
func _ready() -> void:
var erro: Error = ResourceLoader.load_threaded_request(caminho_proxima_cena)
if erro != OK:
push_error("Nao foi possivel iniciar o carregamento: %s" % caminho_proxima_cena)
return
carregando = true
func _process(_delta: float) -> void:
if not carregando:
return
var status: ResourceLoader.ThreadLoadStatus = ResourceLoader.load_threaded_get_status(
caminho_proxima_cena,
progresso
)
match status:
ResourceLoader.THREAD_LOAD_IN_PROGRESS:
atualizar_barra()
ResourceLoader.THREAD_LOAD_LOADED:
carregando = false
finalizar_carregamento()
ResourceLoader.THREAD_LOAD_FAILED, ResourceLoader.THREAD_LOAD_INVALID_RESOURCE:
carregando = false
push_error("Falha ao carregar a cena: %s" % caminho_proxima_cena)
func atualizar_barra() -> void:
# progresso[0] vem de 0.0 a 1.0, entao multiplicamos por 100
var porcentagem: float = 0.0
if progresso.size() > 0:
porcentagem = float(progresso[0]) * 100.0
barra.value = porcentagem
rotulo.text = "%d%%" % int(porcentagem)
func finalizar_carregamento() -> void:
# Garante que a barra chegue em 100 antes de trocar
barra.value = 100.0
rotulo.text = "100%"
var cena: PackedScene = ResourceLoader.load_threaded_get(caminho_proxima_cena)
if cena == null:
push_error("O recurso carregado nao e uma PackedScene valida.")
return
get_tree().change_scene_to_packed(cena)
Vamos ler o que acontece na ordem. No _ready(), disparamos o pedido de carregamento e conferimos o retorno. Se o caminho estiver errado, push_error avisa e a função para ali, sem deixar o jogo travado. Se deu certo, marcamos carregando = true.
A cada frame, _process() consulta o status. Enquanto está THREAD_LOAD_IN_PROGRESS, chamamos atualizar_barra(), que lê progresso[0], converte para porcentagem e joga na ProgressBar e no Label. Quando o status vira THREAD_LOAD_LOADED, paramos de acompanhar e chamamos finalizar_carregamento(), que pega a PackedScene pronta e entra nela com change_scene_to_packed().
Note o uso do match para tratar os quatro estados possíveis. Os dois estados de erro caem no mesmo braço, mostram a mensagem e interrompem o loop. Isso é o mínimo de robustez: uma tela de loading que nunca sai porque o arquivo sumiu é pior do que um travamento curto.
Cuidados para a barra não parecer quebrada
Um detalhe que engana muita gente: o progresso[0] nem sempre sobe de forma linear e suave. Dependendo de como a cena está montada, ele pode ficar parado em 0.0 por um tempo e depois pular direto para valores altos. Isso é normal, porque a engine só reporta o progresso conforme consegue medir. Para o jogador, uma barra que salta de golpe passa a impressão de que algo travou.
Uma forma simples de suavizar é interpolar o valor exibido em direção ao valor real, em vez de aplicar direto. Assim a barra sempre desliza, mesmo quando o progresso interno dá saltos:
@export var velocidade_barra: float = 8.0
var valor_exibido: float = 0.0
func atualizar_barra_suave(delta: float) -> void:
var alvo: float = 0.0
if progresso.size() > 0:
alvo = float(progresso[0]) * 100.0
# Move o valor exibido em direcao ao alvo de forma gradual
valor_exibido = lerp(valor_exibido, alvo, delta * velocidade_barra)
barra.value = valor_exibido
rotulo.text = "%d%%" % int(valor_exibido)
Para usar essa versão, troque a chamada de atualizar_barra() por atualizar_barra_suave(_delta) dentro do _process(), e lembre de renomear o parâmetro _delta para delta, já que agora você o usa. O lerp faz a barra perseguir o alvo, o que remove os saltos bruscos sem mentir sobre o progresso.
Outro cuidado: nunca troque de cena antes da barra chegar visualmente ao fim. Se o carregamento termina em 0.3 segundos mas você pulou a barra para 100 só no último frame, o jogador nem vê. Em jogos pequenos, às vezes a cena carrega tão rápido que a tela de loading pisca por um instante e some, o que fica estranho. Nesses casos, vale segurar a tela por um tempo mínimo, meio segundo por exemplo, para dar peso ao momento. Isso é decisão de design, não de técnica, mas o loading assíncrono te dá liberdade para escolher.
Carregando vários recursos de uma vez
Em muitos jogos você não quer carregar só uma cena, mas também o áudio da fase, uma folha de sprites grande ou um banco de dados de inimigos. O mesmo ResourceLoader cobre isso. Você dispara um load_threaded_request() para cada caminho e depois calcula uma média dos progressos para uma barra única.
extends Control
@onready var barra: ProgressBar = $ProgressBar
var recursos: Array[String] = [
"res://cenas/fase_2.tscn",
"res://audio/trilha_fase_2.ogg",
"res://sprites/inimigos_fase_2.png",
]
var carregando: bool = true
func _ready() -> void:
for caminho in recursos:
var erro: Error = ResourceLoader.load_threaded_request(caminho)
if erro != OK:
push_error("Falha ao pedir: %s" % caminho)
func _process(_delta: float) -> void:
if not carregando:
return
var soma: float = 0.0
var terminados: int = 0
var progresso_item: Array = []
for caminho in recursos:
var status: ResourceLoader.ThreadLoadStatus = ResourceLoader.load_threaded_get_status(
caminho,
progresso_item
)
if status == ResourceLoader.THREAD_LOAD_LOADED:
soma += 1.0
terminados += 1
elif status == ResourceLoader.THREAD_LOAD_IN_PROGRESS:
if progresso_item.size() > 0:
soma += float(progresso_item[0])
barra.value = (soma / float(recursos.size())) * 100.0
if terminados == recursos.size():
carregando = false
entrar_na_fase()
func entrar_na_fase() -> void:
var cena: PackedScene = ResourceLoader.load_threaded_get(recursos[0])
get_tree().change_scene_to_packed(cena)
A lógica é a mesma, só somamos o progresso de cada item e dividimos pela quantidade total. Recursos já terminados contam como 1.0. Quando todos terminam, entramos na cena principal, que aqui é o primeiro item da lista. Os outros recursos ficam em cache na memória, então quando a fase pedir por eles, o carregamento é instantâneo.
Encaixando no fluxo do seu jogo
Na prática, a tela de loading costuma ficar entre o menu e a fase. O jogador clica em "Jogar", você troca para a cena de loading, e ela cuida de carregar a fase pesada e depois entrar nela. Se você já está montando esse fluxo, vale ver também como fazer a transição de cena com fade para que a entrada e a saída da tela de loading não sejam um corte seco, e sim uma passagem suave.
A tela de loading também conversa direto com o resto da sua interface. Se você ainda está estruturando o começo do jogo, o guia de criar o menu principal mostra por onde o jogador chega até o botão que dispara o carregamento. E quando a fase carregada precisa restaurar o estado anterior do jogador, como vida, inventário ou checkpoint, o passo seguinte é salvar e carregar o jogo, porque de nada adianta carregar a cena se os dados dela vierem zerados.
Um ponto importante sobre organização: em vez de espalhar a lógica de carregamento por várias cenas, muitos projetos criam um autoload dedicado, um singleton que recebe o caminho da próxima cena, mostra a tela de loading e faz a troca. Assim qualquer parte do jogo pede um carregamento com uma linha só, e toda a mecânica de thread e barra fica em um lugar único. É o mesmo código que você viu aqui, apenas movido para um nó global.
Erros comuns que valem evitar
O primeiro erro é misturar load() com load_threaded_request() no mesmo recurso. Se você já disparou o carregamento em thread, pegue o resultado com load_threaded_get(), não com load(). Chamar load() no meio do caminho carrega o arquivo de novo na thread principal e desfaz todo o ganho.
O segundo é esquecer de tratar os estados de falha. Como já dito, uma tela de loading sem tratamento de erro é uma armadilha silenciosa: no dia em que um arquivo for renomeado ou um caminho quebrar, o jogo fica preso para sempre naquela barra parada, e o jogador não tem como saber o que aconteceu.
O terceiro é usar change_scene_to_file() no fim do processo em vez de change_scene_to_packed(). Se você acabou de carregar a PackedScene em thread e depois chama change_scene_to_file(), a engine carrega o mesmo arquivo mais uma vez, na thread principal, e você volta ao travamento que estava tentando evitar. Sempre use a PackedScene que já está pronta na mão.
O quarto é atualizar a ProgressBar sem checar progresso.size(). Nos primeiros frames, antes da engine reportar qualquer coisa, o array pode estar vazio, e acessar progresso[0] diretamente gera erro. A checagem de tamanho que aparece nos exemplos evita esse tropeço.
Com esses cuidados, a tela de carregamento deixa de ser um paliativo estético e passa a ser o que deveria: uma parte funcional do jogo que carrega o que é pesado sem tirar o controle da tela do jogador.
Próximo passo
Comece pela versão de uma cena só, com o primeiro script deste post, e confirme que a barra sobe até 100 e a troca acontece. Depois experimente a versão suave com lerp para ver a diferença na sensação. Quando estiver confortável, mova toda a lógica para um autoload e faça o menu chamar esse singleton. A partir daí, ligue a tela de loading a uma transição com fade e ao seu sistema de save e load para fechar o ciclo de entrada em uma fase. É esse conjunto que separa um protótipo de um jogo que parece acabado.
Perguntas frequentes
Por que o jogo trava quando eu carrego uma cena grande no Godot?
Porque load() e change_scene_to_file() carregam tudo na thread principal, que é a mesma que desenha os frames. Enquanto o recurso não termina de carregar, nada é desenhado e o jogo congela. O carregamento assíncrono com ResourceLoader.load_threaded_request() joga esse trabalho para outra thread, deixando a thread principal livre para animar a barra de progresso.
Qual a diferença entre change_scene_to_file e change_scene_to_packed?
change_scene_to_file() recebe um caminho e carrega a cena na hora, de forma síncrona. change_scene_to_packed() recebe uma PackedScene que você já carregou antes. Como no loading assíncrono você já tem a PackedScene pronta na mão, usar change_scene_to_packed() evita carregar o mesmo arquivo duas vezes.
O array de progresso do load_threaded_get_status é obrigatório?
Não é obrigatório, mas sem ele você não consegue mostrar uma barra real. Você passa um Array vazio por referência, o Godot preenche a posição 0 com um float de 0.0 a 1.0, e você usa esse valor para atualizar a ProgressBar. Sem o array, você só sabe se terminou ou não, mas não o quanto falta.
Preciso chamar load_threaded_get_status em todo frame?
A forma mais simples é verificar o status dentro de _process(), que roda a cada frame. Isso mantém a barra suave. Se preferir gastar menos, use um Timer disparando a cada 0.1 segundo, mas para a maioria dos jogos o custo de checar por frame é irrelevante.
Dá para carregar várias cenas ou recursos ao mesmo tempo?
Sim. Você chama load_threaded_request() para cada caminho e depois consulta o status de cada um. Para uma barra única, some os progressos e divida pela quantidade de recursos. É assim que se monta uma tela que pré-carrega fase, inimigos e áudio de uma vez só.
O que acontece se o carregamento falhar?
load_threaded_get_status() pode retornar THREAD_LOAD_FAILED ou THREAD_LOAD_INVALID_RESOURCE. Sempre trate esses casos: mostre uma mensagem, volte ao menu ou tente de novo. Ignorar o erro faz o jogo ficar preso para sempre na tela de loading, que é uma das piores experiências possíveis para o jogador.


