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.

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.

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

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.

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.