Voltar para o Blog
Quest Log

Flocking na Godot 4: movimento de bando com o algoritmo de Boids

Um cardume de peixes se movendo junto em um jogo 2D feito na Godot, todos apontando na mesma direção

Tutorial de flocking e boids na Godot 4 com GDScript tipado: as 3 regras de movimento de bando (separação, alinhamento, coesão) e um Boid reutilizável.

Ferramenta:Godot

Flocking, também conhecido pelo nome do algoritmo de Boids de Craig Reynolds, é a técnica clássica de movimento de bando: peixes que nadam em cardume, pássaros que giram no céu juntos, um enxame de inimigos que cerca o jogador. Neste tutorial você vai montar um Boid reutilizável na Godot 4 com GDScript tipado, entender as três regras que fazem o grupo se mover como um só (separação, alinhamento e coesão) e ver a matemática de vetores por trás de cada uma. No fim, um único script roda em cada agente e o comportamento de bando aparece sozinho, sem ninguém coordenando o grupo de fora.

A parte bonita do flocking é que ele é emergente. Ninguém programa a rota do cardume. Cada peixe olha só para os vizinhos próximos e reage a eles, e o padrão coletivo surge da soma dessas decisões locais. É por isso que o algoritmo é tão eficiente para dar vida a uma cena: você escreve um comportamento simples e ganha algo que parece complexo.

O que é flocking e onde usar

Craig Reynolds publicou os Boids em 1987 para simular o voo de bandos em computação gráfica. A ideia central é que um agente (o "boid", de "bird-oid object") não precisa saber para onde o grupo vai. Ele só precisa de três impulsos calculados a partir dos vizinhos que consegue perceber num certo raio.

Em jogos, o flocking resolve vários problemas de movimento coletivo:

  • Cardume de peixes ou bando de pássaros para vida ambiente, dando a sensação de um mundo vivo.
  • Enxame de inimigos que cercam o jogador sem se empilharem no mesmo pixel.
  • Rebanhos e criaturas que vagam por uma área de forma natural.
  • Partículas com intenção, como faíscas mágicas que se agrupam antes de disparar.

Tudo isso a partir do mesmo Boid. Muda só o peso das regras e a arte por cima. Se você já tem inimigos com comportamento individual e quer que eles também se movam em grupo, o flocking entra como uma camada de movimento por baixo da lógica de decisão, e combina bem com uma IA de inimigo perseguir e patrulhar por cima.

As 3 regras clássicas do Boids

Todo o comportamento sai de três vetores, calculados a cada frame olhando só para os vizinhos dentro do raio de percepção. Vamos entender cada um antes de escrever o código.

Separação: afastar de quem está perto demais

A separação impede que os boids se empilhem. Para cada vizinho muito próximo, o boid gera um vetor que aponta para longe dele. Quanto mais perto o vizinho, mais forte o empurrão.

Na prática você soma, para cada vizinho dentro de um raio pequeno de separação, o vetor posição_do_boid - posição_do_vizinho. Esse vetor aponta do vizinho para o boid, ou seja, para longe. Dividir pela distância faz o empurrão crescer quando estão colados. O resultado é um vetor que diz "saia de cima dos outros".

Alinhamento: seguir a direção média do grupo

O alinhamento faz o boid apontar para onde os vizinhos estão indo. Você soma a velocidade de cada vizinho, tira a média e usa isso como direção desejada. É o que faz o cardume inteiro virar junto: quando alguns peixes mudam de rumo, os vizinhos seguem, e a onda se propaga.

O vetor de alinhamento é a média das velocidades dos vizinhos. Sozinho, ele não puxa ninguém para perto nem afasta, só sincroniza a direção.

Coesão: ir para o centro do grupo

A coesão mantém o bando junto. O boid calcula a posição média dos vizinhos (o centro de massa local) e gera um vetor que aponta da sua posição atual para esse centro. É o oposto da separação em escala: separação empurra dos vizinhos imediatos, coesão puxa para o centro do grupo maior.

O equilíbrio entre as três é o segredo. Coesão forte demais e o bando vira uma bola grudada. Separação forte demais e eles se espalham. Alinhamento forte demais e viram um cardume rígido e antinatural. Por isso vamos deixar os três pesos ajustáveis via @export.

Montando o script do Boid tipado

Vamos usar um CharacterBody2D para cada boid, porque ele já traz velocity e move_and_slide(), que facilitam mover e colidir. Se você não quer colisão física, um Node2D com movimentação manual serve igual. Coloque todos os boids em um grupo chamado boids para eles se acharem.

O plano do _physics_process é: achar vizinhos, calcular os três vetores, somar com pesos, converter em steering e limitar a velocidade. Um script só, rodando em cada agente.

extends CharacterBody2D
class_name Boid

@export var velocidade_max: float = 220.0
@export var forca_max: float = 12.0
@export var raio_percepcao: float = 90.0
@export var raio_separacao: float = 34.0

@export var peso_separacao: float = 1.6
@export var peso_alinhamento: float = 1.0
@export var peso_coesao: float = 0.9

func _ready() -> void:
    add_to_group("boids")
    # Começa com uma velocidade aleatória para o bando não nascer parado.
    var angulo: float = randf() * TAU
    velocity = Vector2.RIGHT.rotated(angulo) * velocidade_max

Agora a busca de vizinhos. A versão direta pega todos os boids do grupo e filtra pela distância. Guardamos os vizinhos numa lista para as três regras reusarem, evitando percorrer o grupo três vezes.

func _pegar_vizinhos() -> Array[Boid]:
    var vizinhos: Array[Boid] = []
    for outro in get_tree().get_nodes_in_group("boids"):
        if outro == self:
            continue
        var boid: Boid = outro as Boid
        if boid == null:
            continue
        if global_position.distance_to(boid.global_position) <= raio_percepcao:
            vizinhos.append(boid)
    return vizinhos

Com os vizinhos em mãos, cada regra vira uma função que devolve um Vector2. Repare que todas retornam um vetor de direção desejada, não a força final ainda.

func _separacao(vizinhos: Array[Boid]) -> Vector2:
    var empurrao: Vector2 = Vector2.ZERO
    for boid in vizinhos:
        var dist: float = global_position.distance_to(boid.global_position)
        if dist < raio_separacao and dist > 0.0:
            # Vetor que aponta para longe do vizinho, mais forte quando perto.
            empurrao += (global_position - boid.global_position) / dist
    return empurrao

func _alinhamento(vizinhos: Array[Boid]) -> Vector2:
    if vizinhos.is_empty():
        return Vector2.ZERO
    var soma: Vector2 = Vector2.ZERO
    for boid in vizinhos:
        soma += boid.velocity
    return soma / float(vizinhos.size())

func _coesao(vizinhos: Array[Boid]) -> Vector2:
    if vizinhos.is_empty():
        return Vector2.ZERO
    var centro: Vector2 = Vector2.ZERO
    for boid in vizinhos:
        centro += boid.global_position
    centro /= float(vizinhos.size())
    # Vetor que aponta da posição atual para o centro do grupo.
    return centro - global_position

Falta juntar tudo. Aqui entra o conceito de steering, a força de direção. Em vez de usar o vetor desejado direto como velocidade (o que causa mudanças bruscas e tremulação), calculamos a diferença entre a direção que queremos e a velocidade atual, e limitamos essa diferença com limit_length. Isso é o que suaviza as curvas e dá o movimento orgânico do bando.

func _steering_para(desejado: Vector2) -> Vector2:
    if desejado == Vector2.ZERO:
        return Vector2.ZERO
    # Direção desejada na velocidade máxima, menos a velocidade atual.
    var alvo: Vector2 = desejado.normalized() * velocidade_max
    return (alvo - velocity).limit_length(forca_max)

func _physics_process(delta: float) -> void:
    var vizinhos: Array[Boid] = _pegar_vizinhos()

    var forca: Vector2 = Vector2.ZERO
    forca += _steering_para(_separacao(vizinhos)) * peso_separacao
    forca += _steering_para(_alinhamento(vizinhos)) * peso_alinhamento
    forca += _steering_para(_coesao(vizinhos)) * peso_coesao

    # Integra a força na velocidade e limita a velocidade máxima.
    velocity += forca
    velocity = velocity.limit_length(velocidade_max)

    move_and_slide()

    # Gira o sprite para apontar na direção do movimento.
    if velocity.length() > 1.0:
        rotation = velocity.angle()

Esse é o Boid completo. Cada regra vira um steering, os steerings são somados com pesos, e o resultado ajusta a velocidade dentro do teto de velocidade_max. O limit_length aparece duas vezes e faz dois trabalhos diferentes: limitar a força de direção (para virar suave) e limitar a velocidade final (para não acelerar sem fim).

Para popular a cena, instancie vários boids em posições aleatórias a partir de um nó pai, todos filhos da mesma cena, e o bando se organiza sozinho no primeiro segundo.

Curso de Godot
Quer fazer seu primeiro jogo na Godot?

No curso de Godot da CursoGame.Dev você vai do zero ao primeiro jogo, com um desafio prático em cada level e suporte individual no WhatsApp quando travar.

Conhecer o curso de Godot
Suporte no WhatsAppAcesso vitalícioGarantia de 7 dias

Ajuste fino: pesos, percepção e tremulação

Com o Boid rodando, o trabalho vira sintonia. Os quatro números que mais mudam a sensação são os pesos e os dois raios.

  • Pesos das regras. Comece com separação um pouco acima de 1, alinhamento e coesão perto de 1, e vá ajustando. Se os boids grudam, suba a separação. Se o bando se desfaz, suba a coesão.
  • Raio de percepção. Raio grande deixa o bando mais coeso e as decisões mais estáveis, mas custa mais para calcular. Raio pequeno deixa o movimento nervoso e propenso a tremer.
  • Raio de separação. Deve ser bem menor que o de percepção. Ele define a "bolha pessoal" de cada boid.
  • Força máxima. A forca_max controla o quanto o boid pode virar por frame. Valor baixo dá curvas largas e preguiçosas, valor alto dá reações rápidas e nervosas.

A tremulação é o defeito mais comum. Ela aparece quando forças opostas se cancelam frame a frame e o boid fica vibrando no lugar. As defesas são: aplicar tudo como steering (já fazemos), limitar a força, garantir uma velocidade mínima para o boid nunca congelar, e não usar um raio de percepção minúsculo. Se ainda tremer, uma velocidade mínima resolve a maioria dos casos:

@export var velocidade_min: float = 60.0

func _aplicar_velocidade_minima() -> void:
    if velocity.length() < velocidade_min:
        if velocity == Vector2.ZERO:
            velocity = Vector2.RIGHT.rotated(randf() * TAU)
        velocity = velocity.normalized() * velocidade_min

Para manter dentro da tela, some uma força que aponta de volta para o centro quando o boid passa das bordas do viewport. Isso evita que o cardume suma no canto.

@export var margem: float = 40.0
@export var peso_borda: float = 2.0

func _forca_borda() -> Vector2:
    var tela: Rect2 = get_viewport_rect()
    var direcao: Vector2 = Vector2.ZERO
    if global_position.x < margem:
        direcao.x = 1.0
    elif global_position.x > tela.size.x - margem:
        direcao.x = -1.0
    if global_position.y < margem:
        direcao.y = 1.0
    elif global_position.y > tela.size.y - margem:
        direcao.y = -1.0
    return direcao

Some _steering_para(_forca_borda()) * peso_borda junto das outras forças no _physics_process. O mesmo padrão serve para desviar de obstáculos: lance um RayCast2D na direção da velocidade e, se ele colidir, gere um vetor que empurra o boid para o lado da normal da colisão, com um peso alto para ganhar das outras regras.

Performance: o custo O(n²) e como aliviar

A busca de vizinhos que usamos checa cada boid contra todos os outros. Com 30 boids são 900 comparações por frame, o que a Godot aguenta tranquilo. Mas o custo cresce ao quadrado: 300 boids dão 90 mil comparações por frame, e aí a taxa de quadros despenca. Isso é o clássico O(n²), o principal gargalo do flocking ingênuo.

Formas de aliviar, da mais simples para a mais robusta:

  • Reduza o raio de percepção. Menos vizinhos por boid significa menos trabalho por comparação, e o efeito visual quase não muda até certo ponto.
  • Limite o número de vizinhos considerados. Poucos peixes de verdade rastreiam mais que um punhado de vizinhos. Pare de acumular depois de achar, digamos, seis ou oito.
  • Use uma grade espacial (spatial hash). Divida o mundo em células e registre cada boid na sua célula. Na busca, olhe só as células vizinhas em vez do grupo inteiro. Isso derruba o custo médio de O(n²) para perto de O(n).
  • Use Area2D em cada boid. Em vez de você procurar vizinhos, deixe a física avisar. Uma Area2D com o raio de percepção reporta quem entra e sai, e você lê uma lista já pronta com get_overlapping_bodies().
  • Reaproveite os agentes com pooling. Se o seu jogo cria e destrói boids o tempo todo (enxames que nascem e morrem), instanciar e liberar nós vira gargalo por si só. Vale aplicar object pooling para otimizar e reciclar os boids de uma piscina em vez de chamar queue_free e instanciar de novo a cada onda.

Uma otimização barata e que não muda o resultado: não recalcule o flocking de todo boid em todo frame. Distribua o cálculo, atualizando metade dos boids num frame e a outra metade no seguinte. A olho nu ninguém percebe, e você corta o custo pela metade na hora.

Próximo passo: do bando ambiente ao enxame inimigo

Você tem um Boid reutilizável, as três regras com pesos ajustáveis, steering com força limitada e velocidade máxima sob controle. A partir daqui o mesmo script vira coisas diferentes só mudando os pesos e o que ele persegue.

Para vida ambiente, deixe os pesos suaves e sem alvo, só o bando vagando. Para um enxame inimigo, adicione uma quarta força que puxa na direção do jogador (um vetor de posição_do_jogador - global_position convertido em steering) e combine com uma camada de decisão que liga e desliga essa perseguição. É onde o flocking encontra a IA de combate: o movimento coletivo sai das três regras, e a decisão de quando atacar ou recuar sai de uma máquina de estados por cima, como a de uma IA de inimigo perseguir e patrulhar.

Se os seus boids também precisam interagir com o mundo, conversar ou reagir ao jogador, vale ver como estruturar um agente completo no guia de criar NPC na Godot e usar o flocking como a base de movimento por baixo desse comportamento. Para enxergar o flocking ao lado das outras técnicas, o panorama de inteligência artificial para jogos na Godot mostra onde cada abordagem entra.

Comece com um cardume simples numa cena de teste, ajuste os pesos até o movimento agradar, e só então leve o Boid para dentro do seu jogo. O flocking é um daqueles sistemas que dão retorno enorme para pouco código, e uma vez montado ele vira uma ferramenta que você reusa em vários projetos aqui no CursoGame.Dev.

Perguntas frequentes

Como evitar que os boids tremam ou vibrem parados?

A tremulação nasce de forças que se cancelam frame a frame e de raios de percepção muito curtos. Suavize aplicando a força como steering (força desejada menos velocidade atual) e limite essa força com limit_length. Some uma velocidade mínima para o boid nunca parar de vez e aumente um pouco o raio de percepção para as decisões ficarem estáveis entre frames.

Como fazer os boids desviarem de obstáculos ou paredes?

Adicione uma quarta força de desvio. O jeito simples é lançar um raycast na direção da velocidade e, se ele bater em algo, gerar um vetor que empurra o boid para o lado do obstáculo. Para manter dentro da tela, calcule uma força que aponta de volta para o centro quando o boid passa das bordas do viewport. Some essa força ao steering com um peso alto para ela ganhar das outras.

Como achar os vizinhos de cada boid sem custo alto?

O caminho direto é pegar todos os boids do grupo com get_tree().get_nodes_in_group() e filtrar por distância dentro do raio de percepção. Isso é O(n²) e aguenta algumas dezenas de agentes. Para centenas, troque a busca por uma grade espacial (spatial hash) ou use Area2D em cada boid para receber só os vizinhos que entram no raio, evitando checar todos contra todos.

Qual a diferença entre flocking e uma máquina de estados de IA?

Flocking é um comportamento emergente: cada boid segue três regras locais e simples, e o padrão de bando aparece sozinho, sem ninguém coordenar o grupo. Uma máquina de estados decide ações individuais (patrulhar, perseguir, fugir). As duas se combinam bem: você usa flocking para o movimento coletivo e uma camada de IA por cima para decidir quando o bando persegue ou dispersa.