Voltar para o Blog
Quest Log

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

Tela de um jogo mostrando uma barra de progresso preenchida pela metade sobre um fundo escuro estilizado

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.

Próximo nível
Quer aprender isso na prática?

No CursoGame.Dev você sai dos tutoriais soltos e constrói jogos publicáveis, com trilha progressiva, quests práticas e feedback real.

Conhecer a plataforma
+500 alunos4.9/5Garantia 7 dias

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.